From 9c988e0f9ce379aaf6a65f4040a48d5e3ea2de39 Mon Sep 17 00:00:00 2001 From: Wizznu Date: Thu, 26 Mar 2026 23:03:58 +0700 Subject: [PATCH] Update Flutter app data flow and add Railway scheduler service --- .gitignore | 4 + lib/providers/kandang_provider.dart | 142 +++-- lib/providers/panen_provider.dart | 190 ++++--- lib/providers/penjadwalan_provider.dart | 96 +++- lib/providers/riwayat_provider.dart | 24 +- lib/screens/dashboard_page.dart | 30 +- lib/screens/home_page.dart | 34 ++ lib/screens/login_page.dart | 15 +- lib/screens/panen_debug_screen.dart | 2 +- lib/screens/riwayat_page.dart | 41 ++ lib/screens/signup_page.dart | 15 +- railway-scheduler/.dockerignore | 6 + railway-scheduler/.env.example | 17 + railway-scheduler/Dockerfile | 13 + railway-scheduler/README.md | 89 +++ railway-scheduler/package.json | 18 + railway-scheduler/railway.json | 10 + railway-scheduler/worker.js | 728 ++++++++++++++++++++++++ 18 files changed, 1317 insertions(+), 157 deletions(-) create mode 100644 railway-scheduler/.dockerignore create mode 100644 railway-scheduler/.env.example create mode 100644 railway-scheduler/Dockerfile create mode 100644 railway-scheduler/README.md create mode 100644 railway-scheduler/package.json create mode 100644 railway-scheduler/railway.json create mode 100644 railway-scheduler/worker.js diff --git a/.gitignore b/.gitignore index 3820a95..74c9567 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/lib/providers/kandang_provider.dart b/lib/providers/kandang_provider.dart index 29f1287..cee6caa 100644 --- a/lib/providers/kandang_provider.dart +++ b/lib/providers/kandang_provider.dart @@ -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 _kandangs = []; bool _isLoading = true; @@ -24,33 +25,62 @@ class KandangProvider extends ChangeNotifier { /// Initialize provider dengan user ID dan load data dari Firebase Future 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; - _kandangs = data.entries.map((entry) { - final kandangData = entry.value as Map; - return Kandang( - id: entry.key, - nama: kandangData['nama'] ?? '', - jumlahAyam: kandangData['jumlahAyam'] ?? 0, - dibuat: kandangData['dibuat'] != null - ? DateTime.parse(kandangData['dibuat']) - : DateTime.now(), - infraPath: kandangData['infraPath'], - ); - }).toList(); - } else { - _kandangs = []; - } - _isLoading = false; - notifyListeners(); - }); + _subscription = _kandangRef.onValue.listen( + (event) { + try { + if (event.snapshot.exists && event.snapshot.value is Map) { + final data = + Map.from(event.snapshot.value as Map); + _kandangs = + data.entries.where((entry) => entry.value is Map).map((entry) { + final kandangData = + Map.from(entry.value as Map); + return Kandang( + id: entry.key.toString(), + nama: kandangData['nama'] ?? '', + jumlahAyam: kandangData['jumlahAyam'] ?? 0, + dibuat: kandangData['dibuat'] != null + ? DateTime.parse(kandangData['dibuat']) + : DateTime.now(), + infraPath: kandangData['infraPath'], + ); + }).toList(); + } else { + _kandangs = []; + } + _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 _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 clearData() async { await _subscription?.cancel(); @@ -138,26 +201,23 @@ class KandangProvider extends ChangeNotifier { _sensorSubscription?.cancel(); // Listen ke 'data' folder untuk mendapatkan infra1 dan infra2 - _sensorSubscription = _database - .ref('data') - .onValue - .listen( - (event) { - if (event.snapshot.exists) { - final data = Map.from( - event.snapshot.value as Map, - ); - _infra1Value = data['infra1'] ?? 0; - _infra2Value = data['infra2'] ?? 0; + _sensorSubscription = _database.ref('data').onValue.listen( + (event) { + if (event.snapshot.exists) { + final data = Map.from( + event.snapshot.value as Map, + ); + _infra1Value = data['infra1'] ?? 0; + _infra2Value = data['infra2'] ?? 0; - // Trigger notifyListeners() agar UI terupdate - notifyListeners(); - } - }, - onError: (error) { - print('Error listening to sensors: $error'); - }, - ); + // Trigger notifyListeners() agar UI terupdate + notifyListeners(); + } + }, + onError: (error) { + print('Error listening to sensors: $error'); + }, + ); } catch (e) { print('Error setting up sensor listener: $e'); } diff --git a/lib/providers/panen_provider.dart b/lib/providers/panen_provider.dart index 73feff4..3ecf9cc 100644 --- a/lib/providers/panen_provider.dart +++ b/lib/providers/panen_provider.dart @@ -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 _snapshotPagiHariIni = {}; // kandangId -> nilaiPagi - Map _snapshotSoreHariIni = {}; // kandangId -> nilaiSore + final Map _snapshotPagiHariIni = {}; // kandangId -> nilaiPagi + final Map _snapshotSoreHariIni = {}; // kandangId -> nilaiSore - final List _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 _panens = []; List 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 - await _savePanenToFirebase(newPanen); + // 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 - await _savePanenToFirebase(newPanen); + // 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 _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 _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.from( + (current as Map?) ?? {}, + ); + + 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 loadTodaySnapshots() async { try { @@ -457,7 +433,7 @@ class PanenProvider extends ChangeNotifier { /// Restore historical panen dari Firebase saat app start Future 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.from(value as Map); + if (value is! Map) return; + final panenData = Map.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.from( + legacySnapshot.value as Map, + ); + _panens.clear(); + + legacyData.forEach((key, value) { + if (value is! Map) return; + final panenData = Map.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)); diff --git a/lib/providers/penjadwalan_provider.dart b/lib/providers/penjadwalan_provider.dart index 7163275..ee208a6 100644 --- a/lib/providers/penjadwalan_provider.dart +++ b/lib/providers/penjadwalan_provider.dart @@ -18,37 +18,91 @@ class PenjadwalanProvider extends ChangeNotifier { Future 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; - _penjadwalans = data.entries.map((entry) { - return Penjadwalan.fromJson({ - 'id': entry.key, - ...entry.value as Map, - }); - }).toList(); + _subscription = _penjadwalanRef.onValue.listen( + (event) { + try { + if (event.snapshot.exists && event.snapshot.value is Map) { + final data = + Map.from(event.snapshot.value as Map); + _penjadwalans = + data.entries.where((entry) => entry.value is Map).map((entry) { + return Penjadwalan.fromJson({ + 'id': entry.key, + ...Map.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; - return numA.compareTo(numB); - }); - } else { - _penjadwalans = []; - } - _isLoading = false; - notifyListeners(); - }); + // 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; + return numA.compareTo(numB); + }); + } else { + _penjadwalans = []; + } + _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 _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'; diff --git a/lib/providers/riwayat_provider.dart b/lib/providers/riwayat_provider.dart index 3277105..d3a8546 100644 --- a/lib/providers/riwayat_provider.dart +++ b/lib/providers/riwayat_provider.dart @@ -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; _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; _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) { diff --git a/lib/screens/dashboard_page.dart b/lib/screens/dashboard_page.dart index 30bd2e1..c37fe5b 100644 --- a/lib/screens/dashboard_page.dart +++ b/lib/screens/dashboard_page.dart @@ -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(); + final telurProvider = context.watch(); 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, + ), + ), + ), + ], + ), ], ), ), diff --git a/lib/screens/home_page.dart b/lib/screens/home_page.dart index aa60e5a..1dcf94e 100644 --- a/lib/screens/home_page.dart +++ b/lib/screens/home_page.dart @@ -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 { int _selectedIndex = 0; + String? _initializedForUserId; final List _pages = [ const DashboardPage(), @@ -24,6 +26,38 @@ class _HomePageState extends State { const RiwayatPage(), ]; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + final authProvider = context.read(); + 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().initializeWithUser(userId); + if (!mounted) return; + await context.read().initializeWithUser(userId); + if (!mounted) return; + final panenProvider = context.read(); + 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(); diff --git a/lib/screens/login_page.dart b/lib/screens/login_page.dart index 0f1844a..c1d4823 100644 --- a/lib/screens/login_page.dart +++ b/lib/screens/login_page.dart @@ -54,8 +54,19 @@ class _LoginPageState extends State { final penjadwalanProvider = context.read(); final kandangProvider = context.read(); - await penjadwalanProvider.initializeWithUser(userId); - await kandangProvider.initializeWithUser(userId); + 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; diff --git a/lib/screens/panen_debug_screen.dart b/lib/screens/panen_debug_screen.dart index 9b8a7e0..0fad4f3 100644 --- a/lib/screens/panen_debug_screen.dart +++ b/lib/screens/panen_debug_screen.dart @@ -468,7 +468,7 @@ class _PanenDebugScreenState extends State { ], ), ); - }).toList(), + }), ], ), ); diff --git a/lib/screens/riwayat_page.dart b/lib/screens/riwayat_page.dart index fc82f3d..2dc9b9e 100644 --- a/lib/screens/riwayat_page.dart +++ b/lib/screens/riwayat_page.dart @@ -325,6 +325,47 @@ class _RiwayatPageState extends State { ), ], ), + 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, + ), + ), + ), + ], + ), ], ), ), diff --git a/lib/screens/signup_page.dart b/lib/screens/signup_page.dart index 0399bc6..503e522 100644 --- a/lib/screens/signup_page.dart +++ b/lib/screens/signup_page.dart @@ -89,8 +89,19 @@ class _SignupPageState extends State { final penjadwalanProvider = context.read(); final kandangProvider = context.read(); - await penjadwalanProvider.initializeWithUser(userId); - await kandangProvider.initializeWithUser(userId); + 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( diff --git a/railway-scheduler/.dockerignore b/railway-scheduler/.dockerignore new file mode 100644 index 0000000..7ca8e7b --- /dev/null +++ b/railway-scheduler/.dockerignore @@ -0,0 +1,6 @@ +node_modules +npm-debug.log +.env +.git +.gitignore +README.md diff --git a/railway-scheduler/.env.example b/railway-scheduler/.env.example new file mode 100644 index 0000000..5f680d5 --- /dev/null +++ b/railway-scheduler/.env.example @@ -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 diff --git a/railway-scheduler/Dockerfile b/railway-scheduler/Dockerfile new file mode 100644 index 0000000..fdaf077 --- /dev/null +++ b/railway-scheduler/Dockerfile @@ -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"] diff --git a/railway-scheduler/README.md b/railway-scheduler/README.md new file mode 100644 index 0000000..e74662d --- /dev/null +++ b/railway-scheduler/README.md @@ -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=` (opsi paling mudah) +- `FIREBASE_PROJECT_ID=telurku-fa78c` +- `FIREBASE_CLIENT_EMAIL=` +- `FIREBASE_PRIVATE_KEY=` +- `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. diff --git a/railway-scheduler/package.json b/railway-scheduler/package.json new file mode 100644 index 0000000..1b6da21 --- /dev/null +++ b/railway-scheduler/package.json @@ -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" + } +} diff --git a/railway-scheduler/railway.json b/railway-scheduler/railway.json new file mode 100644 index 0000000..7644270 --- /dev/null +++ b/railway-scheduler/railway.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "NIXPACKS" + }, + "deploy": { + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10 + } +} diff --git a/railway-scheduler/worker.js b/railway-scheduler/worker.js new file mode 100644 index 0000000..ae3a5b4 --- /dev/null +++ b/railway-scheduler/worker.js @@ -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); +});