From b380eb74620995019ecde60505aa742bb814840e Mon Sep 17 00:00:00 2001 From: uvtqt Date: Tue, 10 Nov 2026 17:24:27 +0100 Subject: [PATCH 1/2] Count notification retention in calendar days, not 336 hours FiredKeyStore and NotificationIds each computed the retention cutoff as dayLabelOf(now.subtract(Duration(days: 14))). That is 14 x 24 h: across a DST change one day is 23 or 25 h, so in the hour either side of local midnight the cutoff landed one label early or late (AGENTS.md: day arithmetic must not assume 86400 s). One shared FiredKeyStore.cutoffLabel([now]) now does it with the existing calendar helpers, dayLabelBefore(todayLabel(now), 14), and NotificationIds uses it instead of its own copy. The effect was only a day more or less of notification bookkeeping kept twice a year, but the two copies could also have drifted apart. --- lib/notify/fired_keys.dart | 12 ++++-- lib/notify/notification_ids.dart | 4 +- test/notify_retention_dst_test.dart | 64 +++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 test/notify_retention_dst_test.dart 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..b67269e06 --- /dev/null +++ b/test/notify_retention_dst_test.dart @@ -0,0 +1,64 @@ +// 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). + +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'); + }); +} From 897e3473388063042248bf1d72446e349c02e816 Mon Sep 17 00:00:00 2001 From: uvtqt Date: Fri, 13 Nov 2026 11:58:25 +0100 Subject: [PATCH 2/2] test: skip the retention DST test on Windows (no setenv/tzset) --- test/notify_retention_dst_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/notify_retention_dst_test.dart b/test/notify_retention_dst_test.dart index b67269e06..9c119ea8a 100644 --- a/test/notify_retention_dst_test.dart +++ b/test/notify_retention_dst_test.dart @@ -3,7 +3,10 @@ // 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). +// 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';