mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
The workout log stored ONE workout per calendar day, so a second same-day
workout was silently dropped and could never earn credit.
Multiple workouts per day:
- workout_log.json: {date: entry} -> {date: [entry, ...]}. Legacy entries are
wrapped on read (_log_io.load_workout_log) so their HMACs stay valid — no
re-signing; every write normalizes the file, migrating it progressively.
- New write chokepoint (_log_mixin.write_signed_entry) APPENDS and dedups by a
stable workout_id, returning RecordResult so callers can tell a new workout
from a duplicate. Re-verifying the same workout is a no-op.
- count_weekly_workouts is anti-gaming: each VERIFIED workout counts
individually; self-reported manual entries count at most once per day. Four
real workouts in one day satisfy the week; four manual entries count as one.
- Reward: the day's first counted workout gets the base +2h; an additional
same-day VERIFIED workout gets +1h; an additional manual gets nothing. The
old "+1h when weekly_count > 4" gate is replaced — weekly volume is the
banked bonus's job and shouldn't be paid twice.
- Fixed: the unlock screen labelled the +1h "#{weekly_count - 4}", which can
render negative under the new rule. Removed the dead runnerup_manual type.
- Flutter: the manual budget counts distinct DAYS, matching the PC.
PC->phone sync (it had never worked once):
- push_pc_manuals pushed from a side-store written at log-time, so anything
logged before that feature existed could never publish. _manual_push now
derives the crdt log directly from workout_log.json — the single source of
truth — covering manual AND verified runs. The record id is the workout's own
workout_id (derived for legacy entries to match the phone's format) and the
HLC comes from the entry's own timestamp, so a re-push is byte-identical and
the repo never churns.
- The phone reads every kind (readMergedWorkoutPayloads) and lists the PC's
workouts under "SYNCED FROM PC". The manual budget still sees manual-only, so
a verified run can't consume a self-report slot.
- Settings claimed "Connected." whenever a token STRING existed. It now
verifies against the API on open, so a revoked token reports 401 instead of
showing a green lie.
Never fail silently:
- 30 swallowed exceptions across 19 files now log WARNING with the concrete
cause AND consequence — plus the token guards in _workout_sync that returned
[] in total silence, the exact bug that hid the dead sync for weeks.
- New pre-commit hook (scripts/check_silent_failures.py) fails any except that
neither re-raises nor logs loudly. ruff's S110 only catches `except: pass`;
the real pattern here was `except: log.info(); return []`. Documented in
CLAUDE.md.
Verified on real data: the Jul 13 run (pulled from its actual TCX over ADB) now
counts alongside that day's manual squash, and all three workouts are published
to kuhyx/syncs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
256 lines
9.0 KiB
Python
256 lines
9.0 KiB
Python
"""Sick-day justification + commitment dialog mixin for the screen locker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import tkinter as tk
|
|
from typing import TYPE_CHECKING
|
|
|
|
from screen_locker import _sick_tracker
|
|
from screen_locker._constants import (
|
|
COMMITMENT_PROMPT_TIMEOUT_SECONDS,
|
|
SICK_COMMITMENT_FORCED_READ_SECONDS,
|
|
SICK_JUSTIFICATION_MIN_CHARS,
|
|
)
|
|
from screen_locker._ui_widgets import disable_paste as _disable_paste
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from screen_locker._sick_tracker import SickHistory
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SickDialogMixin:
|
|
"""Renders the sick-day justification screen and commitment prompts."""
|
|
|
|
# ------------------------------------------------------------------
|
|
# Sick-day justification dialog
|
|
# ------------------------------------------------------------------
|
|
|
|
def _show_sick_justification(self) -> None:
|
|
"""Render the structured sick-day justification screen."""
|
|
history = _sick_tracker.load_history()
|
|
self._sick_history_cache: SickHistory = history
|
|
self.clear_container()
|
|
self._label("Sick Day Request", color="#cc6600", pady=10)
|
|
self._text(_sick_tracker.budget_summary(history), color="#ffaa00")
|
|
|
|
recent = _sick_tracker.format_recent_justifications(history)
|
|
if recent:
|
|
self._text("Recent sick days:", font_size=14, color="#888888", pady=5)
|
|
self._text(recent, font_size=14, color="#cccccc", pady=5)
|
|
|
|
had_commitment = _sick_tracker.had_commitment_for_today(history)
|
|
if had_commitment:
|
|
self._text(
|
|
"⚠ Yesterday you committed to working out today.",
|
|
font_size=18,
|
|
color="#ff6666",
|
|
)
|
|
self._text(
|
|
"Breaking the commitment costs 2 sick-budget days.",
|
|
font_size=14,
|
|
color="#ff6666",
|
|
)
|
|
|
|
self._build_justification_form(had_commitment=had_commitment)
|
|
|
|
def _build_justification_form(self, *, had_commitment: bool) -> None:
|
|
"""Add justification form fields and submit button to the container."""
|
|
form = tk.Frame(self.container, bg="#1a1a1a")
|
|
form.pack(pady=10)
|
|
|
|
self._sick_symptom_var = tk.StringVar()
|
|
self._sick_onset_var = tk.StringVar()
|
|
self._sick_severity_var = tk.IntVar(value=5)
|
|
self._sick_text_widget = self._add_form_widgets(form)
|
|
|
|
self._sick_error_label = self._text("", color="#ff4444", pady=5)
|
|
|
|
button_row = self._button_row()
|
|
self._sick_submit_button = self._button(
|
|
button_row,
|
|
"SUBMIT",
|
|
bg="#666666",
|
|
command=self._submit_sick_justification,
|
|
width=12,
|
|
)
|
|
self._sick_submit_button.pack(side="left", padx=10)
|
|
self._button(
|
|
button_row,
|
|
"BACK",
|
|
bg="#aa0000",
|
|
command=self._start_phone_check,
|
|
width=12,
|
|
).pack(side="left", padx=10)
|
|
|
|
if had_commitment:
|
|
self._sick_submit_button.config(state="disabled")
|
|
self._commitment_forced_remaining = SICK_COMMITMENT_FORCED_READ_SECONDS
|
|
self._update_commitment_forced_delay()
|
|
|
|
def _add_form_widgets(self, parent: tk.Widget) -> tk.Text:
|
|
"""Create symptom/onset/severity/text widgets. Returns the text widget."""
|
|
self._add_label_entry(
|
|
parent,
|
|
label="Symptom (e.g. fever, nausea):",
|
|
variable=self._sick_symptom_var,
|
|
)
|
|
self._add_label_entry(
|
|
parent,
|
|
label="When did it start? (e.g. last night):",
|
|
variable=self._sick_onset_var,
|
|
)
|
|
sev_row = tk.Frame(parent, bg="#1a1a1a")
|
|
sev_row.pack(pady=5)
|
|
tk.Label(
|
|
sev_row,
|
|
text="Severity (1-10):",
|
|
font=("Arial", 14),
|
|
fg="white",
|
|
bg="#1a1a1a",
|
|
).pack(side="left", padx=5)
|
|
tk.Spinbox(
|
|
sev_row,
|
|
from_=1,
|
|
to=10,
|
|
textvariable=self._sick_severity_var,
|
|
width=4,
|
|
font=("Arial", 14),
|
|
).pack(side="left", padx=5)
|
|
|
|
tk.Label(
|
|
parent,
|
|
text=(f"Describe how you feel (min {SICK_JUSTIFICATION_MIN_CHARS} chars):"),
|
|
font=("Arial", 14),
|
|
fg="white",
|
|
bg="#1a1a1a",
|
|
).pack(pady=5)
|
|
text_widget = tk.Text(
|
|
parent,
|
|
width=60,
|
|
height=6,
|
|
font=("Arial", 12),
|
|
bg="#2a2a2a",
|
|
fg="white",
|
|
insertbackground="white",
|
|
)
|
|
text_widget.pack(pady=5)
|
|
_disable_paste(text_widget)
|
|
return text_widget
|
|
|
|
def _update_commitment_forced_delay(self) -> None:
|
|
"""Tick down the forced-read delay then enable the submit button."""
|
|
if self._commitment_forced_remaining > 0:
|
|
self._sick_submit_button.config(
|
|
text=f"WAIT {self._commitment_forced_remaining}s",
|
|
)
|
|
self._commitment_forced_remaining -= 1
|
|
self.root.after(1000, self._update_commitment_forced_delay)
|
|
else:
|
|
self._sick_submit_button.config(text="SUBMIT", state="normal")
|
|
|
|
def _submit_sick_justification(self) -> None:
|
|
"""Validate the form and either show an error or proceed to countdown."""
|
|
symptom = self._sick_symptom_var.get()
|
|
onset = self._sick_onset_var.get()
|
|
try:
|
|
severity = int(self._sick_severity_var.get())
|
|
except (tk.TclError, ValueError) as exc:
|
|
_logger.warning(
|
|
"Sick-day severity is not an integer (%s) — submitting it as 0, "
|
|
"which the justification validator will reject",
|
|
exc,
|
|
)
|
|
severity = 0
|
|
text = self._sick_text_widget.get("1.0", "end").strip()
|
|
draft = _sick_tracker.JustificationDraft(
|
|
symptom=symptom,
|
|
onset=onset,
|
|
severity=severity,
|
|
text=text,
|
|
)
|
|
error = _sick_tracker.validate_justification(draft)
|
|
if error is not None:
|
|
self._sick_error_label.config(text=error)
|
|
return
|
|
|
|
history = self._sick_history_cache
|
|
_sick_tracker.add_justification(history, draft)
|
|
if not _sick_tracker.save_history(history):
|
|
self._sick_error_label.config(
|
|
text="Could not persist sick history — try again",
|
|
)
|
|
return
|
|
self._proceed_to_sick_countdown()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Commitment prompt (after a verified workout)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _show_commitment_prompt(self, *, on_done: Callable[[], None]) -> None:
|
|
"""Ask the user to commit to working out tomorrow.
|
|
|
|
Calls ``on_done()`` once the user answers or the timeout elapses.
|
|
"""
|
|
self.clear_container()
|
|
self._label(
|
|
"Commit to working out tomorrow?",
|
|
font_size=32,
|
|
color="#ffaa00",
|
|
pady=20,
|
|
)
|
|
self._text(
|
|
"If you say YES and skip via 'I'm sick' tomorrow, "
|
|
"the sick day costs 2x normal.",
|
|
font_size=16,
|
|
)
|
|
self._commitment_done_fn = on_done
|
|
self._commitment_remaining = COMMITMENT_PROMPT_TIMEOUT_SECONDS
|
|
self._commitment_timer_label = self._text(
|
|
f"Auto-skipping in {COMMITMENT_PROMPT_TIMEOUT_SECONDS}s",
|
|
color="#888888",
|
|
)
|
|
row = self._button_row()
|
|
self._button(
|
|
row,
|
|
"YES",
|
|
bg="#00aa00",
|
|
command=lambda: self._answer_commitment(commit=True),
|
|
width=12,
|
|
).pack(side="left", padx=10)
|
|
self._button(
|
|
row,
|
|
"NO",
|
|
bg="#aa0000",
|
|
command=lambda: self._answer_commitment(commit=False),
|
|
width=12,
|
|
).pack(side="left", padx=10)
|
|
self._tick_commitment_timeout()
|
|
|
|
def _tick_commitment_timeout(self) -> None:
|
|
"""Advance commitment auto-skip timer; default to NO when it expires."""
|
|
if self._commitment_remaining <= 0:
|
|
self._answer_commitment(commit=False)
|
|
return
|
|
self._commitment_timer_label.config(
|
|
text=f"Auto-skipping in {self._commitment_remaining}s",
|
|
)
|
|
self._commitment_remaining -= 1
|
|
self.root.after(1000, self._tick_commitment_timeout)
|
|
|
|
def _answer_commitment(self, *, commit: bool) -> None:
|
|
"""Persist the commitment answer and call the completion callback."""
|
|
# Disable timer re-entry by zeroing remaining.
|
|
self._commitment_remaining = -1
|
|
if commit:
|
|
history = _sick_tracker.load_history()
|
|
_sick_tracker.record_commitment_for_tomorrow(history)
|
|
_sick_tracker.save_history(history)
|
|
done = getattr(self, "_commitment_done_fn", None)
|
|
if done is not None:
|
|
self._commitment_done_fn = None
|
|
done()
|