mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-13 05:40:47 +01:00
Compare commits
No commits in common. "main" and "v1.0.0-build.330" have entirely different histories.
main
...
v1.0.0-bui
4
.github/workflows/pre-commit.yml
vendored
4
.github/workflows/pre-commit.yml
vendored
@ -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
|
||||
|
||||
8
.github/workflows/python-tests.yml
vendored
8
.github/workflows/python-tests.yml
vendored
@ -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
|
||||
|
||||
|
||||
2
.github/workflows/release-apk.yml
vendored
2
.github/workflows/release-apk.yml
vendored
@ -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
1
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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.
|
||||
#
|
||||
|
||||
@ -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`)
|
||||
|
||||
20
README.md
20
README.md
@ -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
|
||||
|
||||
24
TODO-cap-bonus-at-day-ceiling.md
Normal file
24
TODO-cap-bonus-at-day-ceiling.md
Normal 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
95
TODO-file-length-250.md
Normal 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
178
TODO-finish-dart-250.md
Normal 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
143
TODO-pylint-to-ten.md
Normal 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
|
||||
126
docs/TODO-android-ui-mcp-wrapper.md
Normal file
126
docs/TODO-android-ui-mcp-wrapper.md
Normal 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
|
||||
@ -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 & 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<void> _tapReal(WidgetTester tester, Finder f) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.tap(f);
|
||||
await Future<void>.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>
|
||||
@ -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 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 > 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># 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>
|
||||
@ -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 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 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 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 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 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 km now
|
||||
passes too. The interactive demo and quiz below still use the old 5.0 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>
|
||||
51
docs/explanations/index.html
Normal file
51
docs/explanations/index.html
Normal 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>
|
||||
@ -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
|
||||
|
||||
211
prompts/TODO-dopamine-ux-04-screen-locker.md
Normal file
211
prompts/TODO-dopamine-ux-04-screen-locker.md
Normal 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
|
||||
232
prompts/TODO-dopamine-ux-05-workout-app.md
Normal file
232
prompts/TODO-dopamine-ux-05-workout-app.md
Normal 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
|
||||
@ -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/*",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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,
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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", []))
|
||||
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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: ...
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -86,4 +86,3 @@ class StatusSnapshot:
|
||||
lock_explanation: LockExplanation
|
||||
manual_workout_budget: ManualWorkoutBudgetStatus
|
||||
generated_at: str
|
||||
week_rewards: tuple[str, ...] = ()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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())
|
||||
@ -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."""
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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>"
|
||||
)
|
||||
@ -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)
|
||||
@ -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",),
|
||||
),
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user