fix(alarm): make all date math wall-clock correct across DST and timezone changes
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:
@@ -134,12 +134,19 @@ class AlarmaMusical {
|
|||||||
'volumen': volumen,
|
'volumen': volumen,
|
||||||
'fadeInSegundos': fadeInSegundos,
|
'fadeInSegundos': fadeInSegundos,
|
||||||
'sonidoInterno': sonidoInterno.name,
|
'sonidoInterno': sonidoInterno.name,
|
||||||
'proximaEjecucion': proximaEjecucion?.toIso8601String(),
|
// INSTANT fields serialize as UTC (offset-carrying "Z" ISO): a local
|
||||||
'snoozeHasta': snoozeHasta?.toIso8601String(),
|
// toIso8601String() has no offset, so re-parsing it after the device
|
||||||
'snoozeOrigen': snoozeOrigen?.toIso8601String(),
|
// changes timezone reinterprets the same wall fields as a DIFFERENT
|
||||||
'ultimaEjecucionGestionada': ultimaEjecucionGestionada?.toIso8601String(),
|
// instant (a snooze set in Madrid would shift hours after landing in
|
||||||
'creadaEn': creadaEn?.toIso8601String(),
|
// New York). fechaUnica stays local-ISO on purpose: it is a wall-clock
|
||||||
'actualizadaEn': actualizadaEn?.toIso8601String(),
|
// 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
|
// persistence-resilience (D2): `id` stays a REQUIRED, un-defaulted cast
|
||||||
@@ -196,8 +203,12 @@ class AlarmaMusical {
|
|||||||
return Emisora.fromMap(Map<String, dynamic>.from(raw));
|
return Emisora.fromMap(Map<String, dynamic>.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) =>
|
static DateTime? _dateFromJson(Object? raw) =>
|
||||||
raw is String ? DateTime.tryParse(raw) : null;
|
raw is String ? DateTime.tryParse(raw)?.toLocal() : null;
|
||||||
|
|
||||||
static T _enumFromName<T extends Enum>(
|
static T _enumFromName<T extends Enum>(
|
||||||
List<T> values,
|
List<T> values,
|
||||||
|
|||||||
@@ -239,7 +239,20 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
|||||||
'minute': alarma.minuto,
|
'minute': alarma.minuto,
|
||||||
'scheduleType': alarma.tipoProgramacion.name,
|
'scheduleType': alarma.tipoProgramacion.name,
|
||||||
'weekdays': alarma.diasSemana,
|
'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,
|
'snoozeUntilMillis': alarma.snoozeHasta?.millisecondsSinceEpoch,
|
||||||
'snoozeOriginMillis': alarma.snoozeOrigen?.millisecondsSinceEpoch,
|
'snoozeOriginMillis': alarma.snoozeOrigen?.millisecondsSinceEpoch,
|
||||||
'snoozeMinutes': alarma.snoozeMinutos,
|
'snoozeMinutes': alarma.snoozeMinutos,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class ServicioProgramacionAlarmas {
|
|||||||
? inicio
|
? inicio
|
||||||
: _sigueSiendoInminente(inicio, desde)
|
: _sigueSiendoInminente(inicio, desde)
|
||||||
? inicio
|
? inicio
|
||||||
: inicio.add(const Duration(days: 1));
|
: _siguienteDia(inicio, alarma);
|
||||||
|
|
||||||
return switch (alarma.tipoProgramacion) {
|
return switch (alarma.tipoProgramacion) {
|
||||||
TipoProgramacionAlarma.unica =>
|
TipoProgramacionAlarma.unica =>
|
||||||
@@ -93,7 +93,7 @@ class ServicioProgramacionAlarmas {
|
|||||||
_esValida(alarma, actual, vacaciones, excepciones)) {
|
_esValida(alarma, actual, vacaciones, excepciones)) {
|
||||||
return _normalizarInminente(actual, desde);
|
return _normalizarInminente(actual, desde);
|
||||||
}
|
}
|
||||||
actual = actual.add(const Duration(days: 1));
|
actual = _siguienteDia(actual, alarma);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -113,11 +113,28 @@ class ServicioProgramacionAlarmas {
|
|||||||
_esValida(alarma, actual, vacaciones, excepciones)) {
|
_esValida(alarma, actual, vacaciones, excepciones)) {
|
||||||
return _normalizarInminente(actual, desde);
|
return _normalizarInminente(actual, desde);
|
||||||
}
|
}
|
||||||
actual = actual.add(const Duration(days: 1));
|
actual = _siguienteDia(actual, alarma);
|
||||||
}
|
}
|
||||||
return null;
|
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(
|
bool _esValida(
|
||||||
AlarmaMusical alarma,
|
AlarmaMusical alarma,
|
||||||
DateTime candidato,
|
DateTime candidato,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -167,6 +167,74 @@ void main() {
|
|||||||
expect(proxima, DateTime(2026, 5, 26, 7, 30));
|
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', () {
|
test('mantiene la ocurrencia diaria cuyo disparo acaba de pasar', () {
|
||||||
// Contract the native AlarmScheduler.computeNextTriggerMillis MUST
|
// Contract the native AlarmScheduler.computeNextTriggerMillis MUST
|
||||||
// mirror: a trigger that just passed within toleranciaDisparoInminente
|
// mirror: a trigger that just passed within toleranciaDisparoInminente
|
||||||
|
|||||||
Reference in New Issue
Block a user