A manual workout logged at 00:02 CEST on 2026-09-13 was filed under
2026-09-12: every day-key derivation used datetime.now(tz=UTC), and at
that instant UTC was still yesterday. The 09:18 lock check looked for
2026-09-13, found nothing, and locked the PC — for a workout that was on
disk. The budget counter (a trailing window) counted it fine, which is
why the status showed 1/2 used while the locker still locked. Any log
between local midnight and 02:00 CEST hit this; it had happened before.
- New screen_locker/_day.py: today_str() is the one definition of "what
day is it" — the local calendar day the user sees on the clock, which
is also what the phone app, RunnerUp verification, the status window
and the freedays pool already used. All 14 UTC sites (log writer, lock
predicates, explain chain, early-bird, wake state, shutdown/sick state,
sick budget, manual CLI, phone verification) now go through it, writers
and readers together so no new midnight window opens between them.
- write_signed_entry dedups by workout_id across ALL days: a workout id is
globally unique, and an entry moved to its correct day must not let its
synced twin (still stamped with the old day) re-ingest as a duplicate.
- scripts/rekey_log_entry.py: move a wrongly-filed entry to its real day
(the HMAC never covered the day key, so it still verifies).
- test_local_day_key.py freezes the clock at the exact failing instant.
Replayed the real timeline (log at 00:02:35, evaluate at 09:18:53, weekly
3/5) against HEAD in a throwaway worktree vs this tree:
HEAD -> logged under 2026-09-12, LOCK
fixed -> logged under 2026-09-13, no lock (already_logged)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FcU5dCcUNHMAMzUNChdrrJ
Fifth and last app onto freedays (~/utils/freedays). screen-locker is the
one that already had this idea: scheduled_skips.json was a bare list of
dates, checked first in the lock chain, that only this app could see.
That is exactly what the shared pool generalises, so the chain now asks
freedays and a day taken here also stands down diet-guard, wake-alarm,
leetcode-guard and home-guard.
Sick days are deliberately untouched. Their rolling windows (1/7d, 3/30d,
10/90d), the 120-character justification and the escalating countdown all
exist to make a sick day *hard* to take. Free days are the opposite --
declared ahead, no reason, consecutive runs allowed. Merging the two
policies would have had to ruin one of them, so only the storage of the
frictionless half moved.
This fixes a real bug in passing. is_scheduled_skip_today compared
against a UTC day while add_scheduled_skip.py wrote a local one, so a
skip added near midnight could name a date the chain never matched.
freedays owns "what is today" for both sides now, in local time, and the
disagreement cannot recur.
scripts/migrate_scheduled_skips.py moves the existing dates across; it is
idempotent and has a --dry-run. Already run here: the 8 real skips
(2026-05-19/20/21, 05-29, 05-30, 08-17, 08-22, 08-29) are in the pool and
count against 2026, leaving 27 of 35. The original file is gitignored and
was left on disk untouched.
add_scheduled_skip.py becomes a thin wrapper over freedays rather than
being deleted, because arm.sh calls it and the name is in muscle memory.
Left as-is it would have kept writing to a file nothing reads.
conftest.py went over the 250-line cap, so the two fixtures that keep the
suite off real user state moved to _real_state_fixtures.py. They belong
together and neither subsumes the other: _isolate_home covers paths
resolved from Path.home() at call time, while the freedays redirect
covers paths bound into module constants at import time, which
redirecting home afterwards cannot reach.
requirements.txt also takes pytest-randomly 5.0.0, ruff 0.16.6 and
types-requests 2.33.0.20260906, and the pylint hooks' gatelock/crdt-sync
pins move off v0.7.1/v0.9.0 -- pip refuses two direct URLs for one
distribution, so those stale pins would have made the hook environments
unresolvable rather than merely old.
1256 tests, 100% branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10 -- three tools judging an interpreter nothing
runs any more, and ruff withholding the pyupgrade fixes that only become safe
at the newer target. `pin_ci_python.py` rewrites exactly these three knobs.
What the stale target was hiding: 118 sites on `datetime.timezone.utc` instead
of `datetime.UTC` (UP017), two classes still written with `Generic[...]` rather
than PEP 695 syntax, and ruff's stable formatter applying PEP 758 --
`except (A, B):` becomes `except A, B:`. That last one makes the source
3.14-only at the syntax level, which is what `requires-python = ">=3.14"`
already promised; kuhy chose it deliberately over holding ruff a release back
(2026-08-29).
The PEP 695 fix needed a hand: ruff rewrote `class WidgetGroup(Generic[_W])`
to `class WidgetGroup[W: tk.Misc]` but left the body annotated with the
module-level `_W`, a *different* type variable -- mypy caught it as "a function
returning TypeVar should receive at least one argument containing the same
TypeVar". Renamed the body to the new parameter, gave `FrameGroup.__init__`
the concrete `list[tk.Frame]` it always wrapped, and deleted the two now-dead
module-level TypeVars.
ruff, ruff-format, mypy --strict and both pylint passes clean at the new target.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
workout-locker.timer (added in e5db254) re-runs the full lock-decision
chain every 30 minutes, but is_relaxed_day() is a pure date check with
no memory of an earlier same-day dismissal. Clicking "Skip — No
Penalty" only closed the window without persisting anything, so the
next tick re-derived reason=relaxed_day and rebuilt the same popup.
Now the skip button writes a relaxed_day_skip marker to the log
(excluded from weekly counting and sync, same treatment as heat_skip),
and the startup chain checks for it before re-entering the relaxed-day
branch. Also renames workout_log.json to log.json throughout the repo,
tests, docs, and Dart comments, since the fix touches that file anyway.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qH2hJ6mvCJd3ZXpAJSaF7
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
Ingest manual/off-app workouts synced from the phone (or another device) into
workout_log.json so they count toward the weekly-minimum unlock. No phone UI
yet — fully driven by a crafted sync log.
- _workout_sync.py: pull_all_manual_records() merges every device log under the
sync prefix (phone, pc, …), keeping the highest-HLC copy per stable record id;
and the session-verify path (_latest_payload) now skips kind="manual_workout"
records so a manual can never be treated as a StrongLifts session.
- _manual_sync.py (new): reconstruct_draft() rebuilds a ManualWorkoutDraft from a
synced payload (raw inputs only — never trusting the phone's derived fields);
ingest_manual_records() re-validates with the same validate_manual_workout the
local form uses, enforces the rate budget, then HMAC-signs and writes each
under its own date. Idempotent (dedup by sync_record_id) and non-destructive
(never clobbers an existing counted day).
- _log_mixin.py: extract write_signed_entry(log_file, date, workout_data) so the
live save path and ingestion share one date-keyed signer; _load_existing_logs
now delegates to the shared _read_logs.
- screen_lock.py: run _ingest_synced_manual_workouts() at startup, beside the
RunnerUp auto-fill, before the lock-decision early-exits.
Trust: a phone can at most add a budget-limited honour-system manual_workout,
never a phone_verified one; the PC re-validates and signs on ingest. Full suite
green at 100% coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
Adds a rate-limited manual-workout entry point (StatusWindow + locked
retry screen) for activities like table tennis that ADB/RunnerUp can't
verify, gated by a 2/7d + 5/30d budget and a detailed evidence form
rather than free text. Full credit toward the weekly minimum, debt
clearing, and commitment prompt, matching phone/RunnerUp verification.
Also stops tracking personal runtime state (workout_log.json,
sick_history.json, extra_benefits_state.json, scheduled_skips.json,
shutdown_base.json, early_bird_pending.json) in git — these are
regenerated locally and never belong in a public/private repo's
history.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbPcvAvt1JhiRzEyPDE5dA
The screen locker skipped enforcement on 2026-07-03 without ever showing
a lock: a banked skip credit (earned from a prior 5+/week streak) was
consumed automatically with no confirmation and no visible log. Reworked
the whole reward mechanic instead of just gating it, since banking a
"skip a future workout" credit works against maximizing weekly workouts:
- Removed skip credits entirely (has_skip_credit/consume_skip_credit and
the confirmation dialog built to gate them). The only same-day skip
paths left are heat_skip and sick_day, both requiring a genuine reason.
- Extra workouts (5+/week) now bank shutdown-time-later hours for the
following week instead — comfort, not reduced enforcement. Reuses the
existing _adjust_shutdown_time_by and reset_to_base_if_new_day's
previously-discarded return value as the once-per-day gate.
- early_bird and sick_day no longer pollute workout_log.json. early_bird
is a same-day pending marker now stored in its own self-expiring,
HMAC-signed file; sick_day is sourced entirely from sick_history.json
(already the real source of truth). Fixes an accidental-safety gap
where "already took a sick day today" only halted startup by luck.
- Cleaned up 3 stale non-workout entries already in workout_log.json.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdTccgbK7624kfoaV6CtXS
- Refactor RunnerUp verification: extract RunnerUpDbMixin (_runnerup_db.py),
split _scan_and_fill_week_runnerup into a helper _try_fill_runnerup_for_date
to keep cyclomatic complexity ≤10
- Generalise TCX lookup to any date in the ISO week (was today-only); all gap
days Mon→today auto-filled on every startup and 08:30 timer firing
- Add _adjust_shutdown_time_by(): +1h per extra workout beyond the 4-workout
minimum, capped at midnight (hour=24)
- Add _shutdown_base.py: daily reset of shutdown config to a stored base so
the bonus doesn't silently accumulate across days
- Add _extra_benefits.py: streak tracking, skip credits (earn (n-4) credits
for 5+ workout weeks), early-bird extension to 09:00 for eligible weeks
- Add --status mode (_status.py): non-locking CLI view showing per-day
breakdown (✓/✗), RunnerUp auto-scan, bonus status, shutdown time, streak,
skip credits, and early-bird status
- Hook carrot into _check_non_verify_exits: bonus applied whenever auto-fill
pushes weekly count above the minimum
- Pass all pre-commit hooks (ruff, mypy, pylint, bandit, shellcheck,
codespell, max-file-length); 508 tests at 100% branch coverage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017auyHmf2ZwQcDAwXaSo7KX