mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 18:07:20 +01:00
Every Python consumer now speaks slot minutes:
- satisfaction is nearest-slot snapping (_slot_wire.satisfied_by_entries)
at every "is this slot logged?" site: gate, CLI status, MCP, catering
occupancy and dedup. Nothing on disk is rewritten; 07:00/10:00 entries
keep covering 07:15/10:15 after a schedule move.
- log_meal writes slot_fields(minute) before signing (slot + slot_min).
- schedule store and sync encode via schedule_to_wire/from_wire; a
whole-hour schedule stays byte-identical (f/l/n only).
- CLI ate --hour takes HH or HH:MM; MCP log_meal takes "HH:MM" or a
legacy int hour, validated against today's slots; get_slots returns
{minute, label}.
- gate History tab: first/last are HH:MM ttk.Comboboxes (quarter-hour
list, typed times, Up/Down step without posting the popdown), locked
as disabled; validation mirrors normalized().
- gate timer fires every 15 min; docs rewritten for minutes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
189 lines
6.5 KiB
Python
189 lines
6.5 KiB
Python
"""The History tab's meal-schedule row widgets.
|
|
|
|
Split out of :mod:`._gatelock_calendar_widgets` (216 lines against the repo's
|
|
250-line cap) rather than added to it. The behaviour behind these widgets
|
|
lives in :mod:`._gatelock_scheduleedit`, mirroring how the budget row's
|
|
widgets and its edit/save logic are split.
|
|
|
|
The first and last meal are editable ``ttk.Combobox`` fields: the dropdown
|
|
offers every quarter hour, and the user may also type any ``HH:MM``. The
|
|
``ttk`` import is load-bearing for the tests, which patch this module's
|
|
``ttk`` with a fake -- keep binding it at module level.
|
|
|
|
**The dropdown is a popup over a grabbed lock.** gatelock's grab watch treats
|
|
a ``ttk.Combobox`` popdown as "grab lost" and re-takes the grab within about a
|
|
second, which closes the list. So the list is a convenience, never the only
|
|
path: typing works, and Up/Down step the value by 15 minutes *without* posting
|
|
the list (:func:`step_time`), which keeps the row fully usable from the
|
|
keyboard on the real lock.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
from typing import TYPE_CHECKING
|
|
|
|
from gatelock import make_button
|
|
|
|
from diet_guard._gatelock_calendar_types import _COLORS, _FIELD_BG
|
|
from diet_guard._gatelock_spacing import SM, XS
|
|
from diet_guard._gatelock_typography import BODY, LABEL
|
|
from diet_guard._gatelock_ui import BG, FG
|
|
from diet_guard._meal_schedule import MINUTES_PER_DAY, SLOT_GRID_MINUTES
|
|
from diet_guard._slot_wire import parse_hhmm
|
|
from diet_guard._slots import slot_label
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from diet_guard._gatelock_calendar_types import CalendarVars
|
|
|
|
__all__ = ["TIME_CHOICES", "build_schedule_row", "step_time"]
|
|
|
|
_ENTRY_WIDTH = 4
|
|
# "HH:MM" plus room for the caret, so a typed time is never clipped.
|
|
_TIME_WIDTH = 6
|
|
|
|
#: Every quarter hour of the day, the grid interior slots are rounded onto.
|
|
TIME_CHOICES = tuple(
|
|
slot_label(minute) for minute in range(0, MINUTES_PER_DAY, SLOT_GRID_MINUTES)
|
|
)
|
|
|
|
|
|
def step_time(text: str, direction: int) -> str:
|
|
"""Return ``text`` moved to the next quarter hour in ``direction``.
|
|
|
|
An off-grid time (07:23) steps to the neighbouring grid mark (07:30 up,
|
|
07:15 down) rather than by a flat 15, so stepping always lands on a value
|
|
the dropdown offers. Clamped to the day; unparsable text is returned
|
|
unchanged so a half-typed value is not clobbered.
|
|
|
|
Args:
|
|
text: The field's current value.
|
|
direction: ``+1`` for later, ``-1`` for earlier.
|
|
|
|
Returns:
|
|
The stepped ``HH:MM`` label, or ``text`` if it does not parse.
|
|
"""
|
|
try:
|
|
minute = parse_hhmm(text)
|
|
except ValueError:
|
|
return text
|
|
if direction > 0:
|
|
stepped = (minute // SLOT_GRID_MINUTES + 1) * SLOT_GRID_MINUTES
|
|
else:
|
|
stepped = (minute - 1) // SLOT_GRID_MINUTES * SLOT_GRID_MINUTES
|
|
last_mark = MINUTES_PER_DAY - SLOT_GRID_MINUTES
|
|
return slot_label(max(0, min(last_mark, stepped)))
|
|
|
|
|
|
def _bind_steps(combo: ttk.Combobox, variable: tk.StringVar) -> None:
|
|
"""Make Up/Down step the time instead of posting the dropdown list."""
|
|
|
|
def _step(direction: int) -> str:
|
|
if str(combo.cget("state")) == "normal":
|
|
variable.set(step_time(variable.get(), direction))
|
|
# "break" stops ttk's own binding, which would post the popdown.
|
|
return "break"
|
|
|
|
combo.bind("<Up>", lambda _event: _step(1))
|
|
combo.bind("<Down>", lambda _event: _step(-1))
|
|
|
|
|
|
def _time_combo(row: tk.Frame, variable: tk.StringVar) -> ttk.Combobox:
|
|
"""Return one locked-by-default ``HH:MM`` combobox in the schedule row.
|
|
|
|
Locked means ``disabled``, not ``readonly``: a read-only ttk combobox still
|
|
lets the user pick from the list, which would edit a "locked" row. Its
|
|
colours come from the ``TCombobox`` style (see
|
|
:func:`diet_guard._gatelock_calendar_ui._style_notebook`) -- ttk widgets
|
|
reject ``bg``/``fg``.
|
|
"""
|
|
combo = ttk.Combobox(
|
|
row,
|
|
textvariable=variable,
|
|
values=TIME_CHOICES,
|
|
font=(_COLORS.typography.font_family, BODY),
|
|
width=_TIME_WIDTH,
|
|
justify="center",
|
|
state="disabled",
|
|
)
|
|
_bind_steps(combo, variable)
|
|
combo.pack(side="left", padx=(XS, SM), ipady=XS)
|
|
return combo
|
|
|
|
|
|
def _spin_entry(row: tk.Frame, variable: tk.StringVar) -> tk.Entry:
|
|
"""Return the read-only-by-default meal-count entry in the schedule row."""
|
|
entry = tk.Entry(
|
|
row,
|
|
textvariable=variable,
|
|
font=(_COLORS.typography.font_family, BODY),
|
|
width=_ENTRY_WIDTH,
|
|
bg=_FIELD_BG,
|
|
fg=FG,
|
|
insertbackground=FG,
|
|
justify="center",
|
|
state="readonly",
|
|
readonlybackground=_FIELD_BG,
|
|
)
|
|
entry.pack(side="left", padx=(XS, SM), ipady=XS)
|
|
return entry
|
|
|
|
|
|
def _caption(row: tk.Frame, text: str) -> None:
|
|
"""Pack one static label between the row's fields."""
|
|
tk.Label(
|
|
row,
|
|
text=text,
|
|
font=(_COLORS.typography.font_family, LABEL),
|
|
bg=BG,
|
|
fg=FG,
|
|
).pack(side="left")
|
|
|
|
|
|
def build_schedule_row(
|
|
parent: tk.Frame,
|
|
vars_: CalendarVars,
|
|
on_edit_or_save_schedule: Callable[[], None],
|
|
) -> tuple[ttk.Combobox, ttk.Combobox, tk.Entry, tk.Button, tk.Label]:
|
|
"""Build the meal-schedule row.
|
|
|
|
Returns the two time comboboxes, the count entry, the edit button, and the
|
|
status label, in that order. Like the budget row the fields start locked:
|
|
the schedule is displayed but not directly editable until "Edit".
|
|
|
|
Returns:
|
|
``(first_combo, last_combo, count_entry, edit_button, status_label)``.
|
|
"""
|
|
row = tk.Frame(parent, bg=BG)
|
|
row.pack(pady=(SM, XS))
|
|
_caption(row, "Meals:")
|
|
first_combo = _time_combo(row, vars_.schedule.first)
|
|
_caption(row, "to")
|
|
last_combo = _time_combo(row, vars_.schedule.last)
|
|
_caption(row, "x")
|
|
count_entry = _spin_entry(row, vars_.schedule.count)
|
|
edit_button = make_button(row, _COLORS, "Edit", on_edit_or_save_schedule)
|
|
edit_button.pack(side="left")
|
|
|
|
# The derived checkpoint times, so the effect of a change is visible
|
|
# before it is saved rather than only after the next lock.
|
|
tk.Label(
|
|
parent,
|
|
textvariable=vars_.schedule.times,
|
|
font=(_COLORS.typography.font_family, LABEL),
|
|
bg=BG,
|
|
fg=FG,
|
|
).pack(pady=(0, XS))
|
|
status_label = tk.Label(
|
|
parent,
|
|
textvariable=vars_.schedule.status,
|
|
font=(_COLORS.typography.font_family, LABEL),
|
|
bg=BG,
|
|
fg=FG,
|
|
)
|
|
status_label.pack(pady=(0, XS))
|
|
return first_combo, last_combo, count_entry, edit_button, status_label
|