Update Flutter app data flow and add Railway scheduler service
This commit is contained in:
parent
7fe9adb1d1
commit
9c988e0f9c
|
|
@ -43,3 +43,7 @@ app.*.map.json
|
|||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Firebase credentials and Railway env
|
||||
*-firebase-adminsdk-*.json
|
||||
railway-scheduler/.env
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../models/kandang_model.dart';
|
|||
class KandangProvider extends ChangeNotifier {
|
||||
final FirebaseDatabase _database = FirebaseDatabase.instance;
|
||||
late DatabaseReference _kandangRef;
|
||||
static const String _sharedKandangPath = 'kontrol/kandang';
|
||||
|
||||
List<Kandang> _kandangs = [];
|
||||
bool _isLoading = true;
|
||||
|
|
@ -24,19 +25,33 @@ class KandangProvider extends ChangeNotifier {
|
|||
|
||||
/// Initialize provider dengan user ID dan load data dari Firebase
|
||||
Future<void> initializeWithUser(String userId) async {
|
||||
_kandangRef = _database.ref('users/$userId/kandang');
|
||||
_kandangRef = _database.ref(_sharedKandangPath);
|
||||
|
||||
try {
|
||||
// Pastikan ada data awal agar user baru tidak selalu input dari nol.
|
||||
await _ensureInitialKandangData(userId);
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Warning ensuring kandang initial data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear previous listener
|
||||
await _subscription?.cancel();
|
||||
|
||||
// Load data dari Firebase dengan real-time listener
|
||||
_subscription = _kandangRef.onValue.listen((event) {
|
||||
if (event.snapshot.exists) {
|
||||
final data = event.snapshot.value as Map<dynamic, dynamic>;
|
||||
_kandangs = data.entries.map((entry) {
|
||||
final kandangData = entry.value as Map<dynamic, dynamic>;
|
||||
_subscription = _kandangRef.onValue.listen(
|
||||
(event) {
|
||||
try {
|
||||
if (event.snapshot.exists && event.snapshot.value is Map) {
|
||||
final data =
|
||||
Map<dynamic, dynamic>.from(event.snapshot.value as Map);
|
||||
_kandangs =
|
||||
data.entries.where((entry) => entry.value is Map).map((entry) {
|
||||
final kandangData =
|
||||
Map<dynamic, dynamic>.from(entry.value as Map);
|
||||
return Kandang(
|
||||
id: entry.key,
|
||||
id: entry.key.toString(),
|
||||
nama: kandangData['nama'] ?? '',
|
||||
jumlahAyam: kandangData['jumlahAyam'] ?? 0,
|
||||
dibuat: kandangData['dibuat'] != null
|
||||
|
|
@ -50,7 +65,22 @@ class KandangProvider extends ChangeNotifier {
|
|||
}
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
});
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error parsing kandang data: $e');
|
||||
}
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (kDebugMode) {
|
||||
print('Error listening kandang data: $error');
|
||||
}
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
},
|
||||
);
|
||||
|
||||
// Start listening to sensors from 'data' folder
|
||||
listenToSensors();
|
||||
|
|
@ -59,6 +89,39 @@ class KandangProvider extends ChangeNotifier {
|
|||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _ensureInitialKandangData(String userId) async {
|
||||
final sharedSnapshot = await _kandangRef.get();
|
||||
if (sharedSnapshot.exists) return;
|
||||
|
||||
try {
|
||||
final legacyRef = _database.ref('users/$userId/kandang');
|
||||
final legacySnapshot = await legacyRef.get();
|
||||
if (legacySnapshot.exists) {
|
||||
await _kandangRef.set(legacySnapshot.value);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Warning reading legacy kandang data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
await _kandangRef.set({
|
||||
'kandang1': {
|
||||
'nama': 'Kandang 1',
|
||||
'jumlahAyam': 100,
|
||||
'dibuat': DateTime.now().toIso8601String(),
|
||||
'infraPath': 'infra1',
|
||||
},
|
||||
'kandang2': {
|
||||
'nama': 'Kandang 2',
|
||||
'jumlahAyam': 120,
|
||||
'dibuat': DateTime.now().toIso8601String(),
|
||||
'infraPath': 'infra2',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear semua data saat logout
|
||||
Future<void> clearData() async {
|
||||
await _subscription?.cancel();
|
||||
|
|
@ -138,10 +201,7 @@ class KandangProvider extends ChangeNotifier {
|
|||
_sensorSubscription?.cancel();
|
||||
|
||||
// Listen ke 'data' folder untuk mendapatkan infra1 dan infra2
|
||||
_sensorSubscription = _database
|
||||
.ref('data')
|
||||
.onValue
|
||||
.listen(
|
||||
_sensorSubscription = _database.ref('data').onValue.listen(
|
||||
(event) {
|
||||
if (event.snapshot.exists) {
|
||||
final data = Map<dynamic, dynamic>.from(
|
||||
|
|
|
|||
|
|
@ -5,89 +5,13 @@ import '../models/panen_model.dart';
|
|||
|
||||
class PanenProvider extends ChangeNotifier {
|
||||
final FirebaseDatabase _database = FirebaseDatabase.instance;
|
||||
static const bool _allowFlutterAutoCaptureWrite = false;
|
||||
|
||||
// Tracking snapshot untuk time-windowing logic
|
||||
Map<String, int> _snapshotPagiHariIni = {}; // kandangId -> nilaiPagi
|
||||
Map<String, int> _snapshotSoreHariIni = {}; // kandangId -> nilaiSore
|
||||
final Map<String, int> _snapshotPagiHariIni = {}; // kandangId -> nilaiPagi
|
||||
final Map<String, int> _snapshotSoreHariIni = {}; // kandangId -> nilaiSore
|
||||
|
||||
final List<Panen> _panens = [
|
||||
// Tanggal 7 March 2026 - Kandang 1
|
||||
Panen(
|
||||
id: 'panen_1',
|
||||
kandangId: 'kandang_1',
|
||||
kandangNama: 'Kandang 1',
|
||||
jumlahTelur: 45,
|
||||
tanggalPanen: DateTime(2026, 3, 7, 9, 0), // 7 March 2026, 09:00
|
||||
jam: '09:00',
|
||||
catatan: 'Panen pagi - Kondisi baik',
|
||||
),
|
||||
Panen(
|
||||
id: 'panen_2',
|
||||
kandangId: 'kandang_1',
|
||||
kandangNama: 'Kandang 1',
|
||||
jumlahTelur: 42,
|
||||
tanggalPanen: DateTime(2026, 3, 7, 15, 0), // 7 March 2026, 15:00
|
||||
jam: '15:00',
|
||||
catatan: 'Panen sore - Ada 3 telur pecah',
|
||||
),
|
||||
// Tanggal 7 March 2026 - Kandang 2
|
||||
Panen(
|
||||
id: 'panen_3',
|
||||
kandangId: 'kandang_2',
|
||||
kandangNama: 'Kandang 2',
|
||||
jumlahTelur: 68,
|
||||
tanggalPanen: DateTime(2026, 3, 7, 9, 0), // 7 March 2026, 09:00
|
||||
jam: '09:00',
|
||||
catatan: 'Panen pagi - Produksi meningkat',
|
||||
),
|
||||
Panen(
|
||||
id: 'panen_4',
|
||||
kandangId: 'kandang_2',
|
||||
kandangNama: 'Kandang 2',
|
||||
jumlahTelur: 71,
|
||||
tanggalPanen: DateTime(2026, 3, 7, 15, 0), // 7 March 2026, 15:00
|
||||
jam: '15:00',
|
||||
catatan: 'Panen sore - Normal',
|
||||
),
|
||||
// Tanggal 8 March 2026 - Kandang 1
|
||||
Panen(
|
||||
id: 'panen_5',
|
||||
kandangId: 'kandang_1',
|
||||
kandangNama: 'Kandang 1',
|
||||
jumlahTelur: 48,
|
||||
tanggalPanen: DateTime(2026, 3, 8, 9, 0), // 8 March 2026, 09:00
|
||||
jam: '09:00',
|
||||
catatan: 'Panen pagi - Produksi stabil',
|
||||
),
|
||||
Panen(
|
||||
id: 'panen_6',
|
||||
kandangId: 'kandang_1',
|
||||
kandangNama: 'Kandang 1',
|
||||
jumlahTelur: 44,
|
||||
tanggalPanen: DateTime(2026, 3, 8, 15, 0), // 8 March 2026, 15:00
|
||||
jam: '15:00',
|
||||
catatan: 'Panen sore - Normal',
|
||||
),
|
||||
// Tanggal 8 March 2026 - Kandang 2
|
||||
Panen(
|
||||
id: 'panen_7',
|
||||
kandangId: 'kandang_2',
|
||||
kandangNama: 'Kandang 2',
|
||||
jumlahTelur: 65,
|
||||
tanggalPanen: DateTime(2026, 3, 8, 9, 0), // 8 March 2026, 09:00
|
||||
jam: '09:00',
|
||||
catatan: 'Panen pagi - Kondisi baik',
|
||||
),
|
||||
Panen(
|
||||
id: 'panen_8',
|
||||
kandangId: 'kandang_2',
|
||||
kandangNama: 'Kandang 2',
|
||||
jumlahTelur: 69,
|
||||
tanggalPanen: DateTime(2026, 3, 8, 15, 0), // 8 March 2026, 15:00
|
||||
jam: '15:00',
|
||||
catatan: 'Panen sore - Normal',
|
||||
),
|
||||
];
|
||||
final List<Panen> _panens = [];
|
||||
|
||||
List<Panen> get panens => _panens;
|
||||
|
||||
|
|
@ -293,8 +217,10 @@ class PanenProvider extends ChangeNotifier {
|
|||
_panens.add(newPanen);
|
||||
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
||||
|
||||
// Simpan ke Firebase
|
||||
// Guard: auto-capture write hanya dari Railway worker.
|
||||
if (_allowFlutterAutoCaptureWrite) {
|
||||
await _savePanenToFirebase(newPanen);
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print('✅ Panen Pagi recorded untuk $kandangNama: $jumlahTelur telur');
|
||||
|
|
@ -361,8 +287,10 @@ class PanenProvider extends ChangeNotifier {
|
|||
_panens.add(newPanen);
|
||||
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
||||
|
||||
// Simpan ke Firebase
|
||||
// Guard: auto-capture write hanya dari Railway worker.
|
||||
if (_allowFlutterAutoCaptureWrite) {
|
||||
await _savePanenToFirebase(newPanen);
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
|
|
@ -381,7 +309,7 @@ class PanenProvider extends ChangeNotifier {
|
|||
/// Simpan panen ke Firebase
|
||||
Future<void> _savePanenToFirebase(Panen panen) async {
|
||||
try {
|
||||
final ref = _database.ref('riwayat').push();
|
||||
final ref = _database.ref('riwayat/records').push();
|
||||
await ref.set({
|
||||
'id': panen.id,
|
||||
'kandang_id': panen.kandangId,
|
||||
|
|
@ -395,6 +323,8 @@ class PanenProvider extends ChangeNotifier {
|
|||
'catatan': panen.catatan,
|
||||
});
|
||||
|
||||
await _updateRiwayatSummary(panen);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('✅ Panen saved to Firebase: ${panen.id}');
|
||||
}
|
||||
|
|
@ -405,6 +335,52 @@ class PanenProvider extends ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _updateRiwayatSummary(Panen panen) async {
|
||||
final now = panen.tanggalPanen;
|
||||
final dateKey =
|
||||
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||
final summaryRef = _database.ref('riwayat/summary');
|
||||
|
||||
await summaryRef.runTransaction((current) {
|
||||
final data = Map<dynamic, dynamic>.from(
|
||||
(current as Map?) ?? <dynamic, dynamic>{},
|
||||
);
|
||||
|
||||
int telurHariIni = (data['telur_hari_ini'] ?? 0) as int;
|
||||
int totalTelur = (data['total_telur'] ?? 0) as int;
|
||||
int kandang1 = (data['kandang1_hari_ini'] ?? 0) as int;
|
||||
int kandang2 = (data['kandang2_hari_ini'] ?? 0) as int;
|
||||
final lastDate = '${data['last_reset_date'] ?? ''}';
|
||||
|
||||
if (lastDate != dateKey) {
|
||||
telurHariIni = 0;
|
||||
kandang1 = 0;
|
||||
kandang2 = 0;
|
||||
}
|
||||
|
||||
telurHariIni += panen.jumlahTelur;
|
||||
totalTelur += panen.jumlahTelur;
|
||||
|
||||
final kandangIdLower = panen.kandangId.toLowerCase();
|
||||
if (kandangIdLower == 'kandang1' || kandangIdLower == 'kandang_1') {
|
||||
kandang1 += panen.jumlahTelur;
|
||||
}
|
||||
if (kandangIdLower == 'kandang2' || kandangIdLower == 'kandang_2') {
|
||||
kandang2 += panen.jumlahTelur;
|
||||
}
|
||||
|
||||
return Transaction.success({
|
||||
...data,
|
||||
'telur_hari_ini': telurHariIni,
|
||||
'kandang1_hari_ini': kandang1,
|
||||
'kandang2_hari_ini': kandang2,
|
||||
'total_telur': totalTelur,
|
||||
'last_reset_date': dateKey,
|
||||
'updated_at': DateTime.now().toIso8601String(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Load snapshots dari Firebase ketika app start (untuk sync state)
|
||||
Future<void> loadTodaySnapshots() async {
|
||||
try {
|
||||
|
|
@ -457,7 +433,7 @@ class PanenProvider extends ChangeNotifier {
|
|||
/// Restore historical panen dari Firebase saat app start
|
||||
Future<void> restorePanenHistoryFromFirebase() async {
|
||||
try {
|
||||
final ref = _database.ref('riwayat');
|
||||
final ref = _database.ref('riwayat/records');
|
||||
final snapshot = await ref.get();
|
||||
|
||||
if (snapshot.exists) {
|
||||
|
|
@ -465,13 +441,18 @@ class PanenProvider extends ChangeNotifier {
|
|||
_panens.clear();
|
||||
|
||||
data.forEach((key, value) {
|
||||
final panenData = Map<dynamic, dynamic>.from(value as Map);
|
||||
if (value is! Map) return;
|
||||
final panenData = Map<dynamic, dynamic>.from(value);
|
||||
final tanggalRaw = panenData['tanggal_panen'];
|
||||
final tanggal = DateTime.tryParse('${tanggalRaw ?? ''}');
|
||||
if (tanggal == null) return;
|
||||
|
||||
final panen = Panen(
|
||||
id: panenData['id'] ?? '',
|
||||
kandangId: panenData['kandang_id'] ?? '',
|
||||
kandangNama: panenData['kandang_nama'] ?? '',
|
||||
jumlahTelur: panenData['jumlah_telur'] ?? 0,
|
||||
tanggalPanen: DateTime.parse(panenData['tanggal_panen'] ?? ''),
|
||||
tanggalPanen: tanggal,
|
||||
jam: panenData['jam'] ?? '',
|
||||
catatan: panenData['catatan'] ?? '',
|
||||
jenisPanen: panenData['jenis_panen'],
|
||||
|
|
@ -480,6 +461,39 @@ class PanenProvider extends ChangeNotifier {
|
|||
);
|
||||
_panens.add(panen);
|
||||
});
|
||||
} else {
|
||||
// Fallback legacy structure: /riwayat langsung berisi records + summary.
|
||||
final legacySnapshot = await _database.ref('riwayat').get();
|
||||
if (legacySnapshot.exists) {
|
||||
final legacyData = Map<dynamic, dynamic>.from(
|
||||
legacySnapshot.value as Map,
|
||||
);
|
||||
_panens.clear();
|
||||
|
||||
legacyData.forEach((key, value) {
|
||||
if (value is! Map) return;
|
||||
final panenData = Map<dynamic, dynamic>.from(value);
|
||||
if (!panenData.containsKey('kandang_id')) return;
|
||||
final tanggalRaw = panenData['tanggal_panen'];
|
||||
final tanggal = DateTime.tryParse('${tanggalRaw ?? ''}');
|
||||
if (tanggal == null) return;
|
||||
|
||||
_panens.add(
|
||||
Panen(
|
||||
id: panenData['id'] ?? '',
|
||||
kandangId: panenData['kandang_id'] ?? '',
|
||||
kandangNama: panenData['kandang_nama'] ?? '',
|
||||
jumlahTelur: panenData['jumlah_telur'] ?? 0,
|
||||
tanggalPanen: tanggal,
|
||||
jam: panenData['jam'] ?? '',
|
||||
catatan: panenData['catatan'] ?? '',
|
||||
jenisPanen: panenData['jenis_panen'],
|
||||
sensorSnapshot: panenData['sensor_snapshot'],
|
||||
panenSebelumnya: panenData['panen_sebelumnya'],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_panens.sort((a, b) => b.tanggalPanen.compareTo(a.tanggalPanen));
|
||||
|
||||
|
|
|
|||
|
|
@ -18,24 +18,39 @@ class PenjadwalanProvider extends ChangeNotifier {
|
|||
Future<void> initializeWithUser(String userId) async {
|
||||
_penjadwalanRef = _database.ref('kontrol/penjadwalan');
|
||||
|
||||
try {
|
||||
// Pastikan jadwal awal tersedia agar login pertama langsung ada isi.
|
||||
await _ensureInitialPenjadwalanData();
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Warning ensuring penjadwalan initial data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Clear previous listener
|
||||
await _subscription?.cancel();
|
||||
|
||||
// Load data dari Firebase dengan real-time listener
|
||||
_subscription = _penjadwalanRef.onValue.listen((event) {
|
||||
if (event.snapshot.exists) {
|
||||
final data = event.snapshot.value as Map<dynamic, dynamic>;
|
||||
_penjadwalans = data.entries.map((entry) {
|
||||
_subscription = _penjadwalanRef.onValue.listen(
|
||||
(event) {
|
||||
try {
|
||||
if (event.snapshot.exists && event.snapshot.value is Map) {
|
||||
final data =
|
||||
Map<dynamic, dynamic>.from(event.snapshot.value as Map);
|
||||
_penjadwalans =
|
||||
data.entries.where((entry) => entry.value is Map).map((entry) {
|
||||
return Penjadwalan.fromJson({
|
||||
'id': entry.key,
|
||||
...entry.value as Map<dynamic, dynamic>,
|
||||
...Map<dynamic, dynamic>.from(entry.value as Map),
|
||||
});
|
||||
}).toList();
|
||||
|
||||
// Sort berdasarkan nomor untuk urutan yang rapi
|
||||
_penjadwalans.sort((a, b) {
|
||||
int numA = int.tryParse(a.id.replaceAll('penjadwalan', '')) ?? 999;
|
||||
int numB = int.tryParse(b.id.replaceAll('penjadwalan', '')) ?? 999;
|
||||
int numA =
|
||||
int.tryParse(a.id.replaceAll('penjadwalan', '')) ?? 999;
|
||||
int numB =
|
||||
int.tryParse(b.id.replaceAll('penjadwalan', '')) ?? 999;
|
||||
return numA.compareTo(numB);
|
||||
});
|
||||
} else {
|
||||
|
|
@ -43,12 +58,51 @@ class PenjadwalanProvider extends ChangeNotifier {
|
|||
}
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
});
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('Error parsing penjadwalan data: $e');
|
||||
}
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (kDebugMode) {
|
||||
print('Error listening penjadwalan data: $error');
|
||||
}
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
},
|
||||
);
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _ensureInitialPenjadwalanData() async {
|
||||
final snapshot = await _penjadwalanRef.get();
|
||||
if (snapshot.exists) return;
|
||||
|
||||
await _penjadwalanRef.set({
|
||||
'penjadwalan1': {
|
||||
'kandangId': 'kandang1',
|
||||
'kandangNama': 'Kandang 1',
|
||||
'jam': '09:00',
|
||||
'durasi': '30 menit',
|
||||
'keterangan': 'Panen pagi',
|
||||
'aktif': true,
|
||||
},
|
||||
'penjadwalan2': {
|
||||
'kandangId': 'kandang2',
|
||||
'kandangNama': 'Kandang 2',
|
||||
'jam': '15:00',
|
||||
'durasi': '30 menit',
|
||||
'keterangan': 'Panen sore',
|
||||
'aktif': true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// Get next penjadwalan number (penjadwalan1, penjadwalan2, dst)
|
||||
String _getNextPenjadwalanKey() {
|
||||
if (_penjadwalans.isEmpty) return 'penjadwalan1';
|
||||
|
|
|
|||
|
|
@ -6,16 +6,20 @@ class TelurProvider extends ChangeNotifier {
|
|||
late DatabaseReference _riwayatRef;
|
||||
|
||||
int _telurHariIni = 0;
|
||||
int _kandang1HariIni = 0;
|
||||
int _kandang2HariIni = 0;
|
||||
int _totalTelur = 0;
|
||||
String _lastResetDate = '';
|
||||
bool _isSynced = false;
|
||||
|
||||
int get telurHariIni => _telurHariIni;
|
||||
int get kandang1HariIni => _kandang1HariIni;
|
||||
int get kandang2HariIni => _kandang2HariIni;
|
||||
int get totalTelur => _totalTelur;
|
||||
bool get isSynced => _isSynced;
|
||||
|
||||
TelurProvider() {
|
||||
_riwayatRef = _database.ref('riwayat');
|
||||
_riwayatRef = _database.ref('riwayat/summary');
|
||||
_initializeRiwayat();
|
||||
}
|
||||
|
||||
|
|
@ -28,6 +32,8 @@ class TelurProvider extends ChangeNotifier {
|
|||
// Create initial structure
|
||||
await _riwayatRef.set({
|
||||
'telur_hari_ini': 0,
|
||||
'kandang1_hari_ini': 0,
|
||||
'kandang2_hari_ini': 0,
|
||||
'total_telur': 0,
|
||||
'last_reset_date': DateTime.now().toIso8601String().split('T')[0],
|
||||
});
|
||||
|
|
@ -41,6 +47,8 @@ class TelurProvider extends ChangeNotifier {
|
|||
if (event.snapshot.exists) {
|
||||
final data = event.snapshot.value as Map<dynamic, dynamic>;
|
||||
_telurHariIni = data['telur_hari_ini'] ?? 0;
|
||||
_kandang1HariIni = data['kandang1_hari_ini'] ?? 0;
|
||||
_kandang2HariIni = data['kandang2_hari_ini'] ?? 0;
|
||||
_totalTelur = data['total_telur'] ?? 0;
|
||||
_lastResetDate = data['last_reset_date'] ?? '';
|
||||
|
||||
|
|
@ -65,6 +73,8 @@ class TelurProvider extends ChangeNotifier {
|
|||
if (snapshot.exists) {
|
||||
final data = snapshot.value as Map<dynamic, dynamic>;
|
||||
_telurHariIni = data['telur_hari_ini'] ?? 0;
|
||||
_kandang1HariIni = data['kandang1_hari_ini'] ?? 0;
|
||||
_kandang2HariIni = data['kandang2_hari_ini'] ?? 0;
|
||||
_totalTelur = data['total_telur'] ?? 0;
|
||||
_lastResetDate = data['last_reset_date'] ?? '';
|
||||
notifyListeners();
|
||||
|
|
@ -82,9 +92,13 @@ class TelurProvider extends ChangeNotifier {
|
|||
// Reset telur_hari_ini for new day
|
||||
await _riwayatRef.update({
|
||||
'telur_hari_ini': 0,
|
||||
'kandang1_hari_ini': 0,
|
||||
'kandang2_hari_ini': 0,
|
||||
'last_reset_date': todayDate,
|
||||
});
|
||||
_telurHariIni = 0;
|
||||
_kandang1HariIni = 0;
|
||||
_kandang2HariIni = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -121,9 +135,13 @@ class TelurProvider extends ChangeNotifier {
|
|||
final todayDate = DateTime.now().toIso8601String().split('T')[0];
|
||||
await _riwayatRef.update({
|
||||
'telur_hari_ini': 0,
|
||||
'kandang1_hari_ini': 0,
|
||||
'kandang2_hari_ini': 0,
|
||||
'last_reset_date': todayDate,
|
||||
});
|
||||
_telurHariIni = 0;
|
||||
_kandang1HariIni = 0;
|
||||
_kandang2HariIni = 0;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
print('Error resetting telur hari ini: $e');
|
||||
|
|
@ -134,10 +152,14 @@ class TelurProvider extends ChangeNotifier {
|
|||
try {
|
||||
await _riwayatRef.set({
|
||||
'telur_hari_ini': 0,
|
||||
'kandang1_hari_ini': 0,
|
||||
'kandang2_hari_ini': 0,
|
||||
'total_telur': 0,
|
||||
'last_reset_date': DateTime.now().toIso8601String().split('T')[0],
|
||||
});
|
||||
_telurHariIni = 0;
|
||||
_kandang1HariIni = 0;
|
||||
_kandang2HariIni = 0;
|
||||
_totalTelur = 0;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||
import '../providers/kandang_provider.dart';
|
||||
import '../providers/panen_provider.dart';
|
||||
import '../providers/penjadwalan_provider.dart';
|
||||
import '../providers/riwayat_provider.dart';
|
||||
|
||||
class DashboardPage extends StatelessWidget {
|
||||
const DashboardPage({super.key});
|
||||
|
|
@ -10,6 +11,7 @@ class DashboardPage extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kandangProvider = context.watch<KandangProvider>();
|
||||
final telurProvider = context.watch<TelurProvider>();
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
|
|
@ -77,7 +79,7 @@ class DashboardPage extends StatelessWidget {
|
|||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${kandangProvider.infra1Value + kandangProvider.infra2Value}',
|
||||
'${telurProvider.telurHariIni}',
|
||||
style: const TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
|
@ -88,6 +90,32 @@ class DashboardPage extends StatelessWidget {
|
|||
Text('🥚', style: TextStyle(fontSize: 40)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Kandang 1: ${telurProvider.kandang1HariIni}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Kandang 2: ${telurProvider.kandang2HariIni}',
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||
import '../providers/auth_provider.dart';
|
||||
import '../providers/penjadwalan_provider.dart';
|
||||
import '../providers/kandang_provider.dart';
|
||||
import '../providers/panen_provider.dart';
|
||||
import 'dashboard_page.dart';
|
||||
import 'kontrol_page.dart';
|
||||
import 'riwayat_page.dart';
|
||||
|
|
@ -17,6 +18,7 @@ class HomePage extends StatefulWidget {
|
|||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
int _selectedIndex = 0;
|
||||
String? _initializedForUserId;
|
||||
|
||||
final List<Widget> _pages = [
|
||||
const DashboardPage(),
|
||||
|
|
@ -24,6 +26,38 @@ class _HomePageState extends State<HomePage> {
|
|||
const RiwayatPage(),
|
||||
];
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
final userId = authProvider.user?.uid;
|
||||
|
||||
if (userId == null || userId == _initializedForUserId) return;
|
||||
_initializedForUserId = userId;
|
||||
|
||||
// Re-init provider saat app restart/hot restart ketika user masih login.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
await context.read<PenjadwalanProvider>().initializeWithUser(userId);
|
||||
if (!mounted) return;
|
||||
await context.read<KandangProvider>().initializeWithUser(userId);
|
||||
if (!mounted) return;
|
||||
final panenProvider = context.read<PanenProvider>();
|
||||
await panenProvider.loadTodaySnapshots();
|
||||
await panenProvider.restorePanenHistoryFromFirebase();
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Sinkronisasi data sedang bermasalah, coba lagi.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authProvider = context.watch<AuthProvider>();
|
||||
|
|
|
|||
|
|
@ -54,8 +54,19 @@ class _LoginPageState extends State<LoginPage> {
|
|||
final penjadwalanProvider = context.read<PenjadwalanProvider>();
|
||||
final kandangProvider = context.read<KandangProvider>();
|
||||
|
||||
try {
|
||||
await penjadwalanProvider.initializeWithUser(userId);
|
||||
await kandangProvider.initializeWithUser(userId);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Login berhasil, tetapi sinkronisasi data belum sempurna.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
|
|
|||
|
|
@ -468,7 +468,7 @@ class _PanenDebugScreenState extends State<PanenDebugScreen> {
|
|||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -325,6 +325,47 @@ class _RiwayatPageState extends State<RiwayatPage> {
|
|||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: (panen.jenisPanen ?? '').toLowerCase() ==
|
||||
'sore'
|
||||
? Colors.blue.shade50
|
||||
: Colors.green.shade50,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
(panen.jenisPanen ?? 'manual').toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: (panen.jenisPanen ?? '').toLowerCase() ==
|
||||
'sore'
|
||||
? Colors.blue.shade700
|
||||
: Colors.green.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
panen.catatan,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -89,8 +89,19 @@ class _SignupPageState extends State<SignupPage> {
|
|||
final penjadwalanProvider = context.read<PenjadwalanProvider>();
|
||||
final kandangProvider = context.read<KandangProvider>();
|
||||
|
||||
try {
|
||||
await penjadwalanProvider.initializeWithUser(userId);
|
||||
await kandangProvider.initializeWithUser(userId);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Akun berhasil dibuat, tetapi sinkronisasi data belum sempurna.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# Railway Runtime
|
||||
PORT=3000
|
||||
TZ=Asia/Jakarta
|
||||
SCHEDULER_ENABLED=true
|
||||
COMPACT_MODE=true
|
||||
RIWAYAT_MAX_RECORDS=500
|
||||
SCHEDULER_RUNS_RETAIN_DAYS=14
|
||||
|
||||
# Firebase Admin SDK
|
||||
# Opsi A (disarankan): tempel JSON service account penuh ke satu variable.
|
||||
# FIREBASE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"...","private_key":"-----BEGIN PRIVATE KEY-----\\n...\\n-----END PRIVATE KEY-----\\n","client_email":"..."}
|
||||
|
||||
# Opsi B: pakai field terpisah.
|
||||
FIREBASE_PROJECT_ID=telurku-fa78c
|
||||
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-xxxxx@telurku-fa78c.iam.gserviceaccount.com
|
||||
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\\nREPLACE_ME\\n-----END PRIVATE KEY-----\\n"
|
||||
FIREBASE_DATABASE_URL=https://telurku-fa78c-default-rtdb.asia-southeast1.firebasedatabase.app
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
# TelurKu Railway Scheduler
|
||||
|
||||
Worker Node.js untuk menjalankan penjadwalan panen otomatis di Railway.
|
||||
|
||||
## Fungsi Utama
|
||||
|
||||
- Menjalankan cek jadwal setiap menit (timezone default `Asia/Jakarta`).
|
||||
- Membaca jadwal aktif dari `kontrol/penjadwalan`.
|
||||
- Membaca sensor dari `data` berdasarkan `infraPath` kandang (`kontrol/kandang`).
|
||||
- Menyimpan hasil ke:
|
||||
- `panen_snapshot/{yyyy-MM-dd}`
|
||||
- `riwayat/records`
|
||||
- `riwayat/summary`
|
||||
- Mencegah duplikasi eksekusi dengan lock harian di `scheduler_runs/{yyyy-MM-dd}`.
|
||||
|
||||
## Struktur Folder
|
||||
|
||||
- `worker.js` : engine scheduler + health endpoint
|
||||
- `package.json` : dependency dan start command
|
||||
- `Dockerfile` : container build untuk Railway
|
||||
- `.dockerignore` : optimasi image build
|
||||
- `.env.example` : template environment variables
|
||||
- `railway.json` : config deploy Railway
|
||||
|
||||
## Environment Variables (Railway)
|
||||
|
||||
Gunakan nilai ini di Railway Variables:
|
||||
|
||||
- `PORT=3000`
|
||||
- `TZ=Asia/Jakarta`
|
||||
- `SCHEDULER_ENABLED=true`
|
||||
- `FIREBASE_SERVICE_ACCOUNT_JSON=<json-service-account-penuh>` (opsi paling mudah)
|
||||
- `FIREBASE_PROJECT_ID=telurku-fa78c`
|
||||
- `FIREBASE_CLIENT_EMAIL=<service-account-email>`
|
||||
- `FIREBASE_PRIVATE_KEY=<private-key-dengan-escaped-newline>`
|
||||
- `FIREBASE_DATABASE_URL=https://telurku-fa78c-default-rtdb.asia-southeast1.firebasedatabase.app`
|
||||
|
||||
Catatan untuk `FIREBASE_PRIVATE_KEY`:
|
||||
- Gunakan format satu baris dengan `\n` (lihat `.env.example`).
|
||||
- Jangan commit private key ke Git.
|
||||
|
||||
Catatan untuk `FIREBASE_SERVICE_ACCOUNT_JSON`:
|
||||
- Bisa isi seluruh JSON service account dalam satu variable Railway.
|
||||
- Tetap jangan taruh nilainya di source code atau file yang di-commit.
|
||||
|
||||
## Deploy di Railway
|
||||
|
||||
1. Buat service baru dari repo ini.
|
||||
2. Set **Root Directory** ke `railway-scheduler`.
|
||||
3. Railway bisa build via `Dockerfile` atau Nixpacks.
|
||||
4. Isi semua environment variables.
|
||||
5. Deploy.
|
||||
6. Cek log, pastikan muncul:
|
||||
- `[init] Firebase Admin connected`
|
||||
- `[init] Scheduler active`
|
||||
|
||||
## Health Check
|
||||
|
||||
- Endpoint: `/health`
|
||||
- Response: `{ "ok": true, "service": "telurku-railway-scheduler" }`
|
||||
|
||||
## Local Test
|
||||
|
||||
```bash
|
||||
cd railway-scheduler
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# isi env yang benar
|
||||
npm start
|
||||
```
|
||||
|
||||
## Data Path yang Dipakai
|
||||
|
||||
- Read: `kontrol/penjadwalan`
|
||||
- Read: `kontrol/kandang`
|
||||
- Read: `data`
|
||||
- Write: `riwayat/records`
|
||||
- Write: `riwayat/summary`
|
||||
- Write: `panen_snapshot/{date}`
|
||||
- Write lock: `scheduler_runs/{date}`
|
||||
|
||||
## Reset Otomatis Sore
|
||||
|
||||
Setelah jadwal sore berhasil dieksekusi, worker akan me-reset:
|
||||
|
||||
- `data/infra1 = 0`
|
||||
- `data/infra2 = 0`
|
||||
|
||||
Ringkasan harian di `riwayat/summary` tetap disimpan untuk kebutuhan tampilan Flutter.
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "telurku-railway-scheduler",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Railway worker for automatic egg harvest scheduling",
|
||||
"main": "worker.js",
|
||||
"scripts": {
|
||||
"start": "node worker.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
"firebase-admin": "^12.7.0",
|
||||
"node-cron": "^3.0.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://railway.app/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "NIXPACKS"
|
||||
},
|
||||
"deploy": {
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 10
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,728 @@
|
|||
require('dotenv').config();
|
||||
const http = require('http');
|
||||
const cron = require('node-cron');
|
||||
const admin = require('firebase-admin');
|
||||
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
const TZ = process.env.TZ || 'Asia/Jakarta';
|
||||
const SCHEDULER_ENABLED = String(process.env.SCHEDULER_ENABLED || 'true') === 'true';
|
||||
const COMPACT_MODE = String(process.env.COMPACT_MODE || 'true') === 'true';
|
||||
const RIWAYAT_MAX_RECORDS = Number(process.env.RIWAYAT_MAX_RECORDS || 500);
|
||||
const SCHEDULER_RUNS_RETAIN_DAYS = Number(process.env.SCHEDULER_RUNS_RETAIN_DAYS || 14);
|
||||
let motorOffTimer = null;
|
||||
|
||||
function getEnvOrThrow(key) {
|
||||
const value = process.env[key];
|
||||
if (!value) {
|
||||
throw new Error(`Missing env var: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// PERBAIKAN:
|
||||
function parseServiceAccountFromEnv() {
|
||||
const rawJson = process.env.FIREBASE_SERVICE_ACCOUNT_JSON;
|
||||
if (rawJson) {
|
||||
const parsed = JSON.parse(rawJson);
|
||||
return {
|
||||
projectId: parsed.project_id,
|
||||
clientEmail: parsed.client_email,
|
||||
privateKey: String(parsed.private_key || '').replace(/\\n/g, '\n'),
|
||||
};
|
||||
}
|
||||
|
||||
// Panggil NAMA VARIABEL yang didaftarkan di Railway
|
||||
return {
|
||||
projectId: getEnvOrThrow('FIREBASE_PROJECT_ID'),
|
||||
clientEmail: getEnvOrThrow('FIREBASE_CLIENT_EMAIL'),
|
||||
privateKey: getEnvOrThrow('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function initFirebase() {
|
||||
const { projectId, clientEmail, privateKey } = parseServiceAccountFromEnv();
|
||||
// Panggil NAMA VARIABEL untuk URL database
|
||||
const databaseURL = getEnvOrThrow('FIREBASE_DATABASE_URL');
|
||||
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId,
|
||||
clientEmail,
|
||||
privateKey,
|
||||
}),
|
||||
databaseURL,
|
||||
});
|
||||
|
||||
console.log('[init] Firebase Admin connected:', projectId);
|
||||
}
|
||||
|
||||
function getJakartaNow() {
|
||||
return new Date(new Date().toLocaleString('en-US', { timeZone: TZ }));
|
||||
}
|
||||
|
||||
function formatDateKey(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function formatHHMM(date) {
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function toInt(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function toDate(value) {
|
||||
const d = new Date(String(value || ''));
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function normalizeKandangKey(kandangId) {
|
||||
const id = String(kandangId || '').toLowerCase();
|
||||
if (id === 'kandang1' || id === 'kandang_1') return 'kandang1';
|
||||
if (id === 'kandang2' || id === 'kandang_2') return 'kandang2';
|
||||
return 'lainnya';
|
||||
}
|
||||
|
||||
function parseDurasiMs(durasiRaw) {
|
||||
const text = String(durasiRaw || '').toLowerCase().trim();
|
||||
if (!text) return 30 * 60 * 1000;
|
||||
|
||||
const number = Number.parseInt(text.replace(/[^0-9]/g, ''), 10);
|
||||
if (!Number.isFinite(number) || number <= 0) return 30 * 60 * 1000;
|
||||
|
||||
if (
|
||||
text.includes('jam') ||
|
||||
text.includes('hour') ||
|
||||
text.includes('hours') ||
|
||||
text.includes('hr')
|
||||
) {
|
||||
return number * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
return number * 60 * 1000;
|
||||
}
|
||||
|
||||
function isAllKandangSchedule(jadwal) {
|
||||
const kandangId = String(jadwal.kandangId || '').toLowerCase().trim();
|
||||
const kandangNama = String(jadwal.kandangNama || '').toLowerCase().trim();
|
||||
return (
|
||||
kandangId === 'all' ||
|
||||
kandangId === 'global' ||
|
||||
kandangId === 'semua' ||
|
||||
kandangNama.includes('global') ||
|
||||
kandangNama.includes('semua') ||
|
||||
kandangNama.includes('all')
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTargetKandangIds(jadwal, kandangMap) {
|
||||
if (isAllKandangSchedule(jadwal)) {
|
||||
const ids = Object.keys(kandangMap || {});
|
||||
if (ids.length > 0) return ids;
|
||||
return ['kandang1', 'kandang2'];
|
||||
}
|
||||
|
||||
const kandangId = String(jadwal.kandangId || '').trim();
|
||||
if (!kandangId) return [];
|
||||
return [kandangId];
|
||||
}
|
||||
|
||||
async function triggerMotorForDuration(durasiMs, jadwalId) {
|
||||
const ref = admin.database().ref('aktuator');
|
||||
await ref.update({
|
||||
motor: true,
|
||||
last_trigger_by_scheduler: new Date().toISOString(),
|
||||
last_trigger_jadwal: jadwalId,
|
||||
durasi_ms: durasiMs,
|
||||
});
|
||||
console.log(`[ok] Motor ON untuk jadwal ${jadwalId}, durasi ${Math.round(durasiMs / 1000)} detik`);
|
||||
|
||||
if (motorOffTimer) {
|
||||
clearTimeout(motorOffTimer);
|
||||
}
|
||||
|
||||
motorOffTimer = setTimeout(async () => {
|
||||
try {
|
||||
await ref.update({
|
||||
motor: false,
|
||||
last_off_by_scheduler: new Date().toISOString(),
|
||||
});
|
||||
console.log(`[ok] Motor OFF otomatis setelah durasi jadwal ${jadwalId}`);
|
||||
} catch (e) {
|
||||
console.error('[error] Gagal mematikan motor otomatis:', e.message);
|
||||
}
|
||||
}, durasiMs);
|
||||
}
|
||||
|
||||
function detectJenisPanen(jadwal) {
|
||||
const jam = String(jadwal.jam || '09:00');
|
||||
const hour = Number(jam.split(':')[0] || 9);
|
||||
const ket = String(jadwal.keterangan || '').toLowerCase();
|
||||
|
||||
if (ket.includes('sore')) return 'sore';
|
||||
if (ket.includes('pagi')) return 'pagi';
|
||||
return hour >= 12 ? 'sore' : 'pagi';
|
||||
}
|
||||
|
||||
function resolveInfraPath(kandangId, kandangData) {
|
||||
if (kandangData && kandangData.infraPath) {
|
||||
return String(kandangData.infraPath);
|
||||
}
|
||||
|
||||
const id = String(kandangId || '').toLowerCase();
|
||||
if (id.includes('1')) return 'infra1';
|
||||
if (id.includes('2')) return 'infra2';
|
||||
return 'infra1';
|
||||
}
|
||||
|
||||
async function acquireRunLock(todayKey, lockKey) {
|
||||
const lockRef = admin.database().ref(`scheduler_runs/${todayKey}/${lockKey}`);
|
||||
const tx = await lockRef.transaction((current) => {
|
||||
if (current) return current;
|
||||
return {
|
||||
executedAt: new Date().toISOString(),
|
||||
source: 'railway-scheduler',
|
||||
};
|
||||
});
|
||||
|
||||
return tx.committed;
|
||||
}
|
||||
|
||||
async function updateRiwayatSummary({ kandangId, jumlahTelur, dateKey }) {
|
||||
const summaryRef = admin.database().ref('riwayat/summary');
|
||||
const kandangKey = normalizeKandangKey(kandangId);
|
||||
const totalAdd = toInt(jumlahTelur);
|
||||
|
||||
await summaryRef.transaction((current) => {
|
||||
const nowData = current || {};
|
||||
const lastDate = String(nowData.last_reset_date || '');
|
||||
|
||||
let telurHariIni = toInt(nowData.telur_hari_ini);
|
||||
let kandang1 = toInt(nowData.kandang1_hari_ini);
|
||||
let kandang2 = toInt(nowData.kandang2_hari_ini);
|
||||
let totalTelur = toInt(nowData.total_telur);
|
||||
|
||||
if (lastDate !== dateKey) {
|
||||
telurHariIni = 0;
|
||||
kandang1 = 0;
|
||||
kandang2 = 0;
|
||||
}
|
||||
|
||||
telurHariIni += totalAdd;
|
||||
totalTelur += totalAdd;
|
||||
|
||||
if (kandangKey === 'kandang1') kandang1 += totalAdd;
|
||||
if (kandangKey === 'kandang2') kandang2 += totalAdd;
|
||||
|
||||
return {
|
||||
...nowData,
|
||||
telur_hari_ini: telurHariIni,
|
||||
kandang1_hari_ini: kandang1,
|
||||
kandang2_hari_ini: kandang2,
|
||||
total_telur: totalTelur,
|
||||
last_reset_date: dateKey,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function resetSensorAfterEvening(todayKey) {
|
||||
const lockKey = 'evening_sensor_reset';
|
||||
const gotLock = await acquireRunLock(todayKey, lockKey);
|
||||
if (!gotLock) {
|
||||
console.log('[skip] Sensor reset sudah dieksekusi hari ini');
|
||||
return;
|
||||
}
|
||||
|
||||
await admin.database().ref('data').update({
|
||||
infra1: 0,
|
||||
infra2: 0,
|
||||
last_reset_by_scheduler: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log('[ok] Sensor infra1/infra2 di-reset otomatis setelah jadwal sore');
|
||||
}
|
||||
|
||||
async function writeRiwayat({
|
||||
kandangId,
|
||||
kandangNama,
|
||||
jumlahTelur,
|
||||
jam,
|
||||
jenisPanen,
|
||||
sensorSnapshot,
|
||||
panenSebelumnya,
|
||||
catatan,
|
||||
dateKey,
|
||||
}) {
|
||||
const riwayatRef = admin.database().ref('riwayat/records').push();
|
||||
const now = new Date();
|
||||
const jumlah = toInt(jumlahTelur);
|
||||
const sensor = toInt(sensorSnapshot);
|
||||
|
||||
await riwayatRef.set({
|
||||
id: `railway_${Date.now()}`,
|
||||
kandang_id: kandangId,
|
||||
kandang_nama: kandangNama,
|
||||
jumlah_telur: jumlah,
|
||||
tanggal_panen: now.toISOString(),
|
||||
jam,
|
||||
jenis_panen: jenisPanen,
|
||||
sensor_snapshot: sensor,
|
||||
panen_sebelumnya: panenSebelumnya == null ? null : Number(panenSebelumnya),
|
||||
catatan,
|
||||
});
|
||||
|
||||
await updateRiwayatSummary({
|
||||
kandangId,
|
||||
jumlahTelur: jumlah,
|
||||
dateKey,
|
||||
});
|
||||
}
|
||||
|
||||
async function shouldRunMaintenance() {
|
||||
const lockRef = admin.database().ref('maintenance/migration_v2_global_alias');
|
||||
const tx = await lockRef.transaction((current) => {
|
||||
if (current && current.done === true) return current;
|
||||
return {
|
||||
done: true,
|
||||
done_at: new Date().toISOString(),
|
||||
source: 'railway-scheduler',
|
||||
};
|
||||
});
|
||||
return tx.committed;
|
||||
}
|
||||
|
||||
function looksLikeLegacyRecord(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
return 'kandang_id' in value && 'tanggal_panen' in value;
|
||||
}
|
||||
|
||||
function buildKandangNameMap(kandangMap) {
|
||||
const map = {};
|
||||
for (const kandangId of Object.keys(kandangMap || {})) {
|
||||
const kandang = kandangMap[kandangId];
|
||||
map[kandangId] = String((kandang && kandang.nama) || kandangId);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function parseDateKeyFromIso(iso) {
|
||||
const date = new Date(String(iso || ''));
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return formatDateKey(date);
|
||||
}
|
||||
|
||||
async function inferTargetForGlobalRecord(record, kandangMap) {
|
||||
const dateKey = parseDateKeyFromIso(record.tanggal_panen);
|
||||
if (!dateKey) return null;
|
||||
|
||||
const jenis = String(record.jenis_panen || '').toLowerCase();
|
||||
const sessionKey = jenis === 'sore' ? 'sore' : 'pagi';
|
||||
const sessionSnapRef = admin.database().ref(`panen_snapshot/${dateKey}/${sessionKey}`);
|
||||
const sessionSnap = await sessionSnapRef.get();
|
||||
if (!sessionSnap.exists() || typeof sessionSnap.val() !== 'object') return null;
|
||||
|
||||
const snapData = sessionSnap.val();
|
||||
const candidates = [];
|
||||
for (const kandangId of Object.keys(kandangMap || {})) {
|
||||
if (!Object.prototype.hasOwnProperty.call(snapData, kandangId)) continue;
|
||||
const snapVal = toInt(snapData[kandangId]);
|
||||
if (jenis === 'sore') {
|
||||
const deltaKey = `delta_${kandangId}`;
|
||||
const deltaVal = toInt(snapData[deltaKey]);
|
||||
if (deltaVal === toInt(record.jumlah_telur) || snapVal === toInt(record.sensor_snapshot)) {
|
||||
candidates.push(kandangId);
|
||||
}
|
||||
} else if (snapVal === toInt(record.sensor_snapshot) || snapVal === toInt(record.jumlah_telur)) {
|
||||
candidates.push(kandangId);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.length === 1 ? candidates[0] : null;
|
||||
}
|
||||
|
||||
async function migrateLegacyRiwayatData(kandangMap) {
|
||||
const riwayatRef = admin.database().ref('riwayat');
|
||||
const rootSnap = await riwayatRef.get();
|
||||
if (!rootSnap.exists() || typeof rootSnap.val() !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = rootSnap.val();
|
||||
const recordsRef = admin.database().ref('riwayat/records');
|
||||
const summaryRef = admin.database().ref('riwayat/summary');
|
||||
const kandangNameMap = buildKandangNameMap(kandangMap);
|
||||
|
||||
if (!root.summary && (root.telur_hari_ini != null || root.total_telur != null)) {
|
||||
await summaryRef.update({
|
||||
telur_hari_ini: toInt(root.telur_hari_ini),
|
||||
total_telur: toInt(root.total_telur),
|
||||
last_reset_date: String(root.last_reset_date || formatDateKey(getJakartaNow())),
|
||||
kandang1_hari_ini: toInt(root.kandang1_hari_ini),
|
||||
kandang2_hari_ini: toInt(root.kandang2_hari_ini),
|
||||
updated_at: new Date().toISOString(),
|
||||
migrated_from_legacy: true,
|
||||
});
|
||||
}
|
||||
|
||||
const cleanupUpdates = {};
|
||||
|
||||
for (const [legacyKey, value] of Object.entries(root)) {
|
||||
if (legacyKey === 'records' || legacyKey === 'summary') continue;
|
||||
if (!looksLikeLegacyRecord(value)) {
|
||||
// Hapus field summary lama atau node lama non-record.
|
||||
cleanupUpdates[legacyKey] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const record = { ...value };
|
||||
const kandangIdRaw = String(record.kandang_id || '').toLowerCase();
|
||||
const kandangNamaRaw = String(record.kandang_nama || '').toLowerCase();
|
||||
const isGlobal =
|
||||
kandangIdRaw === 'global' ||
|
||||
kandangIdRaw === 'all' ||
|
||||
kandangIdRaw === 'semua' ||
|
||||
kandangNamaRaw.includes('global') ||
|
||||
kandangNamaRaw.includes('semua') ||
|
||||
kandangNamaRaw.includes('all');
|
||||
|
||||
if (!isGlobal) {
|
||||
await recordsRef.push().set({
|
||||
...record,
|
||||
migrated_from_legacy: true,
|
||||
migrated_at: new Date().toISOString(),
|
||||
});
|
||||
cleanupUpdates[legacyKey] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const inferredKandangId = await inferTargetForGlobalRecord(record, kandangMap);
|
||||
if (inferredKandangId) {
|
||||
await recordsRef.push().set({
|
||||
...record,
|
||||
kandang_id: inferredKandangId,
|
||||
kandang_nama: kandangNameMap[inferredKandangId] || inferredKandangId,
|
||||
migrated_from_global: true,
|
||||
migrated_at: new Date().toISOString(),
|
||||
});
|
||||
cleanupUpdates[legacyKey] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback: jika tidak bisa infer unik, split ke semua kandang aktif.
|
||||
const kandangIds = Object.keys(kandangMap || {});
|
||||
if (kandangIds.length === 0) {
|
||||
cleanupUpdates[legacyKey] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const total = toInt(record.jumlah_telur);
|
||||
const base = Math.floor(total / kandangIds.length);
|
||||
let remainder = total % kandangIds.length;
|
||||
|
||||
for (const kandangId of kandangIds) {
|
||||
const porsi = base + (remainder > 0 ? 1 : 0);
|
||||
if (remainder > 0) remainder -= 1;
|
||||
|
||||
await recordsRef.push().set({
|
||||
...record,
|
||||
kandang_id: kandangId,
|
||||
kandang_nama: kandangNameMap[kandangId] || kandangId,
|
||||
jumlah_telur: porsi,
|
||||
migrated_from_global_split: true,
|
||||
migrated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
cleanupUpdates[legacyKey] = null;
|
||||
}
|
||||
|
||||
if (Object.keys(cleanupUpdates).length > 0) {
|
||||
await riwayatRef.update(cleanupUpdates);
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeRecordsNode() {
|
||||
const nestedRef = admin.database().ref('riwayat/records/records');
|
||||
const nestedSnap = await nestedRef.get();
|
||||
if (!nestedSnap.exists() || typeof nestedSnap.val() !== 'object') return;
|
||||
|
||||
const recordsRef = admin.database().ref('riwayat/records');
|
||||
const nestedData = nestedSnap.val();
|
||||
|
||||
for (const [key, value] of Object.entries(nestedData)) {
|
||||
if (!value || typeof value !== 'object') continue;
|
||||
await recordsRef.child(key).set(value);
|
||||
}
|
||||
|
||||
await nestedRef.remove();
|
||||
console.log('[maintenance] Flattened riwayat/records/records into riwayat/records');
|
||||
}
|
||||
|
||||
async function pruneOldRiwayatRecords(maxRecords) {
|
||||
if (!Number.isFinite(maxRecords) || maxRecords <= 0) return;
|
||||
|
||||
const recordsRef = admin.database().ref('riwayat/records');
|
||||
const snap = await recordsRef.get();
|
||||
if (!snap.exists() || typeof snap.val() !== 'object') return;
|
||||
|
||||
const recordsData = snap.val();
|
||||
const entries = Object.entries(recordsData)
|
||||
.filter(([_, value]) => value && typeof value === 'object')
|
||||
.map(([key, value]) => {
|
||||
const date = toDate(value.tanggal_panen);
|
||||
return { key, time: date ? date.getTime() : 0 };
|
||||
})
|
||||
.sort((a, b) => b.time - a.time);
|
||||
|
||||
if (entries.length <= maxRecords) return;
|
||||
|
||||
const removeUpdates = {};
|
||||
for (const item of entries.slice(maxRecords)) {
|
||||
removeUpdates[item.key] = null;
|
||||
}
|
||||
|
||||
await recordsRef.update(removeUpdates);
|
||||
console.log(`[maintenance] Pruned ${entries.length - maxRecords} old riwayat records (keep ${maxRecords})`);
|
||||
}
|
||||
|
||||
async function pruneOldSchedulerRuns(retainDays) {
|
||||
if (!Number.isFinite(retainDays) || retainDays < 1) return;
|
||||
|
||||
const runsRef = admin.database().ref('scheduler_runs');
|
||||
const snap = await runsRef.get();
|
||||
if (!snap.exists() || typeof snap.val() !== 'object') return;
|
||||
|
||||
const runs = snap.val();
|
||||
const now = getJakartaNow();
|
||||
const cutoff = new Date(now.getTime() - retainDays * 24 * 60 * 60 * 1000);
|
||||
const removeUpdates = {};
|
||||
|
||||
for (const dateKey of Object.keys(runs)) {
|
||||
const date = toDate(`${dateKey}T00:00:00+07:00`);
|
||||
if (!date) continue;
|
||||
if (date < cutoff) {
|
||||
removeUpdates[dateKey] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(removeUpdates).length > 0) {
|
||||
await runsRef.update(removeUpdates);
|
||||
console.log(`[maintenance] Pruned old scheduler_runs older than ${retainDays} days`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCompactMaintenance() {
|
||||
await normalizeRecordsNode();
|
||||
await pruneOldRiwayatRecords(RIWAYAT_MAX_RECORDS);
|
||||
await pruneOldSchedulerRuns(SCHEDULER_RUNS_RETAIN_DAYS);
|
||||
}
|
||||
|
||||
async function runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey) {
|
||||
const jam = String(jadwal.jam || '09:00');
|
||||
const jenisPanen = detectJenisPanen(jadwal);
|
||||
const durasiMs = parseDurasiMs(jadwal.durasi);
|
||||
|
||||
await triggerMotorForDuration(durasiMs, jadwalId);
|
||||
|
||||
const targetKandangIds = resolveTargetKandangIds(jadwal, kandangMap);
|
||||
|
||||
if (targetKandangIds.length === 0) {
|
||||
console.log(`[skip] ${jadwalId}: target kandang tidak ditemukan`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const kandangId of targetKandangIds) {
|
||||
const lockKey = `${jadwalId}_${jenisPanen}_${kandangId}`;
|
||||
const gotLock = await acquireRunLock(todayKey, lockKey);
|
||||
if (!gotLock) {
|
||||
console.log(`[skip] ${jadwalId}/${kandangId}: sudah dieksekusi hari ini (${jenisPanen})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const kandangData = kandangMap[kandangId] || null;
|
||||
const kandangNama = String(
|
||||
(kandangData && kandangData.nama) || jadwal.kandangNama || kandangId || 'Kandang',
|
||||
);
|
||||
const infraPath = resolveInfraPath(kandangId, kandangData);
|
||||
const sensorValue = Number(dataSensor[infraPath] || 0);
|
||||
|
||||
if (jenisPanen === 'pagi') {
|
||||
await admin.database().ref(`panen_snapshot/${todayKey}/pagi`).update({
|
||||
[kandangId]: sensorValue,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await writeRiwayat({
|
||||
kandangId,
|
||||
kandangNama,
|
||||
jumlahTelur: sensorValue,
|
||||
jam,
|
||||
jenisPanen: 'pagi',
|
||||
sensorSnapshot: sensorValue,
|
||||
panenSebelumnya: null,
|
||||
catatan: 'Auto-capture PAGI dari Railway scheduler',
|
||||
dateKey: todayKey,
|
||||
});
|
||||
|
||||
console.log(`[ok] Pagi ${kandangNama}: ${sensorValue} telur (${infraPath})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pagiSnapRef = admin.database().ref(`panen_snapshot/${todayKey}/pagi/${kandangId}`);
|
||||
const pagiSnap = await pagiSnapRef.get();
|
||||
const nilaiPagi = pagiSnap.exists() ? Number(pagiSnap.val() || 0) : 0;
|
||||
const delta = Math.max(sensorValue - nilaiPagi, 0);
|
||||
|
||||
await admin.database().ref(`panen_snapshot/${todayKey}/sore`).update({
|
||||
[kandangId]: sensorValue,
|
||||
[`delta_${kandangId}`]: delta,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await writeRiwayat({
|
||||
kandangId,
|
||||
kandangNama,
|
||||
jumlahTelur: delta,
|
||||
jam,
|
||||
jenisPanen: 'sore',
|
||||
sensorSnapshot: sensorValue,
|
||||
panenSebelumnya: nilaiPagi,
|
||||
catatan: `Auto-capture SORE dari Railway scheduler (delta: ${sensorValue} - ${nilaiPagi})`,
|
||||
dateKey: todayKey,
|
||||
});
|
||||
|
||||
console.log(`[ok] Sore ${kandangNama}: ${delta} telur (${sensorValue}-${nilaiPagi})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTick() {
|
||||
const now = getJakartaNow();
|
||||
const nowHHMM = formatHHMM(now);
|
||||
const todayKey = formatDateKey(now);
|
||||
|
||||
const db = admin.database();
|
||||
const [jadwalSnap, dataSnap, kandangSnap] = await Promise.all([
|
||||
db.ref('kontrol/penjadwalan').get(),
|
||||
db.ref('data').get(),
|
||||
db.ref('kontrol/kandang').get(),
|
||||
]);
|
||||
|
||||
if (!jadwalSnap.exists()) {
|
||||
console.log('[tick] Tidak ada data kontrol/penjadwalan');
|
||||
return;
|
||||
}
|
||||
|
||||
const jadwalMap = jadwalSnap.val() || {};
|
||||
const dataSensor = dataSnap.exists() ? dataSnap.val() : {};
|
||||
const kandangMap = kandangSnap.exists() ? kandangSnap.val() : {};
|
||||
|
||||
const aktifSekarang = Object.entries(jadwalMap).filter(([_, jadwal]) => {
|
||||
if (!jadwal || jadwal.aktif !== true) return false;
|
||||
const jam = String(jadwal.jam || '').slice(0, 5);
|
||||
return jam === nowHHMM;
|
||||
});
|
||||
|
||||
if (aktifSekarang.length === 0) {
|
||||
console.log(`[tick] ${nowHHMM} tidak ada jadwal aktif`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[tick] ${nowHHMM} eksekusi ${aktifSekarang.length} jadwal`);
|
||||
let hasEveningRun = false;
|
||||
|
||||
for (const [jadwalId, jadwal] of aktifSekarang) {
|
||||
try {
|
||||
await runForSchedule(jadwalId, jadwal, dataSensor, kandangMap, todayKey);
|
||||
if (detectJenisPanen(jadwal) === 'sore') {
|
||||
hasEveningRun = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[error] Jadwal ${jadwalId} gagal:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasEveningRun) {
|
||||
await resetSensorAfterEvening(todayKey);
|
||||
}
|
||||
}
|
||||
|
||||
function startHealthServer() {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, service: 'telurku-railway-scheduler' }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('TelurKu Railway Scheduler is running');
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[init] Health server listening on :${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
initFirebase();
|
||||
startHealthServer();
|
||||
|
||||
const kandangSnap = await admin.database().ref('kontrol/kandang').get();
|
||||
const kandangMap = kandangSnap.exists() ? kandangSnap.val() : {};
|
||||
|
||||
const runMaintenance = await shouldRunMaintenance();
|
||||
if (runMaintenance) {
|
||||
try {
|
||||
await migrateLegacyRiwayatData(kandangMap);
|
||||
console.log('[init] Legacy riwayat migration completed');
|
||||
} catch (e) {
|
||||
console.error('[error] Legacy riwayat migration failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (COMPACT_MODE) {
|
||||
try {
|
||||
await runCompactMaintenance();
|
||||
console.log('[init] Compact maintenance completed');
|
||||
} catch (e) {
|
||||
console.error('[error] Compact maintenance failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!SCHEDULER_ENABLED) {
|
||||
console.log('[init] Scheduler disabled by SCHEDULER_ENABLED=false');
|
||||
return;
|
||||
}
|
||||
|
||||
// Run once at startup for visibility, then every minute.
|
||||
try {
|
||||
await runTick();
|
||||
} catch (e) {
|
||||
console.error('[error] Initial tick failed:', e.message);
|
||||
}
|
||||
|
||||
cron.schedule(
|
||||
'* * * * *',
|
||||
async () => {
|
||||
try {
|
||||
await runTick();
|
||||
} catch (e) {
|
||||
console.error('[error] Tick failed:', e.message);
|
||||
}
|
||||
},
|
||||
{ timezone: TZ },
|
||||
);
|
||||
|
||||
console.log(`[init] Scheduler active (timezone=${TZ})`);
|
||||
}
|
||||
|
||||
bootstrap().catch((e) => {
|
||||
console.error('[fatal] bootstrap failed:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in New Issue