From 4062db77e454ea608e38469fca7721b0210eafd1 Mon Sep 17 00:00:00 2001 From: dwindown Date: Mon, 6 Apr 2026 07:12:05 +0700 Subject: [PATCH] fix(offline-first): harden local schedule cache lookup and key normalization --- lib/data/services/sync_service.dart | 112 +++++++++++++++++++++++++--- pubspec.yaml | 2 +- test/widget_test.dart | 61 +++++++++++++++ 3 files changed, 163 insertions(+), 12 deletions(-) diff --git a/lib/data/services/sync_service.dart b/lib/data/services/sync_service.dart index 281d3a1..17975f8 100644 --- a/lib/data/services/sync_service.dart +++ b/lib/data/services/sync_service.dart @@ -26,6 +26,26 @@ class ScheduleCacheStatus { bool get hasData => startDate != null && endDate != null && cachedDays > 0; bool get isExpired => hasData && daysUntilRefresh < 0; bool get needsRefreshSoon => hasData && daysUntilRefresh <= 3; + static final RegExp _flexDateKeyRegex = RegExp(r'^(\d{4})-(\d{1,2})-(\d{1,2})$'); + + static DateTime? _parseScheduleDate(String rawValue) { + final trimmed = rawValue.trim(); + if (trimmed.isEmpty) return null; + + final parsedIso = DateTime.tryParse(trimmed); + if (parsedIso != null) { + return DateTime(parsedIso.year, parsedIso.month, parsedIso.day); + } + + final match = _flexDateKeyRegex.firstMatch(trimmed); + if (match == null) return null; + final year = int.tryParse(match.group(1) ?? ''); + final month = int.tryParse(match.group(2) ?? ''); + final day = int.tryParse(match.group(3) ?? ''); + if (year == null || month == null || day == null) return null; + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + return DateTime(year, month, day); + } static ScheduleCacheStatus fromSchedules( Iterable schedules, @@ -33,10 +53,10 @@ class ScheduleCacheStatus { ) { DateTime? startDate; DateTime? endDate; - var cachedDays = 0; + final uniqueDayKeys = {}; for (final schedule in schedules) { - final parsedDate = DateTime.tryParse(schedule.date); + final parsedDate = _parseScheduleDate(schedule.date); if (parsedDate == null) continue; final normalized = DateTime( @@ -45,7 +65,9 @@ class ScheduleCacheStatus { parsedDate.day, ); - cachedDays++; + final dayKey = + '${normalized.year}-${normalized.month.toString().padLeft(2, '0')}-${normalized.day.toString().padLeft(2, '0')}'; + uniqueDayKeys.add(dayKey); startDate = startDate == null || normalized.isBefore(startDate) ? normalized : startDate; @@ -54,6 +76,7 @@ class ScheduleCacheStatus { : endDate; } + final cachedDays = uniqueDayKeys.length; if (startDate == null || endDate == null || cachedDays == 0) { return const ScheduleCacheStatus.empty(); } @@ -78,6 +101,54 @@ class SyncService { SyncService._(); static final SyncService instance = SyncService._(); static const Duration _autoRefreshCooldown = Duration(hours: 12); + static final DateFormat _dateKeyFormat = DateFormat('yyyy-MM-dd'); + static final RegExp _flexDateKeyRegex = RegExp(r'^(\d{4})-(\d{1,2})-(\d{1,2})$'); + + DateTime _dateOnly(DateTime value) => DateTime(value.year, value.month, value.day); + + String _canonicalDateKey(DateTime value) => _dateKeyFormat.format(_dateOnly(value)); + + DateTime? _parseScheduleDate(String rawValue) { + final trimmed = rawValue.trim(); + if (trimmed.isEmpty) return null; + + final parsedIso = DateTime.tryParse(trimmed); + if (parsedIso != null) return _dateOnly(parsedIso); + + final match = _flexDateKeyRegex.firstMatch(trimmed); + if (match == null) return null; + final year = int.tryParse(match.group(1) ?? ''); + final month = int.tryParse(match.group(2) ?? ''); + final day = int.tryParse(match.group(3) ?? ''); + if (year == null || month == null || day == null) return null; + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + return DateTime(year, month, day); + } + + String? _canonicalDateKeyFromRaw(String rawValue) { + final parsed = _parseScheduleDate(rawValue); + if (parsed == null) return null; + return _canonicalDateKey(parsed); + } + + List _lookupKeysForDate(DateTime value) { + final date = _dateOnly(value); + final canonical = _canonicalDateKey(date); + final loose = '${date.year}-${date.month}-${date.day}'; + final yearMonthLooseDayPadded = + '${date.year}-${date.month}-${date.day.toString().padLeft(2, '0')}'; + final yearMonthPaddedDayLoose = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day}'; + return { + canonical, + loose, + yearMonthLooseDayPadded, + yearMonthPaddedDayLoose, + }.toList(); + } + + bool _isSameDate(DateTime left, DateTime right) => + left.year == right.year && left.month == right.month && left.day == right.day; static Set rollingWindowMonths(DateTime referenceDate) { final currentMonth = DateTime(referenceDate.year, referenceDate.month, 1); @@ -96,7 +167,7 @@ class SyncService { final staleKeys = []; for (final key in keys) { - final parsed = DateTime.tryParse(key); + final parsed = instance._parseScheduleDate(key); if (parsed == null) { staleKeys.add(key); continue; @@ -165,10 +236,11 @@ class SyncService { hasCurrentMonth = true; for (final entry in currentData.entries) { final jadwal = entry.value; + final canonicalKey = _canonicalDateKeyFromRaw(entry.key) ?? entry.key; scheduleBox.put( - entry.key, + canonicalKey, DailyPrayerSchedule( - date: entry.key, + date: canonicalKey, imsak: jadwal['imsak'] ?? '00:00', subuh: jadwal['subuh'] ?? '00:00', terbit: jadwal['terbit'] ?? '00:00', @@ -179,6 +251,9 @@ class SyncService { isya: jadwal['isya'] ?? '00:00', ), ); + if (canonicalKey != entry.key && scheduleBox.containsKey(entry.key)) { + await scheduleBox.delete(entry.key); + } } success = true; } @@ -189,10 +264,11 @@ class SyncService { hasNextMonth = true; for (final entry in nextData.entries) { final jadwal = entry.value; + final canonicalKey = _canonicalDateKeyFromRaw(entry.key) ?? entry.key; scheduleBox.put( - entry.key, + canonicalKey, DailyPrayerSchedule( - date: entry.key, + date: canonicalKey, imsak: jadwal['imsak'] ?? '00:00', subuh: jadwal['subuh'] ?? '00:00', terbit: jadwal['terbit'] ?? '00:00', @@ -203,6 +279,9 @@ class SyncService { isya: jadwal['isya'] ?? '00:00', ), ); + if (canonicalKey != entry.key && scheduleBox.containsKey(entry.key)) { + await scheduleBox.delete(entry.key); + } } } @@ -253,9 +332,20 @@ class SyncService { DailyPrayerSchedule? getTodaySchedule([DateTime? targetDate]) { final scheduleBox = Hive.box(HiveBoxes.prayerSchedule); - final dateToFetch = targetDate ?? DateTime.now(); - final dateStr = DateFormat('yyyy-MM-dd').format(dateToFetch); - return scheduleBox.get(dateStr); + final dateToFetch = _dateOnly(targetDate ?? DateTime.now()); + for (final key in _lookupKeysForDate(dateToFetch)) { + final direct = scheduleBox.get(key); + if (direct != null) return direct; + } + + // Legacy fallback: match by stored row date if cache keys were historical/mixed. + for (final schedule in scheduleBox.values) { + final parsed = _parseScheduleDate(schedule.date); + if (parsed != null && _isSameDate(parsed, dateToFetch)) { + return schedule; + } + } + return null; } ScheduleCacheStatus getCacheStatus([DateTime? referenceDate]) { diff --git a/pubspec.yaml b/pubspec.yaml index 2b14b07..bff7e8c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: jamshalat_masjid_screen description: Smart Digital Prayer Clock for Android TV Box publish_to: 'none' -version: 1.0.11+12 +version: 1.0.12+13 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/test/widget_test.dart b/test/widget_test.dart index 462b7fb..ba4478e 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -117,6 +117,67 @@ void main() { expect(staleKeys, isNot(contains('2026-03-01'))); expect(staleKeys, isNot(contains('2026-04-30'))); }); + + test('normalizes mixed date formats and de-duplicates same day rows', () { + final status = ScheduleCacheStatus.fromSchedules( + [ + DailyPrayerSchedule( + date: '2026-4-6', + imsak: '04:20', + subuh: '04:30', + terbit: '05:45', + dhuha: '06:10', + dzuhur: '11:55', + ashar: '15:10', + maghrib: '17:58', + isya: '19:05', + ), + DailyPrayerSchedule( + date: '2026-04-06', + imsak: '04:20', + subuh: '04:30', + terbit: '05:45', + dhuha: '06:10', + dzuhur: '11:55', + ashar: '15:10', + maghrib: '17:58', + isya: '19:05', + ), + DailyPrayerSchedule( + date: '2026-04-07', + imsak: '04:21', + subuh: '04:31', + terbit: '05:46', + dhuha: '06:11', + dzuhur: '11:56', + ashar: '15:11', + maghrib: '17:59', + isya: '19:06', + ), + ], + DateTime(2026, 4, 6), + ); + + expect(status.startDate, DateTime(2026, 4, 6)); + expect(status.endDate, DateTime(2026, 4, 7)); + expect(status.cachedDays, 2); + expect(status.daysUntilRefresh, 1); + }); + + test('stale key helper accepts non-padded legacy keys', () { + final staleKeys = SyncService.staleScheduleKeys( + [ + '2026-4-6', + '2026-5-1', + '2026-6-1', + ], + DateTime(2026, 4, 6), + ); + + expect(staleKeys, contains('2026-6-1')); + expect(staleKeys, isNot(contains('2026-4-6'))); + expect(staleKeys, isNot(contains('2026-5-1'))); + }); }); group('AppUpdateInfo parsing', () {