diet-guard/diet_guard/_gatelock_scheduleedit.py
Krzysztof kuhy Rudnicki c0564c05a2 Let the PC gate set the eating window and meal count
The second edit surface, so the schedule is editable from either device the
way the budget already is. A row on the History tab beside the budget row,
with the same Edit/Save toggle: first hour, last hour, meals per day, and the
derived checkpoint times underneath so the effect of a change is visible
before it is saved.

Validated rather than clamped. MealSchedule.normalized() would happily turn
"20 to 8" into a two-meal day ending at 09:00, which is a sensible defence
against a peer's corrupt sync data and a terrible response to a typo at the
keyboard. Each rejection says what is wrong and leaves the row in edit mode so
the value can be corrected instead of silently becoming a different schedule.
The narrow-window case has its own message, because that one is genuinely
non-obvious: six meals between 08:00 and 11:00 would round two checkpoints
onto the same hour, and a duplicate slot silently drops one.

Three structural notes:

- The row's widgets are their own module. _gatelock_calendar_widgets.py was at
  216 of the 250-line cap and is now 233; adding the row inline would have
  breached it.
- The five new StringVars are grouped in a ScheduleVars dataclass rather than
  flattened onto CalendarVars, which pylint flagged at 11 attributes. Grouping
  two unrelated concerns separately is the better shape anyway.
- _gate_fixtures.py's _GATE_TK_MODULES is a hand-listed patch target, so both
  new modules are registered there. Left out, they build real tk widgets under
  a fake parent -- which is how it failed the first time, loudly, rather than
  silently opening a window.

785 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
2026-08-16 15:02:35 +02:00

153 lines
6.1 KiB
Python

"""Meal-schedule editing on the gate's History tab.
Sibling of :mod:`._gatelock_budgetedit`, with the same edit/save toggle shape:
the row displays the schedule read-only, "Edit" unlocks it, "Save" validates
and persists. ``_GateScheduleEdit`` sits between
:class:`~diet_guard._gatelock_budgetedit._GateBudgetEdit` and
:class:`~diet_guard._gatelock_calendar._GateCalendar` in the mixin chain.
Editing here changes what the gate itself asks for, so the value is validated
before it is written rather than relying on
:meth:`~diet_guard._meal_schedule.MealSchedule.normalized` to clamp it. The
clamp is the defence against a peer's corrupt sync data; a typo at the
keyboard should say so instead of silently becoming a different schedule.
"""
from __future__ import annotations
import abc
import contextlib
import tkinter as tk
from diet_guard._gatelock_budgetedit import _GateBudgetEdit
from diet_guard._gatelock_ui import ERR, FG
from diet_guard._meal_schedule import (
FIRST_HOUR,
LAST_HOUR,
MAX_MEAL_COUNT,
MIN_MEAL_COUNT,
MealSchedule,
)
from diet_guard._meal_schedule_store import current_schedule, record_schedule_change
from diet_guard._slots import day_slots, slot_label
__all__ = ["_GateScheduleEdit", "schedule_summary"]
def schedule_summary(schedule: MealSchedule) -> str:
"""Return the derived checkpoint times, e.g. ``"08:00 11:00 ..."``."""
return " ".join(slot_label(slot) for slot in day_slots(schedule))
class _GateScheduleEdit(_GateBudgetEdit):
"""The History tab's meal-schedule row: display, edit, validate, persist.
Like :class:`~diet_guard._gatelock_budgetedit._GateBudgetEdit`, this half
only reads and writes its own row; the tab's construction and refresh are
owned by :class:`~diet_guard._gatelock_calendar._GateCalendar`, further
down the mixin chain.
"""
_cal_editing_schedule: bool
@abc.abstractmethod
def _refresh_calendar(self) -> None:
"""Repaint the History tab; implemented by ``_GateCalendar``."""
def _set_schedule_entry_state(self, state: str) -> None:
"""Lock or unlock the three schedule entries on every monitor."""
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.schedule_first_entry.config(state=state)
surface.schedule_last_entry.config(state=state)
surface.schedule_count_entry.config(state=state)
def _set_schedule_button_text(self, text: str) -> None:
"""Relabel the schedule edit/save button on every monitor."""
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.schedule_edit_button.config(text=text)
def _set_schedule_status(self, text: str, *, error: bool) -> None:
"""Update the schedule-edit status line, red for errors."""
self._cal_vars.schedule.status.set(text)
colour = ERR if error else FG
for surface in self._cal_surfaces:
with contextlib.suppress(tk.TclError):
surface.schedule_status_label.config(fg=colour)
def _show_schedule(self, schedule: MealSchedule) -> None:
"""Populate the row's fields and derived-times label."""
self._cal_vars.schedule.first.set(str(schedule.first))
self._cal_vars.schedule.last.set(str(schedule.last))
self._cal_vars.schedule.count.set(str(schedule.count))
self._cal_vars.schedule.times.set(schedule_summary(schedule))
def _on_edit_or_save_schedule(self) -> None:
"""Toggle the schedule row between read-only display and editing.
Mirrors :meth:`_GateBudgetEdit._on_edit_or_save_budget`: a failed
validation leaves editing open so the value can be corrected rather
than silently discarded.
"""
if not self._cal_editing_schedule:
self._cal_editing_schedule = True
self._set_schedule_entry_state("normal")
self._set_schedule_button_text("Save")
self._set_schedule_status("", error=False)
return
if not self._save_schedule_entry():
return
self._cal_editing_schedule = False
self._set_schedule_entry_state("readonly")
self._set_schedule_button_text("Edit")
self._refresh_calendar()
def _read_schedule_fields(self) -> MealSchedule | None:
"""Parse the three entries, or None (with a status set) if unusable."""
raw = (
self._cal_vars.schedule.first.get().strip(),
self._cal_vars.schedule.last.get().strip(),
self._cal_vars.schedule.count.get().strip(),
)
try:
first, last, count = (int(value) for value in raw)
except ValueError:
self._set_schedule_status("Enter whole hours, e.g. 8 20 5.", error=True)
return None
return MealSchedule(first, last, count)
def _save_schedule_entry(self) -> bool:
"""Validate and persist the row's current values.
Returns:
Whether the schedule was valid and persisted.
"""
schedule = self._read_schedule_fields()
if schedule is None:
return False
if not FIRST_HOUR <= schedule.first < schedule.last <= LAST_HOUR:
self._set_schedule_status(
f"First meal must be before the last, both {FIRST_HOUR}-{LAST_HOUR}.",
error=True,
)
return False
if not MIN_MEAL_COUNT <= schedule.count <= MAX_MEAL_COUNT:
self._set_schedule_status(
f"Meals per day must be {MIN_MEAL_COUNT}-{MAX_MEAL_COUNT}.",
error=True,
)
return False
# A window of N hours holds at most N+1 whole-hour checkpoints; asking
# for more would round two meals onto the same hour.
if schedule.count > schedule.last - schedule.first + 1:
self._set_schedule_status(
"Too many meals for that window -- widen it or eat less often.",
error=True,
)
return False
record_schedule_change(schedule)
self._show_schedule(current_schedule())
self._set_schedule_status("Saved.", error=False)
return True