Files
pluriwave/lib/servicios/servicio_programacion_alarmas.dart
T
FreeTLab 88bd251eba fix(alarmas): scope schedule-skip exceptions to skipNext only
ExcepcionAlarma._esValida matched ANY exception tipo against an
occurrence, treating it as a user skip. Only the 'skipNext' tipo
existed until now, but the next commits reuse the same model to
record scheduling-reliability failures per alarm (so the alarms list
can surface them via ultimaExcepcionPara) -- without this guard, a
recorded failure would be silently treated as if the user asked to
skip that occurrence, corrupting scheduling. Adds tipo constants to
ExcepcionAlarma for the upcoming failure kinds.
2026-07-31 20:59:02 +02:00

182 lines
5.9 KiB
Dart

import '../modelos/alarma_musical.dart';
class ServicioProgramacionAlarmas {
static const Duration toleranciaDisparoInminente = Duration(seconds: 90);
DateTime? calcularProxima({
required AlarmaMusical alarma,
required DateTime desde,
List<RangoVacaciones> vacaciones = const [],
List<ExcepcionAlarma> excepciones = const [],
}) {
if (!alarma.activa) return null;
final diaBase =
alarma.tipoProgramacion == TipoProgramacionAlarma.unica &&
alarma.fechaUnica != null
? alarma.fechaUnica!
: desde;
final inicio = DateTime(
diaBase.year,
diaBase.month,
diaBase.day,
alarma.hora,
alarma.minuto,
);
final primerCandidato =
alarma.tipoProgramacion == TipoProgramacionAlarma.unica
? inicio
: _sigueSiendoInminente(inicio, desde)
? inicio
: _siguienteDia(inicio, alarma);
return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.unica =>
_sigueSiendoInminente(primerCandidato, desde) &&
_esValida(alarma, primerCandidato, vacaciones, excepciones)
? _normalizarInminente(primerCandidato, desde)
: null,
TipoProgramacionAlarma.diaria => _buscarDiaria(
alarma,
primerCandidato,
desde,
vacaciones,
excepciones,
),
TipoProgramacionAlarma.diasSemana => _buscarPorDiasSemana(
alarma,
primerCandidato,
desde,
vacaciones,
excepciones,
),
};
}
DateTime calcularSnooze(DateTime desde, int minutos) {
final seguro = minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5;
return desde.add(Duration(minutes: seguro));
}
DateTime? calcularSiguienteDespuesDeEjecucion({
required AlarmaMusical alarma,
required DateTime ejecucion,
List<RangoVacaciones> vacaciones = const [],
List<ExcepcionAlarma> excepciones = const [],
}) {
if (!alarma.activa) return null;
if (alarma.tipoProgramacion == TipoProgramacionAlarma.unica) return null;
return calcularProxima(
alarma: alarma.copyWith(limpiarSnooze: true),
desde: ejecucion.add(
toleranciaDisparoInminente + const Duration(milliseconds: 1),
),
vacaciones: vacaciones,
excepciones: excepciones,
);
}
bool estaEnVacaciones(DateTime fecha, List<RangoVacaciones> vacaciones) =>
vacaciones.any((rango) => rango.contiene(fecha));
DateTime? _buscarDiaria(
AlarmaMusical alarma,
DateTime candidato,
DateTime desde,
List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones,
) {
var actual = candidato;
for (var i = 0; i < 370; i++) {
if (_sigueSiendoInminente(actual, desde) &&
_esValida(alarma, actual, vacaciones, excepciones)) {
return _normalizarInminente(actual, desde);
}
actual = _siguienteDia(actual, alarma);
}
return null;
}
DateTime? _buscarPorDiasSemana(
AlarmaMusical alarma,
DateTime candidato,
DateTime desde,
List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones,
) {
if (alarma.diasSemana.isEmpty) return null;
var actual = candidato;
for (var i = 0; i < 370; i++) {
if (alarma.diasSemana.contains(actual.weekday) &&
_sigueSiendoInminente(actual, desde) &&
_esValida(alarma, actual, vacaciones, excepciones)) {
return _normalizarInminente(actual, desde);
}
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,
List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones,
) {
final ultimaGestionada = alarma.ultimaEjecucionGestionada;
if (ultimaGestionada != null &&
_mismaEjecucion(ultimaGestionada, candidato)) {
return false;
}
if (!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)) {
return false;
}
// Only a deliberate user skip ever removes a candidate occurrence.
// Reliability-failure records share this same list/model (so the alarms
// list can surface them per-alarm via `ultimaExcepcionPara`), but they
// must never be mistaken for a skip — that would silently jump the
// alarm to its NEXT occurrence instead of just flagging the failed one.
return !excepciones.any(
(excepcion) =>
excepcion.alarmaId == alarma.id &&
excepcion.tipo == ExcepcionAlarma.tipoSaltoSiguiente &&
_mismaEjecucion(excepcion.ejecucion, candidato),
);
}
bool _mismaEjecucion(DateTime a, DateTime b) =>
a.year == b.year &&
a.month == b.month &&
a.day == b.day &&
a.hour == b.hour &&
a.minute == b.minute;
bool _sigueSiendoInminente(DateTime candidato, DateTime desde) =>
candidato.isAfter(desde) ||
desde.difference(candidato) <= toleranciaDisparoInminente;
DateTime _normalizarInminente(DateTime candidato, DateTime desde) =>
candidato.isAfter(desde)
? candidato
: desde.add(const Duration(seconds: 2));
}