mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 19:13:55 +01:00
The phone/desktop half of the previous commit, so both devices now derive their slots the same way. Still no edit surface, so `MealScheduleService` returns the default and the derived hours are unchanged at 08/12/16/20. `slot.dart` takes the schedule as a required argument, mirroring `_slots.py`, and `slot_test.dart` gains the same every-hour x several-schedules sweep the Python suite runs. That sweep is the contract: the two must agree on which checkpoint a meal satisfies for every cell, not merely on the endpoints. Also fixes a real bug this feature would otherwise have shipped on top of. `syncToSlots` used the slot hour as the notification id but only cancelled ids in the *current* schedule, so changing 08/12/16/20 -> 08/11/14/17/20 would leave ids 12 and 16 posted with nothing that could ever cancel them: permanent reminders for checkpoints that no longer exist. It now sweeps the whole 0..23 id space. The regression test was confirmed to fail against the old loop (4 cancels vs 24) rather than passing vacuously. MealScheduleService.current degrades to the default when the singleton is absent, so `due_slot_check` deliberately does not init() it -- that would reach path_provider from widget tests and from the WorkManager isolate before main() runs. main.dart owns the real init, and background_sync_service.dart does its own, since a fresh isolate has its own static state and would otherwise nag against the default four meals. 609 Dart tests, 761 Python, both suites green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
85 lines
3.5 KiB
Dart
85 lines
3.5 KiB
Dart
/// Pure meal-slot arithmetic, mirroring diet_guard's `_slots.py`.
|
|
///
|
|
/// Deliberately I/O-free and clock-free: every function is a total function of
|
|
/// its `now` and `schedule` arguments, so the time-of-day edges are
|
|
/// exhaustively unit-testable without mocking the wall clock. Shared between
|
|
/// the in-app status row and the background notification check, exactly like
|
|
/// the Python original is shared between the gate dashboard and the lock
|
|
/// decision.
|
|
///
|
|
/// `schedule` is a required argument on every function here, deliberately: it
|
|
/// used to be read from module constants, and a default would let a call site
|
|
/// that was missed during a refactor keep deriving the old fixed hours on one
|
|
/// device only. That is the split brain this design exists to prevent -- a
|
|
/// slot one device offers and the other does not is a checkpoint that can
|
|
/// never be satisfied. Callers resolve the value at the impure edge, through
|
|
/// `MealScheduleService.current`.
|
|
library;
|
|
|
|
import 'package:diet_guard_app/models/meal_schedule.dart';
|
|
|
|
/// Returns the meal-slot hours for a day, e.g. `[8, 12, 16, 20]`.
|
|
///
|
|
/// Mirrors `_slots.day_slots`.
|
|
List<int> daySlots(MealSchedule schedule) => schedule.slots();
|
|
|
|
/// Returns true if [now] is inside the daily slot-enforcement window.
|
|
///
|
|
/// Mirrors `_slots.within_enforcement_window`.
|
|
bool withinEnforcementWindow(DateTime now, MealSchedule schedule) =>
|
|
now.hour >= schedule.slots().first &&
|
|
now.hour < schedule.enforcementEndHour;
|
|
|
|
/// Returns today's slots whose hour has arrived as of [now].
|
|
///
|
|
/// Empty outside the enforcement window. Mirrors `_slots.elapsed_slots`.
|
|
List<int> elapsedSlots(DateTime now, MealSchedule schedule) {
|
|
if (!withinEnforcementWindow(now, schedule)) return const [];
|
|
return daySlots(schedule).where((slot) => slot <= now.hour).toList();
|
|
}
|
|
|
|
/// Returns elapsed slots not yet covered by [logged].
|
|
///
|
|
/// Mirrors `_slots.missing_slots`.
|
|
List<int> missingSlots(
|
|
DateTime now,
|
|
Set<int> logged,
|
|
MealSchedule schedule,
|
|
) => elapsedSlots(
|
|
now,
|
|
schedule,
|
|
).where((slot) => !logged.contains(slot)).toList();
|
|
|
|
/// Returns the most recent elapsed slot as of [now], or null.
|
|
///
|
|
/// Mirrors `_slots.current_slot`.
|
|
int? currentSlot(DateTime now, MealSchedule schedule) {
|
|
final elapsed = elapsedSlots(now, schedule);
|
|
return elapsed.isEmpty ? null : elapsed.last;
|
|
}
|
|
|
|
/// Returns the slot a meal logged at [now] should be attributed to.
|
|
///
|
|
/// CLAMP RULE (keep byte-identical with `_slots.slot_for_log`): before the
|
|
/// first slot, clamp to the first slot; after the enforcement window ends,
|
|
/// clamp to the last slot; behaviour inside a window is unchanged. The two
|
|
/// languages must reach each answer by the *same* branch, not merely agree on
|
|
/// the value -- `test/models/slot_test.dart` sweeps every hour of the day
|
|
/// against several schedules for exactly that reason.
|
|
///
|
|
/// Unlike [currentSlot] this never returns null, which is the point: an
|
|
/// off-hours meal used to satisfy no slot at all, so eating at 07:30 or 22:30
|
|
/// still produced a "you haven't logged your meal" reminder. Attribution is
|
|
/// deliberately separate from [elapsedSlots]/[missingSlots] -- widening
|
|
/// *those* would instead make every slot fall due at the end of the day.
|
|
int slotForLog(DateTime now, MealSchedule schedule) {
|
|
final slots = daySlots(schedule);
|
|
if (now.hour < slots.first) return slots.first;
|
|
return currentSlot(now, schedule) ?? slots.last;
|
|
}
|
|
|
|
/// Returns a human `HH:00` label for [slot], e.g. `"08:00"`.
|
|
///
|
|
/// Mirrors `_slots.slot_label`.
|
|
String slotLabel(int slot) => '${(slot % 24).toString().padLeft(2, '0')}:00';
|