From d8e67a52042bae19a6f791d3acdd48232309d6d0 Mon Sep 17 00:00:00 2001 From: freetlab Date: Sun, 12 Jul 2026 23:32:31 +0200 Subject: [PATCH] fix(alarm): make all date math wall-clock correct across DST and timezone changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full time-domain audit (three shipped date bugs prompted it) found one root cause and two latent travel defects, all now fixed: Day-stepping used add(Duration(days: 1)), which shifts the absolute instant by exactly 86400s — documented Dart behavior (sdk#47666), so crossing a DST transition drifted the wall hour by +-1h permanently for the rest of the candidate scan (verified: 2026-03-28 07:30 Europe/Madrid + "1 day" = 08:30). The native Calendar engine preserves wall time, and the single-authority fix made the drifted Dart verdict win. Candidates now advance by calendar reconstruction (_siguienteDia: DateTime(y, m, d+1, hora, minuto)), the same wall-clock-preserving semantics as Calendar.add(DAY_OF_YEAR, 1) plus AOSP DeskClock's defensive hour/minute re-assertion, keeping both engines in agreement through any transition. Instant-valued fields (snoozeHasta/snoozeOrigen/proximaEjecucion/ ultimaEjecucionGestionada/creadaEn/actualizadaEn) serialized as offset-less local ISO, so re-parsing after a device timezone change reinterpreted the same wall fields as a different instant. They now serialize as UTC ("Z"); reads normalize to local, and legacy offset-less payloads parse identically — no migration. fechaUnica stays local on purpose: it is a wall-clock date. One-shot alarms sent fechaUnica's midnight epoch to the native side, whose boot/travel re-arm derives the calendar day back from it in the CURRENT zone — a westward shift rolled the date to the previous day. The channel now anchors the date at local noon, keeping it stable across real-world zone shifts. Property tests lock the no-drift guarantee (400 daily / 200 weekday iterations must all land exactly at hora:minuto — on DST-observing dev machines this crosses real transitions), plus UTC round-trip, legacy-payload compatibility, and wall-date preservation tests. --- lib/modelos/alarma_musical.dart | 25 +++++-- lib/servicios/servicio_alarmas_android.dart | 15 +++- .../servicio_programacion_alarmas.dart | 23 +++++- test/modelos/alarma_musical_fechas_test.dart | 70 +++++++++++++++++++ .../servicio_programacion_alarmas_test.dart | 68 ++++++++++++++++++ 5 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 test/modelos/alarma_musical_fechas_test.dart diff --git a/lib/modelos/alarma_musical.dart b/lib/modelos/alarma_musical.dart index 9384cde..d464f69 100644 --- a/lib/modelos/alarma_musical.dart +++ b/lib/modelos/alarma_musical.dart @@ -134,12 +134,19 @@ class AlarmaMusical { 'volumen': volumen, 'fadeInSegundos': fadeInSegundos, 'sonidoInterno': sonidoInterno.name, - 'proximaEjecucion': proximaEjecucion?.toIso8601String(), - 'snoozeHasta': snoozeHasta?.toIso8601String(), - 'snoozeOrigen': snoozeOrigen?.toIso8601String(), - 'ultimaEjecucionGestionada': ultimaEjecucionGestionada?.toIso8601String(), - 'creadaEn': creadaEn?.toIso8601String(), - 'actualizadaEn': actualizadaEn?.toIso8601String(), + // INSTANT fields serialize as UTC (offset-carrying "Z" ISO): a local + // toIso8601String() has no offset, so re-parsing it after the device + // changes timezone reinterprets the same wall fields as a DIFFERENT + // instant (a snooze set in Madrid would shift hours after landing in + // New York). fechaUnica stays local-ISO on purpose: it is a wall-clock + // DATE (only y/m/d are ever read), which must follow the user. + 'proximaEjecucion': proximaEjecucion?.toUtc().toIso8601String(), + 'snoozeHasta': snoozeHasta?.toUtc().toIso8601String(), + 'snoozeOrigen': snoozeOrigen?.toUtc().toIso8601String(), + 'ultimaEjecucionGestionada': + ultimaEjecucionGestionada?.toUtc().toIso8601String(), + 'creadaEn': creadaEn?.toUtc().toIso8601String(), + 'actualizadaEn': actualizadaEn?.toUtc().toIso8601String(), }; // persistence-resilience (D2): `id` stays a REQUIRED, un-defaulted cast @@ -196,8 +203,12 @@ class AlarmaMusical { return Emisora.fromMap(Map.from(raw)); } + // Normalizes to LOCAL on read: new payloads carry "Z" (UTC instants, + // toLocal converts), legacy offset-less payloads parse as local already + // (toLocal is then the identity) — both shapes land as the same local + // DateTime the scheduling math expects, so no data migration is needed. static DateTime? _dateFromJson(Object? raw) => - raw is String ? DateTime.tryParse(raw) : null; + raw is String ? DateTime.tryParse(raw)?.toLocal() : null; static T _enumFromName( List values, diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index f5382fe..9f72053 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -239,7 +239,20 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { 'minute': alarma.minuto, 'scheduleType': alarma.tipoProgramacion.name, 'weekdays': alarma.diasSemana, - 'oneShotDateMillis': alarma.fechaUnica?.millisecondsSinceEpoch, + // Anchored at LOCAL NOON, not midnight: the native side derives the + // calendar DAY back from this epoch in whatever timezone the device is + // in when it re-arms (boot/travel). A midnight epoch reinterpreted in + // a westward zone rolls to the previous day; noon keeps the intended + // date stable across any real-world zone shift (+-11h). + 'oneShotDateMillis': + alarma.fechaUnica == null + ? null + : DateTime( + alarma.fechaUnica!.year, + alarma.fechaUnica!.month, + alarma.fechaUnica!.day, + 12, + ).millisecondsSinceEpoch, 'snoozeUntilMillis': alarma.snoozeHasta?.millisecondsSinceEpoch, 'snoozeOriginMillis': alarma.snoozeOrigen?.millisecondsSinceEpoch, 'snoozeMinutes': alarma.snoozeMinutos, diff --git a/lib/servicios/servicio_programacion_alarmas.dart b/lib/servicios/servicio_programacion_alarmas.dart index 86c37c4..fc01b8a 100644 --- a/lib/servicios/servicio_programacion_alarmas.dart +++ b/lib/servicios/servicio_programacion_alarmas.dart @@ -28,7 +28,7 @@ class ServicioProgramacionAlarmas { ? inicio : _sigueSiendoInminente(inicio, desde) ? inicio - : inicio.add(const Duration(days: 1)); + : _siguienteDia(inicio, alarma); return switch (alarma.tipoProgramacion) { TipoProgramacionAlarma.unica => @@ -93,7 +93,7 @@ class ServicioProgramacionAlarmas { _esValida(alarma, actual, vacaciones, excepciones)) { return _normalizarInminente(actual, desde); } - actual = actual.add(const Duration(days: 1)); + actual = _siguienteDia(actual, alarma); } return null; } @@ -113,11 +113,28 @@ class ServicioProgramacionAlarmas { _esValida(alarma, actual, vacaciones, excepciones)) { return _normalizarInminente(actual, desde); } - actual = actual.add(const Duration(days: 1)); + actual = _siguienteDia(actual, alarma); } return null; } + /// Advances a candidate to the SAME wall-clock time on the next calendar + /// day. `add(Duration(days: 1))` must never be used for this: Dart + /// Duration arithmetic shifts the absolute instant by exactly 86400s, so + /// crossing a DST transition drifts the wall hour by +-1h PERMANENTLY for + /// the rest of the scan (verified: 2026-03-28 07:30 Europe/Madrid +1d -> + /// 08:30). Calendar reconstruction preserves the alarm's wall-clock time + /// through any transition — the same semantics as the native engine's + /// Calendar.add(DAY_OF_YEAR, 1), keeping both sides in agreement. The + /// DateTime constructor normalizes day/month/year overflow. + DateTime _siguienteDia(DateTime actual, AlarmaMusical alarma) => DateTime( + actual.year, + actual.month, + actual.day + 1, + alarma.hora, + alarma.minuto, + ); + bool _esValida( AlarmaMusical alarma, DateTime candidato, diff --git a/test/modelos/alarma_musical_fechas_test.dart b/test/modelos/alarma_musical_fechas_test.dart new file mode 100644 index 0000000..71df417 --- /dev/null +++ b/test/modelos/alarma_musical_fechas_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; + +void main() { + AlarmaMusical base({DateTime? snoozeHasta, DateTime? fechaUnica}) => + AlarmaMusical( + id: 'f1', + nombre: 'Fechas', + hora: 7, + minuto: 30, + tipoProgramacion: + fechaUnica != null + ? TipoProgramacionAlarma.unica + : TipoProgramacionAlarma.diaria, + diasSemana: const [], + fechaUnica: fechaUnica, + snoozeHasta: snoozeHasta, + snoozeOrigen: snoozeHasta?.subtract(const Duration(minutes: 5)), + ultimaEjecucionGestionada: snoozeHasta?.subtract( + const Duration(minutes: 5), + ), + ); + + test('los instantes se serializan como UTC con offset explicito (Z)', () { + // A local toIso8601String() carries no offset, so re-parsing it after a + // device timezone change reinterprets the same wall fields as a + // DIFFERENT instant (a snooze set in Madrid would shift hours after + // landing in New York). Instants must round-trip offset-carrying. + final snooze = DateTime(2026, 7, 13, 7, 10); + final json = base(snoozeHasta: snooze).toJson(); + + expect((json['snoozeHasta'] as String).endsWith('Z'), isTrue); + expect((json['snoozeOrigen'] as String).endsWith('Z'), isTrue); + expect( + (json['ultimaEjecucionGestionada'] as String).endsWith('Z'), + isTrue, + ); + }); + + test('el round-trip de instantes preserva el instante exacto', () { + final snooze = DateTime(2026, 7, 13, 7, 10); + final recuperada = AlarmaMusical.fromJson(base(snoozeHasta: snooze).toJson()); + + expect(recuperada.snoozeHasta, snooze); + expect( + recuperada.snoozeOrigen, + snooze.subtract(const Duration(minutes: 5)), + ); + }); + + test('los payloads legados sin offset siguen leyendose como hora local ' + '(sin migracion)', () { + final legado = base().toJson() + ..['snoozeHasta'] = '2026-07-13T07:10:00.000'; + final recuperada = AlarmaMusical.fromJson(legado); + + expect(recuperada.snoozeHasta, DateTime(2026, 7, 13, 7, 10)); + }); + + test('fechaUnica sigue siendo fecha de pared: conserva y/m/d en local', () { + final unica = DateTime(2026, 7, 13); + final recuperada = AlarmaMusical.fromJson( + base(fechaUnica: unica).toJson(), + ); + + expect(recuperada.fechaUnica!.year, 2026); + expect(recuperada.fechaUnica!.month, 7); + expect(recuperada.fechaUnica!.day, 13); + }); +} diff --git a/test/servicios/servicio_programacion_alarmas_test.dart b/test/servicios/servicio_programacion_alarmas_test.dart index 7590270..eb96415 100644 --- a/test/servicios/servicio_programacion_alarmas_test.dart +++ b/test/servicios/servicio_programacion_alarmas_test.dart @@ -167,6 +167,74 @@ void main() { expect(proxima, DateTime(2026, 5, 26, 7, 30)); }); + test('la hora de pared nunca deriva al iterar dias (DST-safe): diaria', () { + // Property lock for the DST drift bug: advancing candidates with + // add(Duration(days: 1)) shifts the ABSOLUTE instant by 86400s, so on + // DST-observing machines (this repo's dev machine runs Europe/Madrid) + // crossing 2026-03-29 / 2026-10-25 drifted the wall hour +-1h + // permanently. Iterating a full year of next-occurrences must keep + // every result at exactly hora:minuto. + final alarma = AlarmaMusical( + id: 'dst-diaria', + nombre: 'Diaria DST', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ); + + var proxima = + servicio.calcularProxima( + alarma: alarma, + desde: DateTime(2026, 1, 5, 6, 0), + )!; + for (var i = 0; i < 400; i++) { + expect( + proxima.hour, + 7, + reason: 'deriva de hora de pared en ${proxima.toIso8601String()}', + ); + expect(proxima.minute, 30); + proxima = + servicio.calcularSiguienteDespuesDeEjecucion( + alarma: alarma, + ejecucion: proxima, + )!; + } + }); + + test('la hora de pared nunca deriva al iterar dias (DST-safe): ' + 'dias de semana', () { + final alarma = AlarmaMusical( + id: 'dst-semana', + nombre: 'Semana DST', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diasSemana, + diasSemana: const [1, 3, 5], + ); + + var proxima = + servicio.calcularProxima( + alarma: alarma, + desde: DateTime(2026, 1, 5, 6, 0), + )!; + for (var i = 0; i < 200; i++) { + expect( + proxima.hour, + 7, + reason: 'deriva de hora de pared en ${proxima.toIso8601String()}', + ); + expect(proxima.minute, 30); + expect(const [1, 3, 5], contains(proxima.weekday)); + proxima = + servicio.calcularSiguienteDespuesDeEjecucion( + alarma: alarma, + ejecucion: proxima, + )!; + } + }); + test('mantiene la ocurrencia diaria cuyo disparo acaba de pasar', () { // Contract the native AlarmScheduler.computeNextTriggerMillis MUST // mirror: a trigger that just passed within toleranciaDisparoInminente