Compare commits

..

No commits in common. "main" and "v1.0.0-build.330" have entirely different histories.

612 changed files with 8763 additions and 33819 deletions

2
.fvmrc
View File

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

View File

@ -60,7 +60,7 @@ jobs:
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
if: matrix.group == 'flutter'
with:
flutter-version: 3.47.9
flutter-version: 3.47.7
working-directory: stronglift_replacement/workout_app
# analyze needs resolved packages (.dart_tool); the hook runs analyze
# only, so fetch deps here first.
@ -83,7 +83,7 @@ jobs:
- uses: actions/setup-node@v4
if: matrix.group == 'rest'
with:
node-version: "26.13.0"
node-version: "24.22.1"
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install status-UI packages

View File

@ -58,11 +58,11 @@ jobs:
enable-cache: true
cache-dependency-glob: requirements.txt
# No apt step: the runner image already ships Xvfb, and
# No apt step: the runner image already ships xvfb-run, 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
# skip -- _xvfb_reexec returns 1 without xvfb-run, and the import fails
# without tkinter.
- name: Install dependencies
run: |
@ -99,7 +99,7 @@ jobs:
# cache cost ~38 s to restore even on a hit.
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
with:
flutter-version: 3.47.9
flutter-version: 3.47.7
working-directory: stronglift_replacement/workout_app
- name: flutter test --coverage, bucket ${{ matrix.shard }}
@ -136,7 +136,7 @@ jobs:
package_json_file: web/package.json
- uses: actions/setup-node@v4
with:
node-version: 26.13.0
node-version: 24.22.1
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml

View File

@ -60,7 +60,7 @@ jobs:
- uses: subosito/flutter-action@v2
with:
flutter-version: 3.47.9
flutter-version: 3.47.7
channel: stable
cache: true

1
.gitignore vendored
View File

@ -38,7 +38,6 @@ 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

View File

@ -8,18 +8,13 @@ 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$
@ -111,10 +106,7 @@ repos:
- types-requests>=2.31.0
- repo: https://github.com/pylint-dev/pylint
# 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
rev: v4.0.8
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 --
@ -127,36 +119,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. Both are a hard 10.00.
# what else happened to be staged alongside. See
# todo_fix_pylint_10_prompt.md for raising both to a hard 10.00.
- id: pylint
name: pylint (source, 10.00)
alias: pylint-source
name: pylint (source)
exclude: ^screen_locker/tests/
args:
- --rcfile=pyproject.toml
- --fail-under=10
- --fail-under=8.0
- --jobs=4
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.12.0#subdirectory=gatelock
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.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
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.16.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, 10.00)
name: pylint (tests)
alias: pylint-tests
files: ^screen_locker/tests/
args:
- --rcfile=pyproject.toml
- --fail-under=10
- --fail-under=8.0
- --jobs=4
- --disable=protected-access,unused-argument,duplicate-code
additional_dependencies:
- pytest
@ -164,9 +156,9 @@ 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.12.0#subdirectory=gatelock
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.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
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.16.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
@ -190,9 +182,6 @@ repos:
# 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
@ -201,33 +190,9 @@ repos:
always_run: true
require_serial: true
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
- id: ci-precommit
name: CI mirror (pre-commit --all-files)
entry: bash scripts/ci_mirror.sh precommit
language: system
pass_filenames: false
always_run: true
@ -274,13 +239,11 @@ 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.
# 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]
types_or: [python, shell, dart, ts, tsx]
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 -m scripts.check_silent_failures
entry: python3 scripts/check_silent_failures.py
language: system
types: [python]
- id: no-silent-failures-dart
@ -288,7 +251,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 -m scripts.check_silent_failures_dart
entry: python3 scripts/check_silent_failures_dart.py
language: system
types: [dart]
- id: screens-fit
@ -298,13 +261,6 @@ repos:
files: '^screen_locker/.*\.py$'
pass_filenames: false
require_serial: true
- id: text-free
name: No locker screen paints a word (textgate)
entry: python3 -m scripts.verify_text_free
language: system
files: '^(screen_locker|scripts)/.*\.py$'
pass_filenames: false
require_serial: true
- id: flutter-analyze
name: flutter analyze (workout app)
# env -u GIT_DIR: git exports it to hooks, and flutter shells out to
@ -315,46 +271,12 @@ repos:
language: system
files: '^stronglift_replacement/workout_app/.*\.(dart|yaml)$'
pass_filenames: false
- 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
- id: flutter-test-coverage
name: flutter test --coverage 100% (workout app)
entry: bash scripts/check_flutter_coverage.sh
language: system
files: '^stronglift_replacement/workout_app/.*\.dart$'
pass_filenames: false
always_run: true
require_serial: true
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]
@ -365,14 +287,14 @@ repos:
name: eslint (status web UI)
entry: pnpm --dir web run lint
language: system
files: '^web/(src/.*\.(ts|astro)|eslint\.config\.js|package\.json)$'
files: '^web/(src/.*\.(ts|tsx)|eslint\.config\.js|package\.json)$'
pass_filenames: false
require_serial: true
- id: web-typecheck
name: astro check + build (status web UI)
name: tsc --build (status web UI)
entry: pnpm --dir web run build
language: system
files: '^web/(src/.*\.(ts|astro)|tsconfig\.json|astro\.config\.mjs|vitest\.config\.ts)$'
files: '^web/(src/.*\.(ts|tsx)|tsconfig.*\.json|vite\.config\.ts)$'
pass_filenames: false
require_serial: true
- id: web-coverage
@ -394,30 +316,6 @@ 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.
#

View File

@ -122,4 +122,3 @@ wait, and tests pin the retry to 0.
- lint: `pre-commit run ruff --all-files`
- coverage: `python -m pytest -n auto -p no:sugar -q`
- coverage-gaps: `coverage-gaps coverage.lcov`
- deploy: `git -C ~/src/screen-locker pull --ff-only && bash ~/src/screen-locker/scripts/deploy.sh` (reinstalls, restarts web/poke/runnerup units, health-checks them, then release-deploys the workout app; exit 46 from the phone guard means kuhy reruns it with `PHONE_PROTECT_ALLOW=com.kuhy.workout_app`)

View File

@ -48,26 +48,6 @@ 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)
screen-locker exposes a **read-only** MCP server (`screen_locker._mcp`) so

View File

@ -0,0 +1,24 @@
# Cap every live shutdown bonus at the day's ceiling, not 24:00 / 23:00
Carried over from the 2026-10-10 tutor-cutover handoff (item 1a, optional).
## what
The live bonus paths clamp at 24:00 (`_shutdown.py` `_MIDNIGHT`, ~:152) and
then at `adjust_shutdown_schedule.sh`'s fixed `RESTORE_CEILING=1380` (23:00).
If `WAKE_MINUTES` ever moves the earned_time ceiling below 23:00, a bonus could
push shutdown past the real ceiling. Cap them at
`earned_time.shutdown_ceiling_for(day)` instead (already wrapped in
`_earned.py` ~:167).
## where
`screen_locker/_shutdown.py`, `screen_locker/adjust_shutdown_schedule.sh`,
`scripts/_preview_sim.py` (`HELPER_CEILING` mirrors `RESTORE_CEILING`).
## must
- The 168-case "do every task => 23:00" invariant test (fc54534) stays green.
- must not: loosen anything — the shell ceiling remains a hard upper bound.
## done
A test that sets a ceiling below 23:00 shows no bonus path exceeding it.
REMOVE ME AFTER FINISH

95
TODO-file-length-250.md Normal file
View File

@ -0,0 +1,95 @@
# 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 `~/src/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 ~/src/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

178
TODO-finish-dart-250.md Normal file
View File

@ -0,0 +1,178 @@
# 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 ~/src/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 ~/src/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 ~/src/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 ~/src/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 ~/src/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

143
TODO-pylint-to-ten.md Normal file
View File

@ -0,0 +1,143 @@
# 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

View File

@ -0,0 +1,126 @@
# Task: replace blind coordinate tapping with element-targeted Android UI automation
> **STATUS 2026-08-10: BUILT.** The tool now exists at
> `~/src/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 `~/src/todo` and
> `~/src/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:
- `~/src/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):
- `~/src/screen-locker/stronglift_replacement/workout_app`
- `~/src/todo`
- `~/src/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`).
- `~/src/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

