diff --git a/lib/notify/fired_keys.dart b/lib/notify/fired_keys.dart index ff5b38599..8d3513742 100644 --- a/lib/notify/fired_keys.dart +++ b/lib/notify/fired_keys.dart @@ -137,7 +137,7 @@ class FiredKeyStore { // parsed at cold start and on every reload() above. await _prunePrefs(p); } - await LocalDb.pruneNotifFired(_cutoffLabel()); + await LocalDb.pruneNotifFired(cutoffLabel()); } catch (_) {/* bookkeeping is best-effort — the claim stands */} return true; } @@ -176,8 +176,12 @@ class FiredKeyStore { Future recordFired(String dedupeKey) async => claim(dedupeKey); /// The oldest day label a dated claim may carry and still be retained. - static String _cutoffLabel() => - dayLabelOf(DateTime.now().subtract(const Duration(days: retentionDays))); + /// + /// Calendar days, not `subtract(Duration(days: 14))`: that is 14 x 24 h, and + /// across a DST change one of those days is 23 or 25 h long, so in the hour + /// either side of midnight the cutoff landed one label early or late. + static String cutoffLabel([DateTime? now]) => + dayLabelBefore(todayLabel(now), retentionDays)!; static Future _prefs() async { try { @@ -215,7 +219,7 @@ class FiredKeyStore { /// prunes a little late. Keys without a leading YYYY-MM-DD are left untouched. static Future _prunePrefs(SharedPreferences p) async { try { - final cutoff = _cutoffLabel(); + final cutoff = cutoffLabel(); for (final k in p.getKeys()) { if (!k.startsWith(_prefix)) continue; final d = leadingDate(k.substring(_prefix.length)); diff --git a/lib/notify/notification_ids.dart b/lib/notify/notification_ids.dart index 63f99ec9b..879470dc5 100644 --- a/lib/notify/notification_ids.dart +++ b/lib/notify/notification_ids.dart @@ -37,7 +37,6 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../data/day_label.dart'; import '../data/db.dart'; import 'fired_keys.dart'; import 'notification_event.dart'; @@ -204,8 +203,7 @@ class NotificationIds { /// directions. Cheap and rare (only after a FRESH allocation). Future _prune(SharedPreferences p, {String? keep}) async { try { - final cutoff = - dayLabelOf(DateTime.now().subtract(const Duration(days: retentionDays))); + final cutoff = FiredKeyStore.cutoffLabel(); for (final k in p.getKeys().toList(growable: false)) { if (!k.startsWith(_kSlot) || k == keep) continue; final rest = k.substring(_kSlot.length); // ":" diff --git a/test/notify_retention_dst_test.dart b/test/notify_retention_dst_test.dart new file mode 100644 index 000000000..9c119ea8a --- /dev/null +++ b/test/notify_retention_dst_test.dart @@ -0,0 +1,67 @@ +// The fired-key / notification-slot retention cutoff is "14 calendar days +// before today". It used to be `now.subtract(Duration(days: 14))`, which is +// 336 h: across a DST change one of those days is 23 or 25 h, so in the hour +// either side of midnight the cutoff landed one day early or late. Real +// DST only bites if the PROCESS timezone has DST, so this moves it (libc +// setenv("TZ") + tzset(), as day_window_dst_test does). Windows has neither +// symbol, so the file is skipped there rather than failing in setUpAll. +@TestOn('!windows') +library; + +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/notify/fired_keys.dart'; + +typedef _SetenvNative = Int32 Function(Pointer, Pointer, Int32); +typedef _SetenvDart = int Function(Pointer, Pointer, int); +typedef _UnsetenvNative = Int32 Function(Pointer); +typedef _UnsetenvDart = int Function(Pointer); +typedef _TzsetNative = Void Function(); +typedef _TzsetDart = void Function(); + +void _setProcessTz(String? tz) { + final lib = DynamicLibrary.process(); + final key = 'TZ'.toNativeUtf8(); + try { + if (tz == null) { + lib.lookupFunction<_UnsetenvNative, _UnsetenvDart>('unsetenv')(key); + } else { + final value = tz.toNativeUtf8(); + lib.lookupFunction<_SetenvNative, _SetenvDart>('setenv')(key, value, 1); + calloc.free(value); + } + lib.lookupFunction<_TzsetNative, _TzsetDart>('tzset')(); + } finally { + calloc.free(key); + } +} + +void main() { + final originalTz = Platform.environment['TZ']; + // America/New_York: 2026-03-08 is 23 h long, 2026-11-01 is 25 h long. + setUpAll(() => _setProcessTz('America/New_York')); + tearDownAll(() => _setProcessTz(originalTz)); + + test('the host really is on a DST zone (or this test proves nothing)', () { + expect(DateTime(2026, 3, 9).difference(DateTime(2026, 3, 8)).inHours, 23); + }); + + test('just after midnight, past a 23 h day: still 14 calendar days', () { + // 336 h before 00:30 lands at 23:30 on 02-22, a day too far back. + expect(FiredKeyStore.cutoffLabel(DateTime(2026, 3, 9, 0, 30)), + '2026-02-23'); + }); + + test('just before midnight, past a 25 h day: still 14 calendar days', () { + // 336 h before 23:30 lands at 00:30 on 10-20, a day too recent. + expect(FiredKeyStore.cutoffLabel(DateTime(2026, 11, 2, 23, 30)), + '2026-10-19'); + }); + + test('an ordinary day is unchanged', () { + expect(FiredKeyStore.cutoffLabel(DateTime(2026, 6, 20, 12)), '2026-06-06'); + }); +}