mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Split _manual_workout_dialog.py and _manual_workout.py
_manual_workout_dialog.py (357) -> _manual_workout_sport_fields.py
ManualWorkoutSportFieldsMixin
(+ _mw_scrollable_form moved into the
existing widgets mixin, where the
generic scaffold belongs)
_manual_workout.py (355) -> _manual_workout_types.py: the sport
constants, ManualWorkoutDraft, and the
rolling budget accounting
A painting mixin cannot simply move: conftest's mock_tk fixture patches `tk`
per-module through the _TK_MODULES list, so the new module had to be
registered there or its tkinter calls reach a real display. That is what the
two dialog tests caught.
Test count 976 -> 977: _manual_workout_sport_fields is a new lock-surface
module, so test_no_popup_widgets now safety-checks it too. Coverage stays
100%, pylint holds at 8.70/10, `screen_lock --status` unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveB813BQ8abAP68VSqZ5Y
This commit is contained in:
parent
bdc405c8c9
commit
f5c6c2fb9d
@ -15,14 +15,10 @@ see :class:`ManualWorkoutDraft` and :data:`SPORT_CHOICES`.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._constants import (
|
||||
MANUAL_WORKOUT_BUDGET_PER_7_DAYS,
|
||||
MANUAL_WORKOUT_BUDGET_PER_30_DAYS,
|
||||
MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS,
|
||||
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
||||
MANUAL_WORKOUT_RPE_MAX,
|
||||
@ -30,137 +26,36 @@ from screen_locker._constants import (
|
||||
MIN_WORKOUT_DURATION_MINUTES,
|
||||
WORKOUT_DURATION_ACCEPT_MINUTES,
|
||||
)
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._manual_workout_types import (
|
||||
MANUAL_WORKOUT_TYPE,
|
||||
SPORT_CHOICES,
|
||||
SPORT_LABELS,
|
||||
SPORT_OTHER,
|
||||
SPORT_TABLE_TENNIS,
|
||||
ManualWorkoutDraft,
|
||||
budget_summary,
|
||||
count_in_window,
|
||||
is_budget_exhausted,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
__all__ = [
|
||||
"MANUAL_WORKOUT_TYPE",
|
||||
"SPORT_CHOICES",
|
||||
"SPORT_LABELS",
|
||||
"SPORT_OTHER",
|
||||
"SPORT_TABLE_TENNIS",
|
||||
"ManualWorkoutDraft",
|
||||
"budget_summary",
|
||||
"build_entry",
|
||||
"build_sync_payload",
|
||||
"count_in_window",
|
||||
"is_budget_exhausted",
|
||||
"manual_sync_record_id",
|
||||
"validate_manual_workout",
|
||||
]
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
MANUAL_WORKOUT_TYPE = "manual_workout"
|
||||
|
||||
SPORT_TABLE_TENNIS = "table_tennis"
|
||||
SPORT_OTHER = "other"
|
||||
SPORT_CHOICES: tuple[str, ...] = (SPORT_TABLE_TENNIS, SPORT_OTHER)
|
||||
SPORT_LABELS: dict[str, str] = {
|
||||
SPORT_TABLE_TENNIS: "Table tennis",
|
||||
SPORT_OTHER: "Other",
|
||||
}
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
"""Return today's date as ``YYYY-MM-DD`` (UTC)."""
|
||||
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _parse_iso(date_str: str) -> datetime | None:
|
||||
"""Parse ``YYYY-MM-DD`` into a UTC datetime, or return None."""
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
except ValueError as exc:
|
||||
_logger.warning(
|
||||
"Workout log has an unparsable date key %r (%s) — that day is "
|
||||
"IGNORED when counting the manual-workout budget",
|
||||
date_str,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def count_in_window(
|
||||
log_file: Path,
|
||||
days: int,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> int:
|
||||
"""Return how many ``manual_workout`` ENTRIES fall in the trailing window.
|
||||
|
||||
Counted per entry, not per day: each logged manual workout consumes its
|
||||
own budget slot, matching how each one also earns its own weekly-count
|
||||
and shutdown credit (see ``screen_locker._weekly_check.COUNTED_WORKOUT_TYPES``
|
||||
and ``screen_locker._workout_credit``) — the budget is the sole limiter on
|
||||
how many you can log, not a once-per-day collapse.
|
||||
"""
|
||||
today_str = today or _today_iso()
|
||||
today_dt = _parse_iso(today_str)
|
||||
if today_dt is None:
|
||||
return 0
|
||||
cutoff = today_dt - timedelta(days=days)
|
||||
count = 0
|
||||
for date_str, entries in load_workout_log(log_file).items():
|
||||
parsed = _parse_iso(date_str)
|
||||
if parsed is None or not (cutoff < parsed <= today_dt):
|
||||
continue
|
||||
count += sum(
|
||||
1
|
||||
for entry in entries
|
||||
if entry.get("workout_data", {}).get("type") == MANUAL_WORKOUT_TYPE
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
def is_budget_exhausted(
|
||||
log_file: Path,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> bool:
|
||||
"""Return True if either rolling window has reached its manual-workout budget."""
|
||||
return (
|
||||
count_in_window(log_file, 7, today=today) >= MANUAL_WORKOUT_BUDGET_PER_7_DAYS
|
||||
or count_in_window(log_file, 30, today=today)
|
||||
>= MANUAL_WORKOUT_BUDGET_PER_30_DAYS
|
||||
)
|
||||
|
||||
|
||||
def budget_summary(
|
||||
log_file: Path,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> str:
|
||||
"""One-line UI summary string for the manual-workout budget."""
|
||||
week = count_in_window(log_file, 7, today=today)
|
||||
month = count_in_window(log_file, 30, today=today)
|
||||
return (
|
||||
f"Manual: {week}/{MANUAL_WORKOUT_BUDGET_PER_7_DAYS}w · "
|
||||
f"{month}/{MANUAL_WORKOUT_BUDGET_PER_30_DAYS}m"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManualWorkoutDraft:
|
||||
"""User-supplied evidence fields for a manual (unverified) workout.
|
||||
|
||||
Fields shared by every sport come first (required unless noted).
|
||||
Sport-specific fields follow, grouped by which ``sport`` they apply to —
|
||||
only the group matching ``sport`` is validated/persisted.
|
||||
"""
|
||||
|
||||
sport: str
|
||||
start_time: str # "HH:MM"
|
||||
end_time: str # "HH:MM"
|
||||
location_name: str
|
||||
transport_method: str
|
||||
cost: str
|
||||
rpe: int
|
||||
went_well: str
|
||||
to_improve: str
|
||||
overall_feeling: str
|
||||
reservation_phone: str = ""
|
||||
techniques_practiced: str = ""
|
||||
warm_up_minutes: str = ""
|
||||
pain_or_injury: str = "none"
|
||||
# SPORT_TABLE_TENNIS fields
|
||||
matches_won: int = 0
|
||||
matches_lost: int = 0
|
||||
sets_won: int = 0
|
||||
sets_lost: int = 0
|
||||
racket: str = ""
|
||||
balls: str = ""
|
||||
# SPORT_OTHER fields
|
||||
activity_type_other: str = ""
|
||||
activity_details: str = ""
|
||||
equipment: str = ""
|
||||
|
||||
|
||||
def _parse_hhmm(value: str) -> datetime | None:
|
||||
"""Parse an ``HH:MM`` string into a datetime on an arbitrary fixed date."""
|
||||
|
||||
@ -21,9 +21,9 @@ import tkinter as tk
|
||||
|
||||
from screen_locker import _manual_workout
|
||||
from screen_locker._constants import (
|
||||
MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS,
|
||||
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
||||
)
|
||||
from screen_locker._manual_workout_sport_fields import ManualWorkoutSportFieldsMixin
|
||||
from screen_locker._manual_workout_widgets import ManualWorkoutFormWidgetsMixin
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@ -33,7 +33,10 @@ _SPORT_LABEL_TO_CODE = {
|
||||
}
|
||||
|
||||
|
||||
class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
|
||||
class ManualWorkoutDialogMixin(
|
||||
ManualWorkoutFormWidgetsMixin,
|
||||
ManualWorkoutSportFieldsMixin,
|
||||
):
|
||||
"""Renders the manual-workout evidence form and handles submission."""
|
||||
|
||||
def _show_manual_workout_form(self) -> None:
|
||||
@ -66,40 +69,6 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
|
||||
)
|
||||
self._build_manual_workout_form()
|
||||
|
||||
def _mw_scrollable_form(self) -> tk.Frame:
|
||||
"""Create the two-column form frame inside the surface's viewport.
|
||||
|
||||
This used to build its *own* ``Canvas`` + ``Scrollbar`` viewport, which
|
||||
had three problems that are all gone by deletion now that the surface
|
||||
container is itself a :class:`~gatelock.ScrollableSurface`:
|
||||
|
||||
1. **Scrolling was pointer-only.** ``tk.Canvas`` has no class-level key
|
||||
bindings, so ``::tk::FocusOK`` rejected it as a focus stop, and the
|
||||
canvas bound no ``<MouseWheel>``, ``<Prior>``/``<Next>`` or arrows.
|
||||
The scrollbar thumb had to be *dragged* -- inside a lock that cannot
|
||||
be dismissed without submitting this form.
|
||||
2. **Focus walked off-screen.** Canvas clipping does not unmap a child,
|
||||
so every below-the-fold field stayed ``winfo viewable`` and stayed in
|
||||
the tab ring while Tk never scrolled to follow focus. Tab led to
|
||||
fields the user could neither see nor bring into view.
|
||||
3. **The height was a magic fraction.** ``height=0.7 * toplevel`` sat
|
||||
above ~230-290px of fixed chrome (title, budget line, error label,
|
||||
and a 24pt button row), so the constraint was ``0.3 * H >= chrome``:
|
||||
satisfied at 1080p, violated at 768p, where SUBMIT/BACK were pushed
|
||||
off the bottom of a centred, unscrollable container.
|
||||
|
||||
Still built on the primary surface only -- an independently scrolled
|
||||
copy per monitor would show two different parts of one form.
|
||||
"""
|
||||
form = tk.Frame(self.container.first, bg=self._colors.bg)
|
||||
# No outer gap: the budget line above and the first section heading
|
||||
# below already separate the grid, and on a 1024x600 panel this form
|
||||
# fits by single-digit pixels.
|
||||
form.pack(fill="both", expand=True)
|
||||
form.grid_columnconfigure(0, weight=1, uniform="mw")
|
||||
form.grid_columnconfigure(1, weight=1, uniform="mw")
|
||||
return form
|
||||
|
||||
def _build_manual_workout_form(self) -> None:
|
||||
"""Build the two-column evidence form and submit/back buttons.
|
||||
|
||||
@ -200,114 +169,6 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
|
||||
width=12,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
|
||||
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.bg)
|
||||
tk.Label(
|
||||
row,
|
||||
text="Sport:",
|
||||
font=self._colors.font("body"),
|
||||
fg=self._colors.fg,
|
||||
bg=self._colors.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.fg,
|
||||
bg=self._colors.bg,
|
||||
activeforeground=self._colors.fg,
|
||||
activebackground=self._colors.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.field_bg,
|
||||
highlightthickness=0,
|
||||
).pack(side="left", padx=self._colors.space("xs"))
|
||||
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
|
||||
|
||||
def _submit_manual_workout_form(self) -> None:
|
||||
"""Validate the form and either show an error or save + notify host."""
|
||||
try:
|
||||
|
||||
135
screen_locker/_manual_workout_sport_fields.py
Normal file
135
screen_locker/_manual_workout_sport_fields.py
Normal file
@ -0,0 +1,135 @@
|
||||
"""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.bg)
|
||||
tk.Label(
|
||||
row,
|
||||
text="Sport:",
|
||||
font=self._colors.font("body"),
|
||||
fg=self._colors.fg,
|
||||
bg=self._colors.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.fg,
|
||||
bg=self._colors.bg,
|
||||
activeforeground=self._colors.fg,
|
||||
activebackground=self._colors.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.field_bg,
|
||||
highlightthickness=0,
|
||||
).pack(side="left", padx=self._colors.space("xs"))
|
||||
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
|
||||
151
screen_locker/_manual_workout_types.py
Normal file
151
screen_locker/_manual_workout_types.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""Manual-workout sports, the draft record, and rolling budget accounting.
|
||||
|
||||
Split out of :mod:`screen_locker._manual_workout` to keep every file under the
|
||||
250-line cap. Everything here is re-exported from there, so callers and their
|
||||
patch targets are unchanged.
|
||||
|
||||
The budget is what stops a manual entry from being an unlimited escape hatch:
|
||||
manual workouts are self-reported, so only a few count in any rolling window.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._constants import (
|
||||
MANUAL_WORKOUT_BUDGET_PER_7_DAYS,
|
||||
MANUAL_WORKOUT_BUDGET_PER_30_DAYS,
|
||||
)
|
||||
from screen_locker._log_io import load_workout_log
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
MANUAL_WORKOUT_TYPE = "manual_workout"
|
||||
|
||||
SPORT_TABLE_TENNIS = "table_tennis"
|
||||
SPORT_OTHER = "other"
|
||||
SPORT_CHOICES: tuple[str, ...] = (SPORT_TABLE_TENNIS, SPORT_OTHER)
|
||||
SPORT_LABELS: dict[str, str] = {
|
||||
SPORT_TABLE_TENNIS: "Table tennis",
|
||||
SPORT_OTHER: "Other",
|
||||
}
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
"""Return today's date as ``YYYY-MM-DD`` (UTC)."""
|
||||
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _parse_iso(date_str: str) -> datetime | None:
|
||||
"""Parse ``YYYY-MM-DD`` into a UTC datetime, or return None."""
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
except ValueError as exc:
|
||||
_logger.warning(
|
||||
"Workout log has an unparsable date key %r (%s) — that day is "
|
||||
"IGNORED when counting the manual-workout budget",
|
||||
date_str,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def count_in_window(
|
||||
log_file: Path,
|
||||
days: int,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> int:
|
||||
"""Return how many ``manual_workout`` ENTRIES fall in the trailing window.
|
||||
|
||||
Counted per entry, not per day: each logged manual workout consumes its
|
||||
own budget slot, matching how each one also earns its own weekly-count
|
||||
and shutdown credit (see ``screen_locker._weekly_check.COUNTED_WORKOUT_TYPES``
|
||||
and ``screen_locker._workout_credit``) — the budget is the sole limiter on
|
||||
how many you can log, not a once-per-day collapse.
|
||||
"""
|
||||
today_str = today or _today_iso()
|
||||
today_dt = _parse_iso(today_str)
|
||||
if today_dt is None:
|
||||
return 0
|
||||
cutoff = today_dt - timedelta(days=days)
|
||||
count = 0
|
||||
for date_str, entries in load_workout_log(log_file).items():
|
||||
parsed = _parse_iso(date_str)
|
||||
if parsed is None or not (cutoff < parsed <= today_dt):
|
||||
continue
|
||||
count += sum(
|
||||
1
|
||||
for entry in entries
|
||||
if entry.get("workout_data", {}).get("type") == MANUAL_WORKOUT_TYPE
|
||||
)
|
||||
return count
|
||||
|
||||
|
||||
def is_budget_exhausted(
|
||||
log_file: Path,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> bool:
|
||||
"""Return True if either rolling window has reached its manual-workout budget."""
|
||||
return (
|
||||
count_in_window(log_file, 7, today=today) >= MANUAL_WORKOUT_BUDGET_PER_7_DAYS
|
||||
or count_in_window(log_file, 30, today=today)
|
||||
>= MANUAL_WORKOUT_BUDGET_PER_30_DAYS
|
||||
)
|
||||
|
||||
|
||||
def budget_summary(
|
||||
log_file: Path,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> str:
|
||||
"""One-line UI summary string for the manual-workout budget."""
|
||||
week = count_in_window(log_file, 7, today=today)
|
||||
month = count_in_window(log_file, 30, today=today)
|
||||
return (
|
||||
f"Manual: {week}/{MANUAL_WORKOUT_BUDGET_PER_7_DAYS}w · "
|
||||
f"{month}/{MANUAL_WORKOUT_BUDGET_PER_30_DAYS}m"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManualWorkoutDraft:
|
||||
"""User-supplied evidence fields for a manual (unverified) workout.
|
||||
|
||||
Fields shared by every sport come first (required unless noted).
|
||||
Sport-specific fields follow, grouped by which ``sport`` they apply to —
|
||||
only the group matching ``sport`` is validated/persisted.
|
||||
"""
|
||||
|
||||
sport: str
|
||||
start_time: str # "HH:MM"
|
||||
end_time: str # "HH:MM"
|
||||
location_name: str
|
||||
transport_method: str
|
||||
cost: str
|
||||
rpe: int
|
||||
went_well: str
|
||||
to_improve: str
|
||||
overall_feeling: str
|
||||
reservation_phone: str = ""
|
||||
techniques_practiced: str = ""
|
||||
warm_up_minutes: str = ""
|
||||
pain_or_injury: str = "none"
|
||||
# SPORT_TABLE_TENNIS fields
|
||||
matches_won: int = 0
|
||||
matches_lost: int = 0
|
||||
sets_won: int = 0
|
||||
sets_lost: int = 0
|
||||
racket: str = ""
|
||||
balls: str = ""
|
||||
# SPORT_OTHER fields
|
||||
activity_type_other: str = ""
|
||||
activity_details: str = ""
|
||||
equipment: str = ""
|
||||
@ -208,3 +208,37 @@ class ManualWorkoutFormWidgetsMixin:
|
||||
**self._colors.focus_kwargs(),
|
||||
).pack(side="left", padx=self._colors.space("xs"))
|
||||
self._mw_grid(parent, row)
|
||||
|
||||
def _mw_scrollable_form(self) -> tk.Frame:
|
||||
"""Create the two-column form frame inside the surface's viewport.
|
||||
|
||||
This used to build its *own* ``Canvas`` + ``Scrollbar`` viewport, which
|
||||
had three problems that are all gone by deletion now that the surface
|
||||
container is itself a :class:`~gatelock.ScrollableSurface`:
|
||||
|
||||
1. **Scrolling was pointer-only.** ``tk.Canvas`` has no class-level key
|
||||
bindings, so ``::tk::FocusOK`` rejected it as a focus stop, and the
|
||||
canvas bound no ``<MouseWheel>``, ``<Prior>``/``<Next>`` or arrows.
|
||||
The scrollbar thumb had to be *dragged* -- inside a lock that cannot
|
||||
be dismissed without submitting this form.
|
||||
2. **Focus walked off-screen.** Canvas clipping does not unmap a child,
|
||||
so every below-the-fold field stayed ``winfo viewable`` and stayed in
|
||||
the tab ring while Tk never scrolled to follow focus. Tab led to
|
||||
fields the user could neither see nor bring into view.
|
||||
3. **The height was a magic fraction.** ``height=0.7 * toplevel`` sat
|
||||
above ~230-290px of fixed chrome (title, budget line, error label,
|
||||
and a 24pt button row), so the constraint was ``0.3 * H >= chrome``:
|
||||
satisfied at 1080p, violated at 768p, where SUBMIT/BACK were pushed
|
||||
off the bottom of a centred, unscrollable container.
|
||||
|
||||
Still built on the primary surface only -- an independently scrolled
|
||||
copy per monitor would show two different parts of one form.
|
||||
"""
|
||||
form = tk.Frame(self.container.first, bg=self._colors.bg)
|
||||
# No outer gap: the budget line above and the first section heading
|
||||
# below already separate the grid, and on a 1024x600 panel this form
|
||||
# fits by single-digit pixels.
|
||||
form.pack(fill="both", expand=True)
|
||||
form.grid_columnconfigure(0, weight=1, uniform="mw")
|
||||
form.grid_columnconfigure(1, weight=1, uniform="mw")
|
||||
return form
|
||||
|
||||
@ -44,6 +44,7 @@ _TK_MODULES = (
|
||||
"screen_locker.screen_lock",
|
||||
"screen_locker._sick_dialog",
|
||||
"screen_locker._manual_workout_dialog",
|
||||
"screen_locker._manual_workout_sport_fields",
|
||||
"screen_locker._manual_workout_widgets",
|
||||
"screen_locker._ui_widgets",
|
||||
"screen_locker._window_setup",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user