Compare commits

...

195 Commits

Author SHA1 Message Date
e08a520019 Bump shared Flutter pins, typescript-eslint
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
data_transfer_flutter 0.2.2, sync_settings_ui 0.6.3,
typescript-eslint 8.72.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXcjiPpgRfxzrRfoky
2026-11-10 20:08:05 +01:00
94f9822884 Let CI mirror pytest worker count be set
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXcjiPpgRfxzrRfoky
2026-11-10 19:48:00 +01:00
94a55deb86 Keep dropout fixture out of importer tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXcjiPpgRfxzrRfoky
2026-11-10 19:39:51 +01:00
13f1e9ed93 Fix pylint shadowed tcx in webdav test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXcjiPpgRfxzrRfoky
2026-11-10 19:32:43 +01:00
6777656418 Bridge GPS dropouts in run distance
The PC keeps a dropout moving at 1 m/s to 25 km/h; the app zeroed
every gap over 30 s. Bridge unpaused dropouts, pauses stay zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXcjiPpgRfxzrRfoky
2026-11-10 19:11:42 +01:00
9c415d8f9a Bump astro 7.4.8 and gradle 9.9.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXcjiPpgRfxzrRfoky
2026-11-10 19:11:07 +01:00
9641906818 Pace target in backup, run rule comments
Back up and restore the pace target (null or absent = off, a stale
target is cleared). Mirror comments in run_math and friends now state
the PC's merged rule and where the app is stricter. Pace tones get
MANIFEST entries; default map and backup get tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXcjiPpgRfxzrRfoky
2026-11-10 19:05:53 +01:00
cd32c0a79e Merge branch 'worktree-agent-af4066a0307a0da8f'
# Conflicts:
#	stronglift_replacement/workout_app/lib/services/run_math.dart
2026-11-10 18:48:22 +01:00
48d21bfafc One-mode run, pace target, idle map
Remove the run/walk toggle: every recording uses the 25 km/h running
filter and writes a Running TCX; decodeTcx still reads walking files.
Add the optional pace target (stepper, 30 s current pace, band,
throttled cues, persisted). The idle map follows the user's fix; refused
location shows only the red icon, no map and no start.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 18:47:53 +01:00
6df7c397ca Merge branch 'worktree-agent-aa94bbfa685da1112' 2026-11-10 18:36:47 +01:00
882b6a56c8 Reapply "Merge branch 'worktree-agent-aa94bbfa685da1112'"
This reverts commit 43e7f6d7e0.
2026-11-10 18:36:47 +01:00
fccde84bcb Judge speed over 10 s windows, keep dropouts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 18:36:25 +01:00
43e7f6d7e0 Revert "Merge branch 'worktree-agent-aa94bbfa685da1112'"
This reverts commit 8cc66d2803, reversing
changes made to ba9e9c31c0.
2026-11-10 18:29:41 +01:00
8cc66d2803 Merge branch 'worktree-agent-aa94bbfa685da1112' 2026-11-10 18:28:41 +01:00
d066726a60 Cap credited pair speed at 25 km/h
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 18:28:28 +01:00
f528722e1f Make unified tests match the -k filter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 18:26:23 +01:00
0a48b76d59 Credit runs and walks by one rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 18:23:57 +01:00
ba9e9c31c0 Open OSM tiles over TLS in the sandbox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 18:18:00 +01:00
d0bb8d700e Allow OSM tiles in the sandbox, log metres
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 18:13:26 +01:00
1475ab913b Shard ci-precommit into four gate steps
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Each slice runs under 30 s; together they run all 35 default-stage
hooks once. CI (pre-commit.yml) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 17:45:58 +01:00
2998bd5353 Give enum-only run files covered members
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 17:40:13 +01:00
0ec8cfddec Exempt run_tcx fixtures from whitespace fixers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 17:35:55 +01:00
39c85a7dfd Revert "Normalize run_tcx fixture whitespace"
The golden test compares the encoder with these byte-exact files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 17:35:54 +01:00
5d3547c221 Fix gate: file caps, silent catch, pin bump
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 17:31:18 +01:00
7f9169b9d0 Normalize run_tcx fixture whitespace
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 17:19:37 +01:00
17dac66a11 Allow an extra workout after done today
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:53:37 +01:00
23ad2e02b4 Swap google_sign_in for google_account_token
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:48:04 +01:00
aaaecca3be Show the live map on the run screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:27:42 +01:00
bd12b8711d Add run screen, home entry, provisioning
One-screen live run UI (icons and digits), map slot via mapBuilder, upload badge, sandbox e2e, Pixel 6a inset test, dufs provisioning script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:24:25 +01:00
97d73b31f5 Merge main, use shared dufs login
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:15:15 +01:00
66e9948777 WIP run screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:12:59 +01:00
801e303b39 Merge branch 'worktree-agent-a19044abadd522d41' 2026-11-10 16:12:27 +01:00
6fa9666cdd Marker lists distinct runs; guard fixes
Marker: twins (Pixel_6a_ infix) listed once, app-authored files never. Backup guard: a recorded run no longer lifts the workout-history clobber guard. runGuarded: a throw outside per-file handling logs and sets failed. Silent-failure hook clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:11:59 +01:00
b1ff91249b Merge branch 'worktree-agent-acd53f670745f7d44' 2026-11-10 16:09:14 +01:00
0411116f24 Harden RunnerUp retirement after review
Silent-failure checker, mypy, pylint now clean. Hidden packages count
as installed (pm list -u), pull gets a long timeout, wifi entries are
matched by ro.serialno, and a missing log/drop dir is a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:08:40 +01:00
770a88454d Add run controller, run service, km cue
Foreground service (dataSync+location) started after access, numbers-only notification, kilometre buzz and chime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:07:25 +01:00
a6c97dab8f Import RunnerUp history into the app
One-time, phone-only import of /RunnerUp/processed over dufs WebDAV as display-only runs, then a dotfile marker PUT for the PC. Never PUTs a tcx. Icon-only status badge in Settings, shared DufsCredentials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:07:03 +01:00
8ca4c301fd Back up and export GPS runs
runs/run_points were in neither backup.json nor the user export. App-made runs go in the backup (history returns from the PC), all runs in export v3; merge keyed by id or hash, idempotent. The backup clobber guard now also protects a runs-only backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:06:56 +01:00
ca205fa67e Build a fresh tile provider per map
TileLayer disposes its provider; cache only the directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:03:02 +01:00
25ee5ca9ad Add sandbox GPS replay and speed setting
Synthetic 4 km loop generated in Dart, replayed at 1x/10x/60x; main imports the dufs login and retries pending uploads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:02:39 +01:00
50faee673f Wire runnerup-retire units into installer
Daily timer (+10 min after boot) running the retirement, installed
and removed with the other screen-locker units; README section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:01:37 +01:00
b174339979 Add RunnerUp retirement logic
Anchor = PC credit time of an app-authored run (joined on TCX end
time in log.json), due 7 days later, gated on a validated import
marker, then adb backup + uninstall. Typed results, --status/--dry-run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:01:17 +01:00
8a26387396 Add run recorder, upload queue, saver
State machine over PositionSource + GpsFilter with pause segments, pending-run WebDAV PUT queue, dufs login import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:00:31 +01:00
b4e69dbff4 Add RunMap, run detail and runs in history
RunMap.route/.live (flutter_map, OSM tiles cached on disk,
collapsed icon-only attribution, one warning when tiles fail),
RunDetailScreen (full-bleed map + numbers, no scrolling), a runs
strip in the history, and day+sport dedupe that hides the PC's
runnerup_verified entry once the phone has the run itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 16:00:01 +01:00
63caf9dee9 Match RunnerUp TCX: Calories, walk as Other
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 15:41:55 +01:00
c2544c4a9c Add GPS run foundation: TCX codec, DB, deps
Pure-Dart TCX codec pinned to the PC's RunnerUp parser by a shared
golden fixture (Dart byte-compare + pytest through the real verifier),
GPS filters and moving-time maths mirroring the PC walk rule, runs /
run_points tables (schema v9), PositionSource seam over geolocator,
location permissions and foreground service type, map/cache deps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZ9CKZsNxtD5Jr9bYD
2026-11-10 15:37:41 +01:00
1020421371 Bump crdt_sync to v0.12.0
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 14:28:15 +01:00
c4d2bbcd7d Bump deps: fln 23, gradle 9.9, astro, jsdom 31
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Pin @emnapi core/runtime to stable 1.11.3: astro's peers
otherwise resolve to 2.0.0-beta.2 and astro check crashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:44:45 +01:00
595c5f41e6 Pin gatelock v0.12.0
Also bump coverage 7.17.2 and ruff 0.17.4 for the freshness gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:31:14 +01:00
856f0c0774 Lift armed lock when carrot lands late
Poll morning_session every 15s (05:00-11:00) while the lock is
up; on a signed skip bank the early-bird marker, record
wake_alarm_skip_late and leave through the normal close().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:11:39 +01:00
0987975bca Fix lint: hold status mirror, split tests
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVSoczW2NHQd887FUm
2026-11-03 20:46:47 +01:00
6919ddea28 Close open workout window when a hold starts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVSoczW2NHQd887FUm
2026-11-03 20:39:47 +01:00
fdf6251847 Bump web deps: astro 7.4.5, vitest 5.1.0
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Unblocks the dependency-freshness gate for the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVSoczW2NHQd887FUm
2026-11-03 20:31:51 +01:00
47da4131a8 Stand down workout prompt/lock during test hold
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVSoczW2NHQd887FUm
2026-11-03 20:29:11 +01:00
c77dff2b07 deps: sync_settings_ui 0.5.2
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 15:54:00 +01:00
6b3fbe2f90 deps: earned_time 0.21.0
Focus day pays 240 gaming (2/min); full focus day is 8 h.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 15:34:40 +01:00
0c19a3ee76 Add todo app git hooks
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 14:25:01 +01:00
60aa0a7d3a Stream credits phone manual workouts instantly
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
The Firebase stream only ingested StrongLifts sessions, so a manual
workout logged on the phone waited for the 15-min workout-sync tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVTJPDdbeF71W9Wd3q
2026-11-02 11:28:42 +01:00
141484b3e3 Pin focus-project 0.3.0, sickdays 0.4.0
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Conflicting git-tag pins in the shared user site let the last install downgrade focus_project/sickdays (no harvest, no focus locker).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:02:57 +01:00
77b541475e Pin earned-time v0.20.0
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWxdzckyBH7fEbPywpr
2026-11-02 09:32:55 +01:00
4ca5e4b3c2 Pin earned-time v0.19.1
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
2026-11-02 08:48:22 +01:00
124f0506e2 Pin earned-time v0.19.0 (focus project)
Tests isolate FOCUS_PROJECT_DATA so a live project never leaks in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
2026-11-02 08:34:03 +01:00
aa56bc219e Sandbox: keep a launch intent for the FGS
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
2811f78 hid the sandbox from the launcher by removing its LAUNCHER
category. flutter_foreground_task builds its notification from
getLaunchIntentForPackage, which then returned null; the NPE meant
startForeground never ran and Android killed the app with
ForegroundServiceDidNotStartInTimeException on the first set tap and
on every resume. A separate MAIN+INFO filter (checked before LAUNCHER)
restores the intent and leaves the launcher entry hidden. The daily
flavor keeps LAUNCHER and was never affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 22:12:41 +01:00
7264dcb1f7 ci: fix pylint import and SC2155
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 21:57:44 +01:00
ffeb61df3e workout_app: log malformed manual workouts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 21:49:53 +01:00
5d0aa9b57a Add set haptics, finish chime, sound switch
dopamine-ux 05: selectionClick on recording a set or warmup,
mediumImpact + generated chime (music_theory, pinned tag) on
finish, dialog arrival on the slow motion token, AppDuration /
AppCurve in theme.dart, streak card counts up, all of it
collapsing under the OS reduced-motion flag. One Settings
"Sound" switch silences the chime and the break-end cue
(foreground service included, via a silent channel); haptics
stay. Removes the finished dopamine-ux-05 prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 21:46:09 +01:00
e98ee1f874 tests: bonus caps at the day ceiling, not 24:00
aa2776a moved the live clamp to the day's ceiling; this test still pinned the old 24:00.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 21:12:03 +01:00
c145431dc9 Gate pylint at a hard 10.00
Both pylint hooks now use --fail-under=10; the TODO that tracked the
work is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 21:11:34 +01:00
aa3e290391 Document every test, split files at the cap
Real one-line docstrings stating what each test pins down. Files that
would pass 250 lines moved whole classes into _part2 siblings
(sick_panel, earned, poke_server, shutdown_reading,
manual_workout_dialog).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 21:11:34 +01:00
4a36b6b264 Fix mechanical pylint findings in tests
Booleaness asserts, hoisted imports, fixture(name=...) instead of
shadowing arguments, explicit encodings, snake_case names, comparison
of bound methods, dead protected-access disables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 21:11:34 +01:00
f3875302d7 Fix pylint findings in source and scripts
Real fixes: split over-long functions (lock-decision chain, manual
sync, validation, phone verification, status), hoist a dead lazy
import, context-manage Xvfb and temp dirs, add encodings, share the
silent-failure argv loop.

lock_grab_handoff read lock._recovery, which gatelock's LockWindow
does not have (its public property is recovery); a contract test now
checks every attribute the handoff reads against the real class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 21:11:34 +01:00
21a40ba9f5 Lint tests; sync pylint hook pins
ignore-paths made the tests hook lint zero files; drop it. Hook pins
had drifted (gatelock 0.10.0, earned-time 0.16.0) from pyproject; sync.
Add pylint design/naming/protected-access config for the mixin host
surface, and run the silent-failure hooks as modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 21:11:34 +01:00
e7fd13c649 docs: move android_ui TODO to testsAndMisc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 20:52:37 +01:00
fc066e139d tests: ruff-format the ceiling import
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 20:16:50 +01:00
f3bd94f646 web: migrate status page from React to Astro
One static Astro page, vanilla TS poller and renderer,
@kuhyx/web-ui 0.5.0 for tokens only. Same web/dist output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 20:12:46 +01:00
6cd21296a9 workout_app: cover export/import to 100%
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 20:07:56 +01:00
ba3a8a293d Show the workout streak in the status window
Adds StatusSectionsMixin._section_streak, called from
StatusWindow.render: the stored consecutive 5+-workout-week streak and
the reward message the week transition produced, display only. A streak
of 0 is stated plainly, never padded; a "Streak reset" message is
warning-coloured, not green. get_status already returned the streak
(it is asdict(gather_status())); StatusSnapshot gains week_rewards.

The week-transition strings used to go to _logger.info only.
process_week_transition now also keeps them under a week_rewards key in
the existing extra_benefits_state.json (no new file), read back for the
current ISO week by week_rewards(). Their "early-bird extended to 09:00"
clause is dropped: early_bird_extended is read only by display code, no
lock or window decision uses it (early bird is wake-alarm's signed
exemption since 2026-09-20), so it is not shown in the window either.

Per kuhy: no daily-streak state, no shutdown time for streaks, no
freezes or grace days; no enforcement logic touched.

Red first: new section/state/snapshot tests failed, then passed. On a
scratch copy of the real state the window shows "7-week streak" and
"6 workouts in 2026-W43! 7-week streak", no early-bird line.

Removes prompts/TODO-dopamine-ux-04-screen-locker.md (done).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 20:04:14 +01:00
aa2776a45e Cap live bonuses at the day's ceiling
The by-minutes bonus path clamped at 24:00 (_MIDNIGHT) and relied on
adjust_shutdown_schedule.sh's RESTORE_CEILING (23:00). It now clamps at
earned_time.shutdown_ceiling_for(day) via the new _ceiling.ceiling, so a
moved WAKE_MINUTES cannot be passed by a bonus. The shell's 1380 stays
the hard upper bound; ceiling() is min()'d with it, and a test pins the
Python constant to the script's value. _preview_sim and the invariant
test reuse that constant instead of a second 23 * 60.

Failure mode: if the earned_time lookup raises (ArithmeticError,
LookupError, TypeError, ValueError), ceiling() falls back to 23:00
loudly (logger.warning), never silently and never above the helper's
bound. That is fail-open relative to a lowered ceiling, bounded by the
shell clamp; an earned_time without the API (0.3.0) uses its constant.

Red first: new tests (21:00 ceiling, failing lookup, caps-at-ceiling)
failed on c3a5d14, pass now. 14-day replay of the real ledgers
(2026-10-18..31) through old and new code, plus an all-done-at-22:30
counterfactual: identical, every day 23:00; live files unchanged.

TODO-cap-bonus-at-day-ceiling.md removed (done).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 20:04:14 +01:00
1e6f17257c workout_app: retire the finished v2 TODO
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 20:03:15 +01:00
e47f42eb49 Enforce the 250-line cap on C++ and markdown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
2026-11-01 20:03:02 +01:00
9e71954348 feat: JSON export/import on device
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 19:55:30 +01:00
c3a5d1470c docs: plan Astro migration
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXTUM9dpnCS2wtFkx4
2026-11-01 18:42:06 +01:00
8b0b881ec1 Split the Flutter coverage gate into per-tree shard hooks plus a merge hook
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
The whole-suite check (~96 s capped) outlived the 60 s action cap. Manual-stage ids flutter-test-coverage-shard-0..3 each record their bucket's lcov under the HEAD tree hash; flutter-test-coverage-merge fails unless all four exist for the same tree, then applies the unchanged 100% bar and lib-file guard to the union. CI (--shard/--merged) is unchanged.

Claude-Session: https://claude.ai/code/session_01WzxdWYmRsPBNf6gi2LVjFA

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 18:18:25 +01:00
6060ebeb4e workout_app: crdt_sync_dart 0.11.3, sync_settings_ui 0.4.3, crypto 3.0.8, flutter_local_notifications 22.3.3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 18:08:50 +01:00
eec45d26b7 Bump flutter 3.47.9, node 26.13.0, python dev pins, web deps to latest
- flutter 3.47.7 -> 3.47.9 (.fvmrc + the three workflows)
- node 24.22.1 -> 26.13.0 (pre-commit.yml, python-tests.yml)
- mypy 2.4.1, pytest 9.2.0, ruff 0.17.3, types-requests 2.33.0.20261030
- web: pnpm 12.16.1, @types/node 26.6.6, eslint 10.14.0,
  typescript-eslint 8.72.1 (pnpm-lock regenerated)

workout_app's pubspec bumps are held back: crdt_sync_dart-v0.11.2 (newest
tag) exact-pins crypto 3.0.7, so crypto 3.0.8 does not resolve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:54:17 +01:00
8b4dcd8335 pylint: run serial so CI on Python 3.15 stops crashing in dill
setup-python "3.x" moved to CPython 3.15.1, which removed code.co_lnotab.
With jobs != 1 pylint pickles the linter for its workers via dill.dumps(),
and dill 0.4.1 (newest release) still reads co_lnotab, so both pylint hooks
died with AttributeError before linting a file. The 3.15 fix is on dill
master only. jobs = 1 in pyproject.toml (the hooks drop their --jobs=4 and
take it from there) avoids dill entirely and keeps CI on the newest Python.
Hook rev bumped v4.0.8 -> v4.1.3 to match requirements.txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:54:09 +01:00
2d728fb791 workout_app: same 30/25 duration bar as the locker
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 17:34:14 +01:00
dc885b9903 One workout duration bar for every activity: advertise 30, accept 25 (walks included)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 17:33:33 +01:00
8b0fb81d7b Temperature bound test: assert the default is at most 5 s, not exactly 5 s
The original only bounded the elapsed time, so == 5.0 was stricter than the
requirement it replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:31:26 +01:00
f04b0d0efd Commit gate: pytest 67.7 s serial -> 9.4 s, screens-fit hook 3.6 s -> 0.7 s
- addopts: -n 4 --dist worksteal (fixed, not auto: 24 workers would break
  capped.sh's 4 GiB). CI's own -n auto still wins.
- coverage core = sysmon (PEP 669): ~40% less tracing overhead, same data.
- shutdown ceiling invariant: one plain-function patch of earned_units per
  day instead of a MagicMock per unit (~5000 per case); every day and every
  case still checked.
- poke/web server tests: serve_forever(poll_interval=0.01), so shutdown()
  no longer waits 0.5 s per test.
- autouse _no_real_ntp: check_clock_skew sent real UDP to pool.ntp.org from
  every verification test, and a lost reply cost a random test 5 s.
- temperature hard-timeout test: pins the default to 5 s and times the
  mechanism at 0.1 s instead of waiting 5 real seconds.
- _xvfb_reexec: Xvfb -displayfd instead of xvfb-run's fixed 3 s sleep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:27:27 +01:00
06c50a9231 LeetCode bonus needs the day paid in full (earned_time 0.18.0); gate_off asked live
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 13:23:19 +02:00
814ad87301 deps: node 24.22.1, gradle 9.8.2, sqflite 2.4.5, sqflite_common_ffi 2.4.5, pnpm 12.15.0, typescript-eslint 8.72.0, vite 8.3.5 (newest stable)
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
release-apk / release (APK) (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017k9j5dbjenpfZj7dh2faPN
2026-10-23 18:09:40 +02:00
2811f78b4c workout_app launcher: label Workout, icon fitted to the shared 340px ink circle; sandbox flavor hidden from the launcher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kQ5PCKqBHAkAVkPSvp6dk
2026-10-23 14:45:19 +02:00
7ca2354d66 Pin earned_time 0.17.0: a LeetCode day paid from banked credits earns the bonus
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 11:35:20 +02:00
73d70798c8 deps: earned_time 0.16.0 (tri-state leisure= for the gaming day)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
No leisure= argument is passed here, so the new default None (decide by
the switch file) is exactly the old behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 17:02:52 +02:00
0048c99601 deps: earned_time 0.15.0 (leisure switch file)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Also brings the pylint hooks' earned-time pin (0.13.2) back in step with
pyproject.toml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 14:00:42 +02:00
a79ccc523b Workout handoff clears gatelock's Claim.grab_held while the app has input
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Lending the X grab to the workout app stops recovery and releases it with
the claim still live, so gatelock 0.11 held_grabs (leisure-watch C2) read
"held" for the whole workout. release() now reports held=False before
grab_release; reacquire()'s grab_input() sets it true once the grab lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 12:04:48 +02:00
77d3866ee7 Pin earned-time-v0.14.0 (dependency-freshness)
0.14.0 adds LEISURE_FROM (parked 2027-01-01) and resolve(leisure=True);
enforced output for every day before LEISURE_FROM is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 11:55:32 +02:00
e2b1a980cc Pin gatelock-v0.11.0 (Claim.grab_held + held_grabs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 11:47:24 +02:00
dd3b8ca160 web deps: eslint 10.13.0 (freshness gate)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:56:55 +02:00
aa27a8ece6 workout lock: do not re-arm after tonight's evening night-lockdown (gatelock 0.10.0 night_lockdown)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:32:30 +02:00
31a2056d52 deps: earned_time 0.13.2 (unified ladder from 2026-10-18)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:21:36 +02:00
46393fa866 late credits: a credit at/after tonight's shutdown sets it to min(23:00, its own time + reward); grace floor removed (also on sick days)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 18:14:04 +02:00
f2ddf3a491 sick day: shutdown = 20:00 + gaming pay of today's credits (earned_time 0.13.1); credits raise to it, no absorb
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 17:38:24 +02:00
9e23ebae4f deps: earned_time 0.12.0 (shutdown rungs equal gaming pay from 2026-10-18)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 16:56:44 +02:00
67f942284e ci-mirror: split into ci-venv / ci-precommit / ci-pytest, pytest under xdist
The single ci-mirror manual hook took ~100 s (pre-commit --all-files plus a
serial clean-venv pytest, ~85 s), past the 60 s per-action cap. ci_mirror.sh
now takes a subcommand (venv|precommit|pytest|all; default all = the old
one-shot behaviour) and each step is its own manual hook, ordered so the
venv rebuild runs first in its own call. The clean-venv pytest runs
-n auto --dist worksteal; branch coverage stays at 100% (pytest-cov merges
the workers).

Measured (timeout 58): ci-venv 27.0 s cold / 0.1 s warm, ci-precommit
32.6 s, ci-pytest 12.6 s, flutter-test-coverage 46.5 s, web-coverage 1.7 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-17 16:46:38 +02:00
ac65def3ef deps: vitest + @vitest/coverage-v8 5.0.5 (freshness gate)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-17 14:34:09 +02:00
2539084169 Lock screen "I'm sick": one tap clears an already-sick day's kept lockers
The one-tap button is ALL-OFF. On a day already sick that keeps lockers it
now clears the keep set so every locker stands down; if clearing fails the
refusal is returned and the lock stays up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-17 14:17:05 +02:00
3478ee1d2a Sick panel: move keyboard focus into the overlay
Left on the link, Tab and Enter reached the hidden buttons of the screen
under the backdrop. The primary panel's first switch takes focus once laid
out (Tk skips unmapped widgets when traversing), and the kept-workout note
focuses its Back button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-17 14:11:15 +02:00
8450644a56 Lock screen: "choose lockers…" opens sickdays' KeepPanel in the lock
Next to the one-tap "I'm sick" (unchanged: keeps nothing, unlocks), a small
secondary link lays sickdays 0.3.0's embeddable KeepPanel over every
surface, in-process so it gets input under the lock's grab. Closing the
overlay reveals the untouched screen underneath, so no countdown or phone
check is restarted or skipped.

After the panel's own write + sync: sick with the workout lock standing
down -> sick floor, decision, close (shared with the one-tap path, no second
mark or push); sick with the workout kept -> sick floor and a note, lock
stays; cleared / unchanged / cancel -> the panel just closes.

Tests stub KeepPanel and redirect resolve_paths and sync_quietly in every
sickdays.* module, sickdays.gui._panel included. The fit check measures the
panel on a throwaway pool. Pins bumped to sickdays-v0.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-17 14:06:22 +02:00
8ba9ee93fa workout_app: log manual workouts for future dates, planned list + delete
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
release-apk / release (APK) (push) Has been cancelled
The manual form takes a workout date from today to today+7 (never past). The budget is read as of that date: the fullest 7-/30-day window containing it, past and planned entries alike. History lists future manual entries under PLANNED (off the calendar) and deletes them until their day via a crdt-sync tombstone. Re-logging onto a tombstoned id is refused; a failed push keeps the form open with the reason. Shared fixture manual_budget_merge.json gains a 'planned' scenario (top-level expectations unchanged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mmTBiMpvyfLuEAXc6nDYT
2026-10-16 15:36:27 +02:00
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
1a305c0627 Sick days: per-locker keep (sickdays 0.2.0) for the workout lock
The workout lock, its explanation, the status view and the workday penalty's
penalised day now ask _sick.stands_down_workout: a sick day that keeps the
workout locker behaves as a normal day. The penalty's ring day is judged by
the alarm switch. Shutdown (20:00 floor, grace floor, base, target, preview
scripts) keeps is_sick: the floor belongs to the day, not to a locker. The
one-tap button still keeps nothing. Test pool fixture and preview harness now
redirect every sickdays resolve_paths binding, so keep edits never reach the
real pool. Pins bumped to sickdays-v0.2.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-16 15:18:49 +02:00
0b0a18e81c deps: newest stable (freshness gate); migrate web/ npm -> pnpm
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
web: react/react-dom 19.3.1, vitest + coverage-v8 5.0.4, jsdom 30.1.3,
user-event 14.6.8, typescript-eslint 8.71.2. Lockfile is now
web/pnpm-lock.yaml (packageManager pnpm@12.14.0); hooks and CI use pnpm
with --frozen-lockfile. Node 24.22.0 in workflows; workout_app sqflite
2.4.4+2 / sqflite_common_ffi 2.4.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 14:16:56 +02:00
7d8daca111 sick days: one shared sickdays pool, 20:00 shutdown floor, one-tap lock button
A sick day is now a date in the shared sickdays pool (utils sickdays 0.1.0)
and nothing else. Removed: the 7/30/90-day budget, the escalating lockout
countdown, the justification form, the commitment prompt and its double
debt, the workout-debt counter, the -60 min shutdown cut and its
sick_day_state.json, the post-sick-day --verify-workout mode, the sick
auto-upgrade, scripts/revoke_sick_day.py and their tests.

- Lock screen: "I'm sick" on the retry, no-phone and both Tue-Thu screens;
  one tap -> sickdays.add_sick_day(today) -> sick floor -> best-effort
  background sickdays.sync_quietly -> unlock. Refusals (free day,
  unreadable pool) are shown and the lock stays.
- Shutdown: the reset resolves with earned_time.resolve(..., sick=True)
  (max(20:00, ladder), capped 23:00) and saves the sick lift with the grace
  lift, so later credits absorb it instead of stacking on 20:00. A day
  marked after the reset is lifted by the new _sick_floor live pass (every
  5-min tick, --sync-only, --apply-bonuses, and the tap itself).
- Workout lock and grace floor stand down on a sick day; workouts that day
  credit normally through the sync paths.
- wake-alarm workday_penalty.json is void when its issued_date or
  penalty_date is a sick day.
- Status window, i3 line, CLI --status, MCP flags and web reasons read
  sickdays.
- scripts/migrate_sick_history.py imports sick_history.json's dates via
  sickdays.import_legacy, verifies and removes the file; idempotent, run by
  install.sh.
- Pins: earned-time 0.11.0, freedays 0.4.0, sickdays 0.1.0 (tags pending);
  pre-commit 4.7.0, ruff 0.17.1 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 13:30:39 +02:00
ef477d644b deps: bump flutter_local_notifications 22.3.2, @types/node 26.6.5 (freshness gate)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 21:43:13 +02:00
3c1b983c2a deps: earned-time 0.10.0 (tutor quota per weekday: Tue-Thu 15, Mon/Fri 30, Sat/Sun 50 min)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Claude-Session: https://claude.ai/code/session_01E57caQF4KXwEvqEnKtJiHM

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 21:17:14 +02:00
96ad163ee6 earned_time 0.9.0: tutor earner renamed automation -> tutor
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Pins bumped at all four sites; tests follow TUTOR / tutor_bonus_units.
pylint 4.1.3 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YADHKXzZhhePzQ2rNoXFcK
2026-10-12 16:38:25 +02:00
4b65b7667b chore: add MIT LICENSE
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 18:10:46 +02:00
5eb18b92ca deps: earned_time 0.8.0 (tutor credit per active minute); tutor rung tests for minute rows and legacy blocks
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:32:43 +02:00
384ffcda7d tests: earned_time 0.7.0 tutor policy (confirmed, 15 min per block); hook envs on 0.7.0
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 15:15:35 +02:00
41ce020e10 deps: earned_time 0.7.0 (tutor confirmed, 15 min shutdown per block)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 15:02:56 +02:00
b37b7559d8 workout poke moves to tcp/8774: 8773 is book-guard's desktop wrapper, whose window the poke server had been answering since 2026-10-09 (phone app rebuilt, selftest default, contract doc)
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
release-apk / release (APK) (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrBEzjPRdx5oyp8NtTLW5F
2026-10-10 09:38:21 +02:00
e1f1c668a8 TODO: cap live shutdown bonuses at the day's earned_time ceiling (from the tutor-cutover handoff)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 09:17:58 +02:00
fc5453495c drop the weekly shutdown bonus entirely; every bonus stops at the shutdown ceiling
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 08:59:50 +02:00
87c50825f9 drop the banked extra-workout shutdown bonus (no new hours from 2026-W42; streak + early-bird kept)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
A 5+ workout week no longer banks (count - 4)h, and the every-4-week streak
milestone +1h is gone. The streak counter and the 09:00 early-bird extension
stay. The applying path is untouched, so 2026-W41's already-banked 2h keeps
applying through Sunday 2026-10-11; old weekly_shutdown_bonus_hours keys load
and are preserved as-is. The next-week status preview no longer promises
bonus hours.

Claude-Session: https://claude.ai/code/session_01WfVGhY8XcsQsC7qz9UU8xb

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 08:32:40 +02:00
2140ca5180 deps: earned_time 0.6.1 (tutor cutover; new-gate rungs spare the floor)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 08:24:48 +02:00
3a2ad0245c deps: ruff 0.17.0 (CPY001 ignored: no copyright policy)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 22:05:02 +02:00
215f03ecfc earned_time 0.6.0: maturity-aware penalty start (first_credits); grace floor covers every gate earner
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 21:55:21 +02:00
bbc14ec04c earned_time 0.6.0: per-day registry consumer, sleep-ladder date-proof tests, pin v0.6.0
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 21:36:53 +02:00
19ccad6978 deps: freedays 0.3.1 (pins crdt-sync 0.9.2); pre-commit hook pins back in step
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
freedays 0.3.0 pinned crdt-sync v0.9.1 exactly, which a clean venv could
not resolve next to screen-locker's own crdt-sync 0.9.2. The pylint hook
environments had also drifted from pyproject.toml (crdt-sync 0.9.1,
earned-time 0.3.0).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 20:56:32 +02:00
52fd8217f5 test: pin credit time in TestRealLedgers so the grace floor can't win after 19:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 19:24:34 +02:00
965b9c4ebb Sync: Firebase only — drop the GitHub transport (kuhyx/syncs stays a frozen archive)
The PC push aborted before it ever tried Firebase whenever the GitHub PAT
was missing ("Workouts NOT synced: no sync token"), so once the PAT was lost
this machine published nothing although Firebase was configured and live.
The phone has not written the GitHub repo since 2026-08-21.

- sync_client() / sync_client_or_reason(): firebase_client_for("screen_locker")
  only, proven with can_access_remote(), healed once from a sibling app's
  session, else a warning naming what will not sync (push AND pull) plus a
  recorded degraded source. No config -> warning, no fallback.
- _manual_push and scripts/tombstone_sync_record.py take their client from
  sync_client_or_reason(); the push result carries the reason.
- Deleted: read_sync_token, remote_client, the mirror client, _push_outcome
  (the half-landed-push case no longer exists), SYNC_TOKEN_FILE and
  SYNC_REPO_OWNER/NAME, the sync_token test fixture. Status backend is
  "firebase" or "none".
- crdt-sync pinned to v0.9.2 in pyproject, requirements.txt and
  install_systemd.sh (the installer still said v0.9.0).

kuhyx/syncs/screen-locker-sync is left untouched as an archive; no code
reads it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 19:18:56 +02:00
2dac472331 workout_app: send finished workout to the PC instantly; PC link pairing; summary <100 ms
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 19:08:22 +02:00
a826cfa166 workout-poke: instant phone credit over LAN (signed POST :8773) and Firebase stream
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 19:05:51 +02:00
aedfdbc7f5 Credit: one reward per workout slot, desktop notification, --help, runnerup-watch ignores re-pulls
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 19:05:08 +02:00
f2b8fdf7ea deps: vite 8.3.4
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KejiqD7y4NXRiPBKQeWHSy
2026-10-09 14:58:03 +02:00
7e1b4e865e workout_app: manual deload on demand (workout tile + Settings)
"Deload now" in the exercise settings sheet runs exactly the step a fail
streak would take (targetAfterFailure), resets both streaks, and records a
progression_events row (schema v7, backed up and restored with the rest).
No manual step up: the target still only climbs through finished workouts.

Mid-workout it re-targets the exercise for the current session: untapped
sets move to the new reps, tapped sets keep theirs, and the whole exercise
is recorded at the new weight at Finish. From Settings it cancels pending
weight/reps debounce writes so they cannot undo the deload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KejiqD7y4NXRiPBKQeWHSy
2026-10-09 14:55:10 +02:00
0b809c4af1 deps: Flutter 3.47.7 (fixes red freshness CI)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 10:53:10 +02:00
373a075606 Shutdown: walks as workout, rest days, grace floor, workout ledger (earned-time 0.4.0)
- A day's RunnerUp walks (_Walking.tcx) count once their moving time sums to 40 min
- Rest days: declared ahead, 2 per ISO week, HMAC-signed; pay the first workout unit
- Grace floor: shutdown >= first task + 60 min (capped); skipped on a sick day
- Workout ledger: one signed credit row per log credit slot and rest day
- Day-aware ladder amounts from earned_time 0.4.0; preview_shutdown dry-run script

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrjUTo1yhgAsUUasdw4bpG

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 10:43:45 +02:00
6334bd54bf deps: Gradle 9.8.1 (workout_app wrapper)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 20:44:42 +02:00
ff681aa6af deps: vite 8.3.3
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 20:39:04 +02:00
f77e601893 runnerup-watch: credit a RunnerUp run the moment its TCX upload finishes; lock the workout-log write
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 20:36:38 +02:00
18d3789836 deps: types-requests 20261006, shared_preferences 2.5.6, sqflite 2.4.4+1, @vitejs/plugin-react 6.1.2, typescript-eslint 8.71.1
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 20:58:57 +02:00
d1817675c4 earner-bonus: add the new modules, unit and tests missed by 7851d59
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 20:44:01 +02:00
7851d592fd shutdown bonus: apply the moment a gate writes its ledger (earner-bonus.path)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 20:42:13 +02:00
ec7f4805f7 pin earned-time-v0.3.0 (registers the automation earner)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 15:20:10 +02:00
218d327145 deps: jsdom 30.1.2
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 14:21:27 +02:00
2dd719e6a4 earned-time-v0.2.0 (anki); shutdown projection prices each day on its own date
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 14:18:42 +02:00
275a0862fc shutdown: minutes end to end (*_MINUTES schedule keys, HH:MM helper args, to_hours removed)
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:48:24 +02:00
ed5fa133d0 shutdown: derive base and bonuses from the shared earned_time registry
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
The base hour, the workout/LeetCode/reading hours and the reading cut date
were duplicated here and in steam-backlog-enforcer. They now come from
earned_time (kuhyx/utils, earned-time-v0.1.0): the reset sums the registry,
the live pass iterates its flat earners (stamps unchanged:
<name>_bonus_date), and _leetcode_bonus/_reading_bonus are replaced by the
shared HMAC ledger reader. A gate registered later needs no code here.

The schedule still stores whole hours; _earned.to_hours raises on a
remainder rather than flooring it. Verified identical to the old code on
today's real ledgers and log (reset target, stamps, live passes, base hours).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aktgaq93yt51c6D3X5Fzsm
2026-10-04 18:55:51 +02:00
6f3f93ecec deps: mcp 2.3.0, pylint 4.1.2, url_launcher 6.3.3, @types/node, eslint
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
release-apk / release (APK) (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 18:13:01 +02:00
279a7b1ed7 repo contract: CLAUDE.md commands, test_changed, CI gate
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 17:15:50 +02:00
c81f1bac3f CI: 10 Flutter shards, under the 20-job concurrency limit
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 15:17:16 +02:00
5d9b348436 CI: 12 Flutter test shards so Tests fits in a minute
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 15:14:48 +02:00
429e854ddd CI: slim Flutter SDK for gating jobs; APK only on app changes, Gradle cached
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 15:06:56 +02:00
6f351179d7 Flutter 3.47.6 and web deps to newest stable
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Flutter 3.47.5 -> 3.47.6 (.fvmrc + CI); web: globals 17.13.0,
typescript-eslint 8.71.0, vite 8.3.2, vitest + coverage-v8 5.0.3.
Web lint, 100% coverage and build pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBbkN4ZB4aUPJPzR2prRan
2026-10-02 14:19:46 +02:00
0b0cda6782 Morning-session reader moves to gatelock 0.9.0
_morning_session.py is now a thin binding of screen-locker's path and retry
budget over gatelock.morning_session, shared with leetcode-guard and
book-guard; the verdict tests live there once. Same behaviour, same string.
Also bumps mypy 2.4.0 / pylint 4.1.1 / ruff 0.16.10 (newest stable gate) and
the stale gatelock pins in install_systemd.sh and the pylint hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBbkN4ZB4aUPJPzR2prRan
2026-10-02 14:05:40 +02:00
98bfe7d2c3 workout_app: open straight into today's workout on launch
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 19:01:55 +02:00
deb89b3e03 Relaxed days from freedays.WORKDAYS; delete the dead rtcwake mixin and its stale ALARM_DAYS
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 20:24:01 +02:00
cf2d917b33 Fix red CI: coverage 7.16.2, split workout_screen.dart and modes test
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
- coverage 7.16.1 -> 7.16.2 (dependency freshness).
- workout_screen.dart (253) -> _allSetsCompleted/_isPaused move into
  workout_screen_breaks.dart next to _stateOf.
- storage_service_modes_test.dart (289) -> setExerciseSettings and
  per-mode progression groups move to storage_service_settings_test.dart.

Claude-Session: https://claude.ai/code/session_01KeVXsWTuwjmZK5uvbw6vqF

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 19:06:15 +02:00
d35edfc10c workout_app: warmup ×6, per-exercise rests, exercise sheet in Settings
- Warmup is always 6 reps (kWarmupReps): even, one constant.
- Per exercise: rest after success / fail / warmup (0:30-10:00, 15 s
  steps; defaults 3:00/5:00/3:00). Schema v6 NOT NULL DEFAULT columns,
  synced in the Firebase progression record (lenient for older records).
  The notification carries the fail rest so -1 rep re-cuts to it.
- Settings: PER EXERCISE list replaces PROGRESSION THRESHOLDS; each row
  opens the workout tile's sheet and pushes progression when it closes.

Claude-Session: https://claude.ai/code/session_01KeVXsWTuwjmZK5uvbw6vqF

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:55:56 +02:00
38b56e1e2b workout_app: progression modes, inline warmup, injury pause, no app bar
- Per exercise: weight / reps / double progression (n=12, m=6), one pure
  rule shared by Finish and every preview; streaks and next step shown on
  the tile. Warmup is the first circle of the set row and can be turned off.
- Pause an exercise for n days (default 14): shown as paused, recorded as
  FAILED every workout so it steps down; skipped by the notification.
- App bar removed: Reset/Finish live in the rest strip, no rest labels.
- Schema v5 (progression_mode, reps_high, reps_low, has_warmup, paused_until),
  synced through the Firebase progression record; NULL warmup = plan default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeVXsWTuwjmZK5uvbw6vqF
2026-09-27 17:36:17 +02:00
97f4e49a5f Bump @types/node 26.6.3
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The dependency-freshness gate went red on 26.6.2 while the credential fix
was in flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFwA9nymhUdTrVF65kigTV
2026-09-27 08:39:56 +02:00
0921146044 Tests can no longer write the live desktop Firebase credential
`flutter test` runs on a Linux host, so credentialStore()'s Platform.isLinux
branch wrote $HOME/.config/screen_locker/firebase_auth.json -- the refresh
token the screen-locker daemon signs in with. firebase_backend_google_test's
"stores the account under the email Firebase reports" case overwrote it with
the fixture token 'refresh' on every local run; the daemon only survived by
self-healing from a cached token.

Dart:
- lib/services/desktop_config_dir.dart: one resolver, desktopConfigRoot(),
  for every desktop path (the credential file's read/write/delete and the
  ~/.config/crdt-sync account read). @visibleForTesting override; under
  FLUTTER_TEST it throws StateError if the root is, or is inside, the real
  $HOME/.config -- so a test that bypasses the override fails loudly instead
  of writing. HOME unset now yields null instead of a cwd-relative .config.
- The crdt-sync read resolves the root outside its catch-all, so the guard
  cannot be swallowed as "not connected".
- test/flutter_test_config.dart gives every test its own temp config root.
- The Google sign-in test now asserts the token landed in that temp root.
- path_provider is unbound under flutter test (MissingPluginException), so
  the getApplicationSupportDirectory callers cannot reach live dirs.

Python (tests only): isolate_sync_token now patches every import-time
binding of SYNC_TOKEN_FILE (_constants, _manual_push, _sync_status were
missed), and test_home_isolation scans all runtime modules for any Path
still bound inside the real ~/.config/screen_locker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFwA9nymhUdTrVF65kigTV
2026-09-27 08:35:18 +02:00
7ae4c13407 CI under 60 s: uv, xdist, split pre-commit, sharded Flutter coverage
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Tests (~214 s, gated by flutter-coverage):
- Flutter suite runs as 8 parallel buckets of whole test files
  (check_flutter_coverage.sh --shard I/N); flutter-coverage merges their
  lcov files (--merged N) and applies the unchanged 100% bar and
  every-lib-file-present guard. `flutter test --total-shards` splits test
  cases inside each file, so every shard would still compile all 77 files.
  Proved locally: the merged lcov matches an unsharded run line for line
  (3312/3312), and a missing shard fails. flutter-action caches the SDK.
- pytest: uv install, -n auto -q -p no:sugar (local: 25.9 s -> 10.6 s,
  100% coverage both ways).
- lock-popup-safety: uv, and no apt step -- xvfb-run and setup-python's
  tkinter are already on the runner (screens-fit relies on both already).

pre-commit (~122 s): matrix of mypy / pylint / flutter-analyze / rest, each
installing only its own toolchain, with a per-group hook cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188kZdx3bb56QM8JFUnbHuM
2026-09-26 19:36:32 +02:00
258bbe066a Bump ruff 0.16.9, vite 8.3.1, vitest 5.0.2, @vitest/coverage-v8 5.0.2, Gradle 9.8.0
The dependency-freshness gate (and the pre-commit workflow, which runs the
same hook) went red on these five upstream releases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188kZdx3bb56QM8JFUnbHuM
2026-09-26 19:30:57 +02:00
6a07dc56aa Reading hour from book-guard: +1h shutdown; base 20->19 from 2026-10-01
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 19:02:24 +02:00
2b0f5d96a4 Bump pylint 4.0.8 -> 4.0.9, jsdom 30.1.0 -> 30.1.1, typescript-eslint 8.70.0 -> 8.70.1
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
CI's dependency-freshness check runs live against the registries and
caught these after this session's local pre-commit run used a stale
snapshot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kwx6YYv9Mnw9EtRsJ6hbf
2026-09-23 21:38:35 +02:00
584177ec2e Lose Tue/Wed/Thu leniency when wake-alarm signals a missed prior-day ring
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 21:23:31 +02:00
e86dd57817 SandboxLog.flush(): the logcat test awaits the write instead of guessing a turn
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 22:04:15 +02:00
a03afb8ae7 CI runs the Flutter and web coverage bars; they only ran on pre-push before
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 21:54:16 +02:00
885951ee2b pre-push is a 1-second stamp check; the suites moved to the manual stage
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 19:35:35 +02:00
83d77884a0 Workout screen never moves when a rest starts; a sandbox flavor to prove it on the phone
The rest banner used to be inserted into the column only while a break ran,
so its arrival took height from the exercise list, shifted every tile down
and pushed the last one into scroll range. The strip is now a permanently
reserved 64 dp slot (muted "Rest / 00:00" while idle, Skip keeps its space),
the list is a non-scrolling column that scales down as a whole if it would
not fit (0.96 on the Pixel 6a with four tiles, pinned by a widget test at
the phone's logical size with the status bar simulated), and the activity is
locked to portrait. Verified on the real phone: only the tapped circle's
pixels change when a rest starts.

Verifying that needed a workout screen that today's already-logged workout
did not offer, so the app gained a `sandbox` product flavor beside `daily`:
com.kuhy.workout_app.sandbox installs side by side with its own data, refuses
every network connection at the socket layer, starts no LAN server for the
PC to pull from, keeps its /sdcard files beside the daily build's, wears a
red SANDBOX ribbon, and offers a SANDBOX section in Settings (wipe, mark
today not done, rest length in seconds). MainActivity answers the flavor and
carries a `WorkoutSandbox` logcat trace of every tap, break, save and
navigation. On Linux the same mode is WORKOUT_SANDBOX=1, driven under Xvfb by
scripts/run_sandbox_xvfb.sh. CI builds --flavor daily; .phone-deploy-flavor
tells phone_deploy.sh the same.

Set circles and the home icon buttons carry semantics labels so they can be
addressed by element (and by screen readers).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YbBZqUaNWyhHYfzz6zGC7q
2026-09-20 18:55:08 +02:00
9ab7c09c79 The early-bird window is the wake-alarm carrot, not a clock
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
05:00-08:30 (09:00 with the extended reward) locked nobody but did hold the
workout lock off on a clock that knew nothing about whether the user was
up, next to the morning-session rung that did. EarlyBirdMixin._is_early_bird_time
is now that rung's verdict: open before the alarm, during the session, and
until 11:00 once it completed in time -- exactly what wake-alarm signs. The
wake_alarm_skip rung banks the pending marker so the carrot's end still tries
a phone/RunnerUp workout before locking; early-bird-workout-check.timer moves
to 09:30:30 and 11:00:30, the two possible ends. The consumer's boot-retry
window starts at 05:00 with the refresher.

Also: the sick screen lists four past justifications, one line each -- six
full-length ones pushed it 7 px past 1366x768, which the screens-fit gate
only sees with a real history on disk. Takes @types/node 26.6.2, eslint 10.11.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTdfXjbGYYrY7Quxcfz6mg
2026-09-20 08:39:44 +02:00
53bbdda419 Read the morning-session carrot from wake-alarm's signed file
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
The wake_alarm_skip rung has been dead since the repos split: it read
../wake_alarm/wake_state.json (no longer a path) for a skip_workout field
nothing ever wrote, and answered "no skip" for a year without an error.
It now reads ~/.local/state/wake_alarm/morning_session.json, written
HMAC-signed by wake-alarm every two minutes through the morning, and the
whole decision is: signature ok, dated today, now < exempt_until. A
completed-in-time morning defers the lock to 11:00; a live one until
min(start + 90 min, 09:30); nothing else changes the ladder. The enforce
path waits up to 30 s for a freshly booted PC's refresher; status paths
never wait. Takes Flutter 3.47.5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tp54ewdXRLaimUuT8LgYyn
2026-09-19 16:41:17 +02:00
81284f160b Pin the clock in the sync-degradation tests; CI ran them inside the early-bird window
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 10:07:11 +02:00
c0099dff94 Refuse to build a lock screen over a workout already logged today
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:58:13 +02:00
bdc6cc4df1 Take the freshness gate's two bumps: @types/node 26.6.1, jsdom 30.1.0
The pre-push gate refused the previous commit over these two; both are
patch/minor moves and the web suite (52 tests, 100% coverage) is
unchanged on them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:46:22 +02:00
9b84af7537 Count the manual budget per workout, keep a synced twin's id, and let a logged workout outrank an expired early bird
Three faults that together locked kuhy out on 2026-09-18 with a workout
on disk and "Manual-workout budget exhausted" on the screen.

1. The football logged at 00:02 on 2026-09-13 was rekeyed to its local
   day (keeping workout_id manual:2026-09-12T14:30), published, and pulled
   straight back by the 09:40 tick as a new record: _already_ingested only
   matched sync_record_id (which a locally logged entry never carries) and
   the chokepoint re-derived the id from the corrected date, so the same
   workout landed twice. Ingest now files a synced manual under the wire
   id it arrived with and recognises an entry whose own workout_id is that
   record.

2. count_in_window counted raw manual_workout entries, so the twin took a
   second budget slot for a week (2/2) while the weekly count, which goes
   through credit_key, saw one. The budget now shares credit_key -- the
   rule the docstring says every counter must share -- and distinct
   workouts on one day still cost a slot each.

3. _check_today_state_exits returned False (full lock) from the expired
   early-bird branch before ever consulting has_logged_today, so a manual
   walk entered on the lock screen at 09:32 held for exactly one tick: the
   09:35 run locked again ("also=workout_logged_today"), and kept doing so
   every 5 minutes while the day's marker lasted, which is how a sick day
   got spent over a logged workout. The explain chain already treated the
   expired marker as non-terminal; the live ladder now falls through the
   same way.

_manual_sync.py went over the 250-line cap and is split: draft
reconstruction lives in _manual_sync_draft.py, re-exported from the
original path so patch targets are unchanged.

Replayed on the real log: count_day_credits("2026-09-13") == 1 while the
old count_in_window said 2; after the fix the status API reports
used_7d=1, exhausted=False. test_manual_rekey_dedup.py fails 3/4 on HEAD.
1334 tests, 100% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:44:28 +02:00
8df1ff6d09 Bump ruff, flutter_secure_storage and vitest to latest stable
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:16:44 +02:00
a22ec380ca Credit RunnerUp runs by the day rule, from the WebDAV drop dir, and never twice
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:14:17 +02:00
ae7566628c deps: coverage 7.16.1, @testing-library/dom 10.4.2, eslint-plugin-react-refresh 0.5.7
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 20:07:32 +02:00
02a93f1322 Shutdown +1h on a day with a LeetCode solve; base moves 21:00 -> 20:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 20:06:26 +02:00
b6e89b93dc workout_app: run the break countdown in a foreground service with notifications
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
The between-set break never finished once the phone's screen was off. Three
independent causes (worked through on-device on 2026-09-12; left
uncommitted awaiting the go-ahead, committed here as-is plus one pin bump):

1. The countdown was not wall-clock: `_breakRemaining--` on a
   Timer.periodic, which Android throttles and Doze suspends in a
   backgrounded isolate, so the counter stalled and _onBreakFinished()
   never fired. Now a BreakClock deadline on the wall clock.
2. An expired break was silently dropped on restore (`if (remaining > 0)`
   only) — now cued and logged.
3. No notification infrastructure at all: no package, no
   POST_NOTIFICATIONS / FOREGROUND_SERVICE / WAKE_LOCK.

Now: flutter_foreground_task runs the countdown in a service isolate that
survives BACK and the screen lock; flutter_local_notifications shows an
ongoing "Workout A · break m:ss / Next: …" notification with −1 rep / Done
actions and a "Break over" alert on its own channel (raw break_end sound,
vibration). Button presses go through a persisted BreakIntent queue the
screen drains on resume, so a press with the app killed is not lost.
Finish and Reset stop the service; a startup reaper handles the crash case.

Verified on the phone with the screen off and locked: service alive and
ticking while Dozing, both notifications on the lock screen, channel
importance 5 with the break_end sound. Not verifiable from adb (receiver is
RECEIVER_NOT_EXPORTED): an actual action-button tap, and a true recents
swipe — see stronglift_replacement/TODO-workout-app-v2.md.

Also fixed en route: the break-end sound had never played in any widget
test (AssetSource needs path_provider, which nothing faked, and the failure
was swallowed by catchError); the queue's prune wrote back a stale list.

This commit additionally:
- takes the freshness gate's bump flutter_local_notifications 22.3.1;
- restores web/package.json to HEAD (the tree held a stale pre-bump copy);
- gives scripts/check_flutter_coverage.sh a FLUTTER_TEST_CONCURRENCY knob:
  the default one-isolate-per-core run peaks over 4 GiB and is OOM-killed
  under the resource cap. Default behaviour unchanged.

Gate: flutter analyze clean, 511 tests, 3187/3187 lines (100%), pre-commit
green. Not re-installed on the phone for the 22.3.1 pin: no device attached
at commit time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
2026-09-13 20:28:36 +02:00
86b63108c7 Tombstone sync records durably: a ledger the PC re-pushes every tick
manual:2026-09-13T14:30 -- the synced copy of the football that this
morning's day-key rekey let back in under a second id -- made every
workout-sync tick log "Manual-workout budget exhausted -- not ingested".
Tombstoning it once did not stick: sync_log pulls only the OTHER devices'
logs and _manual_push re-derives the PC's log from log.json each tick, so
the next push overwrote the tombstone ("tombstoned", then "LIVE" five
seconds later). Deletion is monotonic only for records still in the merge.

- New _sync_tombstones: {id: {deleted_hlc, reason, tombstoned_at}} in
  screen_locker/sync_tombstones.json (gitignored with its siblings). The
  HLC is minted once and stored so re-pushing does not churn the store.
  The ledger path is resolved at call time, not as a default argument, so
  the test suite's redirect actually applies -- verified by hashing the
  real ledger before and after the run.
- _manual_push.records_from_workout_log merges the ledger over the live
  derivation, so every push carries the tombstones.
- scripts/tombstone_sync_record.py <id> [--apply --reason ...]: dry run
  reports live/tombstoned; --apply writes the ledger, runs one tick and
  reports what the store holds afterwards. Rerunnable.
- tests/_isolated_state.py redirects SYNC_TOMBSTONES_FILE (both bindings).

Verified: --apply -> "tombstoned"; a second ordinary tick -> "already
tombstoned" with the same HLC; workout-sync.service now logs "Sync record
manual:2026-09-13T14:30 is tombstoned -- skipping it" and the budget line
for that id is gone. log.json untouched (the local entry stays; it shares
the original's credit per duplicate_credits.json).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
2026-09-13 20:20:06 +02:00
a918c169df Take the freshness gate's bump: gatelock 0.8.2
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Pin only — 0.8.2 (wait_for_x_server waits as long as it takes) was
already the installed version the suite ran against; CI's
dependency-freshness gate flagged the 0.8.1 pin on 82f3462.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
2026-09-13 20:06:40 +02:00
82f3462896 Replay today's earned shutdown credit in the daily base reset
The i3bar showed 21:00 on 2026-09-13 with a counted workout on disk. The
football was logged at 00:02 and credited against the previous day's
config (already at the 23 ceiling, so it bought nothing); the 09:15 base
reset then wrote 21/21 and only re-layered the weekly bank, never asking
what today's log had already earned. Live credits are read-add-write, so
every workout credited between local midnight and the first locker tick
of the day lost its hours to the reset that followed.

- _shutdown_base.reset_to_base_if_new_day takes the log and writes
  min(23, base + hours today's entries earned) in one write, using the
  same credit rule as the weekly count. Idempotent: still once per day.
- _workout_credit names FIRST_WORKOUT_BONUS_HOURS=2 /
  EXTRA_WORKOUT_BONUS_HOURS=1 and derives earned_shutdown_bonus_hours()
  from them; _shutdown and the +1h path use the constants instead of
  literals so the replay and the live paths cannot drift.
- _weekly_check.credit_key: a synced copy whose sync_record_id names an
  entry already filed on the same day shares that entry's slot. This
  morning's rekey (967e3cd) let the sync pull the football back in as
  manual:2026-09-13T14:30, which counted as a second credit (weekly 4/5
  instead of 3/5) and would have made the replay ask for 24.
- annotate_duplicate_credits.py passes the day's id index so the ledger
  records the manual duplicate the same way it records phone/PC pairs.

Verified through the real path: cleared last_reset_date, started
workout-locker.service -> "--restore 23 23 5", "Daily base reset:
Mon-Wed=23, Thu-Sun=23 (base 21/21 + 2h already earned today)",
shutdown_countdown.sh prints "23:00"; a second start changed nothing.
leetcode-guard grants no shutdown time anywhere (grepped screen-locker,
digital-wellbeing, i3 config) — the 23:00 comes from the workout alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
2026-09-13 20:00:05 +02:00
f79b3c69cf Take the freshness gate's four bumps: flutter_secure_storage 11.1.1, sqflite 2.4.4, sqflite_common_ffi 2.4.3, @types/node 26.5.1
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Pre-push ci-mirror was red at HEAD on exactly these four; the lock was
regenerated with the pinned Flutter 3.47.4 (which also moves
flutter_secure_storage_platform_interface to 2.1.0 transitively).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FcU5dCcUNHMAMzUNChdrrJ
2026-09-13 09:54:32 +02:00
967e3cd322 Key every workout day on the LOCAL calendar day, not UTC
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
2026-09-13 09:50:06 +02:00
9b39196671 Take gatelock 0.8.1: nested LockConfig
The grab axes are grab=GrabPolicy(kind=..., disable_vt=...,
preempt_weaker_holder=...) and every colour read goes through
config.palette; nothing about the lock's behaviour changed. Also carries
the pylint hook on 4.0.8 with the source-only scope config; the gate
stays at 8.0 until the remaining findings are cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 19:52:38 +02:00
fc9720fafe Take the freshness gate's ten bumps: Flutter 3.47.4, mcp 2.2.0, React 19.3, node 24.21
Web lint, tests and build pass on the new pins; the Python suite is
unaffected (mcp is the optional MCP-server extra).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 19:52:30 +02:00
3444a4e9a4 Re-point paths for the ~ reorganisation (home-tidy)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
2026-09-11 19:07:30 +02:00
e98884b200 Re-point paths for the ~ reorganisation (home-tidy)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
2026-09-11 18:46:28 +02:00
0fb2da9c86 Say what is_scheduled_skip_today actually reads now
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
The MCP field kept its name when its source moved to the shared free-day
pool, which is fine for a persisted log value but misleading for a field
a Claude session reads and reasons about: nothing said it was no longer
this app's scheduled_skips.json, and a true there now means every gate
app in the fleet is standing down, not just this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:06:33 +02:00
814 changed files with 66642 additions and 16229 deletions

2
.fvmrc
View File

@ -1,3 +1,3 @@
{
"flutter": "3.47.2"
"flutter": "3.47.9"
}

View File

@ -7,8 +7,31 @@ on:
branches: [main]
jobs:
# One ~120 s job used to install Flutter, Node and all of requirements.txt
# and then run every hook in series; mypy (~20 s), flutter analyze (~13 s
# plus the SDK) and pylint (~12 s) dominated it. Each now gets its own runner
# with only the toolchain it needs, and the other hooks share `rest`; the
# workflow takes as long as the slowest group rather than the sum.
pre-commit:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- group: mypy
hooks: mypy
skip: ""
# Both pylint hooks (source and tests) share the id `pylint`.
- group: pylint
hooks: pylint
skip: ""
- group: flutter
hooks: flutter-analyze
skip: ""
- group: rest
hooks: ""
skip: mypy,pylint,flutter-analyze
name: pre-commit (${{ matrix.group }})
steps:
- uses: actions/checkout@v4
@ -26,32 +49,74 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# The flutter-analyze pre-commit hook shells out to `flutter analyze` in
- uses: astral-sh/setup-uv@v10.2.0
with:
enable-cache: true
cache-dependency-glob: requirements.txt
# The flutter-analyze hook shells out to `flutter analyze` in
# stronglift_replacement/workout_app. Pin to the version the app is
# developed against (Dart SDK ^3.12.2) so CI's analyzer matches local.
- uses: subosito/flutter-action@v2
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
if: matrix.group == 'flutter'
with:
flutter-version: 3.47.2
channel: stable
cache: true
# The web-lint / web-typecheck / web-coverage hooks run npm inside web/.
# Without its installed packages `tsc --build` cannot find the
# 'vite/client' and 'node' type libraries and fails with TS2688 -- which
# is what this workflow had been doing on every run since the status UI
# landed in 8f91158.
- uses: actions/setup-node@v4
with:
node-version: "24.20.0"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install status-UI packages
run: npm ci
working-directory: web
- name: Install dependencies
run: pip install -r requirements.txt
flutter-version: 3.47.9
working-directory: stronglift_replacement/workout_app
# analyze needs resolved packages (.dart_tool); the hook runs analyze
# only, so fetch deps here first.
- name: Resolve Flutter packages
if: matrix.group == 'flutter'
run: flutter pub get
working-directory: stronglift_replacement/workout_app
- uses: pre-commit/action@v3.0.1
# The web-lint / web-typecheck hooks run pnpm inside web/. Without its
# installed packages `tsc --build` cannot find the 'vite/client' and
# 'node' type libraries and fails with TS2688 -- which is what this
# workflow had been doing on every run since the status UI landed in
# 8f91158.
# No `version:` -- pnpm/action-setup reads `packageManager` from
# web/package.json (it errors if both are given).
- uses: pnpm/action-setup@v4
if: matrix.group == 'rest'
with:
package_json_file: web/package.json
- uses: actions/setup-node@v4
if: matrix.group == 'rest'
with:
node-version: "26.13.0"
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install status-UI packages
if: matrix.group == 'rest'
run: pnpm install --frozen-lockfile
working-directory: web
# Every hook brings its own environment except the language: system
# ones, and those all run in `rest`: screens-fit imports screen_locker
# (and so its runtime deps) and dependency-freshness needs pyyaml and
# packaging. The other groups need pre-commit alone. uv with a cached
# wheel store replaces a ~27 s pip install.
- name: Install requirements for the system hooks
if: matrix.group == 'rest'
run: uv pip install --system -r requirements.txt pyyaml packaging
- name: Install pre-commit
if: matrix.group != 'rest'
run: uv pip install --system pre-commit
# One cache per group: a shared key would hold whichever group saved
# first, and the others would rebuild their hook envs on every run. The
# restore key falls back to the old single pre-commit/action cache,
# which already holds every hook env, so a group's first run does not
# rebuild pylint's git dependencies from scratch.
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ matrix.group }}-${{ env.pythonLocation }}-${{ hashFiles('.pre-commit-config.yaml') }}
restore-keys: |
pre-commit-${{ matrix.group }}-${{ env.pythonLocation }}-
pre-commit-3|${{ env.pythonLocation }}|
- name: pre-commit run (${{ matrix.group }})
env:
SKIP: ${{ matrix.skip }}
run: pre-commit run ${{ matrix.hooks }} --all-files --show-diff-on-failure --color=always

View File

@ -6,6 +6,13 @@ on:
pull_request:
branches: [main]
env:
# Bucket count for the Flutter suite; the matrix below lists 0..N-1.
# 10, not more: the account runs at most 20 jobs at once, and a push also
# starts ~10 others (pre-commit, freshness, the rest of this file). At 12
# three shards queued for up to 34 s and the workflow took 91 s.
FLUTTER_SHARDS: 10
jobs:
test:
runs-on: ubuntu-latest
@ -17,11 +24,22 @@ jobs:
with:
python-version: "3.x"
- name: Install dependencies
run: pip install -r requirements.txt
# uv with a cached wheel store installs requirements.txt in seconds;
# plain pip took ~26 s of a ~70 s job.
- uses: astral-sh/setup-uv@v10.2.0
with:
enable-cache: true
cache-dependency-glob: requirements.txt
- name: Install dependencies
run: uv pip install --system -r requirements.txt
# -n auto spreads the suite over every runner core (pytest-xdist is in
# requirements.txt); pytest-cov merges the workers' coverage, and every
# worker loads conftest.py, so the state-file redirects hold in each.
# -q cancels pyproject's -v: 1368 test names were most of the log.
- name: Run tests with coverage
run: python -m pytest -v
run: python -m pytest -n auto -p no:sugar -q
# The pytest suite replaces the whole `tk` module so it can never reach a
# display, which means the lock-screen widget behaviour it guards cannot be
@ -35,17 +53,94 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.x"
- uses: astral-sh/setup-uv@v10.2.0
with:
enable-cache: true
cache-dependency-glob: requirements.txt
# No apt step: the runner image already ships Xvfb, and
# setup-python's interpreter has its own tkinter (python3-tk is for the
# system python, which this job never runs). The apt-get update +
# install cost ~35 s. If either ever goes missing the check does not
# skip -- _xvfb_reexec returns 1 without Xvfb, and the import fails
# without tkinter.
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y xvfb python3-tk
pip install -r requirements.txt
uv pip install --system -r requirements.txt
# The script imports screen_locker directly rather than through
# pytest, which would put the rootdir on sys.path for it.
pip install -e . --no-deps
uv pip install --system -e . --no-deps
- name: Sport selector survives gatelock's recovery tick
# Run as a module (like the screen-fits gate) so scripts/ resolves as a
# package and the check can import the helpers it was split into.
run: python3 -m scripts.verify_lock_popup_safety
# The two coverage bars below used to run only in the local pre-push hook,
# which became a 1-second stamp check on 2026-09-20 (pre-commit's manual
# stage holds them now). Nothing else in CI runs the Flutter or web suites,
# so these jobs are the only place the 100% bars are enforced on every push.
#
# The Flutter suite took ~150 s on one runner, nearly all of it compiling 77
# test files, so it runs as FLUTTER_SHARDS buckets of whole files in
# parallel and flutter-coverage applies the unchanged 100% bar and
# every-lib-file-present guard to their union.
flutter-shard:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
name: flutter test (shard ${{ matrix.shard }})
steps:
- uses: actions/checkout@v4
# The slim cached SDK (utils/flutter-setup): subosito's full-SDK
# cache cost ~38 s to restore even on a hit.
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
with:
flutter-version: 3.47.9
working-directory: stronglift_replacement/workout_app
- name: flutter test --coverage, bucket ${{ matrix.shard }}
run: bash scripts/check_flutter_coverage.sh --shard "${{ matrix.shard }}/$FLUTTER_SHARDS"
- uses: actions/upload-artifact@v4
with:
name: flutter-lcov-${{ matrix.shard }}
path: stronglift_replacement/workout_app/coverage/lcov.info
if-no-files-found: error
retention-days: 1
flutter-coverage:
needs: flutter-shard
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: flutter-lcov-*
path: lcov-shards
- name: flutter test --coverage, 100% of lib/ (workout app)
run: bash scripts/check_flutter_coverage.sh --merged "$FLUTTER_SHARDS" lcov-shards/*/lcov.info
web-coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
package_json_file: web/package.json
- uses: actions/setup-node@v4
with:
node-version: 26.13.0
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: vitest --coverage, 100% (status web UI)
run: |
pnpm --dir web install --frozen-lockfile
pnpm --dir web run coverage

View File

@ -3,6 +3,14 @@ name: release-apk
on:
push:
branches: [main]
# A publisher, not a gate: build only when something that ends up in
# the APK changed. versionCode is the commit count, so skipped pushes
# still leave it monotonic.
paths:
- 'stronglift_replacement/workout_app/**'
- '.fvmrc'
- '.versioncode-offset'
- '.github/workflows/release-apk.yml'
# Lets a release be rebuilt from the current main without a dummy commit.
workflow_dispatch:
@ -47,10 +55,12 @@ jobs:
with:
distribution: temurin
java-version: "17"
# ~/.gradle caches + wrapper, keyed on the Gradle files.
cache: gradle
- uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.2
flutter-version: 3.47.9
channel: stable
cache: true
@ -96,7 +106,7 @@ jobs:
- name: Build signed APK
run: |
flutter build apk --release \
flutter build apk --release --flavor daily \
--build-name="${{ steps.ver.outputs.semver }}" \
--build-number="${{ steps.vc.outputs.code }}"
@ -106,7 +116,7 @@ jobs:
- name: Verify the APK is release-signed
run: |
set -euo pipefail
apk=build/app/outputs/flutter-apk/app-release.apk
apk=build/app/outputs/flutter-apk/app-daily-release.apk
signer=$(ls -d "$ANDROID_SDK_ROOT"/build-tools/* | sort -V | tail -1)/apksigner
"$signer" verify --print-certs "$apk" | tee /tmp/certs.txt
if grep -qi 'CN=Android Debug' /tmp/certs.txt; then
@ -127,7 +137,7 @@ jobs:
run: |
set -euo pipefail
out="workout-app-${{ steps.ver.outputs.semver }}-build.${{ steps.vc.outputs.code }}.apk"
cp build/app/outputs/flutter-apk/app-release.apk "$out"
cp build/app/outputs/flutter-apk/app-daily-release.apk "$out"
echo "path=$out" >> "$GITHUB_OUTPUT"
- name: Publish the per-commit prerelease

25
.github/workflows/repo-contract.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: repo-contract
# The local pre-commit hook can be skipped (a fresh clone has no hooks), so the
# contract is enforced on push too. Mirrors md-naming.yml.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
repo-contract:
name: repo contract
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check out the shared gate
uses: actions/checkout@v4
with:
repository: kuhyx/utils
path: .utils
- name: Enforce the repo contract
run: bash .utils/scripts/check_repo_contract.sh

9
.gitignore vendored
View File

@ -19,12 +19,16 @@ htmlcov/
screen_locker/log.json
# ...and the ledger naming which of its entries record the same workout.
screen_locker/duplicate_credits.json
# ...and the sync tombstones the PC re-pushes every tick (names a workout id).
screen_locker/sync_tombstones.json
# ...including the timestamped backups a correction script leaves behind.
screen_locker/*.json.bak-*
screen_locker/sick_history.json
screen_locker/extra_benefits_state.json
screen_locker/scheduled_skips.json
screen_locker/shutdown_base.json
screen_locker/shutdown_base.lock
screen_locker/log.lock
screen_locker/sick_day_state.json
screen_locker/early_bird_pending.json
@ -34,6 +38,11 @@ screen_locker/early_bird_pending.json
# Web status UI (web/) build and tooling output.
web/node_modules/
web/dist/
web/.astro/
web/coverage/
web/node_modules
web/dist
screen_locker/rest_days.json
screen_locker/grace_floor.json
screen_locker/grace_floor.lock
screen_locker/rest_days.lock

1
.phone-deploy-flavor Normal file
View File

@ -0,0 +1 @@
daily

View File

@ -8,14 +8,21 @@ default_language_version:
default_stages: [pre-commit]
fail_fast: false
default_install_hook_types: [pre-commit, pre-push, post-commit, post-merge, post-rewrite]
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
# run_tcx holds byte-exact RunnerUp exports; the golden test compares the
# encoder's output with them, so the fixers must never rewrite them.
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
exclude: ^screen_locker/tests/fixtures/run_tcx/
- id: end-of-file-fixer
exclude: ^screen_locker/tests/fixtures/run_tcx/
- id: check-yaml
# pnpm-lock.yaml is a two-document stream by design.
exclude: ^web/pnpm-lock\.yaml$
- id: check-json
- id: check-toml
- id: check-added-large-files
@ -36,6 +43,12 @@ repos:
- repo: local
hooks:
- id: repo-contract
name: repo contract (CLAUDE.md Commands + test_changed)
entry: scripts/check_repo_contract.sh
language: system
pass_filenames: false
files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$|(^|/)(AndroidManifest\.xml|build\.gradle(\.kts)?|strings\.xml|pubspec\.yaml|repo-contract\.toml|[^/]+\.desktop|assets/icon/[^/]+\.png)$
- id: no-noqa
name: Block noqa comments
entry: '(?i)#\s*(noqa|type:\s*ignore)'
@ -56,7 +69,7 @@ repos:
files: ^(screen_locker/.*\.py|requirements\.txt)$
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.2
rev: v0.17.1
hooks:
- id: ruff
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
@ -98,7 +111,10 @@ repos:
- types-requests>=2.31.0
- repo: https://github.com/pylint-dev/pylint
rev: v3.3.2
# Same release as requirements.txt. No --jobs here: pyproject.toml holds
# it at 1, because parallel pylint pickles through dill, which crashes on
# Python 3.15 (see the comment on `jobs` there).
rev: v4.1.3
hooks:
# Two hooks, because the three checks below are not defects in a test
# suite -- they are how pytest and unit-testing private mixins work --
@ -111,34 +127,36 @@ repos:
# duplicate-code near-identical `with patch(...)` preambles; a test
# should read top-to-bottom without chasing a fixture.
# Splitting also removes the old failure mode where the score depended on
# what else happened to be staged alongside. See
# todo_fix_pylint_10_prompt.md for raising both to a hard 10.00.
# what else happened to be staged alongside. Both are a hard 10.00.
- id: pylint
name: pylint (source)
name: pylint (source, 10.00)
alias: pylint-source
exclude: ^screen_locker/tests/
args:
- --rcfile=pyproject.toml
- --fail-under=8.0
- --jobs=4
- --fail-under=10
additional_dependencies:
- pytest
# Kept in step with pyproject.toml: pip refuses two different
# direct URLs for one distribution, so any pin that drifts from
# the manifest becomes an unresolvable hook environment rather
# than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
# Same pin as workout_app/tools/render_finish_chime.sh installs.
- music_theory @ git+https://github.com/kuhyx/utils@music-theory-v0.1.0#subdirectory=music_theory
- mcp>=2,<3
- pydantic
- id: pylint
name: pylint (tests)
name: pylint (tests, 10.00)
alias: pylint-tests
files: ^screen_locker/tests/
args:
- --rcfile=pyproject.toml
- --fail-under=8.0
- --jobs=4
- --fail-under=10
- --disable=protected-access,unused-argument,duplicate-code
additional_dependencies:
- pytest
@ -146,9 +164,11 @@ repos:
# direct URLs for one distribution, so any pin that drifts from
# the manifest becomes an unresolvable hook environment rather
# than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
- mcp>=2,<3
- pydantic
@ -167,21 +187,67 @@ repos:
- repo: local
hooks:
- id: ci-mirror
name: CI mirror (clean-venv pytest + pre-commit --all-files)
entry: bash scripts/ci_mirror.sh
# The CI mirror, one step per hook so each call stays under the 60 s
# action cap. Order matters: ci-venv (re)builds .ci-mirror-venv from
# requirements.txt when it changed, so ci-pytest only reuses it.
# `pre-commit run --all-files` is split into four ci-precommit-* steps
# (each well under the cap); together they run every default-stage hook
# exactly once -- see run_precommit_group in scripts/ci_mirror.sh.
- id: ci-venv
name: CI mirror (clean requirements.txt venv)
entry: bash scripts/ci_mirror.sh venv
language: system
pass_filenames: false
always_run: true
require_serial: true
stages: [pre-push]
stages: [manual]
- id: ci-precommit-pylint-source
name: "CI mirror (pre-commit --all-files: pylint source)"
entry: bash scripts/ci_mirror.sh precommit pylint-source
language: system
pass_filenames: false
always_run: true
require_serial: true
stages: [manual]
- id: ci-precommit-pylint-tests
name: "CI mirror (pre-commit --all-files: pylint tests)"
entry: bash scripts/ci_mirror.sh precommit pylint-tests
language: system
pass_filenames: false
always_run: true
require_serial: true
stages: [manual]
- id: ci-precommit-flutter-web
name: "CI mirror (pre-commit --all-files: flutter analyze + web lint/typecheck)"
entry: bash scripts/ci_mirror.sh precommit flutter-web
language: system
pass_filenames: false
always_run: true
require_serial: true
stages: [manual]
- id: ci-precommit-rest
name: "CI mirror (pre-commit --all-files: every other hook)"
entry: bash scripts/ci_mirror.sh precommit rest
language: system
pass_filenames: false
always_run: true
require_serial: true
stages: [manual]
- id: ci-pytest
name: CI mirror (clean-venv pytest, xdist)
entry: bash scripts/ci_mirror.sh pytest
language: system
pass_filenames: false
always_run: true
require_serial: true
stages: [manual]
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
hooks:
- id: codespell
args:
- --skip=*.json,*.lock,.git,__pycache__,.venv
- --skip=*.json,*.lock,*pnpm-lock.yaml,.git,__pycache__,.venv
- --ignore-words-list=als,ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile,tabel,pary,blok,bloc,proces,serwer,parametr,adres,hart,dout,metod,tekst,synonim,grup,mosty,lokal,skalar,milion,nowe,tre,hel,alph
- repo: local
@ -208,11 +274,13 @@ repos:
# the gate it was being refactored to satisfy. ts/tsx joined on
# 2026-08-29 with web/, for exactly the same reason: a language absent
# from this list is a directory the cap silently does not apply to.
types_or: [python, shell, dart, ts, tsx]
# c/c++ and markdown joined on 2026-11-01 (the Linux runner had grown
# to 258; prose is under the same cap per the 250-line rule).
types_or: [python, shell, dart, ts, tsx, c, c++, markdown]
exclude: '^web/(node_modules|dist|coverage)/'
- id: no-silent-failures
name: Block silent failures (swallowed exceptions and silent sys.exit(0) must say why)
entry: python3 scripts/check_silent_failures.py
entry: python3 -m scripts.check_silent_failures
language: system
types: [python]
- id: no-silent-failures-dart
@ -220,7 +288,7 @@ repos:
# single Dart file — in the app where a swallowed sync error costs the
# user a workout with nothing anywhere saying so.
name: Block silent failures in Dart (swallowed errors must log why)
entry: python3 scripts/check_silent_failures_dart.py
entry: python3 -m scripts.check_silent_failures_dart
language: system
types: [dart]
- id: screens-fit
@ -240,44 +308,78 @@ repos:
language: system
files: '^stronglift_replacement/workout_app/.*\.(dart|yaml)$'
pass_filenames: false
- id: flutter-test-coverage
name: flutter test --coverage 100% (workout app)
entry: bash scripts/check_flutter_coverage.sh
- id: flutter-test-coverage-shard-0
name: flutter test bucket 0/4 (workout app, local gate)
entry: bash scripts/check_flutter_coverage.sh --gate-shard 0
language: system
files: '^stronglift_replacement/workout_app/.*\.dart$'
pass_filenames: false
always_run: true
require_serial: true
stages: [pre-push]
stages: [manual]
- id: flutter-test-coverage-shard-1
name: flutter test bucket 1/4 (workout app, local gate)
entry: bash scripts/check_flutter_coverage.sh --gate-shard 1
language: system
pass_filenames: false
always_run: true
require_serial: true
stages: [manual]
- id: flutter-test-coverage-shard-2
name: flutter test bucket 2/4 (workout app, local gate)
entry: bash scripts/check_flutter_coverage.sh --gate-shard 2
language: system
pass_filenames: false
always_run: true
require_serial: true
stages: [manual]
- id: flutter-test-coverage-shard-3
name: flutter test bucket 3/4 (workout app, local gate)
entry: bash scripts/check_flutter_coverage.sh --gate-shard 3
language: system
pass_filenames: false
always_run: true
require_serial: true
stages: [manual]
# Fails unless every shard above passed on THIS tree, then applies the
# unchanged 100% bar and every-lib-file guard to their union.
- id: flutter-test-coverage-merge
name: flutter coverage 100% (union of the 4 buckets)
entry: bash scripts/check_flutter_coverage.sh --gate-merge
language: system
pass_filenames: false
always_run: true
require_serial: true
stages: [manual]
# web/ gates. steam-backlog-enforcer, which this app was copied from, has
# only a coverage hook -- its lint and typecheck are unenforced, so both
# can rot without anything failing. All three run here.
- id: web-lint
name: eslint (status web UI)
entry: npm --prefix web run lint
entry: pnpm --dir web run lint
language: system
files: '^web/(src/.*\.(ts|tsx)|eslint\.config\.js|package\.json)$'
files: '^web/(src/.*\.(ts|astro)|eslint\.config\.js|package\.json)$'
pass_filenames: false
require_serial: true
- id: web-typecheck
name: tsc --build (status web UI)
entry: npm --prefix web run build
name: astro check + build (status web UI)
entry: pnpm --dir web run build
language: system
files: '^web/(src/.*\.(ts|tsx)|tsconfig.*\.json|vite\.config\.ts)$'
files: '^web/(src/.*\.(ts|astro)|tsconfig\.json|astro\.config\.mjs|vitest\.config\.ts)$'
pass_filenames: false
require_serial: true
- id: web-coverage
name: vitest --coverage 100% (status web UI)
entry: npm --prefix web run coverage
entry: pnpm --dir web run coverage
language: system
files: '^web/src/'
pass_filenames: false
require_serial: true
stages: [pre-push]
stages: [manual]
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
# Shared gate in ~/utils owns the namespaces and the exemption list, so
# Shared gate in ~/src/utils owns the namespaces and the exemption list, so
# one repo's idea of a valid name cannot drift from every other repo's.
- id: md-naming
name: markdown naming convention
@ -285,6 +387,30 @@ repos:
language: system
files: \.(md|markdown)$
- id: todo-closer
name: close Todo trailer tasks
entry: scripts/todo_hooks.sh closer
language: system
stages: [post-commit]
always_run: true
pass_filenames: false
- id: todo-import-pr-merge
name: import PR Todo sections (pull)
entry: scripts/todo_hooks.sh import-pr post-merge
language: system
stages: [post-merge]
always_run: true
pass_filenames: false
- id: todo-import-pr-rewrite
name: import PR Todo sections (pull --rebase)
entry: scripts/todo_hooks.sh import-pr post-rewrite
language: system
stages: [post-rewrite]
always_run: true
pass_filenames: false
# Every dependency on its ecosystem's newest STABLE release, exact-pinned
# so the question is mechanically answerable at all.
#
@ -306,3 +432,17 @@ repos:
|Cargo\.(toml|lock)|go\.(mod|sum)
|\.fvmrc|\.nvmrc|\.python-version
|\.github/workflows/.*\.ya?ml)$
- repo: local
hooks:
# The only pre-push hook. Everything that used to run here is now
# `stages: [manual]` -- run on demand with `scripts/check_gate_stamp.sh
# full`, and by CI on every push. finish_auto.sh stamps HEAD after its
# gate; a tree without that receipt cannot leave the machine.
- id: gate-stamp
name: pushed tree passed the local gate (stamp check)
entry: scripts/check_gate_stamp.sh check
language: system
pass_filenames: false
always_run: true
stages: [pre-push]

100
AGENTS.md
View File

@ -2,7 +2,14 @@ do NOT run tests unless specifically instructed to do so or before committing
If tests fail on the same issue twice in a row, STOP and ask the user how to proceed instead of continuing to fix and retry.
ALWAYS confirm that the feature you add / bug you fixed behaves as it should by running the program after your changes (not tests!) and inspecting output comparing it with what user wanted, after confirming by yourself ask user if the program behaves as they intended
After running tests fix all coverage gaps and issues, do not ignore unless specifically instructed to do so
You are NOT done until you install the new version on the phone itself (flutter install --debug from the workout_app directory, then adb shell monkey -p com.kuhy.workout_app to launch).
You are NOT done until you install the new version on the phone itself:
`bash ~/.claude/scripts/phone_deploy.sh stronglift_replacement/workout_app`
(it picks a safe versionCode and launches; never a bare `flutter install`).
To drive a whole workout on the phone without touching real data, deploy the
sandbox flavor instead: `... --flavor sandbox`. That is a separate package
(`com.kuhy.workout_app.sandbox`) with its own data, no network, no LAN server,
a red SANDBOX ribbon, and a SANDBOX section in Settings (wipe, "mark today not
done", rest length in seconds). Its trace is `adb logcat -s WorkoutSandbox`.
## NEVER fail silently
@ -24,3 +31,94 @@ weeks because a missing token `return`ed `[]` and real errors only logged at
should read like a sentence a human can act on.
- **No escape hatch** — `# noqa` is banned repo-wide. If a failure really is
benign, log it at `warning` and move on.
## The shutdown hour is derived, never incremented into
`/etc/shutdown-schedule.conf` is recomputed from scratch by
`_shutdown_base.reset_to_base_if_new_day` on each new day: the base plus
every earned term, capped at 23. The numbers are NOT in this repo: they live
in the shared earner registry `earned_time` (kuhyx/utils, `earned_time/`),
which steam-backlog-enforcer reads for gaming time too. Base = 20:00 minus
each earner's cut once its `penalty_from` day arrives (reading, from
2026-10-01 -> 19:00); terms = workout 2h + 1h per further session, LeetCode
1h, reading 1h, plus whatever is registered later.
Anything that pushes the hour with a plain read-add-write and is *not* a term
of that derivation gets wiped by the next reset -- that is how a 00:02 workout
lost its hours on 2026-09-13. A new bonus source is an `Earner` in the
registry, never code here: the reset includes it as a term, and the live pass
(`apply_flat_bonuses_if_new`, stamped `<name>_bonus_date` in
`shutdown_base.json`) picks it up for flat earners. That pass runs the moment
a gate writes its ledger: `earner-bonus.path` (rendered from the registry by
`_earner_units`, re-synced by every `--sync-only` pass) starts
`earner-bonus.service` (`--apply-bonuses`). The 15-minute `workout-sync` is
only the fallback. The reset and the pass hold `_bonus_lock`, since three
processes can run them at once. The base is the registry
constant, not the state file: the file once persisted `base_*_hour`, which
made the code's default dead.
A sick day is a date in the shared `sickdays` pool, read only through
`_sick.py`. It may keep lockers: the workout lock asks
`_sick.stands_down_workout` (keep-aware), while everything that only computes
shutdown (`_sick_floor`, `_shutdown_base`, `_shutdown_target`, the preview
scripts) asks `is_sick` and ignores keep. A sick day's shutdown is absolute:
20:00 plus the GAMING pay of everything credited that day, capped at 23:00
(`earned_time.sick_shutdown_from`, 0.13). The reset writes it, `_sick_floor`
re-derives it on every live pass from the ledgers, and a sick-day credit only
raises to it (`_raise_on_sick_day`), never adds -- its row is already in the
sum. No lift is saved on a sick day.
## A shutdown never blocks submitting a finished task
A credit timed at or after tonight's shutdown moves tonight to
`min(23:00, the credit's OWN time + its reward)` (`_late_credit.late_tonight`):
only the 23:00 ceiling is hard. The anchor is the credit's own time (the run's
end via `_workout_ledger.entry_done_at`, the ledger row via
`_earned.first_credit_time`), never the sync time, so a late upload buys
nothing. It is live-only: the next day's reset re-derives from scratch. This
replaced the first-credit +60 grace floor (gone 2026-10-17); `_grace_floor`
keeps only `first_done_at`, `tonight_minutes` and the lift file, which
`plan_absorb` spends once if a stale one is left.
Flat earners are read from their gate's ledger by `earned_time.done_today`
via `_earned.py`: HMAC-verified `credit` rows matched by the earner's own
rule (LeetCode's `submitted_at`, book-guard's `detail.bonus == "1"` +
`ended_at`), and *fail closed* -- an unreadable ledger or key earns nothing,
logged. The gates stay read-only; none writes the config.
The schedule is in minutes after midnight end to end. `/etc/shutdown-schedule.conf`
holds authoritative `MON_WED_MINUTES` / `THU_SUN_MINUTES` / `MORNING_END_MINUTES`
plus strict-rounded legacy `*_HOUR` copies (evening floor, morning ceil) for old
readers; `read_shutdown_config` prefers `*_MINUTES` and falls back to `*_HOUR` x 60.
`adjust_shutdown_schedule.sh` takes `HH:MM` (or a legacy bare hour) and the
helpers `_earned.hhmm` / `_earned.span` render times and durations, so a
30-minute earner is applied exactly.
## The morning session is the carrot — and the early-bird window is it
`EarlyBirdMixin._is_early_bird_time` no longer reads a clock: it is open
exactly while wake-alarm's signed file grants an exemption (before the
alarm, during the session, until 11:00 once it completed in time). The
05:00–08:30 window and the extended-to-09:00 reward locked someone who got
up at 07:00 and were a second mechanism next to the one that knows whether
the user is up (2026-09-20). The pending marker is banked by the
`wake_alarm_skip` rung; `early-bird-workout-check.timer` (09:30:30, 11:00:30)
re-checks just after the carrot's two possible ends.
The `wake_alarm_skip` rung reads wake-alarm's HMAC-signed
`~/.local/state/wake_alarm/morning_session.json` (`_morning_session.py`), not
Firebase: wake-alarm is the only reader, and the previous channel here pointed
at a path that stopped existing when the repos split and answered "no skip"
for a year without an error. The whole decision is *signature ok, dated today,
now < exempt_until*; the 09:30/11:00 cutoffs live in wake-alarm
(`DOCS-morning-session-pc.md`) and must not be re-derived here. The enforce
path waits up to 30 s for a freshly booted PC's refresher; status paths never
wait, and tests pin the retry to 0.
## Commands
- run: `./run.sh` (locks the screen; never launch on the live display for checks)
- test: `python -m pytest -n auto -p no:sugar -q`
- test-changed: `scripts/test_changed.sh`
- lint: `pre-commit run ruff --all-files`
- coverage: `python -m pytest -n auto -p no:sugar -q`
- coverage-gaps: `coverage-gaps coverage.lcov`

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Krzysztof Rudnicki
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,8 +1,72 @@
# screen-locker
Tkinter/systemd screen locker with workout tracking, sick-day management, and
Tkinter/systemd screen locker with workout tracking, shared sick days, and
wake-alarm integration. It enforces a workout cadence by locking the screen
until a RunnerUp-verified (or explicitly justified) workout is logged.
until a verified (RunnerUp, StrongLifts) or budgeted manual workout is logged.
## Sick days
A sick day is a date in the fleet-wide [`sickdays`](https://github.com/kuhyx/utils/tree/main/sickdays)
pool, nothing else: no budget, countdown, justification or debt. The lock
screen's **I'm sick** button (retry screen, no-phone screen and the Tue-Thu
prompt) marks today with one tap and unlocks; `sickdays add` or the phone do
the same. On a sick day the workout lock stands down,
wake-alarm's workday penalty is void, and shutdown is 20:00 plus the gaming
pay of everything credited that day, capped at 23:00
(`earned_time.sick_shutdown_from`). A day marked after its daily reset is
raised to it by the next live pass (`_sick_floor`), and every later credit
that day raises it again.
A credit landing at or after tonight's shutdown is never lost: tonight
becomes `min(23:00, the credit's own time + its reward)` (`_late_credit`). `install.sh` migrates the old
`sick_history.json` dates into the pool once (`scripts/migrate_sick_history.py`).
A sick day may *keep* lockers (`sickdays add --keep workout`). A kept workout
lock behaves exactly as on a normal day (lock, quota, relaxed-day penalty on
that date); only the 20:00 shutdown floor still applies, since it belongs to
the day, not to a locker. The one-tap button keeps nothing. The missed-alarm
penalty is void on its ring day only if `alarm` is off, and on its penalty day
only if `workout` is off. All of it goes through `_sick.stands_down_workout`.
## Shutdown time
Every day starts at **19:00** from 2026-10-01, 20:00 before it (`base_minutes()` in
`screen_locker/_shutdown_base.py`)
and bonuses push it later, capped at 23:00: the first counted workout +2h,
each further one +1h, an accepted LeetCode submission that day +1h (flat,
read from leetcode-guard's ledger, once per day), and a credited paper-book
reading session of 20+ pages / 20+ minutes +1h (flat, read from
[book-guard](https://github.com/kuhyx/book-guard)'s ledger, once per day, keyed
on when the reading ended). The config is re-derived from
those sources on each new day, so nothing has to be "remembered" across reboots.
A RunnerUp run is credited from its TCX export, found in the WebDAV drop
directory RunnerUp uploads to on this machine (`~/data/cloud/RunnerUp/`, plus
`processed/` once endurain-import has taken it) or, when a phone is adb-visible,
in `/sdcard/Documents/RunnerUp/`. The 15-minute `workout-sync.timer` scans both,
so a run finished after 20:00 pushes the hour later without the phone being
plugged in; the per-minute shutdown check then lifts the night lockdown on its
own once the hour is past "now" (digital-wellbeing's check script).
Runs and walks are credited by ONE rule, whatever the TCX sport or the
`_Walking` file-name suffix (RunnerUp's files and the workout app's alike): a
day's activities are summed, and the day counts once their distance reaches
3.5 km (5% GPS tolerance) OR their *moving* time reaches the shared duration
bar (30 min minus the hidden leeway). Both are summed over the track cut into
windows of at least 10 s, each judged by its average speed: faster than 25 km/h
(a vehicle or a GPS jump) counts neither time nor distance; moving time needs
0.5 m/s or more (1 m/s across a signal dropout longer than 30 s, so a pause
earns nothing but a run through a dropout keeps its time). Files without
trackpoints earn nothing (the lap totals are never used). The reward is the usual one
workout credit per day (`runnerup_verified:<date>`); the log's `activity`
field is display only. Code: `screen_locker/_walk_tcx.py`.
**Retiring RunnerUp.** A week after the PC first credits a run recorded by the
workout app (TCX author `kuhy workout_app`), `runnerup-retire.timer` (daily, retries
until the phone is attached) backs `/sdcard/Documents/RunnerUp` up to
`~/data/cloud/RunnerUp/runnerup_phone_backup_<date>/` and uninstalls `org.runnerup.free`,
once the app's `.workout_app_import.json` matches `processed/`; no app run, no uninstall.
`python3 -m screen_locker.runnerup_retire --status` / `--dry-run` change nothing.
## MCP server (Claude Code integration)

View File

@ -1,95 +0,0 @@
# Refactor TODO — enforce the 250-line file cap
> **This file is a ready-to-use prompt.** Paste it to Claude, or open this repo
> and say "do refactor_claude_todo". It is self-contained: everything needed to
> execute is below. Generated 2026-08-14 from a measured survey of every repo.
## Goal
Every file in this repo must be **at most 250 lines** — source, tests, and
prose (`.md`/`.txt`/`.rst`/`.tex`) alike — and must **stay** that way forever,
enforced by a gate that fails the commit, not by a note anyone can ignore.
Why: a file that cannot be read in one piece forces re-reads and partial edits,
which is the single largest avoidable cost in an LLM-assisted workflow. Aim by
churn, not size alone — refactoring pays where code is read and changed often
(Fowler, _refactoring economic benefit_).
## Scope in this repo
<!-- BEGIN GENERATED VIOLATIONS -->
- **10 files** currently exceed 250 lines.
- **1,828 lines** over the cap in total (the work left to do); longest file is **740** lines.
ROI = lines x commits in the last year. Work top-down; a long file nobody edits
has near-zero payoff and should not be first.
| lines | commits/yr | kind | file |
| ----: | ---------: | :--- | :--- |
| 503 | 13 | code | `stronglift_replacement/workout_app/lib/screens/settings_screen.dart` |
| 633 | 9 | code | `stronglift_replacement/workout_app/lib/screens/workout_screen.dart` |
| 740 | 7 | code | `stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart` |
| 322 | 10 | code | `stronglift_replacement/workout_app/lib/services/workout_sync_service.dart` |
| 383 | 7 | code | `stronglift_replacement/workout_app/test/screens/home_screen_test.dart` |
| 260 | 9 | code | `stronglift_replacement/workout_app/lib/screens/home_screen.dart` |
| 386 | 5 | code | `stronglift_replacement/workout_app/test/screens/history_screen_test.dart` |
| 362 | 4 | code | `stronglift_replacement/workout_app/test/screens/workout_screen_test.dart` |
| 313 | 2 | code | `stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart` |
| 426 | 1 | code | `stronglift_replacement/workout_app/test/services/progression_sync_service_test.dart` |
<!-- END GENERATED VIOLATIONS -->
Exempt (do NOT split these):
- generated files — `*.g.dart`, `*.freezed.dart`, `*.gr.dart`, `**/l10n/generated/**`,
anything with a `GENERATED` header
- markup — `.html`, `.css`, `.scss`
- data files — `.json`, `.yaml`, `.csv`, wordlists and other data-ish `.txt`
(mean line length under 25 chars)
## How to split
- **Python** — extract cohesive helpers into sibling modules; keep the public
API and imports stable.
- **Shell** — split into `lib/*.sh` sourced by a thin entry script. Keep
`set -euo pipefail` in each.
- **Dart / TypeScript** — extract widgets/components into their own files.
- **Tests** — split by test-group into sibling files
(`foo.test.ts` -> `foo.parsing.test.ts`, `foo.render.test.ts`). Coverage must
not drop.
- **Docs** — split into topic files under `docs/` with an index. For an
oversized `CLAUDE.md`, move detail into referenced docs so the
always-loaded part shrinks.
**Do not** game the cap: no one-lining, no deleting tests, no moving code into
an exempt extension, no `# noqa`-style suppressions.
## Make it permanent (required — this is the point)
A refactor without a gate silently regrows. Before this task is done:
1. Wire the shared gate `~/utils/scripts/check_file_length.sh` into this repo's
`.pre-commit-config.yaml` as a local hook. If the repo has no pre-commit
config, add a minimal one.
2. The hook checks **files in the commit** (not the whole tree), so unrelated
commits never break, and it **fails** — exit 1, not a warning.
3. No baseline file and no allowlist. Those are suppressions.
4. If this repo has CI (`.github/workflows`), add the same check there so it
also fails on push.
## Done condition
- `bash ~/utils/scripts/check_file_length.sh --all` from this repo root exits 0.
- The repo's own test suite and coverage bar are still green.
- `pre-commit run --files <changed files>` passes.
- A deliberately over-250-line test file, staged, makes `git commit` **fail**.
- For a deployed daemon/app: the entry point still actually runs.
## Verify
Run the suite, then actually run the locker entry point — this repo deploys from the working tree, so a broken import is a live outage.
REMOVE ME AFTER FINISH

View File

@ -1,178 +0,0 @@
# Finish TODO — the last 10 Dart files over 250 lines
> **This file is a ready-to-use prompt.** Open this repo and say
> "do todo_finish_dart_250_prompt". It is self-contained.
> Generated 2026-08-15 at the end of a session that took the repo from 41
> files over the cap to 10. All 10 remaining are Dart.
## Goal
`bash ~/utils/scripts/check_file_length.sh --all` from the repo root exits 0.
Everything else in `refactor_claude_todo.md` still applies (no gaming the cap,
no deleting tests, no suppressions). This file only adds what the previous
session learned the hard way.
## Start here (do this first, it costs one command)
```bash
cd ~/screen-locker/stronglift_replacement/workout_app
env -u GIT_DIR flutter test 2>&1 | tail -1 # MUST print: +402: All tests passed!
```
**402 is the invariant.** Dart has no glob-parametrized test like the Python
side's `test_no_popup_widgets`, so the count never legitimately changes. Any
other number means a lost, duplicated, or non-compiling test — stop and fix
before continuing. A file that fails to *load* shows as
`Failing tests: ... loading X` and drops the count, so check the number, not
just the absence of red.
Never use `git stash` (blocked by a hook). To compare against HEAD:
`git worktree add --detach /tmp/wt HEAD` … `git worktree remove /tmp/wt`.
## The 10 files
| lines | file |
| ----: | :--- |
| 740 | `test/services/workout_sync_service_test.dart` |
| 633 | `lib/screens/workout_screen.dart` |
| 503 | `lib/screens/settings_screen.dart` |
| 426 | `test/services/progression_sync_service_test.dart` |
| 386 | `test/screens/history_screen_test.dart` |
| 383 | `test/screens/home_screen_test.dart` |
| 362 | `test/screens/workout_screen_test.dart` |
| 322 | `lib/services/workout_sync_service.dart` |
| 313 | `lib/screens/manual_workout_screen.dart` |
| 260 | `lib/screens/home_screen.dart` |
Re-run `python3 scripts/refresh_refactor_todo.py` for the live set.
## Part 1 — the 4 test files
`scripts/split_dart_test.py` works and is committed. Usage:
```bash
python3 scripts/split_dart_test.py <file> <out1>:<boundaryIdx> [<out2>:<idx> ...]
```
Boundary indices are positions in the list of top-level `group(` /
`testWidgets(` / `test(` lines. Each output takes `boundaries[idx:next_idx]`;
the source keeps everything before the first.
**Its working envelope — stay inside it:**
1. **One pass, from the pristine file.** Specify every output up front. Do NOT
split an already-split file: indices are recomputed against the new file and
the trailer is re-copied, which is how content went missing last time.
2. **Zero top-level declarations after `main()`.** Check with:
`awk -v m=$(grep -n '^void main(' F | cut -d: -f1) 'NR>m && /^[A-Za-z_]/' F`
The splitter copies the trailer, but the detection is unreliable when
helpers sit *between* groups.
3. **No local function declarations in the preamble.** `history_screen_test.dart`
defines `_pump`, `_wrap`, `_seed` as locals inside `main()`; both halves get
the copy but the second half's references resolve wrong. This is why that
file is still on the list.
**All 4 remaining files are OUTSIDE the envelope** (that's why they're left):
- `workout_sync_service_test.dart` — 2 post-`main` declarations
(`_manualWorkoutSurvivalTests`, `_cutoverTests`), plus `_fakeFirebase`,
`_manual`, `_PutCall` used across the split point.
- `home_screen_test.dart` — 4 post-`main` declarations (fake sync classes
overriding `syncNow`).
- `progression_sync_service_test.dart` — 1 post-`main` declaration.
- `history_screen_test.dart` — the local-function preamble above.
**The fix, and it is its own committed step:** move those shared helpers and
fakes into a sibling `test/services/_sync_test_fixtures.dart` (or
`test/screens/_home_test_fixtures.dart`) and `import` it — the same shape as
`screen_locker/tests/_workout_sync_fixtures.py` on the Python side. Commit
that move, verified at 402, *before* splitting. Then the file is inside the
envelope and the splitter handles it.
**One file at a time → verify 402 → commit.** Batching means a wrong count
doesn't tell you which file caused it, and the revert tangles.
## Part 2 — the 6 `lib/` files
Two patterns are proven and committed; copy them.
**A. Private widget classes → `part` file.** Used on `history_screen`,
`settings_screen`, `home_screen`, `exercise_tile`, `github_mirror_screen`.
Dart privacy is library-scoped, so `part` keeps `_Foo` classes private —
making them public would trip `public_member_api_docs` and change the API.
A `part` also gets no `SF:` entry in lcov, so the coverage gate's
"every lib file must appear" guard has nothing new to miss.
**B. Methods → `extension` in a `part` file.** Used on `storage_service`
(5 files), `progression_sync_service` (4), `backup_service`,
`workout_sync_service`. Dart can't continue a class body across files, but an
extension in a part still reaches `_privateField`.
### Three hard constraints — each one cost a debugging cycle
1. **`setState` is `@protected`.** It cannot be called from an extension. Any
State method that calls `setState` must stay in the class. This is the main
blocker on `home_screen.dart` (260) and `settings_screen.dart` (503).
2. **Extension methods dispatch STATICALLY.** A method a test fake overrides
must stay in the class. `_FakeSync extends WorkoutSyncService` overrides
`pushManual` and `readMergedManualPayloads`; with those in an extension the
*real* implementations ran and four manual-workout tests hung on
`pumpAndSettle` instead of failing an assertion. Before moving any public
method, grep: `grep -rn "extends <ClassName>" -A15 test/`.
This is why `workout_sync_service.dart` is stuck at 322 — its public
surface is what fakes bind to.
3. **Static members need qualifying inside an extension**:
`BackupService._baseDir`, `ProgressionSyncService._stateToJson`. The
analyzer catches this (`unqualified_reference_to_static_member_of_extended_type`).
### What's actually left to do in `lib/`
- `workout_screen.dart` (633) — **not yet attempted.** Check it for private
widget classes first (pattern A); that's the cheap win.
- `manual_workout_screen.dart` (313) — not yet attempted.
- `settings_screen.dart` (503) — widgets already extracted. Going lower needs
the ~190-line `build()` broken up: extract the SYNC / OFFLINE BACKUP list
sections into private `StatelessWidget`s taking what they read as
constructor params (they never call `setState`, so they're eligible). A
previous attempt to slice them out as a raw widget-list getter produced
unbalanced brackets — build the widget class properly, don't slice text.
- `home_screen.dart` (260, only 10 over) — same shape, one cohesive `build()`.
- `workout_sync_service.dart` (322) — see constraint 2. Only private helpers
can move; getting under 250 means changing the public surface, which is a
design decision, not a move. **Ask the user before doing that.**
## Verify (all of it, every batch)
```bash
cd ~/screen-locker/stronglift_replacement/workout_app
env -u GIT_DIR dart analyze lib/ # must be "No issues found!"
env -u GIT_DIR flutter test 2>&1 | tail -1 # must be +402
cd ~/screen-locker && pre-commit run # includes the 100% Flutter coverage gate
```
`dart analyze` is a **no-op on test files** — `analysis_options.yaml` excludes
`test/**`. For those, the test count and the compile it implies are the only
signal.
## Done condition
- `bash ~/utils/scripts/check_file_length.sh --all` exits 0.
- 402 Dart tests pass; `flutter` coverage still 100%.
- `pre-commit run --all-files` passes.
- The app still runs on the phone: use the `phone-deploy` skill
(`bash ~/.claude/scripts/phone_deploy.sh <app-dir> --shot <path>`), then
**read the screenshot yourself** and confirm real progression data renders —
weights and "Next: Workout A/B", not seeded defaults. That is what proves
the `storage_service` part-extensions still resolve.
## Environment note (not your bug)
`python3 -m scripts.verify_lock_popup_safety` fails locally with
`RecoveryLoop.__init__() takes 3 positional arguments but 6 were given`.
The code matches the pinned gatelock 0.4.0; this machine has 0.5.0 installed.
Resync with `pip install -r requirements.txt`. **Do not "fix" the code to
match the installed version** — that would break CI. See the comment in
`scripts/_popup_form_check.py`.
REMOVE ME AFTER FINISH

View File

@ -1,143 +0,0 @@
# Fix TODO — make pylint a perfect 10.00 gate
> **This file is a ready-to-use prompt.** Paste it to Claude, or open this repo
> and say "do todo_fix_pylint_10_prompt". It is self-contained: everything
> needed to execute is below. Generated 2026-08-15 from a measured run.
## Goal
`pylint` must score **10.00/10** on this repo, enforced by the pre-commit hook
at `--fail-under=10` — not the current `--fail-under=8.0`, which lets real
findings through as long as enough clean files dilute them.
Why the current bar is wrong: pylint's hook scores the **staged batch** as one
number. A commit touching many test files fails while the identical code
committed alongside a few clean source files passes. The score is a property of
*which files you staged*, not of the code. A 10.00 bar removes that entirely —
zero findings is zero findings regardless of batching.
## Measured starting point (2026-08-15)
Whole repo (`screen_locker/` + `scripts/`): **8.76/10**.
Findings by category, highest first:
| count | check | fixable? |
| ----: | :--- | :--- |
| 473 | `protected-access` (W0212) | no — test-inherent |
| 369 | `unused-argument` (W0613) | no — test-inherent |
| 245 | `missing-function-docstring` (C0116) | **yes** |
| 238 | `suppressed-message` (I0020) | disappears with W0212/W0613 |
| 73 | `duplicate-code` (R0801) | no — test-inherent |
| 66 | `use-implicit-booleaness-not-comparison-to-zero` (C1805) | **yes** |
| 27 | `import-outside-toplevel` (C0415) | **yes**, mostly |
| 25 | `missing-class-docstring` (C0115) | **yes** |
| 25 | `locally-disabled` (I0011) | disappears with W0212/W0613 |
| 24 | `use-implicit-booleaness-not-comparison` (C1803) | **yes** |
| 10 | `useless-suppression` (I0021) | **yes** — delete the dead disables |
| ~40 | long tail (see "Long tail" below) | **yes** |
## The decision already taken
Three checks are **disabled for test paths only**, because they are not defects
— they are how pytest works, and fixing them would mean rewriting ~840 call
sites and making production APIs public to suit tests:
- **`protected-access` (W0212)** — tests drive private mixin methods directly
(`locker._verify_phone_workout()`). This is deliberate: those methods *are*
the unit under test. Ruff already exempts the same thing in tests via
`SLF001` in `pyproject.toml`'s per-file-ignores, so pylint disagreeing with
ruff is just noise.
- **`unused-argument` (W0613)** — pytest fixtures such as `mock_tk` and
`mock_sys_exit` must be *requested* in the signature to take effect, but are
never referenced in the body. Consuming them with a dummy `assert` to satisfy
a linter is strictly worse code.
- **`duplicate-code` (R0801)** — near-identical `with patch(...)` preambles
across sibling test modules. Deduplicating them into helpers hurts: a test
should read top-to-bottom without chasing a shared fixture.
Everything else gets **fixed for real**. No `# pylint: disable=` lines, no
per-line suppressions — this repo bans `# noqa` / `# type: ignore` repo-wide and
the same spirit applies here.
## Steps
1. **Scope the three disables to tests only.** They must NOT apply to
`screen_locker/*.py` or `scripts/*.py`. pylint has no per-path `disable` in
`pyproject.toml`, so use one of:
- a `screen_locker/tests/.pylintrc` that inherits and adds the disables
(pylint picks the nearest rcfile per directory), or
- two pre-commit hooks (`pylint-src` with `exclude: ^screen_locker/tests/`,
`pylint-tests` with `files: ^screen_locker/tests/`), each `--fail-under=10`.
Verify the scoping works: introduce a deliberate `protected-access` in a
*source* file and confirm it still fails.
2. **Fix `missing-function-docstring` / `missing-class-docstring` (270).**
Docstrings must say what the test *pins down*, not restate its name.
Good: `"""An unsigned entry is rejected once a key is available."""`
Bad: `"""Test rejected when key available."""`
A generator that drafts from the test name lives at
`/tmp/.../scratchpad/add_docstrings.py` in the session that wrote this file;
it is refactor tooling, not a deliverable — rewrite it if it is gone. Every
generated line still needs reading: the draft is a starting point.
3. **Fix the booleanness checks (94).** `assert x == []` → `assert not x`,
`assert n == 0` → `assert not n`, `assert s == ""` → `assert not s`.
Careful: only where the value really is a sequence/int/str. `is False` /
`is True` assertions on tri-state returns must NOT be collapsed.
4. **Fix `import-outside-toplevel` (27).** Hoist each to module scope unless it
exists to break an import cycle — where it does, that is a real constraint;
restructure the modules so the cycle is gone, or leave it and note why.
5. **Delete `useless-suppression` (10)** — dead `# pylint: disable=` comments
that no longer suppress anything.
6. **Long tail (~40).** `too-many-instance-attributes`, `too-many-arguments`,
`too-many-return-statements`, `unspecified-encoding`, `line-too-long`,
`comparison-with-callable`, `superfluous-parens`,
`pointless-string-statement`, `reimported`, `consider-using-with`,
`unreachable`. Fix each on its merits.
- `unspecified-encoding` (3) is a genuine latent bug — add `encoding="utf-8"`.
- `comparison-with-callable` (3) is often a real bug (comparing a function
instead of calling it). Read each one carefully.
- `unreachable` (4) is `sys.exit(0)` followed by `return` in
`_startup_checks.py` and elsewhere. Those `return`s are **load-bearing
under test**: `conftest.py` patches `sys.exit` to a no-op, so without the
`return` execution continues into the next branch. Do not delete them
blindly — restructure so the intent is expressed without dead code, or
leave them and record the reason.
- `too-many-ancestors` (1) is `ScreenLocker`'s 19 mixins. Inherent to the
design; raise `max-parents` in config rather than collapsing mixins.
7. **Flip the gate.** Set `--fail-under=10` in `.pre-commit-config.yaml` (both
hooks if you split them) and update the hook `name:` to say 10.
## Done condition
- `pylint --rcfile=pyproject.toml screen_locker/ scripts/` prints
**`rated at 10.00/10`**.
- `pre-commit run --all-files` passes.
- A deliberately-introduced pylint finding in a **source** file makes
`git commit` fail (proves the gate is live and correctly scoped).
- `python -m pytest` still green at `fail_under = 100` branch coverage.
- No `# pylint: disable=` added anywhere as part of this work.
## Verify
Run the suite, then actually run the locker entry point —
`python -m screen_locker.screen_lock --status` — because this repo deploys from
the working tree, so a broken import is a live outage. Hoisting imports (step 4)
is exactly the change that can cause one.
## Do not
- Do not lower `--fail-under` below 10 once it is raised.
- Do not add per-line `# pylint: disable=` comments to reach the number.
- Do not disable W0212/W0613/R0801 globally — tests only. A source file calling
another class's private attribute is a real finding.
- Do not touch `refactor_claude_todo.md`'s 250-line work; that is a separate,
in-progress task with its own prompt file.
REMOVE ME AFTER FINISH

7
arm.sh
View File

@ -33,13 +33,16 @@ readonly UNITS=(
"early-bird-workout-check.timer"
"workout-sync.service"
"workout-sync.timer"
"earner-bonus.service"
)
# Only the timers get enabled; the service is triggered by them and by login.
# Only the timers (and the ledger watch) get enabled; the services are
# triggered by them and by login.
readonly TIMERS=(
"early-bird-workout-check.timer"
"workout-locker.timer"
"workout-sync.timer"
"earner-bonus.path"
)
SKIP_TODAY=0
@ -87,6 +90,8 @@ main() {
for unit in "${UNITS[@]}"; do
cp "$SCRIPT_DIR/$unit" "$USER_UNIT_DIR/$unit"
done
# Rendered from the earned_time registry rather than copied.
PYTHONPATH="$SCRIPT_DIR" python3 -m screen_locker._earner_units "$USER_UNIT_DIR"
systemctl --user daemon-reload

View File

@ -0,0 +1,6 @@
{
"_comment": "Shared literal fixture for the phone->PC workout poke (v1). Both the Python receiver and the Dart sender test against THESE bytes. body_utf8 is the exact request body; signature = hex(HMAC-SHA256(bytes.fromhex(key_hex), body_utf8.encode('utf-8'))). Never re-serialize before verifying.",
"key_hex": "7f3c9a1e5b2d4f6081a3c5e7092b4d6f8a1c3e5072946b8d0f2a4c6e8013579b",
"body_utf8": "{\"v\":1,\"sent_at_ms\":1791558449000,\"nonce\":\"0b1e6c2a-9f4d-4c1b-8e7a-3d5f6a7b8c9d\",\"sandbox\":false,\"record_id\":\"2026-10-09T15:10:40.953\",\"payload\":{\"date\":\"2026-10-09\",\"start_time\":\"2026-10-09T15:10:40.953\",\"workout_type\":\"A\",\"duration_seconds\":7020.5,\"succeeded\":true,\"exercises\":[{\"name\":\"Squat\",\"weight_kg\":100.0,\"sets\":[5,5,5,5,5]}],\"note\":\"zażółć gęślą jaźń\"}}",
"signature_hex": "98b0b80fe717079c800e79562f669fd0189d89c3578a5bbf973761a0e7a3a3f5"
}

View File

@ -0,0 +1,76 @@
# Workout poke: phone -> PC wire contract (v1)
The phone sends a finished StrongLifts session straight to the PC over the
home LAN, so the credit (shutdown time, gaming budget, notification) lands in
under a second instead of waiting for the GitHub/Firebase round trip. The
normal sync push still runs in parallel; the PC dedups by record id, so a
session that arrives both ways is credited once.
## Transport
- `POST http://<pc-host>:8774/v1/workout`, `Content-Type: application/json`.
(8773 until 2026-10-10: that is book-guard's desktop wrapper's port.)
- `<pc-host>` is a phone setting, default `192.168.1.43` (static on the PC).
LAN only: the PC firewall accepts tcp/8774 from `192.168.1.0/24` and nothing
else. No WireGuard. Off the home LAN the poke fails fast and the regular
sync is the only path.
- Header `X-Poke-Signature: <hex HMAC-SHA256(key, raw body bytes)>`.
The PC verifies the MAC over the bytes **as received** and only then parses
them. Neither side may re-serialize JSON to compute or check the MAC.
- Key: 32 random bytes, hex, PC copy at `~/.config/workout_poke/key`
(mode 600); phone copy in the Android keystore, put there by
`scripts/pair_phone.sh` over adb.
## Request body
```json
{"v":1,"sent_at_ms":<int epoch ms>,"nonce":"<uuid4>","sandbox":<bool>,
"record_id":"<sync record id>","payload":{<session exactly as synced>}}
```
- `payload` is the same map the phone puts in its sync log for the session;
the PC feeds `(record_id, payload)` to `ingest_session_records`, which
re-validates it (never trusts `succeeded`).
- `sandbox` is inside the signed body. A sandbox request is verified, timed
and answered, but **never written anywhere** and never credited.
## PC checks, in order (each rejection is logged at warning)
1. Signature matches -> else `401`.
2. `|now - sent_at_ms| <= 120 s` -> else `401` (replay window).
3. `nonce` not seen within the window -> else `409`.
4. `v == 1` and fields present -> else `400`.
## Response (always JSON)
```json
{"ok":<bool>,"credited":<bool>,"duplicate":<bool>,"sandbox":<bool>,
"shutdown":"HH:MM"|null,"gaming_budget_minutes":<int|null>,"pc_ms":<int>,
"reason":"<sentence a human can act on>"}
```
The phone must treat `shutdown` and `gaming_budget_minutes` as nullable.
Outcomes of a verified (200) request:
| case | ok | credited | duplicate |
|---|---|---|---|
| newly credited | true | true | false |
| already in `log.json` (e.g. the sync got there first) | true | false | true |
| logged, but today's credit slot was already paid by another copy of the same workout | true | false | false |
| signed but does not count (too short, no date): `reason` says why | false | false | false |
| sandbox | true | false | false |
Refusals are never 200. Besides the four checks above, the PC may answer
`404` (wrong path), `405` (not POST), `411` (no Content-Length), `413` (body
over 64 KiB) and `500` (internal error; the regular sync still credits). All
of them carry the same JSON shape with `ok:false` and `shutdown` and
`gaming_budget_minutes` both `null`. The PC answers a `413` without reading
the body, so the phone may see a connection reset instead of the reply.
## Shared fixture
`contracts/workout_poke_v1.json` holds a literal key, body and expected
signature (with a float and non-ASCII text, the usual cross-language traps).
The Python receiver and the Dart sender both test against it, so a drift on
either side fails that side's tests.

View File

@ -1,126 +0,0 @@
# Task: replace blind coordinate tapping with element-targeted Android UI automation
> **STATUS 2026-08-10: BUILT.** The tool now exists at
> `~/testsAndMisc/python_pkg/android_ui/` (see its README). It covers `dump` /
> `find` / `tap` / `type` / `wait` / `focus` with verified typing, ambiguity as
> an error, keyboard detection, and partial-tree retries — all exercised
> against the physical Pixel 6a. What remains from the spec below is the
> optional MCP wrapper, `scroll_to()`, and rolling it out to `~/todo` and
> `~/dufs-cloud/app`. Everything after this line is the original brief, kept
> because it records WHY each guarantee exists.
## what
Every Flutter/Android app in these repos is currently driven by an agent the
same bad way: screenshot -> read pixel positions by eye -> `adb shell input tap
<x> <y>`. Replace that with a reusable tool that finds elements by *identity*
(text, content-desc, resource-id) and taps their real bounds, and that can
report what is on screen as structured text instead of an image.
## why this is not a nicety
Observed in one session (2026-08-10, workout_app Firebase verification):
- **Silent mis-taps.** A tap at `(540, 1705)` intended for the password field
landed with the field unfocused. The typed password went nowhere. Nothing
errored — the next screenshot just showed an empty box. A no-op and a hit are
indistinguishable without dumping the tree and diffing.
- **Layout shift invalidates every coordinate.** Opening the soft keyboard moved
the email field from y=1572 to y=1319. Coordinates captured one step earlier
were already wrong.
- **Screenshot scaling is a manual multiply.** The harness renders 1080x2400 at
900x2000, so every read coordinate needs `* 1.20`. One forgotten multiply is
an off-screen tap.
- **Screenshots are expensive.** A full-page PNG per step burns far more context
than the equivalent element list, and can't be grepped or asserted on.
`uiautomator dump` already solves this and is on every device — no new
dependency:
```bash
adb shell uiautomator dump /sdcard/ui.xml
adb pull /sdcard/ui.xml
# -> <node text="Connect Firebase" class="android.widget.Button"
# bounds="[32,1808][1048,1878]" .../>
```
This is how the same session recovered: dumping the tree found both `EditText`
nodes by class and gave exact centers, and the retry worked first time.
## where
New shared tool, not a per-repo copy. Two options, pick one:
- `~/testsAndMisc/python_pkg/android_ui/` — a CLI (`android-ui find "Connect
Firebase"`, `android-ui tap "Connect Firebase"`, `android-ui dump`), matching
how `app_icons` is already shared across repos.
- An MCP server exposing the same as tools, which is what makes it available to
the agent without shelling out. Preferred if MCP is acceptable — the user
asked for "some tool ... (mcp?) which also translates to UI".
Consumers (every repo with an Android surface):
- `~/screen-locker/stronglift_replacement/workout_app`
- `~/todo`
- `~/dufs-cloud/app`
- any future Flutter app (this should be part of the app scaffold)
## must
- `find(query)` -> matching elements with class, text/content-desc, bounds,
center, and enabled/focused state. Match on text, content-desc, or
resource-id; substring and exact.
- `tap(query)` -> resolves the query, taps the center of the *current* bounds
(re-dump before every tap; never reuse a stale coordinate), and **fails
loudly with a non-zero exit when the query matches zero or >1 elements**.
Ambiguity must be an error, not a silent first-match.
- `type(query, text)` -> focus the field, type, then **verify the field's text
actually changed** and fail if not. This is the specific bug that cost this
session a round trip.
- `dump()` -> flat, greppable text list of visible elements. This replaces the
screenshot for most steps.
- Waits: `wait_for(query, timeout)` that polls the tree, so no more blind
`sleep 14`.
- must not: require root, an emulator, a running Flutter debug connection, or
any change to app source. It must work against a plain release APK on a
physical device over adb.
- optional: `flutter drive` / integration_test as a second backend when a debug
build IS available — richer (widget keys, no dump latency), but it cannot
drive a release APK, so it can't be the only path.
- optional: screenshot fallback for genuinely visual checks (theming, icon
rendering) — where a picture is the point, not a workaround.
## done
A verification script that drives workout_app's "connect Firebase" flow
end-to-end with **zero hardcoded pixel coordinates** in it, and:
- passes on the current layout;
- still passes after the SYNC and OFFLINE BACKUP sections are reordered in
`settings_screen.dart` (the test that coordinate-based automation fails);
- exits non-zero, naming the query, when an element is missing.
## verify
On the physical Pixel 6a over adb, against an installed release APK — not an
emulator and not a debug-only Flutter driver session.
## read first
- This session's working recovery: `uiautomator dump` + the bounds-parsing
python in the screen-locker scratchpad (`ui.xml`, `ui2.xml`).
- `~/screen-locker/CLAUDE.md` — "You are NOT done until you install the new
version on the phone itself", which is why agent-driven UI is on the critical
path for every task in this repo.
- Note `uiautomator dump` returns an empty/partial tree while an animation is
in flight; retry-with-backoff belongs in the tool, not in each caller.
- **Observed 2026-08-10, must be handled:** while a Flutter `TextField` holds
focus, the dump returns ONLY the `EditText` nodes — the rest of the tree
(including the button you are about to tap) is missing, and it stays missing
across retries, so a naive retry loop never converges. Worse, `KEYCODE_BACK`
to dismiss the keyboard is treated by Flutter as a route pop: it navigated
out of Settings and discarded the typed credentials, twice. The tool must
unfocus without popping the route (tap dead space, or `dumpsys input_method`
to check `mInputShown` first) and must distinguish "tree is partial" from
"element is genuinely absent" rather than reporting a false negative.
REMOVE ME AFTER FINISH

View File

@ -1,13 +1,13 @@
## Shared gatelock library
`~/utils/gatelock` is **not** its own git repo — `git -C ~/utils/gatelock
rev-parse --show-toplevel` resolves to `/home/kuhy/utils`, so it's a
subdirectory of the `~/utils` monorepo. Per audit scope, gatelock itself was
`~/src/utils/gatelock` is **not** its own git repo — `git -C ~/src/utils/gatelock
rev-parse --show-toplevel` resolves to `/home/kuhy/src/utils`, so it's a
subdirectory of the `~/src/utils` monorepo. Per audit scope, gatelock itself was
not walked against all 28 rules and no separate
`~/utils/gatelock/DESIGN_AUDIT_TODO.md` was written.
`~/src/utils/gatelock/DESIGN_AUDIT_TODO.md` was written.
What's relevant here: gatelock defines exactly **one** real design token —
`LockConfig.bg: str = "#1a1a1a"` (`~/utils/gatelock/gatelock/_window.py:71`),
`LockConfig.bg: str = "#1a1a1a"` (`~/src/utils/gatelock/gatelock/_window.py:71`),
applied via `self.root.configure(bg=self._config.bg, ...)` in
`LockWindow.setup()` (`_window.py:143`). Both consumer repos re-hardcode
this same literal instead of reading it back off their own `LockConfig`

View File

@ -14,7 +14,7 @@ independently, in 9 files. Evidence:
`_sick_dialog.py` (18), `_temperature_status_mixin.py` (5).
`#1a1a1a` alone (the background) appears **31 times** — and it is *also*
already defined once, correctly, as `LockConfig.bg = "#1a1a1a"` in
`~/utils/gatelock/gatelock/_window.py:71`. None of the 31 in-repo literals
`~/src/utils/gatelock/gatelock/_window.py:71`. None of the 31 in-repo literals
import or reference it; `LockWindow.setup()` in gatelock applies
`config.bg` to the root window (`_window.py:143`), and then every
screen-locker dialog re-hardcodes the same string as if gatelock's value

View File

@ -2,6 +2,14 @@
## Status
**Superseded (2026-10-09): screen-locker sync is Firebase RTDB only.** The
phone stopped writing the GitHub repo on 2026-08-21, and from 2026-10-09 the
PC does not use it either: the push, `sync_client()` and
`scripts/tombstone_sync_record.py` all use `firebase_client_for("screen_locker")`,
and the sync_token / GitHub client / mirror code paths are deleted.
`kuhyx/syncs/screen-locker-sync` stays as a frozen archive that no code reads
and nothing deletes. The rest of this document is the historical design record.
**Blocked on Workstream D** (2026-07-04 user decision — see "Sequencing
decision" below). No code has been written for this workstream. The design
questions below (sync pattern, repo name, scope) have been pre-decided so the
@ -47,7 +55,7 @@ production in this user's other repos — read both before designing this
one:
1. **diet-guard's Contents-API repo sync**
(`~/diet-guard/diet_guard/_sync.py` + `_sync_github.py` +
(`~/src/diet-guard/diet_guard/_sync.py` + `_sync_github.py` +
`_sync_merge.py`, ~120/190/80 lines respectively). `GitHubSyncClient`
(`_sync_github.py:38`) wraps the GitHub Contents API with
`get_file_text(path)`, `put_file_text(path, text, message=...)`,
@ -59,7 +67,7 @@ one:
fine-grained PAT read from `~/.config/diet_guard/sync_token` (scoped to
just that one repo's contents).
2. **wake-alarm's Gist-based sync**
(`~/wake-alarm/shutdown-wrapper.sh`, phone app writes to a **private
(`~/src/wake-alarm/shutdown-wrapper.sh`, phone app writes to a **private
Gist**, not a repo). Config at `~/.config/wake_alarm/gist_sync.json`
with `token`/`gist_id`. Much simpler (single JSON blob, no per-device
paths, no merge step) — matches wake-alarm's much simpler data shape (one
@ -148,7 +156,7 @@ sync code first.
- `screen_locker/_constants.py` (`WORKOUT_APP_JSON_REMOTES`,
`WORKOUT_HTTP_PORT` — new `SYNC_*` constants go here too)
- `stronglift_replacement/workout_app/lib/` (phone-side push, new work)
- Reference-only, do not modify: `~/diet-guard/diet_guard/_sync_github.py`,
`_sync.py`, `_sync_merge.py`; `~/wake-alarm/shutdown-wrapper.sh`
- Reference-only, do not modify: `~/src/diet-guard/diet_guard/_sync_github.py`,
`_sync.py`, `_sync_merge.py`; `~/src/wake-alarm/shutdown-wrapper.sh`
- **Do not touch**: `screen_locker/_runnerup_verification.py`,
`_runnerup_db.py` (hard boundary, see Context above)

View File

@ -15,7 +15,7 @@ to have very different shareability:
`put_file_text` / `list_directory` against a private repo) is genuinely
shared already — it's independently duplicated almost line-for-line
between todo's `GitHubClient` and diet-guard's `GitHubSyncClient`
(`~/diet-guard/diet_guard/_sync_github.py`, 191 lines, `requests`-only).
(`~/src/diet-guard/diet_guard/_sync_github.py`, 191 lines, `requests`-only).
Extracting this into a shared Python lib (and a shared Dart lib for
todo's/diet-guard's Dart-side client) is low-risk, small, and is the part
Workstream C already committed to (Contents-API pattern, not Gist —
@ -70,7 +70,7 @@ library too:
## 3. Per-app migration cost (revised — the original doc's "todo = smallest
lift" assumption does not hold)
Checked `~/todo/lib/data/note_repository.dart`: notes are **mutated in
Checked `~/src/todo/lib/data/note_repository.dart`: notes are **mutated in
place** — `upsert()` does `INSERT ... ON CONFLICT DO UPDATE SET text = ?,
priority = ?, status = ?, updated_at = ?`, relying on `sqlite_crdt`'s
per-column Hybrid-Logical-Clock last-writer-wins to reconcile concurrent
@ -138,8 +138,8 @@ off the shelf.
## Critical files referenced (read-only during this pass)
- `~/todo/lib/sync/sync_service.dart`, `~/todo/lib/data/note_repository.dart`
- `~/diet-guard/diet_guard/_sync.py`, `_sync_github.py`, `_sync_merge.py`
- `~/diet-guard/app/lib/services/sync_merge.dart`
- `~/wake-alarm/shutdown-wrapper.sh`
- `~/src/todo/lib/sync/sync_service.dart`, `~/src/todo/lib/data/note_repository.dart`
- `~/src/diet-guard/diet_guard/_sync.py`, `_sync_github.py`, `_sync_merge.py`
- `~/src/diet-guard/app/lib/services/sync_merge.dart`
- `~/src/wake-alarm/shutdown-wrapper.sh`
- `docs/todo/workstream-c-github-sync-workout-data.md` (this repo)

View File

@ -8,9 +8,9 @@ fell out of it (both resolved by explicit user call, 2026-07-04).
**The two shared libraries are now implemented and locally verified
(2026-07-05):**
- `~/crdt-sync` (Python) — 60 tests, 100% branch coverage, clean
- `~/src/utils/crdt-sync` (Python) — 60 tests, 100% branch coverage, clean
ruff/mypy/pylint/bandit/pre-commit. Local git repo, one commit.
- `~/crdt_sync_dart` (pure Dart, no Flutter dep) — 59 tests, clean
- `~/src/utils/crdt_sync_dart` (pure Dart, no Flutter dep) — 59 tests, clean
`dart analyze`. Local git repo, one commit.
Both implement the unified LWW-map-with-sticky-remove scheme (`Hlc`,
@ -62,7 +62,7 @@ from-scratch CRDT implementation.
**diet-guard and wake-alarm do NOT use CRDTs today.** Their existing
"merge" mechanisms are much simpler:
- diet-guard: `_sync_merge.merge_logs` (`~/diet-guard/diet_guard/_sync_merge.py`,
- diet-guard: `_sync_merge.merge_logs` (`~/src/diet-guard/diet_guard/_sync_merge.py`,
81 lines) is a tombstone-aware, entry-key-based merge — closer to
last-write-wins per entry than a general CRDT. Transport is
`_sync_github.GitHubSyncClient` against a private repo's Contents API
@ -151,9 +151,9 @@ time this is scoped for real.
## Critical files (reference only — read, do not modify, during scoping)
- `~/todo/lib/sync/sync_service.dart`, `sync_settings.dart` (CRDT-via-package pattern)
- `~/diet-guard/diet_guard/_sync.py`, `_sync_github.py`, `_sync_merge.py`
- `~/diet-guard/app/lib/services/sync_service.dart`, `sync_merge.dart`
- `~/wake-alarm/shutdown-wrapper.sh`
- `~/src/todo/lib/sync/sync_service.dart`, `sync_settings.dart` (CRDT-via-package pattern)
- `~/src/diet-guard/diet_guard/_sync.py`, `_sync_github.py`, `_sync_merge.py`
- `~/src/diet-guard/app/lib/services/sync_service.dart`, `sync_merge.dart`
- `~/src/wake-alarm/shutdown-wrapper.sh`
- `docs/todo/workstream-c-github-sync-workout-data.md` (this repo — resolve
its open questions jointly with this one, not independently)

View File

@ -10,14 +10,17 @@ Description=Re-check workout after early bird grace period expires (08:30 normal
# After=graphical-session.target, which is where that ordering belongs.
[Timer]
# Fires at 08:30 for the normal early-bird window (5:00–8:30).
# Also fires at 09:05 to catch the extended window (5:00–9:00) earned by 5+ workout weeks.
# Both runs are idempotent: the service handles "already logged" and "window still active".
OnCalendar=*-*-* 08:30:00
OnCalendar=*-*-* 09:05:00
# The early-bird window is the wake-alarm morning-session carrot since
# 2026-09-20 (screen_locker/_early_bird.py): it ends at the signed
# exempt_until -- 11:00 after a completed morning, min(start+90 min, 09:30)
# after an aborted one, alarm+15 min when no session started. These runs land
# just after the two boundaries the 5-minute tick could miss by up to five
# minutes; every run is idempotent ("already logged", "carrot still in force").
OnCalendar=*-*-* 09:30:30
OnCalendar=*-*-* 11:00:30
Unit=workout-locker.service
# Persistent=true: a machine asleep or off at 08:30 would otherwise skip the
# re-check entirely, leaving the early-bird marker banked and the day
# Persistent=true: a machine asleep or off at a boundary would otherwise skip
# the re-check entirely, leaving the early-bird marker banked and the day
# unenforced. Both runs are idempotent (see above), so a catch-up run is safe.
Persistent=true
AccuracySec=1s

11
earner-bonus.service Normal file
View File

@ -0,0 +1,11 @@
[Unit]
Description=Apply newly earned flat shutdown bonuses (LeetCode, reading, Anki, ...)
[Service]
Type=oneshot
# Started by earner-bonus.path the moment a gate writes its ledger. Only the
# flat-bonus pass: no phone sync, no Tk, no lock screen, so a solve moves the
# shutdown time within seconds instead of at the next 15-minute workout-sync.
# The path unit itself is generated from the earned_time registry
# (screen_locker._earner_units), so it is not a file in this repo.
ExecStart=/usr/bin/python3 -m screen_locker.screen_lock --apply-bonuses

View File

@ -42,7 +42,7 @@ readonly REPO_DIR="$SCRIPT_DIR"
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
readonly PROD_PYTHON="/usr/bin/python3"
# The shared lock backend, developed in lockstep with this repo.
readonly GATELOCK_DIR="$HOME/utils/gatelock"
readonly GATELOCK_DIR="$HOME/src/utils/gatelock"
echo "=== Screen Locker Installer ==="
@ -58,7 +58,7 @@ fi
# 2. Install the package ------------------------------------------------------
# --break-system-packages is required on Arch (PEP 668). Editable so a later
# `git pull` in THIS clone updates the running service with no reinstall. That
# is also why the clone must live somewhere durable (~/screen-locker), never a
# is also why the clone must live somewhere durable (~/src/screen-locker), never a
# scratch directory: a non-editable snapshot would freeze at this commit and
# silently stop tracking the repo.
echo "[2/4] Installing screen_locker into user site-packages (editable)..."
@ -69,7 +69,7 @@ echo "[2/4] Installing screen_locker into user site-packages (editable)..."
# pyproject.toml pins `gatelock @ git+https://.../@gatelock-v0.2.1`, so the
# install above resolves that pin and overwrites any local editable gatelock
# with a frozen wheel built from the tag. That silently reverts every
# unreleased change in ~/utils/gatelock and breaks the other lockers that
# unreleased change in ~/src/utils/gatelock and breaks the other lockers that
# depend on them -- observed: it removed `ScrollableSurface` and took
# diet_guard down with it.
#
@ -122,7 +122,7 @@ if ! (cd /tmp && "$PROD_PYTHON" -c "import screen_locker" 2>/dev/null); then
fi
if ! (cd /tmp && "$PROD_PYTHON" -c "import gatelock" 2>/dev/null); then
echo "ERROR: $PROD_PYTHON cannot import gatelock (the shared lock" >&2
echo " backend). Install it editable from ~/utils/gatelock." >&2
echo " backend). Install it editable from ~/src/utils/gatelock." >&2
exit 1
fi
if [[ -d "$GATELOCK_DIR" ]]; then
@ -138,6 +138,16 @@ if [[ -d "$GATELOCK_DIR" ]]; then
fi
echo " ok: screen_locker and gatelock both import under $PROD_PYTHON"
# One-shot, idempotent: the old sick_history.json's dates move into the shared
# sickdays pool and the file is removed once they read back. Rerunning (or a
# machine that never had the file) is a no-op. A failure keeps the file and
# is retried by the next install; it does not block the locker itself.
echo "Migrating the legacy sick history into the sickdays pool..."
if ! "$PROD_PYTHON" "$REPO_DIR/scripts/migrate_sick_history.py"; then
echo " WARNING: sick-history migration failed (file kept); rerun" >&2
echo " $REPO_DIR/scripts/migrate_sick_history.py" >&2
fi
echo
echo "=== Done ==="
echo "The locker is live. Check when it next fires with:"

View File

@ -13,6 +13,12 @@ EARLY_BIRD_TIMER_NAME="early-bird-workout-check.timer"
LOCKER_TIMER_NAME="workout-locker.timer"
SYNC_SERVICE_NAME="workout-sync.service"
SYNC_TIMER_NAME="workout-sync.timer"
BONUS_SERVICE_NAME="earner-bonus.service"
BONUS_PATH_NAME="earner-bonus.path"
RUNNERUP_WATCH_NAME="runnerup-watch.service"
WORKOUT_POKE_NAME="workout-poke.service"
RETIRE_SERVICE_NAME="runnerup-retire.service"
RETIRE_TIMER_NAME="runnerup-retire.timer"
# Runtime dependencies. screen_lock.py imports tkinter at module scope. On Arch
# the tkinter MODULE ships inside the `python` package, but the shared library
@ -35,6 +41,20 @@ ensure_runtime_deps() {
fi
fi
# inotifywait drives runnerup-watch.service (instant RunnerUp credit).
if ! command -v inotifywait >/dev/null 2>&1; then
if command -v pacman >/dev/null 2>&1; then
echo "Installing missing system dependency: inotify-tools"
if [ "$(id -u)" -eq 0 ]; then
pacman -S --needed --noconfirm inotify-tools
else
sudo pacman -S --needed --noconfirm inotify-tools
fi
else
echo "WARNING: inotifywait missing and pacman unavailable - RunnerUp runs are credited only by the 15-min timer" >&2
fi
fi
# The two shared RUNTIME dependencies, both pinned git deps in
# pyproject.toml: gatelock (lock-window/HMAC backend) and crdt-sync (the
# workout-sync transport). Nothing here used to install either, so a fresh
@ -53,9 +73,9 @@ ensure_runtime_deps() {
echo "WARNING: could not install $1 automatically" >&2
}
_pip_runtime_dep gatelock \
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.1#subdirectory=gatelock"
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.0#subdirectory=gatelock"
_pip_runtime_dep crdt_sync \
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.0#subdirectory=crdt-sync"
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync"
}
ensure_runtime_deps
@ -88,11 +108,12 @@ if systemctl --user is-active "workout-locker.timer" &>/dev/null; then
fi
rm -f "$USER_SERVICE_DIR/workout-locker.timer"
# Seed shutdown_base.json with base=21 if not already present
# Seed shutdown_base.json if not already present. It holds only date stamps;
# the base hour itself comes from the earned_time registry (kuhyx/utils).
SHUTDOWN_BASE="$SCRIPT_DIR/screen_locker/shutdown_base.json"
if [[ ! -f "$SHUTDOWN_BASE" ]]; then
printf '{\n "base_mon_wed_hour": 21,\n "base_thu_sun_hour": 21,\n "last_reset_date": ""\n}\n' > "$SHUTDOWN_BASE"
echo "✓ Created shutdown_base.json with base=21:00"
printf '{\n "last_reset_date": ""\n}\n' > "$SHUTDOWN_BASE"
echo "✓ Created shutdown_base.json"
fi
# Copy service file to user systemd directory
@ -112,6 +133,28 @@ cp "$LOCKER_TIMER_FILE" "$USER_SERVICE_DIR/$LOCKER_TIMER_NAME"
cp "$SYNC_SERVICE_FILE" "$USER_SERVICE_DIR/$SYNC_SERVICE_NAME"
cp "$SYNC_TIMER_FILE" "$USER_SERVICE_DIR/$SYNC_TIMER_NAME"
# The ledger watch: a gate writing its ledger applies the earned shutdown
# bonus at once instead of at the next 15-minute sync. The .path unit is
# rendered from the earned_time registry, so it is generated, not copied.
cp "$SCRIPT_DIR/$BONUS_SERVICE_NAME" "$USER_SERVICE_DIR/$BONUS_SERVICE_NAME"
PYTHONPATH="$SCRIPT_DIR" python3 -m screen_locker._earner_units "$USER_SERVICE_DIR"
# The RunnerUp upload watch: a TCX landing in ~/data/cloud/RunnerUp starts
# workout-sync at once instead of waiting for the next timer tick.
cp "$SCRIPT_DIR/$RUNNERUP_WATCH_NAME" "$USER_SERVICE_DIR/$RUNNERUP_WATCH_NAME"
# The phone's LAN poke: a finished StrongLifts session is credited within a
# second instead of at the next sync. The key must exist before the unit
# starts, or it refuses to (docs/DOCS-workout-poke-contract.md).
bash "$SCRIPT_DIR/scripts/workout_poke_keygen.sh"
cp "$SCRIPT_DIR/$WORKOUT_POKE_NAME" "$USER_SERVICE_DIR/$WORKOUT_POKE_NAME"
# The RunnerUp retirement: a week after the workout app's first credited run
# it backs the old app's exports up and uninstalls it from the phone. A daily
# timer, because the phone is not always attached; a no-op until then.
cp "$SCRIPT_DIR/$RETIRE_SERVICE_NAME" "$USER_SERVICE_DIR/$RETIRE_SERVICE_NAME"
cp "$SCRIPT_DIR/$RETIRE_TIMER_NAME" "$USER_SERVICE_DIR/$RETIRE_TIMER_NAME"
# Update paths in the service file to use absolute paths
REPO_ROOT="$SCRIPT_DIR"
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
@ -124,14 +167,17 @@ systemctl --user daemon-reload
# Enable the service to start on login (one-shot, no periodic timer)
systemctl --user enable "$SERVICE_NAME"
# Enable the early bird re-check timer
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME"
# Timers: early bird re-check, periodic re-check, workout sync, the instant
# bonus pass on ledger writes, and the daily RunnerUp retirement retry
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME" "$LOCKER_TIMER_NAME" \
"$SYNC_TIMER_NAME" "$BONUS_PATH_NAME" "$RETIRE_TIMER_NAME"
# Enable the periodic re-check timer
systemctl --user enable --now "$LOCKER_TIMER_NAME"
# Enable the periodic workout sync
systemctl --user enable --now "$SYNC_TIMER_NAME"
# Instant RunnerUp credit on upload, and the LAN workout poke listener
# (restart picks up new code)
for unit in "$RUNNERUP_WATCH_NAME" "$WORKOUT_POKE_NAME"; do
systemctl --user enable "$unit"
systemctl --user restart "$unit"
done
# Verify enforcement is actually armed. enable can silently no-op when systemd
# breaks an ordering cycle by deleting the timer's job -- exactly what happened

View File

@ -1,208 +0,0 @@
> **This file is a ready-to-use prompt.** Open this repo and say
> "do dopamine-ux-04-screen-locker". It is self-contained -- it needs no context from
> any other session.
>
> Canonical copy: `~/utils/dopamine-ux/04-screen-locker.md` (source of truth; this is a
> distributed copy). Running order and cross-prompt rules: `~/utils/dopamine-ux/00-INDEX.md`.
> Generated 2026-08-16 from a survey of this repo -- line numbers are accurate
> as of that date; **anchor on the symbol names, not the line numbers.**
## ⛔ WHEN YOU FINISH THIS PROMPT
Do **both**, in the same session that completes the work:
1. **Delete this file** and commit that deletion together with the
implementation:
```bash
git rm prompts/dopamine-ux-04-screen-locker.md
```
Do not leave a finished prompt lying in the repo -- a stale prompt is
indistinguishable from a pending one, and the next session will re-run it.
2. **Log completion in the canonical index**, in `~/utils` (a *different* repo,
so it needs its own commit):
```bash
cd ~/utils
# append to the "Completion log" table in dopamine-ux/00-INDEX.md:
# | 04-screen-locker.md | DONE <YYYY-MM-DD> | <impl commit sha> | <one-line note> |
git add dopamine-ux/00-INDEX.md
git commit -m "dopamine-ux: mark 04-screen-locker.md done"
git push
```
If you finish only part of the prompt, do **neither** -- instead edit this file
to strike out what is done and state what remains, so the next session starts
from the true state. Partial completion must never be recorded as DONE.
---
Surface the workout streak that screen-locker already tracks
## findings (3, ranked by value over effort)
1. **`snapshot.streak` is populated and no renderer reads it** — the cheapest
win in the whole project. Zero new state, zero conftest change, zero deps.
2. **Week-transition reward strings are discarded into `_logger.info`** —
fully-formed celebratory sentences that never reach a user.
3. **The success moment exists only on the lock screen**, for 1.5s. The status
window — the surface visited *voluntarily* — shows only enforcement.
## what
screen-locker computes a real workout streak and celebratory reward text, then
throws almost all of it away. Verified 2026-08-16:
- `StatusSnapshot` (`screen_locker/_status_types.py` ~:88-101) carries populated
`streak`, `bonus_hours_this_week` and `early_bird_extended` fields, filled by
`_status_data.py` ~:210-214. **Grep confirms no renderer reads them.**
- `_extra_benefits.py` `process_week_transition()` (~:82, reward strings built at
~:130-139) produces fully-formed celebratory sentences — and
`_startup_checks.py` ~:57-58 sends them to `_logger.info` and nowhere else.
- The one success moment that exists is on the **lock screen**:
`_unlock_view.py` `unlock_screen()` (~:25) paints "Great job! 💪" and
`🔥 {streak}-week streak`, for `_UNLOCK_DELAY_MS = 1500` before routing away.
So the surface the user visits **voluntarily** — the status window, opened from
the tray icon — shows enforcement and budget depletion, and no evidence of
success at all. This is the cheapest high-value change in the whole project:
the data already exists, is already true, and needs no new state.
## where
Repo: `~/screen-locker`.
Primary:
- `screen_locker/_status_sections.py` — the section renderers used by the status
window (`_section_*` functions). **177 lines; has headroom.** Add
`_section_streak` here.
- `screen_locker/status_view.py` — `StatusWindow`, `render()`. Call the new
section from here. ⚠️ **246 lines — only 4 left under the 250 cap.** Expect to
extract something, or add the call in a way that costs ~1 line.
- `screen_locker/_status_types.py` — `StatusSnapshot` (~:88-101), the fields you
are rendering. Read-only for this task.
- `screen_locker/_extra_benefits.py` (179 lines) — `process_week_transition()`
and its reward strings; `current_streak()` ~:154,
`weekly_shutdown_bonus_hours()` ~:159, `has_extended_early_bird()` ~:171.
- `screen_locker/_startup_checks.py` ~:57-58 — where the reward strings are
currently discarded into a log call.
- `screen_locker/_unlock_view.py` (72 lines, lots of headroom) — the existing
success moment, if you extend it.
## must
- **Render the already-populated fields.** Add `_section_streak` to
`_status_sections.py` showing the weekly streak, this week's bonus hours, and
the extended-early-bird state, and call it from `status_view.py`'s `render()`.
Zero new state, zero conftest change, zero new dependency.
- **Stop discarding the reward strings.** `process_week_transition()` returns
human-readable celebratory sentences that currently only reach the log. Surface
them where the user will actually see them — the status window is the obvious
home; a milestone is worth more than a silent counter increment.
- Match the existing positive-status styling rather than inventing one:
`_status_sections.py` ~:73-78 already renders `"{n} above the weekly minimum!"`
in success green, and uses `✓` marks at ~:36 and ~:57.
- Keep the streak **true**. It counts real ISO weeks with ≥5 counted workouts
(`_weekly_check.py`: `WEEKLY_WORKOUT_MINIMUM = 5`, `COUNTED_WORKOUT_TYPES =
{phone_verified, runnerup_verified, manual_workout}`). Do not round, pad, or
"encourage" the number.
- must not: **make any reward reduce enforcement.** This is recorded twice in the
repo as a deliberate decision:
> `_startup_checks.py` ~:88-93 — "there is no banked 'skip a workout' credit —
> that mechanic works against the goal of maximizing weekly workouts, so it was
> removed in favor of a shutdown-time-only reward"
> `_extra_benefits.py` ~:5-8 — "This never reduces enforcement … it only grants
> extra comfort time on top of a floor you still have to earn each day."
Every reward here is **display or comfort-time only**. Do not reintroduce a
banked skip, a streak freeze, a grace day, or any mechanic that lowers the
floor. A dopamine loop that lets you skip the workout defeats the app.
- must not: add **sound** to any enforcement or lock path. screen-locker is a
self-restriction tool; a chime on lock is punishment audio, and a chime on
unlock trains the wrong association. This repo stays silent. (Sound is
opt-out-on elsewhere in the fleet; here it is simply absent.)
- must not: add a write tool to the MCP server. `_mcp.py` ~:14-26 states it is
read-only by design. Adding `streak` to the existing `get_status` output is
fine; a new action tool violates a stated invariant.
- must not: add `# noqa` (banned repo-wide) or a silent `except`
(`scripts/check_silent_failures.py` — every handler re-raises or logs at
warning or above).
- must not: create a new on-disk state file unless genuinely unavoidable. If you
do, you **must** add it to `_ISOLATED_STATE` in
`screen_locker/tests/conftest.py` (~:134), listing *every* module that binds
the path at import time — a missed binding lets tests write to kuhy's real
state. Read the comment at ~:131-133 and the fixture docstring at ~:170-175.
- optional: a daily streak alongside the weekly one. Note this **would** be new
state (the existing streak is consecutive-*weeks*), so it triggers the conftest
requirement above. diet-guard's consecutive-*day* streak
(`~/diet-guard/diet_guard/_daystatus.py` ~:144) is the precedent to mirror.
## done
1. Opening the status window (tray icon → click, or `screen-locker-status`) shows
the current streak, bonus hours and early-bird state — with the real values
from `extra_benefits_state.json`, not placeholders.
2. A week-transition milestone reaches the UI instead of only `_logger.info`.
3. `cd ~/screen-locker && python -m pytest` passes.
4. `pre-commit run --files <changed files>` is clean — including the 250-line cap
(`scripts/check_file_length.py`) and the silent-failure check.
5. `git diff` touches no enforcement logic: no change to lock decisions, weekly
minimums, or shutdown floors.
## verify
Desktop — this is a desktop Python app.
```
cd ~/screen-locker
python -m screen_locker --status # CLI: confirm the values it prints
screen-locker-status # the Tk window: confirm they render
```
Paste both outputs. The CLI already prints `streak`/`bonus_hours` (`_status.py`
~:138-141), so it is your ground truth: the window must show the **same numbers**.
If they disagree, the renderer is wrong, not the data.
Current real state for comparison: `screen_locker/extra_benefits_state.json` and
`screen_locker/log.json` (12 days of entries, 2026-07-12 → 2026-08-15).
## read first
- `screen_locker/_status_sections.py` — every existing `_section_*`. Match their
signature and style; note the success-green precedent at ~:73-78.
- `screen_locker/_status_types.py` ~:88-101 — the three fields you are rendering.
- `screen_locker/_extra_benefits.py` — especially the module docstring ~:5-8
(the enforcement invariant) and the reward strings ~:130-139.
- `screen_locker/_unlock_view.py` — the existing celebration, for tone.
- `screen_locker/status_view.py` — check its line count **before** editing.
- `~/screen-locker/CLAUDE.md` — repo rules (no silent failures, ruff `select=ALL`).
## context you would otherwise rediscover
- **`log.json` is the single source of truth**, written only through
`_log_mixin.py` `write_signed_entry()` (~:82), HMAC-signed via
`gatelock.log_integrity`, deduped on `workout_id`. The Flutter app converges on
it via `_manual_push.py`. There is no second history — a streak computed from
it is authoritative.
- All persistence is **JSON files, no sqlite**; paths declared in
`screen_locker/_constants.py`. The streak store is
`screen_locker/extra_benefits_state.json` (`consecutive_5plus_weeks`,
`last_processed_iso_week`, `weekly_shutdown_bonus_hours`,
`extended_early_bird_iso_weeks`).
- Thresholds live in `_extra_benefits.py`: `_MILESTONE_INTERVAL = 4` (4-week
milestone), `_BONUS_THRESHOLD = 5` (workouts per week).
- **There is no config system** — no argparse, no env vars, no config file. Every
tunable is a module-level constant. An opt-out toggle has no existing home;
for a self-restriction tool, requiring a code edit to disable is arguably
correct, so prefer a constant in `_constants.py` over new state.
- `~/gatelock` is **not** a repo — it is `~/utils/gatelock/gatelock/`, consumed as
a pinned git dep, and it holds no workout state.
- Manual workouts were **not** removed: `_manual_workout.py` implements a
rate-limited, evidence-gated subsystem (budget 2 per 7 days, 10 per 30).
- Three UI surfaces exist: the Tk status window (`status_view.py`), a GTK tray
icon at `~/.config/i3/scripts/screen_locker_tray.py` (outside the repo, shells
out to the CLI, refreshes every 60s), and the lock screen itself. No i3blocks
integration exists despite `format_summary_line` being described as one.
REMOVE ME AFTER FINISH

View File

@ -1,232 +0,0 @@
> **This file is a ready-to-use prompt.** Open this repo and say
> "do dopamine-ux-05-workout-app". It is self-contained -- it needs no context from
> any other session.
>
> Canonical copy: `~/utils/dopamine-ux/05-workout-app.md` (source of truth; this is a
> distributed copy). Running order and cross-prompt rules: `~/utils/dopamine-ux/00-INDEX.md`.
> Generated 2026-08-16 from a survey of this repo -- line numbers are accurate
> as of that date; **anchor on the symbol names, not the line numbers.**
## ⛔ WHEN YOU FINISH THIS PROMPT
Do **both**, in the same session that completes the work:
1. **Delete this file** and commit that deletion together with the
implementation:
```bash
git rm prompts/dopamine-ux-05-workout-app.md
```
Do not leave a finished prompt lying in the repo -- a stale prompt is
indistinguishable from a pending one, and the next session will re-run it.
2. **Log completion in the canonical index**, in `~/utils` (a *different* repo,
so it needs its own commit):
```bash
cd ~/utils
# append to the "Completion log" table in dopamine-ux/00-INDEX.md:
# | 05-workout-app.md | DONE <YYYY-MM-DD> | <impl commit sha> | <one-line note> |
git add dopamine-ux/00-INDEX.md
git commit -m "dopamine-ux: mark 05-workout-app.md done"
git push
```
If you finish only part of the prompt, do **neither** -- instead edit this file
to strike out what is done and state what remains, so the next session starts
from the true state. Partial completion must never be recorded as DONE.
---
Close the feedback asymmetry in workout_app
## findings (4, ranked by value over effort)
1. **Finishing a workout gets no cue** — while the rest timer gets sound *and*
an 800ms vibration. The app celebrates waiting and ignores achieving. All
infrastructure is already present; only the call is missing.
2. **Set completion has no haptic** — the app's most repeated action, silent.
3. **The fleet's only animation is an inline magic number** —
`Duration(milliseconds: 200)` in `exercise_tile_rows.dart`.
4. **No sound opt-out exists** — the SQLite settings table is ready, but no
public accessor and no `SwitchListTile` anywhere. Step 2 only.
## what
workout_app is the only app in kuhy's fleet that can already play a sound and
vibrate — and it spends that capability on the wrong moment. Verified 2026-08-16:
- **Rest-timer expiry** gets a full cue: `_playBreakEndCue()` in
`lib/screens/workout_screen_session.dart` (~:128-145) plays
`assets/sounds/break_end.mp3` and fires `Vibration.vibrate(duration: 800)`.
- **Finishing an entire workout** gets nothing. `_persistFinishedWorkout()` in
`lib/screens/workout_screen_finish.dart` (~:99-108) ends by showing
`WorkoutSummaryDialog` with no sound, no haptic, no animation.
- **Completing a set** — the core repeated action of the whole app — gets no
haptic either. The `GestureDetector` at `lib/widgets/exercise_tile_rows.dart`
(~:145) toggles state silently.
So the app celebrates *waiting* and ignores *achieving*. Fix the asymmetry: the
moments worth reinforcing are completing a set and finishing a workout.
This app also contains the fleet's **only animation** — a lone
`AnimatedContainer(duration: Duration(milliseconds: 200))` at
`exercise_tile_rows.dart` ~:146-147, with an inline magic number. Replace that
number with a motion token while you are in the file.
## where
Repo: `~/screen-locker`. App: `~/screen-locker/stronglift_replacement/workout_app`
(a Flutter app nested inside the screen-locker repo; that directory contains **no
Python** — only the app and two design docs).
Primary:
- `lib/widgets/exercise_tile_rows.dart` — set-completion `GestureDetector` ~:145,
the `AnimatedContainer` ~:146-147.
- `lib/screens/workout_screen_finish.dart` — `_persistFinishedWorkout()` ~:99-108.
- `lib/widgets/workout_summary_dialog.dart` — `WorkoutSummaryDialog` ~:10.
- `lib/screens/workout_screen_session.dart` — `_playBreakEndCue()` ~:128-145, the
existing cue to model the new ones on. Note its `.catchError` only
`debugPrint`s (~:131-137).
- `lib/screens/workout_screen.dart` — `_audio = AudioPlayer()` ~:78, `vibration`
import ~:9.
- `lib/widgets/rep_circle.dart` — `RepCircle` ~:34, per-set completion indicator.
Progress/history surfaces (already rich; animate rather than rebuild):
- `lib/screens/history_screen_charts.dart` — `_ProgressStatsCard` ~:71,
**`_StreakRow` ~:139**, `_WeightChart` ~:192.
- `lib/screens/history_screen_painter.dart` — `_ChartPainter` ~:9, `paint()` ~:46,
`shouldRepaint` ~:121 (static; no animation today).
- Streak data: `successStreak`/`failStreak` on `ExerciseState`
(`lib/services/storage_service.dart` ~:42-46).
Settings (for the sound opt-out):
- `lib/screens/settings_screen.dart` — `build()` ~:166, `ListView` ~:174-208.
Parts: `settings_screen_rows.dart`, `settings_screen_sections.dart`,
`settings_screen_exercise_sections.dart`, `settings_screen_thresholds.dart`,
`settings_screen_widget.dart`, `settings_screen_actions.dart`.
**No `SwitchListTile` exists** — model a new section on `_SyncSection`
(`settings_screen_sections.dart` ~:14) or `_OfflineBackupSection`.
- Store: **SQLite**, not SharedPreferences. There is a ready-made key/value table
— schema `lib/services/storage_service_schema.dart` ~:34-36
(`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, migration
~:64-65), accessors `lib/services/storage_service.dart` ~:122-137
(`_getSetting`/`_setSetting`, both **private** — add a public typed wrapper like
the existing `setLastWorkoutType`). Already backed up/restored via
`lib/services/storage_service_backup.dart` ~:23, :43, :156-163.
Theme: `lib/ui/theme.dart` — local hand-copy, **no `design_system` dependency**
(`buildAppTheme()` ~:12, `AppStatusColors` ~:76, `AppSpacing` ~:111,
`AppRadius` ~:133, `AppTextSize`). Wired at `lib/main.dart` ~:115.
## must
**Step 1 — motion + haptics (no new dependency).**
- Add `HapticFeedback.selectionClick()` on **set completion** — the tap at
`exercise_tile_rows.dart` ~:145. This is the app's most repeated action; it
should feel like flipping a physical switch. Fire on tap, not after the write.
- Add a completion cue when a **workout finishes** — haptic plus a visible
arrival for `WorkoutSummaryDialog`, rather than it appearing instantly.
- Replace the inline `Duration(milliseconds: 200)` at ~:146-147 with the motion
token from prompt 01, transcribed into `lib/ui/theme.dart`.
- Animate the streak/stat numbers on the history screen to their new values
instead of snapping. `_ChartPainter.shouldRepaint` currently returns a static
result — leave the chart itself alone unless animating it is cheap.
- Honour `MediaQuery.of(context).disableAnimations` — durations collapse to zero;
haptics and sound still fire.
- `VIBRATE` is already granted (`android/app/src/main/AndroidManifest.xml` ~:10)
and `vibration: ^3.1.0` is already a dependency. Prefer `HapticFeedback` from
`flutter/services.dart` for short UI taps and reserve the `vibration` package
for the long timed buzz it already does well.
**Step 2 — sound (separate, independently revertable).**
- Add a finish-workout cue asset alongside `assets/sounds/break_end.mp3`
(declared at `pubspec.yaml` ~:52-53 — **this is the fleet's only `assets:`
block**, so you are adding a file, not the block).
- Sound ships **on**, with an opt-out. Add a public
`getSoundEnabled()`/`setSoundEnabled(bool)` on `StorageService` wrapping
`_getSetting`/`_setSetting` (store `'true'`/`'false'`), then a settings section
with a `SwitchListTile`.
- The toggle must silence the **new** cues and the existing break-end cue alike —
one switch, not two behaviours.
**Both steps:**
- must not: **reward the weight or rep number.** The cue fires on *logging a set*
and *finishing a workout*, not on hitting a PB or beating last week. Rewarding
the number pushes toward misreporting; rewarding the record keeps the data
honest. A deloaded set gets the same confirmation as a PB.
- must not: fabricate the streak. `successStreak`/`failStreak` count real
outcomes; display them as they are.
- must not: add a cue to *deleting* a set or workout.
- must not: silently swallow an audio error. The existing `.catchError` →
`debugPrint` at ~:131-137 is the local precedent; do not make it quieter.
- must not: break the **100% coverage gate** — new public methods on
`StorageService` need tests. See the comments at
`settings_screen_sections.dart` ~:1-11.
- must not: exceed the **250-line cap** on any file.
- optional: a subtle near-miss cue on the history screen when a streak is one
workout short of a milestone — legitimate only because the number is true.
## done
1. Completing a set produces an immediate haptic on the phone.
2. Finishing a workout produces a haptic and a visible dialog arrival; step 2
adds a sound.
3. No inline animation duration remains in `exercise_tile_rows.dart`.
4. `cd ~/screen-locker/stronglift_replacement/workout_app && flutter analyze` clean.
5. `flutter test` passes **and coverage stays at 100%**.
6. With OS "remove animations" on, the app works and durations are zero.
7. Step 2: the settings toggle flips, persists across restart, and silences both
the new cues and the existing break-end cue.
## verify
**On the phone.**
```
adb devices # confirm 23181JEGR08034
cd ~/screen-locker/stronglift_replacement/workout_app
flutter build apk --release
adb install -r build/app/outputs/flutter-apk/app-release.apk
```
Never `flutter install`, never `adb uninstall`, never `pm clear` — those wipe the
SQLite workout history. `adb install -r` preserves it.
Then run a real set on the device: tap a set complete, finish a workout, and
report what the phone actually did. Haptics and sound are physical outcomes —
state what you felt and heard; do not infer them from the code.
## read first
- `lib/screens/workout_screen_session.dart` ~:128-145 — `_playBreakEndCue()`, the
working cue. Your new cues should look like it.
- `lib/widgets/exercise_tile_rows.dart` — the set-completion tap and the fleet's
only `AnimatedContainer`.
- `lib/services/storage_service.dart` ~:122-137 — `_getSetting`/`_setSetting`, and
`setLastWorkoutType` as the public-wrapper pattern.
- `lib/screens/settings_screen_sections.dart` ~:1-11 — the coverage/line-cap
comments, and `_SyncSection` as the section pattern.
- `~/utils/unified-design-system/motion.md` — motion/haptic vocabulary from
prompt 01. **Prompt 01 must have run first.**
## context you would otherwise rediscover
- This app has **no `design_system` dependency**; `lib/ui/theme.dart` is a
hand-transcription of the shared tokens. Motion tokens must be transcribed
there, matching the existing convention. Do not add the dependency here.
- `shared_preferences` **is** a dependency but is used only for the device id
(`lib/services/sync_device_id.dart` ~:37-38). Settings go in SQLite.
- Non-motion `Duration`s that are **not** animation and must not be touched:
rest tickers (`workout_screen.dart` ~:91, `workout_screen_breaks.dart` ~:20,
`workout_screen_session.dart` ~:37 — 1s), settings debounces
(`settings_screen.dart` ~:140, :148 — 600ms), `main.dart` ~:56 (20s).
- The app syncs to the screen-locker Python side, which treats
`screen_locker/log.json` as the single source of truth. **Do not change
what counts as a workout** — enforcement depends on it, and it is out of scope.
- The repo enforces a 250-line file cap and bans `# noqa`; ruff runs
`select = ["ALL"]` on the Python side.
REMOVE ME AFTER FINISH

View File

@ -1,15 +1,18 @@
[project]
name = "screen-locker"
version = "1.0.0"
description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration"
description = "Tkinter/systemd screen locker with workout tracking, shared sick days, and wake-alarm integration"
requires-python = ">=3.14"
# gatelock: shared lock-window/HMAC backend, also used by diet_guard and wake_alarm
# crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport,
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
# crdt-sync: shared CRDT merge scheme + sync transports, also used by
# diet_guard; here for phone-workout sync, Firebase RTDB only since 2026-10-09
dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays",
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time",
"holds @ git+https://github.com/kuhyx/utils@holds-v0.1.0#subdirectory=holds",
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays",
]
[project.scripts]
@ -22,7 +25,7 @@ screen-locker-web = "screen_locker._web_server:main"
# so the systemd/CLI system-python path stays dependency-light and never has
# to import the MCP SDK.
[project.optional-dependencies]
mcp = ["mcp==2.1.1"]
mcp = ["mcp==2.3.0"]
[tool.setuptools.packages.find]
# This repo also holds stronglift_replacement/ (an unrelated Flutter app, not
@ -42,6 +45,9 @@ ignore = [
"D203",
"D213",
"COM812",
# Personal repo, no copyright policy (ruff 0.17 made CPY001 stable under
# select=ALL).
"CPY001",
"ISC001",
"S603",
]
@ -92,17 +98,7 @@ unfixable = []
# -truth reuse status_view.py above is exempted for — and _make_bare_verifier
# imports ScreenLocker inside the function to avoid an import cycle.
"screen_locker/_status_view_verify.py" = ["PLC0415", "SLF001"]
# _workout_handoff.py must pause gatelock's recovery watchdog while the Flutter
# app holds the screen. The loop re-takes any grab it finds missing every
# 1000ms -- by design, so that pulling a cable is not an escape -- and
# holds_grab() is `grab_current() is not None`, which is False once another X
# client holds it. Left running it would steal the screen back from the workout
# app within a second. gatelock exposes no public pause (only close(), which
# also restores VT switching and would hand the user Ctrl+Alt+F2), and adding
# one there would contradict _recovery.py's monotonicity proof that no
# transition ever decreases the grab.
"screen_locker/_workout_handoff.py" = ["SLF001"]
# explain_lock_decision takes one arg per real file path + sick history, and
# explain_lock_decision takes one arg per real file path and input, and
# returns early per lock-decision-chain stage (see module docstring) — both
# are the deliberate design, not something to collapse into a config object.
"screen_locker/_compliance_state.py" = ["C901", "PLR0911", "PLR0913"]
@ -175,7 +171,12 @@ exclude = [".venv/"]
init-hook = "import sys; sys.path.insert(0, '.')"
analyse-fallback-blocks = true
persistent = true
jobs = 0
# Serial on purpose. With jobs != 1 pylint ships the linter to its workers via
# dill.dumps(), and dill 0.4.1 (newest release) reads code.co_lnotab, which
# CPython 3.15 removed -- every run crashed once setup-python's "3.x" became
# 3.15.1. The fix sits on dill master, unreleased; return to 0 once a dill
# release past 0.4.1 ships. The pre-commit hooks take this value too.
jobs = 1
py-version = "3.14"
ignore = [".venv", "__pycache__"]
ignore-patterns = [".*\\.pyi$"]
@ -187,7 +188,42 @@ disable = []
[tool.pylint.design]
min-public-methods = 0
max-module-lines = 1000
max-attributes = 10
# ScreenLocker composes ~30 mixins; collapsing them is not the goal.
max-parents = 40
# Keyword-only arguments count: the gather_*/explain_* entry points take one
# per real file path so tests can override each.
max-args = 8
# ManualWorkoutDraft is a flat 23-field record mirroring the evidence form and
# the wire payload; StatusWindow / ScreenLocker hold UI and injected state.
max-attributes = 25
[tool.pylint.similarities]
# The remaining overlaps are 6-11 line fragments of deliberately parallel code
# (Tk button rows, the two JSON ledgers' loaders, mixin host stubs); a real
# copy-paste block is longer than this.
min-similarity-lines = 12
[tool.pylint.format]
# BaseHTTPRequestHandler dispatches on the method names do_GET / do_POST.
good-names-rgxs = "^do_(GET|POST)$"
[tool.pylint.classes]
# The mixin-host surface: module-level helpers (shutdown_base, status, the
# status window's verify flow) drive the ScreenLocker's own mixin methods, the
# same single-source-of-truth reuse ruff exempts via per-file SLF001.
exclude-protected = [
"_asdict", "_fields", "_replace", "_source", "_make", "os._exit",
"_adjust_shutdown_time_by",
"_apply_sick_floor",
"_apply_workout_credit",
"_credit_ingested_workout",
"_read_shutdown_config",
"_scan_and_fill_week_runnerup",
"_try_fill_stronglifts_for_week",
"_verify_phone_workout",
"_verify_runnerup_workout",
"_write_shutdown_config",
]
[tool.pylint.typecheck]
generated-members = [
@ -218,6 +254,14 @@ addopts = [
"--cov-branch",
"--cov-report=term-missing",
"--cov-report=lcov",
# The commit gate runs plain `python -m pytest -q`; serial it took 26 s of
# its 60 s budget, 4 workers 9 s. Fixed, not `auto`: auto is 24 workers
# here, past capped.sh's 4 GiB. CI's own `-n auto` comes later and wins;
# `-n0` gets a serial run back.
"-n",
"4",
"--dist",
"worksteal",
# A fixture-only plugin module, not conftest.py (already at the
# repo's 400-line-per-file cap) -- see its own docstring.
"-p",
@ -232,6 +276,9 @@ filterwarnings = [
[tool.coverage.run]
source = ["screen_locker"]
branch = true
# sys.monitoring (PEP 669) rather than the C tracer: test_shutdown_ceiling_
# invariant.py alone measured 13 s vs 22 s, for the same line+branch data.
core = "sysmon"
omit = [
"*/__pycache__/*",
"*/tests/*",

View File

@ -13,6 +13,12 @@ UNITS=(
"early-bird-workout-check.timer"
"workout-sync.timer"
"workout-sync.service"
"earner-bonus.path"
"earner-bonus.service"
"runnerup-watch.service"
"workout-poke.service"
"runnerup-retire.timer"
"runnerup-retire.service"
)
for unit in "${UNITS[@]}"; do

View File

@ -2,22 +2,29 @@
# Runtime: tkinter/subprocess/socket/sqlite3 (stdlib) plus gatelock (below).
bandit==1.9.4
codespell==2.4.3
coverage==7.16.0
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock
mcp==2.1.1
mypy==2.3.1
pre-commit==4.6.2
pylint==4.0.8
pytest==9.1.1
coverage==7.17.2
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
holds @ git+https://github.com/kuhyx/utils@holds-v0.1.0#subdirectory=holds
mcp==2.3.0
mypy==2.4.1
pre-commit==4.7.0
pylint==4.1.3
pytest==9.2.0
pytest-cov==7.1.0
pytest-randomly==5.0.0
pytest-randomly==5.1.0
pytest-sugar==1.1.1
pytest-xdist==3.8.0
# Direct dependency since the Firebase session recovery exchanges a refresh
# token over the REST endpoint; crdt-sync already pulls requests in, but this
# module imports it itself, so it is declared rather than assumed.
requests==2.34.2
ruff==0.16.6
types-requests==2.33.0.20260906
ruff==0.17.4
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays
types-requests==2.33.0.20261030
# Direct dependency since the Firebase session stream (_session_stream) catches
# urllib3's own errors on the long-lived SSE read; requests pulls it in, but
# the module imports it itself, so it is declared rather than assumed.
urllib3==2.8.0

15
runnerup-retire.service Normal file
View File

@ -0,0 +1,15 @@
[Unit]
Description=Back up and uninstall the stock RunnerUp app a week after the workout app's first credited run
[Service]
Type=oneshot
# screen_locker is pip-installed (editable), like workout-sync.service, so no
# WorkingDirectory/PYTHONPATH. Headless: no DISPLAY, no Tk, no lock screen.
#
# Exit 0 for "not due yet", "no app run credited yet" and "phone not attached"
# (the timer retries), non-zero when a human is needed: invalid import marker,
# adb missing, a failed backup or a refused uninstall. That shows up in
# `systemctl --user --failed`. Every skip says why in the journal:
# journalctl --user -u runnerup-retire
# State and the anchor: python3 -m screen_locker.runnerup_retire --status
ExecStart=/usr/bin/python3 -m screen_locker.runnerup_retire

18
runnerup-retire.timer Normal file
View File

@ -0,0 +1,18 @@
[Unit]
Description=Retry the RunnerUp retirement daily until the phone has been cleaned up
# No After=graphical-session.target: timers.target is ordered before it, and
# that After= closes a cycle systemd resolves by deleting this timer's job
# (see early-bird-workout-check.timer for the 2026-08-04 incident).
[Timer]
# The phone is not always on USB, so a failed or skipped pass is simply tried
# again: shortly after boot and once a day. Most passes are an instant no-op
# (not due yet, or already done), so the timer stays installed.
OnBootSec=10min
OnCalendar=*-*-* 19:00:00
# Persistent=true: a PC that was off at 19:00 still gets that day's pass.
Persistent=true
[Install]
WantedBy=timers.target

13
runnerup-watch.service Normal file
View File

@ -0,0 +1,13 @@
[Unit]
Description=Credit a RunnerUp run the moment its TCX upload lands (starts workout-sync)
[Service]
# Long-running inotify watcher on ~/data/cloud/RunnerUp: idle until dufs
# finishes writing a *.tcx, then starts workout-sync.service. See the script
# header for why this is not a .path unit.
ExecStart=%h/src/screen-locker/scripts/runnerup_watch.sh
Restart=always
RestartSec=30
[Install]
WantedBy=default.target

View File

@ -0,0 +1,133 @@
"""A day's activities summed under the one crediting rule (see ``_walk_tcx``).
The day counts as the workout once EITHER its summed distance reaches
``MIN_RUN_DISTANCE_KM`` (within the GPS tolerance) OR its summed moving time
reaches the shared duration bar. The sport only labels messages.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from screen_locker._constants import (
MIN_RUN_DISTANCE_KM,
MIN_WORKOUT_DURATION_MINUTES,
RUNNERUP_DISTANCE_TOLERANCE,
WORKOUT_DURATION_ACCEPT_MINUTES,
)
if TYPE_CHECKING:
from collections.abc import Iterable
# RunnerUp's sport codes, for labels only (the label never decides credit).
SPORT_NAMES: dict[int, str] = {
0: "Running",
1: "Biking",
2: "Other",
3: "Orienteering",
4: "Walking",
5: "Treadmill",
6: "Gym",
7: "Stationary Bike",
}
def distance_qualifies(distance_km: float) -> bool:
"""Whether a distance meets the minimum, with the GPS tolerance."""
return distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) >= MIN_RUN_DISTANCE_KM
def moving_qualifies(moving_minutes: float) -> bool:
"""Whether moving time meets the shared duration bar (leeway included)."""
return moving_minutes >= WORKOUT_DURATION_ACCEPT_MINUTES
def _distance_metres(activity: dict[str, Any]) -> float:
"""Credited metres; the root-DB path has no trackpoints, so its total stands in."""
return float(activity.get("track_distance_m", activity.get("distance_m", 0.0)))
def _moving_seconds(activity: dict[str, Any]) -> float:
"""Moving seconds; the root-DB path has no trackpoints, so wall-clock stands in."""
return float(activity.get("moving_seconds", activity.get("duration_seconds", 0.0)))
@dataclass(frozen=True)
class ActivityDay:
"""A day's activities, summed.
Attributes:
legs: Distinct activities (deduplicated by the activity's ``Id``: the
WebDAV copy and the phone's copy are the same activity).
moving_minutes: Their summed moving time.
distance_km: Their summed distance.
completed_at: Unix time the activity that took the day over the bar
ended -- when the workout was actually done -- or ``None`` if the
day never got there (or the activity recorded no end).
all_walks: Whether every activity was a walk (label only).
sport: The sport code when the day is a single non-walk activity.
"""
legs: int
moving_minutes: float
distance_km: float
completed_at: float | None
all_walks: bool = False
sport: int = -1
@property
def qualifies(self) -> bool:
"""Whether the day counts as its workout: distance OR moving time."""
return distance_qualifies(self.distance_km) or moving_qualifies(
self.moving_minutes
)
def message(self) -> str:
"""The verification line, in the RunnerUp verifier's own style."""
if self.all_walks:
return (
f"Walking: {self.legs} walk(s), {self.distance_km:.1f} km, "
f"{self.moving_minutes:.0f} min moving"
)
if self.legs == 1:
name = SPORT_NAMES.get(self.sport, str(self.sport))
return f"{name}: {self.distance_km:.1f} km in {self.moving_minutes:.0f} min"
return (
f"Activity: {self.legs} activities, {self.distance_km:.1f} km, "
f"{self.moving_minutes:.0f} min moving"
)
def activity_shortfall(day: ActivityDay) -> str:
"""Why a day's activity did not count yet."""
return (
f"{day.message()} -- need {MIN_RUN_DISTANCE_KM:g}+ km or "
f"{MIN_WORKOUT_DURATION_MINUTES}+ min moving"
)
def activity_day(activities: Iterable[dict[str, Any]]) -> ActivityDay:
"""Sum parsed exports (``parse_tcx_file`` dicts) into one :class:`ActivityDay`."""
unique: dict[str, dict[str, Any]] = {}
for activity in activities:
unique.setdefault(str(activity.get("activity_id") or id(activity)), activity)
ordered = sorted(unique.values(), key=lambda a: a.get("ended_at") or 0.0)
moving = 0.0
metres = 0.0
completed_at: float | None = None
for activity in ordered:
moving += _moving_seconds(activity)
metres += _distance_metres(activity)
if completed_at is None and (
moving_qualifies(moving / 60) or distance_qualifies(metres / 1000)
):
completed_at = activity.get("ended_at")
return ActivityDay(
legs=len(ordered),
moving_minutes=moving / 60,
distance_km=metres / 1000,
completed_at=completed_at,
all_walks=bool(ordered) and all(a.get("walking") for a in ordered),
sport=int(ordered[0].get("sport", -1)) if ordered else -1,
)

View File

@ -11,7 +11,7 @@ Deciding whether what came back counts as a workout stays in
from __future__ import annotations
from concurrent.futures import ( # pylint: disable=no-name-in-module
from concurrent.futures import (
ThreadPoolExecutor,
as_completed,
)

View File

@ -174,7 +174,7 @@ def locker_unit_active() -> bool | None:
if not systemctl_available():
return None
code, out = _run(["systemctl", "--user", "is-active", LOCKER_UNIT])
if code != 0 and not out.strip():
if code and not out.strip():
return None
return out.strip() == "active"
@ -186,7 +186,7 @@ def _login_target_active() -> bool:
True when ``graphical-session.target`` is active.
"""
code, out = _run(["systemctl", "--user", "is-active", _LOGIN_TARGET])
return code == 0 and out.strip() == "active"
return not code and out.strip() == "active"
def collect_states() -> list[TimerState]:
@ -205,7 +205,7 @@ def collect_states() -> list[TimerState]:
states.append(
TimerState(
name=name,
enabled=code == 0 and raw == "enabled",
enabled=not code and raw == "enabled",
enabled_raw=raw,
# For a timer this is "systemd has a next trigger for it";
# for the login-run service it is "the target it is wanted by

View File

@ -1,9 +1,20 @@
"""Mixin: auto-upgrade early_bird/sick_day pending states via phone or RunnerUp.
"""Mixin: the today-state rungs of the lock ladder, and the early-bird upgrade.
Neither early_bird (a same-day pending marker, see ``_early_bird.py``) nor
sick_day (tracked in ``sick_history.json`` via ``_sick_tracker.py``) live in
log.json — this module only checks their pending state and, on
success, writes the *real* outcome (phone_verified/runnerup_verified) there.
early_bird is a same-day pending marker (``_early_bird.py``), not a log.json
entry — this module only checks its pending state and, on success, writes
the *real* outcome (phone_verified/runnerup_verified) there.
A sick day is a date in the shared ``sickdays`` pool (``_sick.py``): the
lock stands down for it and nothing is written. A workout done on a sick
day still reaches the log through the ordinary sync paths and earns
normally, so there is no sick-day upgrade any more.
The upgrade credits through ``_apply_workout_credit`` -- save first, reward
only if the entry was actually appended. It used to push the shutdown hour
and THEN save: when the same run was already in the log (the 15-minute sync
pass had pulled the TCX at 20:25 on 2026-09-16) the save deduped to nothing
and the +2h landed anyway, and again on every 5-minute tick while the
pending marker lasted -- a runaway that only the 23:00 ceiling stopped.
"""
from __future__ import annotations
@ -11,10 +22,12 @@ from __future__ import annotations
import logging
import sys
from screen_locker import _sick_tracker
from screen_locker._decision_log import LockDecision, record_decision
from screen_locker._decision_reasons import reasons_extra
from screen_locker._wake_state import has_workout_skip_today
from screen_locker._hold import workout_hold_active
from screen_locker._morning_session import MorningSkip, has_workout_skip_today
from screen_locker._night_lockdown import has_evening_lockdown_today
from screen_locker._sick import stands_down_workout
from screen_locker._weekly_check import has_weekly_minimum, is_relaxed_day
_logger = logging.getLogger(__name__)
@ -60,6 +73,8 @@ class _ReasonsMixin:
"wake_alarm_skip": has_workout_skip_today,
"relaxed_day": is_relaxed_day,
"weekly_minimum_met": lambda: has_weekly_minimum(self.log_file),
"evening_lockdown_today": has_evening_lockdown_today,
"test_hold_active": workout_hold_active,
},
)
@ -72,114 +87,99 @@ class AutoUpgradeMixin(_ReasonsMixin):
"""
def _is_sick_day_today(self) -> bool:
"""Check if today is marked as a sick day in sick_history.json."""
return _sick_tracker.is_sick_day(_sick_tracker.load_history())
"""Whether the sickdays pool switches the workout lock off today.
def _check_early_exits(self, *, verify_only: bool) -> None:
A sick day that keeps the workout locker is a normal day here.
"""
return stands_down_workout()
def _check_early_exits(self) -> None:
"""Check startup conditions and exit early when appropriate."""
if verify_only:
if not self._is_sick_day_today():
_skip("no_sick_day_to_verify", "No sick day logged today.")
return
self._check_non_verify_exits()
def _check_today_state_exits(self) -> bool:
"""Handle early-bird and today's log states. Return True to stop startup."""
if self._is_early_bird_pending() and not self._is_early_bird_time():
if self._try_auto_upgrade_early_bird():
_skip(
"early_bird_auto_upgraded",
"Auto-upgraded early_bird entry to phone_verified.",
)
return True
return False # Expired early bird, upgrade unavailable — full lock.
if self._is_early_bird_pending():
# The ONLY thing that closes this window is
# early-bird-workout-check.timer. When that timer was disabled by an
# ordering cycle (2026-08-04) this branch silently deferred the lock
# every single day, forever.
pending = self._is_early_bird_pending()
window_open = self._is_early_bird_time()
if pending and not window_open and self._try_auto_upgrade_early_bird():
_skip(
"early_bird_auto_upgraded",
"Auto-upgraded early_bird entry to phone_verified.",
)
return True
# An expired marker with nothing to upgrade is NOT a lock on its own.
# It used to `return False` right here, ahead of has_logged_today, so
# a workout logged by any other path after 08:30 never counted while
# the marker lasted: on 2026-09-18 a manual walk entered on the lock
# screen at 09:32 closed the lock, and the 09:35 tick locked again --
# every 5 minutes, for the rest of the day -- while the explain chain
# (_compliance_state, where the expired marker is non-terminal) said
# "lock skipped". Fall through to the same ladder as any other morning.
if pending and window_open:
# The window is the wake-alarm carrot (see EarlyBirdMixin): it
# closes when the signed exempt_until passes, and the 5-minute
# tick plus early-bird-workout-check.timer then re-decide.
_skip(
"early_bird_window_active",
"Early bird window still active — deferring to the 08:30/09:05 "
"re-check timer.",
recheck_by="early-bird-workout-check.timer",
f"Morning-session carrot still in force: {self._morning_skip}.",
recheck_by="workout-locker.timer",
**self._other_conditions("early_bird_window_active"),
)
elif self._is_sick_day_today():
if self._try_auto_upgrade_sick_day():
_skip(
"sick_day_auto_upgraded",
"Auto-upgraded today's sick_day entry to phone_verified.",
)
else:
_skip(
"sick_day",
"Sick day already logged today.",
**self._other_conditions("sick_day"),
)
_skip(
"sick_day",
"Today is a sick day (sickdays pool); the workout lock is off.",
**self._other_conditions("sick_day"),
)
elif self.has_logged_today():
_skip(
"workout_logged_today",
"Workout already logged today.",
**self._other_conditions("workout_logged_today"),
)
elif has_workout_skip_today():
elif window_open:
# Bank the marker so the run that sees the carrot end tries a
# phone/RunnerUp workout before falling through to a lock.
self._save_early_bird_pending()
skip = self._morning_skip
_skip(
"wake_alarm_skip",
"Wake alarm earned a workout skip.",
f"Morning session earned it: {skip or 'carrot in force'}.",
exempt_until=skip.exempt_until.isoformat() if skip else None,
outcome=skip.outcome if skip else None,
**self._other_conditions("wake_alarm_skip"),
)
elif self._is_early_bird_time():
self._save_early_bird_pending()
_skip(
"early_bird_banked",
"Early bird time — banked the pending marker, will re-check "
"at 08:30/09:05.",
recheck_by="early-bird-workout-check.timer",
)
else:
return False
return True
def _try_auto_upgrade_sick_day(self) -> bool:
"""Upgrade sick_day entry when phone or RunnerUp detects a valid workout."""
try:
status, message = self._verify_phone_workout()
except (OSError, RuntimeError) as exc:
_logger.warning(
"Sick-day auto-upgrade could not reach the phone (%s) — today's "
"sick_day entry stays unverified; trying RunnerUp next",
exc,
def _stand_down_for_carrot(self, skip: MorningSkip) -> None:
"""Take an armed lock down because the carrot landed after it armed.
Does what the ``wake_alarm_skip`` rung above does -- bank the pending
marker so the post-carrot run still tries the phone workout, record the
decision -- then leaves through the locker's normal ``close`` (the same
one a credited workout and a test hold use): lock window released,
instance lock dropped, exit 0. Nothing is credited or penalised.
"""
self._save_early_bird_pending()
record_decision(
LockDecision(
locked=False,
reason="wake_alarm_skip_late",
detail=f"Morning session carrot landed after the lock armed: {skip}.",
extra={
"exempt_until": skip.exempt_until.isoformat(),
"outcome": skip.outcome,
},
)
status, message = "error", str(exc)
if status == "verified":
self.workout_data["type"] = "phone_verified"
self.workout_data["source"] = message
self.workout_data["after_sick_day"] = "true"
self._adjust_shutdown_time_later()
self.save_workout_log()
return True
_logger.info("Auto-upgrade phone skipped (%s), trying RunnerUp...", status)
try:
runnerup_status, runnerup_msg = self._verify_runnerup_workout()
except (OSError, RuntimeError) as exc:
_logger.warning(
"Sick-day auto-upgrade could not read RunnerUp either (%s) — "
"today stays a sick_day, NOT upgraded to a verified workout",
exc,
)
return False
if runnerup_status != "verified":
_logger.info(
"Auto-upgrade RunnerUp skipped (%s): %s", runnerup_status, runnerup_msg
)
return False
self.workout_data["type"] = "runnerup_verified"
self.workout_data["source"] = runnerup_msg
self.workout_data["after_sick_day"] = "true"
self._adjust_shutdown_time_later()
self.save_workout_log()
return True
)
_logger.warning(
"Morning-session carrot appeared while the lock was up (%s) — "
"taking the lock down; the early-bird marker is banked.",
skip,
)
self.close()
def _try_auto_upgrade_early_bird(self) -> bool:
"""Try phone then RunnerUp to upgrade an early_bird log entry."""
@ -196,8 +196,7 @@ class AutoUpgradeMixin(_ReasonsMixin):
self.workout_data["type"] = "phone_verified"
self.workout_data["source"] = message
self.workout_data["after_early_bird"] = "true"
self._adjust_shutdown_time_later()
self.save_workout_log()
self._apply_workout_credit()
return True
_logger.info("Early bird phone skipped (%s), trying RunnerUp...", status)
try:
@ -218,6 +217,5 @@ class AutoUpgradeMixin(_ReasonsMixin):
self.workout_data["type"] = "runnerup_verified"
self.workout_data["source"] = runnerup_msg
self.workout_data["after_early_bird"] = "true"
self._adjust_shutdown_time_later()
self.save_workout_log()
self._apply_workout_credit()
return True

View File

@ -0,0 +1,38 @@
"""One writer at a time for the shutdown bonus state.
The daily reset and the live bonus pass both read ``shutdown_base.json``,
push ``/etc/shutdown-schedule.conf`` with a read-add-write and stamp the file.
They run from three processes -- the locker at login, the 15-minute
``workout-sync`` pass and the ledger-triggered ``earner-bonus`` pass -- so two
of them can see the same unstamped earner and each add its time. Holding this
lock across check, write and stamp makes the second one see the stamp.
A dedicated file, not the state file itself: ``_save_state`` truncates that.
"""
from __future__ import annotations
from contextlib import contextmanager
import fcntl
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
__all__ = ["bonus_lock"]
@contextmanager
def bonus_lock(state_file: Path) -> Iterator[None]:
"""Hold an exclusive lock beside *state_file* for the ``with`` body.
Blocks until any other holder is done; the passes it serialises take a
second or two, so waiting is cheaper than skipping a bonus.
"""
with state_file.with_suffix(".lock").open("a") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(handle, fcntl.LOCK_UN)

View File

@ -0,0 +1,76 @@
"""An armed lock stands down when wake-alarm's signed carrot lands late.
2026-11-07: the lock armed at 07:49:17 because today's signed
``morning_session.json`` had not landed yet (the refresher lost a network
race). It landed at 07:50:00 (``in_progress``, exempt until 09:13) and nothing
ever re-read it, so the morning session ran under a fullscreen lock. The
startup ladder reads the file once; this is the same verdict, re-asked every
``POLL_MS`` while the window is up.
The decision stays wake-alarm's (signature ok, dated today, now < exempt_until);
this module only decides *when to ask* -- 05:00-11:00 local, the hours the
refresher runs -- and never waits, since it sits on the Tk loop. The file is
read through the ``_morning_session`` module attribute so the test suite's
redirected path applies.
"""
from __future__ import annotations
from datetime import UTC, datetime
import logging
from typing import TYPE_CHECKING
from screen_locker import _morning_session
if TYPE_CHECKING:
from collections.abc import Callable
from tkinter import Misc
__all__ = ["POLL_MS", "watch_for_carrot"]
_logger = logging.getLogger(__name__)
POLL_MS = 15_000
# Local minutes after midnight; mirrors gatelock.morning_session.MORNING_WINDOW.
_WINDOW_START_MIN = 5 * 60
_WINDOW_END_MIN = 11 * 60
def _local_now() -> datetime:
return datetime.now(tz=UTC).astimezone()
def _in_window(now: datetime) -> bool:
return _WINDOW_START_MIN <= now.hour * 60 + now.minute < _WINDOW_END_MIN
def watch_for_carrot(
root: Misc, on_carrot: Callable[[_morning_session.MorningSkip], None]
) -> None:
"""Hand a late-landing signed carrot to ``on_carrot`` within one poll.
``on_carrot`` is the locker's own stand-down (bank the early-bird marker,
record the decision, then the normal ``close``). Polls on the Tk loop (no
thread) and stops re-arming once it fires. A failed read is logged and the
poll goes on: one bad read must not end the only thing watching.
"""
def _tick() -> None:
now = _local_now()
if _in_window(now):
try:
skip = _morning_session.morning_skip_today(now, wait=False)
except (OSError, ValueError, TypeError, KeyError) as exc:
_logger.warning(
"Could not read the morning session while the lock is up "
"(%s) — keeping the lock and trying again in %d s",
exc,
POLL_MS // 1000,
)
skip = None
if skip is not None:
on_carrot(skip)
return
root.after(POLL_MS, _tick)
root.after(POLL_MS, _tick)

39
screen_locker/_ceiling.py Normal file
View File

@ -0,0 +1,39 @@
"""The latest shutdown a day can earn: ``earned_time``'s ceiling, bounded at 23:00."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import earned_time
if TYPE_CHECKING:
from datetime import date
_logger = logging.getLogger(__name__)
# adjust_shutdown_schedule.sh's RESTORE_CEILING (23:00): the hard upper bound
# every --restore write is clamped to, and the answer when the lookup fails.
HARD_CEILING_MINUTES = 23 * 60
def ceiling(day: date) -> int:
"""The latest shutdown ``day`` can earn, minutes after its midnight.
Never above :data:`HARD_CEILING_MINUTES`. A lookup that raises fails open
to that bound -- the same cap the helper enforces anyway -- and says so at
warning, so a broken registry cannot silently lift or lower the cap.
"""
fn = getattr(earned_time, "shutdown_ceiling_for", None)
if fn is None:
return min(HARD_CEILING_MINUTES, int(earned_time.SHUTDOWN_CEILING_MINUTES))
try:
return min(HARD_CEILING_MINUTES, int(fn(day)))
except (ArithmeticError, LookupError, TypeError, ValueError) as err:
_logger.warning(
"shutdown ceiling for %s unavailable (%s); using %s",
day,
err,
f"{HARD_CEILING_MINUTES // 60:02d}:{HARD_CEILING_MINUTES % 60:02d}",
)
return HARD_CEILING_MINUTES

View File

@ -13,17 +13,76 @@ from pathlib import Path
import sys
from typing import TYPE_CHECKING
from screen_locker._constants import SHUTDOWN_BASE_FILE
from screen_locker._decision_log import record_no_decision
from screen_locker._manual_cli import run_manual_log
from screen_locker._rest_day_cli import run_declare_rest_day
from screen_locker._shutdown_base import apply_flat_bonuses_if_new
from screen_locker._status import run_status
from screen_locker._workout_ledger_cli import run_backfill
if TYPE_CHECKING:
from screen_locker.screen_lock import ScreenLocker
__all__ = ["main"]
_logger = logging.getLogger(__name__)
_LOG_FILE_NAME = "log.json"
_LOG_MANUAL_FLAG = "--log-manual-workout"
_REST_DAY_FLAG = "--declare-rest-day"
_BACKFILL_FLAG = "--backfill-workout-ledger"
# Subcommands that own every argument after them (each has its own argparse,
# which also answers its own --help).
_TRAILING_ARG_FLAGS = (_LOG_MANUAL_FLAG, _REST_DAY_FLAG, _BACKFILL_FLAG)
_MODE_FLAGS = frozenset({"--status", "--sync-only", "--apply-bonuses", "--production"})
_HELP_FLAGS = frozenset({"-h", "--help"})
_EXIT_USAGE = 2
_USAGE = """\
usage: python3 -m screen_locker.screen_lock [MODE]
With no mode: the demo lock screen (Tk). Modes:
--production the real lock screen (what workout-locker.service runs)
--status print this week's workout status and exit
--sync-only headless: pull synced workouts, apply credit, exit
--apply-bonuses headless: apply newly earned flat bonuses, exit
--log-manual-workout ARGS headless manual workout log (see its --help)
--declare-rest-day ARGS sign a future rest day (see its --help)
--backfill-workout-ledger ARGS
backfill the workout ledger (see its --help)
-h, --help show this help and exit
"""
def _own_args(argv: list[str]) -> list[str]:
"""``argv[1:]`` up to the first subcommand that owns the rest."""
args = argv[1:]
for index, arg in enumerate(args):
if arg in _TRAILING_ARG_FLAGS:
return args[:index]
return args
def _handle_help_and_unknown(argv: list[str]) -> None:
"""Exit 0 on ``--help``; exit 2 on an unknown argument.
Before this, ``--help`` matched no branch and fell through to building
the demo lock screen, and a typo'd flag did the same silently.
"""
own = _own_args(argv)
if _HELP_FLAGS & set(own):
# The usage on stdout IS the work --help asked for, so exiting 0 here
# abandons nothing. SystemExit is raised directly, not via sys.exit:
# the suite stubs sys.exit process-wide, and a help request must never
# fall through to building the lock screen even then.
sys.stdout.write(_USAGE)
raise SystemExit(0)
unknown = [arg for arg in own if arg not in _MODE_FLAGS]
if unknown:
sys.stderr.write(f"error: unknown argument(s): {' '.join(unknown)}\n{_USAGE}")
sys.exit(_EXIT_USAGE)
def _headless_locker(locker_cls: type[ScreenLocker]) -> ScreenLocker:
@ -41,6 +100,7 @@ def _headless_locker(locker_cls: type[ScreenLocker]) -> ScreenLocker:
def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None:
"""Dispatch on ``argv`` and run the requested mode."""
_handle_help_and_unknown(argv)
# Configure logging for EVERY mode, not just --sync-only. This used to sit
# inside the --sync-only branch, so `--production` -- the mode systemd
# actually runs -- had no handler and fell back to lastResort, which drops
@ -61,6 +121,14 @@ def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None:
rest = argv[argv.index(_LOG_MANUAL_FLAG) + 1 :]
sys.exit(run_manual_log(_headless_locker(locker_cls).log_file, rest))
if _REST_DAY_FLAG in argv:
# Headless, no Tk: sign a FUTURE rest day (see screen_locker._rest_day).
sys.exit(run_declare_rest_day(argv[argv.index(_REST_DAY_FLAG) + 1 :]))
if _BACKFILL_FLAG in argv:
rest = argv[argv.index(_BACKFILL_FLAG) + 1 :]
sys.exit(run_backfill(_headless_locker(locker_cls).log_file, rest))
if "--sync-only" in argv:
# Headless sync for the timer unit: pull other devices' workouts and
# apply their credit, with no Tk and no lock screen. Sync used to run
@ -73,8 +141,14 @@ def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None:
record_no_decision("--sync-only")
sys.exit(0)
locker = locker_cls(
demo_mode="--production" not in argv,
verify_only="--verify-workout" in argv,
)
if "--apply-bonuses" in argv:
# earner-bonus.service, started by earner-bonus.path when a gate
# writes its ledger: only the flat-bonus pass, no phone sync, so a
# solve moves shutdown within seconds rather than at the next sync.
_logger.info("Ledger changed: applying any newly earned flat bonus.")
apply_flat_bonuses_if_new(SHUTDOWN_BASE_FILE, _headless_locker(locker_cls))
record_no_decision("--apply-bonuses")
sys.exit(0)
locker = locker_cls(demo_mode="--production" not in argv)
locker.run()

View File

@ -14,7 +14,6 @@ rather than at the old ``_compliance_state`` path.
from __future__ import annotations
from datetime import UTC, datetime
import json
import logging
from typing import TYPE_CHECKING
@ -22,34 +21,28 @@ from typing import TYPE_CHECKING
import freedays
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
from screen_locker._constants import (
EARLY_BIRD_END_HOUR,
EARLY_BIRD_END_MINUTE,
EARLY_BIRD_START_HOUR,
)
from screen_locker._day import today_str
from screen_locker._log_io import load_workout_log
from screen_locker._sick_tracker import is_sick_day as _is_sick_day
from screen_locker._sick import stands_down_workout
from screen_locker._weekly_check import RELAXED_DAY_SKIP_TYPE
if TYPE_CHECKING:
from datetime import date
from pathlib import Path
from screen_locker._sick_tracker import SickHistory
_logger = logging.getLogger(__name__)
def _today_str() -> str:
"""Return today's date as ``YYYY-MM-DD`` in UTC."""
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
"""Return today's LOCAL date as ``YYYY-MM-DD`` (see ``screen_locker._day``)."""
return today_str()
def is_scheduled_skip_today(today: date | None = None) -> bool:
"""Return True if *today* is in the shared free-day pool.
The source moved from this app's own ``scheduled_skips.json`` to the
fleet-wide pool (``~/utils/freedays``), so one marked day now stands down
fleet-wide pool (``~/src/utils/freedays``), so one marked day now stands down
every gate app at once instead of only this one.
The date is **local**, not UTC. The file-backed check this replaces
@ -134,23 +127,11 @@ def is_early_bird_pending(pending_file: Path, *, today: str | None = None) -> bo
return False
def is_sick_day_today(history: SickHistory, *, today: str | None = None) -> bool:
"""Return True if today is recorded as a sick day.
def is_sick_day_today(today: date | None = None) -> bool:
"""Return True if the sickdays pool switches the workout lock off that day.
Thin wrapper around ``_sick_tracker.is_sick_day`` kept here so callers of
the status layer have one predicate module to import from.
Kept here so callers of the status layer have one predicate module to
import from; the answer itself is ``_sick.stands_down_workout``'s, so a
sick day that keeps the workout locker reads False.
"""
return _is_sick_day(history, today=today)
def _early_bird_window_open(*, extended: bool, local_minutes: int) -> bool:
"""Deliberate, independent reimplementation of ``_is_early_bird_time``.
Not shared with ``EarlyBirdMixin._is_early_bird_time`` because too many
existing tests are pinned to its ``_get_local_time_minutes`` patch point.
Kept here as a small, separate, pure re-implementation for the status
layer only — see the module docstring in ``_early_bird.py``.
"""
start = EARLY_BIRD_START_HOUR * 60
end = 9 * 60 if extended else EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
return start <= local_minutes < end
return stands_down_workout(today)

View File

@ -6,7 +6,6 @@ Single source of truth for the leaf checks walked by
mixins delegate their read-only checks to the functions here; this module
never touches ADB, sudo, subprocess, or the network, and never writes to
disk.
``explain_lock_decision`` re-derives the same ordered chain for the status
view. One branch is a deliberate simplification: when an early-bird pending
marker has expired, the real code attempts a live phone/RunnerUp
@ -18,11 +17,11 @@ surfaces the pending opportunity separately via ``AutoUpgradeOpportunity``.
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from screen_locker._compliance_predicates import (
_early_bird_window_open,
has_logged_today,
is_early_bird_pending,
is_relaxed_day_skipped_today,
@ -38,13 +37,14 @@ from screen_locker._compliance_trace import (
describe_auto_upgrade_opportunity,
describe_degraded_sources,
)
from screen_locker._day import today_str as local_day
from screen_locker._hold import workout_hold_active
if TYPE_CHECKING:
from collections.abc import Sequence
from pathlib import Path
from screen_locker._degraded_sources import DegradedSource
from screen_locker._sick_tracker import SickHistory
__all__ = [
"AutoUpgradeOpportunity",
@ -60,15 +60,150 @@ __all__ = [
]
@dataclass(frozen=True)
class _Step:
"""One predicate of the lock-decision chain, in evaluation order."""
name: str
reason_true: str
reason_false: str
terminal_reason: str | None = None
note_when_false: bool = False
# Order mirrors ``_check_non_verify_exits`` / ``_check_today_state_exits``.
# ``early_bird_pending_expired`` is recorded but never terminal on its own: an
# expired pending marker still falls through toward a full lock, with the
# upgrade opportunity surfaced separately (see module docstring).
_CHAIN: tuple[_Step, ...] = (
_Step(
"scheduled_skip",
"Today is in scheduled_skips.json (unconditional manual skip).",
"Today is not a manually scheduled skip day.",
"Manually scheduled skip day — no lock, no workout required.",
),
_Step(
"early_bird_pending_expired",
"Logged in during the early-bird window and it has since closed "
"with no workout confirmed yet.",
"No expired early-bird pending marker.",
),
_Step(
"early_bird_window_open",
"Logged in during the early-bird window earlier today; still "
"waiting to see a workout before the window closes.",
"Not pending inside an open early-bird window.",
"Early-bird window still open — lock skipped while waiting for a workout.",
),
_Step(
"sick_day",
"Today is a sick day in the shared sickdays pool.",
"Today is not a sick day.",
"Sick day today — lock skipped.",
),
_Step(
"already_logged",
"A validly-signed workout is already logged for today.",
"No workout logged for today yet.",
"Workout already logged today — lock skipped.",
note_when_false=True,
),
_Step(
"wake_alarm_skip",
"The morning session earned a workout skip for now.",
"No morning-session skip in force.",
"Morning session earned a skip — lock skipped.",
),
_Step(
"test_hold_active",
"A declared test hold is active.",
"No test hold active.",
"Test hold active — no workout window until it ends.",
),
_Step(
"relaxed_day_already_skipped",
"Relaxed day already dismissed via Skip — No Penalty today.",
"No same-day relaxed-day dismissal on record.",
"Relaxed day already skipped today — lock skipped, prompt not shown again.",
),
_Step(
"relaxed_day",
"Today is a relaxed day (Tue/Wed/Thu).",
"Today is not a relaxed day.",
"Relaxed day (Tue/Wed/Thu) — workout optional, lock skipped.",
),
_Step(
"weekly_minimum_met",
"Weekly workout minimum already reached.",
"Weekly workout minimum not yet reached.",
"Weekly workout minimum already met — lock skipped.",
),
_Step(
"evening_lockdown_today",
"Tonight's night lockdown already fired (>= 12:00 today).",
"No evening night lockdown yet today.",
"Night lockdown already happened tonight — the workout lock does not re-arm.",
),
)
def _chain_flags(
*,
log_file: Path,
weekly_minimum_met: bool,
relaxed_day: bool,
wake_skip: bool,
evening_lockdown: bool,
local_dt: datetime,
pending: bool,
) -> dict[str, bool]:
"""Evaluate every predicate once; the keys are the ``_CHAIN`` step names."""
today_str = local_day(local_dt)
# The early-bird window IS the wake-alarm carrot (EarlyBirdMixin).
return {
"scheduled_skip": is_scheduled_skip_today(local_dt.date()),
"early_bird_pending_expired": pending and not wake_skip,
"early_bird_window_open": pending and wake_skip,
"sick_day": is_sick_day_today(local_dt.date()),
"already_logged": has_logged_today(log_file, today=today_str),
"wake_alarm_skip": wake_skip,
"test_hold_active": workout_hold_active(local_dt),
"relaxed_day_already_skipped": relaxed_day
and is_relaxed_day_skipped_today(log_file, today=today_str),
"relaxed_day": relaxed_day,
"weekly_minimum_met": weekly_minimum_met,
"evening_lockdown_today": evening_lockdown,
}
def _walk_chain(
fired: dict[str, bool], context: StageContext, degraded_note: str
) -> LockExplanation | None:
"""Record each step in ``context.trace``; return the first terminal one."""
for step in _CHAIN:
hit = fired[step.name]
reason = step.reason_true if hit else step.reason_false
if step.note_when_false and not hit:
reason += degraded_note
context.trace.append(PredicateResult(step.name, hit, reason))
if hit and step.terminal_reason is not None:
return _stage(
fired=False,
stage=step.name,
reason=step.terminal_reason,
context=context,
)
return None
def explain_lock_decision(
*,
log_file: Path,
early_bird_pending_file: Path,
sick_history: SickHistory,
extended_early_bird: bool,
weekly_minimum_met: bool,
relaxed_day: bool,
wake_skip: bool = False,
evening_lockdown: bool = False,
now: datetime | None = None,
degraded_sources: Sequence[DegradedSource] = (),
) -> LockExplanation:
@ -79,161 +214,25 @@ def explain_lock_decision(
reported via ``heat_skip_evaluated=False``, not guessed.
"""
instant = now if now is not None else datetime.now(tz=UTC)
today_str = instant.astimezone(UTC).strftime("%Y-%m-%d")
local_dt = instant.astimezone()
local_minutes = local_dt.hour * 60 + local_dt.minute
scheduled = is_scheduled_skip_today(local_dt.date())
pending = is_early_bird_pending(early_bird_pending_file, today=today_str)
window_open = _early_bird_window_open(
extended=extended_early_bird, local_minutes=local_minutes
pending = is_early_bird_pending(early_bird_pending_file, today=local_day(instant))
fired = _chain_flags(
log_file=log_file,
weekly_minimum_met=weekly_minimum_met,
relaxed_day=relaxed_day,
wake_skip=wake_skip,
evening_lockdown=evening_lockdown,
local_dt=instant.astimezone(),
pending=pending,
)
expired = pending and not window_open
early_bird_open_and_pending = pending and window_open
window_end_label = "09:00" if extended_early_bird else "08:30"
sick_today = is_sick_day_today(sick_history, today=today_str)
logged = has_logged_today(log_file, today=today_str)
relaxed_day_already_skipped = relaxed_day and is_relaxed_day_skipped_today(
log_file, today=today_str
)
auto_upgrade = describe_auto_upgrade_opportunity(
early_bird_pending=pending,
early_bird_window_open=window_open,
is_sick_day=sick_today,
early_bird_window_open=wake_skip,
)
trace: list[PredicateResult] = []
stage_context = StageContext(trace, auto_upgrade, bool(degraded_sources))
def _check(
name: str,
*,
fired: bool,
reason_true: str,
reason_false: str,
terminal_reason: str | None = None,
) -> LockExplanation | None:
"""Record one trace step; return an explanation if it terminates the chain."""
reason = reason_true if fired else reason_false
trace.append(PredicateResult(name, fired, reason))
if fired and terminal_reason is not None:
return _stage(
fired=False,
stage=name,
reason=terminal_reason,
context=stage_context,
)
return None
result = _check(
"scheduled_skip",
fired=scheduled,
reason_true="Today is in scheduled_skips.json (unconditional manual skip).",
reason_false="Today is not a manually scheduled skip day.",
terminal_reason="Manually scheduled skip day — no lock, no workout required.",
)
if result is not None:
return result
# Recorded but never terminal on its own: an expired pending marker still
# falls through toward a full lock, with the upgrade opportunity surfaced
# separately via `auto_upgrade` (see module docstring).
_check(
"early_bird_pending_expired",
fired=expired,
reason_true="Logged in during the early-bird window and it has since closed "
"with no workout confirmed yet.",
reason_false="No expired early-bird pending marker.",
)
result = _check(
"early_bird_window_open",
fired=early_bird_open_and_pending,
reason_true="Logged in during the early-bird window earlier today; still "
"waiting to see a workout before the window closes.",
reason_false="Not pending inside an open early-bird window.",
terminal_reason=(
"Early-bird window still open — lock skipped while waiting for a workout."
),
)
if result is not None:
return result
result = _check(
"sick_day",
fired=sick_today,
reason_true="Today is marked as a sick day in sick_history.json.",
reason_false="Today is not marked as a sick day.",
terminal_reason="Sick day logged today — lock skipped.",
)
if result is not None:
return result
context = StageContext([], auto_upgrade, bool(degraded_sources))
degraded_note = describe_degraded_sources(degraded_sources)
result = _check(
"already_logged",
fired=logged,
reason_true="A validly-signed workout is already logged for today.",
reason_false="No workout logged for today yet." + degraded_note,
terminal_reason="Workout already logged today — lock skipped.",
)
if result is not None:
return result
result = _check(
"wake_alarm_skip",
fired=wake_skip,
reason_true="Wake-alarm earned a workout skip for today.",
reason_false="No wake-alarm workout skip earned today.",
terminal_reason="Wake-alarm earned a workout skip — lock skipped.",
)
if result is not None:
return result
result = _check(
"early_bird_time_fresh",
fired=window_open,
reason_true=(
f"Currently inside the early-bird window (05:00-{window_end_label})."
),
reason_false="Not currently inside the early-bird window.",
terminal_reason="Inside the early-bird window — lock skipped, pending marker "
f"would be saved for the {window_end_label} re-check.",
)
if result is not None:
return result
result = _check(
"relaxed_day_already_skipped",
fired=relaxed_day_already_skipped,
reason_true="Relaxed day already dismissed via Skip — No Penalty today.",
reason_false="No same-day relaxed-day dismissal on record.",
terminal_reason="Relaxed day already skipped today — lock skipped, "
"prompt not shown again.",
)
if result is not None:
return result
result = _check(
"relaxed_day",
fired=relaxed_day,
reason_true="Today is a relaxed day (Tue/Wed/Thu).",
reason_false="Today is not a relaxed day.",
terminal_reason="Relaxed day (Tue/Wed/Thu) — workout optional, lock skipped.",
)
if result is not None:
return result
result = _check(
"weekly_minimum_met",
fired=weekly_minimum_met,
reason_true="Weekly workout minimum already reached.",
reason_false="Weekly workout minimum not yet reached.",
terminal_reason="Weekly workout minimum already met — lock skipped.",
)
if result is not None:
return result
terminal = _walk_chain(fired, context, degraded_note)
if terminal is not None:
return terminal
# Named for the conclusion; "..._pending_heat_check" read as "stuck".
return _stage(
@ -245,5 +244,5 @@ def explain_lock_decision(
"temperature is checked, and a run may or may not be happening "
"right now (see Health)." + degraded_note
),
context=stage_context,
context=context,
)

View File

@ -32,7 +32,7 @@ class AutoUpgradeOpportunity:
"""Describes whether the real locker would attempt a live auto-upgrade."""
would_attempt: bool
via: str # "early_bird_expired" | "sick_day" | "none"
via: str # "early_bird_expired" | "none"
reason: str
@ -63,7 +63,6 @@ def describe_auto_upgrade_opportunity(
*,
early_bird_pending: bool,
early_bird_window_open: bool,
is_sick_day: bool,
) -> AutoUpgradeOpportunity:
"""Describe what the real ``_try_auto_upgrade_*`` methods would attempt.
@ -81,15 +80,6 @@ def describe_auto_upgrade_opportunity(
"falling back to a full lock."
),
)
if is_sick_day:
return AutoUpgradeOpportunity(
would_attempt=True,
via="sick_day",
reason=(
"Sick day logged — the next lock run will try phone/RunnerUp "
"verification to upgrade it to a real workout."
),
)
return _NO_UPGRADE

View File

@ -9,39 +9,31 @@ from gatelock.log_integrity import DEFAULT_HMAC_KEY_FILE
# Ephemeral runtime state (self-clears on reboot, same directory gatelock's
# own Arbiter uses) -- not repo-tracked data, so it does not belong next to
# log.json/sick_history.json below.
# log.json below.
INSTANCE_LOCK_FILE = default_runtime_dir() / "screen_locker-instance.lock"
# Single-sourced from gatelock so the literal key path can't drift.
# Single-sourced from gatelock so the literal key path can't drift. The key is
# root:root 0644 ON PURPOSE, never 0600: leetcode-guard, book-guard and
# anki-guard run as user timers and sign their ledgers with it, and this
# locker signs log.json, rest_days.json and the workout ledger as the user.
# Nothing in this repo may chmod it; root ownership keeps it from being
# replaced, which is what the signatures rely on.
HMAC_KEY_FILE = DEFAULT_HMAC_KEY_FILE
SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage)
# The workout ledger (earned_time.WORKOUT.ledger): one signed credit row per
# verified RunnerUp TCX credit and per rest day, resolved at call time.
WORKOUT_LEDGER_RELATIVE = Path(".local/share/workout_locker/ledger.json")
PHONE_PENALTY_DELAY_DEMO = 10
PHONE_PENALTY_DELAY_PRODUCTION = 100
# Penalty added to phone-penalty timer when ADB / phone unavailable
# (so unplugging phone does not become an easy escape into sick mode).
# (so unplugging the phone does not become an easy path to the manual log).
NO_PHONE_EXTRA_LOCKOUT_SECONDS = 480 # extra 8 minutes on top of base
# Sick day rate-limiting (rolling windows). Once any window is exhausted
# the "I'm sick" button disappears entirely.
SICK_BUDGET_PER_7_DAYS = 1
SICK_BUDGET_PER_30_DAYS = 3
SICK_BUDGET_PER_90_DAYS = 10
# Each sick day in the trailing 30 days doubles the wait countdown.
SICK_LOCKOUT_MULTIPLIER_PER_RECENT = 2
# Minimum chars in the freeform sick justification.
SICK_JUSTIFICATION_MIN_CHARS = 120
# How many past sick justifications to show on the dialog (read-only).
SICK_HISTORY_REVIEW_COUNT = 10
# Forced read-only delay before SUBMIT enables when a commitment was made.
SICK_COMMITMENT_FORCED_READ_SECONDS = 5
# Breaking a commitment counts as this many sick budget days.
SICK_COMMITMENT_PENALTY_DAYS = 2
# How long the commitment prompt stays visible after a workout unlock.
COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15
# Manual (unverified) workout rate-limiting — looser than sick days since
# activities like table tennis recur more often than illness. Once either
# Manual (unverified) workout rate-limiting. Once either
# window is exhausted the "Log Manual Workout" option disappears entirely.
MANUAL_WORKOUT_BUDGET_PER_7_DAYS = 2
MANUAL_WORKOUT_BUDGET_PER_30_DAYS = 10
# A manual workout may be logged AHEAD for a date up to this many days from
# today (never a past date); it earns its credit on that date, not before.
MANUAL_WORKOUT_MAX_DAYS_AHEAD = 7
# Manual entries use the shared MIN_WORKOUT_DURATION_MINUTES bar below —
# every workout type answers to the same minimum, whatever the sport.
# Minimum chars for the "what was done" activity-details field.
@ -61,13 +53,13 @@ WORKOUT_APP_JSON_REMOTES = (
)
# Port the workout app's HTTP server listens on (no ADB/developer-options needed).
WORKOUT_HTTP_PORT = 8765
# The ONE duration bar every workout type answers to — weightlifting, manual
# entries and runs alike. This is the ADVERTISED number: it is the only one
# that may ever appear in a message the user reads.
MIN_WORKOUT_DURATION_MINUTES = 40
# The ONE duration bar every activity answers to — weightlifting, manual
# entries, runs and walks (summed moving time) alike. This is the ADVERTISED
# number: it is the only one that may ever appear in a message the user reads.
MIN_WORKOUT_DURATION_MINUTES = 30
# ...and this is the leeway that is deliberately INVISIBLE. A session is
# actually accepted at MIN_WORKOUT_DURATION_MINUTES - this, so 35 minutes
# passes while every string still says 40. That gap is intentional (the user
# actually accepted at MIN_WORKOUT_DURATION_MINUTES - this, so 25 minutes
# passes while every string still says 30. That gap is intentional (the user
# asked for it explicitly): do NOT "fix" the inconsistency by advertising the
# real cutoff, and never interpolate WORKOUT_DURATION_ACCEPT_MINUTES into an
# f-string.
@ -75,17 +67,17 @@ WORKOUT_DURATION_LEEWAY_MINUTES = 5
# Derived so the advertised bar and the accept bar cannot drift apart.
# Comparisons use this; messages use MIN_WORKOUT_DURATION_MINUTES. Every
# caller compares with `>=` — one operator across all workout types, so a
# 35.0-minute session cannot pass one gate and fail another.
# 25.0-minute session cannot pass one gate and fail another.
WORKOUT_DURATION_ACCEPT_MINUTES = (
MIN_WORKOUT_DURATION_MINUTES - WORKOUT_DURATION_LEEWAY_MINUTES
)
RUNNERUP_PACKAGES = ("org.runnerup", "org.runnerup.free")
RUNNERUP_DB_SDCARD_TMP = "/sdcard/.runnerup_tmp_verification.db"
# A run qualifies on EITHER criterion — they are an OR, not an AND. A short
# but long-lasting run counts, and so does a fast 5 km that takes under the
# duration bar. Do not reinstate an AND here: it would reject exactly the
# slow-run case this pair exists to allow. The duration arm is the shared
# MIN_WORKOUT_DURATION_MINUTES above; only the distance arm is run-specific.
# A day's activity (run or walk alike) qualifies on EITHER criterion — they are
# an OR, not an AND. A short but long-lasting session counts, and so does a
# fast 5 km that takes under the duration bar. Do not reinstate an AND here:
# it would reject exactly the slow case this pair exists to allow. The
# duration arm is the shared MIN_WORKOUT_DURATION_MINUTES above (moving time).
MIN_RUN_DISTANCE_KM = 3.5 # inclusive: "as low as 3.5 km"
# GPS distance tracking undershoots on tight turns/tree cover/urban canyons;
# a run within 5% of the minimum is accepted rather than rejected on
@ -102,35 +94,62 @@ HEAT_SKIP_CITY: str = "Warsaw"
SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf")
# Helper script path (relative to this file)
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
# State file to track sick day usage and original config values
SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json"
# Persistent sick-day history (rate-limit, debt, commitments, justifications).
# Distinct from SICK_DAY_STATE_FILE which is a one-day shutdown-config snapshot.
SICK_HISTORY_FILE = Path(__file__).resolve().parent / "sick_history.json"
# JSON list of ISO date strings ("YYYY-MM-DD") for which the screen lock is skipped.
SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json"
# State file tracking streak, skip credits, and early-bird extension weeks.
EXTRA_BENEFITS_FILE = Path(__file__).resolve().parent / "extra_benefits_state.json"
# State file storing the base (pre-bonus) shutdown hours and last reset date.
SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json"
# HMAC-signed rest days, declared ahead (see _rest_day), and how far the grace
# floor has lifted today's shutdown above the earned time (see _grace_floor).
REST_DAY_FILE = Path(__file__).resolve().parent / "rest_days.json"
REST_DAYS_PER_ISO_WEEK = 2
GRACE_STATE_FILE = Path(__file__).resolve().parent / "grace_floor.json"
# Every GPS activity (run or walk, whatever its TCX sport) is judged the same
# way: a day's activities count as the workout once their summed distance
# reaches MIN_RUN_DISTANCE_KM OR their summed MOVING time reaches
# WORKOUT_DURATION_ACCEPT_MINUTES. Moving = trackpoint pairs seen within
# WALK_MAX_GAP_SECONDS and travelled at least this fast (slower is standing
# still). A pair faster than MAX_MOVING_SPEED_MPS (25 km/h, the app's running
# jump filter) is a vehicle or a GPS jump and counts neither moving time NOR
# distance, for every activity. The Dart app mirrors the bounds.
MIN_MOVING_SPEED_MPS = 0.5
MAX_MOVING_SPEED_MPS = 25 / 3.6
WALK_MAX_GAP_SECONDS = 30
# Speed is judged over windows of at least this long (1 s fixes jitter), and a
# window longer than WALK_MAX_GAP_SECONDS (a GPS dropout) only counts as
# moving time at this speed or faster, so standing through a gap earns nothing.
TRACK_WINDOW_SECONDS = 10
MIN_DROPOUT_SPEED_MPS = 1.0
# Workout records this PC must keep deleted in the sync store, pushed as
# tombstones on every tick (see _sync_tombstones for why once is not enough).
SYNC_TOMBSTONES_FILE = Path(__file__).resolve().parent / "sync_tombstones.json"
# Future-dated manual workouts logged on THIS PC, re-published on every push
# until their date (see _manual_pending for why a one-shot publish is erased).
MANUAL_PENDING_FILE = Path(__file__).resolve().parent / "manual_pending.json"
# Self-expiring marker: "logged in during today's early-bird window, still
# waiting to see if a real workout shows up." Not a log.json entry —
# it's a same-day pending flag, checked against its own "date" field.
EARLY_BIRD_PENDING_FILE = Path(__file__).resolve().parent / "early_bird_pending.json"
# ---------------------------------------------------------------------------
# Wake-alarm integration (originally from wake_alarm._constants / _state).
# These must match the values used by the companion wake_alarm service.
# ---------------------------------------------------------------------------
# Days on which the wake alarm fires (0=Mon … 6=Sun).
ALARM_DAYS: frozenset[int] = frozenset({0, 4, 5, 6})
# How many hours after midnight the alarm triggers (configurable in wake_alarm).
WAKE_AFTER_HOURS: int = 8
# Path to the rtcwake binary.
RTCWAKE_BIN: str = "/usr/sbin/rtcwake"
# State file written by wake_alarm; read here to check for workout skip.
WAKE_STATE_FILE = (
Path(__file__).resolve().parent.parent / "wake_alarm" / "wake_state.json"
# The PC's copy of the phone's morning session, written HMAC-signed by
# wake-alarm (`python -m wake_alarm._session --refresh`, every 2 min through
# the morning). Read here, never fetched: wake-alarm is the only Firebase
# reader. XDG state rather than a sibling checkout, because the previous
# reader pointed at "../wake_alarm/wake_state.json" -- a path that stopped
# existing when the repos split -- and returned "no skip" for a year without
# a single error. Keep the path in step with wake_alarm/_constants.py.
MORNING_SESSION_FILE = (
Path.home() / ".local" / "state" / "wake_alarm" / "morning_session.json"
)
# A separate file from MORNING_SESSION_FILE: that one always describes
# *today* and is overwritten at the date rollover, so it cannot carry a
# marker forward to tomorrow. wake-alarm writes this one when a Tue/Wed/Thu
# ring goes unanswered; today == penalty_date means today's relaxed-day
# leniency is off. Keep the path in step with wake_alarm/_constants.py.
WORKDAY_PENALTY_FILE = (
Path.home() / ".local" / "state" / "wake_alarm" / "workday_penalty.json"
)
# Directories where RunnerUp writes per-activity TCX exports (File Synchronizer).
@ -140,16 +159,24 @@ RUNNERUP_EXPORT_DIRS: tuple[str, ...] = (
"/storage/emulated/0/Documents/RunnerUp",
)
# Where RunnerUp's WebDAV synchroniser drops the same exports on this machine
# (dufs, see ~/src/dufs-cloud), and where endurain-import moves them once
# Endurain has them. Read straight off the disk, so a run is credited without
# the phone being adb-reachable -- on 2026-09-16 the TCX landed here at 20:08
# and the phone was only plugged in at 20:25. Same filenames as the phone
# export, optionally prefixed with the device name (RunnerUp_Pixel_6a_...).
RUNNERUP_WEBDAV_DIRS: tuple[Path, ...] = (
Path.home() / "data" / "cloud" / "RunnerUp",
Path.home() / "data" / "cloud" / "RunnerUp" / "processed",
)
# ---------------------------------------------------------------------------
# Sync (phone workout data) — Workstream C, built on the shared crdt-sync
# library. GitHub is used purely as dumb file storage via the REST Contents
# API (not a git clone), same pattern as diet_guard's cross-device sync. The
# phone app pushes to devices/<SYNC_PHONE_DEVICE_ID>/log.json; the PC reads
# every device's log and pushes its OWN manual workouts to devices/pc/log.json
# (see _manual_push) — which needs a token with contents:write, not read-only.
# Sync (phone workout data) — built on the shared crdt-sync library, Firebase
# RTDB only since 2026-10-09 (the shared ~/.config/crdt-sync/ credential). The
# old GitHub repo kuhyx/syncs/screen-locker-sync is a frozen archive: nothing
# reads or writes it. Every device pushes to devices/<device id>/log.json; the
# PC reads every device's log and pushes its OWN workouts (see _manual_push).
# ---------------------------------------------------------------------------
SYNC_REPO_OWNER: str = "kuhyx"
SYNC_REPO_NAME: str = "syncs"
# The phone's pre-uuid device id. Still read (the phone may not have migrated
# yet), but no longer the only path the reader knows about -- it discovers
# every device directory.
@ -164,12 +191,6 @@ SYNC_LEGACY_DEVICE_ID: str | None = "pc"
SYNC_DEVICE_ID_FILE: Path = (
Path.home() / ".local" / "share" / "screen_locker" / ".device_id"
)
# A fine-grained GitHub PAT, scoped to just SYNC_REPO_NAME's contents. The
# user creates this once via github.com and saves it here, mode 600. Never
# committed — this path is outside the repo entirely. Unlike diet_guard,
# this file being absent is a normal, expected state (sync is optional here).
SYNC_TOKEN_FILE: Path = Path.home() / ".config" / "screen_locker" / "sync_token"
# Revision cache for cross-device sync. Without it every push re-uploads and
# every pull re-downloads regardless of change, which is the traffic the
# Firebase free tier's monthly budget depends on not happening.

View File

@ -0,0 +1,125 @@
"""Desktop notification the moment a workout earns its credit.
The reward is the point: a workout should be *felt* as paid the second the PC
credits it, not discovered at the next look at the status window. Every path
that credits a workout ends in
:meth:`~screen_locker._workout_credit.WorkoutCreditMixin._apply_credit_for_written_entry`,
which calls :func:`notify_workout_credit` once per newly earned credit slot.
The gaming figure is ``earned_time.resolve`` over screen-locker's own answers
(:func:`screen_locker._shutdown_target.gather` -- the same sum the daily reset
writes), so no budget math lives here. steam-backlog-enforcer resolves the
same registry with its own transports and holds a day's high-water mark, so
its figure can sit above this one, never below on a normal day.
A notification is a courtesy, never part of crediting: any failure (no DBus,
no notify-send, a hang) is logged at warning and swallowed.
"""
from __future__ import annotations
from datetime import date
import logging
import shutil
import subprocess
from typing import TYPE_CHECKING, Final
from screen_locker._day import today_str
from screen_locker._earned import hhmm, span
from screen_locker._grace_floor import tonight_minutes
from screen_locker._shutdown_target import gather
if TYPE_CHECKING:
from pathlib import Path
_logger: Final = logging.getLogger(__name__)
TITLE: Final = "Workout credited \N{FLEXED BICEPS}"
_NOTIFY_TIMEOUT_SECONDS: Final = 5
_MINUTES_PER_HOUR: Final = 60.0
def gaming_minutes_today(log_file: Path | None) -> int | None:
"""Today's gaming budget per the earned_time registry, or ``None``.
``None`` (logged) when the inputs cannot be read; the notification then
shows the shutdown time alone.
"""
try:
target = gather(date.fromisoformat(today_str()), log_file)
except (OSError, ValueError, KeyError) as exc:
_logger.warning(
"Could not compute today's gaming budget for the credit "
"notification (%s) — showing the shutdown time only",
exc,
)
return None
return int(target.resolution.gaming_minutes)
def credit_body(
before: tuple[int, int, int] | None,
after: tuple[int, int, int] | None,
gaming_minutes: int | None,
) -> str:
"""``Shutdown 23:00 (+2h) · gaming 7.0h`` -- whichever parts are known.
``before``/``after`` are the shutdown config around the credit; the delta
is tonight's actual move, so a capped or rest-day credit shows ``+0h``.
"""
parts: list[str] = []
if after is None:
_logger.warning(
"Shutdown config unreadable after a workout credit — the "
"notification shows no shutdown time"
)
else:
day = date.fromisoformat(today_str())
tonight = tonight_minutes(after, day)
text = f"Shutdown {hhmm(tonight)}"
if before is not None:
moved = max(0, tonight - tonight_minutes(before, day))
text += f" (+{span(moved)})"
parts.append(text)
if gaming_minutes is not None:
parts.append(f"gaming {gaming_minutes / _MINUTES_PER_HOUR:.1f}h")
return " \N{MIDDLE DOT} ".join(parts) or "Credit applied (details unavailable)"
def send_notification(title: str, body: str) -> bool:
"""Show a desktop notification via ``notify-send``; False (logged) on failure."""
binary = shutil.which("notify-send")
if binary is None:
_logger.warning("notify-send not found — no desktop notification: %s", body)
return False
try:
result = subprocess.run(
[binary, "--app-name=screen-locker", title, body],
capture_output=True,
text=True,
timeout=_NOTIFY_TIMEOUT_SECONDS,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
_logger.warning("Desktop notification failed (%s): %s", exc, body)
return False
if result.returncode:
_logger.warning(
"notify-send exited %d (%s) — no desktop notification: %s",
result.returncode,
result.stderr.strip() or "no stderr; is a DBus session reachable?",
body,
)
return False
_logger.info("Desktop notification: %s — %s", title, body)
return True
def notify_workout_credit(
before: tuple[int, int, int] | None,
after: tuple[int, int, int] | None,
log_file: Path | None,
) -> bool:
"""Notify a newly earned workout credit. Never raises."""
body = credit_body(before, after, gaming_minutes_today(log_file))
return send_notification(TITLE, body)

View File

@ -0,0 +1,86 @@
"""Which credit a newly written workout earns, decided by slot like the reset.
The live credit path used to ask "was any counted workout already logged
today?" -- so the two StrongLifts ingestion paths of ONE session
(``pc_workout_verified`` from the session sync, ``phone_verified`` from the
early-bird auto-upgrade) earned the first-unit +2h and then a further-unit +1h
(2026-10-09; also 07-17 and 07-27 in ``duplicate_credits.json``). The daily
reset counts the same day with :func:`~screen_locker._weekly_check.count_day_credits`,
which collapses them onto one slot. Deciding the live credit with that same
counter makes the two agree by construction.
"""
from __future__ import annotations
from contextlib import ExitStack, contextmanager
import enum
import logging
from typing import TYPE_CHECKING, Any, Final
from screen_locker._bonus_lock import bonus_lock
from screen_locker._weekly_check import count_day_credits
if TYPE_CHECKING:
from collections.abc import Iterator, Mapping
from pathlib import Path
_logger: Final = logging.getLogger(__name__)
# count_day_credits only uses the date to build slot keys, and both counts
# below are taken over the same day, so any fixed label is equivalent.
_SAME_DAY: Final = "day"
class CreditSlot(enum.Enum):
"""What the newest entry of a day earns."""
NONE = "earns no credit (not a counted workout type)"
FIRST = "first credit slot of the day"
FURTHER = "a new, distinct credit slot after the first"
OCCUPIED = "a slot an earlier entry of the day already credited"
def credit_slot(
prior_entries: list[dict[str, Any]], workout_data: Mapping[str, Any]
) -> CreditSlot:
"""Classify ``workout_data`` against the day's entries written before it.
Args:
prior_entries: The day's log entries before the new one, as returned
by the write chokepoint (snapshotted under the log lock).
workout_data: The new entry's ``workout_data``.
"""
new_entry = {"workout_data": dict(workout_data)}
day = [e for e in prior_entries if isinstance(e, dict)]
before = count_day_credits(_SAME_DAY, day)
with_new = count_day_credits(_SAME_DAY, [*day, new_entry])
if with_new == before:
# No new slot: either it earns nothing at all, or its slot is taken.
return CreditSlot.OCCUPIED if _counts_alone(new_entry) else CreditSlot.NONE
return CreditSlot.FURTHER if before else CreditSlot.FIRST
def _counts_alone(entry: Mapping[str, Any]) -> bool:
"""Whether ``entry`` would earn a credit on an otherwise empty day."""
return count_day_credits(_SAME_DAY, [entry]) == 1
@contextmanager
def locked_shutdown_state(state_file: Path) -> Iterator[None]:
"""Hold :func:`bonus_lock` on ``state_file``; on failure, warn and go on.
The reset and the flat-bonus pass hold the same lock, so a credit can no
longer lose its minutes to their read-add-write. Never nest inside them:
a second flock on a new fd in the same process deadlocks.
"""
with ExitStack() as stack:
try:
stack.enter_context(bonus_lock(state_file))
except OSError as exc:
_logger.warning(
"Could not lock %s (%s) — applying the workout credit without "
"it; a reset running at the same moment could overwrite it",
state_file,
exc,
)
yield

32
screen_locker/_day.py Normal file
View File

@ -0,0 +1,32 @@
"""The one definition of "what day is it" for every day-keyed file.
``log.json``, ``early_bird_pending.json`` and ``wake_state.json`` are all
keyed by ``YYYY-MM-DD``.
Until 2026-09-13 each writer and reader derived that key on its own, and
most of them used ``datetime.now(tz=UTC)`` while the lock window, the phone
app, RunnerUp verification and the status window used LOCAL time. In CEST
that is a two-hour window after local midnight in which a workout logged
"today" was filed under yesterday — the lock check then looked for today,
found nothing, and locked the PC for a workout that was on disk.
The day is the **local calendar day**: it is what the user sees on the
clock when they log, what the lock screen names when it says "no workout
for today", and what the phone already writes. Every module goes through
:func:`today_str` so the two can never disagree again; tests freeze the
clock by patching ``screen_locker._day.datetime``.
"""
from __future__ import annotations
from datetime import UTC, datetime
def today_str(now: datetime | None = None) -> str:
"""Return the local calendar day of *now* (default: this instant) as ``YYYY-MM-DD``.
*now* may be in any timezone; it is converted to the machine's local zone
first, which is the only conversion that turns ``22:02Z`` on the 12th
into the 13th the user actually logged on.
"""
instant = now if now is not None else datetime.now(tz=UTC)
return instant.astimezone().strftime("%Y-%m-%d")

View File

@ -4,57 +4,53 @@ The early-bird "still waiting to see if a real workout shows up" flag is a
same-day pending marker, not a workout — it is intentionally kept out of
log.json (which is reserved for real outcomes) and instead lives in
its own self-expiring, HMAC-signed state file, mirroring the pattern used by
``_wake_state.py`` for the companion wake_alarm service.
``wake_alarm._state`` in the companion wake-alarm service. It is written when
the wake-alarm carrot defers the lock, so that when the carrot ends the next
run tries a phone/RunnerUp workout before locking.
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
import logging
from gatelock.log_integrity import compute_entry_hmac
from screen_locker._compliance_state import is_early_bird_pending
from screen_locker._constants import (
EARLY_BIRD_END_HOUR,
EARLY_BIRD_END_MINUTE,
EARLY_BIRD_PENDING_FILE,
EARLY_BIRD_START_HOUR,
EXTRA_BENEFITS_FILE,
)
from screen_locker._extra_benefits import has_extended_early_bird
from screen_locker._constants import EARLY_BIRD_PENDING_FILE
from screen_locker._day import today_str
from screen_locker._morning_session import MorningSkip, morning_skip_today
_logger = logging.getLogger(__name__)
def _today_str() -> str:
"""Return today's date as YYYY-MM-DD in UTC."""
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
"""Return today's LOCAL date as YYYY-MM-DD (see ``screen_locker._day``)."""
return today_str()
class EarlyBirdMixin:
"""Mixin providing early-bird time window checks and pending-state helpers."""
"""Mixin providing the early-bird window check and pending-state helpers.
def _get_local_time_minutes(self) -> int:
"""Return current local time as minutes from midnight."""
now = datetime.now(tz=UTC).astimezone()
return now.hour * 60 + now.minute
Since 2026-09-20 the window is not a wall clock but the wake-alarm
morning-session carrot: open exactly while wake-alarm's signed
``morning_session.json`` says the morning is being earned (before the
alarm, during the session, and until 11:00 once it completed in time).
The old 05:00-08:30 clock locked someone who got up at 07:00 -- the
opposite of the goal -- and it was a second mechanism next to the one
that actually knows whether the user is up.
"""
_morning_skip: MorningSkip | None = None
def _is_early_bird_time(self) -> bool:
"""Return True if current local time is in the early bird window.
"""Whether the wake-alarm carrot is in force right now.
Normally the window closes at 08:30. When the current ISO week has an
extended early-bird reward (earned by 5+ workouts the prior week) the
window extends to 09:00.
Waits (bounded) for a freshly booted PC's refresher; the verdict is
kept on the instance so the skip detail can quote its ``exempt_until``.
"""
minutes = self._get_local_time_minutes()
start = EARLY_BIRD_START_HOUR * 60
if has_extended_early_bird(EXTRA_BENEFITS_FILE):
end = 9 * 60 # 09:00
else:
end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
return start <= minutes < end
self._morning_skip = morning_skip_today(wait=True)
return self._morning_skip is not None
def _is_early_bird_pending(self) -> bool:
"""Check if today has an unresolved early-bird pending marker."""

241
screen_locker/_earned.py Normal file
View File

@ -0,0 +1,241 @@
"""Shutdown hours from the shared earner registry (``earned_time``, kuhyx/utils).
Every gate's reward -- the workout, the LeetCode hour, the reading hour and
whatever is registered next -- is defined once in ``earned_time``, which
steam-backlog-enforcer reads for gaming time too. This module is the
screen-locker side of it: where the ledgers live, and how minutes are shown.
The gates publish facts only; none writes the shutdown config. Their ledgers
are HMAC-verified against :data:`HMAC_KEY_FILE` and *fail closed* -- an
unreadable ledger or key earns nothing, and says so.
Minutes end to end: the registry, ``/etc/shutdown-schedule.conf`` (its
``*_MINUTES`` keys) and every shutdown time in this package are minutes after
midnight, so a 30-minute earner is applied exactly.
"""
from __future__ import annotations
from datetime import UTC, date, datetime
import functools
import logging
from pathlib import Path
from typing import Final
import earned_time
from screen_locker._constants import HMAC_KEY_FILE
from screen_locker._day import today_str
from screen_locker._gate_off import gate_off
_logger: Final = logging.getLogger(__name__)
# Every earner's ``ledger`` is relative to this. Read-only from here; resolved
# at call time so the test suite's redirect (tests/_isolated_state.py) applies.
LEDGER_HOME: Final = Path.home()
_MINUTES_PER_HOUR: Final = 60
def hhmm(minutes: int) -> str:
"""A time of day, given in minutes after midnight, as ``HH:MM``."""
hours, rest = divmod(minutes, _MINUTES_PER_HOUR)
return f"{hours:02d}:{rest:02d}"
def span(minutes: int) -> str:
"""A duration in minutes as ``2h``, ``30m`` or ``1h30m``."""
hours, rest = divmod(minutes, _MINUTES_PER_HOUR)
if not rest:
return f"{hours}h"
if not hours:
return f"{rest}m"
return f"{hours}h{rest:02d}m"
def ledger_file(earner: earned_time.Earner) -> Path:
"""Where ``earner``'s gate keeps its ledger."""
if earner.ledger is None:
msg = f"earner {earner.name!r} has no ledger"
raise ValueError(msg)
return LEDGER_HOME / earner.ledger
def registry(day: date | None = None) -> tuple[earned_time.Earner, ...]:
"""The earners in force on ``day`` (default today).
``earners_for`` (earned_time >= 0.5) switches the registry on
``TUTOR_FROM``; an older earned_time has one static registry.
"""
fn = getattr(earned_time, "earners_for", None)
return tuple(earned_time.EARNERS if fn is None else fn(day))
def is_gate(earner: earned_time.Earner) -> bool:
"""A ledger-backed earner whose gate publishes credits (not the workout).
The workout has a ledger too, but screen-locker writes it and
``WorkoutCreditMixin`` applies it.
"""
return earner.ledger is not None and earner.name != earned_time.WORKOUT.name
def flat_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
"""The once-per-day, ledger-backed earners in force on ``day``."""
return tuple(e for e in registry(day) if e.kind == "flat" and is_gate(e))
def gate_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
"""Every gate earner in force on ``day``, flat and counted, in registry order."""
return tuple(e for e in registry(day) if is_gate(e))
def counted_gate_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
"""Gate earners paid per unit on ``day`` (the Automation tutor's blocks)."""
return tuple(e for e in registry(day) if e.kind == "counted" and is_gate(e))
def watched_earners() -> tuple[earned_time.Earner, ...]:
"""Every gate earner of every registry, so a cutover misses no ledger."""
fn = getattr(earned_time, "all_earners", None)
pool = earned_time.EARNERS if fn is None else fn()
return tuple(e for e in pool if is_gate(e))
def earned_today(
earner: earned_time.Earner, now: datetime | None = None
) -> bool | None:
"""Whether ``earner``'s gate recorded today's credit; ``None`` if unknown."""
day = (now or datetime.now(tz=UTC)).astimezone().date()
return earned_time.done_today(
earner,
ledger_file(earner),
HMAC_KEY_FILE,
now=now,
gate_off=gate_off(earner, day),
)
def earned_units(earner: earned_time.Earner, day: date) -> int | None:
"""How many of a counted earner's units count for ``day``; ``None`` if unknown."""
return earned_time.credit_units(
earner,
ledger_file(earner),
HMAC_KEY_FILE,
day,
gate_off=gate_off(earner, day),
)
def gate_answers(day: date | None = None) -> dict[str, int | bool | None]:
"""Every gate earner's answer for ``day`` (default today), unknown logged.
Flat earners answer "done today"; counted ones (the tutor) their units.
On an earned_time without counted gates this is exactly
:func:`flat_answers`.
"""
target = day or datetime.now().astimezone().date()
answers: dict[str, int | bool | None] = dict(flat_answers())
for earner in counted_gate_earners(target):
units = earned_units(earner, target)
if units is None:
_warn_unknown(earner)
answers[earner.name] = units
return answers
def flat_answers() -> dict[str, bool | None]:
"""Today's answer from every flat earner, an unknown one logged."""
answers: dict[str, bool | None] = {}
for earner in flat_earners():
answer = earned_today(earner)
if answer is None:
_warn_unknown(earner)
answers[earner.name] = answer
return answers
def _warn_unknown(earner: earned_time.Earner) -> None:
_logger.warning(
"%s state could not be checked; no shutdown time for it", earner.label
)
# The day-aware ladder API (earned_time >= 0.4, from 2026-10-10). Looked up at
# call time so the installed 0.3.0 -- which has none of it -- still runs, on
# the raw fields it always used.
def first_minutes(earner: earned_time.Earner, day: date) -> int:
"""What ``earner``'s first unit pushes shutdown later by on ``day``."""
fn = getattr(earned_time, "shutdown_minutes_for", None)
return earner.shutdown_minutes if fn is None else int(fn(earner, day))
def extra_minutes(earner: earned_time.Earner, day: date) -> int:
"""What each further unit of a counted earner earns on ``day``."""
fn = getattr(earned_time, "extra_shutdown_minutes_for", None)
return earner.extra_shutdown_minutes if fn is None else int(fn(earner, day))
def first_credit_time(earner: earned_time.Earner, day: date) -> float | None:
"""Unix time of ``earner``'s first verified credit counting for ``day``.
``None`` when there is none, when it cannot be checked, and on an
``earned_time`` without ``first_credit_at`` (0.3.0) -- logged, because
then a late ledger credit counts from now, not from its row.
"""
fn = getattr(earned_time, "first_credit_at", None)
if fn is None:
_warn_no_first_credit()
return None
stamp: float | None = fn(
earner, ledger_file(earner), HMAC_KEY_FILE, day, gate_off=gate_off(earner, day)
)
return stamp
@functools.cache
def _warn_no_first_credit() -> None:
"""Once per process: the installed earned_time predates first_credit_at."""
_logger.warning(
"earned_time has no first_credit_at (needs >= 0.4); a late ledger "
"credit counts from now, not from its own time"
)
def first_credits(
earners: tuple[earned_time.Earner, ...], day: date
) -> dict[str, date | None] | None:
"""Each penalised ledger earner's first real credit up to ``day``, for resolve.
``earned_time.resolve`` / ``base_for`` take it as ``first_credits=`` (>= 0.6)
and then start a penalty no earlier than the day after the gate first
paid out. Keyed by name over ``earners`` -- the registry the answers came
from, so the two ``automation`` earners (either side of ``TUTOR_FROM``)
never collide. An earner without ``penalty_from``, a ledger or a matcher
is left out (``resolve`` falls back to its ``confirmed_on``).
Returns:
The map; ``None`` for a day before today -- history keeps the shutdown
it was enforced at, never re-resolved -- and on an earned_time
without ``maturity`` (< 0.6, logged), where callers resolve as before.
"""
if day < date.fromisoformat(today_str()):
return None
fn = getattr(earned_time, "maturity", None)
if fn is None:
_warn_no_maturity()
return None
return {
e.name: fn(e, ledger_file(e), HMAC_KEY_FILE, day).first_credit
for e in earners
if e.penalty_from is not None and e.ledger is not None and e.match is not None
}
@functools.cache
def _warn_no_maturity() -> None:
"""Once per process: the installed earned_time predates maturity."""
_logger.warning(
"earned_time has no maturity (needs >= 0.6); every penalty starts on "
"its penalty_from, even for a gate that never paid out"
)

View File

@ -0,0 +1,106 @@
"""The ``earner-bonus.path`` unit: apply a flat bonus the moment it is earned.
The live bonus pass used to run only on the 15-minute ``workout-sync`` timer,
so a LeetCode solve could wait a quarter of an hour for its shutdown hour
while the gaming hour landed in seconds (2026-10-06). The path unit watches
every flat earner's ledger and starts ``earner-bonus.service``
(``--apply-bonuses``) when a gate writes one.
The watch list is rendered from the ``earned_time`` registry, not kept by
hand. Re-pinning the registry is a pip install that never re-runs the
installer, so :func:`sync_path_unit` -- called from every ``--sync-only``
pass -- rewrites an installed unit that has drifted from the registry. A new
earner is watched within 15 minutes with no step to remember.
``PathChanged=`` and not ``PathModified=``: the gates write a temp file and
rename it into place, which ``PathChanged`` sees, including the first write
into a directory that does not exist yet. ``PathModified`` would also fire
mid-write on a gate that writes in place, and a half-written ledger reads as
"could not check".
"""
from __future__ import annotations
import logging
from pathlib import Path
import subprocess
import sys
from typing import Final
from screen_locker._earned import ledger_file, watched_earners
__all__ = ["PATH_UNIT_NAME", "USER_UNIT_DIR", "render_path_unit", "sync_path_unit"]
_logger = logging.getLogger(__name__)
PATH_UNIT_NAME: Final = "earner-bonus.path"
USER_UNIT_DIR: Final = Path.home() / ".config" / "systemd" / "user"
_HEADER: Final = """\
# Generated by screen_locker._earner_units from the earned_time registry.
# Do not edit: the next --sync-only pass rewrites a unit that has drifted.
[Unit]
Description=Apply a flat earner's shutdown bonus as soon as its ledger changes
[Path]
"""
_FOOTER: Final = """
[Install]
WantedBy=default.target
"""
def render_path_unit() -> str:
"""The unit text: one ``PathChanged=`` per gate earner's ledger.
Every registry's gates, not just today's (``watched_earners``), so a
dated cutover (the Automation tutor's ``TUTOR_FROM``) needs no re-render
on the day. The header keeps saying "flat": changing it would rewrite the
installed unit for no behavioural change.
"""
ledgers = dict.fromkeys(ledger_file(e) for e in watched_earners())
watches = "".join(f"PathChanged={path}\n" for path in ledgers)
return _HEADER + watches + _FOOTER
def sync_path_unit(unit_dir: Path | None = None) -> bool:
"""Rewrite an installed path unit that no longer matches the registry.
An absent unit is left absent: it was never installed, or it was removed
on purpose, and installing units is the installer's job. *unit_dir*
defaults to :data:`USER_UNIT_DIR`, read at call time.
Returns True if the unit was rewritten.
"""
unit = (unit_dir or USER_UNIT_DIR) / PATH_UNIT_NAME
if not unit.is_file():
return False
wanted = render_path_unit()
try:
if unit.read_text(encoding="utf-8") == wanted:
return False
unit.write_text(wanted, encoding="utf-8")
subprocess.run(
["/usr/bin/systemctl", "--user", "daemon-reload"],
check=True,
capture_output=True,
)
except (OSError, subprocess.CalledProcessError) as exc:
_logger.warning(
"%s differs from the earner registry and could not be refreshed: %s"
" -- a newly registered earner waits for the 15-minute sync",
unit,
exc,
)
return False
_logger.warning(
"%s was out of date with the earner registry; rewritten and reloaded",
unit,
)
return True
if __name__ == "__main__":
# install_systemd.sh / arm.sh: write the unit into the given directory.
target = Path(sys.argv[1]) / PATH_UNIT_NAME
target.write_text(render_path_unit(), encoding="utf-8")

View File

@ -2,18 +2,23 @@
Tracks:
- Consecutive weeks with 5+ workouts (streak counter).
- Banked shutdown-time bonus hours earned from extra workouts, applied
every day of the following week. This never reduces enforcement (unlike
a banked "skip a workout" credit would) — it only grants extra comfort
time on top of a floor you still have to earn each day.
- ISO weeks in which the early-bird window is extended to 09:00.
- ISO weeks in which the early-bird window is extended to 09:00. Display-only
bookkeeping: nothing reads it to decide a lock (early bird is wake-alarm's
signed exemption since 2026-09-20).
- The week-transition reward messages, for the status window.
There is no weekly shutdown-time bonus (dropped entirely 2026-10-10, hours
already banked for 2026-W41 included): every shutdown minute comes from the
``earned_time`` ladder and stops at its daily ceiling. A legacy
``weekly_shutdown_bonus_hours`` map in an old state file is ignored, and
dropped at the next week transition's write.
State is persisted in ``extra_benefits_state.json`` next to this file.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from datetime import UTC, date, datetime, timedelta
import json
import logging
from typing import TYPE_CHECKING, Any
@ -25,8 +30,9 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__name__)
_MILESTONE_INTERVAL = 4 # every 4-week streak → +1h extra shutdown bonus
_BONUS_THRESHOLD = 5 # workouts/week required to earn extra rewards
_BONUS_THRESHOLD = 5 # workouts/week required to extend the streak + early-bird
# The removed weekly shutdown bonus's map; ignored on read, dropped on write.
_LEGACY_BONUS_KEY = "weekly_shutdown_bonus_hours"
def _load_state(state_file: Path) -> dict[str, Any]:
@ -39,7 +45,7 @@ def _load_state(state_file: Path) -> dict[str, Any]:
except (OSError, json.JSONDecodeError) as exc:
_logger.warning(
"Could not read extra-benefits state from %s: %s — starting from "
"empty state, so the workout streak and any banked bonus hours are "
"empty state, so the workout streak and early-bird extension are "
"lost for this run",
state_file,
exc,
@ -56,38 +62,19 @@ def _save_state(state_file: Path, state: dict[str, Any]) -> None:
_logger.warning("Failed to save extra benefits state: %s", exc)
def _current_iso_week(now: datetime) -> str:
def _current_iso_week(now: date) -> str:
"""Return *now*'s ISO week as ``YYYY-Www``."""
year, week, _ = now.isocalendar()
return f"{year}-W{week:02d}"
def preview_bonus_if_week_ended_now(
current_week_count: int, current_streak: int
) -> tuple[int, int]:
"""(would_be_new_streak, would_be_bonus_hours) if the ISO week ended now.
Pure extraction of ``process_week_transition``'s threshold math — no file
I/O, safe to call from a read-only status view for a "next week preview."
"""
if current_week_count < _BONUS_THRESHOLD:
return 0, 0
streak = current_streak + 1
bonus_hours = current_week_count - 4
if streak % _MILESTONE_INTERVAL == 0:
bonus_hours += 1
return streak, bonus_hours
def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
"""Process last week's results if we've entered a new ISO week.
Counts workouts from the previous ISO week. If count >= 5:
- Increments the consecutive-streak counter.
- Banks (count - 4) hours of shutdown-time bonus for the current week,
applied once per day on top of the daily base reset.
- Marks the *current* ISO week as having extended early-bird (09:00).
- Adds +1h more bonus every ``_MILESTONE_INTERVAL`` streak weeks.
Otherwise the streak resets to 0. No shutdown-time hours are banked.
Returns a list of human-readable reward strings (empty if no transition).
"""
@ -113,44 +100,38 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
prev_week_count = count_weekly_workouts(log_file, today=prev_week_dt)
streak = int(state.get("consecutive_5plus_weeks", 0))
weekly_bonus_hours: dict[str, int] = dict(
state.get("weekly_shutdown_bonus_hours", {})
)
eb_weeks: list[str] = list(state.get("extended_early_bird_iso_weeks", []))
rewards: list[str] = []
prev_year, prev_week, _ = sunday_prev_week.isocalendar()
prev_week_str = f"{prev_year}-W{prev_week:02d}"
prev_week_str = _current_iso_week(sunday_prev_week)
if prev_week_count >= _BONUS_THRESHOLD:
extra = prev_week_count - 4
streak, bonus_hours = preview_bonus_if_week_ended_now(prev_week_count, streak)
streak += 1
if current_week_str not in eb_weeks:
eb_weeks.append(current_week_str)
rewards.append(
f"{prev_week_count} workouts in {prev_week_str}! "
f"+{extra}h shutdown bonus this week, early-bird extended to 09:00"
f"{prev_week_count} workouts in {prev_week_str}! {streak}-week streak"
)
if streak % _MILESTONE_INTERVAL == 0:
rewards.append(f"{streak}-week streak milestone! +1h extra shutdown bonus")
weekly_bonus_hours[current_week_str] = bonus_hours
else:
if streak > 0:
rewards.append(f"Streak reset (was {streak} weeks of 5+ workouts)")
streak = 0
# Merge onto the loaded state rather than rebuilding it from these four
# Merge onto the loaded state rather than rebuilding it from these three
# keys: sibling fields written by the recovery scripts (e.g.
# ``shutdown_bonus_granted_for``, the guard that stops a compensation
# bonus being granted twice) live in this file too, and rebuilding would
# drop them at the next weekly rollover — silently re-arming a double-grant.
# ``shutdown_bonus_granted_for``) live in this file too, and rebuilding
# would drop them at the next weekly rollover. Only the dead weekly-bonus
# map is dropped, so no reader can mistake it for live state.
updated = dict(state)
updated.pop(_LEGACY_BONUS_KEY, None)
updated.update(
{
"consecutive_5plus_weeks": streak,
"last_processed_iso_week": current_week_str,
"weekly_shutdown_bonus_hours": weekly_bonus_hours,
"extended_early_bird_iso_weeks": eb_weeks,
# Kept for the status window (display only): the strings above
# used to go to the log and nowhere else.
"week_rewards": {"iso_week": current_week_str, "messages": rewards},
}
)
_save_state(state_file, updated)
@ -162,18 +143,6 @@ def current_streak(state_file: Path) -> int:
return int(_load_state(state_file).get("consecutive_5plus_weeks", 0))
def weekly_shutdown_bonus_hours(
state_file: Path, *, today: datetime | None = None
) -> int:
"""Return the banked shutdown-time bonus (hours) for the current ISO week."""
now = today if today is not None else datetime.now(tz=UTC).astimezone()
current_week_str = _current_iso_week(now)
bonus_hours: dict[str, int] = _load_state(state_file).get(
"weekly_shutdown_bonus_hours", {}
)
return int(bonus_hours.get(current_week_str, 0))
def has_extended_early_bird(state_file: Path, *, today: datetime | None = None) -> bool:
"""Return True if the current ISO week has an extended early-bird window (09:00)."""
now = today if today is not None else datetime.now(tz=UTC).astimezone()
@ -182,3 +151,15 @@ def has_extended_early_bird(state_file: Path, *, today: datetime | None = None)
"extended_early_bird_iso_weeks", []
)
return current_week_str in eb_weeks
def week_rewards(state_file: Path, *, today: datetime | None = None) -> tuple[str, ...]:
"""The reward messages this ISO week's transition produced, for display.
Empty once the week has passed, or when nothing was stored.
"""
now = today if today is not None else datetime.now(tz=UTC).astimezone()
stored = _load_state(state_file).get("week_rewards", {})
if not isinstance(stored, dict) or stored.get("iso_week") != _current_iso_week(now):
return ()
return tuple(str(m) for m in stored.get("messages", []))

View File

@ -0,0 +1,101 @@
"""Live shutdown credit for counted gate earners: paid per unit, as it lands.
A flat earner (LeetCode, reading) pays once a day, stamped
``<name>_bonus_date``. A counted gate earner -- the Automation tutor, one
signed ledger row per verified 15-minute block -- pays per unit, so its stamp
is ``<name>_bonus_units``: the day, and how many units the config already
holds. The live pass pays only the difference, priced by
``Earner.shutdown_for`` so the rung's per-unit split (15 per tutor block) and the
``max_units`` cap come from the registry, never from here.
The workout is counted too, but screen-locker owns its log and
``WorkoutCreditMixin`` applies it; it is never in this pass.
State I/O stays in :mod:`screen_locker._shutdown_base`; these functions read
and update the state dict it hands over.
"""
from __future__ import annotations
from datetime import date
import logging
from typing import TYPE_CHECKING, Any
from screen_locker._earned import earned_units, is_gate, span
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
import earned_time
_logger = logging.getLogger(__name__)
def date_stamp(earner: earned_time.Earner) -> str:
"""The once-per-day key of a flat earner's live pass in the state file."""
return f"{earner.name}_bonus_date"
def units_stamp(earner: earned_time.Earner) -> str:
"""The per-day key of ``earner``'s applied units in the state file."""
return f"{earner.name}_bonus_units"
def applied_units(state: dict[str, Any], earner: earned_time.Earner, today: str) -> int:
"""Units of ``earner`` the config already holds today (0 if none stamped)."""
raw = state.get(units_stamp(earner))
if not isinstance(raw, dict) or raw.get("date") != today:
return 0
units = raw.get("units")
return units if isinstance(units, int) and units > 0 else 0
def reset_stamps(terms: Iterable[earned_time.Term], today: str) -> dict[str, object]:
"""The stamps for what a daily reset already wrote into the config.
A flat earner that paid is stamped with the date, a counted gate with
its units, so the live pass does not add either a second time today.
"""
stamps: dict[str, object] = {}
for t in terms:
if t.earner.kind == "flat" and t.shutdown_minutes:
stamps[date_stamp(t.earner)] = today
elif t.earner.kind == "counted" and is_gate(t.earner) and t.answer:
stamps[units_stamp(t.earner)] = {"date": today, "units": t.answer}
return stamps
def apply_counted_bonus(
state: dict[str, Any],
adjust: Callable[[int], bool],
earner: earned_time.Earner,
today: str,
) -> bool:
"""Push shutdown later by ``earner``'s units credited since the last pass.
``adjust`` is the mixin's ``_adjust_shutdown_time_by``. Updates ``state``
(the caller saves it) and returns True when it changed. An unreadable
ledger or key earns nothing and is logged.
"""
day = date.fromisoformat(today)
units = earned_units(earner, day)
if units is None:
_logger.warning(
"%s state could not be checked; no shutdown time for it", earner.label
)
return False
done = applied_units(state, earner, today)
if units <= done:
return False
minutes = earner.shutdown_for(units, day) - earner.shutdown_for(done, day)
if minutes > 0 and not adjust(minutes):
_logger.warning("%s bonus: failed to write shutdown config.", earner.label)
return False
state[units_stamp(earner)] = {"date": today, "units": units}
_logger.info(
"%s bonus: %d unit(s) today, +%s shutdown time.",
earner.label,
units,
span(minutes),
)
return True

View File

@ -0,0 +1,28 @@
"""Whether an earner's gate stands down today, asked live (earned_time 0.18.0).
Only LeetCode has a price: while its gate is on, earned_time pays only the
signed charge that covers the whole day, never one solve of five (kuhy,
2026-10-24). A free day or a sick day not keeping ``leetcode`` has no price,
so one verified solve earns. Never stored: clearing the sick day takes the
exception back on the next read.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import earned_time
import freedays
import sickdays
from screen_locker._sick import stands_down
if TYPE_CHECKING:
from datetime import date
def gate_off(earner: earned_time.Earner, day: date) -> bool:
"""Whether ``earner``'s gate is off on ``day`` (always False but LeetCode)."""
if earner.name != earned_time.LEETCODE.name:
return False
return freedays.is_free_day(day) or stands_down(sickdays.LOCKER_LEETCODE, day)

View File

@ -0,0 +1,127 @@
"""When the day's first task was done, and the day's floor lift.
The grace floor (first task + 60 min) is gone since 2026-10-17: a credit at
or after tonight's shutdown now counts from its own time
(:mod:`screen_locker._late_credit`), which is what the floor approximated,
and the floor's lift then absorbed minutes the late credit had already
restarted from (LeetCode late at 19:00, reading at 19:30: 20:10, not 20:20).
What stays: :func:`first_done_at` (shown by the preview and the reset log)
and the lift file. The lift is ``0`` on every day now (no floor lifts the
config above the earned sum), but ``plan_absorb`` still spends a stale one
recorded before the change, once.
"""
from __future__ import annotations
from datetime import date, datetime
import json
import logging
from typing import TYPE_CHECKING
import earned_time
from screen_locker._constants import GRACE_STATE_FILE
from screen_locker._day import today_str
from screen_locker._earned import first_credit_time, gate_earners
from screen_locker._log_io import load_workout_log
from screen_locker._weekly_check import credit_key, day_workout_index
from screen_locker._workout_ledger import entry_done_at
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
_MON_WED = frozenset({0, 1, 2})
def workout_done_at(log_file: Path, day: date) -> float | None:
"""The completion time of ``day``'s first counted workout, if any."""
iso = day.isoformat()
entries = [
e for e in load_workout_log(log_file).get(iso, []) if isinstance(e, dict)
]
siblings = day_workout_index(entries)
times = [
stamp
for index, entry in enumerate(entries)
if credit_key(iso, index, entry, siblings) is not None
and (stamp := entry_done_at(entry, day)) is not None
]
return min(times, default=None)
def _workout_time(day: date, log_file: Path | None, *, rest_day: bool) -> float | None:
"""When ``day``'s first workout was done: the ledger's and the log's view.
The workout ledger (``earned_time`` >= 0.4) holds only RunnerUp and rest
days, so the log still answers for manual and StrongLifts workouts. On a
rest day the ledger is skipped: its rest row is stamped at the day's
midnight, which is not a completion and would otherwise erase the floor.
"""
times: list[float | None] = []
if log_file is not None:
times.append(workout_done_at(log_file, day))
if not rest_day and earned_time.WORKOUT.ledger is not None:
times.append(first_credit_time(earned_time.WORKOUT, day))
return min((t for t in times if t is not None), default=None)
def first_done_at(
day: date, log_file: Path | None, *, rest_day: bool = False
) -> float | None:
"""The earliest moment any earner was done on ``day``; ``None`` if none.
Every gate in force on ``day`` counts, flat or counted: the Automation
tutor's first verified block starts the floor like a LeetCode solve does.
"""
times = [first_credit_time(e, day) for e in gate_earners(day)]
times.append(_workout_time(day, log_file, rest_day=rest_day))
on_day = [
t
for t in times
if t is not None and datetime.fromtimestamp(t).astimezone().date() == day
]
return min(on_day, default=None)
def load_lift(state_file: Path, today: str) -> int:
"""Today's recorded floor lift (grace or sick), 0 when none or unreadable."""
if not state_file.exists():
return 0 # no lift recorded yet: nothing to absorb
try:
raw = json.loads(state_file.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
_logger.warning(
"Grace state %s unreadable (%s); lift taken as 0", state_file, exc
)
return 0
if not isinstance(raw, dict) or raw.get("date") != today:
return 0
return max(0, int(raw.get("lift", 0)))
def save_lift(lift: int, state_file: Path | None = None) -> None:
"""Record how far the floor lifts today's shutdown above the earned time."""
state_file = state_file or GRACE_STATE_FILE
payload = {"date": today_str(), "lift": max(0, lift)}
try:
state_file.write_text(json.dumps(payload), encoding="utf-8")
except OSError as exc:
_logger.warning("Could not record the grace lift in %s (%s)", state_file, exc)
def plan_absorb(minutes: int, state_file: Path | None = None) -> tuple[int, int]:
"""How much of an ``minutes`` addition moves the config, and the lift left.
Commit the lift with :func:`save_lift` only once the write succeeded.
"""
lift = load_lift(state_file or GRACE_STATE_FILE, today_str())
return max(0, minutes - lift), max(0, lift - minutes)
def tonight_minutes(config: tuple[int, int, int], day: date) -> int:
"""``day``'s shutdown from a (mon_wed, thu_sun, morning_end) config."""
mon_wed, thu_sun, _ = config
return mon_wed if day.weekday() in _MON_WED else thu_sun

View File

@ -39,15 +39,15 @@ def build_heat_skip_content(
*both* edges if it ever outgrows the display -- and it has to be in the
fit check for that reason.
"""
outer = tk.Frame(parent, bg=_COLORS.bg)
outer = tk.Frame(parent, bg=_COLORS.palette.bg)
outer.place(relx=0.5, rely=0.5, anchor="center")
tk.Label(
outer,
text="\u2600 Too hot to workout?",
font=_COLORS.font("body", bold=True, family=_FONT),
bg=_COLORS.bg,
fg=_COLORS.warning,
bg=_COLORS.palette.bg,
fg=_COLORS.palette.warning,
).pack(pady=(0, _COLORS.space("sm")))
tk.Label(
@ -57,28 +57,28 @@ def build_heat_skip_content(
f" (threshold: {HEAT_SKIP_TEMP_THRESHOLD}\u00b0C)"
),
font=_COLORS.font("body", family=_FONT),
bg=_COLORS.bg,
fg=_COLORS.muted,
bg=_COLORS.palette.bg,
fg=_COLORS.palette.muted,
).pack(pady=_COLORS.space("xs"))
tk.Label(
outer,
text="Skip today's workout due to extreme heat?",
font=_COLORS.font("body", family=_FONT),
bg=_COLORS.bg,
fg=_COLORS.muted,
bg=_COLORS.palette.bg,
fg=_COLORS.palette.muted,
).pack(pady=(_COLORS.space("xs"), 0))
btn_frame = tk.Frame(outer, bg=_COLORS.bg)
btn_frame = tk.Frame(outer, bg=_COLORS.palette.bg)
btn_frame.pack(pady=_COLORS.space("lg"))
skip_button = tk.Button(
btn_frame,
text="Skip workout",
command=on_skip,
bg=_COLORS.warning,
fg=_COLORS.on_fill,
activebackground=_COLORS.warning,
bg=_COLORS.palette.warning,
fg=_COLORS.palette.on_fill,
activebackground=_COLORS.palette.warning,
font=_COLORS.font("label", family=_FONT),
padx=_COLORS.space("md"),
pady=_COLORS.space("sm"),
@ -91,9 +91,9 @@ def build_heat_skip_content(
btn_frame,
text="No, I'll workout",
command=on_decline,
bg=_COLORS.field_bg,
fg=_COLORS.fg,
activebackground=_COLORS.field_bg,
bg=_COLORS.palette.field_bg,
fg=_COLORS.palette.fg,
activebackground=_COLORS.palette.field_bg,
font=_COLORS.font("label", family=_FONT),
padx=_COLORS.space("md"),
pady=_COLORS.space("sm"),
@ -131,7 +131,7 @@ class HeatSkipMixin:
# This window is destroyed before the main GateRoot is created.
root = tk.Tk()
root.title("Extreme Heat")
root.configure(bg=_COLORS.bg)
root.configure(bg=_COLORS.palette.bg)
root.attributes("-fullscreen", True)
root.attributes("-topmost", True)
root.grab_set()

86
screen_locker/_hold.py Normal file
View File

@ -0,0 +1,86 @@
"""A declared test hold keeps the workout locker from showing any window.
``holds`` (kuhyx/utils, ``holds-v0.1.0``) is the shared "a proctored test is
running, leave the PC usable" switch. Every workout-locker path -- the relaxed
day's optional prompt, the strict lock and the early-bird re-check -- runs
through ``_startup_checks._check_non_verify_exits``, so one rung there covers
them all. It sits BELOW the reset/bonus/ingest/early-bird bookkeeping: a hold
only suppresses the window, never a credit or a penalty term.
Fail closed: if ``holds`` cannot be imported or read, there is no hold and the
locker behaves exactly as it did before, with an ERROR in the journal. A hold
that fails open would be a free escape from the gate.
"""
from __future__ import annotations
import importlib
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import datetime
from tkinter import Misc
__all__ = ["HOLD_REASON", "skip_during_hold", "watch_for_hold", "workout_hold_active"]
_logger = logging.getLogger(__name__)
HOLD_REASON = "test_hold_active"
_GATE = "workout"
# Well inside the 60 s a hold may take to lift an open window.
POLL_MS = 30_000
def workout_hold_active(now: datetime | None = None) -> bool:
"""Whether a declared hold covers the workout gate right now."""
try:
# Imported at call time so a missing package fails closed, per call.
holds = importlib.import_module("holds")
return bool(holds.covers(_GATE, now))
except ImportError, OSError, ValueError, TypeError, KeyError:
_logger.exception(
"Could not read the test holds — treating it as no hold, so "
"the workout locker behaves as normal"
)
return False
def skip_during_hold(record_skip: Callable[..., None]) -> bool:
"""Skip enforcement while a hold is active.
Takes the locker's ``_record_skip`` (which exits the process) rather than
the locker, like ``_night_lockdown``. Returns True when the skip was
recorded, False when the ladder may go on.
"""
if not workout_hold_active():
return False
record_skip(
HOLD_REASON,
"A declared test hold is active — no workout prompt or lock window "
"until it ends (credit and penalty bookkeeping already ran).",
)
return True
def watch_for_hold(root: Misc, close: Callable[[], None]) -> None:
"""Close an already-open window within one poll of a hold starting.
``close`` is the locker's own ``close`` -- it tears the window down and
exits 0 without writing a credit or a penalty, so a hold that interrupts
an open prompt/lock changes nothing but the screen. Polls on the Tk loop
(no thread) and stops re-arming once it fires.
"""
def _tick() -> None:
if workout_hold_active():
_logger.warning(
"A test hold started while the workout window was open — "
"closing it without credit or penalty."
)
close()
return
root.after(POLL_MS, _tick)
root.after(POLL_MS, _tick)

View File

@ -10,7 +10,7 @@ app serves the same JSON over ``WORKOUT_HTTP_PORT``.
from __future__ import annotations
from concurrent.futures import ( # pylint: disable=no-name-in-module
from concurrent.futures import (
ThreadPoolExecutor,
as_completed,
)

View File

@ -0,0 +1,106 @@
"""A credit at or after tonight's shutdown counts from its own time.
Shutting down must never make a finished task impossible to submit (kuhy,
2026-10-17). The config is read-add-write, so a credit landing after the
shutdown used to add its minutes to a time already past: LeetCode at 19:00
after an 18:00 shutdown made 19:00, and the machine stayed locked. Now such
a credit moves tonight to ``its own time + its reward``, capped at the
ceiling -- only the ceiling (23:00) is hard.
The anchor is the credit's own time (a run's end, a ledger row's), never the
moment a sync applied it, so a late upload does not buy extra time. Past the
shutdown the grace and sick lifts are spent: the caller clears the lift.
"""
from __future__ import annotations
from datetime import date, datetime
import logging
import time
from typing import TYPE_CHECKING
from screen_locker._ceiling import ceiling
from screen_locker._day import today_str
from screen_locker._earned import hhmm
from screen_locker._grace_floor import save_lift, tonight_minutes
_logger = logging.getLogger(__name__)
def _now() -> float:
"""The clock a credit without its own time is stamped with."""
return time.time()
def late_tonight(
config: tuple[int, int, int], day: date, at: float, minutes: int
) -> int | None:
"""Tonight's new shutdown for a late credit, or ``None`` if it is not late.
Late means timed on ``day`` at or after tonight's shutdown: the check
script locks at ``now >= shutdown``, so a credit exactly on it is late.
"""
moment = datetime.fromtimestamp(at).astimezone()
if moment.date() != day:
return None
done = moment.hour * 60 + moment.minute
if done < tonight_minutes(config, day):
return None
return min(ceiling(day), done + minutes)
class LateCreditMixin:
"""Late credits and sick-day credits; composed into ShutdownMixin."""
if TYPE_CHECKING:
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
raise NotImplementedError
def _write_shutdown_config(
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ...
) -> bool:
raise NotImplementedError
def _apply_sick_floor(self) -> bool: ...
def _raise_on_sick_day(self, minutes: int, at: float | None) -> bool:
"""A sick day's credit: raise to the derived target, never add.
The credit's row is already in the target (``_sick_floor``), so adding
its minutes on top would pay it twice; a late one still counts from
its own time. True even when nothing moved (capped, or already there):
the credit is accounted for, and a failed write is retried by the next
tick's sick pass.
"""
self._apply_sick_floor()
late = self._late_write(minutes, at)
return True if late is None else late
def _late_write(self, minutes: int, at: float | None) -> bool | None:
"""Write a late credit's shutdown; ``None`` when it is not late."""
day = date.fromisoformat(today_str())
try:
config = self._read_shutdown_config()
except (OSError, ValueError) as exc:
# The add path reads it again inside its own guard and fails there.
_logger.warning("Late credit: cannot read shutdown config (%s)", exc)
return None
if config is None:
return None # the add path reads it again and reports it
target = late_tonight(config, day, _now() if at is None else at, minutes)
if target is None:
return None
mon_wed, thu_sun, morning = config
if not self._write_shutdown_config(
max(mon_wed, target), max(thu_sun, target), morning, restore=True
):
_logger.warning("Late credit: failed to write shutdown config")
return False
save_lift(0) # past the shutdown, the floors' lift is spent
_logger.info(
"Late credit: shutdown %s -> %s (earned at or after tonight's).",
hhmm(tonight_minutes(config, day)),
hhmm(target),
)
return True

View File

@ -0,0 +1,59 @@
"""The one thing the enforce path must never do: lock over a logged workout.
Every skip in ``_startup_checks._check_non_verify_exits`` is a rung of a
first-match-wins ladder, and the ladder's ORDER is what decides whether a
logged workout counts. That order has been wrong before: on 2026-09-18 an
expired early-bird marker sat one rung above ``workout_logged_today``, so a
walk logged on the lock screen at 09:32 closed the lock and the 09:35 tick
locked again -- and would have every five minutes for the rest of the day.
The DECISION line even printed ``also=workout_logged_today``; the evidence
was there, only the gate was missing.
This module is that gate. It runs right before the ``enforced`` decision is
recorded and refuses to build a lock screen when a counted workout for today
is already in the log, whatever the ladder above it concluded. Reaching it
with a workout logged is a bug in the ladder, so it is reported at ERROR --
the locker must fail safe (no lock) and say so, not fail closed on the user.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
GUARD_REASON = "workout_logged_today_guard"
GUARD_DETAIL = (
"A counted workout is already logged today but the exemption ladder fell "
"through to enforcement — refusing to lock (ladder bug, see journal ERROR)."
)
def refuse_to_lock_over_logged_workout(
has_logged_today: Callable[[], bool],
record_skip: Callable[[str, str], None],
) -> bool:
"""Skip enforcement if today already holds a counted workout.
Takes the locker's two relevant methods rather than the locker itself so
the guard stays a pure function of "is there a workout?" and "how do I
skip?". Returns True when the guard fired (the skip was recorded through
*record_skip*, which exits the process), False when the lock may go
ahead. The ERROR is deliberate: a fired guard means a rung above it is
misordered, and that has to be visible in the journal rather than quietly
absorbed as one more skip.
"""
if not has_logged_today():
return False
_logger.error(
"Enforcement reached with a workout already logged today — the "
"early-exit ladder in _startup_checks/_auto_upgrade is misordered. "
"Refusing to lock; fix the ladder so workout_logged_today is reached."
)
record_skip(GUARD_REASON, GUARD_DETAIL)
return True

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import ExitStack
from dataclasses import dataclass, field
from datetime import UTC, datetime
import json
@ -11,8 +12,11 @@ from typing import TYPE_CHECKING
from gatelock.log_integrity import compute_entry_hmac
from screen_locker import _compliance_state
from screen_locker._bonus_lock import bonus_lock
from screen_locker._day import today_str
from screen_locker._log_io import load_workout_log
from screen_locker._manual_workout import MANUAL_WORKOUT_TYPE, manual_sync_record_id
from screen_locker._workout_ledger import record_log_entry
if TYPE_CHECKING:
from collections.abc import Mapping
@ -78,6 +82,22 @@ def _entry_workout_id(date: str, entry: Mapping[str, object]) -> str | None:
return _derive_workout_id(date, workout_data)
def _is_logged_anywhere(logs: dict[str, list[dict]], workout_id: str) -> bool:
"""True if any day already holds an entry with this ``workout_id``.
Searched across ALL days, not just the one being written: a workout id is
globally unique (it embeds its own date), and a copy of the same workout
can arrive keyed on a different day — e.g. an entry moved to the local day
it was really logged on, then its synced twin coming back stamped with
the old UTC day. Per-day dedup would append that twin as a duplicate.
"""
return any(
_entry_workout_id(day, e) == workout_id
for day, entries in logs.items()
for e in entries
)
def write_signed_entry(
log_file: Path, date: str, workout_data: Mapping[str, object]
) -> RecordResult:
@ -87,22 +107,43 @@ def write_signed_entry(
workout) and manual-sync ingestion, which files a synced workout under its
OWN date so the weekly count places it in the right ISO week. The log is
day-keyed but now holds MULTIPLE entries per day: this APPENDS rather than
overwrites. If an entry with the same ``workout_id`` already exists for the
overwrites. If an entry with the same ``workout_id`` already exists on ANY
day, it is a no-op — the log itself is the idempotency store, so
re-verifying the same workout never double-records. Every write normalizes
the whole file to the list shape, progressively migrating legacy entries.
Returns a :class:`RecordResult` so the credit path can tell an appended
workout from a duplicate and a first-of-day workout from an additional one.
The read-dedup-write runs under an exclusive lock beside the log: the
5-minute locker pass, the 15-minute sync and the RunnerUp upload watcher
all backfill the same run, and two of them reading before either wrote
would both see it missing -- two entries and, since only an appended
result is credited, two shutdown rewards.
"""
with ExitStack() as stack:
try:
stack.enter_context(bonus_lock(log_file))
except OSError as e:
_logger.warning(
"Could not lock workout log %s (%s) — writing without the lock; "
"a pass running at the same moment could record this twice",
log_file,
e,
)
return _write_signed_entry_locked(log_file, date, workout_data)
def _write_signed_entry_locked(
log_file: Path, date: str, workout_data: Mapping[str, object]
) -> RecordResult:
"""Body of :func:`write_signed_entry`; the caller holds the log lock."""
logs = load_workout_log(log_file)
entries = logs.setdefault(date, [])
prior = list(entries)
workout_id = _derive_workout_id(date, workout_data)
if workout_id is not None and any(
_entry_workout_id(date, e) == workout_id for e in entries
):
if workout_id is not None and _is_logged_anywhere(logs, workout_id):
return RecordResult(appended=False, prior_entries=prior)
entry: dict[str, object] = {
@ -122,6 +163,10 @@ def write_signed_entry(
json.dump(logs, f, indent=2)
except OSError as e:
_logger.warning("Could not save workout log: %s", e)
else:
# The one chokepoint every credit passes, so the signed workout
# ledger (earned_time's view of the workout) can never miss one.
record_log_entry(date, entries)
return RecordResult(appended=True, prior_entries=prior)
@ -153,5 +198,5 @@ class LogMixin:
credit only for a genuinely new workout and scale it by whether today
already had one.
"""
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
today = today_str()
return write_signed_entry(self.log_file, today, self.workout_data)

View File

@ -13,15 +13,22 @@ it bypassed those checks; this routes through the ordinary sync-ingest path
(``ingest_manual_records``), so validation, the rolling budget, HMAC signing
and workout_id dedup are the shared implementation rather than a second copy
that can drift.
``--date`` may name a FUTURE day (up to 7 days ahead; past dates are refused).
Such an entry is not ingested: it is held and published by
``_manual_schedule`` and ingested -- and credited -- on its date by the
ordinary sync pass.
"""
from __future__ import annotations
import argparse
from datetime import UTC, datetime
import logging
from typing import TYPE_CHECKING
from screen_locker._day import today_str
from screen_locker._manual_future import is_future_date, validate_workout_date
from screen_locker._manual_schedule import budget_error, schedule_future_workout
from screen_locker._manual_sync import ingest_manual_records
from screen_locker._manual_workout import (
SPORT_CHOICES,
@ -73,7 +80,14 @@ def add_manual_arguments(parser: argparse.ArgumentParser) -> None:
default="",
help=f"what the activity was; required when --sport {SPORT_OTHER}",
)
parser.add_argument("--date", default="", help="ISO date; defaults to today")
parser.add_argument(
"--date",
default="",
help=(
"ISO date, today up to 7 days ahead (no past dates); defaults to "
"today. A future date is held and credited automatically on it."
),
)
parser.add_argument("--start", required=True, help="HH:MM")
parser.add_argument("--end", required=True, help="HH:MM")
parser.add_argument("--location", required=True)
@ -114,36 +128,34 @@ def _draft(args: argparse.Namespace) -> ManualWorkoutDraft:
)
def run_manual_log(log_file: Path, argv: Sequence[str]) -> int:
"""Log one manual workout headlessly. Returns a process exit code.
def _schedule(log_file: Path, draft: ManualWorkoutDraft, date: str) -> int:
"""Hold a FUTURE-dated workout and publish it; exit code for the CLI.
Non-zero means nothing was written: ``ingest_manual_records`` refuses a
draft that fails validation or that the rolling budget has no room for,
and it already logs the concrete reason, so a caller never has to guess
between "rejected" and "already logged".
Never written to log.json or signed here: it is ingested by the ordinary
sync pass on its date.
"""
parser = argparse.ArgumentParser(
prog="screen-locker --log-manual-workout",
description="Log a manual workout without opening any app.",
)
add_manual_arguments(parser)
args = parser.parse_args(list(argv))
# UTC, matching how the budget windows compute "today" -- a local date
# near midnight would file the workout in a different window than the one
# it is then counted in.
date = args.date or datetime.now(tz=UTC).strftime("%Y-%m-%d")
draft = _draft(args)
# Validated here as well as inside the ingest, purely so a person running
# this by hand gets the concrete field error on stderr and a non-zero exit
# instead of only a log line. The ingest re-checks it regardless.
error = validate_manual_workout(draft)
if error is not None:
_logger.error("Manual workout REFUSED — %s. Nothing was written.", error)
scheduled = schedule_future_workout(log_file, draft, date)
if not scheduled.scheduled:
_logger.error(
"Manual workout %s was NOT scheduled — %s",
scheduled.record_id,
scheduled.reason,
)
return 1
_logger.info("Manual workout %s: %s", scheduled.record_id, scheduled.reason)
return 0
record_id = manual_sync_record_id(date, args.start)
def _log_today(log_file: Path, draft: ManualWorkoutDraft, date: str, start: str) -> int:
"""Log today's workout through the ordinary ingest; exit code for the CLI."""
record_id = manual_sync_record_id(date, start)
payload = build_sync_payload(draft, date)
# Today's entry takes the ordinary path, but its budget now also counts
# pending future entries (a window containing today contains tomorrow too).
over_budget = budget_error(log_file, date)
if over_budget is not None:
_logger.error("Manual workout %s was NOT logged — %s", record_id, over_budget)
return 1
ingested = ingest_manual_records(log_file, [(record_id, payload)], today=date)
if not ingested:
@ -160,3 +172,40 @@ def run_manual_log(log_file: Path, argv: Sequence[str]) -> int:
record_id,
)
return 0
def run_manual_log(log_file: Path, argv: Sequence[str]) -> int:
"""Log one manual workout headlessly. Returns a process exit code.
Non-zero means nothing was written: ``ingest_manual_records`` refuses a
draft that fails validation or that the rolling budget has no room for,
and it already logs the concrete reason, so a caller never has to guess
between "rejected" and "already logged".
"""
parser = argparse.ArgumentParser(
prog="screen-locker --log-manual-workout",
description="Log a manual workout without opening any app.",
)
add_manual_arguments(parser)
args = parser.parse_args(list(argv))
# The LOCAL day, like every other day-key (screen_locker._day): the old
# UTC default filed a workout logged at 00:02 CEST under yesterday, and the
# lock check then could not find it "today".
date = args.date or today_str()
draft = _draft(args)
# Validated here as well as inside the ingest, purely so a person running
# this by hand gets the concrete field error on stderr and a non-zero exit
# instead of only a log line. The ingest re-checks it regardless.
error = validate_manual_workout(draft)
if error is not None:
_logger.error("Manual workout REFUSED — %s. Nothing was written.", error)
return 1
date_error = validate_workout_date(date)
if date_error is not None:
_logger.error("Manual workout REFUSED — %s. Nothing was written.", date_error)
return 1
if is_future_date(date):
return _schedule(log_file, draft, date)
return _log_today(log_file, draft, date, args.start)

View File

@ -0,0 +1,222 @@
"""Future-dated manual workouts: the date rule and the per-date budget rule.
A manual workout may be logged AHEAD of time (``today .. today + 7 days``, never
a past date) and earns its normal credit on that date, not before. Two pure
rules live here, shared by every entry point (Tk dialog, CLI, sync ingest):
* :func:`validate_workout_date` -- the one 0..7-days-ahead check;
* :func:`budget_error_for_date` -- the rolling budget (2 per 7 days, 10 per 30)
counted against the WORKOUT's date, over logged credits AND pending future
entries, so a future entry holds its slot from the moment it is logged.
No I/O beyond reading ``log.json``; the pending ledger and the Firebase pull
are in ``_manual_pending`` / ``_manual_schedule``.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
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_MAX_DAYS_AHEAD,
)
from screen_locker._day import today_str
from screen_locker._manual_workout_types import manual_credit_dates
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
__all__ = [
"BudgetUsage",
"budget_closed_for_all_days",
"budget_error_for_date",
"budget_usage_for_date",
"is_future_date",
"parse_workout_date",
"validate_workout_date",
]
_logger = logging.getLogger(__name__)
# (window length in days, cap) -- a window of N days is [start, start + N - 1].
_WINDOWS: tuple[tuple[int, int], ...] = (
(7, MANUAL_WORKOUT_BUDGET_PER_7_DAYS),
(30, MANUAL_WORKOUT_BUDGET_PER_30_DAYS),
)
def parse_workout_date(value: str) -> date | None:
"""Parse a strict ``YYYY-MM-DD`` string, or return None (and warn)."""
try:
return datetime.strptime(value.strip(), "%Y-%m-%d").replace(tzinfo=UTC).date()
except ValueError as exc:
_logger.warning(
"Date %r is not YYYY-MM-DD (%s) — treated as invalid", value, exc
)
return None
def is_future_date(value: str, *, today: str | None = None) -> bool:
"""True if ``value`` is a valid date strictly after today (local day)."""
parsed = parse_workout_date(value)
reference = parse_workout_date(today or today_str())
return parsed is not None and reference is not None and parsed > reference
def validate_workout_date(value: str, *, today: str | None = None) -> str | None:
"""Return an error message if ``value`` is not today..today+7 days, else None.
THE date rule for manual workouts: no past dates (a back-dated entry is the
easiest way to fake a missed week) and no more than
``MANUAL_WORKOUT_MAX_DAYS_AHEAD`` days ahead.
"""
parsed = parse_workout_date(value)
if parsed is None:
return "Workout date must be YYYY-MM-DD"
reference = parse_workout_date(today or today_str())
if reference is None:
return f"Today's date {today!r} is not YYYY-MM-DD"
ahead = (parsed - reference).days
if ahead < 0:
return (
f"Workout date {value.strip()} is in the past -- only today or up "
f"to {MANUAL_WORKOUT_MAX_DAYS_AHEAD} days ahead"
)
if ahead > MANUAL_WORKOUT_MAX_DAYS_AHEAD:
return (
f"Workout date {value.strip()} is {ahead} days ahead -- at most "
f"{MANUAL_WORKOUT_MAX_DAYS_AHEAD} days"
)
return None
def _ordinals(dates: Iterable[str]) -> list[int]:
"""Day ordinals of every parsable date; unparsable ones are skipped."""
days: list[int] = []
for value in dates:
parsed = parse_workout_date(value)
if parsed is not None:
days.append(parsed.toordinal())
return days
def _fullest_window(days: list[int], ordinal: int, length: int) -> tuple[int, int]:
"""Return ``(count, start)`` of the fullest ``length``-day window holding D.
Counts the entries in ``days`` only (the new entry is not among them), over
every window ``[start, start + length - 1]`` that contains ``ordinal``.
"""
return max(
(
(sum(1 for day in days if start <= day < start + length), start)
for start in range(ordinal - length + 1, ordinal + 1)
),
key=lambda found: found[0],
)
def _existing_days(log_file: Path, pending_dates: Iterable[str]) -> list[int]:
"""Ordinals of every logged credit and every pending future entry."""
return [*_ordinals(manual_credit_dates(log_file)), *_ordinals(pending_dates)]
@dataclass(frozen=True)
class BudgetUsage:
"""How full the fullest windows around one workout date already are."""
week: int
month: int
@property
def exhausted(self) -> bool:
"""True if one more entry on that date would break either cap."""
return (
self.week >= MANUAL_WORKOUT_BUDGET_PER_7_DAYS
or self.month >= MANUAL_WORKOUT_BUDGET_PER_30_DAYS
)
def budget_usage_for_date(
log_file: Path,
workout_date: str,
pending_dates: Iterable[str] = (),
) -> BudgetUsage | None:
"""Return the fullest 7- and 30-day windows containing ``workout_date``.
The PC's admission rule as numbers: the shared cross-language fixture
(``fixtures/manual_budget_merge.json``, ``planned.expected_by_date``) pins
these to what the phone computes. None if the date does not parse.
"""
target = parse_workout_date(workout_date)
if target is None:
return None
days = _existing_days(log_file, pending_dates)
ordinal = target.toordinal()
return BudgetUsage(
week=_fullest_window(days, ordinal, 7)[0],
month=_fullest_window(days, ordinal, 30)[0],
)
def budget_error_for_date(
log_file: Path,
workout_date: str,
pending_dates: Iterable[str] = (),
) -> str | None:
"""Return why a manual workout dated ``workout_date`` does not fit, else None.
Adding an entry dated D is allowed only if EVERY rolling window containing
D -- each 7-day window and each 30-day window -- stays within its cap
counting the credits already in ``log_file``, the pending future entries
in ``pending_dates``, and the new entry itself. Counting against D rather
than against today is what lets a full week still schedule next week, and
what keeps tomorrow's entry from being free of the cap.
``pending_dates`` must already exclude the entry being checked (an entry
that holds its slot would otherwise reject itself on its own date).
"""
target = parse_workout_date(workout_date)
if target is None:
return f"Workout date {workout_date!r} must be YYYY-MM-DD"
days = _existing_days(log_file, pending_dates)
for length, cap in _WINDOWS:
used, start = _fullest_window(days, target.toordinal(), length)
if used >= cap:
first = date.fromordinal(start)
last = first + timedelta(days=length - 1)
return (
f"Manual-workout budget: {used} already counted/scheduled "
f"between {first} and {last}, max {cap} per {length} days"
)
return None
def budget_closed_for_all_days(
log_file: Path,
pending_dates: Iterable[str] = (),
*,
today: str | None = None,
) -> bool:
"""True if no date in today..today+7 could take another manual workout.
The "Log Manual Workout" button hides only then; "today's window is full"
no longer closes it, since a later day may still have room.
"""
reference = parse_workout_date(today or today_str())
if reference is None:
_logger.warning("Today %r is not YYYY-MM-DD -- budget treated as closed", today)
return True
pending = list(pending_dates)
return all(
budget_error_for_date(
log_file, (reference + timedelta(days=offset)).isoformat(), pending
)
is not None
for offset in range(MANUAL_WORKOUT_MAX_DAYS_AHEAD + 1)
)

View File

@ -0,0 +1,217 @@
"""Durable ledger of future-dated manual workouts logged on THIS PC.
A manual workout logged for a later day must not enter ``log.json`` until that
day (the weekly check, the budget, the status view and the shutdown derivation
all read the log, and would count it early). It has to live somewhere else, and
be visible to the phone for budget and deletion -- so it is published to this
device's sync log.
Publishing once is not enough, for the reason ``_sync_tombstones`` documents:
``_manual_push`` re-derives this device's log from ``log.json`` on every tick
and ``crdt_sync.sync_log`` never reads the device's OWN remote log back, so
anything published only once is overwritten by the next push. This ledger is
what every push re-publishes (``records_from_workout_log`` merges it in), and
ingestion on the workout's date reads the record back through the ordinary
pull -- the PC does ingest its own device log -- or from here if Firebase is
unreachable that day.
``{record_id: {payload, hlc, scheduled_at}}`` in ``MANUAL_PENDING_FILE``. The HLC
is minted once and stored, so re-pushing the same entry does not churn the
store. An entry leaves the ledger once it is in ``log.json`` (its own copy is
then published from there) or once it is tombstoned.
"""
from __future__ import annotations
from datetime import UTC, datetime
import json
import logging
from typing import TYPE_CHECKING, Any
from crdt_sync import Hlc, Record
from screen_locker._constants import MANUAL_PENDING_FILE
from screen_locker._day import today_str
from screen_locker._log_io import load_workout_log
from screen_locker._log_mixin import _entry_workout_id
from screen_locker._manual_future import parse_workout_date
from screen_locker._manual_workout import MANUAL_WORKOUT_SYNC_KIND
from screen_locker._sync_tombstones import tombstone_records
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from pathlib import Path
__all__ = [
"add_pending",
"local_pending_records",
"pending_future_dates",
"pending_ledger_records",
"settle_pending",
]
_logger = logging.getLogger(__name__)
_PAYLOAD_FIELD = "payload"
def _ledger_path(path: Path | None) -> Path:
"""Resolve the ledger path at call time, so a patched module global applies."""
return MANUAL_PENDING_FILE if path is None else path
def _load(path: Path) -> dict[str, dict[str, Any]]:
"""Read the ledger; a missing file is empty, a corrupt one is loud."""
if not path.exists():
return {}
try:
raw = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as exc:
_logger.warning(
"Pending manual-workout ledger %s is unreadable (%s) -- treating it "
"as EMPTY, so future-dated workouts logged on this PC will not be "
"re-published or credited from here",
path,
exc,
)
return {}
if not isinstance(raw, dict):
_logger.warning(
"Pending manual-workout ledger %s is not a JSON object -- ignoring it",
path,
)
return {}
return {str(rid): entry for rid, entry in raw.items() if isinstance(entry, dict)}
def _save(path: Path, ledger: dict[str, dict[str, Any]]) -> None:
"""Write the ledger as sorted, indented JSON."""
path.write_text(json.dumps(ledger, indent=2, sort_keys=True) + "\n")
def add_pending(
record_id: str,
payload: dict[str, object],
device_id: str,
path: Path | None = None,
) -> bool:
"""Record a future-dated manual workout. Returns False if already held."""
path = _ledger_path(path)
ledger = _load(path)
if record_id in ledger:
return False
ledger[record_id] = {
"payload": payload,
"hlc": Hlc.new_tick(device_id).to_str(),
"scheduled_at": datetime.now(tz=UTC).isoformat(),
}
_save(path, ledger)
return True
def local_pending_records(path: Path | None = None) -> list[tuple[str, dict]]:
"""Return ``(record_id, payload)`` for every ledgered entry not tombstoned.
The shape ``ingest_manual_records`` takes, so this PC's own future entries
can be ingested on their date even when the Firebase pull is empty or down.
"""
dead = tombstone_records().keys()
return [
(rid, entry["payload"])
for rid, entry in _load(_ledger_path(path)).items()
if rid not in dead and isinstance(entry.get("payload"), dict)
]
def pending_ledger_records(path: Path | None = None) -> dict[str, Record]:
"""Return the ledger as crdt_sync records, for ``_manual_push`` to publish.
An entry without a parsable ``hlc`` is skipped with a warning: publishing
it clockless would churn the store (or lose to a stale copy).
"""
records: dict[str, Record] = {}
for record_id, entry in _load(_ledger_path(path)).items():
payload = entry.get("payload")
try:
hlc = Hlc.from_str(str(entry.get("hlc")))
except (ValueError, TypeError) as exc:
_logger.warning(
"Pending manual workout %s has no usable hlc (%r: %s) -- NOT "
"published, so the phone will not see it",
record_id,
entry.get("hlc"),
exc,
)
continue
if isinstance(payload, dict):
records[record_id] = Record(
id=record_id, fields={_PAYLOAD_FIELD: (payload, hlc)}
)
return records
def _logged_ids(log_file: Path) -> set[str]:
"""Every workout id already in ``log.json`` (stored, derived or synced)."""
ids: set[str] = set()
for day, entries in load_workout_log(log_file).items():
for entry in entries:
if (own := _entry_workout_id(day, entry)) is not None:
ids.add(own)
data = entry.get("workout_data", {})
if isinstance(data, dict) and data.get("sync_record_id"):
ids.add(str(data["sync_record_id"]))
return ids
def settle_pending(
log_file: Path,
deleted_ids: Iterable[str] = (),
path: Path | None = None,
) -> list[str]:
"""Drop ledger entries that are in ``log.json`` or deleted; return their ids.
``deleted_ids`` come from the merged sync log (a tombstone written on the
phone reaches it) and the local tombstone ledger is consulted too.
"""
path = _ledger_path(path)
ledger = _load(path)
if not ledger:
return []
gone = {*deleted_ids, *tombstone_records().keys(), *_logged_ids(log_file)}
settled = [rid for rid in ledger if rid in gone]
for rid in settled:
del ledger[rid]
if settled:
_save(path, ledger)
_logger.info("Pending manual workout(s) settled and dropped: %s", settled)
return settled
def pending_future_dates(
log_file: Path,
records: Iterable[tuple[str, Mapping[str, object]]],
*,
today: str | None = None,
) -> dict[str, str]:
"""Return ``{record_id: date}`` of manual records dated AFTER today.
``records`` is the merged Firebase manual pull and/or the local ledger
(the same record id in both counts once). Ids already in ``log.json`` are
not pending. Records dated today or earlier are not pending either: they
were ingested or rejected, and a budget-skipped old record must not keep
occupying a slot for everyone else.
"""
reference = today or today_str()
logged = _logged_ids(log_file)
pending: dict[str, str] = {}
for record_id, payload in records:
day = payload.get("date")
if (
payload.get("kind") == MANUAL_WORKOUT_SYNC_KIND
and isinstance(day, str)
and parse_workout_date(day) is not None
and day > reference
and record_id not in logged
):
pending[record_id] = day
return pending

View File

@ -1,7 +1,7 @@
"""Push the PC's workouts to the shared sync repo.
"""Push the PC's workouts to the shared Firebase sync store.
``log.json`` is the single source of truth: this module derives the
crdt-sync log directly from it and pushes to ``devices/pc/log.json``, so the
crdt-sync log directly from it and pushes to this device's log, so the
phone converges on the SAME history the PC has — manual workouts *and*
machine-verified ones (StrongLifts sessions, RunnerUp runs).
@ -10,11 +10,11 @@ workout is published automatically no matter when or how it was recorded,
including entries logged before this module existed. Idempotence comes from two
properties: the record id is the workout's own stable ``workout_id``, and its
HLC is derived deterministically from the entry's own timestamp — so re-pushing
an unchanged log produces a byte-identical record set and no repo churn.
an unchanged log produces a byte-identical record set and no store churn.
This reverses the old "the PC only ever reads, never pushes" invariant (it had
no data of its own to contribute). Pushing needs a sync token with
**contents:write** on the repo — a read-only token 403s. Nothing here fails
no data of its own to contribute). Firebase is the only transport since
2026-10-09; the old GitHub mirror is a frozen archive. Nothing here fails
silently: every path returns a :class:`PushResult` whose ``reason`` says exactly
what happened, and logs it.
"""
@ -29,32 +29,24 @@ from typing import TYPE_CHECKING
from crdt_sync import (
FileSyncStateStore,
GitHubSyncClient,
GitHubSyncError,
Hlc,
LogCodec,
Record,
RemoteSyncError,
RepoNotFoundError,
RevisionTracking,
SyncTarget,
sync_log,
)
from screen_locker._constants import (
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_STATE_FILE,
SYNC_TIMEOUT_SECONDS,
SYNC_TOKEN_FILE,
)
from screen_locker._constants import SYNC_STATE_FILE
from screen_locker._device import device_identity
from screen_locker._log_io import load_workout_log
from screen_locker._log_mixin import _derive_workout_id
from screen_locker._push_outcome import describe_push
from screen_locker._manual_pending import pending_ledger_records, settle_pending
from screen_locker._sync_retry import with_sync_retry
from screen_locker._sync_tombstones import tombstone_records
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES
from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token, remote_client
from screen_locker._workout_sync import _DEVICES_PREFIX, sync_client_or_reason
if TYPE_CHECKING:
from pathlib import Path
@ -157,6 +149,14 @@ def records_from_workout_log(log_file: Path) -> dict[str, Record]:
log[str(record_id)] = Record(
id=str(record_id), fields={_PAYLOAD_FIELD: (payload, hlc)}
)
# Future-dated manual workouts logged here are not in log.json until their
# day; the ledger is what keeps them published (and visible to the phone's
# budget and delete button). The log's own copy wins once ingested.
for record_id, record in pending_ledger_records().items():
log.setdefault(record_id, record)
# Re-pushed every tick: sync_log never reads this device's own remote
# log, so a tombstone written once is overwritten by the next push.
log.update(tombstone_records())
return log
@ -168,34 +168,22 @@ def push_pc_workouts(log_file: Path) -> PushResult:
the returned :class:`PushResult` and a WARNING say why a push did not happen.
"""
identity = device_identity()
token = read_sync_token()
if token is None:
reason = f"no sync token at {SYNC_TOKEN_FILE}"
_logger.warning(
"Workouts NOT synced: %s — create a fine-grained GitHub PAT with "
"contents:write on %s/%s and save it there (chmod 600)",
reason,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
)
return PushResult(pushed=False, record_count=0, reason=reason)
log = records_from_workout_log(log_file)
if not log:
reason = f"no counted workouts in {log_file}"
_logger.warning("Workouts NOT synced: %s", reason)
return PushResult(pushed=False, record_count=0, reason=reason)
client = remote_client(
GitHubSyncClient(
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
client, reason = sync_client_or_reason()
if client is None:
# sync_client_or_reason has already said why, in full; this line
# ties that cause to the records it just stranded.
_logger.warning(
"Workouts NOT synced: %d workout(s) stay local — %s", len(log), reason
)
)
return PushResult(pushed=False, record_count=len(log), reason=reason)
try:
with_sync_retry(
merged = with_sync_retry(
lambda: sync_log(
SyncTarget(
client=client,
@ -214,32 +202,17 @@ def push_pc_workouts(log_file: Path) -> PushResult:
),
description="push PC workouts",
)
except (GitHubSyncError, RemoteSyncError) as exc:
except RemoteSyncError as exc:
# Report what actually happened, nothing more: this used to assert
# "a 403 means the token lacks contents:write" for every failure,
# including a plain network error, which sent a 2026-07-20
# investigation chasing a permissions bug that did not exist.
reason = f"sync error: {exc}"
# Report what actually happened. This used to assert "a 403 here means
# the token lacks contents:write" for *every* failure, including a
# plain network error — which sent a 2026-07-20 investigation chasing a
# permissions bug that did not exist (the token could write fine; the
# network was simply not up yet). Only RepoNotFoundError actually
# implicates the repo or the token.
if isinstance(exc, RepoNotFoundError):
_logger.warning(
"Workout sync push FAILED for %d workout(s): %s — check the "
"token has contents:write on %s/%s",
len(log),
exc,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
)
else:
_logger.warning(
"Workout sync push FAILED for %d workout(s): %s",
len(log),
exc,
)
_logger.warning("Workout sync push FAILED for %d workout(s): %s", len(log), exc)
return PushResult(pushed=False, record_count=len(log), reason=reason)
# Delegated so a half-landed push can never be logged as a clean one;
# see _push_outcome for why that distinction is load-bearing.
_, reason = describe_push(len(log))
return PushResult(pushed=True, record_count=len(log), reason=reason)
# A future entry deleted on the phone arrives as a tombstone in the merge;
# one already ingested is in log.json. Either way it leaves the ledger.
settle_pending(log_file, [rid for rid, record in merged.items() if record.deleted])
_logger.info("Synced %d workout(s) to Firebase", len(log))
return PushResult(pushed=True, record_count=len(log), reason="pushed")

View File

@ -0,0 +1,115 @@
"""Log a manual workout for a FUTURE day from this PC (Tk dialog and CLI).
A future-dated entry never touches ``log.json`` or ``write_signed_entry`` and
earns nothing today. It is held in the pending ledger (``_manual_pending``),
published to this device's sync log so the phone sees it (budget, delete), and
ingested on its date by the ordinary sync pass, which pays exactly what a
workout logged that day would.
This module is the one place that decides whether such an entry may be logged:
the date window and the per-date budget, over the credits in ``log.json`` plus
every pending future entry -- this PC's ledger AND the merged Firebase manual
records (a phone-logged one holds a slot too).
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from screen_locker._device import device_identity
from screen_locker._manual_future import (
budget_error_for_date,
is_future_date,
validate_workout_date,
)
from screen_locker._manual_pending import (
add_pending,
local_pending_records,
pending_future_dates,
)
from screen_locker._manual_push import push_pc_workouts
from screen_locker._manual_workout import build_sync_payload, manual_sync_record_id
from screen_locker._workout_sync import pull_all_manual_records
if TYPE_CHECKING:
from pathlib import Path
from screen_locker._manual_workout import ManualWorkoutDraft
__all__ = ["ScheduleResult", "budget_error", "schedule_future_workout"]
_logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ScheduleResult:
"""Outcome of :func:`schedule_future_workout`; ``reason`` is for a human."""
scheduled: bool
record_id: str
reason: str
def budget_error(
log_file: Path, workout_date: str, *, exclude_id: str | None = None
) -> str | None:
"""Why a manual workout on ``workout_date`` does not fit the budget, else None.
Reads the pending future entries from this PC's ledger and from the merged
Firebase pull. The pull is best-effort (``pull_all_manual_records`` warns
and returns nothing when Firebase is unreachable), so offline this still
sees the ledger -- only a phone-logged pending entry can be missed, and the
ingest on that day re-checks.
"""
records = [*pull_all_manual_records(), *local_pending_records()]
pending = pending_future_dates(log_file, records)
if exclude_id is not None:
pending.pop(exclude_id, None)
return budget_error_for_date(log_file, workout_date, pending.values())
def schedule_future_workout(
log_file: Path, draft: ManualWorkoutDraft, workout_date: str
) -> ScheduleResult:
"""Hold a validated draft for ``workout_date`` and publish it.
The caller has run ``validate_manual_workout`` on ``draft``. Refuses (and
says why) a date outside today+1..today+7, an exhausted budget, or an
entry already scheduled under the same id; otherwise stores it in the
pending ledger and pushes immediately, so the phone sees it now rather
than on the next 15-minute tick.
"""
record_id = manual_sync_record_id(workout_date, draft.start_time)
date_error = validate_workout_date(workout_date)
if date_error is None and not is_future_date(workout_date):
date_error = f"{workout_date} is today, not a future date"
if date_error is not None:
return ScheduleResult(scheduled=False, record_id=record_id, reason=date_error)
over_budget = budget_error(log_file, workout_date)
if over_budget is not None:
return ScheduleResult(scheduled=False, record_id=record_id, reason=over_budget)
payload = build_sync_payload(draft, workout_date)
if not add_pending(record_id, payload, device_identity().device_id):
return ScheduleResult(
scheduled=False,
record_id=record_id,
reason=f"{record_id} is already scheduled",
)
pushed = push_pc_workouts(log_file)
_logger.info(
"Scheduled manual workout %s for %s (published=%s: %s)",
record_id,
workout_date,
pushed.pushed,
pushed.reason,
)
if pushed.pushed:
reason = f"scheduled for {workout_date}; counts automatically that day"
else:
reason = (
f"scheduled for {workout_date} on this PC (not yet published: "
f"{pushed.reason}); it counts automatically that day"
)
return ScheduleResult(scheduled=True, record_id=record_id, reason=reason)

View File

@ -5,7 +5,7 @@ from elsewhere) arrives via crdt-sync as a ``kind="manual_workout"`` payload
(see :func:`screen_locker._manual_workout.build_sync_payload`). This module
turns those synced payloads into ordinary signed ``manual_workout`` entries in
``log.json`` so they count toward the weekly minimum and — via the
optional ``on_ingested`` callback — earn the same shutdown/debt reward a
optional ``on_ingested`` callback — earn the same shutdown reward a
live-logged workout would.
Trust model: the PC never trusts the phone's derived fields. Every synced
@ -21,14 +21,23 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from screen_locker._day import today_str
from screen_locker._log_io import load_workout_log
from screen_locker._log_mixin import write_signed_entry
from screen_locker._log_mixin import _entry_workout_id, write_signed_entry
from screen_locker._manual_future import (
budget_error_for_date,
is_future_date,
parse_workout_date,
)
from screen_locker._manual_pending import pending_future_dates
from screen_locker._manual_sync_draft import (
_draft_or_report,
is_empty_stub,
reconstruct_draft,
)
from screen_locker._manual_workout import (
MANUAL_WORKOUT_SYNC_KIND,
SPORT_OTHER,
ManualWorkoutDraft,
build_entry,
is_budget_exhausted,
validate_manual_workout,
)
@ -36,142 +45,36 @@ if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Mapping
from pathlib import Path
from screen_locker._log_mixin import RecordResult
from screen_locker._manual_workout import ManualWorkoutDraft
OnIngestedCallback = Callable[[dict, "list[dict]"], None]
__all__ = ["ingest_manual_records", "is_empty_stub", "reconstruct_draft"]
_logger = logging.getLogger(__name__)
# Key under which the source sync record id is stored in the ingested
# ``workout_data`` — makes re-ingestion of the same record idempotent.
_SYNC_ID_FIELD = "sync_record_id"
# The raw user inputs every real manual workout carries. A payload holding none
# of them is a metadata stub, not a damaged workout: there is nothing to
# recover and no amount of retrying will change that.
_SUBSTANTIVE_FIELDS = frozenset(
{
"sport",
"start_time",
"end_time",
"location_name",
"transport_method",
"cost",
"rpe",
"went_well",
"to_improve",
"overall_feeling",
}
)
def is_empty_stub(payload: Mapping[str, object]) -> bool:
"""True when ``payload`` carries no workout content at all.
Observed in the wild as ``{"type", "kind", "date"}`` and nothing else --
a record the phone created without ever attaching the workout. Every sync
cycle re-reported it as malformed, so a permanent, unfixable condition
produced an unbounded stream of identical warnings (four every 15 minutes),
which is how the genuinely actionable lines got lost in the noise.
"""
return not (_SUBSTANTIVE_FIELDS & set(payload))
def _coerce_int(value: object) -> int:
"""Coerce a JSON scalar to int; raise for a non-numeric (skips the record)."""
if isinstance(value, (int, str)):
return int(value)
raise TypeError(value)
def reconstruct_draft(payload: Mapping[str, object]) -> ManualWorkoutDraft | None:
"""Rebuild a :class:`ManualWorkoutDraft` from a synced manual payload.
Returns None if a required raw field is missing or mistyped, so a malformed
record is skipped rather than crashing ingestion. Only the raw user inputs
are read back — the derived fields (``source``, ``duration_minutes``,
``type``) are recomputed by :func:`build_entry` on the PC.
"""
try:
sport = str(payload["sport"])
activity_type_other = (
str(payload.get("activity_type", "")) if sport == SPORT_OTHER else ""
)
return ManualWorkoutDraft(
sport=sport,
start_time=str(payload["start_time"]),
end_time=str(payload["end_time"]),
location_name=str(payload["location_name"]),
transport_method=str(payload["transport_method"]),
cost=str(payload["cost"]),
rpe=_coerce_int(payload["rpe"]),
went_well=str(payload["went_well"]),
to_improve=str(payload["to_improve"]),
overall_feeling=str(payload["overall_feeling"]),
reservation_phone=str(payload.get("reservation_phone", "")),
techniques_practiced=str(payload.get("techniques_practiced", "")),
warm_up_minutes=str(payload.get("warm_up_minutes", "")),
pain_or_injury=str(payload.get("pain_or_injury", "none")),
matches_won=_coerce_int(payload.get("matches_won", 0)),
matches_lost=_coerce_int(payload.get("matches_lost", 0)),
sets_won=_coerce_int(payload.get("sets_won", 0)),
sets_lost=_coerce_int(payload.get("sets_lost", 0)),
racket=str(payload.get("racket", "")),
balls=str(payload.get("balls", "")),
activity_type_other=activity_type_other,
activity_details=str(payload.get("activity_details", "")),
equipment=str(payload.get("equipment", "")),
)
except (KeyError, TypeError, ValueError) as exc:
_logger.warning(
"Synced manual workout payload is malformed (%s: %s) — SKIPPING "
"this record, so it will not be logged or counted on the PC",
type(exc).__name__,
exc,
)
return None
def _draft_or_report(
record_id: str, payload: Mapping[str, object]
) -> ManualWorkoutDraft | None:
"""Rebuild the draft, or report why it cannot be rebuilt and return None.
The two failure modes are deliberately reported at different levels, because
only one of them is actionable:
* an **empty stub** carries no workout at all (seen as ``{type, kind,
date}``) -- nothing to recover, nothing to fix, so re-reporting it at
``warning`` on every 15-minute sync cycle only teaches the reader to
ignore this logger;
* a **malformed** payload has real workout fields that would not parse,
which means genuine credit may be going missing -- that stays loud.
"""
if is_empty_stub(payload):
_logger.info(
"Manual record %s is an empty stub (keys: %s) — no workout data to "
"ingest; skipping permanently, no credit is being lost",
record_id,
sorted(payload),
)
return None
draft = reconstruct_draft(payload)
if draft is None:
_logger.warning(
"Manual record %s is malformed — it HAS workout fields but they "
"could not be parsed, so real credit may be lost",
record_id,
)
return draft
def _already_ingested(logs: dict[str, list[dict]], record_id: str) -> bool:
"""True if any logged entry already carries this sync record id.
"""True if any logged entry already IS this sync record.
Iterates the per-day lists (a day may hold several workouts); a cheap
early-out before reconstructing the draft. The write chokepoint also dedups
by ``workout_id``, so this and that are two guards on the same idempotency.
Matches either way a record can already be here: an entry ingested from
sync carries the id in ``sync_record_id``; an entry logged on this PC
(form or CLI) and then published carries it as its own ``workout_id``,
since the publisher keys records on exactly that. Checking only the first
is how the 2026-09-13 football was re-ingested: it had been logged locally
(no ``sync_record_id``), pushed as ``manual:2026-09-12T14:30``, and pulled
back as a "new" record. A cheap early-out before reconstructing the draft;
the write chokepoint dedups by ``workout_id`` as a second guard.
"""
for entries in logs.values():
for day, entries in logs.items():
for entry in entries:
if _entry_workout_id(day, entry) == record_id:
return True
workout_data = entry.get("workout_data", {})
if (
isinstance(workout_data, dict)
@ -181,6 +84,108 @@ def _already_ingested(logs: dict[str, list[dict]], record_id: str) -> bool:
return False
def _due_date(
record_id: str, payload: Mapping[str, object], reference: str
) -> str | None:
"""Return the record's date if it is a manual record due for ingest, else None.
None covers every reason to leave it alone this pass: not a manual record,
no/unparsable date (warned), or dated after ``reference`` -- deferred, and
retried on every later pass so it lands on its date.
"""
if payload.get("kind") != MANUAL_WORKOUT_SYNC_KIND:
return None
date = payload.get("date")
if not isinstance(date, str):
_logger.warning("Manual record %s has no date — skipped", record_id)
return None
if parse_workout_date(date) is None:
_logger.warning(
"Manual record %s has an unparsable date %r — skipped", record_id, date
)
return None
if is_future_date(date, today=reference):
_logger.info(
"Manual record %s is dated %s, after today (%s) — deferred; it "
"is ingested and credited on its date",
record_id,
date,
reference,
)
return None
return date
def _new_valid_draft(
log_file: Path,
record_id: str,
payload: Mapping[str, object],
reference: str,
) -> tuple[str, ManualWorkoutDraft] | None:
"""The due date and valid draft of a not-yet-ingested record, else ``None``."""
date = _due_date(record_id, payload, reference)
if date is None:
return None
if _already_ingested(load_workout_log(log_file), record_id):
return None
draft = _draft_or_report(record_id, payload)
if draft is None:
return None
error = validate_manual_workout(draft)
if error is not None:
_logger.warning("Manual record %s invalid: %s", record_id, error)
return None
return date, draft
def _ingest_one(
log_file: Path,
record_id: str,
payload: Mapping[str, object],
reference: str,
pending: Mapping[str, str],
) -> tuple[dict[str, object], RecordResult] | None:
"""Validate, budget-check and append one record; ``None`` when skipped."""
ready = _new_valid_draft(log_file, record_id, payload, reference)
if ready is None:
return None
date, draft = ready
over_budget = budget_error_for_date(
log_file,
date,
[day for rid, day in pending.items() if rid != record_id],
)
if over_budget is not None:
_logger.info(
"Manual-workout budget exhausted — %s not ingested (%s)",
record_id,
over_budget,
)
return None
entry = build_entry(draft)
entry[_SYNC_ID_FIELD] = record_id
# The wire id IS the workout's id: the device that logged it minted
# ``manual:<date>T<start>`` once, and the publisher keys on the same
# id. Without this the chokepoint re-derived one from ``date`` +
# start, and when the two dates disagreed (a record whose day key was
# later corrected to the local day) the same workout landed twice
# under two ids -- and then consumed two manual-budget slots.
entry["workout_id"] = record_id
result = write_signed_entry(log_file, date, entry)
if not result.appended:
# This is dedup working, not credit being lost: the workout is
# already in the log under this same workout_id. It was logged at
# warning and re-reported on every 15-minute cycle, which read like
# a recurring fault and buried the lines that did need attention.
_logger.info(
"Manual record %s is already logged under the same workout_id "
"— skipped as a duplicate (its credit is already counted)",
record_id,
)
return None
return entry, result
def ingest_manual_records(
log_file: Path,
records: Iterable[tuple[str, Mapping[str, object]]],
@ -196,51 +201,39 @@ def ingest_manual_records(
workouts). Idempotent: dedup by ``record_id`` here and by ``workout_id`` in
the write chokepoint, so re-syncing the same record is a no-op.
A record dated AFTER ``today`` (default: the local day) is DEFERRED, not
ingested: it stays in the sync store, earns nothing and is not written to
``log.json`` (the weekly check, the budget, the status view and the
shutdown derivation would all count a future day key early). It is retried
on every later pass, so it is ingested -- and credited -- on its date.
The budget is counted against the workout's own date: the new entry fits
only if every rolling window containing that date stays within its cap,
over the credits in the log and every OTHER pending future record in
``records`` (a future entry holds its slot from the moment it is logged).
When ``on_ingested`` is given, it's called ``on_ingested(entry, prior_entries)``
right after each newly-appended record — ``prior_entries`` being that
entry's own date's entries written before it — so the caller (see
``screen_lock.ScreenLocker._ingest_synced_manual_workouts``) can apply the
identical live-workout shutdown/debt reward. This applies regardless of
identical live-workout shutdown reward. This applies regardless of
whether ``date`` is today or back-dated: there's only one current shutdown
config, so a back-dated sync still pushes it exactly as a live workout
would have that day.
Returns the record ids actually ingested.
"""
batch = list(records)
if not batch:
return []
reference = today or today_str()
pending = pending_future_dates(log_file, batch, today=reference)
ingested: list[str] = []
for record_id, payload in records:
if payload.get("kind") != MANUAL_WORKOUT_SYNC_KIND:
continue
date = payload.get("date")
if not isinstance(date, str):
_logger.warning("Manual record %s has no date — skipped", record_id)
continue
if _already_ingested(load_workout_log(log_file), record_id):
continue
draft = _draft_or_report(record_id, payload)
if draft is None:
continue
error = validate_manual_workout(draft)
if error is not None:
_logger.warning("Manual record %s invalid: %s", record_id, error)
continue
if is_budget_exhausted(log_file, today=today):
_logger.info("Manual-workout budget exhausted — %s not ingested", record_id)
continue
entry = build_entry(draft)
entry[_SYNC_ID_FIELD] = record_id
result = write_signed_entry(log_file, date, entry)
if not result.appended:
# This is dedup working, not credit being lost: the workout is
# already in the log under this same workout_id. It was logged at
# warning and re-reported on every 15-minute cycle, which read like
# a recurring fault and buried the lines that did need attention.
_logger.info(
"Manual record %s is already logged under the same workout_id "
"— skipped as a duplicate (its credit is already counted)",
record_id,
)
for record_id, payload in batch:
outcome = _ingest_one(log_file, record_id, payload, reference, pending)
if outcome is None:
continue
entry, result = outcome
ingested.append(record_id)
if on_ingested is not None:
on_ingested(entry, result.prior_entries)

View File

@ -0,0 +1,140 @@
"""Rebuild a :class:`ManualWorkoutDraft` from a synced manual-workout payload.
Split out of :mod:`screen_locker._manual_sync` to keep every file under the
250-line cap; that module re-exports :func:`reconstruct_draft` and
:func:`is_empty_stub`, so callers and patch targets are unchanged.
Only the raw user inputs are read back — the derived fields (``source``,
``duration_minutes``, ``type``) are recomputed on the PC by ``build_entry``,
which is what keeps a phone from ever dictating what a workout is worth.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from screen_locker._manual_workout import SPORT_OTHER, ManualWorkoutDraft
if TYPE_CHECKING:
from collections.abc import Mapping
_logger = logging.getLogger(__name__)
# The raw user inputs every real manual workout carries. A payload holding none
# of them is a metadata stub, not a damaged workout: there is nothing to
# recover and no amount of retrying will change that.
_SUBSTANTIVE_FIELDS = frozenset(
{
"sport",
"start_time",
"end_time",
"location_name",
"transport_method",
"cost",
"rpe",
"went_well",
"to_improve",
"overall_feeling",
}
)
def is_empty_stub(payload: Mapping[str, object]) -> bool:
"""True when ``payload`` carries no workout content at all.
Observed in the wild as ``{"type", "kind", "date"}`` and nothing else --
a record the phone created without ever attaching the workout. Every sync
cycle re-reported it as malformed, so a permanent, unfixable condition
produced an unbounded stream of identical warnings (four every 15 minutes),
which is how the genuinely actionable lines got lost in the noise.
"""
return not _SUBSTANTIVE_FIELDS & set(payload)
def _coerce_int(value: object) -> int:
"""Coerce a JSON scalar to int; raise for a non-numeric (skips the record)."""
if isinstance(value, (int, str)):
return int(value)
raise TypeError(value)
def reconstruct_draft(payload: Mapping[str, object]) -> ManualWorkoutDraft | None:
"""Rebuild a :class:`ManualWorkoutDraft` from a synced manual payload.
Returns None if a required raw field is missing or mistyped, so a malformed
record is skipped rather than crashing ingestion. Only the raw user inputs
are read back — the derived fields (``source``, ``duration_minutes``,
``type``) are recomputed by :func:`build_entry` on the PC.
"""
try:
sport = str(payload["sport"])
activity_type_other = (
str(payload.get("activity_type", "")) if sport == SPORT_OTHER else ""
)
return ManualWorkoutDraft(
sport=sport,
start_time=str(payload["start_time"]),
end_time=str(payload["end_time"]),
location_name=str(payload["location_name"]),
transport_method=str(payload["transport_method"]),
cost=str(payload["cost"]),
rpe=_coerce_int(payload["rpe"]),
went_well=str(payload["went_well"]),
to_improve=str(payload["to_improve"]),
overall_feeling=str(payload["overall_feeling"]),
reservation_phone=str(payload.get("reservation_phone", "")),
techniques_practiced=str(payload.get("techniques_practiced", "")),
warm_up_minutes=str(payload.get("warm_up_minutes", "")),
pain_or_injury=str(payload.get("pain_or_injury", "none")),
matches_won=_coerce_int(payload.get("matches_won", 0)),
matches_lost=_coerce_int(payload.get("matches_lost", 0)),
sets_won=_coerce_int(payload.get("sets_won", 0)),
sets_lost=_coerce_int(payload.get("sets_lost", 0)),
racket=str(payload.get("racket", "")),
balls=str(payload.get("balls", "")),
activity_type_other=activity_type_other,
activity_details=str(payload.get("activity_details", "")),
equipment=str(payload.get("equipment", "")),
)
except (KeyError, TypeError, ValueError) as exc:
_logger.warning(
"Synced manual workout payload is malformed (%s: %s) — SKIPPING "
"this record, so it will not be logged or counted on the PC",
type(exc).__name__,
exc,
)
return None
def _draft_or_report(
record_id: str, payload: Mapping[str, object]
) -> ManualWorkoutDraft | None:
"""Rebuild the draft, or report why it cannot be rebuilt and return None.
The two failure modes are deliberately reported at different levels, because
only one of them is actionable:
* an **empty stub** carries no workout at all (seen as ``{type, kind,
date}``) -- nothing to recover, nothing to fix, so re-reporting it at
``warning`` on every 15-minute sync cycle only teaches the reader to
ignore this logger;
* a **malformed** payload has real workout fields that would not parse,
which means genuine credit may be going missing -- that stays loud.
"""
if is_empty_stub(payload):
_logger.info(
"Manual record %s is an empty stub (keys: %s) — no workout data to "
"ingest; skipping permanently, no credit is being lost",
record_id,
sorted(payload),
)
return None
draft = reconstruct_draft(payload)
if draft is None:
_logger.warning(
"Manual record %s is malformed — it HAS workout fields but they "
"could not be parsed, so real credit may be lost",
record_id,
)
return draft

View File

@ -1,7 +1,7 @@
"""Manual (unverified) workout rate-limiting, validation, and entry-building.
Pure logic — no Tk imports. Unlike sick days, manual-workout entries live
directly in ``log.json`` (not a separate history file): a manual
Pure logic — no Tk imports. Manual-workout entries live directly in
``log.json`` (not a separate history file): a manual
entry is just another ``workout_data`` dict with ``type="manual_workout"``,
so ``LogMixin.save_workout_log`` already HMAC-signs it for free and the
weekly-minimum/bonus logic already counts it once wired into
@ -20,12 +20,15 @@ import logging
from screen_locker._constants import (
MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS,
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
MANUAL_WORKOUT_RPE_MAX,
MANUAL_WORKOUT_RPE_MIN,
MIN_WORKOUT_DURATION_MINUTES,
WORKOUT_DURATION_ACCEPT_MINUTES,
)
from screen_locker._manual_workout_checks import (
validate_reflections,
validate_required,
)
from screen_locker._manual_workout_types import (
MANUAL_WORKOUT_TYPE,
SPORT_CHOICES,
@ -82,19 +85,9 @@ def _duration_minutes(draft: ManualWorkoutDraft) -> float | None:
def validate_manual_workout(draft: ManualWorkoutDraft) -> str | None:
"""Return an error message if the draft is invalid, else None."""
if draft.sport not in SPORT_CHOICES:
return "Please choose a sport"
required = {
"Start time": draft.start_time,
"End time": draft.end_time,
"Location name": draft.location_name,
"Transport method": draft.transport_method,
"Cost": draft.cost,
}
for label, value in required.items():
if not value.strip():
return f"{label} is required"
error = validate_required(draft)
if error is not None:
return error
duration = _duration_minutes(draft)
if duration is None:
@ -119,18 +112,7 @@ def validate_manual_workout(draft: ManualWorkoutDraft) -> str | None:
if error is not None:
return error
for label, value in (
("What went well", draft.went_well),
("What to improve", draft.to_improve),
("Overall feeling", draft.overall_feeling),
):
if len(value.strip()) < MANUAL_WORKOUT_REFLECTION_MIN_CHARS:
return (
f"{label} must be at least {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} "
f"characters (currently {len(value.strip())})"
)
return None
return validate_reflections(draft)
def _validate_table_tennis(draft: ManualWorkoutDraft) -> str | None:
@ -143,7 +125,7 @@ def _validate_table_tennis(draft: ManualWorkoutDraft) -> str | None:
):
if value < 0:
return f"{label} cannot be negative"
if draft.matches_won + draft.matches_lost == 0:
if not draft.matches_won + draft.matches_lost:
return "Enter at least one match played (won + lost)"
if not draft.racket.strip():
return "Racket is required"

View File

@ -0,0 +1,44 @@
"""Field-level checks shared by ``validate_manual_workout``.
Split out of :mod:`screen_locker._manual_workout` to keep every file under the
250-line cap.
"""
from __future__ import annotations
from screen_locker._constants import MANUAL_WORKOUT_REFLECTION_MIN_CHARS
from screen_locker._manual_workout_types import SPORT_CHOICES, ManualWorkoutDraft
def validate_required(draft: ManualWorkoutDraft) -> str | None:
"""Return an error if the sport is unchosen or a required field is blank."""
if draft.sport not in SPORT_CHOICES:
return "Please choose a sport"
required = {
"Start time": draft.start_time,
"End time": draft.end_time,
"Location name": draft.location_name,
"Transport method": draft.transport_method,
"Cost": draft.cost,
}
for label, value in required.items():
if not value.strip():
return f"{label} is required"
return None
def validate_reflections(draft: ManualWorkoutDraft) -> str | None:
"""Return an error if a reflection field is shorter than the minimum."""
for label, value in (
("What went well", draft.went_well),
("What to improve", draft.to_improve),
("Overall feeling", draft.overall_feeling),
):
if len(value.strip()) < MANUAL_WORKOUT_REFLECTION_MIN_CHARS:
return (
f"{label} must be at least {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} "
f"characters (currently {len(value.strip())})"
)
return None

View File

@ -0,0 +1,117 @@
"""Workout-date field of the manual-workout form, and the future-dated path.
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 hosts (``ScreenLocker`` and ``StatusWindow``) are unchanged.
A manual workout may be dated today up to 7 days ahead. Today's takes the
host's ordinary save path (which, on the lock screen, unlocks). A FUTURE one
must not: it earns nothing today, so it is handed to ``_manual_schedule`` and
the form shows a "scheduled" screen that returns to the host's cancelled hook
-- never ``_on_manual_workout_saved``, which would unlock today.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING
from screen_locker._day import today_str
from screen_locker._manual_future import (
budget_closed_for_all_days,
is_future_date,
validate_workout_date,
)
from screen_locker._manual_pending import local_pending_records, pending_future_dates
from screen_locker._manual_schedule import budget_error, schedule_future_workout
from screen_locker._ui_widgets import disable_paste
if TYPE_CHECKING:
from screen_locker._manual_workout import ManualWorkoutDraft
class ManualWorkoutDateMixin:
"""Date row, budget gate and future-dated submission of the manual form."""
def _mw_budget_closed(self) -> bool:
"""True if no day in today..today+7 has room (local ledger, no network)."""
pending = pending_future_dates(self.log_file, local_pending_records())
return budget_closed_for_all_days(self.log_file, pending.values())
def _mw_date_in_row(self, row: tk.Frame) -> None:
"""Pack the workout-date entry (prefilled with today) into ``row``.
Called from the sport row rather than given a row of its own: the
lock cannot scroll, and the form already fills a 768px panel
(``scripts/verify_screen_fits.py``), so the field has to cost no height.
"""
var = tk.StringVar(value=today_str())
self._mw_vars["date"] = var
entry = tk.Entry(
row,
textvariable=var,
width=11,
font=self._colors.font("label"),
bg=self._colors.palette.field_bg,
fg=self._colors.palette.fg,
insertbackground=self._colors.palette.fg,
**self._colors.focus_kwargs(),
)
entry.pack(side="right", padx=self._colors.space("xs"))
disable_paste(entry)
tk.Label(
row,
text="Workout date (today to +7 days):",
font=self._colors.font("label"),
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
).pack(side="right", padx=self._colors.space("xs"))
def _mw_handled_by_date(self, draft: ManualWorkoutDraft) -> bool:
"""Apply the date and budget rules; True if the submit is fully handled.
Handled means an error is on screen or a future entry was scheduled;
False means the date is today and within budget, so the caller
carries on with the host's ordinary save.
"""
date = self._mw_vars["date"].get().strip()
error = validate_workout_date(date)
future = error is None and is_future_date(date)
if error is None and not future:
error = budget_error(self.log_file, date)
if error is not None:
self._mw_error_label.config(text=error)
return True
if not future:
return False
result = schedule_future_workout(self.log_file, draft, date)
if not result.scheduled:
self._mw_error_label.config(text=result.reason)
return True
self._show_manual_workout_scheduled(result.reason)
return True
def _show_manual_workout_scheduled(self, reason: str) -> None:
"""Confirm a future entry and go back; it does NOT unlock anything."""
self.clear_container()
self._label(
"✓ Workout Scheduled",
role="display",
scale=1.3,
color=self._colors.palette.success,
pad="lg",
)
self._text(reason, role="subtitle", color=self._colors.palette.success)
self._text(
"Nothing is credited today. It counts by itself on that date, and "
"can be deleted in the phone app until then.",
color=self._colors.palette.muted,
)
row = self._button_row()
self._button(
row,
"OK",
bg=self._colors.palette.accent,
command=self._on_manual_workout_cancelled,
width=12,
).pack(side="left", padx=self._colors.space("sm"))

View File

@ -23,6 +23,7 @@ from screen_locker import _manual_workout
from screen_locker._constants import (
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
)
from screen_locker._manual_workout_date import ManualWorkoutDateMixin
from screen_locker._manual_workout_sport_fields import ManualWorkoutSportFieldsMixin
from screen_locker._manual_workout_widgets import ManualWorkoutFormWidgetsMixin
@ -34,6 +35,7 @@ _SPORT_LABEL_TO_CODE = {
class ManualWorkoutDialogMixin(
ManualWorkoutDateMixin,
ManualWorkoutFormWidgetsMixin,
ManualWorkoutSportFieldsMixin,
):
@ -42,21 +44,21 @@ class ManualWorkoutDialogMixin(
def _show_manual_workout_form(self) -> None:
"""Render the manual-workout evidence form, or a budget-exhausted note."""
self.clear_container()
self._label("Log Manual Workout", color=self._colors.accent, pad="xs")
if _manual_workout.is_budget_exhausted(self.log_file):
self._label("Log Manual Workout", color=self._colors.palette.accent, pad="xs")
if self._mw_budget_closed():
self._text(
"Manual-workout budget exhausted for this window.",
color=self._colors.danger,
color=self._colors.palette.danger,
)
self._text(
_manual_workout.budget_summary(self.log_file),
color=self._colors.muted,
color=self._colors.palette.muted,
)
row = self._button_row()
self._button(
row,
"BACK",
bg=self._colors.field_bg,
bg=self._colors.palette.field_bg,
command=self._on_manual_workout_cancelled,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
@ -64,7 +66,7 @@ class ManualWorkoutDialogMixin(
self._text(
_manual_workout.budget_summary(self.log_file),
role="label",
color=self._colors.accent,
color=self._colors.palette.accent,
pad="xs",
)
self._build_manual_workout_form()
@ -106,8 +108,8 @@ class ManualWorkoutDialogMixin(
# Both sport sub-frames occupy the same full-width grid slot; only the
# selected one is shown (see _on_mw_sport_changed).
act_row = self._mw_next_full_row(form)
self._mw_tt_frame = tk.Frame(form, bg=self._colors.bg)
self._mw_other_frame = tk.Frame(form, bg=self._colors.bg)
self._mw_tt_frame = tk.Frame(form, bg=self._colors.palette.bg)
self._mw_other_frame = tk.Frame(form, bg=self._colors.palette.bg)
for sport_frame in (self._mw_tt_frame, self._mw_other_frame):
sport_frame.grid_columnconfigure(0, weight=1, uniform="mwact")
sport_frame.grid_columnconfigure(1, weight=1, uniform="mwact")
@ -151,20 +153,20 @@ class ManualWorkoutDialogMixin(
)
self._mw_error_label = self._text(
"", role="label", color=self._colors.danger, pad="xs"
"", role="label", color=self._colors.palette.danger, pad="xs"
)
button_row = self._button_row()
self._button(
button_row,
"SUBMIT",
bg=self._colors.accent,
bg=self._colors.palette.accent,
command=self._submit_manual_workout_form,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
self._button(
button_row,
"BACK",
bg=self._colors.field_bg,
bg=self._colors.palette.field_bg,
command=self._on_manual_workout_cancelled,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
@ -212,6 +214,8 @@ class ManualWorkoutDialogMixin(
if error is not None:
self._mw_error_label.config(text=error)
return
if self._mw_handled_by_date(draft):
return
entry = _manual_workout.build_entry(draft)
# Record the manual for cross-device sync (published on the next lock
# startup). Local + best-effort — never blocks saving/unlocking.

View File

@ -48,13 +48,13 @@ class ManualWorkoutSportFieldsMixin:
self._mw_sport_var = tk.StringVar(
value=_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
)
row = tk.Frame(parent, bg=self._colors.bg)
row = tk.Frame(parent, bg=self._colors.palette.bg)
tk.Label(
row,
text="Sport:",
font=self._colors.font("body"),
fg=self._colors.fg,
bg=self._colors.bg,
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(
@ -65,16 +65,18 @@ class ManualWorkoutSportFieldsMixin:
# 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,
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.field_bg,
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:

View File

@ -19,7 +19,9 @@ from screen_locker._constants import (
MANUAL_WORKOUT_BUDGET_PER_7_DAYS,
MANUAL_WORKOUT_BUDGET_PER_30_DAYS,
)
from screen_locker._day import today_str
from screen_locker._log_io import load_workout_log
from screen_locker._weekly_check import credit_key, day_workout_index
if TYPE_CHECKING:
from pathlib import Path
@ -38,8 +40,8 @@ SPORT_LABELS: dict[str, str] = {
def _today_iso() -> str:
"""Return today's date as ``YYYY-MM-DD`` (UTC)."""
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
"""Return today's LOCAL date as ``YYYY-MM-DD`` (see ``screen_locker._day``)."""
return today_str()
def _parse_iso(date_str: str) -> datetime | None:
@ -56,36 +58,61 @@ def _parse_iso(date_str: str) -> datetime | None:
return None
def manual_credit_dates(log_file: Path) -> list[str]:
"""Return the ``YYYY-MM-DD`` of every manual-workout CREDIT in the log.
One element per workout, decided by
:func:`screen_locker._weekly_check.credit_key` -- the single rule every
counter shares -- so a synced copy of a workout already logged here is the
same credit, not a second budget slot (2026-09-13: a manual rekeyed to its
local day, then pulled back in under a fresh workout_id, consumed two
slots while the weekly count correctly saw one). Dates that do not parse
are warned about by :func:`_parse_iso` and left out.
"""
dates: list[str] = []
for date_str, entries in load_workout_log(log_file).items():
if _parse_iso(date_str) is None:
continue
siblings = day_workout_index(entries)
keys = {
key
for index, entry in enumerate(entries)
if (key := credit_key(date_str, index, entry, siblings)) is not None
and key[0] == MANUAL_WORKOUT_TYPE
}
dates.extend([date_str] * len(keys))
return dates
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.
"""Return how many manual-workout CREDITS fall in the trailing window.
Counted per entry, not per day: each logged manual workout consumes its
Counted per workout, 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.
and ``screen_locker._workout_credit``) -- the budget is the sole limiter on
how many you can log, not a once-per-day collapse. See
:func:`manual_credit_dates` for what "one workout" means.
The window is the ``days`` days ending at ``today`` (inclusive); entries
dated after today are NOT in it. Whether a new entry fits is a different
question, answered per workout date by ``_manual_future``.
"""
today_str = today or _today_iso()
today_dt = _parse_iso(today_str)
day = today or _today_iso()
today_dt = _parse_iso(day)
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
return sum(
1
for date_str in manual_credit_dates(log_file)
if (parsed := _parse_iso(date_str)) is not None and cutoff < parsed <= today_dt
)
def is_budget_exhausted(

View File

@ -72,8 +72,8 @@ class ManualWorkoutFormWidgetsMixin:
parent,
text=title,
font=self._colors.font("body", bold=True),
fg=self._colors.accent,
bg=self._colors.bg,
fg=self._colors.palette.accent,
bg=self._colors.palette.bg,
anchor="w",
)
self._mw_grid(parent, label, full=True)
@ -84,7 +84,7 @@ class ManualWorkoutFormWidgetsMixin:
"""Add a half-width label+entry cell and return its backing StringVar.
Builds its own cell inline (rather than reusing ``_add_label_entry``,
which is pack-based and shared with the sick dialog) so it slots into
which is pack-based) so it slots into
the two-column grid. Paste stays disabled via ``disable_paste``.
Args:
@ -95,7 +95,7 @@ class ManualWorkoutFormWidgetsMixin:
one field per form should ask.
"""
var = tk.StringVar()
cell = tk.Frame(parent, bg=self._colors.bg)
cell = tk.Frame(parent, bg=self._colors.palette.bg)
# Label beside the field, not above it. Stacked, each of the ten cells
# cost two lines plus the gap between them, and the form measured
# 1320px against a 768px panel; side-by-side each cell is one line
@ -105,17 +105,17 @@ class ManualWorkoutFormWidgetsMixin:
cell,
text=label,
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
anchor="w",
).pack(side="left", padx=(0, self._colors.space("xs")))
entry = tk.Entry(
cell,
textvariable=var,
font=self._colors.font("label"),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
bg=self._colors.palette.field_bg,
fg=self._colors.palette.fg,
insertbackground=self._colors.palette.fg,
**self._colors.focus_kwargs(),
)
entry.pack(side="left", fill="x", expand=True)
@ -130,13 +130,13 @@ class ManualWorkoutFormWidgetsMixin:
) -> tk.IntVar:
"""Add a half-width label + numeric Spinbox and return its IntVar."""
var = tk.IntVar(value=0)
row = tk.Frame(parent, bg=self._colors.bg)
row = tk.Frame(parent, bg=self._colors.palette.bg)
tk.Label(
row,
text=label,
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
).pack(side="left", padx=self._colors.space("xs"))
tk.Spinbox(
row,
@ -157,22 +157,22 @@ class ManualWorkoutFormWidgetsMixin:
whole rows of a form that has to fit a 768px panel, and two of them
sit side by side just as legibly.
"""
cell = tk.Frame(parent, bg=self._colors.bg)
cell = tk.Frame(parent, bg=self._colors.palette.bg)
tk.Label(
cell,
text=label,
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
anchor="w",
).pack(fill="x")
text_widget = tk.Text(
cell,
height=2,
font=self._colors.font("label"),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
bg=self._colors.palette.field_bg,
fg=self._colors.palette.fg,
insertbackground=self._colors.palette.fg,
**self._colors.focus_kwargs(),
)
text_widget.pack(pady=self._colors.space("xs"), fill="x")
@ -187,7 +187,7 @@ class ManualWorkoutFormWidgetsMixin:
def _mw_rpe_row(self, parent: tk.Widget) -> None:
"""Add the RPE (rate of perceived exertion) spinbox row."""
row = tk.Frame(parent, bg=self._colors.bg)
row = tk.Frame(parent, bg=self._colors.palette.bg)
tk.Label(
row,
text=(
@ -195,8 +195,8 @@ class ManualWorkoutFormWidgetsMixin:
f"({MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}):"
),
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
).pack(side="left", padx=self._colors.space("xs"))
tk.Spinbox(
row,
@ -234,7 +234,7 @@ class ManualWorkoutFormWidgetsMixin:
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)
form = tk.Frame(self.container.first, bg=self._colors.palette.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.

View File

@ -23,8 +23,8 @@ Safety invariants (do not break when adding tools):
calls must never write to stdout. All logging is routed to STDERR below,
and no tool calls ``screen_lock.py`` / ``status_view.main`` (which
``print`` / open Tk windows / ``sys.exit``).
* **No secret ever leaves.** No tool returns the sync token
(``~/.config/screen_locker/sync_token``) or any HMAC key; the predicates
* **No secret ever leaves.** No tool returns the Firebase session
(``~/.config/screen_locker/firebase_auth.json``) or any HMAC key; the predicates
load only status/state and return booleans, and the status snapshot
carries no secret material.
"""
@ -49,7 +49,6 @@ from screen_locker._compliance_state import (
from screen_locker._constants import (
EARLY_BIRD_PENDING_FILE,
)
from screen_locker._sick_tracker import load_history
from screen_locker._status_data import format_summary_line, gather_status
from screen_locker._status_projection import compliance_state_word
@ -64,7 +63,7 @@ logger = logging.getLogger(__name__)
mcp = MCPServer("screen-locker")
# Every tool here only reads local state: the workout log, the sick history
# Every tool here only reads local state: the workout log, the sickdays pool
# and the clock. Saying so lets a client run them without asking -- and the
# day one of them stops being read-only, this annotation is the thing that
# has to change with it. (See the section banner below: "never mutate" was
@ -92,7 +91,7 @@ def get_status() -> dict[str, Any]:
A JSON-friendly projection of ``gather_status()``: today's and this week's
workout outcomes, the shutdown-time projection, the lock-decision trace,
and the rolling sick-day / manual-workout budgets. Reads on-disk state
and the rolling manual-workout budget. Reads on-disk state
only (no ADB, sudo, or network) and never mutates anything.
"""
return asdict(gather_status())
@ -132,13 +131,21 @@ def get_flags() -> dict[str, bool]:
Each predicate reads its own on-disk state file and degrades to ``False``
when that file is missing or unreadable — none of them mutate state or
expose secrets.
``is_scheduled_skip_today`` keeps its name but no longer reads this app's
``scheduled_skips.json``: it is now the shared, fleet-wide free-day pool
(``~/src/utils/freedays``), so a true here also means diet-guard, wake-alarm,
leetcode-guard and home-guard are standing down today.
``is_sick_day_today`` reads the shared ``sickdays`` pool and answers "is the
workout lock switched off today": a sick day that keeps the ``workout``
locker reads False. Never a screen-locker file.
"""
history = load_history()
return {
"has_logged_today": has_logged_today(_LOG_FILE),
"is_scheduled_skip_today": is_scheduled_skip_today(),
"is_early_bird_pending": is_early_bird_pending(EARLY_BIRD_PENDING_FILE),
"is_sick_day_today": is_sick_day_today(history),
"is_sick_day_today": is_sick_day_today(),
}

View File

@ -0,0 +1,56 @@
"""Read the morning-session carrot the wake-alarm service signed for today.
The reader itself is ``gatelock.morning_session``, shared with leetcode-guard
and book-guard; this module only binds screen-locker's path and retry budget,
which the test suite redirects. The policy lives in wake-alarm: the only
decision anywhere is *HMAC ok, dated today, now < exempt_until*.
"""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
from gatelock.morning_session import (
MORNING_RETRY_SECONDS as _SHARED_RETRY_SECONDS,
)
from gatelock.morning_session import (
MorningSkip,
morning_skip,
)
from screen_locker._constants import MORNING_SESSION_FILE
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import datetime
__all__ = ["MorningSkip", "has_workout_skip_today", "morning_skip_today"]
MORNING_RETRY_SECONDS: float = _SHARED_RETRY_SECONDS
def morning_skip_today(
now: datetime | None = None,
*,
wait: bool,
sleep: Callable[[float], None] = time.sleep,
) -> MorningSkip | None:
"""The skip the morning session earned, or None.
With ``wait`` (the enforce path only), a file that is not today's during
the morning window is retried so a freshly booted PC gives wake-alarm's
catch-up run a chance to land.
"""
return morning_skip(
MORNING_SESSION_FILE,
now,
wait=wait,
retry_seconds=MORNING_RETRY_SECONDS,
sleep=sleep,
)
def has_workout_skip_today() -> bool:
"""Whether the morning session earned a skip right now. Never waits."""
return morning_skip_today(wait=False) is not None

View File

@ -0,0 +1,70 @@
"""The workout lock does not re-arm after tonight's evening night-lockdown.
The predicate itself is ``gatelock.night_lockdown``, shared with
leetcode-guard and book-guard (diet-guard deliberately does not opt in); this
module only binds screen-locker's path, which the test suite redirects, and
the ladder rung that honours it.
Why: once tonight's lockdown has fired, a late credit (``_late_credit``) can
lift it -- and re-locking behind the workout gate the same evening would make
a finished workout impossible to submit. Only an EVENING lock counts (local
date today, >= 12:00); the 04:01 morning re-lock does not. Missing or garbage
stamp means "no lockdown": the workout lock behaves exactly as before.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from gatelock.night_lockdown import (
NIGHT_LOCKDOWN_STAMP as _SHARED_STAMP,
)
from gatelock.night_lockdown import (
evening_lockdown_today,
)
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
__all__ = [
"EVENING_LOCKDOWN_REASON",
"NIGHT_LOCKDOWN_STAMP",
"has_evening_lockdown_today",
"skip_after_evening_lockdown",
"tonights_lockdown",
]
NIGHT_LOCKDOWN_STAMP: Path = _SHARED_STAMP
EVENING_LOCKDOWN_REASON = "evening_lockdown_today"
def tonights_lockdown(now: datetime | None = None) -> datetime | None:
"""When tonight's evening lockdown locked, or None if it has not yet."""
return evening_lockdown_today(NIGHT_LOCKDOWN_STAMP, now)
def has_evening_lockdown_today() -> bool:
"""Whether tonight's evening lockdown already happened. Read-only."""
return tonights_lockdown() is not None
def skip_after_evening_lockdown(record_skip: Callable[..., None]) -> bool:
"""Skip enforcement once tonight's evening lockdown has fired.
Takes the locker's ``_record_skip`` (which exits the process) rather than
the locker, like ``_lock_invariant``. Returns True when the skip was
recorded, False when the lock may go ahead.
"""
locked_at = tonights_lockdown()
if locked_at is None:
return False
record_skip(
EVENING_LOCKDOWN_REASON,
f"Tonight's night lockdown already locked at {locked_at:%H:%M}; the "
"workout lock does not re-arm after it.",
locked_at=locked_at.isoformat(),
)
return True

View File

@ -1,4 +1,4 @@
"""Phone workout verification: GitHub sync first, ADB/HTTP as fallback."""
"""Phone workout verification: Firebase sync first, ADB/HTTP as fallback."""
from __future__ import annotations
@ -8,7 +8,6 @@ import json
import logging
from pathlib import Path
import tempfile
import time
from screen_locker._adb_transport import AdbTransportMixin
from screen_locker._constants import (
@ -16,6 +15,7 @@ from screen_locker._constants import (
WORKOUT_APP_JSON_REMOTES,
WORKOUT_DURATION_ACCEPT_MINUTES,
)
from screen_locker._day import today_str
from screen_locker._http_workout_fetch import HttpWorkoutFetchMixin
from screen_locker._log_mixin import write_signed_entry
from screen_locker._time_check import check_clock_skew
@ -28,6 +28,18 @@ _HTTP_OK = _http_client.OK
_logger = logging.getLogger(__name__)
def _in_current_week(date_str: str) -> bool:
"""True if *date_str* parses and falls in this ISO week up to today."""
today = datetime.now(tz=UTC).astimezone().date()
week_start = today - timedelta(days=today.weekday())
try:
entry_date = date.fromisoformat(date_str)
except ValueError:
_logger.warning("StrongLifts JSON has an unparsable date: %r", date_str)
return False
return week_start <= entry_date <= today
class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
"""Mixin providing phone-based workout verification via ADB."""
@ -41,7 +53,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
only fall back to a stale/older payload if no candidate is from today.
"""
tmp = Path(tempfile.gettempdir()) / "workout_result.json"
today = time.strftime("%Y-%m-%d")
today = today_str()
first_parsed: dict | None = None
for remote in WORKOUT_APP_JSON_REMOTES:
ok, _ = self._run_adb(["pull", remote, str(tmp)])
@ -78,8 +90,10 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
if duration_min < WORKOUT_DURATION_ACCEPT_MINUTES:
return (
"too_short",
f"Workout too short! {duration_min:.0f} min logged, "
f"need at least {MIN_WORKOUT_DURATION_MINUTES} min.",
(
f"Workout too short! {duration_min:.0f} min logged, "
f"need at least {MIN_WORKOUT_DURATION_MINUTES} min."
),
)
flag = "all succeeded" if data.get("succeeded") else "partial"
return "verified", f"Workout verified! ({duration_min:.0f} min, {flag})"
@ -91,7 +105,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
must reflect a workout done *right now*. For crediting a workout
found dated earlier in the week, see ``_try_fill_stronglifts_for_week``.
"""
today = time.strftime("%Y-%m-%d")
today = today_str()
if data.get("date") != today:
return "stale", f"Workout JSON is from {data.get('date')}, not today"
return self._validate_json_content(data)
@ -117,16 +131,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
return 0
data = self._pull_workout_app_json()
date_str = data.get("date") if data else None
if not date_str:
return 0
today = datetime.now(tz=UTC).astimezone().date()
week_start = today - timedelta(days=today.weekday())
try:
entry_date = date.fromisoformat(date_str)
except ValueError:
_logger.warning("StrongLifts JSON has an unparsable date: %r", date_str)
return 0
if not week_start <= entry_date <= today:
if not date_str or not _in_current_week(date_str):
return 0
status, msg = self._validate_json_content(data)
if status != "verified":
@ -143,8 +148,35 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
# ── HTTP fallback (no ADB / developer options required) ───────────────────
def _pull_sync_result(self) -> tuple[tuple[str, str] | None, str | None]:
"""Firebase sync first: the validated result (if any) and sync error.
Firebase sync is the primary channel -- it works without the phone
being on the same network as the PC. Only a *verified* result
short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the
phone's last push predates today, or a later push failed offline)
must not preempt a fresher ADB pull, so anything less than
"verified" is kept by the caller as a fallback-of-last-resort
candidate. A sync error never blocks unlocking either; `sync_failed`
only surfaces if ADB/HTTP also come up empty.
"""
synced_data, sync_error = pull_synced_workout()
if synced_data is None:
if sync_error is not None:
_logger.info(
"Firebase sync unavailable (%s) — trying ADB/HTTP...", sync_error
)
return None, sync_error
sync_result = self._validate_json_data(synced_data)
if sync_result[0] != "verified":
_logger.info(
"Synced data not fully verified (%s) — trying ADB/HTTP...",
sync_result[0],
)
return sync_result, sync_error
def _verify_phone_workout(self) -> tuple[str, str]:
"""Verify today's workout: GitHub sync first, ADB/HTTP as fallback.
"""Verify today's workout: Firebase sync first, ADB/HTTP as fallback.
Returns (status, message). Status values:
verified / too_short / not_verified / no_phone / sync_failed /
@ -154,28 +186,9 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
if not clock_ok:
return "clock_tampered", clock_msg
# GitHub sync is the primary channel — it works without the phone
# being on the same network as the PC. Only a *verified* sync result
# short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the
# phone's last push predates today, or a later push failed offline)
# must not preempt a fresher ADB pull, so anything less than
# "verified" is kept as a fallback-of-last-resort candidate instead
# of being returned immediately. A sync error never blocks unlocking
# either; `sync_failed` only surfaces if ADB/HTTP also come up empty.
synced_data, sync_error = pull_synced_workout()
sync_result: tuple[str, str] | None = None
if synced_data is not None:
sync_result = self._validate_json_data(synced_data)
if sync_result[0] == "verified":
return sync_result
_logger.info(
"Synced data not fully verified (%s) — trying ADB/HTTP...",
sync_result[0],
)
elif sync_error is not None:
_logger.info(
"GitHub sync unavailable (%s) — trying ADB/HTTP...", sync_error
)
sync_result, sync_error = self._pull_sync_result()
if sync_result is not None and sync_result[0] == "verified":
return sync_result
# Prefer ADB when a device is visible, but if the pull yields no usable
# JSON, fall through to the HTTP/WiFi scan — the app's in-memory HTTP
@ -192,13 +205,23 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
data = self._fetch_http_workout()
if data is not None:
return self._validate_json_data(data)
if sync_result is not None:
return sync_result
if sync_error is not None:
return "sync_failed", sync_error
if adb_connected:
return (
"not_verified",
"Workout app JSON not found. Complete a workout in the app first.",
)
return "no_phone", "Phone not reachable via ADB or HTTP on local network"
return _no_data_result(sync_result, sync_error, adb_connected=adb_connected)
def _no_data_result(
sync_result: tuple[str, str] | None,
sync_error: str | None,
*,
adb_connected: bool,
) -> tuple[str, str]:
"""The verdict when neither ADB nor HTTP produced a workout JSON."""
if sync_result is not None:
return sync_result
if sync_error is not None:
return "sync_failed", sync_error
if adb_connected:
return (
"not_verified",
"Workout app JSON not found. Complete a workout in the app first.",
)
return "no_phone", "Phone not reachable via ADB or HTTP on local network"

View File

@ -0,0 +1,242 @@
"""Credit one poked StrongLifts session: the sync's ingest, minus the network.
The phone POSTs a finished session straight to the PC
(``docs/DOCS-workout-poke-contract.md``); this applies it through exactly the
path a synced session takes -- :func:`ingest_session_records`, which
re-validates and dedups under ``log.lock``, then the shared
:meth:`~screen_locker._workout_credit.WorkoutCreditMixin._apply_credit_for_written_entry`
(slot-based credit, shutdown push, the "Workout credited" notification).
Nothing here computes a reward of its own.
Each call builds a FRESH headless locker, the same object
``--sync-only`` uses, so no state leaks from one request into the next.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
import logging
from typing import TYPE_CHECKING, Final, cast
from screen_locker._cli import _headless_locker
from screen_locker._constants import (
EXTRA_BENEFITS_FILE,
SHUTDOWN_BASE_FILE,
SHUTDOWN_CONFIG_FILE,
)
from screen_locker._credit_notify import gaming_minutes_today
from screen_locker._day import today_str
from screen_locker._earned import hhmm
from screen_locker._extra_benefits import process_week_transition
from screen_locker._grace_floor import tonight_minutes
from screen_locker._manual_sync import ingest_manual_records
from screen_locker._session_sync import ingest_session_records, validate_session
from screen_locker._shutdown import read_shutdown_config
from screen_locker._shutdown_base import reset_to_base_if_new_day
from screen_locker.screen_lock import ScreenLocker
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
from pathlib import Path
from screen_locker._workout_credit import WorkoutCreditResult
__all__ = [
"BatchOutcome",
"PokeOutcome",
"credit_manual_workouts",
"credit_session",
"credit_sessions",
"current_gaming_minutes",
"current_shutdown",
]
_logger: Final = logging.getLogger(__name__)
@dataclass(frozen=True)
class PokeOutcome:
"""What crediting one poked session did; ``reason`` is for the phone's UI."""
ok: bool
credited: bool
duplicate: bool
reason: str
@dataclass(frozen=True)
class BatchOutcome:
"""What one batch did: newly logged ids, and how many of those earned time."""
ingested: tuple[str, ...]
credited: int
class PokeLocker(ScreenLocker):
"""The headless locker plus the two steps a poke needs, as public calls."""
def start_the_day(self) -> None:
"""Run the new-day reset the locker's startup runs, in the same order.
Mirrors ``StartupChecksMixin._check_non_verify_exits``, not
``sync_now`` (which never resets): the week transition (streak,
early-bird window) first, then the daily reset.
"""
for reward in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE):
_logger.info("Weekly reward: %s", reward)
reset_to_base_if_new_day(
SHUTDOWN_BASE_FILE,
self,
log_file=self.log_file,
)
# A future-dated manual workout logged on this PC whose day is today
# earns its credit on the first PC action of the day, not at the next
# 15-minute tick. Local ledger only: no network on the instant path.
self._ingest_due_local_manual_workouts()
def credit_written(
self, entry: dict[str, str], prior_entries: list[dict[str, object]]
) -> WorkoutCreditResult:
"""``SyncMixin._credit_ingested_workout``, keeping the result."""
self.workout_data = entry
return self._apply_credit_for_written_entry(prior_entries)
def fresh_locker() -> PokeLocker:
"""The ``--sync-only`` headless locker: no Tk, no window, no lock."""
return cast("PokeLocker", _headless_locker(PokeLocker))
def current_shutdown() -> str | None:
"""Tonight's shutdown from ``/etc/shutdown-schedule.conf`` as ``HH:MM``."""
config = read_shutdown_config(SHUTDOWN_CONFIG_FILE)
if config is None:
_logger.warning(
"Workout poke: shutdown config unreadable, replying shutdown=null"
)
return None
return hhmm(tonight_minutes(config, date.fromisoformat(today_str())))
def current_gaming_minutes(log_file: Path) -> int | None:
"""Today's gaming budget (``None`` and a warning when it cannot be summed)."""
return gaming_minutes_today(log_file)
def _precheck(record_id: str, payload: Mapping[str, object]) -> str | None:
"""Why the session can never count, or ``None`` if it can.
``ingest_session_records`` applies the same rules but only returns what
it ingested, so a rejection would be indistinguishable from a duplicate.
"""
day = payload.get("date")
if not isinstance(day, str) or not day:
return f"session {record_id} has no usable date ({day!r}), so it cannot count"
valid, detail = validate_session(payload)
if not valid:
return f"session {record_id} does not count: {detail}"
return None
def credit_session(record_id: str, payload: Mapping[str, object]) -> PokeOutcome:
"""Reset the day if new, then ingest and credit one session.
The caller serialises calls; the ingest also holds ``log.lock`` and the
credit the shutdown-state lock, so a concurrent sync or locker pass is safe.
"""
locker = fresh_locker()
locker.start_the_day()
problem = _precheck(record_id, payload)
if problem is not None:
_logger.warning("Workout poke not credited: %s", problem)
return PokeOutcome(ok=False, credited=False, duplicate=False, reason=problem)
results: list[WorkoutCreditResult] = []
def on_ingested(
entry: dict[str, str], prior_entries: list[dict[str, object]]
) -> None:
results.append(locker.credit_written(entry, prior_entries))
ingested = ingest_session_records(
locker.log_file, [(record_id, payload)], on_ingested=on_ingested
)
if not ingested or not results:
reason = (
f"session {record_id} is already in the PC's log (the sync or an "
"earlier poke got there first); nothing new to credit"
)
_logger.info("Workout poke duplicate: %s", reason)
return PokeOutcome(ok=True, credited=False, duplicate=True, reason=reason)
if results[0].already_counted_today:
reason = (
f"session {record_id} was logged, but today's slot for it was already "
"paid by another copy of the same workout; no extra time"
)
_logger.warning("Workout poke not credited: %s", reason)
return PokeOutcome(ok=True, credited=False, duplicate=False, reason=reason)
_logger.info("Workout poke credited session %s", record_id)
return PokeOutcome(
ok=True, credited=True, duplicate=False, reason="workout credited"
)
def credit_sessions(
records: Iterable[tuple[str, Mapping[str, object]]],
) -> BatchOutcome:
"""Reset the day if new, then ingest and credit a batch of synced sessions.
``SyncMixin._ingest_synced_sessions`` with one fresh locker for the whole
batch -- what the Firebase stream (:mod:`screen_locker._session_stream`)
runs per event. Already-logged ids cost one ``log.json`` read and write
nothing; invalid ones are warned about by the ingest itself.
"""
locker = fresh_locker()
locker.start_the_day()
results: list[WorkoutCreditResult] = []
def on_ingested(
entry: dict[str, str], prior_entries: list[dict[str, object]]
) -> None:
results.append(locker.credit_written(entry, prior_entries))
ingested = ingest_session_records(locker.log_file, records, on_ingested=on_ingested)
return _outcome(ingested, results, "session")
def credit_manual_workouts(
records: Iterable[tuple[str, Mapping[str, object]]],
*,
today: str | None = None,
) -> BatchOutcome:
"""``credit_sessions`` for manual workouts, via ``ingest_manual_records``."""
locker = fresh_locker()
locker.start_the_day()
results: list[WorkoutCreditResult] = []
def on_ingested(
entry: dict[str, str], prior_entries: list[dict[str, object]]
) -> None:
results.append(locker.credit_written(entry, prior_entries))
ingested = ingest_manual_records(
locker.log_file, records, today=today, on_ingested=on_ingested
)
return _outcome(ingested, results, "manual workout")
def _outcome(
ingested: list[str], results: list[WorkoutCreditResult], what: str
) -> BatchOutcome:
"""Count what earned time; warn about logged copies that earned none."""
credited = sum(not result.already_counted_today for result in results)
if len(results) > credited:
_logger.warning(
"%d streamed %s(s) were logged but earned no time: today's slot "
"was already paid by another copy of the same workout",
len(results) - credited,
what,
)
return BatchOutcome(ingested=tuple(ingested), credited=credited)

View File

@ -0,0 +1,218 @@
"""The resident LAN listener that credits a phone workout within a second.
``workout-poke.service`` runs ``python3 -m screen_locker.poke_server``: a
stdlib ``ThreadingHTTPServer`` on ``0.0.0.0:8774`` (the firewall admits only
192.168.1.0/24) answering ``POST /v1/workout`` per
``docs/DOCS-workout-poke-contract.md``. Every import -- the locker, the
earned-time registry, gatelock -- is paid once at startup, so a request costs
only the credit itself.
The wire checks are pure (:mod:`screen_locker._poke_wire`); the crediting is
the sync's own ingest (:mod:`screen_locker._poke_credit`). One lock serialises
the nonce cache and every credit in this process; cross-process safety comes
from the locks the ingest and credit already hold.
"""
from __future__ import annotations
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import logging
import threading
import time
from typing import TYPE_CHECKING, Final
from screen_locker import _poke_credit
from screen_locker._poke_wire import (
NonceCache,
Rejection,
check_request,
)
if TYPE_CHECKING:
from screen_locker._poke_wire import PokeRequest
__all__ = ["MAX_BODY_BYTES", "POKE_PATH", "PokeServer"]
_logger: Final = logging.getLogger(__name__)
MAX_BODY_BYTES: Final = 64 * 1024
POKE_PATH: Final = "/v1/workout"
_HANDLER_TIMEOUT_S: Final = 10
def _reply(
outcome: _poke_credit.PokeOutcome,
*,
sandbox: bool = False,
snapshot: tuple[str | None, int | None] = (None, None),
) -> dict[str, object]:
"""The contract's response shape; ``pc_ms`` is filled in by the handler.
``snapshot`` is (tonight's shutdown ``HH:MM``, gaming minutes); refusals
leave it null rather than tell an unverified caller anything.
"""
shutdown, gaming = snapshot
return {
"ok": outcome.ok,
"credited": outcome.credited,
"duplicate": outcome.duplicate,
"sandbox": sandbox,
"shutdown": shutdown,
"gaming_budget_minutes": gaming,
"pc_ms": 0,
"reason": outcome.reason,
}
def _refused(reason: str) -> dict[str, object]:
"""A refusal's reply: not ok, nothing credited."""
return _reply(
_poke_credit.PokeOutcome(
ok=False, credited=False, duplicate=False, reason=reason
)
)
def _process(request: PokeRequest) -> dict[str, object]:
"""Credit (or, for a sandbox request, only answer) one verified poke."""
locker = _poke_credit.fresh_locker()
if request.sandbox:
outcome = _poke_credit.PokeOutcome(
ok=True,
credited=False,
duplicate=False,
reason="sandbox request verified; nothing written or credited",
)
else:
outcome = _poke_credit.credit_session(request.record_id, request.payload)
return _reply(
outcome,
sandbox=request.sandbox,
snapshot=(
_poke_credit.current_shutdown(),
_poke_credit.current_gaming_minutes(locker.log_file),
),
)
class PokeServer(ThreadingHTTPServer):
"""Holds the key, the nonce cache and the one credit lock."""
daemon_threads = True
def __init__(self, address: tuple[str, int], key: bytes) -> None:
"""Bind ``address`` and serve :class:`PokeHandler` with ``key``."""
super().__init__(address, PokeHandler)
self.key = key
self.nonces = NonceCache()
self.gate = threading.Lock()
class PokeHandler(BaseHTTPRequestHandler):
"""``POST /v1/workout``; anything else is answered 404/405 in JSON."""
server: PokeServer
timeout = _HANDLER_TIMEOUT_S
server_version = "workout-poke/1"
# The default access log (one stderr line per request) is kept: the
# journal is where it lands, and every refusal is also logged at warning.
def do_GET(self) -> None:
"""Only POST exists."""
self._send(
*self._refusal(
Rejection(
HTTPStatus.METHOD_NOT_ALLOWED, f"only POST {POKE_PATH} exists"
)
)
)
def do_POST(self) -> None:
"""Verify, credit and answer one poke; never let an error go unanswered."""
started = time.perf_counter()
try:
status, reply = self._handle()
except Exception: # pylint: disable=broad-exception-caught # handler boundary: logged, answered 500
_logger.exception("Workout poke from %s crashed", self.client_address[0])
status = HTTPStatus.INTERNAL_SERVER_ERROR
reply = _refused(
"the PC hit an internal error (see its journal); the "
"regular sync will still credit this session"
)
reply["pc_ms"] = round((time.perf_counter() - started) * 1000)
self._send(status, reply)
def _refusal(self, rejection: Rejection) -> tuple[HTTPStatus, dict[str, object]]:
"""Log a refusal at warning; the reply carries the same reason."""
_logger.warning(
"Workout poke from %s rejected (%d): %s",
self.client_address[0],
rejection.status,
rejection.reason,
)
return rejection.status, _refused(rejection.reason)
def _read_body(self) -> bytes | Rejection:
"""Exactly Content-Length bytes of ``POST /v1/workout``, capped.
Over :data:`MAX_BODY_BYTES` is refused before reading a byte of it.
"""
if self.path != POKE_PATH:
return Rejection(
HTTPStatus.NOT_FOUND, f"unknown path {self.path!r}; use {POKE_PATH}"
)
raw = self.headers.get("Content-Length")
if raw is None:
return Rejection(HTTPStatus.LENGTH_REQUIRED, "Content-Length is required")
length = int(raw) if raw.strip().isdigit() else -1
if length < 0:
return Rejection(HTTPStatus.BAD_REQUEST, f"bad Content-Length {raw!r}")
if length > MAX_BODY_BYTES:
return Rejection(
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
f"body is {length} bytes; the limit is {MAX_BODY_BYTES}",
)
body = self.rfile.read(length)
if len(body) != length:
return Rejection(
HTTPStatus.BAD_REQUEST,
f"body ended after {len(body)} of {length} bytes",
)
return body
def _handle(self) -> tuple[HTTPStatus, dict[str, object]]:
"""Run the contract's checks, then credit; returns (status, reply)."""
body = self._read_body()
if isinstance(body, Rejection):
return self._refusal(body)
with self.server.gate:
verdict = check_request(
self.server.key,
body,
self.headers.get("X-Poke-Signature"),
self.server.nonces,
now_ms=time.time_ns() // 1_000_000,
)
if isinstance(verdict, Rejection):
return self._refusal(verdict)
reply = _process(verdict)
_logger.info(
"Workout poke %s (sandbox=%s): %s",
verdict.record_id,
verdict.sandbox,
reply["reason"],
)
return HTTPStatus.OK, reply
def _send(self, status: HTTPStatus, reply: dict[str, object]) -> None:
"""Write ``reply`` as a JSON response and close the connection."""
data = json.dumps(reply, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(data)

218
screen_locker/_poke_wire.py Normal file
View File

@ -0,0 +1,218 @@
"""Phone -> PC workout poke: the wire checks, pure and stdlib-only.
The binding contract is ``docs/DOCS-workout-poke-contract.md`` and its literal
fixture ``contracts/workout_poke_v1.json``. Everything here is a pure function
of its inputs (``now_ms`` is a parameter, never read from a clock), so the
shared-fixture test can feed it the fixture's bytes as-is. The HTTP plumbing
lives in :mod:`screen_locker._poke_server`, the crediting in
:mod:`screen_locker._poke_credit`.
The MAC is computed over the raw body bytes *as received*; nothing here ever
re-serializes JSON, because a Dart and a Python encoder disagree on floats and
non-ASCII escaping.
"""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import hmac
from http import HTTPStatus
import json
import logging
from typing import Final
__all__ = [
"REPLAY_WINDOW_MS",
"NonceCache",
"PokeRequest",
"Rejection",
"check_request",
"parse_key",
"verify_signature",
]
PROTOCOL_VERSION: Final = 1
REPLAY_WINDOW_MS: Final = 120_000
KEY_HEX_LENGTH: Final = 64
_logger: Final = logging.getLogger(__name__)
@dataclass(frozen=True)
class PokeRequest:
"""A request that passed every contract check.
Attributes:
sent_at_ms: The phone's send time, epoch milliseconds.
nonce: The request's one-time id.
sandbox: Verify and answer, but write and credit nothing.
record_id: The session's sync record id.
payload: The session exactly as the phone synced it.
"""
sent_at_ms: int
nonce: str
sandbox: bool
record_id: str
payload: dict[str, object]
@dataclass(frozen=True)
class Rejection:
"""Why a request was refused, and with which HTTP status."""
status: HTTPStatus
reason: str
def parse_key(text: str) -> bytes:
"""Decode the shared key file's content (64 hex chars, 32 bytes).
Raises:
ValueError: With a sentence saying what is wrong with the key.
"""
stripped = text.strip()
if len(stripped) != KEY_HEX_LENGTH:
msg = f"key must be {KEY_HEX_LENGTH} hex characters, got {len(stripped)}"
raise ValueError(msg)
try:
return bytes.fromhex(stripped)
except ValueError as exc:
msg = f"key is not valid hex ({exc})"
raise ValueError(msg) from exc
def verify_signature(key: bytes, body: bytes, signature_header: str | None) -> bool:
"""True iff ``signature_header`` is hex HMAC-SHA256(key, body).
Compared as bytes: the header arrives latin-1 decoded, and
``hmac.compare_digest`` raises on a non-ASCII ``str``.
"""
if not signature_header:
return False
expected = hmac.new(key, body, hashlib.sha256).hexdigest().encode("ascii")
given = signature_header.strip().lower().encode("latin-1", errors="replace")
return hmac.compare_digest(expected, given)
class NonceCache:
"""Nonces seen inside the replay window, pruned by their ``sent_at``.
Pruned by the request's own send time, not by when it arrived: a request
stamped up to 120 s in the future stays acceptable until ``sent_at + 120
s``, so its nonce must be remembered at least that long.
"""
def __init__(self) -> None:
"""Start empty; the window is :data:`REPLAY_WINDOW_MS`."""
self._seen: dict[str, int] = {}
def check_and_add(self, nonce: str, sent_at_ms: int, now_ms: int) -> bool:
"""Record ``nonce``; False if it was already seen inside the window."""
self._seen = {
seen: sent
for seen, sent in self._seen.items()
if now_ms - sent <= REPLAY_WINDOW_MS
}
if nonce in self._seen:
return False
self._seen[nonce] = sent_at_ms
return True
def __len__(self) -> int:
"""How many nonces are remembered right now."""
return len(self._seen)
def _is_int(value: object) -> bool:
"""An ``int`` that is not a ``bool`` (JSON ``true`` decodes to one)."""
return isinstance(value, int) and not isinstance(value, bool)
def _schema_error(doc: dict[str, object]) -> str | None:
"""The first missing or mistyped field after the replay checks, if any."""
if not _is_int(doc.get("v")) or doc.get("v") != PROTOCOL_VERSION:
return f"unsupported protocol version {doc.get('v')!r}; expected 1"
if not isinstance(doc.get("sandbox"), bool):
return "field 'sandbox' must be true or false"
record_id = doc.get("record_id")
if not isinstance(record_id, str) or not record_id:
return "field 'record_id' must be a non-empty string"
if not isinstance(doc.get("payload"), dict):
return "field 'payload' must be a JSON object"
return None
def _decode(body: bytes) -> dict[str, object] | Rejection:
"""The verified body as a JSON object, or a 400."""
try:
doc = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
# Signed by the key holder yet unparsable: a sender bug, not noise.
_logger.warning("Poke body is signed but not UTF-8 JSON: %s", exc)
return Rejection(HTTPStatus.BAD_REQUEST, f"body is not UTF-8 JSON ({exc})")
if not isinstance(doc, dict):
return Rejection(HTTPStatus.BAD_REQUEST, "body is not a JSON object")
return doc
def _replay_check(
doc: dict[str, object], nonces: NonceCache, now_ms: int
) -> tuple[int, str] | Rejection:
"""Checks 2 and 3: fresh enough (401), nonce unseen (409).
Returns ``(sent_at_ms, nonce)``; a missing or mistyped field either check
needs is a 400 at that point.
"""
sent_at = doc.get("sent_at_ms")
if not isinstance(sent_at, int) or isinstance(sent_at, bool):
return Rejection(HTTPStatus.BAD_REQUEST, "field 'sent_at_ms' must be an int")
if abs(now_ms - sent_at) > REPLAY_WINDOW_MS:
return Rejection(
HTTPStatus.UNAUTHORIZED,
f"request is stale: sent_at_ms is {(now_ms - sent_at) / 1000:+.0f} s "
"off the PC clock (+ = in the past), the window is 120 s; check "
"the phone's clock",
)
nonce = doc.get("nonce")
if not isinstance(nonce, str) or not nonce:
return Rejection(HTTPStatus.BAD_REQUEST, "field 'nonce' must be a string")
if not nonces.check_and_add(nonce, sent_at, now_ms):
return Rejection(HTTPStatus.CONFLICT, f"nonce {nonce} was already used")
return sent_at, nonce
def check_request(
key: bytes,
body: bytes,
signature_header: str | None,
nonces: NonceCache,
now_ms: int,
) -> PokeRequest | Rejection:
"""Run the contract's checks in order; the first failure wins.
1. signature -> 401, 2. ``|now - sent_at| <= 120 s`` -> 401,
3. nonce unseen -> 409, 4. ``v == 1`` and fields present -> 400.
JSON is parsed only after the MAC over the raw bytes matched.
"""
if not verify_signature(key, body, signature_header):
return Rejection(HTTPStatus.UNAUTHORIZED, "signature does not match the key")
doc = _decode(body)
if isinstance(doc, Rejection):
return doc
replay = _replay_check(doc, nonces, now_ms)
if isinstance(replay, Rejection):
return replay
problem = _schema_error(doc)
if problem is not None:
return Rejection(HTTPStatus.BAD_REQUEST, problem)
sent_at, nonce = replay
payload = doc["payload"]
return PokeRequest(
sent_at_ms=sent_at,
nonce=nonce,
sandbox=doc["sandbox"] is True,
record_id=str(doc["record_id"]),
payload=dict(payload) if isinstance(payload, dict) else {},
)

View File

@ -1,54 +0,0 @@
"""Turning a completed push into an honest result.
Split out of :mod:`screen_locker._manual_push` to keep every file under the
250-line cap.
A push that the primary backend refused still reaches the GitHub mirror, and
the records are not lost -- the phone reads the union of both. What must not
happen is that half-landed push logging the same line as a whole one, which is
how weeks of broken Firebase pushes looked healthy in the journal.
"""
from __future__ import annotations
import logging
from screen_locker._constants import SYNC_REPO_NAME, SYNC_REPO_OWNER
from screen_locker._degraded_sources import degraded_sources
_logger = logging.getLogger(__name__)
def describe_push(record_count: int) -> tuple[bool, str]:
"""Report whether every backend received the records, and say so.
Args:
record_count: How many records the push carried.
Returns:
``(complete, reason)`` -- ``reason`` reads like a sentence either way,
because it is what ``PushResult`` carries back to the caller.
"""
missing = [source.name for source in degraded_sources()]
if not missing:
_logger.info(
"Synced %d workout(s) to %s/%s",
record_count,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
)
return True, "pushed"
names = ", ".join(missing)
reason = (
f"pushed to {SYNC_REPO_OWNER}/{SYNC_REPO_NAME} only; "
f"{names} did NOT receive these records"
)
_logger.warning(
"Workout sync push INCOMPLETE for %d workout(s): %s — the phone reads "
"both backends, so this still syncs, but %s is broken and must be "
"fixed before it is the only path left",
record_count,
reason,
names,
)
return False, reason

193
screen_locker/_rest_day.py Normal file
View File

@ -0,0 +1,193 @@
"""Rest days: declared AHEAD of time, at most two per ISO week, HMAC-signed.
A declared rest day earns the workout's first-unit shutdown bonus, so the day
resolves as if its workout were done. Two rules keep it from being an escape:
* **Ahead only.** A rest day must be declared on an earlier local day than
the one it names -- never "today", so it cannot be reached for at the
moment the shutdown bites. Checked when declaring AND again when reading
(``declared_on < day``), so a row written by any other route still fails.
The CLI refuses to run on a skewed clock (``check_clock_skew``), the one
way to make tomorrow look like today.
* **Two per ISO week**, counted by the rest day's own week over the verified
rows, oldest declaration first; a third row in a week is ignored on read.
Each declaration is mirrored as a ``rest_day`` credit row in the workout
ledger (:mod:`screen_locker._workout_ledger`), which ``earned_time`` reads.
Rows live in ``rest_days.json`` as ``{"entries": [...]}``, each signed with
``earned_time.entry_signature`` and the shared key, the same as every gate's
ledger: editing the file without the key breaks the row, and a broken row
earns nothing.
Unlike a sick day -- a date in the shared ``sickdays`` pool that stands
every gate down -- a rest day is planned and only touches shutdown time. It
does not stand down the screen lock or the weekly minimum (``freedays`` and
``sickdays`` do that).
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, date, datetime
import json
import logging
from typing import TYPE_CHECKING, Any
import earned_time
from screen_locker import _earned
from screen_locker._constants import REST_DAY_FILE, REST_DAYS_PER_ISO_WEEK
from screen_locker._workout_ledger import append_rows, rest_row
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
_KIND = "rest_day"
@dataclass(frozen=True)
class Declared:
"""Outcome of :func:`declare`: ``ok`` and a sentence a human can act on."""
ok: bool
reason: str
def _key(key_file: Path) -> bytes | None:
try:
key = key_file.read_bytes().strip()
except OSError as exc:
_logger.warning("Cannot read the rest-day signing key %s (%s)", key_file, exc)
return None
return key or None
def _rows(path: Path) -> list[Any] | None:
"""The file's ``entries``; ``[]`` if it does not exist, ``None`` if unreadable."""
if not path.exists():
return []
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
_logger.warning("Rest-day file %s is unreadable (%s)", path, exc)
return None
rows = raw.get("entries") if isinstance(raw, dict) else None
if not isinstance(rows, list):
_logger.warning("Rest-day file %s has no entries array", path)
return None
return rows
def _iso_week(day: date) -> tuple[int, int]:
year, week, _ = day.isocalendar()
return year, week
def _valid(row: object, key: bytes) -> bool:
"""A signed rest-day row, declared on an earlier day than it names."""
if not isinstance(row, dict) or row.get("kind") != _KIND:
return False
if not earned_time.verified(row, key):
_logger.warning("Rest-day row %r fails its signature; ignored", row.get("day"))
return False
try:
return date.fromisoformat(str(row["declared_on"])) < date.fromisoformat(
str(row["day"])
)
except (KeyError, ValueError) as exc:
_logger.warning("Rest-day row %r is malformed (%s); ignored", row, exc)
return False
def rest_day_declarations(
path: Path | None = None, key_file: Path | None = None
) -> dict[date, float]:
"""Every rest day that counts, with the unix time it was declared.
Counted: verified, declared ahead, within the weekly cap (oldest first).
"""
key = _key(key_file or _earned.HMAC_KEY_FILE)
rows = _rows(path or REST_DAY_FILE)
if key is None or rows is None:
return {}
good = sorted(
(r for r in rows if _valid(r, key)), key=lambda r: float(r["declared_at"])
)
counted: dict[date, float] = {}
for row in good:
day = date.fromisoformat(str(row["day"]))
same_week = sum(1 for d in counted if _iso_week(d) == _iso_week(day))
if day not in counted and same_week < REST_DAYS_PER_ISO_WEEK:
counted[day] = float(row["declared_at"])
return counted
def rest_days(path: Path | None = None, key_file: Path | None = None) -> set[date]:
"""Every rest day that counts (see :func:`rest_day_declarations`)."""
return set(rest_day_declarations(path, key_file))
def is_rest_day(
day: date, path: Path | None = None, key_file: Path | None = None
) -> bool:
"""Whether ``day`` is a declared rest day (``False`` when unverifiable)."""
return day in rest_days(path, key_file)
def declare(
day: date,
*,
now: datetime | None = None,
path: Path | None = None,
key_file: Path | None = None,
ledger: Path | None = None,
) -> Declared:
"""Sign and append a rest day for ``day``, if the rules allow it.
Paths default at call time to ``REST_DAY_FILE`` and the key ``_earned``
verifies every ledger with, so the test suite's redirects apply.
"""
path = path or REST_DAY_FILE
key_file = key_file or _earned.HMAC_KEY_FILE
moment = (now or datetime.now(tz=UTC)).astimezone()
if day <= moment.date():
return Declared(
ok=False,
reason=f"{day} is not in the future: rest days are declared ahead",
)
existing = rest_days(path, key_file)
if day in existing:
return Declared(ok=True, reason=f"{day} is already a rest day")
used = sum(1 for d in existing if _iso_week(d) == _iso_week(day))
if used >= REST_DAYS_PER_ISO_WEEK:
return Declared(
ok=False,
reason=f"ISO week {_iso_week(day)[1]} already has "
f"{REST_DAYS_PER_ISO_WEEK} rest days",
)
key = _key(key_file)
rows = _rows(path)
if key is None or rows is None:
return Declared(ok=False, reason="rest-day file or signing key unreadable")
row: dict[str, object] = {
"kind": _KIND,
"day": day.isoformat(),
"declared_on": moment.date().isoformat(),
"declared_at": moment.timestamp(),
}
row["hmac"] = earned_time.entry_signature(row, key)
rows.append(row)
try:
path.write_text(json.dumps({"entries": rows}, indent=2), encoding="utf-8")
except OSError as exc:
_logger.warning("Could not write rest day to %s (%s)", path, exc)
return Declared(ok=False, reason=f"could not write {path}: {exc}")
append_rows([rest_row(day, moment.timestamp())], ledger, key_file)
left = REST_DAYS_PER_ISO_WEEK - used - 1
return Declared(
ok=True,
reason=f"{day} is a rest day ({left} left in ISO week {_iso_week(day)[1]})",
)

View File

@ -0,0 +1,81 @@
"""``--declare-rest-day``: plan a rest day from the command line.
python -m screen_locker.screen_lock --declare-rest-day 2026-10-12
python -m screen_locker.screen_lock --declare-rest-day tomorrow
python -m screen_locker.screen_lock --declare-rest-day --list
The clock is checked against NTP first and an unconfirmable clock is a
refusal, not a pass: unlike the lock's own check (which fails open when NTP
is unreachable), a wound-back clock with the network off is exactly how
"today" would be passed off as tomorrow.
"""
from __future__ import annotations
import argparse
from datetime import date, timedelta
import logging
import sys
from typing import TYPE_CHECKING
from screen_locker._constants import MAX_CLOCK_SKEW_SECONDS
from screen_locker._day import today_str
from screen_locker._rest_day import declare, rest_days
from screen_locker._time_check import _query_ntp_offset
if TYPE_CHECKING:
from collections.abc import Sequence
_logger = logging.getLogger(__name__)
def _say(message: str) -> None:
sys.stdout.write(f"{message}\n")
def _parse_day(raw: str) -> date:
today = date.fromisoformat(today_str())
if raw == "tomorrow":
return today + timedelta(days=1)
return date.fromisoformat(raw)
def _clock_confirmed() -> str | None:
"""``None`` if NTP confirms the clock, else why the declaration is refused."""
offset = _query_ntp_offset()
if offset is None:
return "NTP is unreachable, so the clock cannot be confirmed; try online"
if abs(offset) > MAX_CLOCK_SKEW_SECONDS:
return f"the clock is off by {abs(offset):.0f}s from NTP"
return None
def run_declare_rest_day(argv: Sequence[str]) -> int:
"""Declare (or list) rest days. Returns a process exit code."""
parser = argparse.ArgumentParser(
prog="screen-locker --declare-rest-day",
description="Declare a FUTURE rest day (max 2 per ISO week).",
)
parser.add_argument("day", nargs="?", help="YYYY-MM-DD or 'tomorrow'")
parser.add_argument("--list", action="store_true", help="list rest days")
args = parser.parse_args(list(argv))
if args.list or args.day is None:
days = sorted(rest_days())
_say("\n".join(d.isoformat() for d in days) or "no rest days declared")
return 0
try:
day = _parse_day(args.day)
except ValueError as exc:
_logger.warning("Unparsable rest day %r: %s", args.day, exc)
_say(f"error: {exc}")
return 2
refusal = _clock_confirmed()
if refusal is not None:
_logger.warning("Rest day refused: %s", refusal)
_say(f"refused: {refusal}")
return 1
result = declare(day)
if not result.ok:
_logger.warning("Rest day refused: %s", result.reason)
_say(result.reason if result.ok else f"refused: {result.reason}")
return 0 if result.ok else 1

View File

@ -0,0 +1,119 @@
"""Pure parsing for a Firebase RTDB REST stream of the device-log node.
``GET <db>/screen-locker-sync/devices.json`` with ``Accept: text/event-stream``
answers with Server-Sent Events: ``put``/``patch`` carry
``{"path": ..., "data": ...}`` relative to the streamed node, plus
``keep-alive``, ``cancel`` and ``auth_revoked``. Everything here is network-free
so it can be unit-tested; the connection itself is
:mod:`screen_locker._session_stream`.
Each device log is ONE JSON-string leaf at ``<device>/log~2Ejson``
(``crdt_sync._fbkeys`` escapes the dot), so an event normalises to "these
devices now hold this text (or nothing)".
"""
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import TYPE_CHECKING, Final
from crdt_sync._firebase import decode_key
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
__all__ = ["LOG_LEAF", "SseEvent", "device_updates", "parse_sse"]
LOG_LEAF: Final = "log.json"
@dataclass(frozen=True)
class SseEvent:
"""One Server-Sent Event: its name and its (joined) data lines."""
name: str
data: str
def parse_sse(lines: Iterable[str]) -> Iterator[SseEvent]:
"""Yield events from decoded SSE lines; a blank line ends each event."""
name = ""
data: list[str] = []
for line in lines:
if not line:
if name or data:
yield SseEvent(name or "message", "\n".join(data))
name, data = "", []
elif line.startswith("event:"):
name = line[len("event:") :].strip()
elif line.startswith("data:"):
data.append(line[len("data:") :].lstrip())
def _segments(path: str) -> list[str]:
"""``/a/b~2Ec`` -> ``["a", "b.c"]`` (decoded, empty segments dropped)."""
return [decode_key(part) for part in path.split("/") if part]
def _texts_under(segments: list[str], data: object) -> dict[str, str | None]:
"""Device -> log text for a ``put`` of ``data`` at ``segments``.
``None`` means that device's log is gone (deleted, or not a text blob --
the latter is the caller's to warn about, see :func:`device_updates`).
Raises:
TypeError: The data does not have the device-node shape at all.
"""
if not segments: # the whole node: {device: {leaf: text}}
if data is None:
return {}
if not isinstance(data, dict):
msg = f"devices node is {type(data).__name__}, not an object"
raise TypeError(msg)
return {decode_key(key): _leaf_text(value) for key, value in data.items()}
device = segments[0]
if len(segments) == 1:
return {device: _leaf_text(data)}
if segments[1:] == [LOG_LEAF]:
return {device: data if isinstance(data, str) else None}
return {} # something below a device that is not its log: irrelevant
def _leaf_text(device_node: object) -> str | None:
"""The log text inside one ``{leaf: text}`` device node, if any."""
if not isinstance(device_node, dict):
return None
for key, value in device_node.items():
if decode_key(key) == LOG_LEAF and isinstance(value, str):
return value
return None
def device_updates(event: SseEvent) -> tuple[bool, dict[str, str | None]]:
"""Normalise a ``put``/``patch`` into ``(is_full_snapshot, updates)``.
``updates`` maps a device id to its log's new full text, or ``None`` when
it no longer has one. A ``put`` at ``/`` is a full snapshot: the caller
must REPLACE its cache, since devices missing from it are gone. A
``patch`` carries several children whose keys may be multi-segment paths.
Raises:
ValueError: The data is not JSON.
TypeError: The JSON is not the documented ``{path, data}`` shape.
"""
body = json.loads(event.data)
if not isinstance(body, dict) or not isinstance(body.get("path"), str):
msg = f"{event.name} event without a path: {event.data[:120]!r}"
raise TypeError(msg)
base = _segments(body["path"])
if event.name == "put":
return not base, _texts_under(base, body.get("data"))
children = body.get("data")
if not isinstance(children, dict):
msg = f"patch data is {type(children).__name__}, not an object"
raise TypeError(msg)
updates: dict[str, str | None] = {}
for key, value in children.items():
updates.update(_texts_under([*base, *_segments(key)], value))
return False, updates

View File

@ -12,20 +12,49 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta
import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from screen_locker._log_mixin import write_signed_entry
from screen_locker._walk_tcx import activity_day, activity_shortfall
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
# Same shape as ``_manual_sync.OnIngestedCallback``: the appended
# ``workout_data`` and that day's entries *before* it.
OnIngestedCallback = Callable[[dict, "list[dict]"], None]
_logger = logging.getLogger(__name__)
def _record(
log_file: Path,
date_str: str,
workout_data: dict[str, Any],
on_ingested: OnIngestedCallback | None,
) -> bool:
"""Append one verified RunnerUp entry and credit it; False if a duplicate."""
result = write_signed_entry(log_file, date_str, workout_data)
if not result.appended:
return False
_logger.info(
"Auto-filled RunnerUp entry for %s: %s", date_str, workout_data["source"]
)
if on_ingested is not None:
on_ingested(workout_data, result.prior_entries)
return True
class RunnerUpBackfillMixin:
"""Fills the log from RunnerUp exports for a date or a whole week."""
def _try_fill_runnerup_for_date(self, date_str: str, log_file: Path) -> bool:
def _try_fill_runnerup_for_date(
self,
date_str: str,
log_file: Path,
on_ingested: OnIngestedCallback | None = None,
) -> bool:
"""Append a verified RunnerUp entry for ``date_str`` if not already logged.
Appends via the write chokepoint, which dedups by ``workout_id``
@ -33,42 +62,74 @@ class RunnerUpBackfillMixin:
recorded is a no-op, but a day that only holds, say, a manual workout
still gets the run appended alongside it (multiple workouts per day).
``on_ingested(workout_data, prior_entries)`` fires once per appended
entry, exactly like manual-sync ingestion, so a run pulled off the
phone earns the same day-scaled reward as every other source. It used
to earn only a weekly-surplus bonus (+1h per workout above the weekly
minimum), which is 0 for most of the week: on 2026-09-16 a run synced
at 20:25 left the config at 20:00 while the daily reset would have
derived 22:00 from the same log.
Returns True if a new verified entry was appended for ``date_str``.
"""
for remote in self._find_runnerup_exports_for_date(date_str):
data = self._pull_and_parse_tcx(remote)
if data is None:
continue
status, msg = self._validate_runnerup_data(data)
if status != "verified":
_logger.warning(
"RunnerUp export %s for %s did not qualify (%s): %s",
remote,
date_str,
status,
msg,
)
continue
workout_data = {
"type": "runnerup_verified",
"source": f"Auto-scanned: {msg}",
"distance_km": round(data["distance_m"] / 1000, 2),
"duration_minutes": round(data["duration_seconds"] / 60, 1),
}
if write_signed_entry(log_file, date_str, workout_data).appended:
_logger.info("Auto-filled RunnerUp entry for %s: %s", date_str, msg)
return True
return False
return False
activities = [
data
for remote in self._find_runnerup_exports_for_date(date_str)
if (data := self._pull_and_parse_tcx(remote)) is not None
]
return self._try_fill_day(date_str, activities, log_file, on_ingested)
def _scan_and_fill_week_runnerup(self, log_file: Path) -> int:
@staticmethod
def _try_fill_day(
date_str: str,
activities: list[dict[str, Any]],
log_file: Path,
on_ingested: OnIngestedCallback | None,
) -> bool:
"""Append the day's activity as its RunnerUp workout, once it adds up.
One rule for runs and walks alike (see :mod:`screen_locker._walk_tcx`):
the day's activities are summed, never judged one file at a time. They
share the ``runnerup_verified:{date}`` slot, so a walk and a run on
one day are one RunnerUp credit. ``activity: walking`` is kept for the
phone's history display only; it never decides credit.
"""
if not activities:
return False
day = activity_day(activities)
if not day.qualifies:
_logger.warning(
"RunnerUp activity for %s does not count yet: %s",
date_str,
activity_shortfall(day),
)
return False
workout_data: dict[str, Any] = {
"type": "runnerup_verified",
"source": f"Auto-scanned: {day.message()}",
"distance_km": round(day.distance_km, 2),
"duration_minutes": round(day.moving_minutes, 1),
"completed_at": day.completed_at,
}
if day.all_walks:
workout_data["activity"] = "walking"
return _record(log_file, date_str, workout_data, on_ingested)
def _scan_and_fill_week_runnerup(
self, log_file: Path, on_ingested: OnIngestedCallback | None = None
) -> int:
"""Scan the current ISO week for RunnerUp runs and append any not logged.
Returns the count of newly appended entries (0 if phone not connected).
``on_ingested`` is passed through to every appended entry; see
:meth:`_try_fill_runnerup_for_date`.
Returns the count of newly appended entries (0 if no export source --
neither the WebDAV drop directory nor an adb-visible phone).
"""
if not self._has_adb_device():
if not self._has_runnerup_source():
_logger.info(
"Phone not connected; skipping auto-scan for past RunnerUp exports."
"No RunnerUp source (no WebDAV drop dir, phone not connected); "
"skipping auto-scan for past RunnerUp exports."
)
return 0
@ -79,7 +140,9 @@ class RunnerUpBackfillMixin:
filled = 0
current = week_start
while current <= today:
if self._try_fill_runnerup_for_date(current.strftime("%Y-%m-%d"), log_file):
if self._try_fill_runnerup_for_date(
current.strftime("%Y-%m-%d"), log_file, on_ingested
):
filled += 1
current += timedelta(days=1)

View File

@ -151,9 +151,11 @@ class RunnerUpDbMixin:
if db_path is None:
return (
"not_verified",
"No TCX export found and root DB access is unavailable on this "
"device — enable RunnerUp's File Synchronizer auto-export so "
"runs land in /sdcard/Documents/RunnerUp/",
(
"No TCX export found and root DB access is unavailable on "
"this device — enable RunnerUp's File Synchronizer "
"auto-export so runs land in /sdcard/Documents/RunnerUp/"
),
)
try:

View File

@ -0,0 +1,215 @@
"""Retire the stock RunnerUp app a week after the PC first credits an app run.
kuhy's workout_app records runs itself now. Once the PC has credited one of
them, RunnerUp is redundant: seven days later (``GRACE``) it is backed up and
uninstalled, but only if the app has imported the RunnerUp history (a marker
the app writes) and that import matches what ``processed/`` holds. With no
anchor (no app run ever credited) it never uninstalls.
The phone is not always on USB, so ``runnerup-retire.timer`` retries daily
until this succeeds. Every outcome is a typed :class:`RetireResult` whose
reason is logged at the level it deserves: ``info`` for the normal "not due
yet", ``warning`` for a skip that needs attention, ``error`` for a failure.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from screen_locker._runnerup_retire_adb import PACKAGE, AdbError
from screen_locker._runnerup_retire_result import LEVEL, Outcome, RetireResult
from screen_locker._runnerup_retire_state import load_state, save_state
from screen_locker._runnerup_retire_tcx import app_run_ends, check_marker, find_anchor
if TYPE_CHECKING:
from screen_locker._runnerup_retire_adb import PhoneLike
from screen_locker._runnerup_retire_state import RetireState
_logger = logging.getLogger(__name__)
GRACE = timedelta(days=7)
STATE_FILE = Path.home() / ".local" / "state" / "screen-locker" / "runnerup_retire.json"
WATCH_DIR = Path.home() / "data" / "cloud" / "RunnerUp"
LOG_FILE = Path(__file__).resolve().parent / "log.json"
MARKER_NAME = ".workout_app_import.json"
@dataclass(frozen=True)
class Paths:
"""Every on-disk location, so tests (and ``--status``) can redirect them."""
state_file: Path
watch_dir: Path
log_file: Path
@classmethod
def default(cls) -> Paths:
"""The live locations (read at call time so tests can patch them)."""
return cls(STATE_FILE, WATCH_DIR, LOG_FILE)
@property
def processed(self) -> Path:
"""Where endurain-import files the RunnerUp exports it has handled."""
return self.watch_dir / "processed"
@property
def marker(self) -> Path:
"""The app's historic-import marker."""
return self.watch_dir / MARKER_NAME
def anchor_dirs(self) -> tuple[Path, Path]:
"""Where an app run's TCX can sit: the drop dir, then ``processed/``."""
return (self.watch_dir, self.processed)
def derive_anchor(paths: Paths, state: RetireState) -> datetime | None:
"""The stored anchor, else the first credit of an app run found on disk."""
return state.anchor or find_anchor(paths.log_file, paths.anchor_dirs())
def _finish(paths: Paths, state: RetireState, now: datetime, why: str) -> None:
save_state(
paths.state_file, replace(state, done_at=now.isoformat(), done_reason=why)
)
def _probe(phone: PhoneLike) -> RetireResult | tuple[str, bool]:
"""``(serial, installed)`` if the phone answers, else the result explaining why."""
if not phone.available():
return RetireResult(
Outcome.ADB_MISSING, "adb is not installed (pacman -S android-tools)"
)
try:
device = phone.find_device()
if device.serial is None:
return RetireResult(
Outcome.DEVICE_ABSENT, f"{device.reason}; retrying next run"
)
return device.serial, phone.installed(device.serial)
except AdbError as exc:
_logger.exception("RunnerUp retirement: asking the phone failed")
return RetireResult(
Outcome.PHONE_ERROR, f"asking the phone failed: {exc}", logged=True
)
def _backup(
paths: Paths, phone: PhoneLike, state: RetireState, serial: str, now: datetime
) -> RetireState | RetireResult:
"""Back the phone's exports up once; a later retry reuses a finished backup."""
if state.backup_path and Path(state.backup_path).is_dir():
return state
final = paths.watch_dir / f"runnerup_phone_backup_{now:%Y-%m-%d}"
try:
made, files = phone.backup(serial, final)
except (AdbError, OSError) as exc:
_logger.exception("RunnerUp retirement: backup failed")
return RetireResult(Outcome.BACKUP_FAILED, f"backup failed: {exc}", logged=True)
_logger.info("Backed up %d RunnerUp TCX files to %s", files, made)
state = replace(state, backup_path=str(made))
save_state(paths.state_file, state)
return state
def _retire(
paths: Paths, phone: PhoneLike, state: RetireState, now: datetime, *, dry_run: bool
) -> RetireResult:
"""Everything after "it is due and the marker is good": the phone steps."""
probed = _probe(phone)
if isinstance(probed, RetireResult):
return probed
serial, present = probed
if not present:
if not dry_run:
_finish(paths, state, now, f"{PACKAGE} was already absent")
return RetireResult(Outcome.ALREADY_GONE, f"{PACKAGE} is already absent")
if dry_run:
return RetireResult(
Outcome.DRY_RUN, "would back up and uninstall (no changes made)"
)
backed = _backup(paths, phone, state, serial, now)
if isinstance(backed, RetireResult):
return backed
try:
phone.uninstall(serial)
except AdbError as exc:
_logger.exception("RunnerUp retirement: uninstall failed")
return RetireResult(
Outcome.UNINSTALL_FAILED, f"uninstall failed: {exc}", logged=True
)
_finish(paths, backed, now, f"uninstalled after backup to {backed.backup_path}")
return RetireResult(
Outcome.UNINSTALLED,
f"{PACKAGE} uninstalled; backup in {backed.backup_path}",
)
def _no_anchor(paths: Paths) -> RetireResult:
"""Why there is no anchor; a missing source is a warning, not "nothing yet"."""
absent = [str(p) for p in (paths.watch_dir, paths.log_file) if not p.exists()]
if absent:
return RetireResult(
Outcome.SOURCES_MISSING,
f"cannot look for app runs, missing: {', '.join(absent)}",
)
seen = len(app_run_ends(paths.anchor_dirs()))
return RetireResult(
Outcome.NO_ANCHOR,
f"no app-recorded run credited yet ({seen} app TCX on disk); nothing to retire",
)
def _gate(
paths: Paths, state: RetireState, now: datetime, *, dry_run: bool
) -> RetireState | RetireResult:
"""The no-phone checks; a state back means "due, marker good: go on"."""
if state.done_at:
return RetireResult(
Outcome.ALREADY_DONE, f"done {state.done_at}: {state.done_reason}"
)
anchor = derive_anchor(paths, state)
if anchor is None:
return _no_anchor(paths)
if not state.anchor_at:
state = replace(state, anchor_at=anchor.isoformat())
if not dry_run:
save_state(paths.state_file, state)
due = anchor + GRACE
if now < due:
return RetireResult(
Outcome.NOT_DUE, f"due {due:%Y-%m-%d %H:%M} (anchor {anchor:%Y-%m-%d})"
)
marker = check_marker(paths.marker, paths.processed)
if not marker.ok:
return RetireResult(
Outcome.MARKER_INVALID, f"{marker.reason}; NOT uninstalling"
)
return state
def run_retire(
paths: Paths,
phone: PhoneLike,
now: datetime | None = None,
*,
dry_run: bool = False,
) -> RetireResult:
"""One pass: uninstall RunnerUp if (and only if) every condition holds."""
when = now or datetime.now(UTC)
gated = _gate(paths, load_state(paths.state_file), when, dry_run=dry_run)
if isinstance(gated, RetireResult):
result = gated
else:
result = _retire(paths, phone, gated, when, dry_run=dry_run)
if not result.logged:
_logger.log(
LEVEL.get(result.outcome, logging.INFO),
"RunnerUp retirement: %s: %s",
result.outcome.value,
result.reason,
)
return result

View File

@ -0,0 +1,181 @@
"""The phone side of the RunnerUp retirement: find it, back it up, uninstall.
Every call carries ``-s <serial>`` and goes through one ``run`` seam, so tests
never touch a device. adb's exit code is not trusted for the uninstall: it
counts only when the output says ``Success`` AND ``pm list packages`` no
longer lists the package.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
from pathlib import Path
import shutil
import subprocess
from typing import TYPE_CHECKING, Any, NoReturn, Protocol
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
PHONE_SERIAL = "23181JEGR08034"
PACKAGE = "org.runnerup.free"
REMOTE_DIR = "/sdcard/Documents/RunnerUp"
_ADB_TIMEOUT_S = 50
# The whole export history (TCX + GPX) over wifi adb; the oneshot unit has no
# start timeout, so a slow pull is allowed to finish.
_PULL_TIMEOUT_S = 900
class AdbError(RuntimeError):
"""An adb call failed; the message says what and why."""
def _fail(message: str) -> NoReturn:
raise AdbError(message)
@dataclass(frozen=True)
class Device:
"""Outcome of looking for the phone: the adb serial to use, or why not."""
serial: str | None
reason: str
class PhoneLike(Protocol):
"""What the retirement needs from a phone (tests substitute a fake)."""
def available(self) -> bool:
"""Whether adb can be run at all."""
def find_device(self) -> Device:
"""The attached phone, or why there is none."""
def installed(self, dev: str, *, hidden: bool = True) -> bool:
"""Whether RunnerUp is on the phone."""
def backup(self, dev: str, final: Path) -> tuple[Path, int]:
"""Pull the exports to ``final``."""
def uninstall(self, dev: str) -> None:
"""Uninstall RunnerUp or raise ``AdbError``."""
class Phone:
"""adb calls against one phone, with the process runner injectable."""
def __init__(
self,
serial: str = PHONE_SERIAL,
*,
which: Callable[[str], str | None] = shutil.which,
run: Callable[..., Any] = subprocess.run,
) -> None:
"""Pin the phone's serial; ``which``/``run`` are the test seams."""
self.serial = serial
self._which = which
self._run = run
def available(self) -> bool:
"""Whether an ``adb`` binary is on PATH."""
return self._which("adb") is not None
def _adb(self, *args: str, timeout: int = _ADB_TIMEOUT_S) -> str:
cmd = ["adb", *args]
try:
done = self._run(
cmd, capture_output=True, text=True, timeout=timeout, check=False
)
except (OSError, subprocess.TimeoutExpired) as exc:
msg = f"{' '.join(cmd)} did not run: {exc}"
raise AdbError(msg) from exc
if done.returncode:
detail = (done.stdout + done.stderr).strip()
_fail(f"{' '.join(cmd)} exited {done.returncode}: {detail}")
return str(done.stdout)
def _is_phone(self, serial: str) -> bool:
"""Whether an ``adb devices`` entry is the phone.
USB and mDNS serials embed the phone's serial; a plain ``ip:port``
wifi entry does not, so that one is asked for its ``ro.serialno``.
"""
if self.serial in serial:
return True
if ":" not in serial:
return False
try:
asked = self._adb("-s", serial, "shell", "getprop", "ro.serialno")
except AdbError as exc:
_logger.warning("Could not read the serial of %s (%s)", serial, exc)
return False
return asked.strip() == self.serial
def find_device(self) -> Device:
"""The attached phone, over USB or wifi (a wireless serial embeds it)."""
listing = self._adb("devices")
states: list[str] = []
for line in listing.splitlines()[1:]:
serial, _, state = line.partition("\t")
if not self._is_phone(serial):
continue
if state.strip() == "device":
return Device(serial, "attached")
states.append(state.strip())
if states:
return Device(None, f"phone is {'/'.join(states)}, not ready")
return Device(None, f"phone {self.serial} is not attached")
def installed(self, dev: str, *, hidden: bool = True) -> bool:
"""Whether the package is on the phone (exact ``package:`` line).
``hidden`` adds ``-u`` so an app that focus mode hides or disables
still counts as present; it must not be mistaken for an uninstall.
"""
flag = ["-u"] if hidden else []
out = self._adb("-s", dev, "shell", "pm", "list", "packages", *flag, PACKAGE)
return f"package:{PACKAGE}" in out.split()
def backup(self, dev: str, final: Path) -> tuple[Path, int]:
"""Pull the phone's export folder next to ``final``; return (dir, tcx count).
Pulled into a staging dir and renamed only once every phone ``.tcx``
is on disk, so a half backup never looks like a finished one. The
result is a subfolder, never top-level: a top-level ``*.tcx`` would be
credited by runnerup-watch.
"""
listing = self._adb("-s", dev, "shell", "ls", REMOTE_DIR)
wanted = sum(1 for n in listing.split() if n.endswith(".tcx"))
staging = final.with_name(final.name + ".partial")
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir(parents=True)
self._adb("-s", dev, "pull", REMOTE_DIR, str(staging), timeout=_PULL_TIMEOUT_S)
pulled = staging / Path(REMOTE_DIR).name
got = len(list(pulled.rglob("*.tcx"))) if pulled.is_dir() else -1
if got < wanted:
_fail(f"backup pulled {got} of {wanted} TCX files into {staging}")
target, n = final, 1
while target.exists():
n += 1
target = final.with_name(f"{final.name}-{n}")
pulled.rename(target)
staging.rmdir()
return target, got
def uninstall(self, dev: str) -> None:
"""Uninstall the package and prove it is gone, or raise ``AdbError``."""
try:
out = self._adb("-s", dev, "uninstall", PACKAGE)
except AdbError as exc:
# adb exits 1 with "Failure [DELETE_FAILED_...]": keep that reason
# (a device-owner policy can block it) in the error.
msg = f"uninstall refused: {exc}"
raise AdbError(msg) from exc
if "Success" not in out:
_fail(f"uninstall did not report Success: {out.strip()!r}")
if self.installed(dev, hidden=False):
_fail(f"{PACKAGE} is still listed after uninstall")
_logger.info("Uninstalled %s from %s", PACKAGE, dev)

View File

@ -0,0 +1,62 @@
"""Outcomes of one RunnerUp-retirement pass, and how loudly each is logged."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
import logging
__all__ = ["LEVEL", "Outcome", "RetireResult"]
class Outcome(StrEnum):
"""How a run ended; the value is what the journal shows."""
UNINSTALLED = "uninstalled"
ALREADY_GONE = "already-gone"
ALREADY_DONE = "already-done"
NO_ANCHOR = "no-anchor"
SOURCES_MISSING = "sources-missing"
NOT_DUE = "not-due"
DRY_RUN = "dry-run"
DEVICE_ABSENT = "device-absent"
MARKER_INVALID = "marker-invalid"
ADB_MISSING = "adb-missing"
PHONE_ERROR = "phone-error"
BACKUP_FAILED = "backup-failed"
UNINSTALL_FAILED = "uninstall-failed"
# Level per outcome; the failures also make the unit exit non-zero.
LEVEL = {
Outcome.DEVICE_ABSENT: logging.WARNING,
Outcome.SOURCES_MISSING: logging.WARNING,
Outcome.MARKER_INVALID: logging.WARNING,
Outcome.ADB_MISSING: logging.ERROR,
Outcome.PHONE_ERROR: logging.ERROR,
Outcome.BACKUP_FAILED: logging.ERROR,
Outcome.UNINSTALL_FAILED: logging.ERROR,
}
_NONZERO = frozenset(
{
Outcome.MARKER_INVALID,
Outcome.ADB_MISSING,
Outcome.PHONE_ERROR,
Outcome.BACKUP_FAILED,
Outcome.UNINSTALL_FAILED,
}
)
@dataclass(frozen=True)
class RetireResult:
"""What happened and why, in a sentence a human can act on."""
outcome: Outcome
reason: str
logged: bool = False # the failure was already logged where it was caught
@property
def ok(self) -> bool:
"""False for outcomes the unit should surface as failed."""
return self.outcome not in _NONZERO

View File

@ -0,0 +1,71 @@
"""The retirement's own memory: the anchor, the backup, the done stamp.
One small JSON file (path: ``_runnerup_retire.STATE_FILE``). Written
atomically and only by a real run (``--status`` and ``--dry-run`` never write),
so the file is also the proof that a run happened. The anchor is stored once
and never moved: a later, earlier-looking credit must not push the deadline.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass, fields
from datetime import datetime
import json
import logging
import os
from pathlib import Path
import tempfile
_logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class RetireState:
"""What a previous run recorded (all ISO strings, empty until known)."""
anchor_at: str = ""
backup_path: str = ""
done_at: str = ""
done_reason: str = ""
@property
def anchor(self) -> datetime | None:
"""The stored anchor as an aware datetime, or None."""
return datetime.fromisoformat(self.anchor_at) if self.anchor_at else None
def load_state(path: Path) -> RetireState:
"""Read the state; a missing file is a fresh start, a bad one is loud."""
if not path.exists():
return RetireState()
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
_logger.warning(
"Retire state %s is unreadable (%s): starting from scratch, so the "
"anchor is re-derived and the uninstall re-checked",
path,
exc,
)
return RetireState()
if not isinstance(raw, dict):
_logger.warning("Retire state %s is not a JSON object: ignored", path)
return RetireState()
known = {f.name for f in fields(RetireState)}
clean = {k: v for k, v in raw.items() if k in known and isinstance(v, str)}
try:
state = RetireState(**clean)
_ = state.anchor
except ValueError as exc:
_logger.warning("Retire state %s holds a bad anchor (%s): ignored", path, exc)
return RetireState()
return state
def save_state(path: Path, state: RetireState) -> None:
"""Atomically replace the state file (creates the directory on first write)."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".retire-", suffix=".tmp")
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(asdict(state), handle, indent=2)
Path(tmp).replace(path)

View File

@ -0,0 +1,210 @@
"""What the RunnerUp retirement reads off disk: app runs, the marker, the anchor.
Split from :mod:`screen_locker._runnerup_retire` (250-line cap). Everything
here is read-only; nothing touches the phone.
Telling the two sources apart is done on the TCX *content* (``<Author>`` or
``<Creator>`` named ``kuhy workout_app``, the app's ``kRunSourceName``), never
on the file name: the app may name its files however it likes.
The anchor is "when the PC first CREDITED an app run". The credit chokepoint
(``log.json``) stores a run's TCX end as ``completed_at`` on its
``runnerup_verified`` entry and stamps the entry itself with the moment the PC
wrote it. So the anchor is the ``timestamp`` of the entry whose
``completed_at`` equals an app TCX's last trackpoint. Joining on the end time
(not the day) is what makes a same-day RunnerUp run, which fills the day's one
credit slot first, correctly yield no anchor for the app run.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import json
import logging
import re
from typing import TYPE_CHECKING, Any
from screen_locker._log_io import load_workout_log
from screen_locker._runnerup_tcx import parse_tcx_file
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
_logger = logging.getLogger(__name__)
APP_SOURCE_NAME = "kuhy workout_app"
_APP_RE = re.compile(
rb"<(?:Author|Creator)\b[^>]*>\s*<Name>\s*"
+ re.escape(APP_SOURCE_NAME.encode())
+ rb"\s*</Name>"
)
# The WebDAV copy of a RunnerUp export may carry the device name
# (RunnerUp_Pixel_6a_2026-...) that the phone's own copy lacks.
_DEVICE_PREFIX = re.compile(r"^RunnerUp_(?:[A-Za-z0-9]+_)*?(?=\d{4}-\d{2}-\d{2}-)")
_END_TOLERANCE_S = 1.0
def run_key(name: str) -> str:
"""A run's identity: its file name without any device-name prefix."""
return _DEVICE_PREFIX.sub("RunnerUp_", name)
def list_tcx(directory: Path) -> list[Path]:
"""Finished ``*.tcx`` files directly in ``directory`` (dotfiles are partial)."""
if not directory.is_dir():
return []
return sorted(
p for p in directory.glob("*.tcx") if p.is_file() and not p.name.startswith(".")
)
def is_app_tcx(path: Path) -> bool:
"""Whether the TCX names the new app as its author or creator."""
try:
data = path.read_bytes()
except OSError as exc:
_logger.warning(
"Cannot read %s (%s): counting it as a RunnerUp file", path, exc
)
return False
return bool(_APP_RE.search(data))
def _ended_at(path: Path) -> float | None:
"""The run's end exactly as the credit path computed it (same parser)."""
data = parse_tcx_file(str(path))
if data is None or data.get("ended_at") is None:
_logger.warning("App TCX %s has no usable end time: it cannot anchor", path)
return None
return float(data["ended_at"])
def _credited_at(entry: dict[str, Any]) -> datetime | None:
try:
stamp = datetime.fromisoformat(str(entry["timestamp"]))
except (KeyError, ValueError) as exc:
_logger.warning("Credit entry has no usable timestamp (%r): skipped", exc)
return None
return stamp if stamp.tzinfo else stamp.astimezone()
def _as_unix(raw: object) -> float | None:
"""A unix time from a number or numeric string (the log stores both)."""
if isinstance(raw, int | float) and not isinstance(raw, bool):
return float(raw)
if isinstance(raw, str) and re.fullmatch(r"\d+(\.\d+)?", raw):
return float(raw)
return None
def _credit_ends(log_file: Path) -> list[tuple[float, datetime]]:
"""``(TCX end, PC credit time)`` of every credited run that recorded its end."""
found: list[tuple[float, datetime]] = []
for entries in load_workout_log(log_file).values():
for entry in entries:
data = entry.get("workout_data") or {}
if data.get("type") != "runnerup_verified":
continue
end = _as_unix(data.get("completed_at"))
if end is None:
continue # an older entry that never stored its run's end
when = _credited_at(entry)
if when is not None:
found.append((end, when))
return found
def app_run_ends(dirs: Iterable[Path]) -> list[float]:
"""The end time of every app-authored TCX that has a usable one."""
return [
end
for directory in dirs
for path in list_tcx(directory)
if is_app_tcx(path) and (end := _ended_at(path)) is not None
]
def find_anchor(log_file: Path, dirs: Iterable[Path]) -> datetime | None:
"""When the PC first credited an app-recorded run, or None if it never did."""
ends = app_run_ends(dirs)
credited = [
when
for run_end, when in _credit_ends(log_file)
if any(abs(run_end - end) <= _END_TOLERANCE_S for end in ends)
]
return min(credited) if credited else None
@dataclass(frozen=True)
class MarkerCheck:
"""Verdict on the app's historic-import marker."""
ok: bool
reason: str
def _bad(reason: str) -> MarkerCheck:
return MarkerCheck(ok=False, reason=reason)
def _marker_names(marker: object) -> tuple[list[str] | None, str]:
"""The marker's ``tcx_names`` if its shape is valid, else a reason."""
if not isinstance(marker, dict):
return None, "marker is not a JSON object"
names, count = marker.get("tcx_names"), marker.get("count")
if not isinstance(marker.get("completed_at"), str) or not marker["completed_at"]:
return None, "marker has no completed_at"
if not isinstance(names, list) or not all(isinstance(n, str) for n in names):
return None, "marker tcx_names is not a list of names"
if isinstance(count, bool) or not isinstance(count, int) or count != len(names):
return None, f"marker count {count!r} != {len(names)} listed names"
if any("/" in n or n.startswith(".") for n in names):
return None, "marker tcx_names must be bare file names"
return names, ""
def _read_marker(marker_file: Path) -> tuple[object, str]:
"""The parsed marker JSON, or ``(None, why it could not be read)``."""
if not marker_file.exists():
why = f"{marker_file} is missing: the app has not imported yet"
else:
try:
return json.loads(marker_file.read_text(encoding="utf-8")), ""
except (OSError, ValueError) as exc:
why = f"{marker_file} is unreadable ({exc})"
_logger.warning("Import marker problem: %s", why)
return None, why
_logger.warning("Import marker problem: %s", why)
return None, why
def _compare(names: list[str], processed: Path) -> MarkerCheck:
listed = {run_key(n) for n in names}
if len(listed) != len(names):
return _bad("marker lists the same run more than once")
have = {run_key(p.name) for p in list_tcx(processed) if not is_app_tcx(p)}
if absent := sorted(listed - have):
return _bad(f"{len(absent)} listed run(s) not in processed/: {absent[:3]}")
if unlisted := sorted(have - listed):
return _bad(
f"{len(unlisted)} RunnerUp run(s) in processed/ are not in the "
f"marker ({unlisted[:3]}): re-run the app's import"
)
return MarkerCheck(ok=True, reason=f"{len(listed)} runs match")
def check_marker(marker_file: Path, processed: Path) -> MarkerCheck:
"""Validate the app's import marker against what ``processed/`` really holds.
Valid means: the file parses, ``count`` equals the number of listed names,
and the listed runs are exactly the RunnerUp-recorded (non-app) runs in
``processed/``. A run is counted once however many copies exist (the
device-prefixed WebDAV twin and the phone-named copy are one run).
"""
marker, why = _read_marker(marker_file)
if why:
return _bad(why)
names, reason = _marker_names(marker)
return _compare(names, processed) if names is not None else _bad(reason)

View File

@ -17,6 +17,8 @@ import tempfile
from typing import Any
import xml.etree.ElementTree as ET
from screen_locker._walk_tcx import is_walking_export, track_summary
# TCX XML namespace used by Garmin/RunnerUp.
_TCX_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
@ -41,7 +43,16 @@ class RunnerUpTcxMixin:
"""Pulls and parses RunnerUp's TCX exports over adb."""
def _pull_and_parse_tcx(self, remote_path: str) -> dict[str, Any] | None:
"""Pull a remote TCX file and parse it. Returns activity dict or None."""
"""Parse a TCX export, pulling it over adb first unless it is local.
A path that exists on this machine (the WebDAV drop directory, see
``RUNNERUP_WEBDAV_DIRS``) is parsed in place; anything else is a
phone path and goes through ``adb pull``. Returns the activity dict
or None.
"""
name = Path(remote_path).name
if Path(remote_path).is_file():
return _mark_walk(self._parse_tcx(remote_path), name)
tmp_dir = tempfile.mkdtemp(prefix="runnerup_tcx_")
local_path = str(Path(tmp_dir) / "activity.tcx")
try:
@ -49,48 +60,62 @@ class RunnerUpTcxMixin:
if not ok or not Path(local_path).exists():
_logger.info("Failed to pull TCX file: %s", remote_path)
return None
return self._parse_tcx(local_path)
return _mark_walk(self._parse_tcx(local_path), name)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def _parse_tcx(self, tcx_path: str) -> dict[str, Any] | None:
"""Parse a local TCX file and return activity summary dict.
"""Parse a local TCX file (see :func:`parse_tcx_file`)."""
return parse_tcx_file(tcx_path)
Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so
multi-segment runs (pause/resume) are counted in full.
"""
try:
tree = ET.parse(tcx_path)
except ET.ParseError as exc:
_logger.warning(
"TCX file %s is not valid XML (%s) — the run it describes CANNOT "
"be verified and will not count",
tcx_path,
exc,
)
return None
root = tree.getroot()
activity = root.find(f".//{{{_TCX_NS}}}Activity")
if activity is None:
_logger.info("No Activity element in TCX file")
return None
def parse_tcx_file(tcx_path: str) -> dict[str, Any] | None:
"""Parse a local TCX file and return activity summary dict.
sport_str = activity.get("Sport", "")
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so
multi-segment runs (pause/resume) are counted in full.
"""
try:
tree = ET.parse(tcx_path)
except ET.ParseError as exc:
_logger.warning(
"TCX file %s is not valid XML (%s) — the run it describes CANNOT "
"be verified and will not count",
tcx_path,
exc,
)
return None
total_seconds = 0.0
total_distance = 0.0
for lap in activity.findall(f"{{{_TCX_NS}}}Lap"):
t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds")
d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters")
if t_elem is not None and t_elem.text:
total_seconds += float(t_elem.text)
if d_elem is not None and d_elem.text:
total_distance += float(d_elem.text)
root = tree.getroot()
activity = root.find(f".//{{{_TCX_NS}}}Activity")
if activity is None:
_logger.info("No Activity element in TCX file")
return None
return {
"sport": sport_int,
"duration_seconds": int(total_seconds),
"distance_m": total_distance,
}
sport_str = activity.get("Sport", "")
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
total_seconds = 0.0
total_distance = 0.0
for lap in activity.findall(f"{{{_TCX_NS}}}Lap"):
t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds")
d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters")
if t_elem is not None and t_elem.text:
total_seconds += float(t_elem.text)
if d_elem is not None and d_elem.text:
total_distance += float(d_elem.text)
return {
"sport": sport_int,
"duration_seconds": int(total_seconds),
"distance_m": total_distance,
"walking": is_walking_export(Path(tcx_path).name, sport_str),
**track_summary(activity),
}
def _mark_walk(data: dict[str, Any] | None, name: str) -> dict[str, Any] | None:
"""Flag a walk by its ORIGINAL file name: an adb pull renames the copy."""
if data is not None:
data["walking"] = data["walking"] or is_walking_export(name, "")
return data

View File

@ -7,34 +7,31 @@ Root DB fallback lives in ``_runnerup_db.py``.
from __future__ import annotations
from datetime import UTC, datetime
import logging
from typing import Any
from screen_locker._constants import (
MIN_RUN_DISTANCE_KM,
MIN_WORKOUT_DURATION_MINUTES,
RUNNERUP_DISTANCE_TOLERANCE,
RUNNERUP_EXPORT_DIRS,
WORKOUT_DURATION_ACCEPT_MINUTES,
RUNNERUP_WEBDAV_DIRS,
)
from screen_locker._day import today_str
from screen_locker._runnerup_backfill import RunnerUpBackfillMixin
from screen_locker._runnerup_db import RunnerUpDbMixin
from screen_locker._runnerup_tcx import RunnerUpTcxMixin
from screen_locker._time_check import check_clock_skew
from screen_locker._walk_tcx import (
SPORT_NAMES,
activity_day,
activity_shortfall,
distance_qualifies,
)
_logger = logging.getLogger(__name__)
_SPORT_NAMES: dict[int, str] = {
0: "Running",
1: "Biking",
2: "Other",
3: "Orienteering",
4: "Walking",
5: "Treadmill",
6: "Gym",
7: "Stationary Bike",
}
# Kept for importers; the labels live next to the one crediting rule.
_SPORT_NAMES = SPORT_NAMES
# TCX uses sport name strings; map back to integer codes for unified validation.
@ -50,14 +47,43 @@ class RunnerUpVerificationMixin(
# File-based path (no root required)
# ------------------------------------------------------------------
def _has_runnerup_source(self) -> bool:
"""Whether any RunnerUp export source is reachable right now.
The WebDAV drop directory on this disk counts as much as an
adb-visible phone: both hold the same TCX files.
"""
return self._has_local_runnerup_dir() or self._has_adb_device()
@staticmethod
def _has_local_runnerup_dir() -> bool:
return any(d.is_dir() for d in RUNNERUP_WEBDAV_DIRS)
@staticmethod
def _find_local_runnerup_exports_for_date(date_str: str) -> list[str]:
"""TCX files for ``date_str`` in the WebDAV drop directories, sorted."""
found: list[str] = []
for local_dir in RUNNERUP_WEBDAV_DIRS:
if not local_dir.is_dir():
continue
for path in sorted(local_dir.glob("*.tcx")):
if date_str in path.name and str(path) not in found:
found.append(str(path))
return found
def _find_runnerup_exports_for_date(self, date_str: str) -> list[str]:
"""Return adb paths of RunnerUp TCX exports for the given date, or empty list.
"""Return paths of RunnerUp TCX exports for the given date, or empty list.
Local WebDAV copies come first (no phone needed), then the phone's
export directories over adb when a device is attached.
Args:
date_str: ISO date string in ``YYYY-MM-DD`` format matched against
TCX filenames (``RunnerUp_YYYY-MM-DD-HH-MM-SS_xxx.tcx``).
"""
found: list[str] = []
found = self._find_local_runnerup_exports_for_date(date_str)
if not self._has_adb_device():
return found
for dirpath in RUNNERUP_EXPORT_DIRS:
ok, out = self._run_adb(["shell", "ls", dirpath])
if not ok or not out.strip():
@ -77,7 +103,7 @@ class RunnerUpVerificationMixin(
fails validation), or ``None`` if no today's file exists at all
(caller should try the root DB path instead).
"""
today = datetime.now(tz=UTC).astimezone().strftime("%Y-%m-%d")
today = today_str()
exports = self._find_runnerup_exports_for_date(today)
if not exports:
_logger.warning(
@ -90,63 +116,46 @@ class RunnerUpVerificationMixin(
)
return None
# Try each file; return the best result (verified > validation error).
best: tuple[str, str] | None = None
for remote in exports:
data = self._pull_and_parse_tcx(remote)
if data is None:
continue
status, msg = self._validate_runnerup_data(data)
if status == "verified":
return status, msg
if best is None:
best = (status, msg)
# All files found but none passed validation.
return best or (
"not_verified",
"RunnerUp TCX export found but could not be read",
)
# One rule for the whole day, whatever the sport or file name: the
# day's activities are summed (see _walk_tcx), never judged one file
# at a time.
activities = [
data
for remote in exports
if (data := self._pull_and_parse_tcx(remote)) is not None
]
if not activities:
return (
"not_verified",
"RunnerUp TCX export found but could not be read",
)
day = activity_day(activities)
if day.qualifies:
return "verified", day.message()
return "too_short", activity_shortfall(day)
def _validate_runnerup_data(self, data: dict[str, Any]) -> tuple[str, str]:
"""Validate a RunnerUp activity against configured thresholds.
Returns ``(status, message)`` following the same contract as
``PhoneVerificationMixin._verify_phone_workout``.
``PhoneVerificationMixin._verify_phone_workout``. The one rule is
:func:`screen_locker._walk_tcx.activity_day`; a dict without
``moving_seconds`` (the root-DB path) is judged by its duration.
"""
sport = data["sport"]
duration_min = data["duration_seconds"] / 60
distance_km = data["distance_m"] / 1000
# Either criterion qualifies the run on its own (see _constants.py).
# Distance gets the GPS tolerance (a measurement correction); duration
# gets the shared hidden leeway (a deliberate concession). Different
# reasons, so they stay separate constants.
distance_ok = (
distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) >= MIN_RUN_DISTANCE_KM
)
duration_ok = duration_min >= WORKOUT_DURATION_ACCEPT_MINUTES
if not distance_ok and not duration_ok:
msg = (
f"Run was {distance_km:.1f} km / {duration_min:.0f} min — need "
f"{MIN_RUN_DISTANCE_KM:g}+ km or {MIN_WORKOUT_DURATION_MINUTES}+ min"
)
return "too_short", msg
if distance_ok and distance_km < MIN_RUN_DISTANCE_KM:
day = activity_day([data])
if not day.qualifies:
return "too_short", activity_shortfall(day)
if day.distance_km < MIN_RUN_DISTANCE_KM and distance_qualifies(
day.distance_km
):
_logger.info(
"RunnerUp distance %.2f km is under the %.1f km minimum but "
"within %.0f%% GPS tolerance — accepted.",
distance_km,
day.distance_km,
MIN_RUN_DISTANCE_KM,
RUNNERUP_DISTANCE_TOLERANCE * 100,
)
sport_name = _SPORT_NAMES.get(sport, str(sport))
return (
"verified",
f"{sport_name}: {distance_km:.1f} km in {duration_min:.0f} min",
)
return "verified", day.message()
# ------------------------------------------------------------------
# Main entry point
@ -165,13 +174,13 @@ class RunnerUpVerificationMixin(
if not skew_ok:
return "clock_tampered", skew_msg
if not self._has_adb_device():
if not self._has_runnerup_source():
return (
"no_phone",
"Phone not connected — plug in via USB or enable wireless ADB",
)
# Path 1: file-based (no root needed).
# Path 1: file-based (no root needed; WebDAV copies first, then adb).
file_result = self._verify_runnerup_via_files()
if file_result is not None:
_logger.info("RunnerUp file-based result: %s", file_result[0])
@ -179,5 +188,13 @@ class RunnerUpVerificationMixin(
# Path 2: root DB pull (fallback when no export files found yet).
# On a non-rooted device this path cannot succeed; _verify_runnerup_via_db
# reports that plainly rather than as a generic failure.
# reports that plainly rather than as a generic failure. Needs adb.
if not self._has_adb_device():
return (
"not_verified",
(
f"No RunnerUp export for today in {RUNNERUP_WEBDAV_DIRS} "
"and the phone is not adb-reachable"
),
)
return self._verify_runnerup_via_db()

View File

@ -0,0 +1,232 @@
"""Off-LAN instant credit: hold a Firebase RTDB stream of the device logs.
Off the home LAN the phone's only transport is Firebase, so without this a
finished session waits up to 15 minutes for ``workout-sync.timer``. This
thread, inside ``workout-poke.service``, keeps ``GET
<db>/screen-locker-sync/devices.json`` open as Server-Sent Events and credits
each newly streamed session within about a second
(:class:`screen_locker._stream_cache.DeviceLogCache`).
Free-tier budget (Spark: 10 GB/month download, no per-read quota). Every
(re)connect downloads the whole devices node once: 375,755 bytes measured on
2026-10-09 (18 device dirs; logged with each snapshot, after transfer
decoding, so an upper bound on the wire). The ID token lives one hour, so
that is ~24 snapshots a day, ~9 MB: less than the 15-min sync, which fetches
every device log on every tick. After the snapshot, a write streams only the
written device's whole log: ~83 KB for the current phone, ~43 KB for each PC
push. RTDB sends NO event for a byte-identical write (observed), and the
phone's home-screen ``syncNow`` skips the write when its log is unchanged, so
no-op syncs cost nothing. Nothing here issues a read beyond the stream.
Every failure is logged at warning or above with what it means, the thread
backs off (1 s doubling to 60 s, jittered) and never touches the HTTP
listener. The sync stays the safety net: the stream only makes credit faster.
"""
from __future__ import annotations
from datetime import UTC, datetime
import logging
import random
import threading
import time
from typing import TYPE_CHECKING, Final
import requests
import urllib3
from screen_locker._device import device_identity
from screen_locker._rtdb_stream import parse_sse
from screen_locker._stream_auth import StreamError, stream_auth
from screen_locker._stream_cache import DeviceLogCache
from screen_locker._workout_sync import _DEVICES_PREFIX
if TYPE_CHECKING:
from collections.abc import Iterator
from screen_locker._rtdb_stream import SseEvent
__all__ = ["SessionStream"]
_logger: Final = logging.getLogger(__name__)
_FALLBACK: Final = (
"off-LAN workouts will only be credited by the 15-min workout-sync until "
"this recovers"
)
_BACKOFF_MIN_S: Final = 1.0
_BACKOFF_MAX_S: Final = 60.0
# RTDB sends a keep-alive every ~30 s; silence past this means a dead socket.
_TIMEOUT_S: Final = (10, 90)
_LOGGED_KEEPALIVES: Final = 3
_HTTP_AUTH_REJECTED: Final = (401, 403)
# A connection that ends sooner than this pays a backoff even when the end was
# planned (auth_revoked, renewal): no path may reconnect in a tight loop, and
# each reconnect re-downloads the snapshot.
_MIN_LIFETIME_S: Final = 300.0
_STREAM_ERRORS: Final = (
requests.RequestException,
urllib3.exceptions.HTTPError,
OSError,
)
_JITTER: Final = random.SystemRandom()
class SessionStream:
"""A daemon thread holding the stream; one connection at a time."""
def __init__(self, gate: threading.Lock) -> None:
"""``gate`` is the poke server's credit lock, so credits never race."""
identity = device_identity()
self._cache = DeviceLogCache(
_DEVICES_PREFIX,
frozenset({identity.device_id, identity.legacy_id}),
gate,
)
self._http = requests.Session()
self._stop = threading.Event()
self._got_snapshot = False
def start(self) -> threading.Thread:
"""Run :meth:`run_forever` on a daemon thread and return it."""
thread = threading.Thread(
target=self.run_forever, name="firebase-session-stream", daemon=True
)
thread.start()
return thread
def stop(self) -> None:
"""Ask the loop to exit after the current connection."""
self._stop.set()
def run_forever(self) -> None:
"""Connect, stream, back off on failure; never raise out of the thread."""
delay = _BACKOFF_MIN_S
while not self._stop.is_set():
self._got_snapshot = False
opened = time.monotonic()
planned = self._run_one()
lived = time.monotonic() - opened >= _MIN_LIFETIME_S
if self._got_snapshot and lived:
delay = _BACKOFF_MIN_S
if planned and lived:
continue # a long-lived connection ending on schedule
pause = delay * _JITTER.uniform(0.5, 1.0)
delay = min(delay * 2, _BACKOFF_MAX_S)
_logger.warning("Reconnecting the session stream in %.1f s", pause)
self._stop.wait(pause)
def _run_one(self) -> bool:
"""One connection; True if it ended on purpose (renewal, auth_revoked)."""
try:
self._connect_once()
except StreamError as exc:
_logger.warning("Firebase session stream failed: %s; %s", exc, _FALLBACK)
return False
except Exception: # pylint: disable=broad-exception-caught # daemon boundary: logged, loop reconnects
_logger.exception("Firebase session stream crashed; %s", _FALLBACK)
return False
return True
def _connect_once(self) -> None:
"""One connection; returns only when it ended on purpose.
Raises:
StreamError: Auth, HTTP or protocol failure.
"""
auth = stream_auth(_DEVICES_PREFIX)
try:
response = self._http.get(
auth.url,
params={"auth": auth.token},
headers={"Accept": "text/event-stream"},
stream=True,
timeout=_TIMEOUT_S,
)
except _STREAM_ERRORS as exc:
msg = f"network error opening the stream: {exc}"
raise StreamError(msg) from exc
with response:
if not response.ok:
kind = (
"rejected (token or rules)"
if response.status_code in _HTTP_AUTH_REJECTED
else "failed"
)
msg = (
f"stream {kind}: HTTP {response.status_code} {response.text[:200]}"
)
raise StreamError(msg)
_logger.info("Firebase session stream connected to %s", _DEVICES_PREFIX)
self._consume(response, auth.renew_at)
def _consume(self, response: requests.Response, renew_at: datetime) -> None:
"""Dispatch events until the server or the renew deadline ends it.
Raises:
StreamError: The stream broke, was cancelled or closed unasked.
"""
meter = _ByteMeter()
keepalives = 0
try:
for event in parse_sse(meter.lines(response)):
if event.name == "keep-alive":
keepalives += 1
if keepalives <= _LOGGED_KEEPALIVES:
_logger.info("Session stream keep-alive #%d", keepalives)
elif event.name in {"put", "patch"}:
self._dispatch(event, meter.total)
elif event.name == "auth_revoked":
_logger.warning(
"Session stream token revoked/expired (%s); reconnecting",
event.data,
)
return
elif event.name == "cancel":
msg = f"the database cancelled the stream ({event.data})"
raise StreamError(msg)
else:
_logger.warning("Session stream ignored event %r", event.name)
if datetime.now(UTC) >= renew_at:
_logger.info("Session stream renewing its ID token before expiry")
return
except _STREAM_ERRORS as exc:
msg = f"stream broke after {meter.total} bytes: {exc}"
raise StreamError(msg) from exc
msg = f"the server closed the stream after {meter.total} bytes"
raise StreamError(msg)
def _dispatch(self, event: SseEvent, total_bytes: int) -> None:
"""Hand one put/patch to the cache and log what it did."""
try:
result = self._cache.handle(event)
except (ValueError, TypeError) as exc:
_logger.warning("Session stream event undecodable (%s); %s", exc, _FALLBACK)
return
self._got_snapshot |= result.snapshot
_logger.info(
"Session stream %s (%d bytes read on this connection): %s",
"snapshot" if result.snapshot else event.name,
total_bytes,
result.summary,
)
class _ByteMeter:
"""Splits the raw stream into lines as bytes arrive, counting them."""
def __init__(self) -> None:
self.total = 0
def lines(self, response: requests.Response) -> Iterator[str]:
"""Yield decoded lines without waiting for a full read buffer.
``iter_lines`` reads fixed-size chunks and would sit on a 30-byte
keep-alive until more data came; ``read1`` returns what has arrived.
"""
pending = b""
while chunk := response.raw.read1(65536):
self.total += len(chunk)
*complete, pending = (pending + chunk).split(b"\n")
for line in complete:
yield line.rstrip(b"\r").decode("utf-8")

View File

@ -2,28 +2,50 @@
from __future__ import annotations
from datetime import UTC, datetime
from datetime import date
import logging
import subprocess
from typing import TYPE_CHECKING
import earned_time
from screen_locker._ceiling import ceiling
from screen_locker._constants import (
ADJUST_SHUTDOWN_SCRIPT,
SHUTDOWN_CONFIG_FILE,
)
from screen_locker._shutdown_sick_state import SickDayStateMixin
from screen_locker._wake_alarm import WakeAlarmMixin
from screen_locker._day import today_str
from screen_locker._earned import first_minutes, hhmm, span
from screen_locker._grace_floor import plan_absorb, save_lift
from screen_locker._late_credit import LateCreditMixin
from screen_locker._sick import is_sick
from screen_locker._sick_floor import SickFloorMixin
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
_SHUTDOWN_CONFIG_KEYS = ("MON_WED_HOUR", "THU_SUN_HOUR", "MORNING_END_HOUR")
# Every time below is minutes after midnight. The config's *_MINUTES keys are
# authoritative; a file written before the minutes migration has only the
# whole-hour *_HOUR keys, read as HOUR * 60.
_SHUTDOWN_CONFIG_NAMES = ("MON_WED", "THU_SUN", "MORNING_END")
_MINUTES_PER_HOUR = 60
def _parse_config(path: Path) -> dict[str, int]:
"""Every integer ``KEY=value`` line of *path*."""
parsed: dict[str, int] = {}
with path.open() as f:
for line in f:
key, sep, value = line.strip().partition("=")
if sep and value.strip().isdigit():
parsed[key] = int(value)
return parsed
def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
"""Read shutdown config from *path*. Returns (mw_hour, ts_hour, me_hour) or None.
"""Read shutdown config from *path* as (mon_wed, thu_sun, morning_end) minutes.
Reading needs no privilege (only writing does, via
``adjust_shutdown_schedule.sh``) — safe to call from a read-only status view.
@ -31,112 +53,96 @@ def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
if not path.exists():
_logger.warning("Config not found: %s", path)
return None
parsed: dict[str, int] = {}
with path.open() as f:
for line in f:
stripped = line.strip()
for key in _SHUTDOWN_CONFIG_KEYS:
if stripped.startswith(f"{key}="):
parsed[key] = int(stripped.split("=")[1])
if len(parsed) < len(_SHUTDOWN_CONFIG_KEYS):
_logger.warning("Shutdown config missing required values")
return None
return (
parsed["MON_WED_HOUR"],
parsed["THU_SUN_HOUR"],
parsed["MORNING_END_HOUR"],
)
parsed = _parse_config(path)
values: list[int] = []
for name in _SHUTDOWN_CONFIG_NAMES:
if f"{name}_MINUTES" in parsed:
values.append(parsed[f"{name}_MINUTES"])
elif f"{name}_HOUR" in parsed:
values.append(parsed[f"{name}_HOUR"] * _MINUTES_PER_HOUR)
else:
_logger.warning("Shutdown config missing required values")
return None
return values[0], values[1], values[2]
class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
class ShutdownMixin(SickFloorMixin, LateCreditMixin):
"""Mixin providing shutdown schedule adjustment functionality."""
def _apply_earlier_shutdown(self, today: str) -> bool:
"""Read config, save state, and write earlier shutdown hours."""
config_values = self._read_shutdown_config()
if config_values is None:
return False
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
if not self._save_sick_day_state(today, mon_wed_hour, thu_sun_hour):
_logger.error("Failed to save state - aborting adjustment")
return False
new_mon_wed = max(18, mon_wed_hour - 1)
new_thu_sun = max(18, thu_sun_hour - 1)
return self._write_shutdown_config(
new_mon_wed,
new_thu_sun,
morning_end_hour,
)
def _adjust_shutdown_time_later(self, at: float | None = None) -> bool:
"""Push shutdown later by the first workout's reward, capped at the ceiling.
def _adjust_shutdown_time_earlier(self) -> bool:
"""Adjust shutdown schedule 1.5 hours earlier (stricter).
This can only be used once per day. Original values are saved and
automatically restored when checked the next day.
Returns True if successful, False otherwise.
"""
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
self._restore_original_config_if_needed()
if self._sick_mode_used_today():
_logger.warning("Sick mode already used today")
return False
try:
return self._apply_earlier_shutdown(today)
except (OSError, ValueError) as e:
_logger.warning("Failed to adjust shutdown time: %s", e)
return False
def _adjust_shutdown_time_later(self) -> bool:
"""Adjust shutdown schedule 2 hours later as workout reward.
Returns True if successful, False otherwise.
``at`` is when the workout ended (default now): a late one counts from
it (:mod:`._late_credit`). Returns True if successful, False otherwise.
"""
day = date.fromisoformat(today_str())
reward = first_minutes(earned_time.WORKOUT, day)
if is_sick(day):
return self._raise_on_sick_day(reward, at)
late = self._late_write(reward, at)
if late is not None:
return late
bonus, lift = plan_absorb(reward)
try:
config_values = self._read_shutdown_config()
if config_values is None:
return False
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
new_mon_wed = min(23, mon_wed_hour + 2)
new_thu_sun = min(23, thu_sun_hour + 2)
return self._write_shutdown_config(
new_mon_wed,
new_thu_sun,
morning_end_hour,
mon_wed, thu_sun, morning_end = config_values
ok = self._write_shutdown_config(
min(ceiling(day), mon_wed + bonus),
min(ceiling(day), thu_sun + bonus),
morning_end,
restore=True,
)
except (OSError, ValueError) as e:
_logger.warning("Failed to adjust shutdown time for workout: %s", e)
return False
if ok:
save_lift(lift)
return ok
def _adjust_shutdown_time_by(self, extra_hours: int) -> bool:
"""Adjust shutdown hours by *extra_hours*, capped at 24 (midnight).
def _adjust_shutdown_time_by(
self, extra_minutes: int, at: float | None = None
) -> bool:
"""Push shutdown later by *extra_minutes*, capped at the day's ceiling.
Used for extra-workout bonuses beyond the weekly minimum. A cap of 24
works because ``day-specific-shutdown-check.sh`` fires at 00:00 and
catches it via the morning-window condition (0 <= 300 minutes).
Used for earner and extra-workout bonuses. The cap is
``earned_time.shutdown_ceiling_for(today)`` (23:00 today, lower if
``WAKE_MINUTES`` ever moves); the helper's 23:00 stays the hard bound.
Returns True if successful, False otherwise.
``at`` is when the credit was earned (default now): a late one counts
from it (:mod:`._late_credit`). Returns True if successful.
"""
day = date.fromisoformat(today_str())
if is_sick(day):
return self._raise_on_sick_day(extra_minutes, at)
late = self._late_write(extra_minutes, at)
if late is not None:
return late
# A stale lift recorded before the grace floor went is spent first.
moved, lift = plan_absorb(extra_minutes)
try:
config_values = self._read_shutdown_config()
if config_values is None:
return False
mw, ts, morning = config_values
return self._write_shutdown_config(
min(24, mw + extra_hours),
min(24, ts + extra_hours),
ok = self._write_shutdown_config(
min(ceiling(day), mw + moved),
min(ceiling(day), ts + moved),
morning,
restore=True,
)
except (OSError, ValueError) as e:
_logger.warning(
"Failed to adjust shutdown time by %d h: %s", extra_hours, e
"Failed to adjust shutdown time by %s: %s", span(extra_minutes), e
)
return False
if ok:
save_lift(lift)
return ok
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
"""Read shutdown config. Returns (mw_hour, ts_hour, me_hour) or None."""
"""Read shutdown config as (mon_wed, thu_sun, morning_end) minutes, or None."""
return read_shutdown_config(SHUTDOWN_CONFIG_FILE)
def _build_shutdown_cmd(
@ -151,23 +157,23 @@ class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
cmd = ["/usr/bin/sudo", str(ADJUST_SHUTDOWN_SCRIPT)]
if restore:
cmd.append("--restore")
cmd.extend([str(mon_wed), str(thu_sun), str(morning)])
cmd.extend([hhmm(mon_wed), hhmm(thu_sun), hhmm(morning)])
return cmd
def _write_shutdown_config(
self,
mon_wed_hour: int,
thu_sun_hour: int,
morning_end_hour: int,
mon_wed: int,
thu_sun: int,
morning_end: int,
*,
restore: bool = False,
) -> bool:
"""Write new shutdown config values using helper script.
Args:
mon_wed_hour: Shutdown hour for Monday-Wednesday.
thu_sun_hour: Shutdown hour for Thursday-Sunday.
morning_end_hour: Morning end hour.
mon_wed: Monday-Wednesday shutdown, minutes after midnight.
thu_sun: Thursday-Sunday shutdown, minutes after midnight.
morning_end: End of the morning window, minutes after midnight.
restore: If True, allows restoring to later times.
Returns True if successful, False otherwise.
@ -179,18 +185,18 @@ class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
)
return False
cmd = self._build_shutdown_cmd(
mon_wed_hour,
thu_sun_hour,
morning_end_hour,
mon_wed,
thu_sun,
morning_end,
restore=restore,
)
return self._run_shutdown_cmd(cmd, mon_wed_hour, thu_sun_hour)
return self._run_shutdown_cmd(cmd, mon_wed, thu_sun)
def _run_shutdown_cmd(
self,
cmd: list[str],
mon_wed_hour: int,
thu_sun_hour: int,
mon_wed: int,
thu_sun: int,
) -> bool:
"""Execute the shutdown adjustment command."""
try:
@ -204,9 +210,9 @@ class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
_logger.warning("Failed to adjust shutdown config: %s", e)
return False
_logger.info(
"Adjusted shutdown: Mon-Wed=%d, Thu-Sun=%d. %s",
mon_wed_hour,
thu_sun_hour,
"Adjusted shutdown: Mon-Wed=%s, Thu-Sun=%s. %s",
hhmm(mon_wed),
hhmm(thu_sun),
result.stdout.strip(),
)
return True

View File

@ -1,114 +1,248 @@
"""Daily shutdown-time base reset for the screen locker.
On each new calendar day the shutdown config is reset to base hours (21:00
by default) so that the day's workout bonuses always layer on top of a known
floor rather than accumulating indefinitely across days.
On each new calendar day the shutdown config is reset to the day's base so
that the day's bonuses always layer on top of a known floor rather than
accumulating indefinitely across days.
The sick-day state file is cleared on reset so the sick-restore path cannot
overwrite the fresh base when it runs later in the same startup.
The base and every bonus come from the shared earner registry in force that
day (``earned_time``, see :mod:`screen_locker._earned`). Nothing here is
state: the file carries only stamps (``<name>_bonus_date`` for a flat
earner, ``<name>_bonus_units`` for a counted gate, :mod:`._gate_bonus`).
The reset re-derives what today has ALREADY earned and writes base plus that
in one go: the workout hours from today's log and every flat earner's hour
from its gate's ledger. Live credits are read-add-write against the config,
so a workout credited between local midnight and the first locker tick of the
day (a manual log at 00:02, say) had its hours wiped by the reset that
followed -- on 2026-09-13 that left the bar at 21:00 with a counted workout on
disk. Any bonus source that is not a term of this derivation has that bug;
registering it in ``earned_time`` makes it one.
A sick day (``sickdays``) is resolved with ``sick=True``: 20:00 plus the
gaming pay of the day's credits, with no lift saved. A day marked sick after
the reset is raised by the live pass (:mod:`screen_locker._sick_floor`).
"""
from __future__ import annotations
from datetime import UTC, datetime
from datetime import date, datetime
import json
import logging
from typing import TYPE_CHECKING, Any
import earned_time
from screen_locker._bonus_lock import bonus_lock
from screen_locker._day import today_str
from screen_locker._earned import (
counted_gate_earners,
earned_today,
first_credit_time,
first_credits,
first_minutes,
flat_earners,
hhmm,
registry,
span,
)
from screen_locker._gate_bonus import apply_counted_bonus, date_stamp, reset_stamps
from screen_locker._grace_floor import save_lift
from screen_locker._log_io import load_workout_log
from screen_locker._shutdown_target import gather
from screen_locker._sick import is_sick
from screen_locker._weekly_check import count_day_credits
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
_DEFAULT_BASE_HOUR = 21
# Morning end assumed when the live config cannot be read: 05:00.
_DEFAULT_MORNING_END = 5 * 60
def get_base_hours(state_file: Path) -> tuple[int, int]:
"""Return ``(base_mon_wed_hour, base_thu_sun_hour)`` from *state_file*.
def base_minutes(day: date | None = None) -> int:
"""The base shutdown for ``day`` (default today), in minutes after midnight.
Falls back to ``(21, 21)`` if the file is missing or corrupt.
Every penalty in force is already taken off; from today on, a gate's not
before it has paid out (:func:`~screen_locker._earned.first_credits`). A
sick day's base is raised to the sick floor, as ``earned_time.resolve``
does for a sick day that earned nothing.
"""
target = day or datetime.now().astimezone().date()
earners = registry(target)
starts = first_credits(earners, target)
base = (
earned_time.base_for(target, earners)
if starts is None
else earned_time.base_for(target, earners, first_credits=starts)
).shutdown_minutes
if is_sick(target):
return max(base, earned_time.sick_shutdown_from(0, target))
return base
def today_credit_count(log_file: Path, today: str) -> int:
"""Return how many distinct workouts today's log counts.
Counted with the same :func:`~screen_locker._weekly_check.count_day_credits`
rule as the weekly total, so a workout recorded twice earns once.
"""
entries = load_workout_log(log_file).get(today, [])
return count_day_credits(today, [e for e in entries if isinstance(e, dict)])
def _load_state(state_file: Path) -> dict[str, Any]:
"""Return the reset state, or an empty dict when it is missing or corrupt.
A corrupt file is logged and treated as "nothing stamped": the reset then
runs again, which is idempotent, and a flat hour is re-applied at worst
once, capped by the ceiling.
"""
if not state_file.exists():
return (_DEFAULT_BASE_HOUR, _DEFAULT_BASE_HOUR)
return {}
try:
with state_file.open() as f:
state: dict[str, Any] = json.load(f)
return (
int(state.get("base_mon_wed_hour", _DEFAULT_BASE_HOUR)),
int(state.get("base_thu_sun_hour", _DEFAULT_BASE_HOUR)),
)
except (OSError, json.JSONDecodeError, ValueError) as exc:
except (OSError, json.JSONDecodeError) as exc:
_logger.warning(
"Could not read shutdown base hours from %s: %s — defaulting both "
"to %02d:00",
"Could not read shutdown reset state from %s: %s -- treating today "
"as not yet reset",
state_file,
exc,
_DEFAULT_BASE_HOUR,
)
return (_DEFAULT_BASE_HOUR, _DEFAULT_BASE_HOUR)
return {}
return state
def _save_state(state_file: Path, state: dict[str, Any]) -> None:
"""Write the reset state, logging rather than raising on failure."""
try:
with state_file.open("w") as f:
json.dump(state, f, indent=2)
except OSError as exc:
_logger.warning("Failed to write shutdown reset state: %s", exc)
def reset_to_base_if_new_day(
state_file: Path,
mixin: object,
sick_day_state_file: Path | None = None,
log_file: Path | None = None,
) -> bool:
"""Reset the shutdown config to base hours if a new calendar day has begun.
"""Reset the shutdown config to the base if a new calendar day began.
Writes base hours via *mixin*._write_shutdown_config (with restore=True so
the script allows moving the time earlier), updates ``last_reset_date`` in
*state_file*, and removes *sick_day_state_file* if it exists so the
sick-restore path does not fight with the fresh base on the same startup.
Writes the base plus whatever today has already earned -- the workout
time in *log_file* (see :func:`today_credit_count`) and every flat
earner's time -- via *mixin*._write_shutdown_config (with restore=True so
the script allows moving the time earlier) and stamps ``last_reset_date``
in *state_file*.
Returns True if a reset was performed, False if today was already reset.
"""
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
with bonus_lock(state_file):
return _reset(state_file, mixin, log_file)
if state_file.exists():
try:
with state_file.open() as f:
state: dict[str, Any] = json.load(f)
if state.get("last_reset_date") == today:
return False
except (OSError, json.JSONDecodeError) as exc:
_logger.warning(
"Could not read last_reset_date from %s: %s — cannot tell if "
"today was already reset, so resetting to base hours again",
state_file,
exc,
)
base_mw, base_ts = get_base_hours(state_file)
def _reset(
state_file: Path,
mixin: object,
log_file: Path | None,
) -> bool:
""":func:`reset_to_base_if_new_day`'s body; the caller holds the lock."""
today = today_str()
if _load_state(state_file).get("last_reset_date") == today:
return False
# Preserve the morning-end hour from the live config.
derived = gather(date.fromisoformat(today), log_file)
day = derived.resolution
target = derived.minutes
# Preserve the morning end from the live config.
config = mixin._read_shutdown_config()
morning_end = config[2] if config else 5
morning_end = config[2] if config else _DEFAULT_MORNING_END
ok: bool = mixin._write_shutdown_config(base_mw, base_ts, morning_end, restore=True)
ok: bool = mixin._write_shutdown_config(target, target, morning_end, restore=True)
if not ok:
_logger.warning("Daily base reset: failed to write shutdown config.")
return False
# Clear stale sick-day state so it does not override the base reset.
if sick_day_state_file is not None and sick_day_state_file.exists():
try:
sick_day_state_file.unlink()
_logger.info("Daily base reset: cleared stale sick-day state.")
except OSError as exc:
_logger.warning(
"Daily base reset: could not remove sick-day state: %s", exc
)
# 0 now that no floor lifts the sum; overwrites a stale lift from before.
save_lift(derived.lift)
new_state: dict[str, Any] = {
"base_mon_wed_hour": base_mw,
"base_thu_sun_hour": base_ts,
"last_reset_date": today,
}
try:
with state_file.open("w") as f:
json.dump(new_state, f, indent=2)
except OSError as exc:
_logger.warning("Daily base reset: failed to write state file: %s", exc)
# The bonuses the reset already included are stamped, so the live pass
# does not add them a second time today.
new_state: dict[str, Any] = {"last_reset_date": today}
new_state.update(reset_stamps(day.terms, today))
_save_state(state_file, new_state)
_logger.info("Daily base reset: Mon-Wed=%d, Thu-Sun=%d.", base_mw, base_ts)
earned = " + ".join(
f"{span(t.shutdown_minutes)} {t.earner.label}" for t in day.terms
)
_logger.info(
"Daily base reset: %s (base %s + %s already earned today%s%s).",
hhmm(target),
hhmm(day.base.shutdown_minutes),
earned,
"; rest day" if derived.rest_day else "",
"; sick day" if day.sick else "",
)
return True
def _apply_flat_bonus(
state_file: Path,
mixin: object,
earner: earned_time.Earner,
) -> bool:
"""Push shutdown later by one earner's once-per-day time, if it is earned.
The daily reset only sees a bonus earned before it ran; this is the live
counterpart for the usual case, where the solve or the reading lands
hours later and is picked up by the next timer tick. Idempotent through
the earner's stamp in *state_file*, which the reset sets as well when it
already included the bonus.
Returns True if the bonus was applied on this call.
"""
today = today_str()
state = _load_state(state_file)
stamp = date_stamp(earner)
if state.get(stamp) == today:
return False
answer = earned_today(earner)
if answer is None:
_logger.warning(
"%s state could not be checked; no shutdown time for it", earner.label
)
return False
if not answer:
return False
day = date.fromisoformat(today)
minutes = first_minutes(earner, day)
# Anchored on the credit's own row, so a late solve counts from its time.
if not mixin._adjust_shutdown_time_by(minutes, at=first_credit_time(earner, day)):
_logger.warning("%s bonus: failed to write shutdown config.", earner.label)
return False
state[stamp] = today
_save_state(state_file, state)
_logger.info("%s bonus: +%s shutdown time today.", earner.label, span(minutes))
return True
def apply_flat_bonuses_if_new(state_file: Path, mixin: object) -> None:
"""Every once-per-day flat bonus, in registry order (LeetCode, reading, ...).
Then each counted gate's new units (:mod:`._gate_bonus`); the workout's
live credit is ``WorkoutCreditMixin``'s.
"""
with bonus_lock(state_file):
for earner in flat_earners():
_apply_flat_bonus(state_file, mixin, earner)
adjust = mixin._adjust_shutdown_time_by
for earner in counted_gate_earners():
state = _load_state(state_file)
if apply_counted_bonus(state, adjust, earner, today_str()):
_save_state(state_file, state)
# Last, so the sick target sees everything earned so far; it covers a
# day marked sick after today's reset.
mixin._apply_sick_floor()

Some files were not shown because too many files have changed in this diff Show More