@ -0,0 +1,464 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Driving the workout app to 100% test coverage</title>
<style>
:root {
--bg: #ffffff; --fg: #1a1a1a; --muted: #5a5a5a; --card: #f5f5f7;
--border: #e2e2e6; --accent: #4f46e5; --accent-soft: #ede9fe;
--good: #15803d; --good-bg: #dcfce7; --bad: #b91c1c; --bad-bg: #fee2e2;
--code-bg: #f6f8fa; --add: #e6ffec; --add-fg: #04562a; --del: #ffebe9; --del-fg: #82071e;
--pill: #eef2ff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14151a; --fg: #e6e6ea; --muted: #9aa0aa; --card: #1c1e26;
--border: #2a2d38; --accent: #a5b4fc; --accent-soft: #262347;
--good: #4ade80; --good-bg: #16351f; --bad: #f87171; --bad-bg: #3a1a1a;
--code-bg: #1a1c23; --add: #12321f; --add-fg: #7ee2a8; --del: #3a1a1d; --del-fg: #f0a8ae;
--pill: #23263a;
}
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.65; font-size: 17px;
}
.wrap { max-width: min(48rem, 92vw); margin: 0 auto; padding: 2.5rem 0 5rem; }
h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 .3rem; }
h2 { font-size: 1.5rem; margin: 3rem 0 1rem; padding-top: .4rem; border-top: 1px solid var(--border); }
h3 { font-size: 1.15rem; margin: 1.8rem 0 .6rem; }
p, li { color: var(--fg); }
.sub { color: var(--muted); font-size: 1.02rem; margin: 0 0 1.5rem; }
a { color: var(--accent); }
code { background: var(--code-bg); padding: .1em .35em; border-radius: 5px; font-size: .88em;
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; }
pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 10px;
padding: 1rem; overflow-x: auto; white-space: pre-wrap; word-break: break-word; font-size: .82rem; line-height: 1.55; }
pre code { background: none; padding: 0; font-size: inherit; }
.toc { background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 1rem 1.25rem; margin: 1.5rem 0 0; }
.toc strong { font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
.toc ol { margin: .5rem 0 0; padding-left: 1.2rem; }
.toc a { text-decoration: none; }
.toc a:hover { text-decoration: underline; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1.25rem; margin: 1.25rem 0; }
.pill { display: inline-block; background: var(--pill); color: var(--accent); font-size: .72rem;
font-weight: 600; padding: .15em .6em; border-radius: 999px; letter-spacing: .03em; }
.add { color: var(--add-fg); background: var(--add); display: block; }
.del { color: var(--del-fg); background: var(--del); display: block; }
.diagram { display: flex; flex-wrap: wrap; gap: .75rem; align-items: stretch; margin: 1.25rem 0; }
.zone { flex: 1 1 220px; border: 2px solid var(--border); border-radius: 12px; padding: 1rem; }
.zone.fake { border-color: var(--bad); }
.zone.real { border-color: var(--good); }
.zone h4 { margin: 0 0 .5rem; font-size: .95rem; }
.zone .tag { font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.zone.fake .tag { color: var(--bad); }
.zone.real .tag { color: var(--good); }
.box { border: 1px solid var(--border); border-radius: 8px; background: var(--bg); padding: .5rem .7rem; margin: .5rem 0; font-size: .82rem; }
.flowrow { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin: .4rem 0; font-size: .85rem; }
.arrow { color: var(--muted); font-weight: 700; }
.k { font-weight: 600; }
table.cov { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: .9rem; }
table.cov th, table.cov td { border: 1px solid var(--border); padding: .5rem .6rem; text-align: left; }
table.cov th { background: var(--card); }
table.cov td.num { text-align: right; font-variant-numeric: tabular-nums; }
.good { color: var(--good); font-weight: 600; }
.bad { color: var(--bad); font-weight: 600; }
/* micro-world */
.sim { background: var(--card); border: 1px solid var(--border); border-radius: 14px; padding: 1.25rem; margin: 1.5rem 0; }
.sim h4 { margin: 0 0 .25rem; }
.sim .controls { display: flex; flex-wrap: wrap; gap: .5rem; margin: .9rem 0; }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.seg button { background: var(--bg); color: var(--fg); border: none; padding: .5rem .85rem; font-size: .85rem; cursor: pointer; }
.seg button.on { background: var(--accent); color: #fff; }
.runbtn { background: var(--accent); color: #fff; border: none; border-radius: 10px; padding: .6rem 1.1rem; font-size: .9rem; font-weight: 600; cursor: pointer; }
.runbtn:active { transform: translateY(1px); }
.stage { margin-top: 1rem; border: 1px dashed var(--border); border-radius: 10px; padding: 1rem; background: var(--bg); }
.step { font-size: .85rem; margin: .3rem 0; opacity: .35; transition: opacity .25s; }
.step.active { opacity: 1; }
.verdict { margin-top: .9rem; font-weight: 700; padding: .6rem .8rem; border-radius: 8px; font-size: .95rem; }
.verdict.pass { background: var(--good-bg); color: var(--good); }
.verdict.fail { background: var(--bad-bg); color: var(--bad); }
.verdict.idle { background: var(--code-bg); color: var(--muted); font-weight: 500; }
/* quiz */
.q { border: 1px solid var(--border); border-radius: 12px; padding: 1.1rem 1.25rem; margin: 1.1rem 0; background: var(--card); }
.q .qhead { font-weight: 600; margin-bottom: .75rem; }
.opt { display: block; width: 100%; text-align: left; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: 9px; padding: .6rem .8rem; margin: .4rem 0; font-size: .92rem; cursor: pointer; }
.opt:hover { border-color: var(--accent); }
.opt.correct { background: var(--good-bg); border-color: var(--good); color: var(--good); }
.opt.wrong { background: var(--bad-bg); border-color: var(--bad); color: var(--bad); }
.opt:disabled { cursor: default; }
.rationale { font-size: .85rem; color: var(--muted); margin-top: .5rem; display: none; }
.rationale.show { display: block; }
.score { position: sticky; bottom: 0; background: var(--accent); color: #fff; border-radius: 10px;
padding: .6rem 1rem; margin-top: 1rem; font-weight: 600; text-align: center; }
.mono { font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; font-size: .85em; }
</style>
</head>
<body>
<div class="wrap">
<span class="pill">CHANGE EXPLAINED · 2026-07-13</span>
<h1>Driving the workout app to 100% test coverage</h1>
<p class="sub">How a stuck push gate got unblocked — and the Flutter widget-test techniques that made the hard 267 lines of chart, calendar and live-workout UI testable.</p>
<nav class="toc">
<strong>Contents</strong>
<ol>
<li><a href="#background">Background — the gate that never went green</a></li>
<li><a href="#intuition">Intuition — two clocks, one test</a></li>
<li><a href="#code">Code — what actually changed</a></li>
<li><a href="#quiz">Quiz — check your understanding</a></li>
</ol>
</nav>
<h2 id="background">1 · Background</h2>
<p>The repo contains a Flutter phone app, <code>workout_app</code>, that tracks strength workouts and syncs them to a PC. To keep quality high, the project installs a <em>pre-push gate</em>: whenever a commit touches Dart code, pushing runs two checks locally before the push is allowed to leave your machine.</p>
<div class="card">
<div class="flowrow"><span class="k">git push</span> <span class="arrow">→</span> <span class="box"><b>flutter analyze</b> — zero issues (even <span class="mono">info</span>-level)</span></div>
<div class="flowrow"><span class="k"></span> <span class="arrow">→</span> <span class="box"><b>flutter test --coverage</b> — <b>100%</b> of every <code>lib/</code> line, and every file must appear in the report</span></div>
</div>
<p>That second check is strict in two ways. First, the bar is <strong>100%</strong> — not 90%, not "the lines you changed". Second, a helper script (<code>check_flutter_coverage.sh</code>) also fails if any <code>lib/**/*.dart</code> file is <em>missing</em> from the coverage report, so you can't quietly earn 100% by never importing a file into a test.</p>
<p>The catch: this gate had <strong>never once passed</strong> for a Dart commit. It was added while the app was only ~83% covered, so ten commits — an entire phone-to-PC sync feature — sat finished but <em>unpushable</em>, stuck behind 316 uncovered lines. This change closes that gap and ships all of it.</p>
<h3>Where the 316 lines lived</h3>
<table class="cov">
<thead><tr><th>File</th><th class="num">Before</th><th class="num">After</th><th>Nature of the gap</th></tr></thead>
<tbody>
<tr><td>storage_service.dart</td><td class="num">139/165</td><td class="num good">163/163</td><td>migrations, restore, edge branches</td></tr>
<tr><td>home_screen.dart</td><td class="num">117/121</td><td class="num good">121/121</td><td>post-navigation reloads</td></tr>
<tr><td>settings_screen.dart</td><td class="num">243/262</td><td class="num good">262/262</td><td>device-flow branches, steppers</td></tr>
<tr><td>history_screen.dart</td><td class="num">167/308</td><td class="num good">307/307</td><td>whole exercise drill-down + chart</td></tr>
<tr><td>workout_screen.dart</td><td class="num">144/270</td><td class="num good">269/269</td><td>live-workout breaks &amp; finish</td></tr>
</tbody>
</table>
<p>The two screens at the bottom — the history charts/calendar and the live-workout timer UI — held 267 of the 316 lines and were the genuinely hard part. Testing them ran head-first into a subtlety about <em>how time works inside a Flutter widget test</em>. That's the idea worth understanding, so it gets its own section.</p>
<h2 id="intuition">2 · Intuition — two clocks, one test</h2>
<p>A normal <code>testWidgets</code> test does <strong>not</strong> run on real time. Flutter wraps it in a <em>fake clock</em> (<span class="mono">FakeAsync</span>): timers only advance when you explicitly say <code>tester.pump(someDuration)</code>. This is usually wonderful — you can "wait" 3 minutes for a rest timer in a microsecond, deterministically.</p>
<p>But the app's real database, <span class="mono">sqflite</span>, schedules work on <em>real</em> timers and background isolates. Under the fake clock, those never get a chance to finish. So the moment a tapped widget kicks off a database write, you get a landmine:</p>
<div class="diagram">
<div class="zone fake">
<div class="tag">Fake-async zone (default)</div>
<h4>tap outside <span class="mono">runAsync</span></h4>
<div class="box">tap a set → <span class="mono">_tapCircle</span></div>
<div class="flowrow"><span class="arrow">↓</span></div>
<div class="box"><span class="mono">unawaited(_saveActiveSession())</span> → sqflite write</div>
<div class="flowrow"><span class="arrow">↓</span></div>
<div class="box bad">write parks on a real timer the fake clock never ticks</div>
<div class="flowrow"><span class="arrow">↓</span></div>
<div class="box"><b class="bad">✗ "Pending timers" — test fails</b></div>
</div>
<div class="zone real">
<div class="tag">Real loop via <span class="mono">tester.runAsync</span></div>
<h4>tap inside <span class="mono">runAsync</span></h4>
<div class="box">tap a set → <span class="mono">_tapCircle</span></div>
<div class="flowrow"><span class="arrow">↓</span></div>
<div class="box"><span class="mono">unawaited(_saveActiveSession())</span> → sqflite write</div>
<div class="flowrow"><span class="arrow">↓</span></div>
<div class="box good">real event loop runs the write to completion</div>
<div class="flowrow"><span class="arrow">↓</span></div>
<div class="box"><b class="good">✓ no leftover timers — test passes</b></div>
</div>
</div>
<p>So the rule that unlocked the live-workout screen is: <strong>any tap that triggers a database write or starts a <span class="mono">Timer.periodic</span> must be dispatched inside <code>tester.runAsync(...)</code></strong>, which runs that code on the genuine event loop where the work can actually finish. The assertion afterward happens on the fake clock again, so you still get deterministic <code>expect</code>s.</p>
<p>Two corollaries fell out of this while writing the tests:</p>
<ul>
<li><b>A dialog that awaits, then does DB work, is one flow.</b> When you tap "Finish", a confirm dialog opens; confirming resumes <code>_confirmFinish</code> in the <em>same zone the first tap fired in</em>, and that continuation does six sqflite writes. So the <em>whole</em> finish flow — open dialog, confirm, save — has to live in a single <code>runAsync</code> block, not just the confirm tap.</li>
<li><b><code>tester.longPress</code> fights <code>runAsync</code>.</b> It does its own internal fake-clock pumps to time the press, which don't advance inside <code>runAsync</code>. The fix is a manual gesture: <code>startGesture</code>, hold for a real 700 ms, then release — a real-time long-press.</li>
</ul>
<div class="sim" id="sim">
<h4>▶ Micro-world: run the test yourself</h4>
<p style="font-size:.9rem;color:var(--muted);margin:.2rem 0 0">Pick where the tapped code runs and what it does, then dispatch the tap. Watch whether the background work completes or leaks a pending timer.</p>
<div class="controls">
<div>
<div style="font-size:.75rem;color:var(--muted);margin-bottom:.25rem">Dispatch the tap in…</div>
<div class="seg" id="zoneSeg">
<button data-zone="fake" class="on">fake-async (default)</button>
<button data-zone="real">tester.runAsync</button>
</div>
</div>
<div>
<div style="font-size:.75rem;color:var(--muted);margin-bottom:.25rem">The tapped code…</div>
<div class="seg" id="workSeg">
<button data-work="db" class="on">writes to sqflite</button>
<button data-work="timer">starts a break timer</button>
<button data-work="pure">just calls setState</button>
</div>
</div>
</div>
<button class="runbtn" id="runBtn">Dispatch tap →</button>
<div class="stage" id="stage">
<div class="step" id="s1">1 · gesture dispatched, <span class="mono">onTap</span> fires</div>
<div class="step" id="s2">2 · handler runs…</div>
<div class="step" id="s3">3 · background work scheduled…</div>
<div class="step" id="s4">4 · test tears down, checks for leftover timers…</div>
<div class="verdict idle" id="verdict">Choose options and press “Dispatch tap”.</div>
</div>
</div>
<h3>The other half: reaching the code at all</h3>
<p>Zone discipline makes taps <em>survive</em>, but many lines were uncovered simply because the widget was never <em>built</em>. Flutter's <code>ListView</code> lazily builds only what's near the viewport, so the history screen's session tiles and the "DUMBBELL LUNGE" section label — which sit below an 800×600 test fold — never existed in the tree, and their lines stayed cold. The fix is to <code>scrollUntilVisible</code> / <code>ensureVisible</code> them first: scrolling builds them, which both renders and covers them.</p>
<h3>Honest 100%: what <code>coverage:ignore</code> is for</h3>
<p>A few lines genuinely can't run on a Linux CI host, so they're marked <code>coverage:ignore</code> with a stated reason rather than faked. The bar for an ignore is "no reachable test path exists," not "this was annoying to test":</p>
<div class="diagram">
<div class="box" style="flex:1 1 240px"><b>Android/iOS only</b><br><span style="font-size:.85rem;color:var(--muted)"><code>Vibration.vibrate()</code> — <code>hasVibrator()</code> returns <b>false</b> on Linux (no <code>Platform.isAndroid/isIOS</code>), so the body never runs there.</span></div>
<div class="box" style="flex:1 1 240px"><b>Production-only path</b><br><span style="font-size:.85rem;color:var(--muted)">The real on-device DB path (<code>getDatabasesPath()</code>) — tests always inject an in-memory or temp-file path instead.</span></div>
<div class="box" style="flex:1 1 240px"><b>Truly unreachable</b><br><span style="font-size:.85rem;color:var(--muted)">A defensive <code>null</code>-state branch that seeding makes impossible, and a history <code>_load</code> branch only reachable via a path that never calls <code>_load</code>.</span></div>
</div>
<h2 id="code">3 · Code — what actually changed</h2>
<p>The diff is <strong>792 insertions</strong> across ten files, but it splits cleanly into two buckets: a lot of <em>test</em> code, and a handful of tiny <em>production</em> edits that make code reachable from tests. Grouped by concern:</p>
<h3>A · Test seams in production code</h3>
<p>To test the "server not started" placeholder, a private widget had to become public — private widgets can't be constructed from a test file:</p>
<pre><code><span class="del">-class _ServerAddressTile extends StatelessWidget {
- const _ServerAddressTile({required this.addresses});</span>
<span class="add">+/// ...Public so the empty-address fallback can be exercised in a widget test.
+class ServerAddressTile extends StatelessWidget {
+ const ServerAddressTile({required this.addresses, super.key});</span></code></pre>
<p>And to test the schema migration, <code>resetForTesting</code> gained an optional path so a test can hand it a real <em>file</em> DB (in-memory DBs can't be reopened at a new version, which is exactly what an <code>onUpgrade</code> migration needs):</p>
<pre><code><span class="del">- static void resetForTesting() {
- _testDbPath = ':memory:';</span>
<span class="add">+ static void resetForTesting({String dbPath = ':memory:'}) {
+ _testDbPath = dbPath;</span></code></pre>
<h3>B · Justified coverage ignores</h3>
<p>Four small blocks are marked unreachable-on-CI, each with a comment explaining <em>why</em> — for example the haptics call:</p>
<pre><code> if (await Vibration.hasVibrator()) {
<span class="add">+ // Android/iOS-only: hasVibrator() returns false on the Linux test host
+ // (no Platform.isAndroid/isIOS), so this body never runs there.
+ // coverage:ignore-start</span>
unawaited(Vibration.vibrate(duration: 800));
<span class="add">+ // coverage:ignore-end</span>
}</code></pre>
<h3>C · The tests — real behaviour, on the real loop</h3>
<p>The bulk of the change. A representative live-workout test: a tap starts a rest break, and the tap is dispatched through a helper that keeps it on the real event loop so the sqflite write and periodic timer complete cleanly.</p>
<pre><code>Future&lt;void&gt; _tapReal(WidgetTester tester, Finder f) async {
await tester.runAsync(() async {
await tester.tap(f);
await Future&lt;void&gt;.delayed(const Duration(milliseconds: 100));
});
await tester.pump();
}
testWidgets('tapping a set starts a break which Skip cancels', (tester) async {
await _pump(tester, _wrap());
await _tapReal(tester, find.byType(RepCircle).first);
expect(find.byType(BreakBanner), findsOneWidget); // break started
await _tapReal(tester, find.text('Skip'));
expect(find.byType(BreakBanner), findsNothing); // Skip cancelled it
});</code></pre>
<p>And the schema-migration unit test that the new <code>dbPath</code> seam unlocked — build a v1 database on disk, open it through <code>StorageService</code> (which runs at v3), and assert the migration ran:</p>
<pre><code>final oldDb = await databaseFactory.openDatabase(dbFile,
options: OpenDatabaseOptions(version: 1, onCreate: /* v1 schema */));
await oldDb.close();
StorageService.resetForTesting(dbPath: dbFile); // real file, not :memory:
await StorageService.init(); // triggers onUpgrade 1 → 3
expect(await _svc.getNextWorkoutType(), 'A'); // settings table now exists
expect((await _svc.getExerciseState(name))!.successThreshold, 3); // column added</code></pre>
<h3>D · Making analyze green</h3>
<p>The gate also requires a spotless <code>flutter analyze</code>, and it exits non-zero on even an <span class="mono">info</span>. The last commit documents the newly-public field, reshapes the ignore comment so it isn't an over-long line, and reflows one pre-existing 81-char comment. Small, but the push doesn't go through without it.</p>
<h2 id="quiz">4 · Quiz</h2>
<p class="sub">Five questions on the reasoning behind the change. Click an answer to reveal whether it's right and why.</p>
<div id="quiz"></div>
<div class="score" id="score">Score: 0 / 0</div>
</div>
<script>
/* ---------- Micro-world ---------- */
(function () {
var zone = 'fake', work = 'db';
var zoneSeg = document.getElementById('zoneSeg');
var workSeg = document.getElementById('workSeg');
zoneSeg.addEventListener('click', function (e) {
if (!e.target.dataset.zone) return;
zone = e.target.dataset.zone;
[].forEach.call(zoneSeg.children, function (b) { b.classList.toggle('on', b.dataset.zone === zone); });
});
workSeg.addEventListener('click', function (e) {
if (!e.target.dataset.work) return;
work = e.target.dataset.work;
[].forEach.call(workSeg.children, function (b) { b.classList.toggle('on', b.dataset.work === work); });
});
var steps = ['s1', 's2', 's3', 's4'].map(function (id) { return document.getElementById(id); });
var verdict = document.getElementById('verdict');
var timers = [];
function clearTimers() { timers.forEach(clearTimeout); timers = []; }
function labelFor() {
if (work === 'db') return 'schedules a sqflite write (a real timer)';
if (work === 'timer') return 'starts a Timer.periodic rest break';
return 'only calls setState — no async work';
}
document.getElementById('runBtn').addEventListener('click', function () {
clearTimers();
steps.forEach(function (s) { s.classList.remove('active'); });
verdict.className = 'verdict idle';
verdict.textContent = 'Running…';
var leaks = (zone === 'fake') && (work === 'db' || work === 'timer');
var texts = [
'1 · gesture dispatched, onTap fires',
'2 · handler runs in the ' + (zone === 'fake' ? 'fake-async zone' : 'real event loop (runAsync)'),
'3 · handler ' + labelFor(),
'4 · teardown checks for leftover timers…'
];
steps.forEach(function (s, i) {
timers.push(setTimeout(function () {
s.classList.add('active');
s.textContent = texts[i];
}, 350 * (i + 1)));
});
timers.push(setTimeout(function () {
if (leaks) {
verdict.className = 'verdict fail';
verdict.textContent = '✗ FAIL — the ' + (work === 'db' ? 'sqflite write' : 'periodic timer') +
' never completed under the fake clock. "Pending timers" at teardown.';
} else if (zone === 'real' && work !== 'pure') {
verdict.className = 'verdict pass';
verdict.textContent = '✓ PASS — on the real loop the ' + (work === 'db' ? 'write' : 'timer') +
' finished (or was cancelled on dispose). No leftover timers.';
} else {
verdict.className = 'verdict pass';
verdict.textContent = '✓ PASS — pure setState schedules no async work, so there is nothing to leak in either zone.';
}
}, 350 * 5));
});
})();
/* ---------- Quiz ---------- */
var QUESTIONS = [
{
q: "A set-tap test run outside runAsync failed with “pending timers.” What caused it?",
options: [
"Its sqflite write and break timer stayed unfinished in the FakeAsync zone",
"The RepCircle sat below the 800×600 test fold so the simulated tap never landed",
"The setUp block forgot the fake secure-storage handler the tap path needs",
"The BreakBanner threw while building because its remaining value was null"
],
correct: 0,
why: "Under the fake clock, the real timers that sqflite (and Timer.periodic) schedule never advance, so they linger and fail teardown."
},
{
q: "Why does dispatching that same tap inside tester.runAsync fix the failure?",
options: [
"runAsync disables the widget's own timers so none of them can leak later",
"It runs that code on the real event loop, where the DB write completes",
"It auto-mocks the vibration and audio platform channels for the test",
"It forces a pumpAndSettle that drains every pending microtask first"
],
correct: 1,
why: "runAsync escapes the fake clock: the write and timer execute on the genuine event loop and actually finish, leaving nothing pending."
},
{
q: "Why was _ServerAddressTile renamed to the public ServerAddressTile?",
options: [
"A second screen needed to embed the identical tile without copying it",
"The analyzer forbids underscore-prefixed StatelessWidgets in screen files",
"A test file can't build a private widget, so its empty case is untested",
"So HttpServerService could write new addresses straight into the tile"
],
correct: 2,
why: "Only its no-address 'Server not started' branch was uncovered, and a test can't construct a library-private widget — so it had to be exposed."
},
{
q: "Why is the Vibration.vibrate call wrapped in coverage:ignore rather than tested?",
options: [
"Vibration is a side effect that unit tests must never trigger on the build host",
"The vibration plugin channel was too slow to mock reliably in the suite",
"The team decided haptics fall outside the 100% coverage requirement",
"hasVibrator() is false on the Linux CI host, so that body never runs there"
],
correct: 3,
why: "hasVibrator() checks Platform.isAndroid/isIOS via device_info_plus; both are false on Linux, so the guarded body is genuinely unreachable in CI."
},
{
q: "Why did resetForTesting gain an optional dbPath parameter?",
options: [
"To speed the suite up by sharing a single database across every test",
"To let production override the DB location from an environment variable",
"So a test can point at a real file DB and drive the v1→v3 migration",
"To stop the in-memory DB from leaking rows between separate test cases"
],
correct: 2,
why: "onUpgrade only runs when a persisted DB is reopened at a higher version; an in-memory DB can't be reopened, so a real file path was needed."
}
];
(function () {
var host = document.getElementById('quiz');
var scoreEl = document.getElementById('score');
var answered = 0, correct = 0;
QUESTIONS.forEach(function (item, qi) {
var card = document.createElement('div');
card.className = 'q';
var head = document.createElement('div');
head.className = 'qhead';
head.textContent = (qi + 1) + '. ' + item.q;
card.appendChild(head);
var done = false;
var buttons = [];
item.options.forEach(function (opt, oi) {
var b = document.createElement('button');
b.className = 'opt';
b.textContent = opt;
b.addEventListener('click', function () {
if (done) return;
done = true;
answered++;
buttons.forEach(function (bb, bi) {
bb.disabled = true;
if (bi === item.correct) bb.classList.add('correct');
});
if (oi === item.correct) { correct++; }
else { b.classList.add('wrong'); }
rat.classList.add('show');
scoreEl.textContent = 'Score: ' + correct + ' / ' + answered;
});
buttons.push(b);
card.appendChild(b);
});
var rat = document.createElement('div');
rat.className = 'rationale';
rat.textContent = '→ ' + item.why;
card.appendChild(rat);
host.appendChild(card);
});
})();
</script>
</body>
</html>

View File

@ -0,0 +1,686 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Multiple workouts per day, the sync that never ran, and a ban on silence</title>
<style>
:root {
--bg: #ffffff; --fg: #1a1a1a; --muted: #5b6470; --line: #e2e6ea;
--card: #f6f8fa; --accent: #0b6bcb; --good: #1a7f37; --bad: #b42318;
--warn: #9a6700; --code: #f2f4f7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171a; --fg: #e6e9ec; --muted: #9aa4b0; --line: #2b3138;
--card: #1b1f24; --accent: #6cb6ff; --good: #4ac26b; --bad: #ff7b72;
--warn: #d4a72c; --code: #1e2329;
}
}
* { box-sizing: border-box; }
body {
margin: 0 auto; padding: 2rem 1rem 5rem; max-width: min(48rem, 92vw);
background: var(--bg); color: var(--fg); line-height: 1.65;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
h1 { font-size: 1.9rem; line-height: 1.25; margin: 0 0 .4rem; }
h2 { font-size: 1.4rem; margin: 2.6rem 0 .8rem; padding-top: .6rem;
border-top: 1px solid var(--line); }
h3 { font-size: 1.08rem; margin: 1.6rem 0 .5rem; }
.sub { color: var(--muted); margin: 0 0 1.5rem; }
a { color: var(--accent); }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
code { background: var(--code); padding: .12em .35em; border-radius: 4px;
font-size: .88em; }
.codewrap { overflow-x: auto; background: var(--code); border: 1px solid var(--line);
border-radius: 8px; margin: .8rem 0; }
pre { margin: 0; padding: .85rem 1rem; white-space: pre-wrap; overflow-x: auto;
font-size: .84rem; line-height: 1.5; }
.add { color: var(--good); }
.del { color: var(--bad); }
nav.toc { background: var(--card); border: 1px solid var(--line);
border-radius: 10px; padding: .9rem 1.1rem; margin: 1.5rem 0 2rem; }
nav.toc ol { margin: .4rem 0 0; padding-left: 1.2rem; }
nav.toc li { margin: .25rem 0; }
.card { background: var(--card); border: 1px solid var(--line);
border-radius: 10px; padding: 1rem 1.1rem; margin: 1.2rem 0; }
.note { border-left: 3px solid var(--accent); }
.warnbox { border-left: 3px solid var(--warn); }
.badbox { border-left: 3px solid var(--bad); }
table { border-collapse: collapse; width: 100%; font-size: .9rem; }
.tablewrap { overflow-x: auto; margin: 1rem 0; }
th, td { border: 1px solid var(--line); padding: .45rem .6rem; text-align: left; }
th { background: var(--card); }
/* diagram primitives */
.diagram { margin: 1.2rem 0; }
.row { display: flex; gap: .6rem; align-items: stretch; flex-wrap: wrap; }
.box { border: 1px solid var(--line); border-radius: 8px; padding: .55rem .7rem;
background: var(--card); font-size: .84rem; flex: 1 1 0; min-width: 8.5rem; }
.box .t { font-weight: 600; font-size: .78rem; color: var(--muted);
text-transform: uppercase; letter-spacing: .03em; margin-bottom: .3rem; }
.arrow { display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 1.3rem; padding: .2rem 0; }
.pill { display: inline-block; border-radius: 999px; padding: .08em .55em;
font-size: .76rem; border: 1px solid var(--line); }
.pill.v { color: var(--good); border-color: var(--good); }
.pill.m { color: var(--warn); border-color: var(--warn); }
.pill.x { color: var(--bad); border-color: var(--bad); }
/* micro-world */
.sim { border: 1px solid var(--line); border-radius: 10px; padding: 1rem;
background: var(--card); }
.sim .days { display: grid; grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
gap: .5rem; margin: .8rem 0; }
.day { border: 1px solid var(--line); border-radius: 8px; padding: .5rem;
background: var(--bg); }
.day h4 { margin: 0 0 .35rem; font-size: .8rem; color: var(--muted); }
.day .slot { font-size: .74rem; margin: .12rem 0; }
.btnrow { display: flex; gap: .3rem; margin-top: .35rem; flex-wrap: wrap; }
button { font: inherit; font-size: .78rem; padding: .2rem .5rem; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px;
background: var(--bg); color: var(--fg); }
button:hover { border-color: var(--accent); }
.verdict { margin-top: .8rem; padding: .7rem .9rem; border-radius: 8px;
background: var(--bg); border: 1px solid var(--line); font-size: .9rem; }
.big { font-size: 1.5rem; font-weight: 700; }
/* quiz */
.q { margin: 1.4rem 0; }
.q .stem { font-weight: 600; margin-bottom: .5rem; }
.opt { display: block; width: 100%; text-align: left; margin: .3rem 0;
padding: .5rem .7rem; border-radius: 8px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); cursor: pointer; font-size: .9rem; }
.opt:disabled { cursor: default; }
.opt.correct { border-color: var(--good); background: color-mix(in srgb, var(--good) 12%, var(--bg)); }
.opt.wrong { border-color: var(--bad); background: color-mix(in srgb, var(--bad) 12%, var(--bg)); }
.rat { font-size: .85rem; color: var(--muted); margin: .4rem 0 0; display: none; }
.rat.show { display: block; }
#score { font-weight: 600; }
</style>
</head>
<body>
<h1>Multiple workouts per day, the sync that never ran, and a ban on silence</h1>
<p class="sub">
<code>feat/check-phone-runnerup-and-tray</code> · 3 commits · 62 files ·
+2719 / −777 · 2026-07-17
</p>
<nav class="toc">
<strong>Contents</strong>
<ol>
<li><a href="#background">Background — what this thing is</a></li>
<li><a href="#intuition">Intuition — one slot per day</a></li>
<li><a href="#code">Code — the change, by concern</a></li>
<li><a href="#quiz">Quiz</a></li>
</ol>
</nav>
<h2 id="background">1. Background — what this thing is</h2>
<p>
<strong>screen-locker</strong> is a discipline tool. A systemd user service runs
<code>python -m screen_locker.screen_lock --production</code> at login and again
from a timer at 08:30/09:05. If you haven't earned your workout, it throws a
fullscreen Tk window over everything until you do. If you have, it calls
<code>sys.exit(0)</code> and you never see it. That's why "the locker isn't
running" is usually the <em>healthy</em> state, not a fault.
</p>
<p>
It decides using <code>screen_locker/log.json</code> — a gitignored,
HMAC-signed file. Each entry is signed with a key shared across screen-locker,
diet_guard and wake_alarm, so the log can't be quietly hand-edited to fake a
workout. Two things read it constantly: <code>has_weekly_minimum()</code>
(fire the lock or not?) and the reward path (how late may the PC stay on
tonight?).
</p>
<p>
A workout gets in three ways. <span class="pill v">phone_verified</span> — a
StrongLifts session pulled off the phone. <span class="pill v">runnerup_verified</span>
— a real run, parsed from the actual TCX file that RunnerUp writes to
<code>/sdcard/Documents/RunnerUp/</code> and pulled over ADB. And
<span class="pill m">manual_workout</span> — a self-reported evidence form
(squash, table tennis), rate-limited by a budget because nothing verifies it.
That distinction — machine-checked versus self-reported — is the hinge the
whole change turns on.
</p>
<p>
There's also a companion Flutter app on the phone, and a shared GitHub repo
(<code>kuhyx/syncs</code>) holding a small CRDT log per device, so the two
machines can agree on what happened.
</p>
<h3>The shape that caused everything</h3>
<p>
<code>log.json</code> was <strong>keyed by date, one entry per day</strong>.
The writer said so in as many words, and did exactly that:
</p>
<div class="codewrap"><pre><span class="muted"># _log_mixin.py — before</span>
"""...The log is day-keyed: one entry per date."""
logs[date] = entry <span class="del"># a second workout that day OVERWRITES the first</span></pre></div>
<p>
So the file physically could not hold two workouts on one day. Everything
downstream inherited that: <code>count_weekly_workouts</code> iterated the date
keys, so "4 workouts a week" and "4 <em>days</em> a week" were the same
sentence. Nobody had to choose between them, because the data model had
already chosen.
</p>
<h2 id="intuition">2. Intuition — one slot per day</h2>
<p>
The trigger was mundane. On Monday 13 July, kuhy played squash <em>and</em> went
for a 9.8&nbsp;km run. He logged the squash by hand. The run — a real, verified,
TCX-backed run — had nowhere to go. The day already had its slot.
</p>
<div class="diagram">
<div class="row">
<div class="box">
<div class="t">Before — one slot</div>
<div>2026-07-13 →</div>
<div style="margin-top:.3rem"><span class="pill m">squash (manual)</span></div>
<div style="margin-top:.4rem; color:var(--bad); font-size:.8rem">
the 9.8 km run would <strong>overwrite</strong> this
</div>
</div>
<div class="box">
<div class="t">After — a list</div>
<div>2026-07-13 → [</div>
<div style="margin-top:.3rem">
<span class="pill m">squash</span> <span class="pill v">run 9.8 km</span>
</div>
<div>]</div>
<div style="margin-top:.4rem; color:var(--good); font-size:.8rem">
both kept, both counted
</div>
</div>
</div>
</div>
<h3>The trap: a list alone is a cheat code</h3>
<p>
Letting a day hold many workouts sounds harmless until you notice what the
lock is <em>for</em>. If every entry counted, you could type four
<span class="pill m">manual</span> entries on a Monday morning, satisfy "4
workouts this week", and never be locked again until Sunday. The whole tool
would be an honour system with extra steps.
</p>
<p>
So the rule isn't "count workouts". It's <strong>count what can't be
faked</strong>:
</p>
<div class="card note">
<strong>The weekly rule.</strong> Each <span class="pill v">verified</span>
workout counts <em>individually</em> — several in one day all count, because a
TCX file and a StrongLifts session are machine-checked.
<span class="pill m">manual</span> entries count <strong>at most once per
day</strong>, however many you write. Self-reports can't inflate the total.
</div>
<p>
Play with it. Add workouts and watch the count and the lock:
</p>
<div class="sim diagram" id="sim">
<div style="font-size:.85rem; color:var(--muted)">
A week (Mon–Thu shown). Weekly minimum is <strong>4</strong>.
</div>
<div class="days" id="days"></div>
<div class="btnrow">
<button id="reset">Reset</button>
<button id="preset-real">Preset: kuhy's Jul 13</button>
<button id="preset-cheat">Preset: the 4-manual cheat</button>
</div>
<div class="verdict" id="verdict"></div>
</div>
<h3>The second trap: the same workout, twice</h3>
<p>
Once a day can hold many workouts, "is this one already here?" stops being
free. Every entry now carries a <code>workout_id</code> — deliberately the
<em>same string</em> the sync layer uses as its record id, so a workout logged
on the PC and the same workout arriving from the phone collapse to one row
instead of two.
</p>
<div class="diagram">
<div class="row">
<div class="box">
<div class="t">manual</div>
<code>manual:2026-07-13T14:00</code>
<div style="font-size:.78rem;color:var(--muted);margin-top:.3rem">date + start time</div>
</div>
<div class="box">
<div class="t">runnerup</div>
<code>runnerup_verified:2026-07-13</code>
<div style="font-size:.78rem;color:var(--muted);margin-top:.3rem">type + date</div>
</div>
<div class="box">
<div class="t">phone session</div>
<code>startTime.toIso8601String()</code>
<div style="font-size:.78rem;color:var(--muted);margin-top:.3rem">minted by the app</div>
</div>
</div>
</div>
<h3>The sync had never worked. Not once.</h3>
<p>
While fixing the above, a second thing surfaced. The PC was supposed to
publish its workouts so the phone could see them. It never had —
<code>screen-locker-sync/</code> in the repo contained a single
<code>.gitkeep</code>. Two independent reasons:
</p>
<ol>
<li>
<strong>No token.</strong> <code>push_pc_manuals()</code> opened with
<code>if token is None: return</code> — a bare return, no log line. The PAT
had been saved to the wrong app's config directory. The code knew and said
nothing.
</li>
<li>
<strong>A side-store.</strong> It pushed from <code>manual_sync_log.json</code>,
written only at log-time. So anything recorded before that feature existed —
like the squash — could never publish, no matter how many times it ran.
</li>
</ol>
<p>
The fix for (2) also fixes a category error: <em>derive the push from
<code>log.json</code> itself</em>. The log is the truth; the sync is a
projection of it. Then history publishes automatically, forever, with no
second place to fall out of date.
</p>
<h3>Why silence was the real bug</h3>
<p>
Notice the shape of the failure. Not a crash — a <code>return []</code>. During
the debugging session, <code>pull_all_manual_records()</code> returned
<code>0</code> and it was <strong>impossible to tell</strong> whether the repo
was empty or the call had failed, because the token guard was silent and real
errors logged at <code>INFO</code>. The answer only came from bypassing the
function and hitting the API by hand.
</p>
<div class="card badbox">
<strong>An invisible log is a silent failure with extra steps.</strong> That's
now a rule with teeth: <code>scripts/check_silent_failures.py</code>, a
pre-commit hook. Every <code>except</code> must re-raise or log at
<code>warning</code>+. It found <strong>27 violations across 16 modules</strong>
on its first run — and flagged its own source file too.
</div>
<p>
Worth knowing why a custom AST rule was needed at all: ruff already runs with
<code>select = ["ALL"]</code>, so <code>S110</code> (<code>except: pass</code>)
was <em>already</em> enforced — and the codebase had none. The lethal pattern
wasn't <code>pass</code>. It was <code>except: log.info(...); return []</code>,
which <em>looks</em> handled and no ruff rule catches.
</p>
<h2 id="code">3. Code — the change, by concern</h2>
<h3>3.1 One reader, two shapes</h3>
<p>
A new dependency-free module is the single place that reads the file. Old and
new shapes both load, so a half-migrated file — or a rolled-back writer — is
never a crash. Crucially, wrapping a legacy dict in a list leaves the
<em>signed bytes untouched</em>, so every existing HMAC still verifies with
zero re-signing.
</p>
<div class="codewrap"><pre><span class="add">+ # screen_locker/_log_io.py (new)</span>
+ def load_workout_log(log_file) -> dict[str, list[dict]]:
+ normalized = {}
+ for date, value in read_raw_log(log_file).items():
+ if isinstance(value, list):
+ normalized[date] = [e for e in value if isinstance(e, dict)]
+ elif isinstance(value, dict):
+ normalized[date] = [value] # legacy: wrap, don't rewrite
+ return normalized</pre></div>
<h3>3.2 One writer, which appends and dedups</h3>
<p>
The overwrite becomes an append behind a single chokepoint that returns a
result instead of <code>None</code> — so the caller can tell a genuinely new
workout from a duplicate, and scale the reward accordingly.
</p>
<div class="codewrap"><pre><span class="del">- logs[date] = entry # overwrite</span>
<span class="add">+ 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
+ ):
+ return RecordResult(appended=False, prior_entries=prior)
+ entries.append(entry)
+ return RecordResult(appended=True, prior_entries=prior)</span></pre></div>
<div class="card warnbox">
<strong>This bit was written twice.</strong> The first version compared
<code>e.get("workout_id")</code> directly. Legacy entries predate that field,
so theirs is <code>None</code> — un-matchable. The moment the PC started
publishing history, the 08:30 timer ingested the squash back and appended a
<em>second</em> copy of it. It had been dismissed in the plan as a "known
minor, accepted" risk; it fired within the hour, on real data.
<code>_entry_workout_id()</code> now <em>derives</em> an existing entry's id
when it has none, so a legacy row matches its own synced twin.
</div>
<h3>3.3 The counting rule</h3>
<p>
The anti-gaming asymmetry from section 2, in the one function that both the
lock and the banked bonus consult:
</p>
<div class="codewrap"><pre>for date_str, entries in load_workout_log(log_file).items():
...
has_manual = False
for entry in entries:
wtype = entry.get("workout_data", {}).get("type", "")
if wtype in VERIFIED_WORKOUT_TYPES:
count += 1 <span class="add"># each verified workout counts</span>
elif wtype == MANUAL_WORKOUT_TYPE:
has_manual = True
if has_manual:
count += 1 <span class="add"># all of a day's manuals count as one</span></pre></div>
<h3>3.4 The reward, with two guards</h3>
<p>
<code>_apply_workout_credit</code> now scales to the day: the first counted
workout earns the base <strong>+2h</strong>; an <em>additional same-day
verified</em> one earns <strong>+1h</strong>; an additional manual earns
nothing. The old "+1h when <code>weekly_count &gt; 4</code>" gate was removed —
weekly volume is the banked bonus's job, and paying for it twice was the bug
hiding in plain sight. The credit fires only if the write actually appended
(<em>id guard</em>) and the entry is dated today (<em>date guard</em>) — so a
back-dated backfill raises the count but never pushes tonight's shutdown.
</p>
<p>
The tests caught a real defect here: the unlock screen still labelled the +1h
<code>#{weekly_count - 4}</code>, which under the new rule can render
<em>negative</em>.
</p>
<h3>3.5 The sync, derived from the log</h3>
<p>
Record id = the workout's own <code>workout_id</code>. HLC = derived from the
entry's own timestamp — which makes a re-push <em>byte-identical</em>, so the
repo never churns. That determinism is what lets the push run on every startup
without turning the sync repo into a commit firehose.
</p>
<div class="codewrap"><pre><span class="add">+ hlc = Hlc.new_tick(_PC_DEVICE_ID, wall_time_ms=_entry_wall_ms(entry, date))
+ log[record_id] = Record(id=record_id, fields={_PAYLOAD_FIELD: (payload, hlc)})</span></pre></div>
<p>
And nothing about it is silent any more:
</p>
<div class="codewrap"><pre><span class="add">+ @dataclass(frozen=True)
+ class PushResult:
+ pushed: bool
+ record_count: int
+ reason: str # always a sentence a human can act on</span></pre></div>
<h3>3.6 Two recoveries, one pattern</h3>
<p>
The phone kept a Keystore token and mirrored it to
<code>/sdcard</code> — but <code>load()</code> only consulted the mirror when
the Keystore was <strong>empty</strong>, never when its token was
present-but-<em>rejected</em>. A stale entry could shadow a good backup forever
while the UI insisted all was well. Now a 401 triggers one retry from the
mirror before anyone is asked to re-authorize.
</p>
<p>
The same pattern then closed a sharper hole. <code>active_session</code> — which
set you're on, and the reps done — lived only in app-private SQLite. A
<code>pm clear</code> wiped it <em>mid-workout</em>, and <code>backup.json</code>
didn't help because it mirrors only <em>completed</em> history. The state had to
be re-entered by hand. The persistence granularity was already right (it saves
on every set tap); only the <em>location</em> was wrong.
</p>
<div class="diagram">
<div class="row">
<div class="box">
<div class="t">app-private (wiped)</div>
SQLite <code>active_session</code><br>
Keystore <code>sync.token</code>
</div>
<div class="arrow">→</div>
<div class="box">
<div class="t">/sdcard (survives)</div>
<code>active_session.json</code><br>
<code>sync_token</code><br>
<code>backup.json</code>
</div>
</div>
<p style="font-size:.82rem;color:var(--muted);margin:.5rem 0 0">
On launch, an empty table re-seeds itself from the mirror. Set 4 of the
Romanian Deadlift, reps <code>[8,8,8,8]</code>, survives an uninstall.
</p>
</div>
<h3>3.7 The rule that outlives the fix</h3>
<div class="codewrap"><pre><span class="add">+ # scripts/check_silent_failures.py — pre-commit hook `no-silent-failures`
+ def _handler_is_loud(handler) -> bool:
+ """True if the handler re-raises or logs loudly about the failure."""
+ return any(
+ isinstance(node, ast.Raise) or _is_loud_log_call(node)
+ for node in ast.walk(handler)
+ )</span></pre></div>
<p>
There's deliberately no escape hatch, because <code>noqa</code> is banned
repo-wide. Two ironies for the record: the checker's own docstring contained
the literal string <code>#&nbsp;noqa</code> and tripped the noqa-blocker, and
<code>_log_io.py</code> — written for this very change — violated the new rule.
Both were invisible while untracked, since <code>git ls-files</code> hides
untracked files from the hooks, and only surfaced once committed.
</p>
<h2 id="quiz">4. Quiz</h2>
<p style="color:var(--muted);font-size:.9rem">
Five questions on the reasoning. Score: <span id="score">0 / 0</span>
</p>
<div id="quiz"></div>
<script>
/* ---------------- micro-world: the weekly counting rule ---------------- */
(function () {
const DAYS = ["Mon 13", "Tue 14", "Wed 15", "Thu 16"];
const MIN = 4;
let state = DAYS.map(() => ({ verified: 0, manual: 0 }));
const daysEl = document.getElementById("days");
const verdictEl = document.getElementById("verdict");
function count() {
// Mirrors count_weekly_workouts: each verified counts; manuals cap at 1/day.
return state.reduce((n, d) => n + d.verified + (d.manual > 0 ? 1 : 0), 0);
}
function render() {
daysEl.innerHTML = "";
state.forEach((d, i) => {
const el = document.createElement("div");
el.className = "day";
const contrib = d.verified + (d.manual > 0 ? 1 : 0);
el.innerHTML =
"<h4>" + DAYS[i] + "</h4>" +
'<div class="slot"><span class="pill v">verified</span> ' + d.verified + "</div>" +
'<div class="slot"><span class="pill m">manual</span> ' + d.manual +
(d.manual > 1 ? ' <span style="color:var(--warn)">(capped)</span>' : "") + "</div>" +
'<div class="slot" style="margin-top:.3rem;color:var(--muted)">counts: <strong>' +
contrib + "</strong></div>";
const row = document.createElement("div");
row.className = "btnrow";
const bv = document.createElement("button");
bv.textContent = "+ verified";
bv.onclick = () => { state[i].verified++; render(); };
const bm = document.createElement("button");
bm.textContent = "+ manual";
bm.onclick = () => { state[i].manual++; render(); };
row.append(bv, bm);
el.append(row);
daysEl.append(el);
});
const n = count();
const locked = n < MIN;
verdictEl.innerHTML =
'<div>weekly count: <span class="big">' + n + "</span> / " + MIN + "</div>" +
'<div style="margin-top:.4rem">lock this week: ' +
(locked
? '<span class="pill x">FIRES</span>'
: '<span class="pill v">skipped — minimum met</span>') +
"</div>" +
'<div style="margin-top:.4rem;font-size:.84rem;color:var(--muted)">' +
"Each verified workout adds 1. All of a day's manual entries add 1 between them." +
"</div>";
}
document.getElementById("reset").onclick = () => {
state = DAYS.map(() => ({ verified: 0, manual: 0 })); render();
};
document.getElementById("preset-real").onclick = () => {
// Jul 13: squash (manual) + the 9.8 km run (verified) => that day counts 2.
state = DAYS.map(() => ({ verified: 0, manual: 0 }));
state[0] = { verified: 1, manual: 1 };
render();
};
document.getElementById("preset-cheat").onclick = () => {
// Four self-reports on one morning: the whole point of the cap.
state = DAYS.map(() => ({ verified: 0, manual: 0 }));
state[0] = { verified: 0, manual: 4 };
render();
};
render();
})();
/* ---------------- quiz ---------------- */
(function () {
const QS = [
{
stem: "The log moved from {date: entry} to {date: [entry]}. Why does load_workout_log wrap legacy dict values on read instead of rewriting the file up front?",
opts: [
"A rewrite needs the HMAC key, which is not always available at read time.",
"Wrapping leaves each entry's signed bytes untouched, so old HMACs verify.",
"The file is gitignored, so a rewrite would be lost on the next checkout.",
"SQLite locks the file during reads, so an in-place rewrite may corrupt it."
],
correct: 1,
rat: "Wrapping never changes the bytes compute_entry_hmac signed, so zero re-signing is needed."
},
{
stem: "A single day holds 2 verified workouts and 3 manual ones. What does that day contribute to the weekly count?",
opts: [
"5 — every counted workout counts on its own, as multi/day implies.",
"2 — only machine-verified workouts count toward the weekly total.",
"3 — each verified counts; the manual entries count once in total.",
"1 — the day contributes exactly one, matching the old day-based rule."
],
correct: 2,
rat: "Verified workouts stack (2); all of a day's manual entries collapse to a single 1."
},
{
stem: "Why is each pushed record's HLC derived from the entry's own timestamp rather than the current clock?",
opts: [
"It stops the phone's clock from overriding the PC on same-day workouts.",
"GitHub rejects records whose clock is newer than the commit timestamp.",
"Re-pushing an unchanged log yields identical records, so no churn.",
"crdt_sync requires a wall_time_ms whenever a node id is also supplied."
],
correct: 2,
rat: "Determinism makes the push idempotent, so running it on every startup never spams the repo."
},
{
stem: "The new hook flags silent except handlers. Why did `if token is None: return []` still need fixing by hand?",
opts: [
"It is a guard clause, not an except handler, so the AST rule never sees it.",
"The rule ignores functions whose return type is a list rather than a result.",
"The checker skips files that were untracked when the hook last ran on them.",
"ruff's S110 already covers it, so the custom checker deliberately skips it."
],
correct: 0,
rat: "The checker walks ast.ExceptHandler nodes; a bare guard return is invisible to it."
},
{
stem: "After the PC began publishing history, the Jul 13 squash appeared twice. What went wrong?",
opts: [
"The push minted a fresh workout_id each run, so the dedup never matched.",
"The legacy entry stored no workout_id, so the id comparison found no match.",
"The 08:30 timer ran ingest twice and the second run raced the first write.",
"The manual-sync ingest skipped its record-id check for pre-migration rows."
],
correct: 1,
rat: "Its stored id was None, so it could not match its own synced copy — the id is now derived."
}
];
const quizEl = document.getElementById("quiz");
const scoreEl = document.getElementById("score");
let answered = 0, right = 0;
QS.forEach((q, qi) => {
const wrap = document.createElement("div");
wrap.className = "q";
const stem = document.createElement("div");
stem.className = "stem";
stem.textContent = (qi + 1) + ". " + q.stem;
wrap.append(stem);
const rat = document.createElement("p");
rat.className = "rat";
q.opts.forEach((text, oi) => {
const b = document.createElement("button");
b.className = "opt";
b.textContent = text;
b.onclick = () => {
if (wrap.dataset.done) return;
wrap.dataset.done = "1";
answered++;
if (oi === q.correct) right++;
wrap.querySelectorAll(".opt").forEach((o, i) => {
o.disabled = true;
if (i === q.correct) o.classList.add("correct");
else if (i === oi) o.classList.add("wrong");
});
rat.textContent = (oi === q.correct ? "Correct. " : "Not quite. ") + q.rat;
rat.classList.add("show");
scoreEl.textContent = right + " / " + answered;
};
wrap.append(b);
});
wrap.append(rat);
quizEl.append(wrap);
});
})();
</script>
</body>
</html>

View File

@ -0,0 +1,686 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Check Phone learns to look backward, and forgives shaky GPS</title>
<style>
:root {
--bg: #ffffff; --fg: #1a1a1a; --muted: #5b6470; --line: #e2e6ea;
--card: #f6f8fa; --accent: #0b6bcb; --good: #1a7f37; --bad: #b42318;
--warn: #9a6700; --code: #f2f4f7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171a; --fg: #e6e9ec; --muted: #9aa4b0; --line: #2b3138;
--card: #1b1f24; --accent: #6cb6ff; --good: #4ac26b; --bad: #ff7b72;
--warn: #d4a72c; --code: #1e2329;
}
}
* { box-sizing: border-box; }
body {
margin: 0 auto; padding: 2rem 1rem 5rem; max-width: min(48rem, 92vw);
background: var(--bg); color: var(--fg); line-height: 1.65;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
h1 { font-size: 1.9rem; line-height: 1.25; margin: 0 0 .4rem; }
h2 { font-size: 1.4rem; margin: 2.6rem 0 .8rem; padding-top: .6rem;
border-top: 1px solid var(--line); }
h3 { font-size: 1.08rem; margin: 1.6rem 0 .5rem; }
.sub { color: var(--muted); margin: 0 0 1.5rem; }
a { color: var(--accent); }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
code { background: var(--code); padding: .12em .35em; border-radius: 4px;
font-size: .88em; }
.codewrap { overflow-x: auto; background: var(--code); border: 1px solid var(--line);
border-radius: 8px; margin: .8rem 0; }
pre { margin: 0; padding: .85rem 1rem; white-space: pre-wrap; overflow-x: auto;
font-size: .84rem; line-height: 1.5; }
.add { color: var(--good); }
.del { color: var(--bad); }
nav.toc { background: var(--card); border: 1px solid var(--line);
border-radius: 10px; padding: .9rem 1.1rem; margin: 1.5rem 0 2rem; }
nav.toc ol { margin: .4rem 0 0; padding-left: 1.2rem; }
nav.toc li { margin: .25rem 0; }
.card { background: var(--card); border: 1px solid var(--line);
border-radius: 10px; padding: 1rem 1.1rem; margin: 1.2rem 0; }
.note { border-left: 3px solid var(--accent); }
.warnbox { border-left: 3px solid var(--warn); }
.badbox { border-left: 3px solid var(--bad); }
table { border-collapse: collapse; width: 100%; font-size: .9rem; }
.tablewrap { overflow-x: auto; margin: 1rem 0; }
th, td { border: 1px solid var(--line); padding: .45rem .6rem; text-align: left; }
th { background: var(--card); }
/* diagram primitives */
.diagram { margin: 1.2rem 0; }
.row { display: flex; gap: .6rem; align-items: stretch; flex-wrap: wrap; }
.box { border: 1px solid var(--line); border-radius: 8px; padding: .55rem .7rem;
background: var(--card); font-size: .84rem; flex: 1 1 0; min-width: 8.5rem; }
.box .t { font-weight: 600; font-size: .78rem; color: var(--muted);
text-transform: uppercase; letter-spacing: .03em; margin-bottom: .3rem; }
.arrow { display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 1.3rem; padding: .2rem 0; }
.down { text-align: center; color: var(--muted); font-size: 1.2rem; margin: .1rem 0; }
.pill { display: inline-block; border-radius: 999px; padding: .08em .55em;
font-size: .76rem; border: 1px solid var(--line); }
.pill.v { color: var(--good); border-color: var(--good); }
.pill.m { color: var(--warn); border-color: var(--warn); }
.pill.x { color: var(--bad); border-color: var(--bad); }
/* micro-world */
.sim { border: 1px solid var(--line); border-radius: 10px; padding: 1rem;
background: var(--card); }
.sim label { display: block; font-size: .84rem; margin: .7rem 0 .25rem; }
.sim .grp { border: 1px solid var(--line); border-radius: 8px; padding: .6rem .7rem;
background: var(--bg); margin: .6rem 0; }
.sim select, .sim input[type=range] { width: 100%; }
.rangeval { font-size: .8rem; color: var(--muted); }
.btnrow { display: flex; gap: .3rem; margin-top: .5rem; flex-wrap: wrap; }
button { font: inherit; font-size: .82rem; padding: .3rem .6rem; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px;
background: var(--bg); color: var(--fg); }
button:hover { border-color: var(--accent); }
button.primary { border-color: var(--accent); color: var(--accent); font-weight: 600; }
.trace { margin-top: .8rem; padding: .7rem .9rem; border-radius: 8px;
background: var(--bg); border: 1px solid var(--line); font-size: .85rem; }
.trace .step { padding: .35rem 0; border-bottom: 1px dashed var(--line); }
.trace .step:last-child { border-bottom: none; }
.trace .step .k { color: var(--muted); font-size: .78rem; }
.verdict-line { margin-top: .5rem; font-weight: 600; }
/* quiz */
.q { margin: 1.4rem 0; }
.q .stem { font-weight: 600; margin-bottom: .5rem; }
.opt { display: block; width: 100%; text-align: left; margin: .3rem 0;
padding: .5rem .7rem; border-radius: 8px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); cursor: pointer; font-size: .9rem; }
.opt:disabled { cursor: default; }
.opt.correct { border-color: var(--good); background: color-mix(in srgb, var(--good) 12%, var(--bg)); }
.opt.wrong { border-color: var(--bad); background: color-mix(in srgb, var(--bad) 12%, var(--bg)); }
.rat { font-size: .85rem; color: var(--muted); margin: .4rem 0 0; display: none; }
.rat.show { display: block; }
#score { font-weight: 600; }
</style>
</head>
<body>
<h1>Check Phone learns to look backward, and forgives shaky GPS</h1>
<p class="sub">
<code>c277325</code> · 12 files · +583 / −78 · 2026-07-18
</p>
<nav class="toc">
<strong>Contents</strong>
<ol>
<li><a href="#background">Background — what Check Phone actually checks</a></li>
<li><a href="#intuition">Intuition — a same-day blind spot, and a shaky GPS trace</a></li>
<li><a href="#code">Code — the change, by concern</a></li>
<li><a href="#quiz">Quiz</a></li>
</ol>
</nav>
<h2 id="background">1. Background — what Check Phone actually checks</h2>
<p>
<strong>screen-locker</strong> locks the screen until a weekly workout
quota is met. The status window has a voluntary "Check Phone" button
(<code>screen_locker/_status_view_verify.py</code>,
<code>PhoneCheckMixin</code>) that, on click, pulls real data off your
Android phone over ADB and — if it verifies — logs it and applies the
reward. It never trusts a typed claim; it always pulls the actual file
first. Two sources feed it:
</p>
<div class="diagram">
<div class="row">
<div class="box">
<div class="t">StrongLifts</div>
Pulls <code>workout_result.json</code> off the phone.
<code>_verify_phone_workout</code> in
<code>_phone_verification.py</code>.
</div>
<div class="box">
<div class="t">RunnerUp</div>
Pulls the real <code>.tcx</code> file from
<code>/sdcard/Documents/RunnerUp/</code>.
<code>_verify_runnerup_workout</code> in
<code>_runnerup_verification.py</code>.
</div>
</div>
</div>
<p>
Both of those functions were written to answer one specific question:
<em>did you work out today?</em> They match on today's date only —
StrongLifts by comparing the JSON's <code>date</code> field, RunnerUp by
grepping filenames for today's date string. That's the right question for
the locked screen, which decides whether to unlock <em>right now</em>. But
the voluntary "Check Phone" button reused those same today-only functions,
even though what it's really trying to answer is broader: <em>is this
week's quota met?</em>
</p>
<p>
The quota itself, from <code>_weekly_check.py</code>, follows an
anti-gaming rule established in the previous change to this repo: each
<span class="pill v">verified</span> workout (StrongLifts or RunnerUp)
counts individually, however many happen on one day, because they're
machine-checked and can't be faked. A <span class="pill m">manual</span>
self-report counts at most once per day. The weekly minimum is 4.
</p>
<p>
A week-aware scanner for RunnerUp already existed —
<code>_scan_and_fill_week_runnerup</code> walks every day from Monday to
today, and for each one still ADB-pulls and validates the real TCX file
before writing anything. It was wired into the automatic login check and
the <code>--status</code> CLI, but never into the "Check Phone" button.
Nothing equivalent existed for StrongLifts at all.
</p>
<h2 id="intuition">2. Intuition — a same-day blind spot, and a shaky GPS trace</h2>
<p>
Here's what actually happened. Wednesday's status window connected to the
phone and clicked Check Phone. The phone had a real RunnerUp run from
<strong>yesterday</strong> — a 4.9&nbsp;km run recorded the evening before,
after the button's earlier check that day had already failed. The result:
</p>
<div class="card badbox">
<code>Phone check (not_verified): StrongLifts: Workout JSON is from
2026-07-17, not today · RunnerUp: No RunnerUp activity found for today</code>
</div>
<p>
Both messages are literally true and both are useless. There <em>is</em> a
real, verifiable run — the button just never looked at any date but today.
</p>
<div class="diagram">
<div class="row">
<div class="box">
<div class="t">Before</div>
StrongLifts today? no →<br>
RunnerUp today? no →<br>
<span style="color:var(--bad)">give up</span>
</div>
<div class="box">
<div class="t">After</div>
StrongLifts today? no →<br>
RunnerUp today? no →<br>
<span style="color:var(--good)">scan Mon…today for anything unlogged</span>
</div>
</div>
</div>
<p>
The week-scan fallback reuses the existing RunnerUp scanner and a new,
symmetric StrongLifts one — both still pull and validate the real file via
ADB, they just stop requiring the date to be <em>today</em> and instead
accept anything from Monday through today that isn't logged yet.
</p>
<h3>The second problem the first one uncovered</h3>
<p>
Testing this against the real phone surfaced a second issue: that 4.9&nbsp;km
run should count, but <code>MIN_RUN_DISTANCE_KM</code> is 5.0. GPS traces
measure distance by summing straight-line hops between sampled points, so
tight turns, tree cover, and urban canyons make the recorded distance
consistently <em>shorter</em> than the run actually was. A real 5&nbsp;km run can
legitimately log as 4.9. So a 5% tolerance was added — applied to distance
only, since duration comes from the device clock and has no such noise:
</p>
<div class="diagram">
<div class="row">
<div class="box">
<div class="t">recorded</div>
<span style="font-size:1.3rem;font-weight:700">4.90&nbsp;km</span>
</div>
<div class="arrow">×1.05 →</div>
<div class="box">
<div class="t">tolerant distance</div>
<span style="font-size:1.3rem;font-weight:700">5.145&nbsp;km</span>
</div>
<div class="arrow">≥ 5.0 →</div>
<div class="box" style="border-color:var(--good)">
<div class="t">verified</div>
<span class="pill v">counts</span>
</div>
</div>
</div>
<p>
Play with the whole chain below — it mirrors
<code>_verify_phone_then_runnerup</code>'s real order: StrongLifts today,
then RunnerUp today, then the week-scan fallback, using the same 5%
distance rule at every RunnerUp step.
</p>
<div class="sim diagram" id="sim">
<div class="grp">
<label><input type="checkbox" id="sl-today"> StrongLifts JSON dated <strong>today</strong></label>
</div>
<div class="grp">
<label><input type="checkbox" id="ru-today"> RunnerUp TCX dated <strong>today</strong></label>
<label for="ru-today-km">distance (km) <span class="rangeval" id="ru-today-km-val">4.9</span></label>
<input type="range" id="ru-today-km" min="3" max="6" step="0.1" value="4.9">
</div>
<div class="grp">
<label><input type="checkbox" id="week-gap" checked> Unlogged workout earlier this week</label>
<label for="week-type">source</label>
<select id="week-type">
<option value="runnerup">RunnerUp run</option>
<option value="stronglifts">StrongLifts session</option>
</select>
<div id="week-km-wrap">
<label for="week-km">distance (km) <span class="rangeval" id="week-km-val">4.9</span></label>
<input type="range" id="week-km" min="3" max="6" step="0.1" value="4.9">
</div>
</div>
<div class="btnrow">
<button class="primary" id="run-check">Click "Check Phone"</button>
<button id="reset-sim">Reset</button>
</div>
<div class="trace" id="trace"></div>
</div>
<h2 id="code">3. Code — the change, by concern</h2>
<h3>3.1 Splitting the date-check out of StrongLifts validation</h3>
<p>
<code>_validate_json_data</code> used to do two jobs at once: check the
date, then check the exercises/duration/succeeded content. The week-scan
needs the second job without the first, so it was split:
</p>
<div class="codewrap"><pre><span class="add">+ def _validate_json_content(self, data: dict) -> tuple[str, str]:
+ """...no date check..."""
+ if not data.get("exercises"):
+ return "no_exercises", "No exercises found in the workout JSON"
+ ...
def _validate_json_data(self, data: dict) -> tuple[str, str]:
<span class="del">- today = time.strftime("%Y-%m-%d")</span>
<span class="del">- if data.get("date") != today: return "stale", ...</span>
<span class="del">- if not data.get("exercises"): ...</span>
<span class="add">+ today = time.strftime("%Y-%m-%d")
+ if data.get("date") != today:
+ return "stale", f"Workout JSON is from {data.get('date')}, not today"
+ return self._validate_json_content(data)</span></pre></div>
<p>
<code>_validate_json_data</code> — the live, today-only check used by the
locked screen's unlock chain — keeps behaving exactly as before. It's now
just a thin date gate in front of the shared content check.
</p>
<h3>3.2 A week-scan backfill for StrongLifts (new — nothing existed before)</h3>
<p>
RunnerUp's <code>_scan_and_fill_week_runnerup</code> can find multiple
unlogged days because RunnerUp writes one <code>.tcx</code> file per
activity. StrongLifts only has <em>one</em> <code>workout_result.json</code>
snapshot on the phone at a time, so its equivalent can only ever backfill
one day per call — that's not a limitation of the fix, it's what the data
actually allows:
</p>
<div class="codewrap"><pre><span class="add">+ def _try_fill_stronglifts_for_week(self, log_file: Path) -> int:
+ if not self._has_adb_device():
+ 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=timezone.utc).astimezone().date()
+ week_start = today - timedelta(days=today.weekday())
+ entry_date = date.fromisoformat(date_str) # ValueError -> 0, logged
+ if not week_start <= entry_date <= today:
+ return 0
+ status, msg = self._validate_json_content(data) # no date gate
+ if status != "verified":
+ return 0
+ ...
+ if write_signed_entry(log_file, date_str, workout_data).appended:
+ return 1
+ return 0</span></pre></div>
<p>
<code>write_signed_entry</code> dedups by <code>workout_id</code> (here
<code>"phone_verified:{date}"</code>), so re-scanning an already-filled day
is a safe no-op — calling this twice in a row correctly returns
<code>1</code> then <code>0</code>.
</p>
<h3>3.3 Wiring both week-scans into the button</h3>
<p>
The old return type was a bare 4-tuple, which had no room for "nothing
verified today, but something got backfilled." A small frozen dataclass
replaces it, and a new helper runs both scanners and applies whatever
bonus is earned — reusing the exact prev/new-count math the login
auto-check and <code>--status</code> already use, specifically <em>not</em>
routing through <code>_apply_workout_credit</code>, which always files
under <em>today</em> by design and would mis-date a backfilled entry:
</p>
<div class="codewrap"><pre><span class="add">+ @dataclass(frozen=True)
+ class PhoneCheckOutcome:
+ credited_type: str | None
+ status: str
+ phone_message: str
+ runnerup_message: str | None
+ week_fill_message: str | None = None
+ def _backfill_week_and_apply_bonus(verifier) -> str | None:
+ prev_count = count_weekly_workouts(verifier.log_file)
+ filled = verifier._scan_and_fill_week_runnerup(verifier.log_file)
+ filled += verifier._try_fill_stronglifts_for_week(verifier.log_file)
+ if not filled:
+ return None
+ new_count = count_weekly_workouts(verifier.log_file)
+ bonus = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count))
+ if bonus > 0:
+ verifier._adjust_shutdown_time_by(bonus)
+ return f"Auto-filled {filled} workout(s) from earlier this week." + (
+ f" +{bonus}h shutdown time." if bonus > 0 else ""
+ )</span></pre></div>
<p>
<code>_verify_phone_then_runnerup</code> calls this only after both
today-checks fail, and <code>_on_phone_check_result</code> branches on it:
a verified-today result still goes through <code>_apply_workout_credit</code>
as before; a <code>week_fill_message</code> is shown as-is, because the
backfill step already wrote the entry and applied its own bonus — running
credit again there would double it.
</p>
<h3>3.4 The RunnerUp GPS tolerance</h3>
<div class="codewrap"><pre> distance_km = data["distance_m"] / 1000
<span class="add">+ if distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) < MIN_RUN_DISTANCE_KM:</span>
<span class="del">- if distance_km < MIN_RUN_DISTANCE_KM:</span>
msg = f"Run was {distance_km:.1f} km — need {MIN_RUN_DISTANCE_KM:.0f}+ km"
return "too_short", msg
<span class="add">+ if distance_km < MIN_RUN_DISTANCE_KM:
+ _logger.info("...within %.0f%% GPS tolerance — accepted.", ...)</span></pre></div>
<p>
<code>RUNNERUP_DISTANCE_TOLERANCE = 0.05</code> lives in
<code>_constants.py</code> next to the minimum it adjusts. The log line on
a tolerance-assisted pass exists so a below-threshold-but-accepted run is
still visible in the logs, never a silent pass — the entry itself still
stores the true, un-inflated distance.
</p>
<div class="note" style="border-left:4px solid #b58900;padding:0.75rem 1rem;margin:1.5rem 0;background:#fdf6e3">
<strong>Superseded 2026-08-14.</strong> The numbers on this page describe the
rule as it stood on 2026-07-18 and are kept for that history. The thresholds
and their <em>shape</em> have both since changed: duration and distance were
an <strong>AND</strong> here (30+ min <em>and</em> 5.0+ km); they are now an
<strong>OR</strong> — a run qualifies at
<code>MIN_RUN_DISTANCE_KM = 3.5</code> (inclusive, still with the 5% GPS
tolerance described above) <em>or</em> at
<code>MIN_RUN_DURATION_MINUTES = 40</code> (strict, no tolerance). The
30-minute floor every run had to clear is gone, so a fast 5&nbsp;km now
passes too. The interactive demo and quiz below still use the old 5.0&nbsp;km
AND-rule.
</div>
<h3>3.5 The CLI bug this surfaced (unrelated file, same root cause)</h3>
<p>
Verifying the fix against the real phone produced a week with two
workouts on one day for the first time. That's when
<code>_status.py</code>'s <code>--status</code> CLI printed
<em>"Need 2 more to reach the minimum (4)"</em> for a week that was
actually 4/4 — the GUI, which uses <code>count_weekly_workouts</code>
directly, showed the correct number.
</p>
<div class="codewrap"><pre><span class="del">- before_count = 0
- for i in range(7):
- ...
- if _print_day_line(d, ...):
- before_count += 1 # capped at 1 per day, regardless of
- # how many workouts that day held</span>
<span class="add">+ for i in range(7):
+ ...
+ _print_day_line(d, ...) # display only now
+ before_count = count_weekly_workouts(log_file) # the real anti-gaming count</span></pre></div>
<p>
<code>before_count</code> had been derived from <code>_print_day_line</code>'s
boolean return — one checkmark per day, regardless of how many workouts
that day held. It happened to agree with the real count for years, because
until this change no day ever legitimately held two <em>verified</em>
entries. The fix makes the CLI compute the same weekly total the GUI
already used, so the two views can no longer disagree.
</p>
<h2 id="quiz">4. Quiz</h2>
<p style="color:var(--muted);font-size:.9rem">
Five questions on the reasoning. Score: <span id="score">0 / 0</span>
</p>
<div id="quiz"></div>
<script>
/* ---------------- micro-world: the Check Phone chain ---------------- */
(function () {
const TOLERANCE = 0.05;
const MIN_KM = 5.0;
const slToday = document.getElementById("sl-today");
const ruToday = document.getElementById("ru-today");
const ruTodayKm = document.getElementById("ru-today-km");
const ruTodayKmVal = document.getElementById("ru-today-km-val");
const weekGap = document.getElementById("week-gap");
const weekType = document.getElementById("week-type");
const weekKmWrap = document.getElementById("week-km-wrap");
const weekKm = document.getElementById("week-km");
const weekKmVal = document.getElementById("week-km-val");
const traceEl = document.getElementById("trace");
function passesDistance(km) {
return km * (1 + TOLERANCE) >= MIN_KM;
}
function syncLabels() {
ruTodayKmVal.textContent = parseFloat(ruTodayKm.value).toFixed(1);
weekKmVal.textContent = parseFloat(weekKm.value).toFixed(1);
weekKmWrap.style.display = weekType.value === "runnerup" ? "block" : "none";
}
[ruTodayKm, weekKm, weekType].forEach((el) => el.addEventListener("input", syncLabels));
syncLabels();
function step(label, detail, ok) {
return '<div class="step"><span class="k">' + label + "</span> — " + detail +
(ok === true ? ' <span class="pill v">pass</span>' :
ok === false ? ' <span class="pill x">fail</span>' : "") + "</div>";
}
function run() {
let html = "";
// 1. StrongLifts today
if (slToday.checked) {
html += step("1. StrongLifts today", "JSON dated today, content valid", true);
finish(html, "phone_verified", "Credited today: StrongLifts verified.");
return;
}
html += step("1. StrongLifts today", "no JSON dated today", false);
// 2. RunnerUp today
if (ruToday.checked) {
const km = parseFloat(ruTodayKm.value);
const tol = (km * (1 + TOLERANCE)).toFixed(2);
const ok = passesDistance(km);
html += step(
"2. RunnerUp today",
km.toFixed(1) + " km × 1.05 = " + tol + " km " + (ok ? "≥" : "<") + " 5.0 km",
ok
);
if (ok) {
finish(html, "runnerup_verified", "Credited today: RunnerUp verified.");
return;
}
} else {
html += step("2. RunnerUp today", "no TCX dated today", false);
}
// 3. Week-scan fallback
if (weekGap.checked) {
if (weekType.value === "stronglifts") {
html += step("3. Week-scan (StrongLifts)", "unlogged day, content valid", true);
finish(html, "week_fill", "Auto-filled 1 workout from earlier this week.");
return;
}
const km = parseFloat(weekKm.value);
const tol = (km * (1 + TOLERANCE)).toFixed(2);
const ok = passesDistance(km);
html += step(
"3. Week-scan (RunnerUp)",
km.toFixed(1) + " km × 1.05 = " + tol + " km " + (ok ? "≥" : "<") + " 5.0 km",
ok
);
if (ok) {
finish(html, "week_fill", "Auto-filled 1 workout from earlier this week.");
return;
}
} else {
html += step("3. Week-scan", "nothing unlogged this week", false);
}
finish(html, "none",
"StrongLifts: Workout JSON is from earlier, not today · " +
"RunnerUp: No RunnerUp activity found for today");
}
function finish(html, kind, message) {
const cls = kind === "none" ? "x" : "v";
html += '<div class="verdict-line">Result: <span class="pill ' + cls + '">' +
message + "</span></div>";
traceEl.innerHTML = html;
}
document.getElementById("run-check").onclick = run;
document.getElementById("reset-sim").onclick = () => {
slToday.checked = false;
ruToday.checked = false;
ruTodayKm.value = 4.9;
weekGap.checked = true;
weekType.value = "runnerup";
weekKm.value = 4.9;
syncLabels();
traceEl.innerHTML = "";
};
})();
/* ---------------- quiz ---------------- */
(function () {
const QS = [
{
stem: "A 4.9 km run is logged today, and no StrongLifts JSON exists for today. What does Check Phone report?",
opts: [
"runnerup_verified — 4.9 km times 1.05 clears the 5.0 km line.",
"not_verified — a 4.9 km run stays under the 5.0 km minimum.",
"phone_verified — RunnerUp falls back to the StrongLifts checker.",
"week_fill — today's date always routes to the week-scan path."
],
correct: 0,
rat: "The 5% tolerance is applied before comparing to MIN_RUN_DISTANCE_KM, so today's own RunnerUp check already passes it — the week-scan fallback is never reached."
},
{
stem: "Why does _try_fill_stronglifts_for_week return at most 1, while _scan_and_fill_week_runnerup can return more?",
opts: [
"StrongLifts entries are capped at one credit per week by policy.",
"The phone holds one snapshot; RunnerUp writes one file per run.",
"Only RunnerUp results carry a date, so StrongLifts can't span days.",
"ADB restricts a single pull command to one remote file at a time."
],
correct: 1,
rat: "RunnerUp writes a dated .tcx per activity, so several unlogged days can each supply one; StrongLifts only ever has the single latest snapshot to pull."
},
{
stem: "Why does the week-scan backfill skip _apply_workout_credit and call _adjust_shutdown_time_by directly instead?",
opts: [
"_apply_workout_credit needs a live Tk window a worker thread lacks.",
"_apply_workout_credit always files the entry under today's own date.",
"_adjust_shutdown_time_by is the only method the bare verifier exposes.",
"Skipping it avoids clearing the sick-day debt on a backfilled entry."
],
correct: 1,
rat: "Its own docstring says so: it 'always files under today' as the intra-day reward path, which would mis-date a backfilled entry from an earlier day."
},
{
stem: "Before this change, why did _status.py's CLI summary agree with the GUI for years despite using a different formula?",
opts: [
"Both formulas give identical totals for any log file's contents.",
"The CLI recomputed its total from count_weekly_workouts either way.",
"No day had ever held two verified entries until this very change.",
"The CLI only ran weekly, before a second same-day workout could occur."
],
correct: 2,
rat: "A checkmark-per-day count and a per-workout count only diverge once some day holds more than one verified entry — which this very change made possible for the first time."
},
{
stem: "In the Check Phone chain, in what order are the three checks tried?",
opts: [
"RunnerUp today, then StrongLifts today, then the week-scan fallback.",
"The week-scan fallback first, then StrongLifts today, then RunnerUp today.",
"StrongLifts today, then RunnerUp today, then the week-scan fallback.",
"All three run together, and the first one to finish wins the credit."
],
correct: 2,
rat: "_verify_phone_then_runnerup checks StrongLifts first, falls back to RunnerUp, and only reaches the week-scan when neither today-check verifies."
}
];
const quizEl = document.getElementById("quiz");
const scoreEl = document.getElementById("score");
let answered = 0, right = 0;
QS.forEach((q, qi) => {
const wrap = document.createElement("div");
wrap.className = "q";
const stem = document.createElement("div");
stem.className = "stem";
stem.textContent = (qi + 1) + ". " + q.stem;
wrap.append(stem);
const rat = document.createElement("p");
rat.className = "rat";
q.opts.forEach((text, oi) => {
const b = document.createElement("button");
b.className = "opt";
b.textContent = text;
b.onclick = () => {
if (wrap.dataset.done) return;
wrap.dataset.done = "1";
answered++;
if (oi === q.correct) right++;
wrap.querySelectorAll(".opt").forEach((o, i) => {
o.disabled = true;
if (i === q.correct) o.classList.add("correct");
else if (i === oi) o.classList.add("wrong");
});
rat.textContent = (oi === q.correct ? "Correct. " : "Not quite. ") + q.rat;
rat.classList.add("show");
scoreEl.textContent = right + " / " + answered;
};
wrap.append(b);
});
wrap.append(rat);
quizEl.append(wrap);
});
})();
</script>
</body>
</html>

View File

@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>screen-locker — change explanations</title>
<style>
:root{--bg:#fbfbfd;--fg:#1a1a1f;--muted:#5b5b6b;--card:#fff;--line:#e4e4ec;
--accent:#6b4fd6;--accent-soft:#efeaff;--shadow:0 1px 3px rgba(0,0,0,.08),0 8px 24px rgba(0,0,0,.05);}
@media (prefers-color-scheme:dark){:root{--bg:#131318;--fg:#e9e9f0;--muted:#a2a2b4;--card:#1c1c24;
--line:#2c2c38;--accent:#a78bfa;--accent-soft:#241f38;--shadow:0 1px 3px rgba(0,0,0,.4),0 10px 30px rgba(0,0,0,.35);}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased}
.wrap{width:min(48rem,92vw);margin:0 auto;padding:2.6rem 0 5rem}
.kicker{color:var(--accent);font-weight:700;font-size:.8rem;letter-spacing:.08em;text-transform:uppercase}
h1{font-size:1.7rem;margin:.35rem 0 .3rem}
.lead{color:var(--muted);margin:0 0 1.6rem}
.grid{display:flex;flex-direction:column;gap:.7rem}
a.card{display:flex;flex-direction:column;gap:.2rem;text-decoration:none;color:var(--fg);
background:var(--card);border:1px solid var(--line);border-radius:12px;padding:.9rem 1.1rem;box-shadow:var(--shadow)}
a.card:hover{border-color:var(--accent);background:var(--accent-soft)}
.date{font-size:.75rem;color:var(--muted);font-variant-numeric:tabular-nums;letter-spacing:.03em}
.title{font-size:1.05rem;font-weight:600;line-height:1.35}
.empty{color:var(--muted)}
footer{margin-top:2.4rem;padding-top:1.1rem;border-top:1px solid var(--line);color:var(--muted);font-size:.83rem}
</style>
</head>
<body>
<div class="wrap">
<div class="kicker">screen-locker</div>
<h1>Change explanations</h1>
<p class="lead">3 explanations · interactive walkthroughs of notable changes, newest first.</p>
<div class="grid">
<a class="card" href="2026-07-18-explanation-check-phone-week-aware.html">
<span class="date">2026-07-18</span>
<span class="title">Check Phone learns to look backward, and forgives shaky GPS</span>
</a>
<a class="card" href="2026-07-17-explanation-multi-workout-day-and-sync.html">
<span class="date">2026-07-17</span>
<span class="title">Multiple workouts per day, the sync that never ran, and a ban on silence</span>
</a>
<a class="card" href="2026-07-13-explanation-flutter-coverage-to-100.html">
<span class="date">2026-07-13</span>
<span class="title">Driving the workout app to 100% test coverage</span>
</a>
</div>
<footer>Generated by the <code>explain-diff</code> skill. Open any card to replay its walkthrough.</footer>
</div>
</body>
</html>

View File

@ -17,8 +17,6 @@ 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
@ -149,12 +147,6 @@ cp "$SCRIPT_DIR/$RUNNERUP_WATCH_NAME" "$USER_SERVICE_DIR/$RUNNERUP_WATCH_NAME"
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"
@ -167,17 +159,25 @@ systemctl --user daemon-reload
# Enable the service to start on login (one-shot, no periodic timer)
systemctl --user enable "$SERVICE_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 early bird re-check timer
systemctl --user enable --now "$EARLY_BIRD_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
# 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"
# Enable the instant bonus pass on ledger writes
systemctl --user enable --now "$BONUS_PATH_NAME"
# Enable the instant RunnerUp credit on upload (restart picks up a new script)
systemctl --user enable "$RUNNERUP_WATCH_NAME"
systemctl --user restart "$RUNNERUP_WATCH_NAME"
# Enable the LAN workout poke listener (restart picks up new code)
systemctl --user enable "$WORKOUT_POKE_NAME"
systemctl --user restart "$WORKOUT_POKE_NAME"
# 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

@ -0,0 +1,211 @@
> **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: `~/src/utils/dopamine-ux/04-screen-locker.md` (source of truth; this is a
> distributed copy). Running order and cross-prompt rules: `~/src/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 `~/src/utils` (a *different* repo,
so it needs its own commit):
```bash
cd ~/src/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` 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: `~/src/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()`, `has_extended_early_bird()`.
- `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 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 in the
repo as a deliberate decision:
> `_startup_checks.py` — "there is no banked 'skip a workout' credit —
> that mechanic works against the goal of maximizing weekly workouts, so it was
> removed."
Every reward here is **display 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: **grant shutdown time for a streak, a week or a milestone.** The
weekly banked shutdown bonus (and the 4-week `_MILESTONE_INTERVAL` +1h) was
removed on purpose (2026-10-10): every shutdown minute comes from the
`earned_time` ladder and stops at its daily ceiling. Rewards here are display
only — no "+Nh" weekly reward, no bonus-hours field, no new shutdown source.
- 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
(`~/src/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 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 ~/src/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 ~/src/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 the streak and early-bird state
(`_status.py`), 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.
- `~/src/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`, `extended_early_bird_iso_weeks`; a legacy
`weekly_shutdown_bonus_hours` map is ignored and dropped on the next write).
- The threshold lives in `_extra_benefits.py`: `_BONUS_THRESHOLD = 5` (workouts
per week, for the streak and the early-bird extension). There is no milestone
constant any more.
- **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 `~/src/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

@ -0,0 +1,232 @@
> **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: `~/src/utils/dopamine-ux/05-workout-app.md` (source of truth; this is a
> distributed copy). Running order and cross-prompt rules: `~/src/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 `~/src/utils` (a *different* repo,
so it needs its own commit):
```bash
cd ~/src/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: `~/src/screen-locker`. App: `~/src/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 ~/src/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 ~/src/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.
- `~/src/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

@ -7,12 +7,11 @@ requires-python = ">=3.14"
# 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.13.0#subdirectory=gatelock",
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.11.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.5.0#subdirectory=sickdays",
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.0#subdirectory=earned_time",
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays",
]
[project.scripts]
@ -98,6 +97,16 @@ 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 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.
@ -171,14 +180,17 @@ exclude = [".venv/"]
init-hook = "import sys; sys.path.insert(0, '.')"
analyse-fallback-blocks = true
persistent = true
# 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
jobs = 0
py-version = "3.14"
ignore = [".venv", "__pycache__"]
# Test suites intentionally use patterns (protected-access, fixture-arg
# shadowing) that don't apply to source code; ruff's per-file-ignores carry
# the equivalent exemptions, so tests stay linted -- by ruff. This is the same
# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has
# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the
# basename of what pylint is *given*, and pre-commit hands it explicit file
# paths, so a directory name in `ignore` never fires under the hook.
ignore-paths = ["^(.*/)?tests/.*$"]
ignore-patterns = [".*\\.pyi$"]
[tool.pylint.messages_control]
@ -188,42 +200,7 @@ disable = []
[tool.pylint.design]
min-public-methods = 0
max-module-lines = 1000
# 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",
]
max-attributes = 10
[tool.pylint.typecheck]
generated-members = [
@ -254,14 +231,6 @@ 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",
@ -276,9 +245,6 @@ 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

@ -17,8 +17,6 @@ UNITS=(
"earner-bonus.service"
"runnerup-watch.service"
"workout-poke.service"
"runnerup-retire.timer"
"runnerup-retire.service"
)
for unit in "${UNITS[@]}"; do

View File

@ -2,28 +2,27 @@
# Runtime: tkinter/subprocess/socket/sqlite3 (stdlib) plus gatelock (below).
bandit==1.9.4
codespell==2.4.3
coverage==7.17.2
coverage==7.17.0
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
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.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.13.0#subdirectory=gatelock
holds @ git+https://github.com/kuhyx/utils@holds-v0.1.0#subdirectory=holds
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.11.0#subdirectory=gatelock
mcp==2.3.0
mypy==2.4.1
mypy==2.4.0
pre-commit==4.7.0
pylint==4.1.3
pytest==9.2.0
pytest==9.1.1
pytest-cov==7.1.0
pytest-randomly==5.1.0
pytest-randomly==5.0.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.17.4
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.5.0#subdirectory=sickdays
types-requests==2.33.0.20261030
ruff==0.17.2
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
types-requests==2.33.0.20261006
# 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.

View File

@ -1,15 +0,0 @@
[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

View File

@ -1,18 +0,0 @@
[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

View File

@ -1,133 +0,0 @@
"""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 (
from concurrent.futures import ( # pylint: disable=no-name-in-module
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 and not out.strip():
if code != 0 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 not code and out.strip() == "active"
return code == 0 and out.strip() == "active"
def collect_states() -> list[TimerState]:
@ -205,7 +205,7 @@ def collect_states() -> list[TimerState]:
states.append(
TimerState(
name=name,
enabled=not code and raw == "enabled",
enabled=code == 0 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

@ -24,8 +24,7 @@ import sys
from screen_locker._decision_log import LockDecision, record_decision
from screen_locker._decision_reasons import reasons_extra
from screen_locker._hold import workout_hold_active
from screen_locker._morning_session import MorningSkip, has_workout_skip_today
from screen_locker._morning_session import 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
@ -74,7 +73,6 @@ class _ReasonsMixin:
"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,
},
)
@ -153,34 +151,6 @@ class AutoUpgradeMixin(_ReasonsMixin):
return False
return True
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,
},
)
)
_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."""
try:

View File

@ -1,76 +0,0 @@
"""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)

View File

@ -1,39 +0,0 @@
"""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

@ -6,6 +6,7 @@ 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
@ -17,7 +18,6 @@ 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
@ -38,7 +38,6 @@ from screen_locker._compliance_trace import (
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
@ -60,142 +59,6 @@ __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,
@ -214,25 +77,155 @@ def explain_lock_decision(
reported via ``heat_skip_evaluated=False``, not guessed.
"""
instant = now if now is not None else datetime.now(tz=UTC)
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,
today_str = local_day(instant)
local_dt = instant.astimezone()
scheduled = is_scheduled_skip_today(local_dt.date())
pending = is_early_bird_pending(early_bird_pending_file, today=today_str)
# The early-bird window IS the wake-alarm carrot (EarlyBirdMixin).
window_open = wake_skip
expired = pending and not window_open
early_bird_open_and_pending = pending and window_open
sick_today = is_sick_day_today(local_dt.date())
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=wake_skip,
early_bird_window_open=window_open,
)
context = StageContext([], auto_upgrade, bool(degraded_sources))
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 a sick day in the shared sickdays pool.",
reason_false="Today is not a sick day.",
terminal_reason="Sick day today — lock skipped.",
)
if result is not None:
return result
degraded_note = describe_degraded_sources(degraded_sources)
terminal = _walk_chain(fired, context, degraded_note)
if terminal is not None:
return terminal
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="The morning session earned a workout skip for now.",
reason_false="No morning-session skip in force.",
terminal_reason="Morning session earned a skip — lock skipped.",
)
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
result = _check(
"evening_lockdown_today",
fired=evening_lockdown,
reason_true="Tonight's night lockdown already fired (>= 12:00 today).",
reason_false="No evening night lockdown yet today.",
terminal_reason="Night lockdown already happened tonight — the workout "
"lock does not re-arm.",
)
if result is not None:
return result
# Named for the conclusion; "..._pending_heat_check" read as "stuck".
return _stage(
@ -244,5 +237,5 @@ def explain_lock_decision(
"temperature is checked, and a run may or may not be happening "
"right now (see Health)." + degraded_note
),
context=context,
context=stage_context,
)

View File

@ -53,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 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
# 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
# ...and this is the leeway that is deliberately INVISIBLE. A session is
# actually accepted at MIN_WORKOUT_DURATION_MINUTES - this, so 25 minutes
# passes while every string still says 30. That gap is intentional (the user
# actually accepted at MIN_WORKOUT_DURATION_MINUTES - this, so 35 minutes
# passes while every string still says 40. 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.
@ -67,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
# 25.0-minute session cannot pass one gate and fail another.
# 35.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 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).
# 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.
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
@ -105,22 +105,11 @@ SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json"
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
# A day's RunnerUp walks (commute legs) count as the workout once their
# MOVING time sums to this; moving = trackpoint pairs at walking speed.
WALK_MIN_MOVING_MINUTES = 40
WALK_SPEED_MPS = (0.5, 3.0)
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"

View File

@ -34,12 +34,7 @@ if TYPE_CHECKING:
_logger: Final = logging.getLogger(__name__)
# textgate: glyphs + numbers (colour emoji are safe in the notification
# daemon, unlike Tk). Was "Workout credited 💪" / "Shutdown 23:00 (+2h) ·
# gaming 7.0h" / "Credit applied (details unavailable)".
TITLE: Final = "\N{FLEXED BICEPS} \N{CHECK MARK}"
_SHUTDOWN: Final = "\N{POWER SYMBOL}"
_GAMING: Final = "\N{VIDEO GAME}"
TITLE: Final = "Workout credited \N{FLEXED BICEPS}"
_NOTIFY_TIMEOUT_SECONDS: Final = 5
_MINUTES_PER_HOUR: Final = 60.0
@ -67,7 +62,7 @@ def credit_body(
after: tuple[int, int, int] | None,
gaming_minutes: int | None,
) -> str:
"""``⏻ 23:00 (+2h) · 🎮 7.0h`` -- whichever parts are known.
"""``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``.
@ -81,14 +76,14 @@ def credit_body(
else:
day = date.fromisoformat(today_str())
tonight = tonight_minutes(after, day)
text = f"{_SHUTDOWN} {hhmm(tonight)}"
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 "\N{CHECK MARK}"
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:
@ -108,7 +103,7 @@ def send_notification(title: str, body: str) -> bool:
except (OSError, subprocess.TimeoutExpired) as exc:
_logger.warning("Desktop notification failed (%s): %s", exc, body)
return False
if result.returncode:
if result.returncode != 0:
_logger.warning(
"notify-send exited %d (%s) — no desktop notification: %s",
result.returncode,

View File

@ -57,7 +57,7 @@ def credit_slot(
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
return CreditSlot.FIRST if before == 0 else CreditSlot.FURTHER
def _counts_alone(entry: Mapping[str, Any]) -> bool:

View File

@ -16,7 +16,7 @@ midnight, so a 30-minute earner is applied exactly.
from __future__ import annotations
from datetime import UTC, date, datetime
from datetime import date, datetime
import functools
import logging
from pathlib import Path
@ -26,7 +26,6 @@ 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__)
@ -106,25 +105,12 @@ 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),
)
return earned_time.done_today(earner, ledger_file(earner), HMAC_KEY_FILE, now=now)
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),
)
return earned_time.credit_units(earner, ledger_file(earner), HMAC_KEY_FILE, day)
def gate_answers(day: date | None = None) -> dict[str, int | bool | None]:
@ -176,6 +162,12 @@ def extra_minutes(earner: earned_time.Earner, day: date) -> int:
return earner.extra_shutdown_minutes if fn is None else int(fn(earner, day))
def ceiling(day: date) -> int:
"""The latest shutdown ``day`` can earn, minutes after its midnight."""
fn = getattr(earned_time, "shutdown_ceiling_for", None)
return earned_time.SHUTDOWN_CEILING_MINUTES if fn is None else int(fn(day))
def first_credit_time(earner: earned_time.Earner, day: date) -> float | None:
"""Unix time of ``earner``'s first verified credit counting for ``day``.
@ -187,9 +179,7 @@ def first_credit_time(earner: earned_time.Earner, day: date) -> float | 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)
)
stamp: float | None = fn(earner, ledger_file(earner), HMAC_KEY_FILE, day)
return stamp

View File

@ -2,10 +2,7 @@
Tracks:
- Consecutive weeks with 5+ workouts (streak counter).
- 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.
- ISO weeks in which the early-bird window is extended to 09:00.
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
@ -18,7 +15,7 @@ State is persisted in ``extra_benefits_state.json`` next to this file.
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from datetime import UTC, datetime, timedelta
import json
import logging
from typing import TYPE_CHECKING, Any
@ -62,7 +59,7 @@ 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: date) -> str:
def _current_iso_week(now: datetime) -> str:
"""Return *now*'s ISO week as ``YYYY-Www``."""
year, week, _ = now.isocalendar()
return f"{year}-W{week:02d}"
@ -103,14 +100,16 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
eb_weeks: list[str] = list(state.get("extended_early_bird_iso_weeks", []))
rewards: list[str] = []
prev_week_str = _current_iso_week(sunday_prev_week)
prev_year, prev_week, _ = sunday_prev_week.isocalendar()
prev_week_str = f"{prev_year}-W{prev_week:02d}"
if prev_week_count >= _BONUS_THRESHOLD:
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}! {streak}-week streak"
f"{prev_week_count} workouts in {prev_week_str}! "
f"{streak}-week streak, early-bird extended to 09:00 this week"
)
else:
if streak > 0:
@ -129,9 +128,6 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
"consecutive_5plus_weeks": streak,
"last_processed_iso_week": current_week_str,
"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)
@ -151,15 +147,3 @@ 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

@ -1,28 +0,0 @@
"""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

@ -1,98 +0,0 @@
"""The locker's pictograms: one place, so a glyph means one thing everywhere.
textgate (kuhy, 2026-11-08): nothing he sees carries words. Every screen is
built from these plus numbers, each paired with a palette colour as the
second channel (success / warning / danger / muted). Plain geometric
characters only: a colour-emoji fallback font can crash Tk 8.6, so no
U+1Fxxx and nothing with an emoji presentation by default.
"""
from __future__ import annotations
from typing import Final
LIFT: Final = "●━━●" # a barbell: the StrongLifts workout / "go work out"
RUN: Final = "⇝" # a RunnerUp run
PHONE: Final = "▯" # the phone (adb)
WAIT: Final = "⋯" # checking / unlocking in progress
OK: Final = "✓"
FAIL: Final = "✗"
RETRY: Final = "↻"
CLOSE: Final = "✕"
BACK: Final = "↩"
GO: Final = "▶"
SKIP: Final = "↷"
SYNC: Final = "⇅" # a sync backend (Firebase)
CLOCK: Final = "◷" # time, the system clock
SHORT: Final = "◔" # a session shorter than the minimum
FORM: Final = "≡" # the manual-workout evidence form
SICK: Final = "✚" # a sick day
SHUTDOWN: Final = "⏻" # the PC's shutdown time
STREAK: Final = "▰" # one week of the streak
WEEK_DONE: Final = "●" # a counted day
WEEK_SICK: Final = "✚" # a sick day in the week row
WEEK_EMPTY: Final = "○" # a day with nothing counted
HEAT: Final = "▲" # the outdoor temperature
LOCK: Final = "■" # the lock fired
UNLOCK: Final = "□" # the lock stood down
OPTIONAL: Final = "◇" # Tue-Thu: a workout is optional today
GIFT: Final = "+" # an earned reward, always followed by its amount
# Startup phone-check status -> the glyph that replaces its sentence.
STATUS_GLYPH: Final[dict[str, str]] = {
"too_short": SHORT,
"clock_tampered": f"{CLOCK}{FAIL}",
"stale": f"{CLOCK}{FAIL}",
"no_exercises": f"{LIFT} 0",
"not_verified": FAIL,
"no_phone": f"{PHONE}{FAIL}",
"error": f"{PHONE}{FAIL}",
}
# log.json entry ``type`` -> glyph, for the status window's day cells.
ENTRY_GLYPH: Final[dict[str, str]] = {
"phone_verified": LIFT,
"strength": LIFT,
"pc_workout_verified": LIFT,
"runnerup_verified": RUN,
"running": RUN,
"manual_workout": FORM,
"heat_skip": HEAT,
"relaxed_day_skip": SKIP,
"early_bird": OK,
}
# Lock-decision stage (``_compliance_state._STEPS`` names) -> glyph. A stage
# not listed shows only the lock/unlock square and its colour.
STAGE_GLYPH: Final[dict[str, str]] = {
"scheduled_skip": SKIP,
"early_bird_pending_expired": CLOCK,
"early_bird_window_open": CLOCK,
"sick_day": SICK,
"already_logged": OK,
"wake_alarm_skip": CLOCK,
"test_hold_active": WAIT,
"relaxed_day_already_skipped": f"{OPTIONAL}{SKIP}",
"relaxed_day": OPTIONAL,
"weekly_minimum_met": WEEK_DONE,
"evening_lockdown_today": SHUTDOWN,
"would_lock": LIFT,
}
def entry_glyphs(types: tuple[str, ...]) -> str:
"""Glyphs for a day's entry types; an unknown type shows as ``OK``."""
return " ".join(ENTRY_GLYPH.get(t, OK) for t in types)
def dots(done: int, total: int) -> str:
"""``●●○○ 2/4``: progress as filled dots plus the numbers (two channels)."""
filled = max(0, min(done, total))
return f"{WEEK_DONE * filled}{WEEK_EMPTY * (total - filled)} {done}/{total}"
def status_glyph(status: str) -> str:
"""Return the glyph for a phone-check *status* (``FAIL`` if unknown)."""
return STATUS_GLYPH.get(status, FAIL)

View File

@ -42,35 +42,39 @@ def build_heat_skip_content(
outer = tk.Frame(parent, bg=_COLORS.palette.bg)
outer.place(relx=0.5, rely=0.5, anchor="center")
# textgate: glyphs + numbers only, warning colour as the second channel.
# Was "\u2600 Too hot to workout?" / "<city>: 31\u00b0C (threshold: 30\u00b0C)" /
# "Skip today's workout due to extreme heat?". Plain geometric glyphs,
# not the sun (U+2600 can fall back to a colour-emoji font and crash Tk).
tk.Label(
outer,
text="\u25b2",
font=_COLORS.font("display", bold=True, family=_FONT),
text="\u2600 Too hot to workout?",
font=_COLORS.font("body", bold=True, family=_FONT),
bg=_COLORS.palette.bg,
fg=_COLORS.palette.warning,
).pack(pady=(0, _COLORS.space("sm")))
tk.Label(
outer,
# The city name was dropped too: the reading is what matters.
text=f"{temp:.0f}\u00b0C \u2265 {HEAT_SKIP_TEMP_THRESHOLD}\u00b0C",
text=(
f"{HEAT_SKIP_CITY}: {temp:.0f}\u00b0C"
f" (threshold: {HEAT_SKIP_TEMP_THRESHOLD}\u00b0C)"
),
font=_COLORS.font("body", family=_FONT),
bg=_COLORS.palette.bg,
fg=_COLORS.palette.warning,
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.palette.bg,
fg=_COLORS.palette.muted,
).pack(pady=(_COLORS.space("xs"), 0))
btn_frame = tk.Frame(outer, bg=_COLORS.palette.bg)
btn_frame.pack(pady=_COLORS.space("lg"))
# Was "Skip workout" (warning fill) / "No, I'll workout" (neutral fill).
skip_button = tk.Button(
btn_frame,
name="skip",
text="\u21b7",
text="Skip workout",
command=on_skip,
bg=_COLORS.palette.warning,
fg=_COLORS.palette.on_fill,
@ -85,8 +89,7 @@ def build_heat_skip_content(
decline_button = tk.Button(
btn_frame,
name="decline",
text="▶",
text="No, I'll workout",
command=on_decline,
bg=_COLORS.palette.field_bg,
fg=_COLORS.palette.fg,
@ -127,7 +130,7 @@ class HeatSkipMixin:
# Use the root itself (not a Toplevel) so we can go fullscreen.
# This window is destroyed before the main GateRoot is created.
root = tk.Tk()
root.title("")
root.title("Extreme Heat")
root.configure(bg=_COLORS.palette.bg)
root.attributes("-fullscreen", True)
root.attributes("-topmost", True)

View File

@ -1,86 +0,0 @@
"""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 (
from concurrent.futures import ( # pylint: disable=no-name-in-module
ThreadPoolExecutor,
as_completed,
)

View File

@ -19,9 +19,8 @@ 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._earned import ceiling, hhmm
from screen_locker._grace_floor import save_lift, tonight_minutes
_logger = logging.getLogger(__name__)
@ -54,13 +53,11 @@ class LateCreditMixin:
if TYPE_CHECKING:
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
raise NotImplementedError
def _read_shutdown_config(self) -> tuple[int, int, int] | None: ...
def _write_shutdown_config(
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ...
) -> bool:
raise NotImplementedError
) -> bool: ...
def _apply_sick_floor(self) -> bool: ...

View File

@ -45,9 +45,6 @@ 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"]
@ -116,76 +113,6 @@ def _due_date(
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]]],
@ -230,10 +157,51 @@ def ingest_manual_records(
pending = pending_future_dates(log_file, batch, today=reference)
ingested: list[str] = []
for record_id, payload in batch:
outcome = _ingest_one(log_file, record_id, payload, reference, pending)
if outcome is None:
date = _due_date(record_id, payload, reference)
if date is None:
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
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,
)
continue
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,
)
continue
entry, result = outcome
ingested.append(record_id)
if on_ingested is not None:
on_ingested(entry, result.prior_entries)

View File

@ -49,7 +49,7 @@ def is_empty_stub(payload: Mapping[str, object]) -> bool:
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)
return not (_SUBSTANTIVE_FIELDS & set(payload))
def _coerce_int(value: object) -> int:

View File

@ -20,19 +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,
SPORT_GLYPHS,
SPORT_LABELS,
SPORT_OTHER,
SPORT_TABLE_TENNIS,
@ -45,7 +41,6 @@ from screen_locker._manual_workout_types import (
__all__ = [
"MANUAL_WORKOUT_TYPE",
"SPORT_CHOICES",
"SPORT_GLYPHS",
"SPORT_LABELS",
"SPORT_OTHER",
"SPORT_TABLE_TENNIS",
@ -87,9 +82,19 @@ 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."""
error = validate_required(draft)
if error is not None:
return error
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"
duration = _duration_minutes(draft)
if duration is None:
@ -114,7 +119,18 @@ def validate_manual_workout(draft: ManualWorkoutDraft) -> str | None:
if error is not None:
return error
return validate_reflections(draft)
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
def _validate_table_tennis(draft: ManualWorkoutDraft) -> str | None:
@ -127,7 +143,7 @@ def _validate_table_tennis(draft: ManualWorkoutDraft) -> str | None:
):
if value < 0:
return f"{label} cannot be negative"
if not draft.matches_won + draft.matches_lost:
if draft.matches_won + draft.matches_lost == 0:
return "Enter at least one match played (won + lost)"
if not draft.racket.strip():
return "Racket is required"

View File

@ -1,44 +0,0 @@
"""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

@ -13,7 +13,6 @@ the form shows a "scheduled" screen that returns to the host's cancelled hook
from __future__ import annotations
import logging
import tkinter as tk
from typing import TYPE_CHECKING
@ -30,16 +29,6 @@ from screen_locker._ui_widgets import disable_paste
if TYPE_CHECKING:
from screen_locker._manual_workout import ManualWorkoutDraft
# textgate: plain glyphs (no colour emoji: Tk 8.6 can crash on them).
SCHEDULED_GLYPH = "✓"
"""Heading of the scheduled screen (success colour)."""
BACK_GLYPH = "↩"
"""The scheduled screen's only button: back to the form's host."""
REJECTED_GLYPH = "✗"
"""Form rejected (danger); was the validator's sentence (now logged)."""
_logger = logging.getLogger(__name__)
class ManualWorkoutDateMixin:
"""Date row, budget gate and future-dated submission of the manual form."""
@ -70,10 +59,9 @@ class ManualWorkoutDateMixin:
)
entry.pack(side="right", padx=self._colors.space("xs"))
disable_paste(entry)
# Was "Workout date (today to +7 days):" -- a day square + the range.
tk.Label(
row,
text="□ +0…+7",
text="Workout date (today to +7 days):",
font=self._colors.font("label"),
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
@ -92,45 +80,37 @@ class ManualWorkoutDateMixin:
if error is None and not future:
error = budget_error(self.log_file, date)
if error is not None:
self._mw_reject(error)
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_reject(result.reason)
self._mw_error_label.config(text=result.reason)
return True
_logger.info("Manual workout scheduled for %s: %s", date, result.reason)
self._show_manual_workout_scheduled(date)
self._show_manual_workout_scheduled(result.reason)
return True
def _mw_reject(self, reason: str) -> None:
"""Show the form's fail mark; the reason goes to the log (textgate)."""
_logger.warning("Manual workout date/budget rejected: %s", reason)
self._mw_error_label.config(text=REJECTED_GLYPH)
def _show_manual_workout_scheduled(self, date: str) -> None:
"""Confirm a future entry and go back; it does NOT unlock anything.
Shows the scheduled *date* (content) under the tick; the scheduler's
sentence is logged by the caller.
"""
def _show_manual_workout_scheduled(self, reason: str) -> None:
"""Confirm a future entry and go back; it does NOT unlock anything."""
self.clear_container()
# Was "✓ Workout Scheduled" + "Nothing is credited today. It counts by
# itself on that date, and can be deleted in the phone app until
# then." + an "OK" button: a green tick, the reason, a back arrow.
self._label(
SCHEDULED_GLYPH,
"✓ Workout Scheduled",
role="display",
scale=1.3,
color=self._colors.palette.success,
pad="lg",
)
self._text(date, role="subtitle", color=self._colors.palette.success)
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,
BACK_GLYPH,
"OK",
bg=self._colors.palette.accent,
command=self._on_manual_workout_cancelled,
width=12,

View File

@ -23,24 +23,12 @@ from screen_locker import _manual_workout
from screen_locker._constants import (
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
)
from screen_locker._manual_workout_date import (
BACK_GLYPH,
REJECTED_GLYPH,
ManualWorkoutDateMixin,
)
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
_logger = logging.getLogger(__name__)
# textgate glyphs (plain Unicode, no colour emoji: Tk 8.6 can crash on them).
FORM_GLYPH = "≡"
"""Form heading (accent); was "Log Manual Workout"."""
EXHAUSTED_GLYPH = "✕"
"""Budget used up (danger); was the "budget exhausted" sentence."""
SUBMIT_GLYPH = "✓"
"""Submit button (accent fill); was "SUBMIT"."""
_SPORT_LABEL_TO_CODE = {
label: code for code, label in _manual_workout.SPORT_LABELS.items()
}
@ -56,11 +44,12 @@ class ManualWorkoutDialogMixin(
def _show_manual_workout_form(self) -> None:
"""Render the manual-workout evidence form, or a budget-exhausted note."""
self.clear_container()
# textgate: glyphs, colours second. Was "Log Manual Workout" heading /
# "Manual-workout budget exhausted for this window." / "BACK".
self._label(FORM_GLYPH, color=self._colors.palette.accent, pad="xs")
self._label("Log Manual Workout", color=self._colors.palette.accent, pad="xs")
if self._mw_budget_closed():
self._label(EXHAUSTED_GLYPH, color=self._colors.palette.danger, pad="xs")
self._text(
"Manual-workout budget exhausted for this window.",
color=self._colors.palette.danger,
)
self._text(
_manual_workout.budget_summary(self.log_file),
color=self._colors.palette.muted,
@ -68,7 +57,7 @@ class ManualWorkoutDialogMixin(
row = self._button_row()
self._button(
row,
BACK_GLYPH,
"BACK",
bg=self._colors.palette.field_bg,
command=self._on_manual_workout_cancelled,
width=12,
@ -98,23 +87,24 @@ class ManualWorkoutDialogMixin(
self._mw_rpe_var = tk.IntVar(value=5)
self._mw_text_widgets: dict[str, tk.Text] = {}
# textgate: every prompt is a glyph; optional fields are in
# parentheses; section headings are plain rules. The old prompts were
# the Basics heading, start and end time, the location heading and
# name, how you got there, the cost in PLN, the optional reservation
# phone number, and the activity-details heading.
self._mw_section(form)
self._mw_section(form, "Basics")
self._mw_sport_row(form)
self._mw_vars["start_time"] = self._mw_entry(form, "◷▶", focus=True)
self._mw_vars["end_time"] = self._mw_entry(form, "◷■")
self._mw_vars["start_time"] = self._mw_entry(
form, "Start time (HH:MM):", focus=True
)
self._mw_vars["end_time"] = self._mw_entry(form, "End time (HH:MM):")
self._mw_section(form)
self._mw_vars["location_name"] = self._mw_entry(form, "⌖")
self._mw_vars["transport_method"] = self._mw_entry(form, "⇄")
self._mw_vars["cost"] = self._mw_entry(form, "¤")
self._mw_vars["reservation_phone"] = self._mw_entry(form, "(▯#)")
self._mw_section(form, "Location & logistics")
self._mw_vars["location_name"] = self._mw_entry(form, "Location name:")
self._mw_vars["transport_method"] = self._mw_entry(
form, "How did you get there?"
)
self._mw_vars["cost"] = self._mw_entry(form, "Cost (e.g. 40 PLN):")
self._mw_vars["reservation_phone"] = self._mw_entry(
form, "Reservation phone number (if booked by phone, optional):"
)
self._mw_section(form)
self._mw_section(form, "Activity details")
# 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)
@ -134,22 +124,33 @@ class ManualWorkoutDialogMixin(
self._build_table_tennis_fields(self._mw_tt_frame)
self._build_other_sport_fields(self._mw_other_frame)
self._mw_rpe_row(form)
# Was "Techniques/focus areas practiced (optional):", "Warm-up
# duration (optional):", "Pain or injury notes (optional, ...)".
self._mw_vars["techniques_practiced"] = self._mw_entry(form, "(◎)")
self._mw_vars["warm_up_minutes"] = self._mw_entry(form, "(∿)")
self._mw_vars["pain_or_injury"] = self._mw_entry(form, "(↯)")
self._mw_vars["techniques_practiced"] = self._mw_entry(
form, "Techniques/focus areas practiced (optional):"
)
self._mw_vars["warm_up_minutes"] = self._mw_entry(
form, "Warm-up duration (optional):"
)
self._mw_vars["pain_or_injury"] = self._mw_entry(
form, "Pain or injury notes (optional, default none):"
)
self._on_mw_sport_changed(
_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
)
# Was "Reflection", "What went well / What to improve / Overall
# feeling about the session (min N chars):".
self._mw_section(form)
least = f"≥{MANUAL_WORKOUT_REFLECTION_MIN_CHARS}"
self._mw_text_widgets["went_well"] = self._mw_textbox(form, f"+ {least}")
self._mw_text_widgets["to_improve"] = self._mw_textbox(form, f"↑ {least}")
self._mw_text_widgets["overall_feeling"] = self._mw_textbox(form, f"~ {least}")
self._mw_section(form, "Reflection")
self._mw_text_widgets["went_well"] = self._mw_textbox(
form,
f"What went well (min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):",
)
self._mw_text_widgets["to_improve"] = self._mw_textbox(
form,
f"What to improve (min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):",
)
self._mw_text_widgets["overall_feeling"] = self._mw_textbox(
form,
"Overall feeling about the session "
f"(min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):",
)
self._mw_error_label = self._text(
"", role="label", color=self._colors.palette.danger, pad="xs"
@ -157,14 +158,14 @@ class ManualWorkoutDialogMixin(
button_row = self._button_row()
self._button(
button_row,
SUBMIT_GLYPH,
"SUBMIT",
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_GLYPH,
"BACK",
bg=self._colors.palette.field_bg,
command=self._on_manual_workout_cancelled,
width=12,
@ -211,9 +212,7 @@ class ManualWorkoutDialogMixin(
)
error = _manual_workout.validate_manual_workout(draft)
if error is not None:
# textgate: the reason is logged; the form shows the fail mark.
_logger.warning("Manual workout form rejected: %s", error)
self._mw_error_label.config(text=REJECTED_GLYPH)
self._mw_error_label.config(text=error)
return
if self._mw_handled_by_date(draft):
return

View File

@ -49,11 +49,17 @@ class ManualWorkoutSportFieldsMixin:
value=_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
)
row = tk.Frame(parent, bg=self._colors.palette.bg)
# textgate: the "Sport:" caption is gone; the radio row is the cue.
for code, label in _manual_workout.SPORT_LABELS.items():
tk.Label(
row,
text="Sport:",
font=self._colors.font("body"),
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
).pack(side="left", padx=self._colors.space("xs"))
for label in _manual_workout.SPORT_LABELS.values():
tk.Radiobutton(
row,
text=_manual_workout.SPORT_GLYPHS[code],
text=label,
value=label,
variable=self._mw_sport_var,
# Radiobutton's command takes no argument -- bind the label.
@ -91,25 +97,25 @@ class ManualWorkoutSportFieldsMixin:
def _build_table_tennis_fields(self, parent: tk.Widget) -> None:
"""Build the table-tennis-specific score/equipment fields."""
# textgate: was "Matches won/lost:", "Sets won/lost:", "Racket used:",
# "Balls used:". Match / set glyph + win / loss mark.
self._mw_int_vars["matches_won"] = self._mw_int_field(parent, "◍✓")
self._mw_int_vars["matches_lost"] = self._mw_int_field(parent, "◍✗")
self._mw_int_vars["sets_won"] = self._mw_int_field(parent, "▤✓")
self._mw_int_vars["sets_lost"] = self._mw_int_field(parent, "▤✗")
self._mw_vars["racket"] = self._mw_entry(parent, "◖")
self._mw_vars["balls"] = self._mw_entry(parent, "•")
self._mw_int_vars["matches_won"] = self._mw_int_field(parent, "Matches won:")
self._mw_int_vars["matches_lost"] = self._mw_int_field(parent, "Matches lost:")
self._mw_int_vars["sets_won"] = self._mw_int_field(parent, "Sets won:")
self._mw_int_vars["sets_lost"] = self._mw_int_field(parent, "Sets lost:")
self._mw_vars["racket"] = self._mw_entry(parent, "Racket used:")
self._mw_vars["balls"] = self._mw_entry(parent, "Balls used:")
def _build_other_sport_fields(self, parent: tk.Widget) -> None:
"""Build the generic "other sport" fields."""
# Was "What sport/activity:" / "What was done (min N chars):" /
# "Equipment used (optional):"; optional fields are in parentheses.
self._mw_vars["activity_type_other"] = self._mw_entry(parent, "✱")
self._mw_vars["activity_type_other"] = self._mw_entry(
parent, "What sport/activity:"
)
self._mw_text_widgets["activity_details"] = self._mw_textbox(
parent,
f"✱≡ ≥{MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS}",
f"What was done (min {MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS} chars):",
)
self._mw_vars["equipment"] = self._mw_entry(
parent, "Equipment used (optional):"
)
self._mw_vars["equipment"] = self._mw_entry(parent, "(⊡)")
def _current_mw_sport(self) -> str:
"""Return the internal sport code for the currently-selected label."""

View File

@ -37,12 +37,6 @@ SPORT_LABELS: dict[str, str] = {
SPORT_TABLE_TENNIS: "Table tennis",
SPORT_OTHER: "Other",
}
# What the form paints for each sport (textgate); SPORT_LABELS stays the
# radio value and the data, never painted.
SPORT_GLYPHS: dict[str, str] = {
SPORT_TABLE_TENNIS: "◍",
SPORT_OTHER: "✱",
}
def _today_iso() -> str:
@ -109,8 +103,8 @@ def count_in_window(
dated after today are NOT in it. Whether a new entry fits is a different
question, answered per workout date by ``_manual_future``.
"""
day = today or _today_iso()
today_dt = _parse_iso(day)
today_str = today or _today_iso()
today_dt = _parse_iso(today_str)
if today_dt is None:
return 0
cutoff = today_dt - timedelta(days=days)
@ -139,16 +133,12 @@ def budget_summary(
*,
today: str | None = None,
) -> str:
"""One-line UI summary of the manual-workout budget: glyph and numbers.
``≡ used/7-day budget · used/30-day budget`` (textgate; was
``Manual: 0/2w · 0/10m``). The shorter window always comes first.
"""
"""One-line UI summary string for the manual-workout budget."""
week = count_in_window(log_file, 7, today=today)
month = count_in_window(log_file, 30, today=today)
return (
f"≡ {week}/{MANUAL_WORKOUT_BUDGET_PER_7_DAYS} · "
f"{month}/{MANUAL_WORKOUT_BUDGET_PER_30_DAYS}"
f"Manual: {week}/{MANUAL_WORKOUT_BUDGET_PER_7_DAYS}w · "
f"{month}/{MANUAL_WORKOUT_BUDGET_PER_30_DAYS}m"
)

View File

@ -66,10 +66,17 @@ class ManualWorkoutFormWidgetsMixin:
counters[id(parent)] = idx + 2
return idx // 2
def _mw_section(self, parent: tk.Widget) -> None:
"""Add a full-width rule between form sections (was a heading)."""
rule = tk.Frame(parent, bg=self._colors.palette.accent, height=2)
self._mw_grid(parent, rule, full=True)
def _mw_section(self, parent: tk.Widget, title: str) -> None:
"""Add a full-width section heading inside the form."""
label = tk.Label(
parent,
text=title,
font=self._colors.font("body", bold=True),
fg=self._colors.palette.accent,
bg=self._colors.palette.bg,
anchor="w",
)
self._mw_grid(parent, label, full=True)
def _mw_entry(
self, parent: tk.Widget, label: str, *, focus: bool = False
@ -183,8 +190,10 @@ class ManualWorkoutFormWidgetsMixin:
row = tk.Frame(parent, bg=self._colors.palette.bg)
tk.Label(
row,
# Was "RPE — perceived exertion (1-10):": effort bars + range.
text=f"▁▃▅▇ {MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}",
text=(
f"RPE — perceived exertion "
f"({MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}):"
),
font=self._colors.font("label"),
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,

View File

@ -28,18 +28,6 @@ _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."""
@ -131,7 +119,16 @@ 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 or not _in_current_week(date_str):
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:
return 0
status, msg = self._validate_json_content(data)
if status != "verified":
@ -148,33 +145,6 @@ 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: Firebase sync first, ADB/HTTP as fallback.
@ -186,9 +156,28 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
if not clock_ok:
return "clock_tampered", clock_msg
sync_result, sync_error = self._pull_sync_result()
if sync_result is not None and sync_result[0] == "verified":
return sync_result
# Firebase 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(
"Firebase sync unavailable (%s) — trying ADB/HTTP...", sync_error
)
# 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
@ -205,23 +194,13 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
data = self._fetch_http_workout()
if data is not None:
return self._validate_json_data(data)
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"
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

@ -30,7 +30,6 @@ 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
@ -46,7 +45,6 @@ if TYPE_CHECKING:
__all__ = [
"BatchOutcome",
"PokeOutcome",
"credit_manual_workouts",
"credit_session",
"credit_sessions",
"current_gaming_minutes",
@ -203,40 +201,11 @@ def credit_sessions(
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 "
"%d streamed session(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

@ -135,7 +135,7 @@ class PokeHandler(BaseHTTPRequestHandler):
started = time.perf_counter()
try:
status, reply = self._handle()
except Exception: # pylint: disable=broad-exception-caught # handler boundary: logged, answered 500
except Exception:
_logger.exception("Workout poke from %s crashed", self.client_address[0])
status = HTTPStatus.INTERNAL_SERVER_ERROR
reply = _refused(

View File

@ -15,7 +15,7 @@ import logging
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
from screen_locker._walk_tcx import walk_day, walk_shortfall
if TYPE_CHECKING:
from collections.abc import Callable
@ -72,47 +72,66 @@ class RunnerUpBackfillMixin:
Returns True if a new verified entry was appended for ``date_str``.
"""
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)
walks: list[dict[str, Any]] = []
for remote in self._find_runnerup_exports_for_date(date_str):
data = self._pull_and_parse_tcx(remote)
if data is None:
continue
if data.get("walking"):
# Never one walk at a time: a day's walks are summed below.
walks.append(data)
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),
"completed_at": data.get("ended_at"),
}
return _record(log_file, date_str, workout_data, on_ingested)
return self._try_fill_walks(date_str, walks, log_file, on_ingested)
@staticmethod
def _try_fill_day(
def _try_fill_walks(
date_str: str,
activities: list[dict[str, Any]],
walks: 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.
"""Append the day's walking 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.
Same type and so the same ``runnerup_verified:{date}`` slot as a run:
a walk and a run on one day are one RunnerUp credit, exactly as two
runs already were.
"""
if not activities:
if not walks:
return False
day = activity_day(activities)
day = walk_day(walks)
if not day.qualifies:
_logger.warning(
"RunnerUp activity for %s does not count yet: %s",
"RunnerUp walks for %s do not count yet: %s",
date_str,
activity_shortfall(day),
walk_shortfall(day),
)
return False
workout_data: dict[str, Any] = {
workout_data = {
"type": "runnerup_verified",
"activity": "walking",
"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(

View File

@ -1,215 +0,0 @@
"""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

@ -1,181 +0,0 @@
"""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

@ -1,62 +0,0 @@
"""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

@ -1,71 +0,0 @@
"""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

@ -1,210 +0,0 @@
"""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

@ -65,53 +65,48 @@ class RunnerUpTcxMixin:
shutil.rmtree(tmp_dir, ignore_errors=True)
def _parse_tcx(self, tcx_path: str) -> dict[str, Any] | None:
"""Parse a local TCX file (see :func:`parse_tcx_file`)."""
return parse_tcx_file(tcx_path)
"""Parse a local TCX file and return activity summary dict.
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
def parse_tcx_file(tcx_path: str) -> dict[str, Any] | None:
"""Parse a local TCX file and return activity summary dict.
root = tree.getroot()
activity = root.find(f".//{{{_TCX_NS}}}Activity")
if activity is None:
_logger.info("No Activity element in TCX file")
return None
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
sport_str = activity.get("Sport", "")
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
root = tree.getroot()
activity = root.find(f".//{{{_TCX_NS}}}Activity")
if activity is None:
_logger.info("No Activity element in TCX file")
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)
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),
}
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:

View File

@ -12,26 +12,31 @@ from typing import Any
from screen_locker._constants import (
MIN_RUN_DISTANCE_KM,
MIN_WORKOUT_DURATION_MINUTES,
RUNNERUP_DISTANCE_TOLERANCE,
RUNNERUP_EXPORT_DIRS,
RUNNERUP_WEBDAV_DIRS,
WORKOUT_DURATION_ACCEPT_MINUTES,
)
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,
)
from screen_locker._walk_tcx import walk_day, walk_shortfall
_logger = logging.getLogger(__name__)
# Kept for importers; the labels live next to the one crediting rule.
_SPORT_NAMES = SPORT_NAMES
_SPORT_NAMES: dict[int, str] = {
0: "Running",
1: "Biking",
2: "Other",
3: "Orienteering",
4: "Walking",
5: "Treadmill",
6: "Gym",
7: "Stationary Bike",
}
# TCX uses sport name strings; map back to integer codes for unified validation.
@ -116,46 +121,73 @@ class RunnerUpVerificationMixin(
)
return None
# 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)
# Try each file; return the best result (verified > validation error).
# Walks never qualify one file at a time: they are summed below.
best: tuple[str, str] | None = None
walks: list[dict[str, Any]] = []
for remote in exports:
data = self._pull_and_parse_tcx(remote)
if data is None:
continue
if data.get("walking"):
walks.append(data)
continue
status, msg = self._validate_runnerup_data(data)
if status == "verified":
return status, msg
if best is None:
best = (status, msg)
if walks:
day = walk_day(walks)
if day.qualifies:
return "verified", day.message()
best = best or ("too_short", walk_shortfall(day))
# All files found but none passed validation.
return best or (
"not_verified",
"RunnerUp TCX export found but could not be read",
)
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``. 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.
``PhoneVerificationMixin._verify_phone_workout``.
"""
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
):
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:
_logger.info(
"RunnerUp distance %.2f km is under the %.1f km minimum but "
"within %.0f%% GPS tolerance — accepted.",
day.distance_km,
distance_km,
MIN_RUN_DISTANCE_KM,
RUNNERUP_DISTANCE_TOLERANCE * 100,
)
return "verified", day.message()
sport_name = _SPORT_NAMES.get(sport, str(sport))
return (
"verified",
f"{sport_name}: {distance_km:.1f} km in {duration_min:.0f} min",
)
# ------------------------------------------------------------------
# Main entry point

View File

@ -123,7 +123,7 @@ class SessionStream:
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
except Exception:
_logger.exception("Firebase session stream crashed; %s", _FALLBACK)
return False
return True

View File

@ -9,13 +9,12 @@ 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._day import today_str
from screen_locker._earned import first_minutes, hhmm, span
from screen_locker._earned import ceiling, 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
@ -32,6 +31,9 @@ _logger = logging.getLogger(__name__)
_SHUTDOWN_CONFIG_NAMES = ("MON_WED", "THU_SUN", "MORNING_END")
_MINUTES_PER_HOUR = 60
# Extra bonuses may name midnight; the helper still clamps them to 23:00.
_MIDNIGHT = 24 * _MINUTES_PER_HOUR
def _parse_config(path: Path) -> dict[str, int]:
"""Every integer ``KEY=value`` line of *path*."""
@ -63,7 +65,8 @@ def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
else:
_logger.warning("Shutdown config missing required values")
return None
return values[0], values[1], values[2]
mon_wed, thu_sun, morning_end = values
return mon_wed, thu_sun, morning_end
class ShutdownMixin(SickFloorMixin, LateCreditMixin):
@ -104,17 +107,16 @@ class ShutdownMixin(SickFloorMixin, LateCreditMixin):
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.
"""Push shutdown later by *extra_minutes*, capped at 24:00 (midnight).
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.
Used for earner and extra-workout bonuses. A cap of midnight works
because ``day-specific-shutdown-check.sh`` fires at 00:00 and catches it
via the morning-window condition; the helper clamps it to 23:00 anyway.
``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):
if is_sick(date.fromisoformat(today_str())):
return self._raise_on_sick_day(extra_minutes, at)
late = self._late_write(extra_minutes, at)
if late is not None:
@ -127,8 +129,8 @@ class ShutdownMixin(SickFloorMixin, LateCreditMixin):
return False
mw, ts, morning = config_values
ok = self._write_shutdown_config(
min(ceiling(day), mw + moved),
min(ceiling(day), ts + moved),
min(_MIDNIGHT, mw + moved),
min(_MIDNIGHT, ts + moved),
morning,
restore=True,
)

View File

@ -48,13 +48,11 @@ class SickFloorMixin:
if TYPE_CHECKING:
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
raise NotImplementedError
def _read_shutdown_config(self) -> tuple[int, int, int] | None: ...
def _write_shutdown_config(
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ...
) -> bool:
raise NotImplementedError
) -> bool: ...
def _apply_sick_floor(self) -> bool:
"""Raise tonight's shutdown to the sick target if today is sick.

View File

@ -28,7 +28,6 @@ from screen_locker._decision_log import (
)
from screen_locker._degraded_sources import degraded_sources
from screen_locker._extra_benefits import process_week_transition
from screen_locker._hold import skip_during_hold
from screen_locker._lock_invariant import refuse_to_lock_over_logged_workout
from screen_locker._night_lockdown import skip_after_evening_lockdown
from screen_locker._shutdown_base import (
@ -126,8 +125,7 @@ class StartupChecksMixin(SyncMixin):
# before any early-exit check, so gaps are closed regardless of today's
# logged state (early_bird, sick_day, etc.).
self._auto_fill_week_runnerup_bonus()
# Test hold: no prompt/lock/heat dialog; bookkeeping above is untouched.
if self._check_today_state_exits() or skip_during_hold(self._record_skip):
if self._check_today_state_exits():
return
# Day-of-week routing: Tue/Wed/Thu relaxed (optional), Fri-Mon enforced.
if is_relaxed_day():

View File

@ -71,29 +71,55 @@ def _print_day_line(d: date, entries: list[dict], sick_days: frozenset[date]) ->
return any_counted
def _scan_runnerup(locker: ScreenLocker, log_file: Path, before_count: int) -> int:
"""Run the RunnerUp week scan, print what it filled, return the new count.
def run_status(locker: ScreenLocker) -> None:
"""Print weekly workout status, run RunnerUp scan, apply bonus, then exit."""
today = datetime.now(tz=UTC).astimezone().date()
monday = today - timedelta(days=today.weekday())
log_file: Path = locker.log_file
log_data = load_workout_log(log_file)
sick_days = workout_standdown_dates()
Each fill is credited by the shared ingestion callback (+2h first counted
workout of its day, +1h each further one), the same rule the timer's sync
pass and the daily base reset use.
"""
print("=== Weekly Workout Status ===")
# Per-day breakdown
for i in range(7):
d = monday + timedelta(days=i)
if d > today:
break
_print_day_line(d, log_data.get(d.isoformat(), []), sick_days)
print()
# The weekly total, per the anti-gaming rule (each verified workout counts
# individually, manual counts once/day) — NOT a per-day checkmark count,
# which would undercount a day holding two verified workouts.
before_count = count_weekly_workouts(log_file)
# RunnerUp scan. Each fill is credited by the shared ingestion callback
# (+2h first counted workout of its day, +1h each further one), the same
# rule the timer's sync pass and the daily base reset use.
n_filled = locker._scan_and_fill_week_runnerup(
log_file, on_ingested=locker._credit_ingested_workout
)
locker.workout_data = {}
if n_filled <= 0:
if n_filled > 0:
print(f" Auto-filled {n_filled} workout(s) from RunnerUp exports.")
after_count = count_weekly_workouts(log_file)
_, thu_sun, _ = locker._read_shutdown_config() or (None, None, None)
if thu_sun is not None:
print(f" Shutdown tonight: {hhmm(thu_sun)}.")
else:
print(" No new workouts found via RunnerUp scan.")
return before_count
print(f" Auto-filled {n_filled} workout(s) from RunnerUp exports.")
_, thu_sun, _ = locker._read_shutdown_config() or (None, None, None)
if thu_sun is not None:
print(f" Shutdown tonight: {hhmm(thu_sun)}.")
return count_weekly_workouts(log_file)
after_count = before_count
print()
def _heat_skips_this_month(log_data: dict[str, list[dict]]) -> str:
"""One-line summary of this month's heat skips: count and the latest."""
# Extra benefits summary
streak = current_streak(EXTRA_BENEFITS_FILE)
eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE)
eb_str = "Yes — until 09:00" if eb_ext else "No"
# Heat skips this month
this_month = datetime.now(tz=UTC).astimezone().date().strftime("%Y-%m")
heat_entries = [
(d, entry)
@ -103,17 +129,21 @@ def _heat_skips_this_month(log_data: dict[str, list[dict]]) -> str:
if isinstance(entry, dict)
and entry.get("workout_data", {}).get("type") == "heat_skip"
]
if not heat_entries:
return "0"
last_date, last_e = max(heat_entries, key=lambda de: de[0])
last_temp = last_e.get("workout_data", {}).get("temperature_celsius", "?")
return f"{len(heat_entries)} (last: {last_date}, {last_temp}°C)"
if heat_entries:
last_date, last_e = max(heat_entries, key=lambda de: de[0])
last_temp = last_e.get("workout_data", {}).get("temperature_celsius", "?")
heat_str = f"{len(heat_entries)} (last: {last_date}, {last_temp}°C)"
else:
heat_str = "0"
print(f" Streak (5+ wks) : {streak}")
print(f" Early-bird extended : {eb_str}")
print(f" Heat skips (month) : {heat_str}")
print()
def _print_minimum(after_count: int) -> None:
"""Print how far the weekly count is from (or past) the minimum."""
remaining = max(0, WEEKLY_WORKOUT_MINIMUM - after_count)
extra = max(0, after_count - WEEKLY_WORKOUT_MINIMUM)
if remaining > 0:
print(
f" Need {remaining} more to reach the minimum ({WEEKLY_WORKOUT_MINIMUM})."
@ -126,37 +156,13 @@ def _print_minimum(after_count: int) -> None:
f" ({WEEKLY_WORKOUT_MINIMUM}/{WEEKLY_WORKOUT_MINIMUM})."
)
def run_status(locker: ScreenLocker) -> None:
"""Print weekly workout status, run RunnerUp scan, apply bonus, then exit."""
today = datetime.now(tz=UTC).astimezone().date()
monday = today - timedelta(days=today.weekday())
log_file: Path = locker.log_file
log_data = load_workout_log(log_file)
sick_days = workout_standdown_dates()
print("=== Weekly Workout Status ===")
for i in range(7):
d = monday + timedelta(days=i)
if d > today:
break
_print_day_line(d, log_data.get(d.isoformat(), []), sick_days)
print()
after_count = _scan_runnerup(locker, log_file, count_weekly_workouts(log_file))
print()
eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE)
print(f" Streak (5+ wks) : {current_streak(EXTRA_BENEFITS_FILE)}")
print(f" Early-bird extended : {'Yes — until 09:00' if eb_ext else 'No'}")
print(f" Heat skips (month) : {_heat_skips_this_month(log_data)}")
print()
_print_minimum(after_count)
# Shutdown config
cfg = locker._read_shutdown_config()
if cfg:
print(f" Shutdown tonight : {hhmm(cfg[0])}")
_mw, _ts, _morning = cfg
print(f" Shutdown tonight : {hhmm(_mw)}")
# --status is a read-out, not an enforcement run; make that explicit so the
# journal never reads as though the locker evaluated and declined to lock.
record_no_decision("--status")
sys.exit(0)

View File

@ -48,7 +48,6 @@ from screen_locker._constants import (
from screen_locker._extra_benefits import (
current_streak,
has_extended_early_bird,
week_rewards,
)
from screen_locker._log_io import load_workout_log
from screen_locker._morning_session import has_workout_skip_today
@ -178,7 +177,6 @@ def gather_status(
week=week,
streak=current_streak(extra_benefits_file),
early_bird_extended=early_bird_extended,
week_rewards=week_rewards(extra_benefits_file, today=today_local),
shutdown=_shutdown_projection(
shutdown_config_file=shutdown_config_file,
today_local=today_local,

View File

@ -89,7 +89,7 @@ def _tonight_minutes(snapshot: StatusSnapshot) -> int | None:
def format_summary_line(snapshot: StatusSnapshot) -> str:
"""Cheap one-liner for the i3blocks summary (e.g. before/after a click)."""
week = snapshot.week
mark = "…" if week.remaining else "✓"
mark = "✓" if week.remaining == 0 else "…"
tonight = _tonight_minutes(snapshot)
tonight_str = hhmm(tonight) if tonight is not None else "?"
sick = " · sick day" if snapshot.today.is_sick_day else ""

View File

@ -6,19 +6,14 @@ mixins, so the class's public surface is unchanged.
Every method here is display-only: it reads the snapshot it is handed and
paints widgets. Nothing in this module writes to disk or reaches the network.
textgate: no section has a heading or a sentence any more. Each is one or
two lines of glyphs (``_glyphs``) and numbers, coloured by state. Weekdays
are positions in a Mon..Sun row, not names.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from screen_locker import _glyphs as g
from screen_locker._earned import hhmm
from screen_locker._sync_status import gather_sync_status
from screen_locker._sync_status import format_sync_line, gather_sync_status
if TYPE_CHECKING:
import tkinter as tk
@ -35,103 +30,86 @@ if TYPE_CHECKING:
class StatusSectionsMixin:
"""Renders each section of the status window from a snapshot."""
def _day_cell(self, day: DayStatus) -> str:
"""One day as a glyph: its entries, sick, or an empty circle."""
if day.entry_types:
return g.entry_glyphs(day.entry_types)
return g.WEEK_SICK if day.is_sick_day else g.WEEK_EMPTY
def _section_today(self, parent: tk.Widget, day: DayStatus) -> None:
"""Render today's outcome (was "✓ Today (Wed Nov 11): phone_verified")."""
"""Render today's outcome."""
del parent
mark = g.OK if day.counted else (g.SICK if day.is_sick_day else g.FAIL)
self._label(
f"{mark} {self._day_cell(day)}",
role="title",
mark = "✓" if day.counted else ("😷" if day.is_sick_day else "—")
entry_str = ", ".join(day.entry_types) or (
"sick day" if day.is_sick_day else "no entry yet"
)
self._text(
f"{mark} Today ({day.label}): {entry_str}",
role="body",
color=self._colors.palette.success
if day.counted
else self._colors.palette.warning,
)
if day.source:
# Secondary provenance note on an already-shown entry -- a
# deliberately caption-sized exception to the 16px floor.
self._text(
day.source, role="caption", color=self._colors.palette.muted, pad="xs"
)
def _section_week(self, parent: tk.Widget, week: WeeklySummary) -> None:
"""Render this ISO week: dots + count, then one cell per day.
Was "This Week: 2/4", a "Mon Nov 09: no entry" line per day and "Need
2 more this week." / "1 above the weekly minimum!".
"""
"""Render this ISO week's per-day breakdown and totals."""
del parent
enough = week.remaining <= 0
self._text(
g.dots(week.counted_count, week.minimum),
role="title",
color=self._colors.palette.success
if enough
else self._colors.palette.warning,
pad="sm",
self._label(
f"This Week: {week.counted_count}/{week.minimum}", role="body", pad="sm"
)
self._text(
" ".join(self._day_cell(day) for day in week.days),
role="body",
color=self._colors.palette.muted,
pad="xs",
)
def _section_streak(
self, parent: tk.Widget, streak: int, week_rewards: tuple[str, ...] = ()
) -> None:
"""Render the consecutive 5+-workout-week streak and this week's reward.
Display only, and exactly the stored values (``extra_benefits_state``):
a streak of 0 is shown as 0, never padded or rounded up. Nothing here
grants time or relaxes a lock. Was "🔥 N-week streak (5+ workouts a
week)" / "No streak yet: ..." and the reward sentences.
"""
del parent
self._text(
f"{g.STREAK * min(streak, 10)} {streak}" if streak else f"{g.STREAK} 0",
role="body",
color=self._colors.palette.success
if streak
else self._colors.palette.muted,
)
for message in week_rewards:
# A reset is news, not a celebration: it must not be painted green.
lost = message.startswith("Streak reset")
for day in week.days:
mark = "✓" if day.counted else ("😷" if day.is_sick_day else "·")
entry_str = ", ".join(day.entry_types) or (
"sick day" if day.is_sick_day else "no entry"
)
self._text(
f"{g.STREAK}{g.FAIL}" if lost else f"{g.STREAK}{g.GIFT}",
f"{mark} {day.label}: {entry_str}",
role="body",
color=self._colors.palette.warning
if lost
else self._colors.palette.success,
color=self._colors.palette.muted,
pad="xs",
)
if week.remaining > 0:
self._text(
f"Need {week.remaining} more this week.",
role="body",
color=self._colors.palette.warning,
)
elif week.extra > 0:
self._text(
f"{week.extra} above the weekly minimum!",
role="body",
color=self._colors.palette.success,
)
def _section_lock_explanation(
self, parent: tk.Widget, expl: LockExplanation
) -> None:
"""Render whether the lock fired today and the stage that decided it.
Was "Why the lock did/didn't fire" over the stage's sentence.
"""
"""Render why the lock did/didn't fire today, plus its evaluation trace."""
del parent
square = g.LOCK if expl.fired else g.UNLOCK
self._label("Why the lock did/didn't fire", role="body", pad="sm")
self._text(
f"{square} {g.STAGE_GLYPH.get(expl.stage, '')}".rstrip(),
role="title",
expl.reason,
role="body",
color=self._colors.palette.danger
if expl.fired
else self._colors.palette.success,
)
if expl.auto_upgrade.would_attempt:
self._text(g.RETRY, role="label", color=self._colors.palette.warning)
self._text(
f"Pending auto-upgrade: {expl.auto_upgrade.reason}",
role="label",
color=self._colors.palette.warning,
)
def _section_manual_workout_budget(
self, parent: tk.Widget, manual: ManualWorkoutBudgetStatus
) -> None:
"""Render rolling manual-workout budget usage (7-day, then 30-day)."""
"""Render rolling manual-workout budget usage."""
del parent
self._label("Manual Workout Budget", role="body", pad="sm")
self._text(
f"{g.FORM} {manual.used_7d}/{manual.budget_7d} · "
f"{manual.used_30d}/{manual.budget_30d}",
f"{manual.used_7d}/{manual.budget_7d} week · "
f"{manual.used_30d}/{manual.budget_30d} month",
role="body",
color=self._colors.palette.danger
if manual.exhausted
@ -139,7 +117,7 @@ class StatusSectionsMixin:
)
def _section_sync_backend(self, parent: tk.Widget) -> None:
"""Render whether this machine's sync is healthy, and its peer count.
"""Render which backend this machine syncs through.
Read from local state only (see :mod:`screen_locker._sync_status`), so
opening or refreshing the window never makes a network call. Coloured
@ -149,8 +127,9 @@ class StatusSectionsMixin:
"""
del parent
status = gather_sync_status()
self._label("Sync backend", role="body", pad="sm")
self._text(
f"{g.SYNC} {g.OK if status.healthy else g.FAIL} {status.peer_count}",
format_sync_line(status),
role="body",
color=self._colors.palette.muted
if status.healthy
@ -160,25 +139,41 @@ class StatusSectionsMixin:
def _section_shutdown(
self, parent: tk.Widget, shutdown: ShutdownProjection
) -> None:
"""Render tonight's live config, rest-of-week, and next-week preview.
Was "Live config — Mon-Wed 21:00, Thu-Sun 22:00", "Rest of week: Mon
21:00, ...", "Next week (speculative): ..." and an explanation. Now
the times only, in day order: live config bold, the rest muted.
"""
"""Render tonight's live config, rest-of-week, and next-week preview."""
del parent
self._label("Shutdown Time", role="body", pad="sm")
if shutdown.tonight is not None:
mon_wed, thu_sun, _morning = shutdown.tonight
self._text(f"{g.SHUTDOWN} {hhmm(mon_wed)} · {hhmm(thu_sun)}", role="title")
self._text(
f"Live config — Mon-Wed {hhmm(mon_wed)}, Thu-Sun {hhmm(thu_sun)}",
role="body",
)
else:
self._text(
f"{g.SHUTDOWN} {g.FAIL}",
role="title",
"Live shutdown config unavailable.",
role="body",
color=self._colors.palette.warning,
)
for days in (shutdown.rest_of_week, shutdown.next_week_preview):
self._text(
" ".join(hhmm(d.minutes) for d in days),
role="caption",
color=self._colors.palette.muted,
)
# Rest-of-week/next-week/explanation are speculative annotations, not
# the section's primary content -- a deliberate caption-sized
# exception to the 16px floor, all sharing one muted tone instead of
# three unrelated ad hoc grays.
rest_line = ", ".join(
f"{d.label} {hhmm(d.minutes)}" for d in shutdown.rest_of_week
)
self._text(
f"Rest of week: {rest_line}",
role="caption",
color=self._colors.palette.muted,
)
next_line = ", ".join(
f"{d.label} {hhmm(d.minutes)}" for d in shutdown.next_week_preview
)
self._text(
f"Next week (speculative): {next_line}",
role="caption",
color=self._colors.palette.muted,
)
self._text(
shutdown.explanation, role="caption", color=self._colors.palette.muted
)

View File

@ -86,4 +86,3 @@ class StatusSnapshot:
lock_explanation: LockExplanation
manual_workout_budget: ManualWorkoutBudgetStatus
generated_at: str
week_rewards: tuple[str, ...] = ()

View File

@ -8,20 +8,17 @@ screen's unlock does.
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
from dataclasses import dataclass
from datetime import date
import logging
from typing import TYPE_CHECKING
import earned_time
from screen_locker import _glyphs as g
from screen_locker._day import today_str
from screen_locker._earned import extra_minutes, span
from screen_locker._status_data import gather_status
from screen_locker._weekly_check import WEEKLY_WORKOUT_MINIMUM, count_weekly_workouts
from screen_locker.screen_lock import ScreenLocker
if TYPE_CHECKING:
from collections.abc import Callable
@ -29,9 +26,7 @@ if TYPE_CHECKING:
from pathlib import Path
from screen_locker._workout_credit import WorkoutCreditResult
_logger = logging.getLogger(__name__)
from screen_locker.screen_lock import ScreenLocker
@dataclass(frozen=True)
@ -58,6 +53,8 @@ def _make_bare_verifier(log_file: Path) -> ScreenLocker:
``--status`` — just enough state for the verification mixins' methods
to run, no Tk lock UI, no ``__init__`` side effects.
"""
from screen_locker.screen_lock import ScreenLocker
verifier = object.__new__(ScreenLocker)
verifier.log_file = log_file
verifier.workout_data = {}
@ -78,9 +75,7 @@ def _backfill_week_and_apply_bonus(verifier: ScreenLocker) -> str | None:
workout above the weekly minimum); it is a separate source with its own
dedup and is out of scope for the RunnerUp credit fix.
Returns a glyph summary if anything was filled, else ``None``:
``↻●━━● +N`` and, with a bonus, `` ⏻ +1h`` (textgate; was "Auto-filled
N workouts from earlier this week. +1h shutdown time.").
Returns a human-readable summary if anything was filled, else ``None``.
"""
prev_count = count_weekly_workouts(verifier.log_file)
filled = verifier._scan_and_fill_week_runnerup(
@ -100,9 +95,10 @@ def _backfill_week_and_apply_bonus(verifier: ScreenLocker) -> str | None:
filled += sl_filled
if not filled:
return None
message = f"{g.RETRY}{g.LIFT} +{filled}"
plural = "workout" if filled == 1 else "workouts"
message = f"Auto-filled {filled} {plural} from earlier this week."
if bonus > 0:
message += f" {g.SHUTDOWN} +{span(bonus)}"
message += f" +{span(bonus)} shutdown time."
return message
@ -190,8 +186,10 @@ class PhoneCheckMixin:
if outcome.credited_type == "runnerup_verified"
else outcome.phone_message
)
source_glyph = (
g.RUN if outcome.credited_type == "runnerup_verified" else g.LIFT
source_label = (
"RunnerUp"
if outcome.credited_type == "runnerup_verified"
else "StrongLifts"
)
verifier = self.verifier_factory(self.log_file)
verifier.workout_data = {
@ -200,27 +198,24 @@ class PhoneCheckMixin:
}
credit = verifier._apply_workout_credit()
self._credit_message = "\n".join(
self._credit_result_lines(f"{source_glyph} {g.OK}", credit)
self._credit_result_lines(f"{source_label} verified: {message}", credit)
)
elif outcome.week_fill_message is not None:
self._credit_message = outcome.week_fill_message
else:
# The reasons are logged; the window shows the failure glyphs.
_logger.warning(
"Check Phone found nothing: StrongLifts: %s · RunnerUp: %s",
outcome.phone_message,
outcome.runnerup_message,
)
self._phone_check_result = (
outcome.status,
f"{g.LIFT}{g.FAIL} {g.RUN}{g.FAIL}",
(
f"StrongLifts: {outcome.phone_message} · "
f"RunnerUp: {outcome.runnerup_message}"
),
)
self.render(gather_status())
def _credit_result_lines(
self, header: str, credit: WorkoutCreditResult
) -> list[str]:
"""Glyph outcome lines for an applied workout credit (no words).
"""Human-readable outcome lines for an applied workout credit.
Shared by the "Check Phone" verify path and the "Log Manual Workout"
path so both report the reward identically: the base +2h shutdown push
@ -230,11 +225,12 @@ class PhoneCheckMixin:
"""
lines = [header]
if credit.already_counted_today:
# Was "(Already recorded today — no additional credit.)".
lines.append(f"{g.OK}{g.OK} {g.GIFT}0")
lines.append("(Already recorded today — no additional credit.)")
return lines
if credit.shutdown_adjusted:
lines.append(f"{g.SHUTDOWN} {g.GIFT}{span(120)}")
lines.append("Shutdown time +2h later!")
if credit.extra_bonus_delta > 0:
lines.append(f"{g.LIFT} {g.GIFT}{span(credit.extra_bonus_delta)}")
lines.append(
f"Extra workout today! +{span(credit.extra_bonus_delta)} tonight"
)
return lines

View File

@ -8,8 +8,7 @@ tombstones ``_manual_push`` re-publishes every tick, and dropping it would
let a retracted session be credited again -- but an event that touched only
this PC's log never triggers a pass.
Crediting is :func:`screen_locker._poke_credit.credit_sessions` (and
``credit_manual_workouts`` for phone-logged manual workouts) under the
Crediting is :func:`screen_locker._poke_credit.credit_sessions` under the
same in-process lock as the LAN poke handler. ``write_signed_entry`` already
dedups by workout id; the ``seen`` set here only spares the file I/O for
records this daemon has already handed to the ingest.
@ -24,19 +23,11 @@ from typing import TYPE_CHECKING, Final
from screen_locker import _poke_credit
from screen_locker._rtdb_stream import LOG_LEAF, device_updates
from screen_locker._sync_records import (
_manual_records,
_session_records,
merge_device_texts,
)
from screen_locker._sync_records import _session_records, merge_device_texts
if TYPE_CHECKING:
from collections.abc import Callable
import threading
from crdt_sync import Hlc
from screen_locker._poke_credit import BatchOutcome
from screen_locker._rtdb_stream import SseEvent
__all__ = ["DeviceLogCache", "EventResult"]
@ -87,17 +78,15 @@ class DeviceLogCache:
self._texts[dev] = text
return changed
def _pending(
self, extract: Callable[[str], dict[str, tuple[dict, Hlc]]], what: str
) -> list[tuple[str, dict[str, object], str]]:
"""Union ``extract`` records this daemon has not yet handed to the ingest."""
def _pending(self) -> list[tuple[str, dict[str, object], str]]:
"""Union records this daemon has not yet handed to the ingest."""
union = merge_device_texts(
(
(f"{self._prefix}/{dev}/{LOG_LEAF}", text)
for dev, text in sorted(self._texts.items())
),
extract,
what,
_session_records,
"sessions",
)
return [
(rid, payload, str(hlc))
@ -126,62 +115,39 @@ class DeviceLogCache:
snapshot=False,
summary=f"only this PC's own log changed ({devices}); no credit",
)
sessions = self._pending(_session_records, "sessions")
manuals = self._pending(_manual_records, "manual workouts")
if not sessions and not manuals:
pending = self._pending()
if not pending:
return EventResult(
snapshot=full,
summary=f"{len(self._texts)} device log(s) cached, changed: {devices}; "
f"every session and manual workout already handed to the ingest "
f"-- no-op, no file I/O ({_ms(started)} ms)",
f"every session already handed to the ingest -- no-op, no file I/O "
f"({_ms(started)} ms)",
)
try:
with self._gate:
done = [
_credit(pending, credit, what)
for pending, credit, what in (
(sessions, _poke_credit.credit_sessions, "session"),
(
manuals,
_poke_credit.credit_manual_workouts,
"manual workout",
),
)
if pending
]
except Exception: # pylint: disable=broad-exception-caught # one bad event must not drop the stream
outcome = _poke_credit.credit_sessions(
[(rid, payload) for rid, payload, _hlc in pending]
)
except Exception:
# Not a stream problem: keep the connection (a reconnect would
# re-download the snapshot and fail the same way). Nothing is
# marked seen, so the next event retries.
_logger.exception(
"Crediting %d streamed session(s) and %d manual workout(s) failed; "
"the next stream event retries, and the 15-min workout-sync still "
"covers them",
len(sessions),
len(manuals),
"Crediting %d streamed session(s) failed; the next stream event "
"retries, and the 15-min workout-sync still covers them",
len(pending),
)
return EventResult(snapshot=full, summary="crediting failed (see above)")
self._seen.update((rid, hlc) for rid, _payload, hlc in [*sessions, *manuals])
self._seen.update((rid, hlc) for rid, _payload, hlc in pending)
return EventResult(
snapshot=full,
summary=f"{len(self._texts)} device log(s) cached, changed: {devices}; "
f"{'; '.join(done)} ({_ms(started)} ms)",
f"{len(pending)} session(s) checked, {len(outcome.ingested)} newly "
f"logged {list(outcome.ingested)}, {outcome.credited} credited "
f"({_ms(started)} ms)",
)
def _credit(
pending: list[tuple[str, dict[str, object], str]],
credit: Callable[[list[tuple[str, dict[str, object]]]], BatchOutcome],
what: str,
) -> str:
"""Run one batch credit step; describe what it did."""
outcome = credit([(rid, payload) for rid, payload, _hlc in pending])
return (
f"{len(pending)} {what}(s) checked, {len(outcome.ingested)} newly "
f"logged {list(outcome.ingested)}, {outcome.credited} credited"
)
def _ms(started: float) -> int:
"""Milliseconds since ``started`` (a ``perf_counter`` reading)."""
return round((time.perf_counter() - started) * 1000)

View File

@ -15,10 +15,8 @@ writes to ``log.json``. Host class must provide:
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from screen_locker import _glyphs as g
from screen_locker._constants import HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD
from screen_locker._temperature import HARD_TIMEOUT_SECONDS, submit_background
@ -27,45 +25,54 @@ if TYPE_CHECKING:
from screen_locker._temperature import TemperatureCheck
_logger = logging.getLogger(__name__)
class TemperatureStatusMixin:
"""Provides the background Warsaw-temperature fetch/poll/render."""
def _section_temperature(self, parent: tk.Widget) -> None:
"""Render the live temperature against the heat-skip threshold.
"""Render the live Warsaw temperature.
The same reading the real locker's heat-skip check would see,
fetched in the background. textgate: was "Warsaw Temperature" and a
sentence per state; now ``▲`` with the reading (or wait / clock-fail /
fail glyphs), danger-coloured with ``↷`` when it would heat-skip.
fetched in the background.
"""
del parent
self._label("Warsaw Temperature", role="body", pad="sm")
result = self._temp_result
palette = self._colors.palette
if result is None:
self._text(f"{g.HEAT} {g.WAIT}", role="body", color=palette.muted)
self._text(
"Checking Warsaw temperature…",
role="caption",
color=self._colors.palette.muted,
)
return
if result.timed_out:
_logger.warning(
"Temperature check timed out after ~%ds", int(HARD_TIMEOUT_SECONDS)
)
self._text(
f"{g.HEAT} {g.CLOCK}{g.FAIL}", role="body", color=palette.warning
f"Warsaw temperature check timed out after ~"
f"{int(HARD_TIMEOUT_SECONDS)}s — network may be down.",
role="caption",
color=self._colors.palette.warning,
)
return
if result.temp_celsius is None:
self._text(f"{g.HEAT} {g.FAIL}", role="body", color=palette.warning)
self._text(
"Warsaw temperature check failed (network/API error).",
role="caption",
color=self._colors.palette.warning,
)
return
hot = result.temp_celsius >= HEAT_SKIP_TEMP_THRESHOLD
relation = "≥" if hot else "<"
self._text(
f"{g.HEAT} {result.temp_celsius:.0f}°C {relation} "
f"{HEAT_SKIP_TEMP_THRESHOLD}°C" + (f" {g.SKIP}" if hot else ""),
role="body",
color=palette.danger if hot else palette.muted,
f"Warsaw: {result.temp_celsius:.0f}°C (heat-skip threshold "
f"{HEAT_SKIP_TEMP_THRESHOLD}°C)",
role="caption",
color=self._colors.palette.warning if hot else self._colors.palette.muted,
)
if hot:
self._text(
"Would trigger heat-skip today.",
role="caption",
color=self._colors.palette.danger,
)
def _start_temperature_check(self) -> None:
"""Submit a background Warsaw-temperature fetch and start polling it.

View File

@ -1,18 +1,10 @@
"""UI flow methods mixin for the screen locker.
textgate: every screen here is glyphs (``_glyphs``) plus numbers, with the
palette colour as the second channel. The sentences these screens used to
paint ("No Workout Found", the verifier's reason, the sync diagnosis) go to
the log at warning instead, so the reason is never lost, only unpainted.
"""
"""UI flow methods mixin for the screen locker."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
import logging
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
from typing import TYPE_CHECKING
from screen_locker import _glyphs as g
from screen_locker import _manual_workout
from screen_locker._source_health import collect_source_findings, explain_findings
from screen_locker._ui_flows_lockout import LockoutFlowMixin
@ -22,8 +14,6 @@ from screen_locker._ui_flows_sick_panel import SickPanelFlowMixin
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
"""Mixin providing UI flow logic for the screen locker.
@ -38,16 +28,15 @@ class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
Split from :meth:`_start_phone_check` so the fit check
(``scripts/verify_screen_fits.py``) can measure this screen without
starting a background thread that talks to a phone over adb.
Was "Checking phone..." / "Looking for today's workout in StrongLifts".
"""
self.clear_container()
self._label(
f"{g.PHONE} {g.LIFT}",
"Checking phone...",
role="display",
color=self._colors.palette.warning,
pad="lg",
)
self._text(g.WAIT, role="display", color=self._colors.palette.muted)
self._text("Looking for today's workout in StrongLifts...", role="body")
def _start_phone_check(self) -> None:
"""Check phone for today's workout immediately at startup."""
@ -65,39 +54,23 @@ class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
else:
self.root.after(500, self._poll_phone_check)
def _show_retry_and_sick(self, message: str, *, status: str = "") -> None:
"""Show retry, I'm sick and (budget allowing) the manual log.
Was "No Workout Found" over *message*. The screen now shows the
failure glyph for *status*; *message* goes to the log.
"""
_logger.warning("No workout accepted (%s): %s", status or "?", message)
def _show_retry_and_sick(self, message: str) -> None:
"""Show TRY AGAIN, I'm sick and (budget allowing) the manual log."""
self.clear_container()
self._label(
f"{g.LIFT} {g.FAIL}",
"No Workout Found",
role="display",
color=self._colors.palette.danger,
pad="md",
)
if status:
self._text(
g.status_glyph(status),
role="display",
color=self._colors.palette.warning,
)
self._text(message, color=self._colors.palette.warning)
# Never accuse the user of skipping a workout the machine simply could
# not see. On 2026-08-24 this screen said "No Workout Found" after a
# 1h57m session, because Firebase was unreadable and the GitHub mirror
# had been stale for nine days -- neither of which was shown here.
# Now a sync-fault glyph, one per finding; the prose is logged.
findings = collect_source_findings()
if findings:
_logger.warning("%s", explain_findings(findings))
self._text(
f"{g.SYNC}{g.FAIL} " * len(findings),
role="title",
color=self._colors.palette.warning,
)
diagnosis = explain_findings(collect_source_findings())
if diagnosis:
self._text(diagnosis, color=self._colors.palette.muted)
self._text(
_manual_workout.budget_summary(self.log_file),
color=self._colors.palette.muted,
@ -105,47 +78,44 @@ class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
frame = self._button_row()
self._button(
frame,
g.RETRY,
"TRY AGAIN",
bg=self._colors.palette.accent,
command=self._start_phone_check,
width=4,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
self._sick_button(frame)
if self._mw_budget_closed():
# Was "Manual-workout budget exhausted. No manual-log option".
self._text(f"{g.FORM} {g.CLOSE}", color=self._colors.palette.danger)
self._text(
"Manual-workout budget exhausted. No manual-log option available.",
color=self._colors.palette.danger,
)
else:
self._button(
frame,
g.FORM,
"Log Manual Workout",
bg=self._colors.palette.accent,
command=self._show_manual_workout_form,
width=4,
width=16,
).pack(side="left", padx=self._colors.space("sm"))
def _paint_accepted(self, glyph: str) -> None:
"""Paint "accepted, unlocking" and schedule the unlock.
Was "✓ Workout Verified!" / "✓ Run Verified!" / "✓ Manual Workout
Logged!" over the source line and "Unlocking...".
"""
def _on_manual_workout_saved(self, entry: dict) -> None:
"""Show confirmation and unlock after a manual-workout entry is built."""
self.workout_data = entry
self.clear_container()
self._label(
f"{glyph} {g.OK}",
"✓ Manual Workout Logged!",
role="display",
scale=1.3,
color=self._colors.palette.success,
pad="lg",
)
self._text(g.WAIT, role="display", color=self._colors.palette.muted)
self._text(
entry.get("source", ""), role="subtitle", color=self._colors.palette.success
)
self._text("Unlocking...", role="body", color=self._colors.palette.muted)
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
def _on_manual_workout_saved(self, entry: dict) -> None:
"""Show confirmation and unlock after a manual-workout entry is built."""
self.workout_data = entry
self._paint_accepted(g.FORM)
def _on_manual_workout_cancelled(self) -> None:
"""Return to the retry screen when the manual-workout form is cancelled."""
self._start_phone_check()
@ -155,13 +125,39 @@ class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
if status == "verified":
self.workout_data["type"] = "phone_verified"
self.workout_data["source"] = message
self._paint_accepted(g.LIFT)
elif status in ("too_short", "clock_tampered"):
self._show_retry_and_sick(message, status=status)
self.clear_container()
self._label(
"✓ Workout Verified!",
role="display",
scale=1.3,
color=self._colors.palette.success,
pad="lg",
)
self._text(message, role="subtitle", color=self._colors.palette.success)
self._text("Unlocking...", role="body", color=self._colors.palette.muted)
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
elif status == "too_short":
self._show_retry_and_sick(
f"❌ {message}\n\n"
"Your workout was too short!\n"
"Actually do the full workout, don't just\n"
"spam through the exercises.",
)
elif status == "clock_tampered":
self._show_retry_and_sick(
f"❌ {message}\n\n"
"System clock appears to be manipulated.\n"
"Fix your system time and try again.",
)
elif status in ("stale", "no_exercises", "not_verified"):
# Try RunnerUp before showing failure — user may have run instead of lifted.
self._start_runnerup_fallback(
lambda: self._show_retry_and_sick(message, status=status)
lambda: self._show_retry_and_sick(
f"❌ {message}\n\n"
"Neither StrongLifts nor RunnerUp found a workout today.\n"
"Go do your workout first!",
)
)
else:
# no_phone or error — try RunnerUp first, then penalty timer.
@ -172,16 +168,15 @@ class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
Shows a waiting screen, runs the check in a background thread, then
either unlocks (run verified) or calls ``on_failure``.
Was "Checking RunnerUp..." / "Looking for today's run in RunnerUp".
"""
self.clear_container()
self._label(
f"{g.PHONE} {g.RUN}",
"Checking RunnerUp...",
role="display",
color=self._colors.palette.warning,
pad="lg",
)
self._text(g.WAIT, role="display", color=self._colors.palette.muted)
self._text("Looking for today's run in RunnerUp...", role="body")
executor = ThreadPoolExecutor(max_workers=1)
self._runnerup_future = executor.submit(self._verify_runnerup_workout)
executor.shutdown(wait=False)
@ -195,7 +190,20 @@ class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
if status == "verified":
self.workout_data["type"] = "runnerup_verified"
self.workout_data["source"] = message
self._paint_accepted(g.RUN)
self.clear_container()
self._label(
"✓ Run Verified!",
role="display",
scale=1.3,
color=self._colors.palette.success,
pad="lg",
)
self._text(message, role="subtitle", color=self._colors.palette.success)
self._text(
"Unlocking...", role="body", color=self._colors.palette.muted
)
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
else:
self._runnerup_on_failure()
else:

View File

@ -7,10 +7,8 @@ Split out of :mod:`screen_locker._ui_flows` to keep every file under the
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from screen_locker import _glyphs as g
from screen_locker._constants import (
NO_PHONE_EXTRA_LOCKOUT_SECONDS,
PHONE_PENALTY_DELAY_DEMO,
@ -20,8 +18,6 @@ from screen_locker._constants import (
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
class LockoutFlowMixin:
"""Lockout countdown and the no-phone penalty timer."""
@ -31,14 +27,10 @@ class LockoutFlowMixin:
# ------------------------------------------------------------------
def lockout(self) -> None:
"""Display lockout screen with countdown timer.
Was "Go work out! Locked for N seconds": the barbell in the danger
colour over the seconds counting down.
"""
"""Display lockout screen with countdown timer."""
self.clear_container()
self.lockout_label = self._label(
g.LIFT,
f"Go work out!\nLocked for {self.lockout_time} seconds",
role="display",
scale=1.5,
color=self._colors.palette.danger,
@ -69,13 +61,7 @@ class LockoutFlowMixin:
def _show_phone_penalty(
self, message: str, *, on_done: Callable[[], None] | None = None
) -> None:
"""Show penalty countdown when phone verification is unavailable.
Was "Cannot Verify Workout", the reason, and "Connect phone via ADB
to skip this wait, or wait for the penalty timer". Now the phone glyph
crossed out over the seconds left; the reason goes to the log.
"""
_logger.warning("Phone check unavailable, penalty wait: %s", message)
"""Show penalty countdown when phone verification is unavailable."""
self.clear_container()
self._phone_penalty_done_fn: Callable[[], None] = (
on_done
@ -94,11 +80,18 @@ class LockoutFlowMixin:
else base_delay + NO_PHONE_EXTRA_LOCKOUT_SECONDS
)
self._label(
f"{g.PHONE}{g.FAIL}",
"Cannot Verify Workout",
role="display",
color=self._colors.palette.warning,
pad="md",
)
self._text(message, color=self._colors.palette.warning)
self._text(
"Connect phone via ADB to skip this wait,\n"
"or wait for the penalty timer.\n\n"
"Note: Phone must be rooted and StrongLifts installed.",
role="body",
)
self.phone_penalty_remaining = delay
self.phone_penalty_label = self._label(
str(delay),

View File

@ -1,23 +1,15 @@
"""Relaxed-day (Tue/Wed/Thu) UI flow methods mixin.
textgate: glyphs and numbers only (``_glyphs``); the verifier's reason goes
to the log, not the screen.
"""
"""Relaxed-day (Tue/Wed/Thu) UI flow methods mixin."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
import logging
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
from screen_locker import _glyphs as g
from screen_locker._weekly_check import (
RELAXED_DAY_SKIP_TYPE,
WEEKLY_WORKOUT_MINIMUM,
count_weekly_workouts,
)
_logger = logging.getLogger(__name__)
class UIFlowsRelaxedMixin:
"""Mixin providing the relaxed-day UI flow logic."""
@ -31,39 +23,36 @@ class UIFlowsRelaxedMixin:
The screen is not locked — the user can skip freely or voluntarily
import a Stronglift workout that counts toward the weekly minimum.
Was "Optional Day (Tue / Wed / Thu)" / "Weekly workouts: N / M. No
penalty for skipping today." / "Skip — No Penalty" / "Log Stronglift
Workout": now the optional-day diamond, the week as dots + numbers,
skip (green) and the barbell (go).
"""
count = count_weekly_workouts(self.log_file)
self.clear_container()
self._label(
g.OPTIONAL,
role="display",
"Optional Day (Tue / Wed / Thu)",
role="title",
color=self._colors.palette.warning,
pad="md",
)
self._text(
g.dots(count, WEEKLY_WORKOUT_MINIMUM),
role="title",
f"Weekly workouts: {count} / {WEEKLY_WORKOUT_MINIMUM}\n"
"No penalty for skipping today.",
role="subtitle",
color=self._colors.palette.muted,
pad="sm",
)
frame = self._button_row()
self._button(
frame,
g.SKIP,
"Skip — No Penalty",
bg=self._colors.palette.success,
command=self._skip_relaxed_day,
width=4,
width=18,
).pack(side="left", padx=self._colors.space("sm"))
self._button(
frame,
f"{g.GO} {g.LIFT}",
"Log Stronglift Workout",
bg=self._colors.palette.accent,
command=self._start_relaxed_phone_check,
width=8,
width=20,
).pack(side="left", padx=self._colors.space("sm"))
# Skipping is free here, but a sick day is more than a skip: it
# stands every gate down and lifts tonight's shutdown to 20:00.
@ -85,12 +74,12 @@ class UIFlowsRelaxedMixin:
"""Run Stronglift check in relaxed mode (no screen grab)."""
self.clear_container()
self._label(
f"{g.PHONE} {g.LIFT}",
"Checking phone...",
role="display",
color=self._colors.palette.warning,
pad="lg",
)
self._text(g.WAIT, role="display", color=self._colors.palette.muted)
self._text("Looking for today's workout in StrongLifts...", role="body")
executor = ThreadPoolExecutor(max_workers=1)
self._phone_future = executor.submit(self._verify_phone_workout)
executor.shutdown(wait=False)
@ -119,34 +108,30 @@ class UIFlowsRelaxedMixin:
self._show_relaxed_retry(message, status)
def _show_relaxed_retry(self, message: str, status: str) -> None:
"""Show retry and skip-close when workout not found in relaxed mode.
Was "No Workout Found" / the reason / "TRY AGAIN" / "Close (Skip)".
"""
_logger.warning("Relaxed-day check found no workout (%s): %s", status, message)
"""Show retry and skip-close when workout not found in relaxed mode."""
self.clear_container()
self._label(
f"{g.LIFT} {g.FAIL}",
"No Workout Found",
role="display",
color=self._colors.palette.danger,
pad="md",
)
self._text(
g.status_glyph(status), role="display", color=self._colors.palette.warning
f"❌ {message}\n\nReason: {status}", color=self._colors.palette.warning
)
frame = self._button_row()
self._button(
frame,
g.RETRY,
"TRY AGAIN",
bg=self._colors.palette.accent,
command=self._start_relaxed_phone_check,
width=4,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
self._sick_button(frame)
self._button(
frame,
g.SKIP,
"Close (Skip)",
bg=self._colors.palette.success,
command=self.close,
width=4,
width=14,
).pack(side="left", padx=self._colors.space("sm"))

View File

@ -23,10 +23,8 @@ Split out of :mod:`screen_locker._ui_flows` to keep every file under the
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from screen_locker import _glyphs as g
from screen_locker._constants import SHUTDOWN_BASE_FILE
from screen_locker._credit_slot import locked_shutdown_state
from screen_locker._sick import mark_today_sick, push_in_background
@ -34,20 +32,18 @@ from screen_locker._sick import mark_today_sick, push_in_background
if TYPE_CHECKING:
import tkinter as tk
_logger = logging.getLogger(__name__)
class SickDayFlowMixin:
"""The one-tap sick-day button and what follows it."""
def _sick_button(self, frame: tk.Widget) -> None:
"""Pack the sick-day button (was "I'm sick") into ``frame``."""
"""Pack the "I'm sick" button into ``frame``."""
self._button(
frame,
g.SICK,
"I'm sick",
bg=self._colors.palette.warning,
command=self.mark_sick_and_unlock,
width=4,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
# The keep-aware alternative (``_ui_flows_sick_panel``), next to it.
self._choose_lockers_link(frame)
@ -82,19 +78,15 @@ class SickDayFlowMixin:
self.close()
def _show_sick_refused(self, message: str) -> None:
"""Show that today could not be marked sick; the lock stays up.
Was "Could not mark today as sick" over sickdays' reason; the reason
(a free day, an unreadable pool) now goes to the log.
"""
_logger.warning("Could not mark today sick: %s", message)
"""Say why today could not be marked sick; the lock stays up."""
self.clear_container()
self._label(
f"{g.SICK} {g.FAIL}",
"Could not mark today as sick",
role="display",
color=self._colors.palette.danger,
pad="md",
)
self._text(message, color=self._colors.palette.warning)
frame = self._button_row()
back = (
self._start_relaxed_day_flow
@ -103,8 +95,8 @@ class SickDayFlowMixin:
)
self._button(
frame,
g.BACK,
"Back",
bg=self._colors.palette.accent,
command=back,
width=4,
width=12,
).pack(side="left", padx=self._colors.space("sm"))

View File

@ -29,7 +29,6 @@ from gatelock import bind_activate
import sickdays
from sickdays.gui import KeepPanel
from screen_locker import _glyphs as g
from screen_locker._sick import stands_down_workout
from screen_locker._surface_group import FrameGroup
@ -40,10 +39,8 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__name__)
# textgate: was "choose lockers…" (sick, then pick) and "Workout lock kept —
# finish the workout to unlock." (sick, but the barbell lock stays).
LINK_TEXT = f"{g.SICK}▾"
KEPT_NOTE = f"{g.SICK} {g.LIFT} {g.LOCK}"
LINK_TEXT = "choose lockers…"
KEPT_NOTE = "Workout lock kept — finish the workout to unlock."
def build_sick_panel(
@ -165,10 +162,10 @@ class SickPanelFlowMixin:
).pack(pady=self._colors.space("md"))
back = self._button(
box,
g.BACK,
"Back",
bg=self._colors.palette.accent,
command=self._close_sick_panel,
width=4,
width=12,
)
back.pack(pady=self._colors.space("sm"))
back.first.focus_set()

View File

@ -3,16 +3,10 @@
Split out of ``screen_lock`` so that module stays inside the repo's 400-line
cap; this is pure view code (paint the credit, then close), which is exactly
the kind of thing the other ``_ui_*`` mixins already own.
textgate: was "Great job! 💪" / "Shutdown time +2h later! 🎁" / "Extra
workout today! +1h tonight" / "🔥 N-week streak (5+ workouts each)" /
"Screen Unlocked!". Now: the open lock in green, then each reward as its
glyph and amount, then the streak as week bars and their count.
"""
from __future__ import annotations
from screen_locker import _glyphs as g
from screen_locker._constants import EXTRA_BENEFITS_FILE
from screen_locker._earned import span
from screen_locker._extra_benefits import current_streak
@ -24,12 +18,12 @@ class UnlockViewMixin:
"""Paints the unlock screen, then closes it."""
def unlock_screen(self) -> None:
"""Apply workout credit and display the success glyphs."""
"""Apply workout credit and display the success message."""
credit = self._apply_workout_credit()
self.clear_container()
self._label(
f"{g.UNLOCK} {g.OK}",
"Great job! 💪",
role="display",
scale=1.5,
color=self._colors.palette.success,
@ -37,21 +31,22 @@ class UnlockViewMixin:
)
if credit.shutdown_adjusted:
self._text(
f"{g.SHUTDOWN} {g.GIFT}{span(120)}",
"Shutdown time +2h later! 🎁",
role="title",
color=self._colors.palette.warning,
)
if credit.extra_bonus_delta > 0:
self._text(
f"{g.LIFT} {g.GIFT}{span(credit.extra_bonus_delta)}",
f"Extra workout today! +{span(credit.extra_bonus_delta)} tonight",
role="subtitle",
color=self._colors.palette.warning,
)
streak = current_streak(EXTRA_BENEFITS_FILE)
if streak >= 1:
self._text(
f"{g.STREAK * min(streak, 10)} {streak}",
f"🔥 {streak}-week streak (5+ workouts each)",
role="label",
color=self._colors.palette.muted,
)
self._text("Screen Unlocked!", role="display", pad="md")
self.root.after(_UNLOCK_DELAY_MS, self.close)

View File

@ -1,64 +1,41 @@
"""One crediting rule for every GPS activity, from its RunnerUp-style TCX export.
"""Walking in RunnerUp TCX exports: which files are walks, and their moving time.
A day's activities -- runs and walks, RunnerUp's files and the workout app's
alike -- are summed, and the day counts as the workout once EITHER
A day's walking counts as the workout once its walks -- usually separate
commute legs, one file each -- add up to ``WALK_MIN_MOVING_MINUTES`` of
*moving* time.
* their distance reaches ``MIN_RUN_DISTANCE_KM`` (within the GPS tolerance), OR
* their *moving* time reaches the shared duration bar
(``WORKOUT_DURATION_ACCEPT_MINUTES``).
The TCX sport and the ``_Walking`` file-name suffix never change the outcome;
they only decide the label a message and the log's ``activity`` field carry
(the phone's history shows it).
**Which files are walks (label only).** RunnerUp's WebDAV and File
synchronisers both export with ``ExportOptions.getDefault()``, whose TCX writer
knows only Running/Biking and writes ``Sport="Other"`` for a walk. The sport
survives only in the file name (``Sport.TapiriikType()``): ``..._Walking.tcx``.
The Strava export variant writes ``Sport="Walking"``, so that counts too.
**Which files are walks.** RunnerUp's WebDAV and File synchronisers both
export with ``ExportOptions.getDefault()``, whose TCX writer knows only
Running/Biking and writes ``Sport="Other"`` for a walk. The sport survives
only in the file name (``Sport.TapiriikType()``): ``..._Walking.tcx``. The
Strava export variant writes ``Sport="Walking"``, so that counts too.
``Other`` alone is not a walk: it is every sport RunnerUp cannot name.
**Moving time, not elapsed.** A lap's ``TotalTimeSeconds`` is wall-clock: one
real lap reads 101 min for 1 km of standing about. The track is cut into
windows of at least ``TRACK_WINDOW_SECONDS`` (a fix every second jitters, so
one raw pair can read 15 m/s inside a 3 m/s run) and each window is judged by
its average speed. Faster than ``MAX_MOVING_SPEED_MPS`` (25 km/h) is a vehicle
or a GPS jump: neither time nor distance counts, so a bus ride earns nothing.
Otherwise its metres count, and its seconds count as moving time at
``MIN_MOVING_SPEED_MPS`` or faster; standing still is too slow. A window longer
than ``WALK_MAX_GAP_SECONDS`` is a signal dropout (real exports have 2 minute
ones): the runner may have kept running unseen, so it counts as moving only at
``MIN_DROPOUT_SPEED_MPS`` or faster, while a pause (no distance across the
gap) earns nothing -- a pause cannot buy a run its duration.
The day's distance is the sum of the windows (``track_distance_m``), never the
lap's ``DistanceMeters`` total, and an export with no trackpoints cannot be
speed-checked, so it credits nothing. Only the root-DB fallback, which has no
trackpoints at all, is judged by its recorded distance and duration.
real lap reads 101 min for 1 km of standing about. Moving time is summed over
consecutive trackpoint pairs whose speed is walking speed
(``WALK_SPEED_MPS``): standing still is too slow, a tram or a bus too fast.
A pair further apart than ``WALK_MAX_GAP_SECONDS`` is a GPS dropout whose
motion is unknown and earns nothing -- the walk is never credited for time
nobody saw.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import itertools
import logging
from typing import TYPE_CHECKING, Any
from screen_locker._activity_day import (
SPORT_NAMES,
ActivityDay,
activity_day,
activity_shortfall,
distance_qualifies,
moving_qualifies,
)
from screen_locker._constants import (
MAX_MOVING_SPEED_MPS,
MIN_DROPOUT_SPEED_MPS,
MIN_MOVING_SPEED_MPS,
TRACK_WINDOW_SECONDS,
WALK_MAX_GAP_SECONDS,
WALK_MIN_MOVING_MINUTES,
WALK_SPEED_MPS,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Iterable
import xml.etree.ElementTree as ET
_logger = logging.getLogger(__name__)
@ -95,78 +72,73 @@ def _trackpoints(activity: ET.Element) -> list[tuple[float, float]]:
return points
def _segments(
points: list[tuple[float, float]],
) -> Iterator[tuple[float, float]]:
"""``(seconds, metres)`` of the track cut into windows of >= TRACK_WINDOW_SECONDS.
A fix every second jitters (one pair can read 15 m/s inside a 3 m/s run),
so speed is judged over a window, never over a raw 1 s pair. Fixes that
are already further apart than the window are their own segment.
"""
if not points:
return
anchor = points[0]
for point in points[1:]:
if point[0] - anchor[0] >= TRACK_WINDOW_SECONDS:
yield point[0] - anchor[0], point[1] - anchor[1]
anchor = point
if points[-1] is not anchor:
yield points[-1][0] - anchor[0], points[-1][1] - anchor[1]
def track_summary(activity: ET.Element) -> dict[str, Any]:
"""Moving seconds, credited distance and end time, from the trackpoints.
A segment (see :func:`_segments`) counts unless it was travelled faster
than ``MAX_MOVING_SPEED_MPS``: it then adds its metres to
``track_distance_m``, and its seconds to ``moving_seconds`` too when it was
travelled at least at ``MIN_MOVING_SPEED_MPS``. A segment longer than
``WALK_MAX_GAP_SECONDS`` is a signal dropout whose motion is unknown; it
only counts as moving time at ``MIN_DROPOUT_SPEED_MPS``, so a pause (no
distance across the gap) earns no time while a runner whose GPS went away
for two minutes keeps the time and metres the far side proves.
"""
"""Moving seconds and end time of one activity, from its trackpoints."""
points = _trackpoints(activity)
slow, fast = WALK_SPEED_MPS
moving = 0.0
metres = 0.0
for seconds, travelled in _segments(points):
if seconds <= 0:
continue
speed = travelled / seconds
if speed > MAX_MOVING_SPEED_MPS:
continue
metres += max(travelled, 0.0)
floor = (
MIN_MOVING_SPEED_MPS
if seconds <= WALK_MAX_GAP_SECONDS
else MIN_DROPOUT_SPEED_MPS
)
if speed >= floor:
moving += seconds
for (t0, d0), (t1, d1) in itertools.pairwise(points):
gap = t1 - t0
if 0 < gap <= WALK_MAX_GAP_SECONDS and slow <= (d1 - d0) / gap <= fast:
moving += gap
return {
"activity_id": activity.findtext(f"{_TCX_NS}Id") or "",
"moving_seconds": moving,
"track_distance_m": metres,
"ended_at": points[-1][0] if points else None,
}
# The day aggregate lives in ``_activity_day``; these names stay importable here,
# along with the walk-only names they replaced.
WalkDay = ActivityDay
walk_day = activity_day
walk_shortfall = activity_shortfall
__all__ = [
"SPORT_NAMES",
"ActivityDay",
"WalkDay",
"activity_day",
"activity_shortfall",
"distance_qualifies",
"is_walking_export",
"moving_qualifies",
"track_summary",
"walk_day",
"walk_shortfall",
]
@dataclass(frozen=True)
class WalkDay:
"""A day's walks, summed.
Attributes:
legs: Distinct walks (deduplicated by the activity's ``Id``: the
WebDAV copy and the phone's copy are the same walk).
moving_minutes: Their summed moving time.
distance_km: Their summed distance.
completed_at: Unix time the walk that crossed the bar ended -- when
the workout was actually done -- or ``None`` if it never did.
"""
legs: int
moving_minutes: float
distance_km: float
completed_at: float | None
@property
def qualifies(self) -> bool:
"""Whether the day's walking counts as its workout."""
return self.moving_minutes >= WALK_MIN_MOVING_MINUTES
def message(self) -> str:
"""The verification line, in the RunnerUp verifier's own style."""
return (
f"Walking: {self.legs} walk(s), {self.distance_km:.1f} km, "
f"{self.moving_minutes:.0f} min moving"
)
def walk_shortfall(day: WalkDay) -> str:
"""Why a day's walking did not count yet."""
return f"{day.message()} -- need {WALK_MIN_MOVING_MINUTES}+ min moving"
def walk_day(walks: Iterable[dict[str, Any]]) -> WalkDay:
"""Sum parsed walk exports (``_parse_tcx`` dicts) into one :class:`WalkDay`."""
unique: dict[str, dict[str, Any]] = {}
for walk in walks:
unique.setdefault(str(walk.get("activity_id") or id(walk)), walk)
ordered = sorted(unique.values(), key=lambda w: w.get("ended_at") or 0.0)
moving = 0.0
completed_at: float | None = None
for walk in ordered:
moving += float(walk.get("moving_seconds", 0.0))
if completed_at is None and moving / 60 >= WALK_MIN_MOVING_MINUTES:
completed_at = walk.get("ended_at")
return WalkDay(
legs=len(ordered),
moving_minutes=moving / 60,
distance_km=sum(float(w.get("distance_m", 0.0)) for w in ordered) / 1000,
completed_at=completed_at,
)

View File

@ -89,7 +89,7 @@ def _decision_limit(query: str) -> int:
class _Handler(BaseHTTPRequestHandler):
"""Serve the status payloads and the static frontend bundle (read-only)."""
def log_message(self, fmt: str, /, *args: object) -> None:
def log_message(self, fmt: str, *args: object) -> None:
"""Route the default request log to ``logging`` at debug level.
Args:

View File

@ -223,7 +223,7 @@ def count_weekly_workouts(
exc,
)
continue
if entry_date < week_start or entry_date > today_date:
if not (week_start <= entry_date <= today_date):
continue
count += count_day_credits(date_str, entries)
return count

View File

@ -201,10 +201,9 @@ class WindowSetupMixin:
def _setup_demo_close_button(self) -> None:
"""Add close button for demo mode."""
# Was "✕ Close Demo": the cross on the danger fill says it alone.
close_btn = tk.Button(
self.root,
text="✕",
text="✕ Close Demo",
font=self._colors.font("caption"),
bg=self._colors.palette.danger,
fg=self._colors.palette.on_fill,

View File

@ -49,11 +49,9 @@ if TYPE_CHECKING:
stdout: IO[str] | None
returncode: int | None
def terminate(self) -> None:
"""Ask the process to stop."""
def terminate(self) -> None: ...
def wait(self) -> int:
"""Block until the process exits; return its exit code."""
def wait(self) -> int: ...
class _Spawner(Protocol):
"""``subprocess.Popen``, narrowed to the one call shape used here."""

View File

@ -183,7 +183,7 @@ class WorkoutCreditMixin:
"""
try:
_credit_notify.notify_workout_credit(before, after, self.log_file)
except Exception: # pylint: disable=broad-exception-caught # notification must never fail the credit
except Exception:
_logger.exception(
"Workout credit notification failed — the credit itself is "
"applied; only the desktop notification is missing"

View File

@ -41,7 +41,7 @@ def lock_grab_handoff(lock: LockWindow) -> GrabHandoff:
def release() -> None:
# Stop first: a watchdog tick between the release and the app's own
# grab would take the screen straight back off it.
lock.recovery.stop()
lock._recovery.stop()
# Claim.grab_held false BEFORE the release (fail closed), so
# leisure-watch never reads "held" while the workout app has input.
# reacquire() -> grab_input() sets it true once the grab lands.

View File

@ -4,8 +4,8 @@ Every other earner publishes its credits as HMAC-signed rows in a ledger that
``earned_time`` reads (``first_credit_at``, ``credit_units``). This is the
workout's: ``~/.local/share/workout_locker/ledger.json``, one row per
* workout credit slot in ``log.json`` -- RunnerUp (the day's runs and walks,
summed by one rule), StrongLifts (phone or PC session), manual, any lock-screen or
* workout credit slot in ``log.json`` -- RunnerUp (a run, or the day's walks
summed), StrongLifts (phone or PC session), manual, any lock-screen or
verify path -- appended by the one log write chokepoint
(``_log_mixin.write_signed_entry``), so no path can credit without it; and
* declared rest day -- appended when it is declared.

View File

@ -38,11 +38,9 @@ if TYPE_CHECKING:
stdout: IO[str] | None
returncode: int | None
def poll(self) -> int | None:
"""Return the exit code, or ``None`` while it still runs."""
def poll(self) -> int | None: ...
def terminate(self) -> None:
"""Ask the process to stop."""
def terminate(self) -> None: ...
__all__ = ["WorkoutSession"]

View File

@ -69,7 +69,7 @@ def _start_stream(server: PokeServer) -> None:
# Same lock as the HTTP handler: a poke and a streamed copy of one
# session must never be credited concurrently.
SessionStream(server.gate).start()
except Exception: # pylint: disable=broad-exception-caught # daemon boundary: logged, LAN poke must survive
except Exception:
_logger.exception(
"Could not start the Firebase session stream; off-LAN workouts will "
"only be credited by the 15-min workout-sync until the daemon restarts"

View File

@ -1,99 +0,0 @@
"""``python3 -m screen_locker.runnerup_retire``: uninstall RunnerUp, a week on.
What ``runnerup-retire.service`` runs. ``--status`` prints anchor, due date,
marker validity and (with ``--query-phone``) whether RunnerUp is installed;
it never writes and never talks to the phone unless asked. ``--dry-run`` runs
every check, including the read-only adb queries, but writes nothing, pulls
nothing and uninstalls nothing. The exit code is non-zero only for outcomes
that need a human (bad marker, adb missing, failed backup or uninstall).
"""
from __future__ import annotations
import argparse
from datetime import UTC, datetime
import logging
import sys
from typing import TYPE_CHECKING
from screen_locker._runnerup_retire import (
GRACE,
Paths,
derive_anchor,
run_retire,
)
from screen_locker._runnerup_retire_adb import PHONE_SERIAL, AdbError, Phone
from screen_locker._runnerup_retire_state import load_state
from screen_locker._runnerup_retire_tcx import check_marker
if TYPE_CHECKING:
from screen_locker._runnerup_retire_adb import PhoneLike
__all__ = ["main", "status_lines"]
_logger = logging.getLogger(__name__)
def _installed_line(phone: PhoneLike, *, query: bool) -> str:
if not query:
return "installed: not queried (add --query-phone)"
try:
device = phone.find_device()
if device.serial is None:
return f"installed: unknown ({device.reason})"
return f"installed: {'yes' if phone.installed(device.serial) else 'no'}"
except AdbError as exc:
_logger.warning("--status could not ask the phone: %s", exc)
return f"installed: unknown ({exc})"
def status_lines(
paths: Paths, phone: PhoneLike, now: datetime, *, query_phone: bool = False
) -> list[str]:
"""The ``--status`` report. Read-only: nothing is created or saved."""
state = load_state(paths.state_file)
anchor = derive_anchor(paths, state)
if anchor is None:
anchor_line, due_line = "anchor: none (no app run credited yet)", "due: never"
else:
origin = "stored" if state.anchor_at else "derived, not yet stored"
due = anchor + GRACE
days = (due - now).total_seconds() / 86400
anchor_line = f"anchor: {anchor.isoformat()} ({origin})"
due_line = f"due: {due:%Y-%m-%d %H:%M} ({days:+.1f} days)"
marker = check_marker(paths.marker, paths.processed)
return [
anchor_line,
due_line,
f"marker: {'valid' if marker.ok else 'INVALID'} ({marker.reason})",
f"done: {state.done_at or 'no'}",
_installed_line(phone, query=query_phone),
]
def main(argv: list[str] | None = None) -> int:
"""CLI entry; returns the process exit code."""
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", maxsplit=1)[0])
parser.add_argument(
"--status", action="store_true", help="print state, change nothing"
)
parser.add_argument(
"--dry-run", action="store_true", help="check everything, change nothing"
)
parser.add_argument(
"--query-phone", action="store_true", help="with --status: ask adb"
)
parser.add_argument("--serial", default=PHONE_SERIAL, help="phone adb serial")
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
paths, phone = Paths.default(), Phone(args.serial)
if args.status:
now = datetime.now(UTC)
lines = status_lines(paths, phone, now, query_phone=args.query_phone)
sys.stdout.write("\n".join(lines) + "\n")
return 0
return 0 if run_retire(paths, phone, dry_run=args.dry_run).ok else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -22,7 +22,6 @@ from gatelock import (
from screen_locker import _instance
from screen_locker._auto_upgrade import AutoUpgradeMixin
from screen_locker._carrot_watch import watch_for_carrot
from screen_locker._constants import (
EARLY_BIRD_END_HOUR,
EARLY_BIRD_END_MINUTE,
@ -38,7 +37,6 @@ from screen_locker._constants import (
from screen_locker._decision_log import record_run_aborted
from screen_locker._early_bird import EarlyBirdMixin
from screen_locker._heat_skip import HeatSkipMixin
from screen_locker._hold import watch_for_hold
from screen_locker._log_mixin import LogMixin
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
from screen_locker._phone_verification import PhoneVerificationMixin
@ -168,9 +166,7 @@ class ScreenLocker(
self._instance_lock.release()
sys.exit(0)
self.root.on_callback_error = self.on_callback_error
# textgate: the window is fullscreen, so its title is never painted;
# was "Workout Locker" (+ " [DEMO MODE]"). Nothing matches on it.
self.root.title("")
self.root.title("Workout Locker" + (" [DEMO MODE]" if demo_mode else ""))
self.demo_mode = demo_mode
self.lockout_time = 10 if demo_mode else 1800
self._lock: LockWindow | None = None
@ -212,8 +208,6 @@ class ScreenLocker(
# across two separate if/elif/else statements).
if self._lock is not None: # pragma: no branch
self._lock.grab_input()
watch_for_hold(self.root, self.close)
watch_for_carrot(self.root, self._stand_down_for_carrot)
def close(self) -> None:
"""Close the application and exit."""

View File

@ -30,7 +30,6 @@ from typing import TYPE_CHECKING
from gatelock import LockConfig
from screen_locker import _glyphs as g
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
from screen_locker._status_data import (
StatusSnapshot,
@ -113,10 +112,9 @@ class StatusWindow(
"""Redraw the whole window from *snapshot*."""
self._last_snapshot = snapshot
self.clear_container()
# textgate: the "Workout Status" heading is gone; see the sections.
self._label("Workout Status", role="title", pad="md")
self._section_today(self.container, snapshot.today)
self._section_week(self.container, snapshot.week)
self._section_streak(self.container, snapshot.streak, snapshot.week_rewards)
self._section_lock_explanation(self.container, snapshot.lock_explanation)
self._section_temperature(self.container)
self._section_manual_workout_budget(
@ -131,29 +129,44 @@ class StatusWindow(
if status == "verified"
else self._colors.palette.warning
)
self._text(message, role="title", color=color)
self._text(f"Phone check ({status}): {message}", role="body", color=color)
if self._credit_message is not None:
self._text(
self._credit_message, role="body", color=self._colors.palette.success
)
# Check phone / manual log are the primary write actions (accent,
# high contrast per rule 3); refresh / close are secondary utility
# actions (muted) -- two tiers instead of four arbitrary hues. Was
# "Check Phone" / "Log Manual Workout" / "Refresh" / "Close".
# "Check Phone"/"Log Manual Workout" are the primary write actions
# (accent, high contrast per rule 3); "Refresh"/"Close" are secondary
# utility actions (muted) -- two tiers instead of four arbitrary hues.
frame = self._button_row()
buttons = [(g.PHONE, self._colors.palette.accent, self._on_check_phone_clicked)]
self._button(
frame,
"Check Phone",
bg=self._colors.palette.accent,
command=self._on_check_phone_clicked,
width=14,
).pack(side="left", padx=self._colors.space("sm"))
if not self._mw_budget_closed():
buttons.append(
(g.FORM, self._colors.palette.accent, self._show_manual_workout_form)
)
buttons += [
(g.RETRY, self._colors.palette.field_bg, self._on_refresh_clicked),
(g.CLOSE, self._colors.palette.field_bg, self.root.destroy),
]
for glyph, bg, command in buttons:
self._button(frame, glyph, bg=bg, command=command, width=4).pack(
side="left", padx=self._colors.space("sm")
)
self._button(
frame,
"Log Manual Workout",
bg=self._colors.palette.accent,
command=self._show_manual_workout_form,
width=16,
).pack(side="left", padx=self._colors.space("sm"))
self._button(
frame,
"Refresh",
bg=self._colors.palette.field_bg,
command=self._on_refresh_clicked,
width=10,
).pack(side="left", padx=self._colors.space("sm"))
self._button(
frame,
"Close",
bg=self._colors.palette.field_bg,
command=self.root.destroy,
width=8,
).pack(side="left", padx=self._colors.space("sm"))
def _on_refresh_clicked(self) -> None:
"""Clear any stale phone-check/credit/temperature results, re-check both."""
@ -178,7 +191,9 @@ class StatusWindow(
verifier.workout_data = entry
credit = verifier._apply_workout_credit()
self._credit_message = "\n".join(
self._credit_result_lines(f"{g.FORM} {g.OK}", credit)
self._credit_result_lines(
f"Manual workout logged: {entry.get('source', '')}", credit
)
)
self.render(gather_status())
@ -205,7 +220,7 @@ def main(argv: list[str] | None = None) -> None:
return
root = tk.Tk()
root.title("")
root.title("Workout Status")
root.configure(bg=_STATUS_COLORS.palette.bg)
root.minsize(560, 200)

View File

@ -1,96 +0,0 @@
"""Helpers for the unified-activity tests: TCX builder and a phone-less verifier."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from unittest.mock import patch
from screen_locker._runnerup_verification import RunnerUpVerificationMixin
if TYPE_CHECKING:
from pathlib import Path
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
DAY = "2026-10-10"
def tcx(
sport: str,
start: str,
*,
moving_min: float,
metres: float,
pause_min: float = 0.0,
) -> str:
"""A RunnerUp-shaped export: ``moving_min`` of steady motion covering ``metres``.
A ``pause_min`` pause sits in the middle of the track as ONE gap between
two trackpoints (the clock runs, the distance does not), so the lap's
wall-clock time is ``moving_min + pause_min``.
"""
begin = datetime.fromisoformat(f"{DAY}T{start}:00+00:00")
speed = metres / (moving_min * 60)
total = int(moving_min * 60)
half = total // 2 // 5 * 5
pause = int(pause_min * 60)
points = ""
for s in range(0, total + 1, 5):
offset = s + (pause if s > half else 0)
points += (
f"<Trackpoint><Time>{(begin + timedelta(seconds=offset)).isoformat()}"
f"</Time><DistanceMeters>{s * speed:.1f}</DistanceMeters></Trackpoint>"
)
return (
f'<TrainingCenterDatabase xmlns="{_NS}"><Activities>'
f'<Activity Sport="{sport}"><Id>{DAY}T{start}</Id><Lap>'
f"<TotalTimeSeconds>{total + pause}</TotalTimeSeconds>"
f"<DistanceMeters>{metres}</DistanceMeters>"
f"<Track>{points}</Track></Lap></Activity></Activities>"
"</TrainingCenterDatabase>"
)
class Verifier(RunnerUpVerificationMixin):
"""The real mixin reading one temporary drop directory and no phone."""
def __init__(self, folder: Path) -> None:
self.folder = folder
def _has_adb_device(self) -> bool:
return False
def _run_adb(self, args: list[str]) -> tuple[bool, str]:
return False, ""
def _find_runnerup_exports_for_date(self, date_str: str) -> list[str]:
return sorted(str(p) for p in self.folder.glob(f"*{date_str}*.tcx"))
def drop(folder: Path, stamp: str, suffix: str, body: str) -> None:
"""Write a RunnerUp-named export for the test day."""
(folder / f"RunnerUp_Pixel_6a_{DAY}-{stamp}{suffix}.tcx").write_text(body)
def verify(verifier: Verifier) -> tuple[str, str] | None:
"""The file-based verdict for the test day."""
with patch("screen_locker._runnerup_verification.today_str", return_value=DAY):
return verifier._verify_runnerup_via_files()
def tcx_points(sport: str, start: str, points: list[tuple[int, float]]) -> str:
"""An export whose track is exactly ``(seconds after start, cumulative metres)``."""
begin = datetime.fromisoformat(f"{DAY}T{start}:00+00:00")
body = "".join(
f"<Trackpoint><Time>{(begin + timedelta(seconds=s)).isoformat()}</Time>"
f"<DistanceMeters>{m:.1f}</DistanceMeters></Trackpoint>"
for s, m in points
)
return (
f'<TrainingCenterDatabase xmlns="{_NS}"><Activities>'
f'<Activity Sport="{sport}"><Id>{DAY}T{start}</Id><Lap>'
f"<TotalTimeSeconds>{points[-1][0]}</TotalTimeSeconds>"
f"<DistanceMeters>{points[-1][1]}</DistanceMeters>"
f"<Track>{body}</Track></Lap></Activity></Activities>"
"</TrainingCenterDatabase>"
)

View File

@ -1,28 +0,0 @@
"""A ``holds`` stand-in for tests: ``covers`` returns a value or raises it."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
class FakeHolds:
"""A ``holds`` stand-in: ``covers`` returns the value, or raises it."""
def __init__(self, covers: object) -> None:
self._covers = covers
self.calls: list[tuple[str, datetime | None]] = []
def covers(self, gate: str, now: datetime | None = None) -> bool:
"""Record the call, then answer like ``holds.covers``."""
self.calls.append((gate, now))
if isinstance(self._covers, BaseException):
raise self._covers
return bool(self._covers)
def fake_holds_module(*, covers: object) -> FakeHolds:
"""A ``holds`` stand-in to install with ``monkeypatch.setitem(sys.modules, ...)``."""
return FakeHolds(covers)

View File

@ -105,20 +105,4 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
"workday_penalty.json",
("_constants.WORKDAY_PENALTY_FILE", "_workday_penalty.WORKDAY_PENALTY_FILE"),
),
# RunnerUp retirement: the anchor/done stamp, the dufs drop directory it
# reads the marker and TCX files from (and writes the phone backup into),
# and the credit log the anchor is derived from. A test must never see, or
# create files under, the user's real RunnerUp folder.
(
"runnerup_retire.json",
("_runnerup_retire.STATE_FILE",),
),
(
"RunnerUp",
("_runnerup_retire.WATCH_DIR",),
),
(
"retire_log.json",
("_runnerup_retire.LOG_FILE",),
),
)

View File

@ -45,8 +45,8 @@ def mock_tk() -> Generator[MagicMock]:
yield mock
@pytest.fixture(name="mock_sys_exit")
def mock_sys_exit_fixture() -> Generator[MagicMock]:
@pytest.fixture
def mock_sys_exit() -> Generator[MagicMock]:
"""Mock sys.exit to prevent test termination."""
with patch("screen_locker.screen_lock.sys.exit") as mock:
yield mock

View File

@ -10,10 +10,8 @@ other. ``_isolate_home`` covers paths resolved at *call* time from
``Path.home()``; ``_no_free_days_by_default`` covers paths bound into module
constants at *import* time, which redirecting home afterwards cannot reach.
``pre_ladder`` and ``leetcode_gate_off`` are the opt-in fixtures here:
``pre_ladder`` keeps a test off the real calendar's ladder cutover, the
date-dependent half of the same question; ``leetcode_gate_off`` keeps
ledger-plumbing tests off LeetCode's paid-in-full rule.
``pre_ladder`` is the one opt-in fixture here: it keeps a test off the real
calendar's ladder cutover, the date-dependent half of the same question.
"""
from __future__ import annotations
@ -24,12 +22,7 @@ import sys
from typing import TYPE_CHECKING
from unittest.mock import patch
import freedays._api
import pytest
import sickdays
import sickdays.gui
import screen_locker._night_lockdown
if TYPE_CHECKING:
from collections.abc import Iterator
@ -49,6 +42,7 @@ def _no_free_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
chain in every test here -- it is the very first check in the chain --
and the failures would name early-bird and sick-day logic instead.
"""
import freedays._api
monkeypatch.setattr(
freedays._api,
@ -68,6 +62,8 @@ def _no_sick_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
developer's real today sick. ``sync_quietly`` is stubbed so the button's
background push never reaches Firebase.
"""
import sickdays
import sickdays.gui # so its modules are in sys.modules below
pool = sickdays.Paths.under(tmp_path / "sickdays")
# Every sickdays module that bound ``resolve_paths`` by name: the readers
@ -176,17 +172,6 @@ def pre_ladder(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("earned_time._ladder.LADDER_FROM", date.max)
@pytest.fixture
def leetcode_gate_off(monkeypatch: pytest.MonkeyPatch) -> None:
"""Let a bare LeetCode credit earn, as on a free or stood-down sick day.
These tests pin the shared ledger plumbing with one credit row per earner.
Since earned_time 0.18.0 a LeetCode credit alone earns only while the gate
is off; the paid-in-full rule itself is ``test_leetcode_gate_off.py``.
"""
monkeypatch.setattr("screen_locker._earned.gate_off", lambda _earner, _day: True)
@pytest.fixture(autouse=True)
def _no_real_night_lockdown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Never let the lock chain read the machine's real night-lockdown stamp.
@ -195,39 +180,10 @@ def _no_real_night_lockdown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
this every enforce-path test would skip the lock on any evening after the
real lockdown fired -- and pass in the morning.
"""
import screen_locker._night_lockdown
monkeypatch.setattr(
screen_locker._night_lockdown,
"NIGHT_LOCKDOWN_STAMP",
tmp_path / "locked_at_epoch",
)
@pytest.fixture(autouse=True)
def _no_real_ntp() -> Iterator[None]:
"""Never send a real NTP query to pool.ntp.org from a test.
``check_clock_skew`` (every phone and RunnerUp verification) opens a UDP
socket, which ``_block_real_network``'s ``create_connection`` stub cannot
reach. A lost reply cost a test the full 5 s ``_NTP_TIMEOUT``, at random.
Stubbed as "unreachable", what the real query answers on any OSError.
``test_time_check`` drives the real query through its own import.
"""
with patch("screen_locker._time_check._query_ntp_offset", return_value=None):
yield
@pytest.fixture(autouse=True)
def _no_live_focus_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""No live focus project reshapes the registry (earned_time >= 0.19)."""
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "focus_project"))
@pytest.fixture(autouse=True)
def _no_real_holds(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Point ``holds`` at an empty file: a real hold must not mute the ladder.
Without this the suite would read ``~/.local/share/holds/holds.json`` and
every ladder test would start failing inside a declared test hold.
"""
monkeypatch.setenv("HOLDS_DATA", str(tmp_path / "holds-none.json"))

View File

@ -1,137 +0,0 @@
"""Builders for the RunnerUp retirement tests: TCX files, marker, credit log."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
import json
from typing import TYPE_CHECKING, Any
from screen_locker._runnerup_retire import Paths
from screen_locker._runnerup_retire_adb import AdbError, Device
if TYPE_CHECKING:
from pathlib import Path
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
APP = "kuhy workout_app"
# 2026-11-01T10:30:00Z, the end of every TCX built by default.
END_UNIX = 1793529000.0
def tcx_text(
*, author: str = "RunnerUp", creator: str = "", end: str = "2026-11-01T10:30:00Z"
) -> str:
"""A tiny TCX: two trackpoints (the last is the run's end) and an author."""
creator_xml = f"<Creator><Name>{creator}</Name></Creator>" if creator else ""
return (
f'<?xml version="1.0"?><TrainingCenterDatabase xmlns="{_NS}"><Activities>'
'<Activity Sport="Running"><Id>2026-11-01T10:00:00Z</Id>'
"<Lap><TotalTimeSeconds>1800</TotalTimeSeconds>"
"<DistanceMeters>5000</DistanceMeters><Track>"
"<Trackpoint><Time>2026-11-01T10:00:00Z</Time>"
"<DistanceMeters>0</DistanceMeters></Trackpoint>"
f"<Trackpoint><Time>{end}</Time><DistanceMeters>5000</DistanceMeters>"
f"</Trackpoint></Track></Lap>{creator_xml}</Activity></Activities>"
f'<Author xsi:type="Application_t" xmlns:xsi="x"><Name>{author}</Name>'
"</Author></TrainingCenterDatabase>"
)
def put_tcx(folder: Path, name: str, **kwargs: str) -> Path:
"""Write a TCX named ``name`` into ``folder`` (created if needed)."""
folder.mkdir(parents=True, exist_ok=True)
path = folder / name
path.write_text(tcx_text(**kwargs), encoding="utf-8")
return path
def put_marker(watch: Path, names: list[str], count: int | None = None) -> Path:
"""Write the app's import marker into the drop folder."""
watch.mkdir(parents=True, exist_ok=True)
marker = watch / ".workout_app_import.json"
body = {
"completed_at": "2026-11-02T08:00:00Z",
"count": len(names) if count is None else count,
"tcx_names": names,
}
marker.write_text(json.dumps(body), encoding="utf-8")
return marker
def put_log(log_file: Path, entries: dict[str, list[dict[str, Any]]]) -> None:
"""Write a day-keyed credit log."""
log_file.write_text(json.dumps(entries), encoding="utf-8")
def credit(
timestamp: str, completed_at: object = None, kind: str = "runnerup_verified"
) -> dict[str, Any]:
"""One log entry as the credit chokepoint writes it."""
data: dict[str, Any] = {"type": kind}
if completed_at is not None:
data["completed_at"] = completed_at
return {"timestamp": timestamp, "workout_data": data}
ANCHOR = datetime(2026, 11, 1, 10, 45, tzinfo=UTC)
DUE = ANCHOR + timedelta(days=7)
RUN = "RunnerUp_2026-08-12-17-42-11_Running.tcx"
@dataclass
class FakePhone:
"""Records what the retirement asked of the phone."""
adb: bool = True
device: Device = field(default_factory=lambda: Device("S", "attached"))
present: bool = True
ask_error: AdbError | None = None
backup_error: Exception | None = None
uninstall_error: AdbError | None = None
calls: list[str] = field(default_factory=list)
def available(self) -> bool:
"""Available."""
return self.adb
def find_device(self) -> Device:
"""Find device."""
if self.ask_error:
raise self.ask_error
return self.device
def installed(self, dev: str, *, hidden: bool = True) -> bool:
"""Installed."""
self.calls.append(f"installed {dev}")
return self.present
def backup(self, dev: str, final: Path) -> tuple[Path, int]:
"""Backup."""
self.calls.append("backup")
if self.backup_error:
raise self.backup_error
final.mkdir(parents=True)
return final, 3
def uninstall(self, dev: str) -> None:
"""Uninstall."""
self.calls.append("uninstall")
if self.uninstall_error:
raise self.uninstall_error
def world(tmp_path: Path, *, anchored: bool = True, marker: bool = True) -> Paths:
"""A drop folder with one RunnerUp run, one app run, and the credit log."""
paths = Paths(
tmp_path / "state" / "s.json", tmp_path / "drop", tmp_path / "log.json"
)
put_tcx(paths.processed, RUN)
put_tcx(paths.processed, "app.tcx", author=APP)
put_log(
paths.log_file,
{"d": [credit(ANCHOR.isoformat(), END_UNIX)]} if anchored else {},
)
if marker:
put_marker(paths.watch_dir, [RUN])
return paths

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