fix(alarm): make all date math wall-clock correct across DST and timezone changes
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m46s

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.
This commit is contained in:
2026-07-12 23:32:32 +02:00
parent e84cd2d7ed
commit d8e67a5204
5 changed files with 190 additions and 11 deletions
@@ -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