fix(offline-first): harden local schedule cache lookup and key normalization

This commit is contained in:
dwindown
2026-04-06 07:12:05 +07:00
parent 410e84fbad
commit 4062db77e4
3 changed files with 163 additions and 12 deletions

View File

@@ -26,6 +26,26 @@ class ScheduleCacheStatus {
bool get hasData => startDate != null && endDate != null && cachedDays > 0; bool get hasData => startDate != null && endDate != null && cachedDays > 0;
bool get isExpired => hasData && daysUntilRefresh < 0; bool get isExpired => hasData && daysUntilRefresh < 0;
bool get needsRefreshSoon => hasData && daysUntilRefresh <= 3; 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( static ScheduleCacheStatus fromSchedules(
Iterable<DailyPrayerSchedule> schedules, Iterable<DailyPrayerSchedule> schedules,
@@ -33,10 +53,10 @@ class ScheduleCacheStatus {
) { ) {
DateTime? startDate; DateTime? startDate;
DateTime? endDate; DateTime? endDate;
var cachedDays = 0; final uniqueDayKeys = <String>{};
for (final schedule in schedules) { for (final schedule in schedules) {
final parsedDate = DateTime.tryParse(schedule.date); final parsedDate = _parseScheduleDate(schedule.date);
if (parsedDate == null) continue; if (parsedDate == null) continue;
final normalized = DateTime( final normalized = DateTime(
@@ -45,7 +65,9 @@ class ScheduleCacheStatus {
parsedDate.day, 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) startDate = startDate == null || normalized.isBefore(startDate)
? normalized ? normalized
: startDate; : startDate;
@@ -54,6 +76,7 @@ class ScheduleCacheStatus {
: endDate; : endDate;
} }
final cachedDays = uniqueDayKeys.length;
if (startDate == null || endDate == null || cachedDays == 0) { if (startDate == null || endDate == null || cachedDays == 0) {
return const ScheduleCacheStatus.empty(); return const ScheduleCacheStatus.empty();
} }
@@ -78,6 +101,54 @@ class SyncService {
SyncService._(); SyncService._();
static final SyncService instance = SyncService._(); static final SyncService instance = SyncService._();
static const Duration _autoRefreshCooldown = Duration(hours: 12); 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<String> _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 <String>{
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<String> rollingWindowMonths(DateTime referenceDate) { static Set<String> rollingWindowMonths(DateTime referenceDate) {
final currentMonth = DateTime(referenceDate.year, referenceDate.month, 1); final currentMonth = DateTime(referenceDate.year, referenceDate.month, 1);
@@ -96,7 +167,7 @@ class SyncService {
final staleKeys = <String>[]; final staleKeys = <String>[];
for (final key in keys) { for (final key in keys) {
final parsed = DateTime.tryParse(key); final parsed = instance._parseScheduleDate(key);
if (parsed == null) { if (parsed == null) {
staleKeys.add(key); staleKeys.add(key);
continue; continue;
@@ -165,10 +236,11 @@ class SyncService {
hasCurrentMonth = true; hasCurrentMonth = true;
for (final entry in currentData.entries) { for (final entry in currentData.entries) {
final jadwal = entry.value; final jadwal = entry.value;
final canonicalKey = _canonicalDateKeyFromRaw(entry.key) ?? entry.key;
scheduleBox.put( scheduleBox.put(
entry.key, canonicalKey,
DailyPrayerSchedule( DailyPrayerSchedule(
date: entry.key, date: canonicalKey,
imsak: jadwal['imsak'] ?? '00:00', imsak: jadwal['imsak'] ?? '00:00',
subuh: jadwal['subuh'] ?? '00:00', subuh: jadwal['subuh'] ?? '00:00',
terbit: jadwal['terbit'] ?? '00:00', terbit: jadwal['terbit'] ?? '00:00',
@@ -179,6 +251,9 @@ class SyncService {
isya: jadwal['isya'] ?? '00:00', isya: jadwal['isya'] ?? '00:00',
), ),
); );
if (canonicalKey != entry.key && scheduleBox.containsKey(entry.key)) {
await scheduleBox.delete(entry.key);
}
} }
success = true; success = true;
} }
@@ -189,10 +264,11 @@ class SyncService {
hasNextMonth = true; hasNextMonth = true;
for (final entry in nextData.entries) { for (final entry in nextData.entries) {
final jadwal = entry.value; final jadwal = entry.value;
final canonicalKey = _canonicalDateKeyFromRaw(entry.key) ?? entry.key;
scheduleBox.put( scheduleBox.put(
entry.key, canonicalKey,
DailyPrayerSchedule( DailyPrayerSchedule(
date: entry.key, date: canonicalKey,
imsak: jadwal['imsak'] ?? '00:00', imsak: jadwal['imsak'] ?? '00:00',
subuh: jadwal['subuh'] ?? '00:00', subuh: jadwal['subuh'] ?? '00:00',
terbit: jadwal['terbit'] ?? '00:00', terbit: jadwal['terbit'] ?? '00:00',
@@ -203,6 +279,9 @@ class SyncService {
isya: jadwal['isya'] ?? '00:00', 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]) { DailyPrayerSchedule? getTodaySchedule([DateTime? targetDate]) {
final scheduleBox = final scheduleBox =
Hive.box<DailyPrayerSchedule>(HiveBoxes.prayerSchedule); Hive.box<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
final dateToFetch = targetDate ?? DateTime.now(); final dateToFetch = _dateOnly(targetDate ?? DateTime.now());
final dateStr = DateFormat('yyyy-MM-dd').format(dateToFetch); for (final key in _lookupKeysForDate(dateToFetch)) {
return scheduleBox.get(dateStr); 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]) { ScheduleCacheStatus getCacheStatus([DateTime? referenceDate]) {

View File

@@ -1,7 +1,7 @@
name: jamshalat_masjid_screen name: jamshalat_masjid_screen
description: Smart Digital Prayer Clock for Android TV Box description: Smart Digital Prayer Clock for Android TV Box
publish_to: 'none' publish_to: 'none'
version: 1.0.11+12 version: 1.0.12+13
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'

View File

@@ -117,6 +117,67 @@ void main() {
expect(staleKeys, isNot(contains('2026-03-01'))); expect(staleKeys, isNot(contains('2026-03-01')));
expect(staleKeys, isNot(contains('2026-04-30'))); 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', () { group('AppUpdateInfo parsing', () {