screen-locker/screen_locker/_manual_workout_sport_fields.py
Krzysztof kuhy Rudnicki e12b840f8c manual workout: log for future dates (credited on the day)
A manual workout may be dated today..today+7 days (no past dates). A future
entry is held out of log.json and earns nothing until its date; the first
sync/lock/reset pass on that day ingests and credits it like a workout logged
that day.

- ingest_manual_records defers records dated after the local day and counts
  the budget (2/7d, 10/30d) against the workout's own date over logged credits
  plus pending future records (a pending record never rejects itself)
- PC-logged future entries (Tk dialog, --log-manual-workout --date) go to a
  durable pending ledger re-published on every push (a one-shot publish to
  devices/pc is overwritten by the next push); tombstones win, entries leave
  the ledger once logged or deleted
- one shared date helper (validate_workout_date) used by the dialog and CLI;
  the form's date field rides in the sport row to keep the lock screen fitting
- poke start_the_day also ingests due local entries after the daily reset
- PC admission rule pinned to the phone's by the shared budget fixture
  (planned.expected_by_date)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mmTBiMpvyfLuEAXc6nDYT
2026-10-16 15:34:15 +02:00

138 lines
5.9 KiB
Python

"""Sport-specific rows of the manual-workout evidence form.
Split out of :mod:`screen_locker._manual_workout_dialog` to keep every file
under the 250-line cap. Composed back into ``ManualWorkoutDialogMixin`` there,
so both host classes (``ScreenLocker`` and ``StatusWindow``) are unchanged.
Choosing a sport swaps in that sport's fields rather than showing one generic
free-text blob -- see ``_manual_workout.SPORT_CHOICES``.
"""
from __future__ import annotations
import logging
import tkinter as tk
from screen_locker import _manual_workout
from screen_locker._constants import MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS
_logger = logging.getLogger(__name__)
_SPORT_LABEL_TO_CODE = {
label: code for code, label in _manual_workout.SPORT_LABELS.items()
}
class ManualWorkoutSportFieldsMixin:
"""Builds the sport picker and the per-sport detail rows."""
def _mw_sport_row(self, parent: tk.Widget) -> None:
"""Add the sport-selector radio buttons; swaps the activity section.
CANONICAL ACCOUNT OF THE 2026-07-26 BUG -- other files point here.
This was a ``tk.OptionMenu``. A posted Tk menu is a separate
override-redirect toplevel that takes the Tk grab for itself, and
gatelock's 1 s recovery tick killed it within a second of opening:
``_surfaces.enforce()`` lifts the lock surface over the menu, and
``_reassert_grab()`` saw the grab sitting on the menu rather than the
root and yanked it back. The selector stayed stuck on "Table tennis"
while locked, and a walk got logged in the table-tennis form.
Radio buttons are ordinary children of the surface: they lift with it
and live inside the root's grab tree, so neither mechanism can reach
them. Nothing on a lock surface may open a popup window --
``tests/test_no_popup_widgets.py`` enforces that statically and
``scripts/verify_lock_popup_safety.py`` behaviourally.
"""
self._mw_sport_var = tk.StringVar(
value=_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
)
row = tk.Frame(parent, bg=self._colors.palette.bg)
tk.Label(
row,
text="Sport:",
font=self._colors.font("body"),
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
).pack(side="left", padx=self._colors.space("xs"))
for label in _manual_workout.SPORT_LABELS.values():
tk.Radiobutton(
row,
text=label,
value=label,
variable=self._mw_sport_var,
# Radiobutton's command takes no argument -- bind the label.
command=lambda chosen=label: self._on_mw_sport_changed(chosen),
font=self._colors.font("body"),
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
activeforeground=self._colors.palette.fg,
activebackground=self._colors.palette.bg,
# Without selectcolor the indicator is a white blob on the dark
# overlay; the OptionMenu was the one unstyled widget in this
# form, which likely added to it reading as dead.
selectcolor=self._colors.palette.field_bg,
highlightthickness=0,
).pack(side="left", padx=self._colors.space("xs"))
# The workout-date field rides in this row, at no extra height.
self._mw_date_in_row(row)
self._mw_grid(parent, row, full=True)
def _on_mw_sport_changed(self, selected_label: str) -> None:
"""Show the fields for the newly-selected sport, hide the other's.
Both frames share one grid slot; ``grid_remove`` hides a frame while
remembering its cell options so ``grid`` restores it in place.
"""
sport = _SPORT_LABEL_TO_CODE.get(
selected_label, _manual_workout.SPORT_TABLE_TENNIS
)
if sport == _manual_workout.SPORT_TABLE_TENNIS:
self._mw_other_frame.grid_remove()
self._mw_tt_frame.grid()
else:
self._mw_tt_frame.grid_remove()
self._mw_other_frame.grid()
def _build_table_tennis_fields(self, parent: tk.Widget) -> None:
"""Build the table-tennis-specific score/equipment fields."""
self._mw_int_vars["matches_won"] = self._mw_int_field(parent, "Matches won:")
self._mw_int_vars["matches_lost"] = self._mw_int_field(parent, "Matches lost:")
self._mw_int_vars["sets_won"] = self._mw_int_field(parent, "Sets won:")
self._mw_int_vars["sets_lost"] = self._mw_int_field(parent, "Sets lost:")
self._mw_vars["racket"] = self._mw_entry(parent, "Racket used:")
self._mw_vars["balls"] = self._mw_entry(parent, "Balls used:")
def _build_other_sport_fields(self, parent: tk.Widget) -> None:
"""Build the generic "other sport" fields."""
self._mw_vars["activity_type_other"] = self._mw_entry(
parent, "What sport/activity:"
)
self._mw_text_widgets["activity_details"] = self._mw_textbox(
parent,
f"What was done (min {MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS} chars):",
)
self._mw_vars["equipment"] = self._mw_entry(
parent, "Equipment used (optional):"
)
def _current_mw_sport(self) -> str:
"""Return the internal sport code for the currently-selected label."""
return _SPORT_LABEL_TO_CODE.get(
self._mw_sport_var.get(), _manual_workout.SPORT_TABLE_TENNIS
)
def _mw_int_value(self, key: str) -> int:
"""Read an int Spinbox var, defaulting to 0 on an invalid value."""
try:
return int(self._mw_int_vars[key].get())
except (tk.TclError, ValueError) as exc:
_logger.warning(
"Manual workout field %r holds a non-integer value (%s) — "
"recording it as 0",
key,
exc,
)
return 0