Update Flutter app data flow and add Railway scheduler service

This commit is contained in:
Wizznu 2026-03-26 23:03:58 +07:00
parent 7fe9adb1d1
commit 9c988e0f9c
18 changed files with 1317 additions and 157 deletions

4
.gitignore vendored
View File

@ -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

View File

@ -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,33 +25,62 @@ 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>;
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<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.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<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,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<dynamic, dynamic>.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<dynamic, dynamic>.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');
}

View File

@ -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
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<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));

View File

@ -18,37 +18,91 @@ 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) {
return Penjadwalan.fromJson({
'id': entry.key,
...entry.value as Map<dynamic, dynamic>,
});
}).toList();
_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,
...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;
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<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';

View File

@ -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) {

View File

@ -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,
),
),
),
],
),
],
),
),

View File

@ -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>();

View File

@ -54,8 +54,19 @@ class _LoginPageState extends State<LoginPage> {
final penjadwalanProvider = context.read<PenjadwalanProvider>();
final kandangProvider = context.read<KandangProvider>();
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;

View File

@ -468,7 +468,7 @@ class _PanenDebugScreenState extends State<PanenDebugScreen> {
],
),
);
}).toList(),
}),
],
),
);

View File

@ -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,
),
),
),
],
),
],
),
),

View File

@ -89,8 +89,19 @@ class _SignupPageState extends State<SignupPage> {
final penjadwalanProvider = context.read<PenjadwalanProvider>();
final kandangProvider = context.read<KandangProvider>();
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(

View File

@ -0,0 +1,6 @@
node_modules
npm-debug.log
.env
.git
.gitignore
README.md

View File

@ -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

View File

@ -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"]

View File

@ -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.

View File

@ -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"
}
}

View File

@ -0,0 +1,10 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "NIXPACKS"
},
"deploy": {
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10
}
}

728
railway-scheduler/worker.js Normal file
View File

@ -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);
});