mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 13:27:41 +01:00
Compare commits
203 Commits
v1.0.0-bui
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ccc0e09938 | |||
| 71cb5eecdb | |||
| eb4ce6fd68 | |||
| f0d4f1036c | |||
| 83a5c6b4bc | |||
| 71a35d6aa1 | |||
| 2c52408cec | |||
| 17014c6399 | |||
| b584c0441b | |||
| 5fa160f114 | |||
| 661b293bc2 | |||
| 1f8f602564 | |||
| b24718710e | |||
| e5bfdd9a9a | |||
| 3073faa623 | |||
| 32a735f168 | |||
| b6db88f9b9 | |||
| e08a520019 | |||
| 94f9822884 | |||
| 94a55deb86 | |||
| 13f1e9ed93 | |||
| 6777656418 | |||
| 9c415d8f9a | |||
| 9641906818 | |||
| cd32c0a79e | |||
| 48d21bfafc | |||
| 6df7c397ca | |||
| 882b6a56c8 | |||
| fccde84bcb | |||
| 43e7f6d7e0 | |||
| 8cc66d2803 | |||
| d066726a60 | |||
| f528722e1f | |||
| 0a48b76d59 | |||
| ba9e9c31c0 | |||
| d0bb8d700e | |||
| 1475ab913b | |||
| 2998bd5353 | |||
| 0ec8cfddec | |||
| 39c85a7dfd | |||
| 5d3547c221 | |||
| 7f9169b9d0 | |||
| 17dac66a11 | |||
| 23ad2e02b4 | |||
| aaaecca3be | |||
| bd12b8711d | |||
| 97d73b31f5 | |||
| 66e9948777 | |||
| 801e303b39 | |||
| 6fa9666cdd | |||
| b1ff91249b | |||
| 0411116f24 | |||
| 770a88454d | |||
| a6c97dab8f | |||
| 8ca4c301fd | |||
| ca205fa67e | |||
| 25ee5ca9ad | |||
| 50faee673f | |||
| b174339979 | |||
| 8a26387396 | |||
| b4e69dbff4 | |||
| 63caf9dee9 | |||
| c2544c4a9c | |||
| 1020421371 | |||
| c4d2bbcd7d | |||
| 595c5f41e6 | |||
| 856f0c0774 | |||
| 0987975bca | |||
| 6919ddea28 | |||
| fdf6251847 | |||
| 47da4131a8 | |||
| c77dff2b07 | |||
| 6b3fbe2f90 | |||
| 0c19a3ee76 | |||
| 60aa0a7d3a | |||
| 141484b3e3 | |||
| 77b541475e | |||
| 4ca5e4b3c2 | |||
| 124f0506e2 | |||
| aa56bc219e | |||
| 7264dcb1f7 | |||
| ffeb61df3e | |||
| 5d0aa9b57a | |||
| e98ee1f874 | |||
| c145431dc9 | |||
| aa3e290391 | |||
| 4a36b6b264 | |||
| f3875302d7 | |||
| 21a40ba9f5 | |||
| e7fd13c649 | |||
| fc066e139d | |||
| f3bd94f646 | |||
| 6cd21296a9 | |||
| ba3a8a293d | |||
| aa2776a45e | |||
| 1e6f17257c | |||
| e47f42eb49 | |||
| 9e71954348 | |||
| c3a5d1470c | |||
| 8b0b881ec1 | |||
| 6060ebeb4e | |||
| eec45d26b7 | |||
| 8b4dcd8335 | |||
| 2d728fb791 | |||
| dc885b9903 | |||
| 8b0fb81d7b | |||
| f04b0d0efd | |||
| 06c50a9231 | |||
| 814ad87301 | |||
| 2811f78b4c | |||
| 7ca2354d66 | |||
| 73d70798c8 | |||
| 0048c99601 | |||
| a79ccc523b | |||
| 77d3866ee7 | |||
| e2b1a980cc | |||
| dd3b8ca160 | |||
| aa27a8ece6 | |||
| 31a2056d52 | |||
| 46393fa866 | |||
| f2ddf3a491 | |||
| 9e23ebae4f | |||
| 67f942284e | |||
| ac65def3ef | |||
| 2539084169 | |||
| 3478ee1d2a | |||
| 8450644a56 | |||
| 8ba9ee93fa | |||
| e12b840f8c | |||
| 1a305c0627 | |||
| 0b0a18e81c | |||
| 7d8daca111 | |||
| ef477d644b | |||
| 3c1b983c2a | |||
| 96ad163ee6 | |||
| 4b65b7667b | |||
| 5eb18b92ca | |||
| 384ffcda7d | |||
| 41ce020e10 | |||
| b37b7559d8 | |||
| e1f1c668a8 | |||
| fc5453495c | |||
| 87c50825f9 | |||
| 2140ca5180 | |||
| 3a2ad0245c | |||
| 215f03ecfc | |||
| bbc14ec04c | |||
| 19ccad6978 | |||
| 52fd8217f5 | |||
| 965b9c4ebb | |||
| 2dac472331 | |||
| a826cfa166 | |||
| aedfdbc7f5 | |||
| f2b8fdf7ea | |||
| 7e1b4e865e | |||
| 0b809c4af1 | |||
| 373a075606 | |||
| 6334bd54bf | |||
| ff681aa6af | |||
| f77e601893 | |||
| 18d3789836 | |||
| d1817675c4 | |||
| 7851d592fd | |||
| ec7f4805f7 | |||
| 218d327145 | |||
| 2dd719e6a4 | |||
| 275a0862fc | |||
| ed5fa133d0 | |||
| 6f3f93ecec | |||
| 279a7b1ed7 | |||
| c81f1bac3f | |||
| 5d9b348436 | |||
| 429e854ddd | |||
| 6f351179d7 | |||
| 0b0cda6782 | |||
| 98bfe7d2c3 | |||
| deb89b3e03 | |||
| cf2d917b33 | |||
| d35edfc10c | |||
| 38b56e1e2b | |||
| 97f4e49a5f | |||
| 0921146044 | |||
| 7ae4c13407 | |||
| 258bbe066a | |||
| 6a07dc56aa | |||
| 2b0f5d96a4 | |||
| 584177ec2e | |||
| e86dd57817 | |||
| a03afb8ae7 | |||
| 885951ee2b | |||
| 83d77884a0 | |||
| 9ab7c09c79 | |||
| 53bbdda419 | |||
| 81284f160b | |||
| c0099dff94 | |||
| bdc6cc4df1 | |||
| 9b84af7537 | |||
| 8df1ff6d09 | |||
| a22ec380ca | |||
| ae7566628c | |||
| 02a93f1322 | |||
| b6e89b93dc | |||
| 86b63108c7 |
107
.github/workflows/pre-commit.yml
vendored
107
.github/workflows/pre-commit.yml
vendored
@ -7,8 +7,31 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# One ~120 s job used to install Flutter, Node and all of requirements.txt
|
||||||
|
# and then run every hook in series; mypy (~20 s), flutter analyze (~13 s
|
||||||
|
# plus the SDK) and pylint (~12 s) dominated it. Each now gets its own runner
|
||||||
|
# with only the toolchain it needs, and the other hooks share `rest`; the
|
||||||
|
# workflow takes as long as the slowest group rather than the sum.
|
||||||
pre-commit:
|
pre-commit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- group: mypy
|
||||||
|
hooks: mypy
|
||||||
|
skip: ""
|
||||||
|
# Both pylint hooks (source and tests) share the id `pylint`.
|
||||||
|
- group: pylint
|
||||||
|
hooks: pylint
|
||||||
|
skip: ""
|
||||||
|
- group: flutter
|
||||||
|
hooks: flutter-analyze
|
||||||
|
skip: ""
|
||||||
|
- group: rest
|
||||||
|
hooks: ""
|
||||||
|
skip: mypy,pylint,flutter-analyze
|
||||||
|
name: pre-commit (${{ matrix.group }})
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@ -26,32 +49,74 @@ jobs:
|
|||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.x"
|
python-version: "3.x"
|
||||||
# The flutter-analyze pre-commit hook shells out to `flutter analyze` in
|
- uses: astral-sh/setup-uv@v10.2.0
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: requirements.txt
|
||||||
|
|
||||||
|
# The flutter-analyze hook shells out to `flutter analyze` in
|
||||||
# stronglift_replacement/workout_app. Pin to the version the app is
|
# stronglift_replacement/workout_app. Pin to the version the app is
|
||||||
# developed against (Dart SDK ^3.12.2) so CI's analyzer matches local.
|
# developed against (Dart SDK ^3.12.2) so CI's analyzer matches local.
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
|
||||||
|
if: matrix.group == 'flutter'
|
||||||
with:
|
with:
|
||||||
flutter-version: 3.47.4
|
flutter-version: 3.47.9
|
||||||
channel: stable
|
working-directory: stronglift_replacement/workout_app
|
||||||
cache: true
|
|
||||||
# The web-lint / web-typecheck / web-coverage hooks run npm inside web/.
|
|
||||||
# Without its installed packages `tsc --build` cannot find the
|
|
||||||
# 'vite/client' and 'node' type libraries and fails with TS2688 -- which
|
|
||||||
# is what this workflow had been doing on every run since the status UI
|
|
||||||
# landed in 8f91158.
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "24.21.0"
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: web/package-lock.json
|
|
||||||
- name: Install status-UI packages
|
|
||||||
run: npm ci
|
|
||||||
working-directory: web
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -r requirements.txt
|
|
||||||
# analyze needs resolved packages (.dart_tool); the hook runs analyze
|
# analyze needs resolved packages (.dart_tool); the hook runs analyze
|
||||||
# only, so fetch deps here first.
|
# only, so fetch deps here first.
|
||||||
- name: Resolve Flutter packages
|
- name: Resolve Flutter packages
|
||||||
|
if: matrix.group == 'flutter'
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
working-directory: stronglift_replacement/workout_app
|
working-directory: stronglift_replacement/workout_app
|
||||||
- uses: pre-commit/action@v3.0.1
|
|
||||||
|
# The web-lint / web-typecheck hooks run pnpm inside web/. Without its
|
||||||
|
# installed packages `tsc --build` cannot find the 'vite/client' and
|
||||||
|
# 'node' type libraries and fails with TS2688 -- which is what this
|
||||||
|
# workflow had been doing on every run since the status UI landed in
|
||||||
|
# 8f91158.
|
||||||
|
# No `version:` -- pnpm/action-setup reads `packageManager` from
|
||||||
|
# web/package.json (it errors if both are given).
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
if: matrix.group == 'rest'
|
||||||
|
with:
|
||||||
|
package_json_file: web/package.json
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
if: matrix.group == 'rest'
|
||||||
|
with:
|
||||||
|
node-version: "26.13.0"
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: web/pnpm-lock.yaml
|
||||||
|
- name: Install status-UI packages
|
||||||
|
if: matrix.group == 'rest'
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
working-directory: web
|
||||||
|
|
||||||
|
# Every hook brings its own environment except the language: system
|
||||||
|
# ones, and those all run in `rest`: screens-fit imports screen_locker
|
||||||
|
# (and so its runtime deps) and dependency-freshness needs pyyaml and
|
||||||
|
# packaging. The other groups need pre-commit alone. uv with a cached
|
||||||
|
# wheel store replaces a ~27 s pip install.
|
||||||
|
- name: Install requirements for the system hooks
|
||||||
|
if: matrix.group == 'rest'
|
||||||
|
run: uv pip install --system -r requirements.txt pyyaml packaging
|
||||||
|
- name: Install pre-commit
|
||||||
|
if: matrix.group != 'rest'
|
||||||
|
run: uv pip install --system pre-commit
|
||||||
|
|
||||||
|
# One cache per group: a shared key would hold whichever group saved
|
||||||
|
# first, and the others would rebuild their hook envs on every run. The
|
||||||
|
# restore key falls back to the old single pre-commit/action cache,
|
||||||
|
# which already holds every hook env, so a group's first run does not
|
||||||
|
# rebuild pylint's git dependencies from scratch.
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pre-commit
|
||||||
|
key: pre-commit-${{ matrix.group }}-${{ env.pythonLocation }}-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
pre-commit-${{ matrix.group }}-${{ env.pythonLocation }}-
|
||||||
|
pre-commit-3|${{ env.pythonLocation }}|
|
||||||
|
|
||||||
|
- name: pre-commit run (${{ matrix.group }})
|
||||||
|
env:
|
||||||
|
SKIP: ${{ matrix.skip }}
|
||||||
|
run: pre-commit run ${{ matrix.hooks }} --all-files --show-diff-on-failure --color=always
|
||||||
|
|||||||
109
.github/workflows/python-tests.yml
vendored
109
.github/workflows/python-tests.yml
vendored
@ -6,6 +6,13 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Bucket count for the Flutter suite; the matrix below lists 0..N-1.
|
||||||
|
# 10, not more: the account runs at most 20 jobs at once, and a push also
|
||||||
|
# starts ~10 others (pre-commit, freshness, the rest of this file). At 12
|
||||||
|
# three shards queued for up to 34 s and the workflow took 91 s.
|
||||||
|
FLUTTER_SHARDS: 10
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -17,11 +24,22 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.x"
|
python-version: "3.x"
|
||||||
|
|
||||||
- name: Install dependencies
|
# uv with a cached wheel store installs requirements.txt in seconds;
|
||||||
run: pip install -r requirements.txt
|
# plain pip took ~26 s of a ~70 s job.
|
||||||
|
- uses: astral-sh/setup-uv@v10.2.0
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: requirements.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv pip install --system -r requirements.txt
|
||||||
|
|
||||||
|
# -n auto spreads the suite over every runner core (pytest-xdist is in
|
||||||
|
# requirements.txt); pytest-cov merges the workers' coverage, and every
|
||||||
|
# worker loads conftest.py, so the state-file redirects hold in each.
|
||||||
|
# -q cancels pyproject's -v: 1368 test names were most of the log.
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
run: python -m pytest -v
|
run: python -m pytest -n auto -p no:sugar -q
|
||||||
|
|
||||||
# The pytest suite replaces the whole `tk` module so it can never reach a
|
# The pytest suite replaces the whole `tk` module so it can never reach a
|
||||||
# display, which means the lock-screen widget behaviour it guards cannot be
|
# display, which means the lock-screen widget behaviour it guards cannot be
|
||||||
@ -35,17 +53,94 @@ jobs:
|
|||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.x"
|
python-version: "3.x"
|
||||||
|
- uses: astral-sh/setup-uv@v10.2.0
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: requirements.txt
|
||||||
|
|
||||||
|
# No apt step: the runner image already ships Xvfb, and
|
||||||
|
# setup-python's interpreter has its own tkinter (python3-tk is for the
|
||||||
|
# system python, which this job never runs). The apt-get update +
|
||||||
|
# install cost ~35 s. If either ever goes missing the check does not
|
||||||
|
# skip -- _xvfb_reexec returns 1 without Xvfb, and the import fails
|
||||||
|
# without tkinter.
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
uv pip install --system -r requirements.txt
|
||||||
sudo apt-get install -y xvfb python3-tk
|
|
||||||
pip install -r requirements.txt
|
|
||||||
# The script imports screen_locker directly rather than through
|
# The script imports screen_locker directly rather than through
|
||||||
# pytest, which would put the rootdir on sys.path for it.
|
# pytest, which would put the rootdir on sys.path for it.
|
||||||
pip install -e . --no-deps
|
uv pip install --system -e . --no-deps
|
||||||
|
|
||||||
- name: Sport selector survives gatelock's recovery tick
|
- name: Sport selector survives gatelock's recovery tick
|
||||||
# Run as a module (like the screen-fits gate) so scripts/ resolves as a
|
# Run as a module (like the screen-fits gate) so scripts/ resolves as a
|
||||||
# package and the check can import the helpers it was split into.
|
# package and the check can import the helpers it was split into.
|
||||||
run: python3 -m scripts.verify_lock_popup_safety
|
run: python3 -m scripts.verify_lock_popup_safety
|
||||||
|
|
||||||
|
# The two coverage bars below used to run only in the local pre-push hook,
|
||||||
|
# which became a 1-second stamp check on 2026-09-20 (pre-commit's manual
|
||||||
|
# stage holds them now). Nothing else in CI runs the Flutter or web suites,
|
||||||
|
# so these jobs are the only place the 100% bars are enforced on every push.
|
||||||
|
#
|
||||||
|
# The Flutter suite took ~150 s on one runner, nearly all of it compiling 77
|
||||||
|
# test files, so it runs as FLUTTER_SHARDS buckets of whole files in
|
||||||
|
# parallel and flutter-coverage applies the unchanged 100% bar and
|
||||||
|
# every-lib-file-present guard to their union.
|
||||||
|
flutter-shard:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||||
|
name: flutter test (shard ${{ matrix.shard }})
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# The slim cached SDK (utils/flutter-setup): subosito's full-SDK
|
||||||
|
# cache cost ~38 s to restore even on a hit.
|
||||||
|
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
|
||||||
|
with:
|
||||||
|
flutter-version: 3.47.9
|
||||||
|
working-directory: stronglift_replacement/workout_app
|
||||||
|
|
||||||
|
- name: flutter test --coverage, bucket ${{ matrix.shard }}
|
||||||
|
run: bash scripts/check_flutter_coverage.sh --shard "${{ matrix.shard }}/$FLUTTER_SHARDS"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: flutter-lcov-${{ matrix.shard }}
|
||||||
|
path: stronglift_replacement/workout_app/coverage/lcov.info
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
flutter-coverage:
|
||||||
|
needs: flutter-shard
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
pattern: flutter-lcov-*
|
||||||
|
path: lcov-shards
|
||||||
|
|
||||||
|
- name: flutter test --coverage, 100% of lib/ (workout app)
|
||||||
|
run: bash scripts/check_flutter_coverage.sh --merged "$FLUTTER_SHARDS" lcov-shards/*/lcov.info
|
||||||
|
|
||||||
|
web-coverage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
package_json_file: web/package.json
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 26.13.0
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: web/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: vitest --coverage, 100% (status web UI)
|
||||||
|
run: |
|
||||||
|
pnpm --dir web install --frozen-lockfile
|
||||||
|
pnpm --dir web run coverage
|
||||||
|
|||||||
18
.github/workflows/release-apk.yml
vendored
18
.github/workflows/release-apk.yml
vendored
@ -3,6 +3,14 @@ name: release-apk
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
# A publisher, not a gate: build only when something that ends up in
|
||||||
|
# the APK changed. versionCode is the commit count, so skipped pushes
|
||||||
|
# still leave it monotonic.
|
||||||
|
paths:
|
||||||
|
- 'stronglift_replacement/workout_app/**'
|
||||||
|
- '.fvmrc'
|
||||||
|
- '.versioncode-offset'
|
||||||
|
- '.github/workflows/release-apk.yml'
|
||||||
# Lets a release be rebuilt from the current main without a dummy commit.
|
# Lets a release be rebuilt from the current main without a dummy commit.
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@ -47,10 +55,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "17"
|
java-version: "17"
|
||||||
|
# ~/.gradle caches + wrapper, keyed on the Gradle files.
|
||||||
|
cache: gradle
|
||||||
|
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: 3.47.4
|
flutter-version: 3.47.9
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
@ -96,7 +106,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build signed APK
|
- name: Build signed APK
|
||||||
run: |
|
run: |
|
||||||
flutter build apk --release \
|
flutter build apk --release --flavor daily \
|
||||||
--build-name="${{ steps.ver.outputs.semver }}" \
|
--build-name="${{ steps.ver.outputs.semver }}" \
|
||||||
--build-number="${{ steps.vc.outputs.code }}"
|
--build-number="${{ steps.vc.outputs.code }}"
|
||||||
|
|
||||||
@ -106,7 +116,7 @@ jobs:
|
|||||||
- name: Verify the APK is release-signed
|
- name: Verify the APK is release-signed
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
apk=build/app/outputs/flutter-apk/app-release.apk
|
apk=build/app/outputs/flutter-apk/app-daily-release.apk
|
||||||
signer=$(ls -d "$ANDROID_SDK_ROOT"/build-tools/* | sort -V | tail -1)/apksigner
|
signer=$(ls -d "$ANDROID_SDK_ROOT"/build-tools/* | sort -V | tail -1)/apksigner
|
||||||
"$signer" verify --print-certs "$apk" | tee /tmp/certs.txt
|
"$signer" verify --print-certs "$apk" | tee /tmp/certs.txt
|
||||||
if grep -qi 'CN=Android Debug' /tmp/certs.txt; then
|
if grep -qi 'CN=Android Debug' /tmp/certs.txt; then
|
||||||
@ -127,7 +137,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
out="workout-app-${{ steps.ver.outputs.semver }}-build.${{ steps.vc.outputs.code }}.apk"
|
out="workout-app-${{ steps.ver.outputs.semver }}-build.${{ steps.vc.outputs.code }}.apk"
|
||||||
cp build/app/outputs/flutter-apk/app-release.apk "$out"
|
cp build/app/outputs/flutter-apk/app-daily-release.apk "$out"
|
||||||
echo "path=$out" >> "$GITHUB_OUTPUT"
|
echo "path=$out" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Publish the per-commit prerelease
|
- name: Publish the per-commit prerelease
|
||||||
|
|||||||
25
.github/workflows/repo-contract.yml
vendored
Normal file
25
.github/workflows/repo-contract.yml
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
name: repo-contract
|
||||||
|
|
||||||
|
# The local pre-commit hook can be skipped (a fresh clone has no hooks), so the
|
||||||
|
# contract is enforced on push too. Mirrors md-naming.yml.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
repo-contract:
|
||||||
|
name: repo contract
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check out the shared gate
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: kuhyx/utils
|
||||||
|
path: .utils
|
||||||
|
|
||||||
|
- name: Enforce the repo contract
|
||||||
|
run: bash .utils/scripts/check_repo_contract.sh
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@ -19,12 +19,16 @@ htmlcov/
|
|||||||
screen_locker/log.json
|
screen_locker/log.json
|
||||||
# ...and the ledger naming which of its entries record the same workout.
|
# ...and the ledger naming which of its entries record the same workout.
|
||||||
screen_locker/duplicate_credits.json
|
screen_locker/duplicate_credits.json
|
||||||
|
# ...and the sync tombstones the PC re-pushes every tick (names a workout id).
|
||||||
|
screen_locker/sync_tombstones.json
|
||||||
# ...including the timestamped backups a correction script leaves behind.
|
# ...including the timestamped backups a correction script leaves behind.
|
||||||
screen_locker/*.json.bak-*
|
screen_locker/*.json.bak-*
|
||||||
screen_locker/sick_history.json
|
screen_locker/sick_history.json
|
||||||
screen_locker/extra_benefits_state.json
|
screen_locker/extra_benefits_state.json
|
||||||
screen_locker/scheduled_skips.json
|
screen_locker/scheduled_skips.json
|
||||||
screen_locker/shutdown_base.json
|
screen_locker/shutdown_base.json
|
||||||
|
screen_locker/shutdown_base.lock
|
||||||
|
screen_locker/log.lock
|
||||||
screen_locker/sick_day_state.json
|
screen_locker/sick_day_state.json
|
||||||
screen_locker/early_bird_pending.json
|
screen_locker/early_bird_pending.json
|
||||||
|
|
||||||
@ -34,6 +38,11 @@ screen_locker/early_bird_pending.json
|
|||||||
# Web status UI (web/) build and tooling output.
|
# Web status UI (web/) build and tooling output.
|
||||||
web/node_modules/
|
web/node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
|
web/.astro/
|
||||||
web/coverage/
|
web/coverage/
|
||||||
web/node_modules
|
web/node_modules
|
||||||
web/dist
|
web/dist
|
||||||
|
screen_locker/rest_days.json
|
||||||
|
screen_locker/grace_floor.json
|
||||||
|
screen_locker/grace_floor.lock
|
||||||
|
screen_locker/rest_days.lock
|
||||||
|
|||||||
1
.phone-deploy-flavor
Normal file
1
.phone-deploy-flavor
Normal file
@ -0,0 +1 @@
|
|||||||
|
daily
|
||||||
@ -8,14 +8,21 @@ default_language_version:
|
|||||||
default_stages: [pre-commit]
|
default_stages: [pre-commit]
|
||||||
fail_fast: false
|
fail_fast: false
|
||||||
|
|
||||||
|
default_install_hook_types: [pre-commit, pre-push, post-commit, post-merge, post-rewrite]
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.6.0
|
rev: v4.6.0
|
||||||
hooks:
|
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
|
- id: trailing-whitespace
|
||||||
args: [--markdown-linebreak-ext=md]
|
args: [--markdown-linebreak-ext=md]
|
||||||
|
exclude: ^screen_locker/tests/fixtures/run_tcx/
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
exclude: ^screen_locker/tests/fixtures/run_tcx/
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
|
# pnpm-lock.yaml is a two-document stream by design.
|
||||||
|
exclude: ^web/pnpm-lock\.yaml$
|
||||||
- id: check-json
|
- id: check-json
|
||||||
- id: check-toml
|
- id: check-toml
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
@ -36,6 +43,12 @@ repos:
|
|||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
|
- id: repo-contract
|
||||||
|
name: repo contract (CLAUDE.md Commands + test_changed)
|
||||||
|
entry: scripts/check_repo_contract.sh
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$|(^|/)(AndroidManifest\.xml|build\.gradle(\.kts)?|strings\.xml|pubspec\.yaml|repo-contract\.toml|[^/]+\.desktop|assets/icon/[^/]+\.png)$
|
||||||
- id: no-noqa
|
- id: no-noqa
|
||||||
name: Block noqa comments
|
name: Block noqa comments
|
||||||
entry: '(?i)#\s*(noqa|type:\s*ignore)'
|
entry: '(?i)#\s*(noqa|type:\s*ignore)'
|
||||||
@ -56,7 +69,7 @@ repos:
|
|||||||
files: ^(screen_locker/.*\.py|requirements\.txt)$
|
files: ^(screen_locker/.*\.py|requirements\.txt)$
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.15.2
|
rev: v0.17.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
|
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
|
||||||
@ -98,7 +111,10 @@ repos:
|
|||||||
- types-requests>=2.31.0
|
- types-requests>=2.31.0
|
||||||
|
|
||||||
- repo: https://github.com/pylint-dev/pylint
|
- repo: https://github.com/pylint-dev/pylint
|
||||||
rev: v4.0.8
|
# Same release as requirements.txt. No --jobs here: pyproject.toml holds
|
||||||
|
# it at 1, because parallel pylint pickles through dill, which crashes on
|
||||||
|
# Python 3.15 (see the comment on `jobs` there).
|
||||||
|
rev: v4.1.3
|
||||||
hooks:
|
hooks:
|
||||||
# Two hooks, because the three checks below are not defects in a test
|
# Two hooks, because the three checks below are not defects in a test
|
||||||
# suite -- they are how pytest and unit-testing private mixins work --
|
# suite -- they are how pytest and unit-testing private mixins work --
|
||||||
@ -111,34 +127,36 @@ repos:
|
|||||||
# duplicate-code near-identical `with patch(...)` preambles; a test
|
# duplicate-code near-identical `with patch(...)` preambles; a test
|
||||||
# should read top-to-bottom without chasing a fixture.
|
# should read top-to-bottom without chasing a fixture.
|
||||||
# Splitting also removes the old failure mode where the score depended on
|
# Splitting also removes the old failure mode where the score depended on
|
||||||
# what else happened to be staged alongside. See
|
# what else happened to be staged alongside. Both are a hard 10.00.
|
||||||
# todo_fix_pylint_10_prompt.md for raising both to a hard 10.00.
|
|
||||||
- id: pylint
|
- id: pylint
|
||||||
name: pylint (source)
|
name: pylint (source, 10.00)
|
||||||
|
alias: pylint-source
|
||||||
exclude: ^screen_locker/tests/
|
exclude: ^screen_locker/tests/
|
||||||
args:
|
args:
|
||||||
- --rcfile=pyproject.toml
|
- --rcfile=pyproject.toml
|
||||||
- --fail-under=8.0
|
- --fail-under=10
|
||||||
- --jobs=4
|
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- pytest
|
- pytest
|
||||||
# Kept in step with pyproject.toml: pip refuses two different
|
# Kept in step with pyproject.toml: pip refuses two different
|
||||||
# direct URLs for one distribution, so any pin that drifts from
|
# direct URLs for one distribution, so any pin that drifts from
|
||||||
# the manifest becomes an unresolvable hook environment rather
|
# the manifest becomes an unresolvable hook environment rather
|
||||||
# than merely an old one.
|
# than merely an old one.
|
||||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock
|
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
|
||||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
- 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.2.0#subdirectory=freedays
|
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time
|
||||||
|
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||||
|
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
|
||||||
|
# Same pin as workout_app/tools/render_finish_chime.sh installs.
|
||||||
|
- music_theory @ git+https://github.com/kuhyx/utils@music-theory-v0.1.0#subdirectory=music_theory
|
||||||
- mcp>=2,<3
|
- mcp>=2,<3
|
||||||
- pydantic
|
- pydantic
|
||||||
- id: pylint
|
- id: pylint
|
||||||
name: pylint (tests)
|
name: pylint (tests, 10.00)
|
||||||
alias: pylint-tests
|
alias: pylint-tests
|
||||||
files: ^screen_locker/tests/
|
files: ^screen_locker/tests/
|
||||||
args:
|
args:
|
||||||
- --rcfile=pyproject.toml
|
- --rcfile=pyproject.toml
|
||||||
- --fail-under=8.0
|
- --fail-under=10
|
||||||
- --jobs=4
|
|
||||||
- --disable=protected-access,unused-argument,duplicate-code
|
- --disable=protected-access,unused-argument,duplicate-code
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- pytest
|
- pytest
|
||||||
@ -146,9 +164,11 @@ repos:
|
|||||||
# direct URLs for one distribution, so any pin that drifts from
|
# direct URLs for one distribution, so any pin that drifts from
|
||||||
# the manifest becomes an unresolvable hook environment rather
|
# the manifest becomes an unresolvable hook environment rather
|
||||||
# than merely an old one.
|
# than merely an old one.
|
||||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock
|
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
|
||||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
- 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.2.0#subdirectory=freedays
|
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time
|
||||||
|
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||||
|
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
|
||||||
- mcp>=2,<3
|
- mcp>=2,<3
|
||||||
- pydantic
|
- pydantic
|
||||||
|
|
||||||
@ -167,21 +187,67 @@ repos:
|
|||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: ci-mirror
|
# The CI mirror, one step per hook so each call stays under the 60 s
|
||||||
name: CI mirror (clean-venv pytest + pre-commit --all-files)
|
# action cap. Order matters: ci-venv (re)builds .ci-mirror-venv from
|
||||||
entry: bash scripts/ci_mirror.sh
|
# requirements.txt when it changed, so ci-pytest only reuses it.
|
||||||
|
# `pre-commit run --all-files` is split into four ci-precommit-* steps
|
||||||
|
# (each well under the cap); together they run every default-stage hook
|
||||||
|
# exactly once -- see run_precommit_group in scripts/ci_mirror.sh.
|
||||||
|
- id: ci-venv
|
||||||
|
name: CI mirror (clean requirements.txt venv)
|
||||||
|
entry: bash scripts/ci_mirror.sh venv
|
||||||
language: system
|
language: system
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
require_serial: true
|
require_serial: true
|
||||||
stages: [pre-push]
|
stages: [manual]
|
||||||
|
- id: ci-precommit-pylint-source
|
||||||
|
name: "CI mirror (pre-commit --all-files: pylint source)"
|
||||||
|
entry: bash scripts/ci_mirror.sh precommit pylint-source
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
require_serial: true
|
||||||
|
stages: [manual]
|
||||||
|
- id: ci-precommit-pylint-tests
|
||||||
|
name: "CI mirror (pre-commit --all-files: pylint tests)"
|
||||||
|
entry: bash scripts/ci_mirror.sh precommit pylint-tests
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
require_serial: true
|
||||||
|
stages: [manual]
|
||||||
|
- id: ci-precommit-flutter-web
|
||||||
|
name: "CI mirror (pre-commit --all-files: flutter analyze + web lint/typecheck)"
|
||||||
|
entry: bash scripts/ci_mirror.sh precommit flutter-web
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
require_serial: true
|
||||||
|
stages: [manual]
|
||||||
|
- id: ci-precommit-rest
|
||||||
|
name: "CI mirror (pre-commit --all-files: every other hook)"
|
||||||
|
entry: bash scripts/ci_mirror.sh precommit rest
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
require_serial: true
|
||||||
|
stages: [manual]
|
||||||
|
- id: ci-pytest
|
||||||
|
name: CI mirror (clean-venv pytest, xdist)
|
||||||
|
entry: bash scripts/ci_mirror.sh pytest
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
require_serial: true
|
||||||
|
stages: [manual]
|
||||||
|
|
||||||
- repo: https://github.com/codespell-project/codespell
|
- repo: https://github.com/codespell-project/codespell
|
||||||
rev: v2.3.0
|
rev: v2.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: codespell
|
- id: codespell
|
||||||
args:
|
args:
|
||||||
- --skip=*.json,*.lock,.git,__pycache__,.venv
|
- --skip=*.json,*.lock,*pnpm-lock.yaml,.git,__pycache__,.venv
|
||||||
- --ignore-words-list=als,ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile,tabel,pary,blok,bloc,proces,serwer,parametr,adres,hart,dout,metod,tekst,synonim,grup,mosty,lokal,skalar,milion,nowe,tre,hel,alph
|
- --ignore-words-list=als,ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile,tabel,pary,blok,bloc,proces,serwer,parametr,adres,hart,dout,metod,tekst,synonim,grup,mosty,lokal,skalar,milion,nowe,tre,hel,alph
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
@ -208,11 +274,13 @@ repos:
|
|||||||
# the gate it was being refactored to satisfy. ts/tsx joined on
|
# 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
|
# 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.
|
# from this list is a directory the cap silently does not apply to.
|
||||||
types_or: [python, shell, dart, ts, tsx]
|
# c/c++ and markdown joined on 2026-11-01 (the Linux runner had grown
|
||||||
|
# to 258; prose is under the same cap per the 250-line rule).
|
||||||
|
types_or: [python, shell, dart, ts, tsx, c, c++, markdown]
|
||||||
exclude: '^web/(node_modules|dist|coverage)/'
|
exclude: '^web/(node_modules|dist|coverage)/'
|
||||||
- id: no-silent-failures
|
- id: no-silent-failures
|
||||||
name: Block silent failures (swallowed exceptions and silent sys.exit(0) must say why)
|
name: Block silent failures (swallowed exceptions and silent sys.exit(0) must say why)
|
||||||
entry: python3 scripts/check_silent_failures.py
|
entry: python3 -m scripts.check_silent_failures
|
||||||
language: system
|
language: system
|
||||||
types: [python]
|
types: [python]
|
||||||
- id: no-silent-failures-dart
|
- id: no-silent-failures-dart
|
||||||
@ -220,7 +288,7 @@ repos:
|
|||||||
# single Dart file — in the app where a swallowed sync error costs the
|
# single Dart file — in the app where a swallowed sync error costs the
|
||||||
# user a workout with nothing anywhere saying so.
|
# user a workout with nothing anywhere saying so.
|
||||||
name: Block silent failures in Dart (swallowed errors must log why)
|
name: Block silent failures in Dart (swallowed errors must log why)
|
||||||
entry: python3 scripts/check_silent_failures_dart.py
|
entry: python3 -m scripts.check_silent_failures_dart
|
||||||
language: system
|
language: system
|
||||||
types: [dart]
|
types: [dart]
|
||||||
- id: screens-fit
|
- id: screens-fit
|
||||||
@ -230,6 +298,13 @@ repos:
|
|||||||
files: '^screen_locker/.*\.py$'
|
files: '^screen_locker/.*\.py$'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
require_serial: true
|
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
|
- id: flutter-analyze
|
||||||
name: flutter analyze (workout app)
|
name: flutter analyze (workout app)
|
||||||
# env -u GIT_DIR: git exports it to hooks, and flutter shells out to
|
# env -u GIT_DIR: git exports it to hooks, and flutter shells out to
|
||||||
@ -240,40 +315,74 @@ repos:
|
|||||||
language: system
|
language: system
|
||||||
files: '^stronglift_replacement/workout_app/.*\.(dart|yaml)$'
|
files: '^stronglift_replacement/workout_app/.*\.(dart|yaml)$'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
- id: flutter-test-coverage
|
- id: flutter-test-coverage-shard-0
|
||||||
name: flutter test --coverage 100% (workout app)
|
name: flutter test bucket 0/4 (workout app, local gate)
|
||||||
entry: bash scripts/check_flutter_coverage.sh
|
entry: bash scripts/check_flutter_coverage.sh --gate-shard 0
|
||||||
language: system
|
language: system
|
||||||
files: '^stronglift_replacement/workout_app/.*\.dart$'
|
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
require_serial: true
|
require_serial: true
|
||||||
stages: [pre-push]
|
stages: [manual]
|
||||||
|
- id: flutter-test-coverage-shard-1
|
||||||
|
name: flutter test bucket 1/4 (workout app, local gate)
|
||||||
|
entry: bash scripts/check_flutter_coverage.sh --gate-shard 1
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
require_serial: true
|
||||||
|
stages: [manual]
|
||||||
|
- id: flutter-test-coverage-shard-2
|
||||||
|
name: flutter test bucket 2/4 (workout app, local gate)
|
||||||
|
entry: bash scripts/check_flutter_coverage.sh --gate-shard 2
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
require_serial: true
|
||||||
|
stages: [manual]
|
||||||
|
- id: flutter-test-coverage-shard-3
|
||||||
|
name: flutter test bucket 3/4 (workout app, local gate)
|
||||||
|
entry: bash scripts/check_flutter_coverage.sh --gate-shard 3
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
require_serial: true
|
||||||
|
stages: [manual]
|
||||||
|
# Fails unless every shard above passed on THIS tree, then applies the
|
||||||
|
# unchanged 100% bar and every-lib-file guard to their union.
|
||||||
|
- id: flutter-test-coverage-merge
|
||||||
|
name: flutter coverage 100% (union of the 4 buckets)
|
||||||
|
entry: bash scripts/check_flutter_coverage.sh --gate-merge
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
require_serial: true
|
||||||
|
stages: [manual]
|
||||||
|
|
||||||
# web/ gates. steam-backlog-enforcer, which this app was copied from, has
|
# web/ gates. steam-backlog-enforcer, which this app was copied from, has
|
||||||
# only a coverage hook -- its lint and typecheck are unenforced, so both
|
# only a coverage hook -- its lint and typecheck are unenforced, so both
|
||||||
# can rot without anything failing. All three run here.
|
# can rot without anything failing. All three run here.
|
||||||
- id: web-lint
|
- id: web-lint
|
||||||
name: eslint (status web UI)
|
name: eslint (status web UI)
|
||||||
entry: npm --prefix web run lint
|
entry: pnpm --dir web run lint
|
||||||
language: system
|
language: system
|
||||||
files: '^web/(src/.*\.(ts|tsx)|eslint\.config\.js|package\.json)$'
|
files: '^web/(src/.*\.(ts|astro)|eslint\.config\.js|package\.json)$'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
require_serial: true
|
require_serial: true
|
||||||
- id: web-typecheck
|
- id: web-typecheck
|
||||||
name: tsc --build (status web UI)
|
name: astro check + build (status web UI)
|
||||||
entry: npm --prefix web run build
|
entry: pnpm --dir web run build
|
||||||
language: system
|
language: system
|
||||||
files: '^web/(src/.*\.(ts|tsx)|tsconfig.*\.json|vite\.config\.ts)$'
|
files: '^web/(src/.*\.(ts|astro)|tsconfig\.json|astro\.config\.mjs|vitest\.config\.ts)$'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
require_serial: true
|
require_serial: true
|
||||||
- id: web-coverage
|
- id: web-coverage
|
||||||
name: vitest --coverage 100% (status web UI)
|
name: vitest --coverage 100% (status web UI)
|
||||||
entry: npm --prefix web run coverage
|
entry: pnpm --dir web run coverage
|
||||||
language: system
|
language: system
|
||||||
files: '^web/src/'
|
files: '^web/src/'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
require_serial: true
|
require_serial: true
|
||||||
stages: [pre-push]
|
stages: [manual]
|
||||||
|
|
||||||
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
|
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
|
||||||
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
|
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
|
||||||
@ -285,6 +394,30 @@ repos:
|
|||||||
language: system
|
language: system
|
||||||
files: \.(md|markdown)$
|
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
|
# Every dependency on its ecosystem's newest STABLE release, exact-pinned
|
||||||
# so the question is mechanically answerable at all.
|
# so the question is mechanically answerable at all.
|
||||||
#
|
#
|
||||||
@ -306,3 +439,17 @@ repos:
|
|||||||
|Cargo\.(toml|lock)|go\.(mod|sum)
|
|Cargo\.(toml|lock)|go\.(mod|sum)
|
||||||
|\.fvmrc|\.nvmrc|\.python-version
|
|\.fvmrc|\.nvmrc|\.python-version
|
||||||
|\.github/workflows/.*\.ya?ml)$
|
|\.github/workflows/.*\.ya?ml)$
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
# The only pre-push hook. Everything that used to run here is now
|
||||||
|
# `stages: [manual]` -- run on demand with `scripts/check_gate_stamp.sh
|
||||||
|
# full`, and by CI on every push. finish_auto.sh stamps HEAD after its
|
||||||
|
# gate; a tree without that receipt cannot leave the machine.
|
||||||
|
- id: gate-stamp
|
||||||
|
name: pushed tree passed the local gate (stamp check)
|
||||||
|
entry: scripts/check_gate_stamp.sh check
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
stages: [pre-push]
|
||||||
|
|||||||
100
AGENTS.md
100
AGENTS.md
@ -2,7 +2,14 @@ do NOT run tests unless specifically instructed to do so or before committing
|
|||||||
If tests fail on the same issue twice in a row, STOP and ask the user how to proceed instead of continuing to fix and retry.
|
If tests fail on the same issue twice in a row, STOP and ask the user how to proceed instead of continuing to fix and retry.
|
||||||
ALWAYS confirm that the feature you add / bug you fixed behaves as it should by running the program after your changes (not tests!) and inspecting output comparing it with what user wanted, after confirming by yourself ask user if the program behaves as they intended
|
ALWAYS confirm that the feature you add / bug you fixed behaves as it should by running the program after your changes (not tests!) and inspecting output comparing it with what user wanted, after confirming by yourself ask user if the program behaves as they intended
|
||||||
After running tests fix all coverage gaps and issues, do not ignore unless specifically instructed to do so
|
After running tests fix all coverage gaps and issues, do not ignore unless specifically instructed to do so
|
||||||
You are NOT done until you install the new version on the phone itself (flutter install --debug from the workout_app directory, then adb shell monkey -p com.kuhy.workout_app to launch).
|
You are NOT done until you install the new version on the phone itself:
|
||||||
|
`bash ~/.claude/scripts/phone_deploy.sh stronglift_replacement/workout_app`
|
||||||
|
(it picks a safe versionCode and launches; never a bare `flutter install`).
|
||||||
|
To drive a whole workout on the phone without touching real data, deploy the
|
||||||
|
sandbox flavor instead: `... --flavor sandbox`. That is a separate package
|
||||||
|
(`com.kuhy.workout_app.sandbox`) with its own data, no network, no LAN server,
|
||||||
|
a red SANDBOX ribbon, and a SANDBOX section in Settings (wipe, "mark today not
|
||||||
|
done", rest length in seconds). Its trace is `adb logcat -s WorkoutSandbox`.
|
||||||
|
|
||||||
## NEVER fail silently
|
## NEVER fail silently
|
||||||
|
|
||||||
@ -24,3 +31,94 @@ weeks because a missing token `return`ed `[]` and real errors only logged at
|
|||||||
should read like a sentence a human can act on.
|
should read like a sentence a human can act on.
|
||||||
- **No escape hatch** — `# noqa` is banned repo-wide. If a failure really is
|
- **No escape hatch** — `# noqa` is banned repo-wide. If a failure really is
|
||||||
benign, log it at `warning` and move on.
|
benign, log it at `warning` and move on.
|
||||||
|
|
||||||
|
## The shutdown hour is derived, never incremented into
|
||||||
|
|
||||||
|
`/etc/shutdown-schedule.conf` is recomputed from scratch by
|
||||||
|
`_shutdown_base.reset_to_base_if_new_day` on each new day: the base plus
|
||||||
|
every earned term, capped at 23. The numbers are NOT in this repo: they live
|
||||||
|
in the shared earner registry `earned_time` (kuhyx/utils, `earned_time/`),
|
||||||
|
which steam-backlog-enforcer reads for gaming time too. Base = 20:00 minus
|
||||||
|
each earner's cut once its `penalty_from` day arrives (reading, from
|
||||||
|
2026-10-01 -> 19:00); terms = workout 2h + 1h per further session, LeetCode
|
||||||
|
1h, reading 1h, plus whatever is registered later.
|
||||||
|
Anything that pushes the hour with a plain read-add-write and is *not* a term
|
||||||
|
of that derivation gets wiped by the next reset -- that is how a 00:02 workout
|
||||||
|
lost its hours on 2026-09-13. A new bonus source is an `Earner` in the
|
||||||
|
registry, never code here: the reset includes it as a term, and the live pass
|
||||||
|
(`apply_flat_bonuses_if_new`, stamped `<name>_bonus_date` in
|
||||||
|
`shutdown_base.json`) picks it up for flat earners. That pass runs the moment
|
||||||
|
a gate writes its ledger: `earner-bonus.path` (rendered from the registry by
|
||||||
|
`_earner_units`, re-synced by every `--sync-only` pass) starts
|
||||||
|
`earner-bonus.service` (`--apply-bonuses`). The 15-minute `workout-sync` is
|
||||||
|
only the fallback. The reset and the pass hold `_bonus_lock`, since three
|
||||||
|
processes can run them at once. The base is the registry
|
||||||
|
constant, not the state file: the file once persisted `base_*_hour`, which
|
||||||
|
made the code's default dead.
|
||||||
|
|
||||||
|
A sick day is a date in the shared `sickdays` pool, read only through
|
||||||
|
`_sick.py`. It may keep lockers: the workout lock asks
|
||||||
|
`_sick.stands_down_workout` (keep-aware), while everything that only computes
|
||||||
|
shutdown (`_sick_floor`, `_shutdown_base`, `_shutdown_target`, the preview
|
||||||
|
scripts) asks `is_sick` and ignores keep. A sick day's shutdown is absolute:
|
||||||
|
20:00 plus the GAMING pay of everything credited that day, capped at 23:00
|
||||||
|
(`earned_time.sick_shutdown_from`, 0.13). The reset writes it, `_sick_floor`
|
||||||
|
re-derives it on every live pass from the ledgers, and a sick-day credit only
|
||||||
|
raises to it (`_raise_on_sick_day`), never adds -- its row is already in the
|
||||||
|
sum. No lift is saved on a sick day.
|
||||||
|
|
||||||
|
## A shutdown never blocks submitting a finished task
|
||||||
|
|
||||||
|
A credit timed at or after tonight's shutdown moves tonight to
|
||||||
|
`min(23:00, the credit's OWN time + its reward)` (`_late_credit.late_tonight`):
|
||||||
|
only the 23:00 ceiling is hard. The anchor is the credit's own time (the run's
|
||||||
|
end via `_workout_ledger.entry_done_at`, the ledger row via
|
||||||
|
`_earned.first_credit_time`), never the sync time, so a late upload buys
|
||||||
|
nothing. It is live-only: the next day's reset re-derives from scratch. This
|
||||||
|
replaced the first-credit +60 grace floor (gone 2026-10-17); `_grace_floor`
|
||||||
|
keeps only `first_done_at`, `tonight_minutes` and the lift file, which
|
||||||
|
`plan_absorb` spends once if a stale one is left.
|
||||||
|
|
||||||
|
Flat earners are read from their gate's ledger by `earned_time.done_today`
|
||||||
|
via `_earned.py`: HMAC-verified `credit` rows matched by the earner's own
|
||||||
|
rule (LeetCode's `submitted_at`, book-guard's `detail.bonus == "1"` +
|
||||||
|
`ended_at`), and *fail closed* -- an unreadable ledger or key earns nothing,
|
||||||
|
logged. The gates stay read-only; none writes the config.
|
||||||
|
|
||||||
|
The schedule is in minutes after midnight end to end. `/etc/shutdown-schedule.conf`
|
||||||
|
holds authoritative `MON_WED_MINUTES` / `THU_SUN_MINUTES` / `MORNING_END_MINUTES`
|
||||||
|
plus strict-rounded legacy `*_HOUR` copies (evening floor, morning ceil) for old
|
||||||
|
readers; `read_shutdown_config` prefers `*_MINUTES` and falls back to `*_HOUR` x 60.
|
||||||
|
`adjust_shutdown_schedule.sh` takes `HH:MM` (or a legacy bare hour) and the
|
||||||
|
helpers `_earned.hhmm` / `_earned.span` render times and durations, so a
|
||||||
|
30-minute earner is applied exactly.
|
||||||
|
|
||||||
|
## The morning session is the carrot — and the early-bird window is it
|
||||||
|
|
||||||
|
`EarlyBirdMixin._is_early_bird_time` no longer reads a clock: it is open
|
||||||
|
exactly while wake-alarm's signed file grants an exemption (before the
|
||||||
|
alarm, during the session, until 11:00 once it completed in time). The
|
||||||
|
05:00–08:30 window and the extended-to-09:00 reward locked someone who got
|
||||||
|
up at 07:00 and were a second mechanism next to the one that knows whether
|
||||||
|
the user is up (2026-09-20). The pending marker is banked by the
|
||||||
|
`wake_alarm_skip` rung; `early-bird-workout-check.timer` (09:30:30, 11:00:30)
|
||||||
|
re-checks just after the carrot's two possible ends.
|
||||||
|
|
||||||
|
The `wake_alarm_skip` rung reads wake-alarm's HMAC-signed
|
||||||
|
`~/.local/state/wake_alarm/morning_session.json` (`_morning_session.py`), not
|
||||||
|
Firebase: wake-alarm is the only reader, and the previous channel here pointed
|
||||||
|
at a path that stopped existing when the repos split and answered "no skip"
|
||||||
|
for a year without an error. The whole decision is *signature ok, dated today,
|
||||||
|
now < exempt_until*; the 09:30/11:00 cutoffs live in wake-alarm
|
||||||
|
(`DOCS-morning-session-pc.md`) and must not be re-derived here. The enforce
|
||||||
|
path waits up to 30 s for a freshly booted PC's refresher; status paths never
|
||||||
|
wait, and tests pin the retry to 0.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- run: `./run.sh` (locks the screen; never launch on the live display for checks)
|
||||||
|
- test: `python -m pytest -n auto -p no:sugar -q`
|
||||||
|
- test-changed: `scripts/test_changed.sh`
|
||||||
|
- lint: `pre-commit run ruff --all-files`
|
||||||
|
- coverage: `python -m pytest -n auto -p no:sugar -q`
|
||||||
|
- coverage-gaps: `coverage-gaps coverage.lcov`
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Krzysztof Rudnicki
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
68
README.md
68
README.md
@ -1,8 +1,72 @@
|
|||||||
# screen-locker
|
# screen-locker
|
||||||
|
|
||||||
Tkinter/systemd screen locker with workout tracking, sick-day management, and
|
Tkinter/systemd screen locker with workout tracking, shared sick days, and
|
||||||
wake-alarm integration. It enforces a workout cadence by locking the screen
|
wake-alarm integration. It enforces a workout cadence by locking the screen
|
||||||
until a RunnerUp-verified (or explicitly justified) workout is logged.
|
until a verified (RunnerUp, StrongLifts) or budgeted manual workout is logged.
|
||||||
|
|
||||||
|
## Sick days
|
||||||
|
|
||||||
|
A sick day is a date in the fleet-wide [`sickdays`](https://github.com/kuhyx/utils/tree/main/sickdays)
|
||||||
|
pool, nothing else: no budget, countdown, justification or debt. The lock
|
||||||
|
screen's **I'm sick** button (retry screen, no-phone screen and the Tue-Thu
|
||||||
|
prompt) marks today with one tap and unlocks; `sickdays add` or the phone do
|
||||||
|
the same. On a sick day the workout lock stands down,
|
||||||
|
wake-alarm's workday penalty is void, and shutdown is 20:00 plus the gaming
|
||||||
|
pay of everything credited that day, capped at 23:00
|
||||||
|
(`earned_time.sick_shutdown_from`). A day marked after its daily reset is
|
||||||
|
raised to it by the next live pass (`_sick_floor`), and every later credit
|
||||||
|
that day raises it again.
|
||||||
|
|
||||||
|
A credit landing at or after tonight's shutdown is never lost: tonight
|
||||||
|
becomes `min(23:00, the credit's own time + its reward)` (`_late_credit`). `install.sh` migrates the old
|
||||||
|
`sick_history.json` dates into the pool once (`scripts/migrate_sick_history.py`).
|
||||||
|
|
||||||
|
A sick day may *keep* lockers (`sickdays add --keep workout`). A kept workout
|
||||||
|
lock behaves exactly as on a normal day (lock, quota, relaxed-day penalty on
|
||||||
|
that date); only the 20:00 shutdown floor still applies, since it belongs to
|
||||||
|
the day, not to a locker. The one-tap button keeps nothing. The missed-alarm
|
||||||
|
penalty is void on its ring day only if `alarm` is off, and on its penalty day
|
||||||
|
only if `workout` is off. All of it goes through `_sick.stands_down_workout`.
|
||||||
|
|
||||||
|
## Shutdown time
|
||||||
|
|
||||||
|
Every day starts at **19:00** from 2026-10-01, 20:00 before it (`base_minutes()` in
|
||||||
|
`screen_locker/_shutdown_base.py`)
|
||||||
|
and bonuses push it later, capped at 23:00: the first counted workout +2h,
|
||||||
|
each further one +1h, an accepted LeetCode submission that day +1h (flat,
|
||||||
|
read from leetcode-guard's ledger, once per day), and a credited paper-book
|
||||||
|
reading session of 20+ pages / 20+ minutes +1h (flat, read from
|
||||||
|
[book-guard](https://github.com/kuhyx/book-guard)'s ledger, once per day, keyed
|
||||||
|
on when the reading ended). The config is re-derived from
|
||||||
|
those sources on each new day, so nothing has to be "remembered" across reboots.
|
||||||
|
|
||||||
|
A RunnerUp run is credited from its TCX export, found in the WebDAV drop
|
||||||
|
directory RunnerUp uploads to on this machine (`~/data/cloud/RunnerUp/`, plus
|
||||||
|
`processed/` once endurain-import has taken it) or, when a phone is adb-visible,
|
||||||
|
in `/sdcard/Documents/RunnerUp/`. The 15-minute `workout-sync.timer` scans both,
|
||||||
|
so a run finished after 20:00 pushes the hour later without the phone being
|
||||||
|
plugged in; the per-minute shutdown check then lifts the night lockdown on its
|
||||||
|
own once the hour is past "now" (digital-wellbeing's check script).
|
||||||
|
|
||||||
|
Runs and walks are credited by ONE rule, whatever the TCX sport or the
|
||||||
|
`_Walking` file-name suffix (RunnerUp's files and the workout app's alike): a
|
||||||
|
day's activities are summed, and the day counts once their distance reaches
|
||||||
|
3.5 km (5% GPS tolerance) OR their *moving* time reaches the shared duration
|
||||||
|
bar (30 min minus the hidden leeway). Both are summed over the track cut into
|
||||||
|
windows of at least 10 s, each judged by its average speed: faster than 25 km/h
|
||||||
|
(a vehicle or a GPS jump) counts neither time nor distance; moving time needs
|
||||||
|
0.5 m/s or more (1 m/s across a signal dropout longer than 30 s, so a pause
|
||||||
|
earns nothing but a run through a dropout keeps its time). Files without
|
||||||
|
trackpoints earn nothing (the lap totals are never used). The reward is the usual one
|
||||||
|
workout credit per day (`runnerup_verified:<date>`); the log's `activity`
|
||||||
|
field is display only. Code: `screen_locker/_walk_tcx.py`.
|
||||||
|
|
||||||
|
**Retiring RunnerUp.** A week after the PC first credits a run recorded by the
|
||||||
|
workout app (TCX author `kuhy workout_app`), `runnerup-retire.timer` (daily, retries
|
||||||
|
until the phone is attached) backs `/sdcard/Documents/RunnerUp` up to
|
||||||
|
`~/data/cloud/RunnerUp/runnerup_phone_backup_<date>/` and uninstalls `org.runnerup.free`,
|
||||||
|
once the app's `.workout_app_import.json` matches `processed/`; no app run, no uninstall.
|
||||||
|
`python3 -m screen_locker.runnerup_retire --status` / `--dry-run` change nothing.
|
||||||
|
|
||||||
## MCP server (Claude Code integration)
|
## MCP server (Claude Code integration)
|
||||||
|
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
# Refactor TODO — enforce the 250-line file cap
|
|
||||||
|
|
||||||
> **This file is a ready-to-use prompt.** Paste it to Claude, or open this repo
|
|
||||||
> and say "do refactor_claude_todo". It is self-contained: everything needed to
|
|
||||||
> execute is below. Generated 2026-08-14 from a measured survey of every repo.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Every file in this repo must be **at most 250 lines** — source, tests, and
|
|
||||||
prose (`.md`/`.txt`/`.rst`/`.tex`) alike — and must **stay** that way forever,
|
|
||||||
enforced by a gate that fails the commit, not by a note anyone can ignore.
|
|
||||||
|
|
||||||
Why: a file that cannot be read in one piece forces re-reads and partial edits,
|
|
||||||
which is the single largest avoidable cost in an LLM-assisted workflow. Aim by
|
|
||||||
churn, not size alone — refactoring pays where code is read and changed often
|
|
||||||
(Fowler, _refactoring economic benefit_).
|
|
||||||
|
|
||||||
## Scope in this repo
|
|
||||||
|
|
||||||
<!-- BEGIN GENERATED VIOLATIONS -->
|
|
||||||
|
|
||||||
- **10 files** currently exceed 250 lines.
|
|
||||||
- **1,828 lines** over the cap in total (the work left to do); longest file is **740** lines.
|
|
||||||
|
|
||||||
ROI = lines x commits in the last year. Work top-down; a long file nobody edits
|
|
||||||
has near-zero payoff and should not be first.
|
|
||||||
|
|
||||||
| lines | commits/yr | kind | file |
|
|
||||||
| ----: | ---------: | :--- | :--- |
|
|
||||||
| 503 | 13 | code | `stronglift_replacement/workout_app/lib/screens/settings_screen.dart` |
|
|
||||||
| 633 | 9 | code | `stronglift_replacement/workout_app/lib/screens/workout_screen.dart` |
|
|
||||||
| 740 | 7 | code | `stronglift_replacement/workout_app/test/services/workout_sync_service_test.dart` |
|
|
||||||
| 322 | 10 | code | `stronglift_replacement/workout_app/lib/services/workout_sync_service.dart` |
|
|
||||||
| 383 | 7 | code | `stronglift_replacement/workout_app/test/screens/home_screen_test.dart` |
|
|
||||||
| 260 | 9 | code | `stronglift_replacement/workout_app/lib/screens/home_screen.dart` |
|
|
||||||
| 386 | 5 | code | `stronglift_replacement/workout_app/test/screens/history_screen_test.dart` |
|
|
||||||
| 362 | 4 | code | `stronglift_replacement/workout_app/test/screens/workout_screen_test.dart` |
|
|
||||||
| 313 | 2 | code | `stronglift_replacement/workout_app/lib/screens/manual_workout_screen.dart` |
|
|
||||||
| 426 | 1 | code | `stronglift_replacement/workout_app/test/services/progression_sync_service_test.dart` |
|
|
||||||
|
|
||||||
<!-- END GENERATED VIOLATIONS -->
|
|
||||||
|
|
||||||
|
|
||||||
Exempt (do NOT split these):
|
|
||||||
|
|
||||||
- generated files — `*.g.dart`, `*.freezed.dart`, `*.gr.dart`, `**/l10n/generated/**`,
|
|
||||||
anything with a `GENERATED` header
|
|
||||||
- markup — `.html`, `.css`, `.scss`
|
|
||||||
- data files — `.json`, `.yaml`, `.csv`, wordlists and other data-ish `.txt`
|
|
||||||
(mean line length under 25 chars)
|
|
||||||
|
|
||||||
|
|
||||||
## How to split
|
|
||||||
|
|
||||||
- **Python** — extract cohesive helpers into sibling modules; keep the public
|
|
||||||
API and imports stable.
|
|
||||||
- **Shell** — split into `lib/*.sh` sourced by a thin entry script. Keep
|
|
||||||
`set -euo pipefail` in each.
|
|
||||||
- **Dart / TypeScript** — extract widgets/components into their own files.
|
|
||||||
- **Tests** — split by test-group into sibling files
|
|
||||||
(`foo.test.ts` -> `foo.parsing.test.ts`, `foo.render.test.ts`). Coverage must
|
|
||||||
not drop.
|
|
||||||
- **Docs** — split into topic files under `docs/` with an index. For an
|
|
||||||
oversized `CLAUDE.md`, move detail into referenced docs so the
|
|
||||||
always-loaded part shrinks.
|
|
||||||
|
|
||||||
**Do not** game the cap: no one-lining, no deleting tests, no moving code into
|
|
||||||
an exempt extension, no `# noqa`-style suppressions.
|
|
||||||
|
|
||||||
## Make it permanent (required — this is the point)
|
|
||||||
|
|
||||||
A refactor without a gate silently regrows. Before this task is done:
|
|
||||||
|
|
||||||
1. Wire the shared gate `~/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
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
# Finish TODO — the last 10 Dart files over 250 lines
|
|
||||||
|
|
||||||
> **This file is a ready-to-use prompt.** Open this repo and say
|
|
||||||
> "do todo_finish_dart_250_prompt". It is self-contained.
|
|
||||||
> Generated 2026-08-15 at the end of a session that took the repo from 41
|
|
||||||
> files over the cap to 10. All 10 remaining are Dart.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
`bash ~/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
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
# Fix TODO — make pylint a perfect 10.00 gate
|
|
||||||
|
|
||||||
> **This file is a ready-to-use prompt.** Paste it to Claude, or open this repo
|
|
||||||
> and say "do todo_fix_pylint_10_prompt". It is self-contained: everything
|
|
||||||
> needed to execute is below. Generated 2026-08-15 from a measured run.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
`pylint` must score **10.00/10** on this repo, enforced by the pre-commit hook
|
|
||||||
at `--fail-under=10` — not the current `--fail-under=8.0`, which lets real
|
|
||||||
findings through as long as enough clean files dilute them.
|
|
||||||
|
|
||||||
Why the current bar is wrong: pylint's hook scores the **staged batch** as one
|
|
||||||
number. A commit touching many test files fails while the identical code
|
|
||||||
committed alongside a few clean source files passes. The score is a property of
|
|
||||||
*which files you staged*, not of the code. A 10.00 bar removes that entirely —
|
|
||||||
zero findings is zero findings regardless of batching.
|
|
||||||
|
|
||||||
## Measured starting point (2026-08-15)
|
|
||||||
|
|
||||||
Whole repo (`screen_locker/` + `scripts/`): **8.76/10**.
|
|
||||||
|
|
||||||
Findings by category, highest first:
|
|
||||||
|
|
||||||
| count | check | fixable? |
|
|
||||||
| ----: | :--- | :--- |
|
|
||||||
| 473 | `protected-access` (W0212) | no — test-inherent |
|
|
||||||
| 369 | `unused-argument` (W0613) | no — test-inherent |
|
|
||||||
| 245 | `missing-function-docstring` (C0116) | **yes** |
|
|
||||||
| 238 | `suppressed-message` (I0020) | disappears with W0212/W0613 |
|
|
||||||
| 73 | `duplicate-code` (R0801) | no — test-inherent |
|
|
||||||
| 66 | `use-implicit-booleaness-not-comparison-to-zero` (C1805) | **yes** |
|
|
||||||
| 27 | `import-outside-toplevel` (C0415) | **yes**, mostly |
|
|
||||||
| 25 | `missing-class-docstring` (C0115) | **yes** |
|
|
||||||
| 25 | `locally-disabled` (I0011) | disappears with W0212/W0613 |
|
|
||||||
| 24 | `use-implicit-booleaness-not-comparison` (C1803) | **yes** |
|
|
||||||
| 10 | `useless-suppression` (I0021) | **yes** — delete the dead disables |
|
|
||||||
| ~40 | long tail (see "Long tail" below) | **yes** |
|
|
||||||
|
|
||||||
## The decision already taken
|
|
||||||
|
|
||||||
Three checks are **disabled for test paths only**, because they are not defects
|
|
||||||
— they are how pytest works, and fixing them would mean rewriting ~840 call
|
|
||||||
sites and making production APIs public to suit tests:
|
|
||||||
|
|
||||||
- **`protected-access` (W0212)** — tests drive private mixin methods directly
|
|
||||||
(`locker._verify_phone_workout()`). This is deliberate: those methods *are*
|
|
||||||
the unit under test. Ruff already exempts the same thing in tests via
|
|
||||||
`SLF001` in `pyproject.toml`'s per-file-ignores, so pylint disagreeing with
|
|
||||||
ruff is just noise.
|
|
||||||
- **`unused-argument` (W0613)** — pytest fixtures such as `mock_tk` and
|
|
||||||
`mock_sys_exit` must be *requested* in the signature to take effect, but are
|
|
||||||
never referenced in the body. Consuming them with a dummy `assert` to satisfy
|
|
||||||
a linter is strictly worse code.
|
|
||||||
- **`duplicate-code` (R0801)** — near-identical `with patch(...)` preambles
|
|
||||||
across sibling test modules. Deduplicating them into helpers hurts: a test
|
|
||||||
should read top-to-bottom without chasing a shared fixture.
|
|
||||||
|
|
||||||
Everything else gets **fixed for real**. No `# pylint: disable=` lines, no
|
|
||||||
per-line suppressions — this repo bans `# noqa` / `# type: ignore` repo-wide and
|
|
||||||
the same spirit applies here.
|
|
||||||
|
|
||||||
## Steps
|
|
||||||
|
|
||||||
1. **Scope the three disables to tests only.** They must NOT apply to
|
|
||||||
`screen_locker/*.py` or `scripts/*.py`. pylint has no per-path `disable` in
|
|
||||||
`pyproject.toml`, so use one of:
|
|
||||||
- a `screen_locker/tests/.pylintrc` that inherits and adds the disables
|
|
||||||
(pylint picks the nearest rcfile per directory), or
|
|
||||||
- two pre-commit hooks (`pylint-src` with `exclude: ^screen_locker/tests/`,
|
|
||||||
`pylint-tests` with `files: ^screen_locker/tests/`), each `--fail-under=10`.
|
|
||||||
|
|
||||||
Verify the scoping works: introduce a deliberate `protected-access` in a
|
|
||||||
*source* file and confirm it still fails.
|
|
||||||
|
|
||||||
2. **Fix `missing-function-docstring` / `missing-class-docstring` (270).**
|
|
||||||
Docstrings must say what the test *pins down*, not restate its name.
|
|
||||||
Good: `"""An unsigned entry is rejected once a key is available."""`
|
|
||||||
Bad: `"""Test rejected when key available."""`
|
|
||||||
A generator that drafts from the test name lives at
|
|
||||||
`/tmp/.../scratchpad/add_docstrings.py` in the session that wrote this file;
|
|
||||||
it is refactor tooling, not a deliverable — rewrite it if it is gone. Every
|
|
||||||
generated line still needs reading: the draft is a starting point.
|
|
||||||
|
|
||||||
3. **Fix the booleanness checks (94).** `assert x == []` → `assert not x`,
|
|
||||||
`assert n == 0` → `assert not n`, `assert s == ""` → `assert not s`.
|
|
||||||
Careful: only where the value really is a sequence/int/str. `is False` /
|
|
||||||
`is True` assertions on tri-state returns must NOT be collapsed.
|
|
||||||
|
|
||||||
4. **Fix `import-outside-toplevel` (27).** Hoist each to module scope unless it
|
|
||||||
exists to break an import cycle — where it does, that is a real constraint;
|
|
||||||
restructure the modules so the cycle is gone, or leave it and note why.
|
|
||||||
|
|
||||||
5. **Delete `useless-suppression` (10)** — dead `# pylint: disable=` comments
|
|
||||||
that no longer suppress anything.
|
|
||||||
|
|
||||||
6. **Long tail (~40).** `too-many-instance-attributes`, `too-many-arguments`,
|
|
||||||
`too-many-return-statements`, `unspecified-encoding`, `line-too-long`,
|
|
||||||
`comparison-with-callable`, `superfluous-parens`,
|
|
||||||
`pointless-string-statement`, `reimported`, `consider-using-with`,
|
|
||||||
`unreachable`. Fix each on its merits.
|
|
||||||
- `unspecified-encoding` (3) is a genuine latent bug — add `encoding="utf-8"`.
|
|
||||||
- `comparison-with-callable` (3) is often a real bug (comparing a function
|
|
||||||
instead of calling it). Read each one carefully.
|
|
||||||
- `unreachable` (4) is `sys.exit(0)` followed by `return` in
|
|
||||||
`_startup_checks.py` and elsewhere. Those `return`s are **load-bearing
|
|
||||||
under test**: `conftest.py` patches `sys.exit` to a no-op, so without the
|
|
||||||
`return` execution continues into the next branch. Do not delete them
|
|
||||||
blindly — restructure so the intent is expressed without dead code, or
|
|
||||||
leave them and record the reason.
|
|
||||||
- `too-many-ancestors` (1) is `ScreenLocker`'s 19 mixins. Inherent to the
|
|
||||||
design; raise `max-parents` in config rather than collapsing mixins.
|
|
||||||
|
|
||||||
7. **Flip the gate.** Set `--fail-under=10` in `.pre-commit-config.yaml` (both
|
|
||||||
hooks if you split them) and update the hook `name:` to say 10.
|
|
||||||
|
|
||||||
## Done condition
|
|
||||||
|
|
||||||
- `pylint --rcfile=pyproject.toml screen_locker/ scripts/` prints
|
|
||||||
**`rated at 10.00/10`**.
|
|
||||||
- `pre-commit run --all-files` passes.
|
|
||||||
- A deliberately-introduced pylint finding in a **source** file makes
|
|
||||||
`git commit` fail (proves the gate is live and correctly scoped).
|
|
||||||
- `python -m pytest` still green at `fail_under = 100` branch coverage.
|
|
||||||
- No `# pylint: disable=` added anywhere as part of this work.
|
|
||||||
|
|
||||||
## Verify
|
|
||||||
|
|
||||||
Run the suite, then actually run the locker entry point —
|
|
||||||
`python -m screen_locker.screen_lock --status` — because this repo deploys from
|
|
||||||
the working tree, so a broken import is a live outage. Hoisting imports (step 4)
|
|
||||||
is exactly the change that can cause one.
|
|
||||||
|
|
||||||
## Do not
|
|
||||||
|
|
||||||
- Do not lower `--fail-under` below 10 once it is raised.
|
|
||||||
- Do not add per-line `# pylint: disable=` comments to reach the number.
|
|
||||||
- Do not disable W0212/W0613/R0801 globally — tests only. A source file calling
|
|
||||||
another class's private attribute is a real finding.
|
|
||||||
- Do not touch `refactor_claude_todo.md`'s 250-line work; that is a separate,
|
|
||||||
in-progress task with its own prompt file.
|
|
||||||
|
|
||||||
REMOVE ME AFTER FINISH
|
|
||||||
7
arm.sh
7
arm.sh
@ -33,13 +33,16 @@ readonly UNITS=(
|
|||||||
"early-bird-workout-check.timer"
|
"early-bird-workout-check.timer"
|
||||||
"workout-sync.service"
|
"workout-sync.service"
|
||||||
"workout-sync.timer"
|
"workout-sync.timer"
|
||||||
|
"earner-bonus.service"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only the timers get enabled; the service is triggered by them and by login.
|
# Only the timers (and the ledger watch) get enabled; the services are
|
||||||
|
# triggered by them and by login.
|
||||||
readonly TIMERS=(
|
readonly TIMERS=(
|
||||||
"early-bird-workout-check.timer"
|
"early-bird-workout-check.timer"
|
||||||
"workout-locker.timer"
|
"workout-locker.timer"
|
||||||
"workout-sync.timer"
|
"workout-sync.timer"
|
||||||
|
"earner-bonus.path"
|
||||||
)
|
)
|
||||||
|
|
||||||
SKIP_TODAY=0
|
SKIP_TODAY=0
|
||||||
@ -87,6 +90,8 @@ main() {
|
|||||||
for unit in "${UNITS[@]}"; do
|
for unit in "${UNITS[@]}"; do
|
||||||
cp "$SCRIPT_DIR/$unit" "$USER_UNIT_DIR/$unit"
|
cp "$SCRIPT_DIR/$unit" "$USER_UNIT_DIR/$unit"
|
||||||
done
|
done
|
||||||
|
# Rendered from the earned_time registry rather than copied.
|
||||||
|
PYTHONPATH="$SCRIPT_DIR" python3 -m screen_locker._earner_units "$USER_UNIT_DIR"
|
||||||
|
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
|
|
||||||
|
|||||||
6
contracts/workout_poke_v1.json
Normal file
6
contracts/workout_poke_v1.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Shared literal fixture for the phone->PC workout poke (v1). Both the Python receiver and the Dart sender test against THESE bytes. body_utf8 is the exact request body; signature = hex(HMAC-SHA256(bytes.fromhex(key_hex), body_utf8.encode('utf-8'))). Never re-serialize before verifying.",
|
||||||
|
"key_hex": "7f3c9a1e5b2d4f6081a3c5e7092b4d6f8a1c3e5072946b8d0f2a4c6e8013579b",
|
||||||
|
"body_utf8": "{\"v\":1,\"sent_at_ms\":1791558449000,\"nonce\":\"0b1e6c2a-9f4d-4c1b-8e7a-3d5f6a7b8c9d\",\"sandbox\":false,\"record_id\":\"2026-10-09T15:10:40.953\",\"payload\":{\"date\":\"2026-10-09\",\"start_time\":\"2026-10-09T15:10:40.953\",\"workout_type\":\"A\",\"duration_seconds\":7020.5,\"succeeded\":true,\"exercises\":[{\"name\":\"Squat\",\"weight_kg\":100.0,\"sets\":[5,5,5,5,5]}],\"note\":\"zażółć gęślą jaźń\"}}",
|
||||||
|
"signature_hex": "98b0b80fe717079c800e79562f669fd0189d89c3578a5bbf973761a0e7a3a3f5"
|
||||||
|
}
|
||||||
76
docs/DOCS-workout-poke-contract.md
Normal file
76
docs/DOCS-workout-poke-contract.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Workout poke: phone -> PC wire contract (v1)
|
||||||
|
|
||||||
|
The phone sends a finished StrongLifts session straight to the PC over the
|
||||||
|
home LAN, so the credit (shutdown time, gaming budget, notification) lands in
|
||||||
|
under a second instead of waiting for the GitHub/Firebase round trip. The
|
||||||
|
normal sync push still runs in parallel; the PC dedups by record id, so a
|
||||||
|
session that arrives both ways is credited once.
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
- `POST http://<pc-host>:8774/v1/workout`, `Content-Type: application/json`.
|
||||||
|
(8773 until 2026-10-10: that is book-guard's desktop wrapper's port.)
|
||||||
|
- `<pc-host>` is a phone setting, default `192.168.1.43` (static on the PC).
|
||||||
|
LAN only: the PC firewall accepts tcp/8774 from `192.168.1.0/24` and nothing
|
||||||
|
else. No WireGuard. Off the home LAN the poke fails fast and the regular
|
||||||
|
sync is the only path.
|
||||||
|
- Header `X-Poke-Signature: <hex HMAC-SHA256(key, raw body bytes)>`.
|
||||||
|
The PC verifies the MAC over the bytes **as received** and only then parses
|
||||||
|
them. Neither side may re-serialize JSON to compute or check the MAC.
|
||||||
|
- Key: 32 random bytes, hex, PC copy at `~/.config/workout_poke/key`
|
||||||
|
(mode 600); phone copy in the Android keystore, put there by
|
||||||
|
`scripts/pair_phone.sh` over adb.
|
||||||
|
|
||||||
|
## Request body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"v":1,"sent_at_ms":<int epoch ms>,"nonce":"<uuid4>","sandbox":<bool>,
|
||||||
|
"record_id":"<sync record id>","payload":{<session exactly as synced>}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `payload` is the same map the phone puts in its sync log for the session;
|
||||||
|
the PC feeds `(record_id, payload)` to `ingest_session_records`, which
|
||||||
|
re-validates it (never trusts `succeeded`).
|
||||||
|
- `sandbox` is inside the signed body. A sandbox request is verified, timed
|
||||||
|
and answered, but **never written anywhere** and never credited.
|
||||||
|
|
||||||
|
## PC checks, in order (each rejection is logged at warning)
|
||||||
|
|
||||||
|
1. Signature matches -> else `401`.
|
||||||
|
2. `|now - sent_at_ms| <= 120 s` -> else `401` (replay window).
|
||||||
|
3. `nonce` not seen within the window -> else `409`.
|
||||||
|
4. `v == 1` and fields present -> else `400`.
|
||||||
|
|
||||||
|
## Response (always JSON)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"ok":<bool>,"credited":<bool>,"duplicate":<bool>,"sandbox":<bool>,
|
||||||
|
"shutdown":"HH:MM"|null,"gaming_budget_minutes":<int|null>,"pc_ms":<int>,
|
||||||
|
"reason":"<sentence a human can act on>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
The phone must treat `shutdown` and `gaming_budget_minutes` as nullable.
|
||||||
|
|
||||||
|
Outcomes of a verified (200) request:
|
||||||
|
|
||||||
|
| case | ok | credited | duplicate |
|
||||||
|
|---|---|---|---|
|
||||||
|
| newly credited | true | true | false |
|
||||||
|
| already in `log.json` (e.g. the sync got there first) | true | false | true |
|
||||||
|
| logged, but today's credit slot was already paid by another copy of the same workout | true | false | false |
|
||||||
|
| signed but does not count (too short, no date): `reason` says why | false | false | false |
|
||||||
|
| sandbox | true | false | false |
|
||||||
|
|
||||||
|
Refusals are never 200. Besides the four checks above, the PC may answer
|
||||||
|
`404` (wrong path), `405` (not POST), `411` (no Content-Length), `413` (body
|
||||||
|
over 64 KiB) and `500` (internal error; the regular sync still credits). All
|
||||||
|
of them carry the same JSON shape with `ok:false` and `shutdown` and
|
||||||
|
`gaming_budget_minutes` both `null`. The PC answers a `413` without reading
|
||||||
|
the body, so the phone may see a connection reset instead of the reply.
|
||||||
|
|
||||||
|
## Shared fixture
|
||||||
|
|
||||||
|
`contracts/workout_poke_v1.json` holds a literal key, body and expected
|
||||||
|
signature (with a float and non-ASCII text, the usual cross-language traps).
|
||||||
|
The Python receiver and the Dart sender both test against it, so a drift on
|
||||||
|
either side fails that side's tests.
|
||||||
@ -1,126 +0,0 @@
|
|||||||
# Task: replace blind coordinate tapping with element-targeted Android UI automation
|
|
||||||
|
|
||||||
> **STATUS 2026-08-10: BUILT.** The tool now exists at
|
|
||||||
> `~/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
|
|
||||||
@ -1,464 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,686 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,686 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
**Superseded (2026-10-09): screen-locker sync is Firebase RTDB only.** The
|
||||||
|
phone stopped writing the GitHub repo on 2026-08-21, and from 2026-10-09 the
|
||||||
|
PC does not use it either: the push, `sync_client()` and
|
||||||
|
`scripts/tombstone_sync_record.py` all use `firebase_client_for("screen_locker")`,
|
||||||
|
and the sync_token / GitHub client / mirror code paths are deleted.
|
||||||
|
`kuhyx/syncs/screen-locker-sync` stays as a frozen archive that no code reads
|
||||||
|
and nothing deletes. The rest of this document is the historical design record.
|
||||||
|
|
||||||
**Blocked on Workstream D** (2026-07-04 user decision — see "Sequencing
|
**Blocked on Workstream D** (2026-07-04 user decision — see "Sequencing
|
||||||
decision" below). No code has been written for this workstream. The design
|
decision" below). No code has been written for this workstream. The design
|
||||||
questions below (sync pattern, repo name, scope) have been pre-decided so the
|
questions below (sync pattern, repo name, scope) have been pre-decided so the
|
||||||
|
|||||||
@ -10,14 +10,17 @@ Description=Re-check workout after early bird grace period expires (08:30 normal
|
|||||||
# After=graphical-session.target, which is where that ordering belongs.
|
# After=graphical-session.target, which is where that ordering belongs.
|
||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
# Fires at 08:30 for the normal early-bird window (5:00–8:30).
|
# The early-bird window is the wake-alarm morning-session carrot since
|
||||||
# Also fires at 09:05 to catch the extended window (5:00–9:00) earned by 5+ workout weeks.
|
# 2026-09-20 (screen_locker/_early_bird.py): it ends at the signed
|
||||||
# Both runs are idempotent: the service handles "already logged" and "window still active".
|
# exempt_until -- 11:00 after a completed morning, min(start+90 min, 09:30)
|
||||||
OnCalendar=*-*-* 08:30:00
|
# after an aborted one, alarm+15 min when no session started. These runs land
|
||||||
OnCalendar=*-*-* 09:05:00
|
# just after the two boundaries the 5-minute tick could miss by up to five
|
||||||
|
# minutes; every run is idempotent ("already logged", "carrot still in force").
|
||||||
|
OnCalendar=*-*-* 09:30:30
|
||||||
|
OnCalendar=*-*-* 11:00:30
|
||||||
Unit=workout-locker.service
|
Unit=workout-locker.service
|
||||||
# Persistent=true: a machine asleep or off at 08:30 would otherwise skip the
|
# Persistent=true: a machine asleep or off at a boundary would otherwise skip
|
||||||
# re-check entirely, leaving the early-bird marker banked and the day
|
# the re-check entirely, leaving the early-bird marker banked and the day
|
||||||
# unenforced. Both runs are idempotent (see above), so a catch-up run is safe.
|
# unenforced. Both runs are idempotent (see above), so a catch-up run is safe.
|
||||||
Persistent=true
|
Persistent=true
|
||||||
AccuracySec=1s
|
AccuracySec=1s
|
||||||
|
|||||||
11
earner-bonus.service
Normal file
11
earner-bonus.service
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Apply newly earned flat shutdown bonuses (LeetCode, reading, Anki, ...)
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# Started by earner-bonus.path the moment a gate writes its ledger. Only the
|
||||||
|
# flat-bonus pass: no phone sync, no Tk, no lock screen, so a solve moves the
|
||||||
|
# shutdown time within seconds instead of at the next 15-minute workout-sync.
|
||||||
|
# The path unit itself is generated from the earned_time registry
|
||||||
|
# (screen_locker._earner_units), so it is not a file in this repo.
|
||||||
|
ExecStart=/usr/bin/python3 -m screen_locker.screen_lock --apply-bonuses
|
||||||
10
install.sh
10
install.sh
@ -138,6 +138,16 @@ if [[ -d "$GATELOCK_DIR" ]]; then
|
|||||||
fi
|
fi
|
||||||
echo " ok: screen_locker and gatelock both import under $PROD_PYTHON"
|
echo " ok: screen_locker and gatelock both import under $PROD_PYTHON"
|
||||||
|
|
||||||
|
# One-shot, idempotent: the old sick_history.json's dates move into the shared
|
||||||
|
# sickdays pool and the file is removed once they read back. Rerunning (or a
|
||||||
|
# machine that never had the file) is a no-op. A failure keeps the file and
|
||||||
|
# is retried by the next install; it does not block the locker itself.
|
||||||
|
echo "Migrating the legacy sick history into the sickdays pool..."
|
||||||
|
if ! "$PROD_PYTHON" "$REPO_DIR/scripts/migrate_sick_history.py"; then
|
||||||
|
echo " WARNING: sick-history migration failed (file kept); rerun" >&2
|
||||||
|
echo " $REPO_DIR/scripts/migrate_sick_history.py" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "=== Done ==="
|
echo "=== Done ==="
|
||||||
echo "The locker is live. Check when it next fires with:"
|
echo "The locker is live. Check when it next fires with:"
|
||||||
|
|||||||
@ -13,6 +13,12 @@ EARLY_BIRD_TIMER_NAME="early-bird-workout-check.timer"
|
|||||||
LOCKER_TIMER_NAME="workout-locker.timer"
|
LOCKER_TIMER_NAME="workout-locker.timer"
|
||||||
SYNC_SERVICE_NAME="workout-sync.service"
|
SYNC_SERVICE_NAME="workout-sync.service"
|
||||||
SYNC_TIMER_NAME="workout-sync.timer"
|
SYNC_TIMER_NAME="workout-sync.timer"
|
||||||
|
BONUS_SERVICE_NAME="earner-bonus.service"
|
||||||
|
BONUS_PATH_NAME="earner-bonus.path"
|
||||||
|
RUNNERUP_WATCH_NAME="runnerup-watch.service"
|
||||||
|
WORKOUT_POKE_NAME="workout-poke.service"
|
||||||
|
RETIRE_SERVICE_NAME="runnerup-retire.service"
|
||||||
|
RETIRE_TIMER_NAME="runnerup-retire.timer"
|
||||||
|
|
||||||
# Runtime dependencies. screen_lock.py imports tkinter at module scope. On Arch
|
# Runtime dependencies. screen_lock.py imports tkinter at module scope. On Arch
|
||||||
# the tkinter MODULE ships inside the `python` package, but the shared library
|
# the tkinter MODULE ships inside the `python` package, but the shared library
|
||||||
@ -35,6 +41,20 @@ ensure_runtime_deps() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# inotifywait drives runnerup-watch.service (instant RunnerUp credit).
|
||||||
|
if ! command -v inotifywait >/dev/null 2>&1; then
|
||||||
|
if command -v pacman >/dev/null 2>&1; then
|
||||||
|
echo "Installing missing system dependency: inotify-tools"
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
pacman -S --needed --noconfirm inotify-tools
|
||||||
|
else
|
||||||
|
sudo pacman -S --needed --noconfirm inotify-tools
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "WARNING: inotifywait missing and pacman unavailable - RunnerUp runs are credited only by the 15-min timer" >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# The two shared RUNTIME dependencies, both pinned git deps in
|
# The two shared RUNTIME dependencies, both pinned git deps in
|
||||||
# pyproject.toml: gatelock (lock-window/HMAC backend) and crdt-sync (the
|
# pyproject.toml: gatelock (lock-window/HMAC backend) and crdt-sync (the
|
||||||
# workout-sync transport). Nothing here used to install either, so a fresh
|
# workout-sync transport). Nothing here used to install either, so a fresh
|
||||||
@ -53,9 +73,9 @@ ensure_runtime_deps() {
|
|||||||
echo "WARNING: could not install $1 automatically" >&2
|
echo "WARNING: could not install $1 automatically" >&2
|
||||||
}
|
}
|
||||||
_pip_runtime_dep gatelock \
|
_pip_runtime_dep gatelock \
|
||||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock"
|
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.0#subdirectory=gatelock"
|
||||||
_pip_runtime_dep crdt_sync \
|
_pip_runtime_dep crdt_sync \
|
||||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.0#subdirectory=crdt-sync"
|
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync"
|
||||||
}
|
}
|
||||||
ensure_runtime_deps
|
ensure_runtime_deps
|
||||||
|
|
||||||
@ -88,11 +108,12 @@ if systemctl --user is-active "workout-locker.timer" &>/dev/null; then
|
|||||||
fi
|
fi
|
||||||
rm -f "$USER_SERVICE_DIR/workout-locker.timer"
|
rm -f "$USER_SERVICE_DIR/workout-locker.timer"
|
||||||
|
|
||||||
# Seed shutdown_base.json with base=21 if not already present
|
# Seed shutdown_base.json if not already present. It holds only date stamps;
|
||||||
|
# the base hour itself comes from the earned_time registry (kuhyx/utils).
|
||||||
SHUTDOWN_BASE="$SCRIPT_DIR/screen_locker/shutdown_base.json"
|
SHUTDOWN_BASE="$SCRIPT_DIR/screen_locker/shutdown_base.json"
|
||||||
if [[ ! -f "$SHUTDOWN_BASE" ]]; then
|
if [[ ! -f "$SHUTDOWN_BASE" ]]; then
|
||||||
printf '{\n "base_mon_wed_hour": 21,\n "base_thu_sun_hour": 21,\n "last_reset_date": ""\n}\n' > "$SHUTDOWN_BASE"
|
printf '{\n "last_reset_date": ""\n}\n' > "$SHUTDOWN_BASE"
|
||||||
echo "✓ Created shutdown_base.json with base=21:00"
|
echo "✓ Created shutdown_base.json"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Copy service file to user systemd directory
|
# Copy service file to user systemd directory
|
||||||
@ -112,6 +133,28 @@ cp "$LOCKER_TIMER_FILE" "$USER_SERVICE_DIR/$LOCKER_TIMER_NAME"
|
|||||||
cp "$SYNC_SERVICE_FILE" "$USER_SERVICE_DIR/$SYNC_SERVICE_NAME"
|
cp "$SYNC_SERVICE_FILE" "$USER_SERVICE_DIR/$SYNC_SERVICE_NAME"
|
||||||
cp "$SYNC_TIMER_FILE" "$USER_SERVICE_DIR/$SYNC_TIMER_NAME"
|
cp "$SYNC_TIMER_FILE" "$USER_SERVICE_DIR/$SYNC_TIMER_NAME"
|
||||||
|
|
||||||
|
# The ledger watch: a gate writing its ledger applies the earned shutdown
|
||||||
|
# bonus at once instead of at the next 15-minute sync. The .path unit is
|
||||||
|
# rendered from the earned_time registry, so it is generated, not copied.
|
||||||
|
cp "$SCRIPT_DIR/$BONUS_SERVICE_NAME" "$USER_SERVICE_DIR/$BONUS_SERVICE_NAME"
|
||||||
|
PYTHONPATH="$SCRIPT_DIR" python3 -m screen_locker._earner_units "$USER_SERVICE_DIR"
|
||||||
|
|
||||||
|
# The RunnerUp upload watch: a TCX landing in ~/data/cloud/RunnerUp starts
|
||||||
|
# workout-sync at once instead of waiting for the next timer tick.
|
||||||
|
cp "$SCRIPT_DIR/$RUNNERUP_WATCH_NAME" "$USER_SERVICE_DIR/$RUNNERUP_WATCH_NAME"
|
||||||
|
|
||||||
|
# The phone's LAN poke: a finished StrongLifts session is credited within a
|
||||||
|
# second instead of at the next sync. The key must exist before the unit
|
||||||
|
# starts, or it refuses to (docs/DOCS-workout-poke-contract.md).
|
||||||
|
bash "$SCRIPT_DIR/scripts/workout_poke_keygen.sh"
|
||||||
|
cp "$SCRIPT_DIR/$WORKOUT_POKE_NAME" "$USER_SERVICE_DIR/$WORKOUT_POKE_NAME"
|
||||||
|
|
||||||
|
# The RunnerUp retirement: a week after the workout app's first credited run
|
||||||
|
# it backs the old app's exports up and uninstalls it from the phone. A daily
|
||||||
|
# timer, because the phone is not always attached; a no-op until then.
|
||||||
|
cp "$SCRIPT_DIR/$RETIRE_SERVICE_NAME" "$USER_SERVICE_DIR/$RETIRE_SERVICE_NAME"
|
||||||
|
cp "$SCRIPT_DIR/$RETIRE_TIMER_NAME" "$USER_SERVICE_DIR/$RETIRE_TIMER_NAME"
|
||||||
|
|
||||||
# Update paths in the service file to use absolute paths
|
# Update paths in the service file to use absolute paths
|
||||||
REPO_ROOT="$SCRIPT_DIR"
|
REPO_ROOT="$SCRIPT_DIR"
|
||||||
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
||||||
@ -124,14 +167,17 @@ systemctl --user daemon-reload
|
|||||||
# Enable the service to start on login (one-shot, no periodic timer)
|
# Enable the service to start on login (one-shot, no periodic timer)
|
||||||
systemctl --user enable "$SERVICE_NAME"
|
systemctl --user enable "$SERVICE_NAME"
|
||||||
|
|
||||||
# Enable the early bird re-check timer
|
# Timers: early bird re-check, periodic re-check, workout sync, the instant
|
||||||
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME"
|
# bonus pass on ledger writes, and the daily RunnerUp retirement retry
|
||||||
|
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME" "$LOCKER_TIMER_NAME" \
|
||||||
|
"$SYNC_TIMER_NAME" "$BONUS_PATH_NAME" "$RETIRE_TIMER_NAME"
|
||||||
|
|
||||||
# Enable the periodic re-check timer
|
# Instant RunnerUp credit on upload, and the LAN workout poke listener
|
||||||
systemctl --user enable --now "$LOCKER_TIMER_NAME"
|
# (restart picks up new code)
|
||||||
|
for unit in "$RUNNERUP_WATCH_NAME" "$WORKOUT_POKE_NAME"; do
|
||||||
# Enable the periodic workout sync
|
systemctl --user enable "$unit"
|
||||||
systemctl --user enable --now "$SYNC_TIMER_NAME"
|
systemctl --user restart "$unit"
|
||||||
|
done
|
||||||
|
|
||||||
# Verify enforcement is actually armed. enable can silently no-op when systemd
|
# 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
|
# breaks an ordering cycle by deleting the timer's job -- exactly what happened
|
||||||
|
|||||||
@ -1,208 +0,0 @@
|
|||||||
> **This file is a ready-to-use prompt.** Open this repo and say
|
|
||||||
> "do dopamine-ux-04-screen-locker". It is self-contained -- it needs no context from
|
|
||||||
> any other session.
|
|
||||||
>
|
|
||||||
> Canonical copy: `~/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`, `bonus_hours_this_week` and `early_bird_extended` fields, filled by
|
|
||||||
`_status_data.py` ~:210-214. **Grep confirms no renderer reads them.**
|
|
||||||
- `_extra_benefits.py` `process_week_transition()` (~:82, reward strings built at
|
|
||||||
~:130-139) produces fully-formed celebratory sentences — and
|
|
||||||
`_startup_checks.py` ~:57-58 sends them to `_logger.info` and nowhere else.
|
|
||||||
- The one success moment that exists is on the **lock screen**:
|
|
||||||
`_unlock_view.py` `unlock_screen()` (~:25) paints "Great job! 💪" and
|
|
||||||
`🔥 {streak}-week streak`, for `_UNLOCK_DELAY_MS = 1500` before routing away.
|
|
||||||
|
|
||||||
So the surface the user visits **voluntarily** — the status window, opened from
|
|
||||||
the tray icon — shows enforcement and budget depletion, and no evidence of
|
|
||||||
success at all. This is the cheapest high-value change in the whole project:
|
|
||||||
the data already exists, is already true, and needs no new state.
|
|
||||||
|
|
||||||
## where
|
|
||||||
|
|
||||||
Repo: `~/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()` ~:154,
|
|
||||||
`weekly_shutdown_bonus_hours()` ~:159, `has_extended_early_bird()` ~:171.
|
|
||||||
- `screen_locker/_startup_checks.py` ~:57-58 — where the reward strings are
|
|
||||||
currently discarded into a log call.
|
|
||||||
- `screen_locker/_unlock_view.py` (72 lines, lots of headroom) — the existing
|
|
||||||
success moment, if you extend it.
|
|
||||||
|
|
||||||
## must
|
|
||||||
|
|
||||||
- **Render the already-populated fields.** Add `_section_streak` to
|
|
||||||
`_status_sections.py` showing the weekly streak, this week's bonus hours, and
|
|
||||||
the extended-early-bird state, and call it from `status_view.py`'s `render()`.
|
|
||||||
Zero new state, zero conftest change, zero new dependency.
|
|
||||||
- **Stop discarding the reward strings.** `process_week_transition()` returns
|
|
||||||
human-readable celebratory sentences that currently only reach the log. Surface
|
|
||||||
them where the user will actually see them — the status window is the obvious
|
|
||||||
home; a milestone is worth more than a silent counter increment.
|
|
||||||
- Match the existing positive-status styling rather than inventing one:
|
|
||||||
`_status_sections.py` ~:73-78 already renders `"{n} above the weekly minimum!"`
|
|
||||||
in success green, and uses `✓` marks at ~:36 and ~:57.
|
|
||||||
- Keep the streak **true**. It counts real ISO weeks with ≥5 counted workouts
|
|
||||||
(`_weekly_check.py`: `WEEKLY_WORKOUT_MINIMUM = 5`, `COUNTED_WORKOUT_TYPES =
|
|
||||||
{phone_verified, runnerup_verified, manual_workout}`). Do not round, pad, or
|
|
||||||
"encourage" the number.
|
|
||||||
|
|
||||||
- must not: **make any reward reduce enforcement.** This is recorded twice in the
|
|
||||||
repo as a deliberate decision:
|
|
||||||
> `_startup_checks.py` ~:88-93 — "there is no banked 'skip a workout' credit —
|
|
||||||
> that mechanic works against the goal of maximizing weekly workouts, so it was
|
|
||||||
> removed in favor of a shutdown-time-only reward"
|
|
||||||
> `_extra_benefits.py` ~:5-8 — "This never reduces enforcement … it only grants
|
|
||||||
> extra comfort time on top of a floor you still have to earn each day."
|
|
||||||
|
|
||||||
Every reward here is **display or comfort-time only**. Do not reintroduce a
|
|
||||||
banked skip, a streak freeze, a grace day, or any mechanic that lowers the
|
|
||||||
floor. A dopamine loop that lets you skip the workout defeats the app.
|
|
||||||
- must not: add **sound** to any enforcement or lock path. screen-locker is a
|
|
||||||
self-restriction tool; a chime on lock is punishment audio, and a chime on
|
|
||||||
unlock trains the wrong association. This repo stays silent. (Sound is
|
|
||||||
opt-out-on elsewhere in the fleet; here it is simply absent.)
|
|
||||||
- must not: add a write tool to the MCP server. `_mcp.py` ~:14-26 states it is
|
|
||||||
read-only by design. Adding `streak` to the existing `get_status` output is
|
|
||||||
fine; a new action tool violates a stated invariant.
|
|
||||||
- must not: add `# noqa` (banned repo-wide) or a silent `except`
|
|
||||||
(`scripts/check_silent_failures.py` — every handler re-raises or logs at
|
|
||||||
warning or above).
|
|
||||||
- must not: create a new on-disk state file unless genuinely unavoidable. If you
|
|
||||||
do, you **must** add it to `_ISOLATED_STATE` in
|
|
||||||
`screen_locker/tests/conftest.py` (~:134), listing *every* module that binds
|
|
||||||
the path at import time — a missed binding lets tests write to kuhy's real
|
|
||||||
state. Read the comment at ~:131-133 and the fixture docstring at ~:170-175.
|
|
||||||
|
|
||||||
- optional: a daily streak alongside the weekly one. Note this **would** be new
|
|
||||||
state (the existing streak is consecutive-*weeks*), so it triggers the conftest
|
|
||||||
requirement above. diet-guard's consecutive-*day* streak
|
|
||||||
(`~/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, bonus hours and early-bird state — with the real values
|
|
||||||
from `extra_benefits_state.json`, not placeholders.
|
|
||||||
2. A week-transition milestone reaches the UI instead of only `_logger.info`.
|
|
||||||
3. `cd ~/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 `streak`/`bonus_hours` (`_status.py`
|
|
||||||
~:138-141), so it is your ground truth: the window must show the **same numbers**.
|
|
||||||
If they disagree, the renderer is wrong, not the data.
|
|
||||||
|
|
||||||
Current real state for comparison: `screen_locker/extra_benefits_state.json` and
|
|
||||||
`screen_locker/log.json` (12 days of entries, 2026-07-12 → 2026-08-15).
|
|
||||||
|
|
||||||
## read first
|
|
||||||
|
|
||||||
- `screen_locker/_status_sections.py` — every existing `_section_*`. Match their
|
|
||||||
signature and style; note the success-green precedent at ~:73-78.
|
|
||||||
- `screen_locker/_status_types.py` ~:88-101 — the three fields you are rendering.
|
|
||||||
- `screen_locker/_extra_benefits.py` — especially the module docstring ~:5-8
|
|
||||||
(the enforcement invariant) and the reward strings ~:130-139.
|
|
||||||
- `screen_locker/_unlock_view.py` — the existing celebration, for tone.
|
|
||||||
- `screen_locker/status_view.py` — check its line count **before** editing.
|
|
||||||
- `~/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`, `weekly_shutdown_bonus_hours`,
|
|
||||||
`extended_early_bird_iso_weeks`).
|
|
||||||
- Thresholds live in `_extra_benefits.py`: `_MILESTONE_INTERVAL = 4` (4-week
|
|
||||||
milestone), `_BONUS_THRESHOLD = 5` (workouts per week).
|
|
||||||
- **There is no config system** — no argparse, no env vars, no config file. Every
|
|
||||||
tunable is a module-level constant. An opt-out toggle has no existing home;
|
|
||||||
for a self-restriction tool, requiring a code edit to disable is arguably
|
|
||||||
correct, so prefer a constant in `_constants.py` over new state.
|
|
||||||
- `~/gatelock` is **not** a repo — it is `~/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
|
|
||||||
@ -1,232 +0,0 @@
|
|||||||
> **This file is a ready-to-use prompt.** Open this repo and say
|
|
||||||
> "do dopamine-ux-05-workout-app". It is self-contained -- it needs no context from
|
|
||||||
> any other session.
|
|
||||||
>
|
|
||||||
> Canonical copy: `~/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
|
|
||||||
@ -1,15 +1,18 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "screen-locker"
|
name = "screen-locker"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration"
|
description = "Tkinter/systemd screen locker with workout tracking, shared sick days, and wake-alarm integration"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
# gatelock: shared lock-window/HMAC backend, also used by diet_guard and wake_alarm
|
# gatelock: shared lock-window/HMAC backend, also used by diet_guard and wake_alarm
|
||||||
# crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport,
|
# crdt-sync: shared CRDT merge scheme + sync transports, also used by
|
||||||
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
|
# diet_guard; here for phone-workout sync, Firebase RTDB only since 2026-10-09
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.2#subdirectory=gatelock",
|
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.13.0#subdirectory=gatelock",
|
||||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
|
"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.2.0#subdirectory=freedays",
|
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
|
||||||
|
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time",
|
||||||
|
"holds @ git+https://github.com/kuhyx/utils@holds-v0.1.0#subdirectory=holds",
|
||||||
|
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@ -22,7 +25,7 @@ screen-locker-web = "screen_locker._web_server:main"
|
|||||||
# so the systemd/CLI system-python path stays dependency-light and never has
|
# so the systemd/CLI system-python path stays dependency-light and never has
|
||||||
# to import the MCP SDK.
|
# to import the MCP SDK.
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
mcp = ["mcp==2.2.0"]
|
mcp = ["mcp==2.3.0"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
# This repo also holds stronglift_replacement/ (an unrelated Flutter app, not
|
# This repo also holds stronglift_replacement/ (an unrelated Flutter app, not
|
||||||
@ -42,6 +45,9 @@ ignore = [
|
|||||||
"D203",
|
"D203",
|
||||||
"D213",
|
"D213",
|
||||||
"COM812",
|
"COM812",
|
||||||
|
# Personal repo, no copyright policy (ruff 0.17 made CPY001 stable under
|
||||||
|
# select=ALL).
|
||||||
|
"CPY001",
|
||||||
"ISC001",
|
"ISC001",
|
||||||
"S603",
|
"S603",
|
||||||
]
|
]
|
||||||
@ -92,17 +98,7 @@ unfixable = []
|
|||||||
# -truth reuse status_view.py above is exempted for — and _make_bare_verifier
|
# -truth reuse status_view.py above is exempted for — and _make_bare_verifier
|
||||||
# imports ScreenLocker inside the function to avoid an import cycle.
|
# imports ScreenLocker inside the function to avoid an import cycle.
|
||||||
"screen_locker/_status_view_verify.py" = ["PLC0415", "SLF001"]
|
"screen_locker/_status_view_verify.py" = ["PLC0415", "SLF001"]
|
||||||
# _workout_handoff.py must pause gatelock's recovery watchdog while the Flutter
|
# explain_lock_decision takes one arg per real file path and input, and
|
||||||
# app holds the screen. The loop re-takes any grab it finds missing every
|
|
||||||
# 1000ms -- by design, so that pulling a cable is not an escape -- and
|
|
||||||
# holds_grab() is `grab_current() is not None`, which is False once another X
|
|
||||||
# client holds it. Left running it would steal the screen back from the workout
|
|
||||||
# app within a second. gatelock exposes no public pause (only close(), which
|
|
||||||
# also restores VT switching and would hand the user Ctrl+Alt+F2), and adding
|
|
||||||
# one there would contradict _recovery.py's monotonicity proof that no
|
|
||||||
# transition ever decreases the grab.
|
|
||||||
"screen_locker/_workout_handoff.py" = ["SLF001"]
|
|
||||||
# explain_lock_decision takes one arg per real file path + sick history, and
|
|
||||||
# returns early per lock-decision-chain stage (see module docstring) — both
|
# returns early per lock-decision-chain stage (see module docstring) — both
|
||||||
# are the deliberate design, not something to collapse into a config object.
|
# are the deliberate design, not something to collapse into a config object.
|
||||||
"screen_locker/_compliance_state.py" = ["C901", "PLR0911", "PLR0913"]
|
"screen_locker/_compliance_state.py" = ["C901", "PLR0911", "PLR0913"]
|
||||||
@ -175,17 +171,14 @@ exclude = [".venv/"]
|
|||||||
init-hook = "import sys; sys.path.insert(0, '.')"
|
init-hook = "import sys; sys.path.insert(0, '.')"
|
||||||
analyse-fallback-blocks = true
|
analyse-fallback-blocks = true
|
||||||
persistent = true
|
persistent = true
|
||||||
jobs = 0
|
# Serial on purpose. With jobs != 1 pylint ships the linter to its workers via
|
||||||
|
# dill.dumps(), and dill 0.4.1 (newest release) reads code.co_lnotab, which
|
||||||
|
# CPython 3.15 removed -- every run crashed once setup-python's "3.x" became
|
||||||
|
# 3.15.1. The fix sits on dill master, unreleased; return to 0 once a dill
|
||||||
|
# release past 0.4.1 ships. The pre-commit hooks take this value too.
|
||||||
|
jobs = 1
|
||||||
py-version = "3.14"
|
py-version = "3.14"
|
||||||
ignore = [".venv", "__pycache__"]
|
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$"]
|
ignore-patterns = [".*\\.pyi$"]
|
||||||
|
|
||||||
[tool.pylint.messages_control]
|
[tool.pylint.messages_control]
|
||||||
@ -195,7 +188,42 @@ disable = []
|
|||||||
[tool.pylint.design]
|
[tool.pylint.design]
|
||||||
min-public-methods = 0
|
min-public-methods = 0
|
||||||
max-module-lines = 1000
|
max-module-lines = 1000
|
||||||
max-attributes = 10
|
# ScreenLocker composes ~30 mixins; collapsing them is not the goal.
|
||||||
|
max-parents = 40
|
||||||
|
# Keyword-only arguments count: the gather_*/explain_* entry points take one
|
||||||
|
# per real file path so tests can override each.
|
||||||
|
max-args = 8
|
||||||
|
# ManualWorkoutDraft is a flat 23-field record mirroring the evidence form and
|
||||||
|
# the wire payload; StatusWindow / ScreenLocker hold UI and injected state.
|
||||||
|
max-attributes = 25
|
||||||
|
|
||||||
|
[tool.pylint.similarities]
|
||||||
|
# The remaining overlaps are 6-11 line fragments of deliberately parallel code
|
||||||
|
# (Tk button rows, the two JSON ledgers' loaders, mixin host stubs); a real
|
||||||
|
# copy-paste block is longer than this.
|
||||||
|
min-similarity-lines = 12
|
||||||
|
|
||||||
|
[tool.pylint.format]
|
||||||
|
# BaseHTTPRequestHandler dispatches on the method names do_GET / do_POST.
|
||||||
|
good-names-rgxs = "^do_(GET|POST)$"
|
||||||
|
|
||||||
|
[tool.pylint.classes]
|
||||||
|
# The mixin-host surface: module-level helpers (shutdown_base, status, the
|
||||||
|
# status window's verify flow) drive the ScreenLocker's own mixin methods, the
|
||||||
|
# same single-source-of-truth reuse ruff exempts via per-file SLF001.
|
||||||
|
exclude-protected = [
|
||||||
|
"_asdict", "_fields", "_replace", "_source", "_make", "os._exit",
|
||||||
|
"_adjust_shutdown_time_by",
|
||||||
|
"_apply_sick_floor",
|
||||||
|
"_apply_workout_credit",
|
||||||
|
"_credit_ingested_workout",
|
||||||
|
"_read_shutdown_config",
|
||||||
|
"_scan_and_fill_week_runnerup",
|
||||||
|
"_try_fill_stronglifts_for_week",
|
||||||
|
"_verify_phone_workout",
|
||||||
|
"_verify_runnerup_workout",
|
||||||
|
"_write_shutdown_config",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.pylint.typecheck]
|
[tool.pylint.typecheck]
|
||||||
generated-members = [
|
generated-members = [
|
||||||
@ -226,6 +254,14 @@ addopts = [
|
|||||||
"--cov-branch",
|
"--cov-branch",
|
||||||
"--cov-report=term-missing",
|
"--cov-report=term-missing",
|
||||||
"--cov-report=lcov",
|
"--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
|
# A fixture-only plugin module, not conftest.py (already at the
|
||||||
# repo's 400-line-per-file cap) -- see its own docstring.
|
# repo's 400-line-per-file cap) -- see its own docstring.
|
||||||
"-p",
|
"-p",
|
||||||
@ -240,6 +276,9 @@ filterwarnings = [
|
|||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["screen_locker"]
|
source = ["screen_locker"]
|
||||||
branch = true
|
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 = [
|
omit = [
|
||||||
"*/__pycache__/*",
|
"*/__pycache__/*",
|
||||||
"*/tests/*",
|
"*/tests/*",
|
||||||
|
|||||||
@ -13,6 +13,12 @@ UNITS=(
|
|||||||
"early-bird-workout-check.timer"
|
"early-bird-workout-check.timer"
|
||||||
"workout-sync.timer"
|
"workout-sync.timer"
|
||||||
"workout-sync.service"
|
"workout-sync.service"
|
||||||
|
"earner-bonus.path"
|
||||||
|
"earner-bonus.service"
|
||||||
|
"runnerup-watch.service"
|
||||||
|
"workout-poke.service"
|
||||||
|
"runnerup-retire.timer"
|
||||||
|
"runnerup-retire.service"
|
||||||
)
|
)
|
||||||
|
|
||||||
for unit in "${UNITS[@]}"; do
|
for unit in "${UNITS[@]}"; do
|
||||||
|
|||||||
@ -2,22 +2,29 @@
|
|||||||
# Runtime: tkinter/subprocess/socket/sqlite3 (stdlib) plus gatelock (below).
|
# Runtime: tkinter/subprocess/socket/sqlite3 (stdlib) plus gatelock (below).
|
||||||
bandit==1.9.4
|
bandit==1.9.4
|
||||||
codespell==2.4.3
|
codespell==2.4.3
|
||||||
coverage==7.16.0
|
coverage==7.17.2
|
||||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
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.2.0#subdirectory=freedays
|
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time
|
||||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.2#subdirectory=gatelock
|
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||||
mcp==2.2.0
|
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.13.0#subdirectory=gatelock
|
||||||
mypy==2.3.1
|
holds @ git+https://github.com/kuhyx/utils@holds-v0.1.0#subdirectory=holds
|
||||||
pre-commit==4.6.2
|
mcp==2.3.0
|
||||||
pylint==4.0.8
|
mypy==2.4.1
|
||||||
pytest==9.1.1
|
pre-commit==4.7.0
|
||||||
|
pylint==4.1.3
|
||||||
|
pytest==9.2.0
|
||||||
pytest-cov==7.1.0
|
pytest-cov==7.1.0
|
||||||
pytest-randomly==5.0.0
|
pytest-randomly==5.1.0
|
||||||
pytest-sugar==1.1.1
|
pytest-sugar==1.1.1
|
||||||
pytest-xdist==3.8.0
|
pytest-xdist==3.8.0
|
||||||
# Direct dependency since the Firebase session recovery exchanges a refresh
|
# Direct dependency since the Firebase session recovery exchanges a refresh
|
||||||
# token over the REST endpoint; crdt-sync already pulls requests in, but this
|
# token over the REST endpoint; crdt-sync already pulls requests in, but this
|
||||||
# module imports it itself, so it is declared rather than assumed.
|
# module imports it itself, so it is declared rather than assumed.
|
||||||
requests==2.34.2
|
requests==2.34.2
|
||||||
ruff==0.16.7
|
ruff==0.17.4
|
||||||
types-requests==2.33.0.20260906
|
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays
|
||||||
|
types-requests==2.33.0.20261030
|
||||||
|
# Direct dependency since the Firebase session stream (_session_stream) catches
|
||||||
|
# urllib3's own errors on the long-lived SSE read; requests pulls it in, but
|
||||||
|
# the module imports it itself, so it is declared rather than assumed.
|
||||||
|
urllib3==2.8.0
|
||||||
|
|||||||
15
runnerup-retire.service
Normal file
15
runnerup-retire.service
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Back up and uninstall the stock RunnerUp app a week after the workout app's first credited run
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
# screen_locker is pip-installed (editable), like workout-sync.service, so no
|
||||||
|
# WorkingDirectory/PYTHONPATH. Headless: no DISPLAY, no Tk, no lock screen.
|
||||||
|
#
|
||||||
|
# Exit 0 for "not due yet", "no app run credited yet" and "phone not attached"
|
||||||
|
# (the timer retries), non-zero when a human is needed: invalid import marker,
|
||||||
|
# adb missing, a failed backup or a refused uninstall. That shows up in
|
||||||
|
# `systemctl --user --failed`. Every skip says why in the journal:
|
||||||
|
# journalctl --user -u runnerup-retire
|
||||||
|
# State and the anchor: python3 -m screen_locker.runnerup_retire --status
|
||||||
|
ExecStart=/usr/bin/python3 -m screen_locker.runnerup_retire
|
||||||
18
runnerup-retire.timer
Normal file
18
runnerup-retire.timer
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Retry the RunnerUp retirement daily until the phone has been cleaned up
|
||||||
|
|
||||||
|
# No After=graphical-session.target: timers.target is ordered before it, and
|
||||||
|
# that After= closes a cycle systemd resolves by deleting this timer's job
|
||||||
|
# (see early-bird-workout-check.timer for the 2026-08-04 incident).
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# The phone is not always on USB, so a failed or skipped pass is simply tried
|
||||||
|
# again: shortly after boot and once a day. Most passes are an instant no-op
|
||||||
|
# (not due yet, or already done), so the timer stays installed.
|
||||||
|
OnBootSec=10min
|
||||||
|
OnCalendar=*-*-* 19:00:00
|
||||||
|
# Persistent=true: a PC that was off at 19:00 still gets that day's pass.
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
13
runnerup-watch.service
Normal file
13
runnerup-watch.service
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Credit a RunnerUp run the moment its TCX upload lands (starts workout-sync)
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# Long-running inotify watcher on ~/data/cloud/RunnerUp: idle until dufs
|
||||||
|
# finishes writing a *.tcx, then starts workout-sync.service. See the script
|
||||||
|
# header for why this is not a .path unit.
|
||||||
|
ExecStart=%h/src/screen-locker/scripts/runnerup_watch.sh
|
||||||
|
Restart=always
|
||||||
|
RestartSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
133
screen_locker/_activity_day.py
Normal file
133
screen_locker/_activity_day.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
"""A day's activities summed under the one crediting rule (see ``_walk_tcx``).
|
||||||
|
|
||||||
|
The day counts as the workout once EITHER its summed distance reaches
|
||||||
|
``MIN_RUN_DISTANCE_KM`` (within the GPS tolerance) OR its summed moving time
|
||||||
|
reaches the shared duration bar. The sport only labels messages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from screen_locker._constants import (
|
||||||
|
MIN_RUN_DISTANCE_KM,
|
||||||
|
MIN_WORKOUT_DURATION_MINUTES,
|
||||||
|
RUNNERUP_DISTANCE_TOLERANCE,
|
||||||
|
WORKOUT_DURATION_ACCEPT_MINUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
# RunnerUp's sport codes, for labels only (the label never decides credit).
|
||||||
|
SPORT_NAMES: dict[int, str] = {
|
||||||
|
0: "Running",
|
||||||
|
1: "Biking",
|
||||||
|
2: "Other",
|
||||||
|
3: "Orienteering",
|
||||||
|
4: "Walking",
|
||||||
|
5: "Treadmill",
|
||||||
|
6: "Gym",
|
||||||
|
7: "Stationary Bike",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def distance_qualifies(distance_km: float) -> bool:
|
||||||
|
"""Whether a distance meets the minimum, with the GPS tolerance."""
|
||||||
|
return distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) >= MIN_RUN_DISTANCE_KM
|
||||||
|
|
||||||
|
|
||||||
|
def moving_qualifies(moving_minutes: float) -> bool:
|
||||||
|
"""Whether moving time meets the shared duration bar (leeway included)."""
|
||||||
|
return moving_minutes >= WORKOUT_DURATION_ACCEPT_MINUTES
|
||||||
|
|
||||||
|
|
||||||
|
def _distance_metres(activity: dict[str, Any]) -> float:
|
||||||
|
"""Credited metres; the root-DB path has no trackpoints, so its total stands in."""
|
||||||
|
return float(activity.get("track_distance_m", activity.get("distance_m", 0.0)))
|
||||||
|
|
||||||
|
|
||||||
|
def _moving_seconds(activity: dict[str, Any]) -> float:
|
||||||
|
"""Moving seconds; the root-DB path has no trackpoints, so wall-clock stands in."""
|
||||||
|
return float(activity.get("moving_seconds", activity.get("duration_seconds", 0.0)))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActivityDay:
|
||||||
|
"""A day's activities, summed.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
legs: Distinct activities (deduplicated by the activity's ``Id``: the
|
||||||
|
WebDAV copy and the phone's copy are the same activity).
|
||||||
|
moving_minutes: Their summed moving time.
|
||||||
|
distance_km: Their summed distance.
|
||||||
|
completed_at: Unix time the activity that took the day over the bar
|
||||||
|
ended -- when the workout was actually done -- or ``None`` if the
|
||||||
|
day never got there (or the activity recorded no end).
|
||||||
|
all_walks: Whether every activity was a walk (label only).
|
||||||
|
sport: The sport code when the day is a single non-walk activity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
legs: int
|
||||||
|
moving_minutes: float
|
||||||
|
distance_km: float
|
||||||
|
completed_at: float | None
|
||||||
|
all_walks: bool = False
|
||||||
|
sport: int = -1
|
||||||
|
|
||||||
|
@property
|
||||||
|
def qualifies(self) -> bool:
|
||||||
|
"""Whether the day counts as its workout: distance OR moving time."""
|
||||||
|
return distance_qualifies(self.distance_km) or moving_qualifies(
|
||||||
|
self.moving_minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
def message(self) -> str:
|
||||||
|
"""The verification line, in the RunnerUp verifier's own style."""
|
||||||
|
if self.all_walks:
|
||||||
|
return (
|
||||||
|
f"Walking: {self.legs} walk(s), {self.distance_km:.1f} km, "
|
||||||
|
f"{self.moving_minutes:.0f} min moving"
|
||||||
|
)
|
||||||
|
if self.legs == 1:
|
||||||
|
name = SPORT_NAMES.get(self.sport, str(self.sport))
|
||||||
|
return f"{name}: {self.distance_km:.1f} km in {self.moving_minutes:.0f} min"
|
||||||
|
return (
|
||||||
|
f"Activity: {self.legs} activities, {self.distance_km:.1f} km, "
|
||||||
|
f"{self.moving_minutes:.0f} min moving"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def activity_shortfall(day: ActivityDay) -> str:
|
||||||
|
"""Why a day's activity did not count yet."""
|
||||||
|
return (
|
||||||
|
f"{day.message()} -- need {MIN_RUN_DISTANCE_KM:g}+ km or "
|
||||||
|
f"{MIN_WORKOUT_DURATION_MINUTES}+ min moving"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def activity_day(activities: Iterable[dict[str, Any]]) -> ActivityDay:
|
||||||
|
"""Sum parsed exports (``parse_tcx_file`` dicts) into one :class:`ActivityDay`."""
|
||||||
|
unique: dict[str, dict[str, Any]] = {}
|
||||||
|
for activity in activities:
|
||||||
|
unique.setdefault(str(activity.get("activity_id") or id(activity)), activity)
|
||||||
|
ordered = sorted(unique.values(), key=lambda a: a.get("ended_at") or 0.0)
|
||||||
|
moving = 0.0
|
||||||
|
metres = 0.0
|
||||||
|
completed_at: float | None = None
|
||||||
|
for activity in ordered:
|
||||||
|
moving += _moving_seconds(activity)
|
||||||
|
metres += _distance_metres(activity)
|
||||||
|
if completed_at is None and (
|
||||||
|
moving_qualifies(moving / 60) or distance_qualifies(metres / 1000)
|
||||||
|
):
|
||||||
|
completed_at = activity.get("ended_at")
|
||||||
|
return ActivityDay(
|
||||||
|
legs=len(ordered),
|
||||||
|
moving_minutes=moving / 60,
|
||||||
|
distance_km=metres / 1000,
|
||||||
|
completed_at=completed_at,
|
||||||
|
all_walks=bool(ordered) and all(a.get("walking") for a in ordered),
|
||||||
|
sport=int(ordered[0].get("sport", -1)) if ordered else -1,
|
||||||
|
)
|
||||||
@ -11,7 +11,7 @@ Deciding whether what came back counts as a workout stays in
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from concurrent.futures import ( # pylint: disable=no-name-in-module
|
from concurrent.futures import (
|
||||||
ThreadPoolExecutor,
|
ThreadPoolExecutor,
|
||||||
as_completed,
|
as_completed,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -174,7 +174,7 @@ def locker_unit_active() -> bool | None:
|
|||||||
if not systemctl_available():
|
if not systemctl_available():
|
||||||
return None
|
return None
|
||||||
code, out = _run(["systemctl", "--user", "is-active", LOCKER_UNIT])
|
code, out = _run(["systemctl", "--user", "is-active", LOCKER_UNIT])
|
||||||
if code != 0 and not out.strip():
|
if code and not out.strip():
|
||||||
return None
|
return None
|
||||||
return out.strip() == "active"
|
return out.strip() == "active"
|
||||||
|
|
||||||
@ -186,7 +186,7 @@ def _login_target_active() -> bool:
|
|||||||
True when ``graphical-session.target`` is active.
|
True when ``graphical-session.target`` is active.
|
||||||
"""
|
"""
|
||||||
code, out = _run(["systemctl", "--user", "is-active", _LOGIN_TARGET])
|
code, out = _run(["systemctl", "--user", "is-active", _LOGIN_TARGET])
|
||||||
return code == 0 and out.strip() == "active"
|
return not code and out.strip() == "active"
|
||||||
|
|
||||||
|
|
||||||
def collect_states() -> list[TimerState]:
|
def collect_states() -> list[TimerState]:
|
||||||
@ -205,7 +205,7 @@ def collect_states() -> list[TimerState]:
|
|||||||
states.append(
|
states.append(
|
||||||
TimerState(
|
TimerState(
|
||||||
name=name,
|
name=name,
|
||||||
enabled=code == 0 and raw == "enabled",
|
enabled=not code and raw == "enabled",
|
||||||
enabled_raw=raw,
|
enabled_raw=raw,
|
||||||
# For a timer this is "systemd has a next trigger for it";
|
# 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
|
# for the login-run service it is "the target it is wanted by
|
||||||
|
|||||||
@ -1,9 +1,20 @@
|
|||||||
"""Mixin: auto-upgrade early_bird/sick_day pending states via phone or RunnerUp.
|
"""Mixin: the today-state rungs of the lock ladder, and the early-bird upgrade.
|
||||||
|
|
||||||
Neither early_bird (a same-day pending marker, see ``_early_bird.py``) nor
|
early_bird is a same-day pending marker (``_early_bird.py``), not a log.json
|
||||||
sick_day (tracked in ``sick_history.json`` via ``_sick_tracker.py``) live in
|
entry — this module only checks its pending state and, on success, writes
|
||||||
log.json — this module only checks their pending state and, on
|
the *real* outcome (phone_verified/runnerup_verified) there.
|
||||||
success, writes the *real* outcome (phone_verified/runnerup_verified) there.
|
|
||||||
|
A sick day is a date in the shared ``sickdays`` pool (``_sick.py``): the
|
||||||
|
lock stands down for it and nothing is written. A workout done on a sick
|
||||||
|
day still reaches the log through the ordinary sync paths and earns
|
||||||
|
normally, so there is no sick-day upgrade any more.
|
||||||
|
|
||||||
|
The upgrade credits through ``_apply_workout_credit`` -- save first, reward
|
||||||
|
only if the entry was actually appended. It used to push the shutdown hour
|
||||||
|
and THEN save: when the same run was already in the log (the 15-minute sync
|
||||||
|
pass had pulled the TCX at 20:25 on 2026-09-16) the save deduped to nothing
|
||||||
|
and the +2h landed anyway, and again on every 5-minute tick while the
|
||||||
|
pending marker lasted -- a runaway that only the 23:00 ceiling stopped.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -11,10 +22,12 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from screen_locker import _sick_tracker
|
|
||||||
from screen_locker._decision_log import LockDecision, record_decision
|
from screen_locker._decision_log import LockDecision, record_decision
|
||||||
from screen_locker._decision_reasons import reasons_extra
|
from screen_locker._decision_reasons import reasons_extra
|
||||||
from screen_locker._wake_state import has_workout_skip_today
|
from screen_locker._hold import workout_hold_active
|
||||||
|
from screen_locker._morning_session import MorningSkip, has_workout_skip_today
|
||||||
|
from screen_locker._night_lockdown import has_evening_lockdown_today
|
||||||
|
from screen_locker._sick import stands_down_workout
|
||||||
from screen_locker._weekly_check import has_weekly_minimum, is_relaxed_day
|
from screen_locker._weekly_check import has_weekly_minimum, is_relaxed_day
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@ -60,6 +73,8 @@ class _ReasonsMixin:
|
|||||||
"wake_alarm_skip": has_workout_skip_today,
|
"wake_alarm_skip": has_workout_skip_today,
|
||||||
"relaxed_day": is_relaxed_day,
|
"relaxed_day": is_relaxed_day,
|
||||||
"weekly_minimum_met": lambda: has_weekly_minimum(self.log_file),
|
"weekly_minimum_met": lambda: has_weekly_minimum(self.log_file),
|
||||||
|
"evening_lockdown_today": has_evening_lockdown_today,
|
||||||
|
"test_hold_active": workout_hold_active,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -72,114 +87,99 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def _is_sick_day_today(self) -> bool:
|
def _is_sick_day_today(self) -> bool:
|
||||||
"""Check if today is marked as a sick day in sick_history.json."""
|
"""Whether the sickdays pool switches the workout lock off today.
|
||||||
return _sick_tracker.is_sick_day(_sick_tracker.load_history())
|
|
||||||
|
|
||||||
def _check_early_exits(self, *, verify_only: bool) -> None:
|
A sick day that keeps the workout locker is a normal day here.
|
||||||
|
"""
|
||||||
|
return stands_down_workout()
|
||||||
|
|
||||||
|
def _check_early_exits(self) -> None:
|
||||||
"""Check startup conditions and exit early when appropriate."""
|
"""Check startup conditions and exit early when appropriate."""
|
||||||
if verify_only:
|
|
||||||
if not self._is_sick_day_today():
|
|
||||||
_skip("no_sick_day_to_verify", "No sick day logged today.")
|
|
||||||
return
|
|
||||||
self._check_non_verify_exits()
|
self._check_non_verify_exits()
|
||||||
|
|
||||||
def _check_today_state_exits(self) -> bool:
|
def _check_today_state_exits(self) -> bool:
|
||||||
"""Handle early-bird and today's log states. Return True to stop startup."""
|
"""Handle early-bird and today's log states. Return True to stop startup."""
|
||||||
if self._is_early_bird_pending() and not self._is_early_bird_time():
|
pending = self._is_early_bird_pending()
|
||||||
if self._try_auto_upgrade_early_bird():
|
window_open = self._is_early_bird_time()
|
||||||
_skip(
|
if pending and not window_open and self._try_auto_upgrade_early_bird():
|
||||||
"early_bird_auto_upgraded",
|
_skip(
|
||||||
"Auto-upgraded early_bird entry to phone_verified.",
|
"early_bird_auto_upgraded",
|
||||||
)
|
"Auto-upgraded early_bird entry to phone_verified.",
|
||||||
return True
|
)
|
||||||
return False # Expired early bird, upgrade unavailable — full lock.
|
return True
|
||||||
if self._is_early_bird_pending():
|
# An expired marker with nothing to upgrade is NOT a lock on its own.
|
||||||
# The ONLY thing that closes this window is
|
# It used to `return False` right here, ahead of has_logged_today, so
|
||||||
# early-bird-workout-check.timer. When that timer was disabled by an
|
# a workout logged by any other path after 08:30 never counted while
|
||||||
# ordering cycle (2026-08-04) this branch silently deferred the lock
|
# the marker lasted: on 2026-09-18 a manual walk entered on the lock
|
||||||
# every single day, forever.
|
# screen at 09:32 closed the lock, and the 09:35 tick locked again --
|
||||||
|
# every 5 minutes, for the rest of the day -- while the explain chain
|
||||||
|
# (_compliance_state, where the expired marker is non-terminal) said
|
||||||
|
# "lock skipped". Fall through to the same ladder as any other morning.
|
||||||
|
if pending and window_open:
|
||||||
|
# The window is the wake-alarm carrot (see EarlyBirdMixin): it
|
||||||
|
# closes when the signed exempt_until passes, and the 5-minute
|
||||||
|
# tick plus early-bird-workout-check.timer then re-decide.
|
||||||
_skip(
|
_skip(
|
||||||
"early_bird_window_active",
|
"early_bird_window_active",
|
||||||
"Early bird window still active — deferring to the 08:30/09:05 "
|
f"Morning-session carrot still in force: {self._morning_skip}.",
|
||||||
"re-check timer.",
|
recheck_by="workout-locker.timer",
|
||||||
recheck_by="early-bird-workout-check.timer",
|
|
||||||
**self._other_conditions("early_bird_window_active"),
|
**self._other_conditions("early_bird_window_active"),
|
||||||
)
|
)
|
||||||
elif self._is_sick_day_today():
|
elif self._is_sick_day_today():
|
||||||
if self._try_auto_upgrade_sick_day():
|
_skip(
|
||||||
_skip(
|
"sick_day",
|
||||||
"sick_day_auto_upgraded",
|
"Today is a sick day (sickdays pool); the workout lock is off.",
|
||||||
"Auto-upgraded today's sick_day entry to phone_verified.",
|
**self._other_conditions("sick_day"),
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
_skip(
|
|
||||||
"sick_day",
|
|
||||||
"Sick day already logged today.",
|
|
||||||
**self._other_conditions("sick_day"),
|
|
||||||
)
|
|
||||||
elif self.has_logged_today():
|
elif self.has_logged_today():
|
||||||
_skip(
|
_skip(
|
||||||
"workout_logged_today",
|
"workout_logged_today",
|
||||||
"Workout already logged today.",
|
"Workout already logged today.",
|
||||||
**self._other_conditions("workout_logged_today"),
|
**self._other_conditions("workout_logged_today"),
|
||||||
)
|
)
|
||||||
elif has_workout_skip_today():
|
elif window_open:
|
||||||
|
# Bank the marker so the run that sees the carrot end tries a
|
||||||
|
# phone/RunnerUp workout before falling through to a lock.
|
||||||
|
self._save_early_bird_pending()
|
||||||
|
skip = self._morning_skip
|
||||||
_skip(
|
_skip(
|
||||||
"wake_alarm_skip",
|
"wake_alarm_skip",
|
||||||
"Wake alarm earned a workout skip.",
|
f"Morning session earned it: {skip or 'carrot in force'}.",
|
||||||
|
exempt_until=skip.exempt_until.isoformat() if skip else None,
|
||||||
|
outcome=skip.outcome if skip else None,
|
||||||
**self._other_conditions("wake_alarm_skip"),
|
**self._other_conditions("wake_alarm_skip"),
|
||||||
)
|
)
|
||||||
elif self._is_early_bird_time():
|
|
||||||
self._save_early_bird_pending()
|
|
||||||
_skip(
|
|
||||||
"early_bird_banked",
|
|
||||||
"Early bird time — banked the pending marker, will re-check "
|
|
||||||
"at 08:30/09:05.",
|
|
||||||
recheck_by="early-bird-workout-check.timer",
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _try_auto_upgrade_sick_day(self) -> bool:
|
def _stand_down_for_carrot(self, skip: MorningSkip) -> None:
|
||||||
"""Upgrade sick_day entry when phone or RunnerUp detects a valid workout."""
|
"""Take an armed lock down because the carrot landed after it armed.
|
||||||
try:
|
|
||||||
status, message = self._verify_phone_workout()
|
Does what the ``wake_alarm_skip`` rung above does -- bank the pending
|
||||||
except (OSError, RuntimeError) as exc:
|
marker so the post-carrot run still tries the phone workout, record the
|
||||||
_logger.warning(
|
decision -- then leaves through the locker's normal ``close`` (the same
|
||||||
"Sick-day auto-upgrade could not reach the phone (%s) — today's "
|
one a credited workout and a test hold use): lock window released,
|
||||||
"sick_day entry stays unverified; trying RunnerUp next",
|
instance lock dropped, exit 0. Nothing is credited or penalised.
|
||||||
exc,
|
"""
|
||||||
|
self._save_early_bird_pending()
|
||||||
|
record_decision(
|
||||||
|
LockDecision(
|
||||||
|
locked=False,
|
||||||
|
reason="wake_alarm_skip_late",
|
||||||
|
detail=f"Morning session carrot landed after the lock armed: {skip}.",
|
||||||
|
extra={
|
||||||
|
"exempt_until": skip.exempt_until.isoformat(),
|
||||||
|
"outcome": skip.outcome,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
status, message = "error", str(exc)
|
)
|
||||||
if status == "verified":
|
_logger.warning(
|
||||||
self.workout_data["type"] = "phone_verified"
|
"Morning-session carrot appeared while the lock was up (%s) — "
|
||||||
self.workout_data["source"] = message
|
"taking the lock down; the early-bird marker is banked.",
|
||||||
self.workout_data["after_sick_day"] = "true"
|
skip,
|
||||||
self._adjust_shutdown_time_later()
|
)
|
||||||
self.save_workout_log()
|
self.close()
|
||||||
return True
|
|
||||||
_logger.info("Auto-upgrade phone skipped (%s), trying RunnerUp...", status)
|
|
||||||
try:
|
|
||||||
runnerup_status, runnerup_msg = self._verify_runnerup_workout()
|
|
||||||
except (OSError, RuntimeError) as exc:
|
|
||||||
_logger.warning(
|
|
||||||
"Sick-day auto-upgrade could not read RunnerUp either (%s) — "
|
|
||||||
"today stays a sick_day, NOT upgraded to a verified workout",
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
if runnerup_status != "verified":
|
|
||||||
_logger.info(
|
|
||||||
"Auto-upgrade RunnerUp skipped (%s): %s", runnerup_status, runnerup_msg
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
self.workout_data["type"] = "runnerup_verified"
|
|
||||||
self.workout_data["source"] = runnerup_msg
|
|
||||||
self.workout_data["after_sick_day"] = "true"
|
|
||||||
self._adjust_shutdown_time_later()
|
|
||||||
self.save_workout_log()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _try_auto_upgrade_early_bird(self) -> bool:
|
def _try_auto_upgrade_early_bird(self) -> bool:
|
||||||
"""Try phone then RunnerUp to upgrade an early_bird log entry."""
|
"""Try phone then RunnerUp to upgrade an early_bird log entry."""
|
||||||
@ -196,8 +196,7 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
|||||||
self.workout_data["type"] = "phone_verified"
|
self.workout_data["type"] = "phone_verified"
|
||||||
self.workout_data["source"] = message
|
self.workout_data["source"] = message
|
||||||
self.workout_data["after_early_bird"] = "true"
|
self.workout_data["after_early_bird"] = "true"
|
||||||
self._adjust_shutdown_time_later()
|
self._apply_workout_credit()
|
||||||
self.save_workout_log()
|
|
||||||
return True
|
return True
|
||||||
_logger.info("Early bird phone skipped (%s), trying RunnerUp...", status)
|
_logger.info("Early bird phone skipped (%s), trying RunnerUp...", status)
|
||||||
try:
|
try:
|
||||||
@ -218,6 +217,5 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
|||||||
self.workout_data["type"] = "runnerup_verified"
|
self.workout_data["type"] = "runnerup_verified"
|
||||||
self.workout_data["source"] = runnerup_msg
|
self.workout_data["source"] = runnerup_msg
|
||||||
self.workout_data["after_early_bird"] = "true"
|
self.workout_data["after_early_bird"] = "true"
|
||||||
self._adjust_shutdown_time_later()
|
self._apply_workout_credit()
|
||||||
self.save_workout_log()
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
38
screen_locker/_bonus_lock.py
Normal file
38
screen_locker/_bonus_lock.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"""One writer at a time for the shutdown bonus state.
|
||||||
|
|
||||||
|
The daily reset and the live bonus pass both read ``shutdown_base.json``,
|
||||||
|
push ``/etc/shutdown-schedule.conf`` with a read-add-write and stamp the file.
|
||||||
|
They run from three processes -- the locker at login, the 15-minute
|
||||||
|
``workout-sync`` pass and the ledger-triggered ``earner-bonus`` pass -- so two
|
||||||
|
of them can see the same unstamped earner and each add its time. Holding this
|
||||||
|
lock across check, write and stamp makes the second one see the stamp.
|
||||||
|
|
||||||
|
A dedicated file, not the state file itself: ``_save_state`` truncates that.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import fcntl
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
__all__ = ["bonus_lock"]
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def bonus_lock(state_file: Path) -> Iterator[None]:
|
||||||
|
"""Hold an exclusive lock beside *state_file* for the ``with`` body.
|
||||||
|
|
||||||
|
Blocks until any other holder is done; the passes it serialises take a
|
||||||
|
second or two, so waiting is cheaper than skipping a bonus.
|
||||||
|
"""
|
||||||
|
with state_file.with_suffix(".lock").open("a") as handle:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||||
76
screen_locker/_carrot_watch.py
Normal file
76
screen_locker/_carrot_watch.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"""An armed lock stands down when wake-alarm's signed carrot lands late.
|
||||||
|
|
||||||
|
2026-11-07: the lock armed at 07:49:17 because today's signed
|
||||||
|
``morning_session.json`` had not landed yet (the refresher lost a network
|
||||||
|
race). It landed at 07:50:00 (``in_progress``, exempt until 09:13) and nothing
|
||||||
|
ever re-read it, so the morning session ran under a fullscreen lock. The
|
||||||
|
startup ladder reads the file once; this is the same verdict, re-asked every
|
||||||
|
``POLL_MS`` while the window is up.
|
||||||
|
|
||||||
|
The decision stays wake-alarm's (signature ok, dated today, now < exempt_until);
|
||||||
|
this module only decides *when to ask* -- 05:00-11:00 local, the hours the
|
||||||
|
refresher runs -- and never waits, since it sits on the Tk loop. The file is
|
||||||
|
read through the ``_morning_session`` module attribute so the test suite's
|
||||||
|
redirected path applies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from screen_locker import _morning_session
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
from tkinter import Misc
|
||||||
|
|
||||||
|
__all__ = ["POLL_MS", "watch_for_carrot"]
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
POLL_MS = 15_000
|
||||||
|
# Local minutes after midnight; mirrors gatelock.morning_session.MORNING_WINDOW.
|
||||||
|
_WINDOW_START_MIN = 5 * 60
|
||||||
|
_WINDOW_END_MIN = 11 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def _local_now() -> datetime:
|
||||||
|
return datetime.now(tz=UTC).astimezone()
|
||||||
|
|
||||||
|
|
||||||
|
def _in_window(now: datetime) -> bool:
|
||||||
|
return _WINDOW_START_MIN <= now.hour * 60 + now.minute < _WINDOW_END_MIN
|
||||||
|
|
||||||
|
|
||||||
|
def watch_for_carrot(
|
||||||
|
root: Misc, on_carrot: Callable[[_morning_session.MorningSkip], None]
|
||||||
|
) -> None:
|
||||||
|
"""Hand a late-landing signed carrot to ``on_carrot`` within one poll.
|
||||||
|
|
||||||
|
``on_carrot`` is the locker's own stand-down (bank the early-bird marker,
|
||||||
|
record the decision, then the normal ``close``). Polls on the Tk loop (no
|
||||||
|
thread) and stops re-arming once it fires. A failed read is logged and the
|
||||||
|
poll goes on: one bad read must not end the only thing watching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _tick() -> None:
|
||||||
|
now = _local_now()
|
||||||
|
if _in_window(now):
|
||||||
|
try:
|
||||||
|
skip = _morning_session.morning_skip_today(now, wait=False)
|
||||||
|
except (OSError, ValueError, TypeError, KeyError) as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"Could not read the morning session while the lock is up "
|
||||||
|
"(%s) — keeping the lock and trying again in %d s",
|
||||||
|
exc,
|
||||||
|
POLL_MS // 1000,
|
||||||
|
)
|
||||||
|
skip = None
|
||||||
|
if skip is not None:
|
||||||
|
on_carrot(skip)
|
||||||
|
return
|
||||||
|
root.after(POLL_MS, _tick)
|
||||||
|
|
||||||
|
root.after(POLL_MS, _tick)
|
||||||
39
screen_locker/_ceiling.py
Normal file
39
screen_locker/_ceiling.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""The latest shutdown a day can earn: ``earned_time``'s ceiling, bounded at 23:00."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import earned_time
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# adjust_shutdown_schedule.sh's RESTORE_CEILING (23:00): the hard upper bound
|
||||||
|
# every --restore write is clamped to, and the answer when the lookup fails.
|
||||||
|
HARD_CEILING_MINUTES = 23 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def ceiling(day: date) -> int:
|
||||||
|
"""The latest shutdown ``day`` can earn, minutes after its midnight.
|
||||||
|
|
||||||
|
Never above :data:`HARD_CEILING_MINUTES`. A lookup that raises fails open
|
||||||
|
to that bound -- the same cap the helper enforces anyway -- and says so at
|
||||||
|
warning, so a broken registry cannot silently lift or lower the cap.
|
||||||
|
"""
|
||||||
|
fn = getattr(earned_time, "shutdown_ceiling_for", None)
|
||||||
|
if fn is None:
|
||||||
|
return min(HARD_CEILING_MINUTES, int(earned_time.SHUTDOWN_CEILING_MINUTES))
|
||||||
|
try:
|
||||||
|
return min(HARD_CEILING_MINUTES, int(fn(day)))
|
||||||
|
except (ArithmeticError, LookupError, TypeError, ValueError) as err:
|
||||||
|
_logger.warning(
|
||||||
|
"shutdown ceiling for %s unavailable (%s); using %s",
|
||||||
|
day,
|
||||||
|
err,
|
||||||
|
f"{HARD_CEILING_MINUTES // 60:02d}:{HARD_CEILING_MINUTES % 60:02d}",
|
||||||
|
)
|
||||||
|
return HARD_CEILING_MINUTES
|
||||||
@ -13,17 +13,76 @@ from pathlib import Path
|
|||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from screen_locker._constants import SHUTDOWN_BASE_FILE
|
||||||
from screen_locker._decision_log import record_no_decision
|
from screen_locker._decision_log import record_no_decision
|
||||||
from screen_locker._manual_cli import run_manual_log
|
from screen_locker._manual_cli import run_manual_log
|
||||||
|
from screen_locker._rest_day_cli import run_declare_rest_day
|
||||||
|
from screen_locker._shutdown_base import apply_flat_bonuses_if_new
|
||||||
from screen_locker._status import run_status
|
from screen_locker._status import run_status
|
||||||
|
from screen_locker._workout_ledger_cli import run_backfill
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from screen_locker.screen_lock import ScreenLocker
|
from screen_locker.screen_lock import ScreenLocker
|
||||||
|
|
||||||
__all__ = ["main"]
|
__all__ = ["main"]
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_LOG_FILE_NAME = "log.json"
|
_LOG_FILE_NAME = "log.json"
|
||||||
_LOG_MANUAL_FLAG = "--log-manual-workout"
|
_LOG_MANUAL_FLAG = "--log-manual-workout"
|
||||||
|
_REST_DAY_FLAG = "--declare-rest-day"
|
||||||
|
_BACKFILL_FLAG = "--backfill-workout-ledger"
|
||||||
|
|
||||||
|
# Subcommands that own every argument after them (each has its own argparse,
|
||||||
|
# which also answers its own --help).
|
||||||
|
_TRAILING_ARG_FLAGS = (_LOG_MANUAL_FLAG, _REST_DAY_FLAG, _BACKFILL_FLAG)
|
||||||
|
_MODE_FLAGS = frozenset({"--status", "--sync-only", "--apply-bonuses", "--production"})
|
||||||
|
_HELP_FLAGS = frozenset({"-h", "--help"})
|
||||||
|
_EXIT_USAGE = 2
|
||||||
|
|
||||||
|
_USAGE = """\
|
||||||
|
usage: python3 -m screen_locker.screen_lock [MODE]
|
||||||
|
|
||||||
|
With no mode: the demo lock screen (Tk). Modes:
|
||||||
|
--production the real lock screen (what workout-locker.service runs)
|
||||||
|
--status print this week's workout status and exit
|
||||||
|
--sync-only headless: pull synced workouts, apply credit, exit
|
||||||
|
--apply-bonuses headless: apply newly earned flat bonuses, exit
|
||||||
|
--log-manual-workout ARGS headless manual workout log (see its --help)
|
||||||
|
--declare-rest-day ARGS sign a future rest day (see its --help)
|
||||||
|
--backfill-workout-ledger ARGS
|
||||||
|
backfill the workout ledger (see its --help)
|
||||||
|
-h, --help show this help and exit
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _own_args(argv: list[str]) -> list[str]:
|
||||||
|
"""``argv[1:]`` up to the first subcommand that owns the rest."""
|
||||||
|
args = argv[1:]
|
||||||
|
for index, arg in enumerate(args):
|
||||||
|
if arg in _TRAILING_ARG_FLAGS:
|
||||||
|
return args[:index]
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_help_and_unknown(argv: list[str]) -> None:
|
||||||
|
"""Exit 0 on ``--help``; exit 2 on an unknown argument.
|
||||||
|
|
||||||
|
Before this, ``--help`` matched no branch and fell through to building
|
||||||
|
the demo lock screen, and a typo'd flag did the same silently.
|
||||||
|
"""
|
||||||
|
own = _own_args(argv)
|
||||||
|
if _HELP_FLAGS & set(own):
|
||||||
|
# The usage on stdout IS the work --help asked for, so exiting 0 here
|
||||||
|
# abandons nothing. SystemExit is raised directly, not via sys.exit:
|
||||||
|
# the suite stubs sys.exit process-wide, and a help request must never
|
||||||
|
# fall through to building the lock screen even then.
|
||||||
|
sys.stdout.write(_USAGE)
|
||||||
|
raise SystemExit(0)
|
||||||
|
unknown = [arg for arg in own if arg not in _MODE_FLAGS]
|
||||||
|
if unknown:
|
||||||
|
sys.stderr.write(f"error: unknown argument(s): {' '.join(unknown)}\n{_USAGE}")
|
||||||
|
sys.exit(_EXIT_USAGE)
|
||||||
|
|
||||||
|
|
||||||
def _headless_locker(locker_cls: type[ScreenLocker]) -> ScreenLocker:
|
def _headless_locker(locker_cls: type[ScreenLocker]) -> ScreenLocker:
|
||||||
@ -41,6 +100,7 @@ def _headless_locker(locker_cls: type[ScreenLocker]) -> ScreenLocker:
|
|||||||
|
|
||||||
def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None:
|
def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None:
|
||||||
"""Dispatch on ``argv`` and run the requested mode."""
|
"""Dispatch on ``argv`` and run the requested mode."""
|
||||||
|
_handle_help_and_unknown(argv)
|
||||||
# Configure logging for EVERY mode, not just --sync-only. This used to sit
|
# Configure logging for EVERY mode, not just --sync-only. This used to sit
|
||||||
# inside the --sync-only branch, so `--production` -- the mode systemd
|
# inside the --sync-only branch, so `--production` -- the mode systemd
|
||||||
# actually runs -- had no handler and fell back to lastResort, which drops
|
# actually runs -- had no handler and fell back to lastResort, which drops
|
||||||
@ -61,6 +121,14 @@ def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None:
|
|||||||
rest = argv[argv.index(_LOG_MANUAL_FLAG) + 1 :]
|
rest = argv[argv.index(_LOG_MANUAL_FLAG) + 1 :]
|
||||||
sys.exit(run_manual_log(_headless_locker(locker_cls).log_file, rest))
|
sys.exit(run_manual_log(_headless_locker(locker_cls).log_file, rest))
|
||||||
|
|
||||||
|
if _REST_DAY_FLAG in argv:
|
||||||
|
# Headless, no Tk: sign a FUTURE rest day (see screen_locker._rest_day).
|
||||||
|
sys.exit(run_declare_rest_day(argv[argv.index(_REST_DAY_FLAG) + 1 :]))
|
||||||
|
|
||||||
|
if _BACKFILL_FLAG in argv:
|
||||||
|
rest = argv[argv.index(_BACKFILL_FLAG) + 1 :]
|
||||||
|
sys.exit(run_backfill(_headless_locker(locker_cls).log_file, rest))
|
||||||
|
|
||||||
if "--sync-only" in argv:
|
if "--sync-only" in argv:
|
||||||
# Headless sync for the timer unit: pull other devices' workouts and
|
# Headless sync for the timer unit: pull other devices' workouts and
|
||||||
# apply their credit, with no Tk and no lock screen. Sync used to run
|
# apply their credit, with no Tk and no lock screen. Sync used to run
|
||||||
@ -73,8 +141,14 @@ def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None:
|
|||||||
record_no_decision("--sync-only")
|
record_no_decision("--sync-only")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
locker = locker_cls(
|
if "--apply-bonuses" in argv:
|
||||||
demo_mode="--production" not in argv,
|
# earner-bonus.service, started by earner-bonus.path when a gate
|
||||||
verify_only="--verify-workout" in argv,
|
# writes its ledger: only the flat-bonus pass, no phone sync, so a
|
||||||
)
|
# solve moves shutdown within seconds rather than at the next sync.
|
||||||
|
_logger.info("Ledger changed: applying any newly earned flat bonus.")
|
||||||
|
apply_flat_bonuses_if_new(SHUTDOWN_BASE_FILE, _headless_locker(locker_cls))
|
||||||
|
record_no_decision("--apply-bonuses")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
locker = locker_cls(demo_mode="--production" not in argv)
|
||||||
locker.run()
|
locker.run()
|
||||||
|
|||||||
@ -21,22 +21,15 @@ from typing import TYPE_CHECKING
|
|||||||
import freedays
|
import freedays
|
||||||
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
||||||
|
|
||||||
from screen_locker._constants import (
|
|
||||||
EARLY_BIRD_END_HOUR,
|
|
||||||
EARLY_BIRD_END_MINUTE,
|
|
||||||
EARLY_BIRD_START_HOUR,
|
|
||||||
)
|
|
||||||
from screen_locker._day import today_str
|
from screen_locker._day import today_str
|
||||||
from screen_locker._log_io import load_workout_log
|
from screen_locker._log_io import load_workout_log
|
||||||
from screen_locker._sick_tracker import is_sick_day as _is_sick_day
|
from screen_locker._sick import stands_down_workout
|
||||||
from screen_locker._weekly_check import RELAXED_DAY_SKIP_TYPE
|
from screen_locker._weekly_check import RELAXED_DAY_SKIP_TYPE
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from screen_locker._sick_tracker import SickHistory
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -134,23 +127,11 @@ def is_early_bird_pending(pending_file: Path, *, today: str | None = None) -> bo
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def is_sick_day_today(history: SickHistory, *, today: str | None = None) -> bool:
|
def is_sick_day_today(today: date | None = None) -> bool:
|
||||||
"""Return True if today is recorded as a sick day.
|
"""Return True if the sickdays pool switches the workout lock off that day.
|
||||||
|
|
||||||
Thin wrapper around ``_sick_tracker.is_sick_day`` kept here so callers of
|
Kept here so callers of the status layer have one predicate module to
|
||||||
the status layer have one predicate module to import from.
|
import from; the answer itself is ``_sick.stands_down_workout``'s, so a
|
||||||
|
sick day that keeps the workout locker reads False.
|
||||||
"""
|
"""
|
||||||
return _is_sick_day(history, today=today)
|
return stands_down_workout(today)
|
||||||
|
|
||||||
|
|
||||||
def _early_bird_window_open(*, extended: bool, local_minutes: int) -> bool:
|
|
||||||
"""Deliberate, independent reimplementation of ``_is_early_bird_time``.
|
|
||||||
|
|
||||||
Not shared with ``EarlyBirdMixin._is_early_bird_time`` because too many
|
|
||||||
existing tests are pinned to its ``_get_local_time_minutes`` patch point.
|
|
||||||
Kept here as a small, separate, pure re-implementation for the status
|
|
||||||
layer only — see the module docstring in ``_early_bird.py``.
|
|
||||||
"""
|
|
||||||
start = EARLY_BIRD_START_HOUR * 60
|
|
||||||
end = 9 * 60 if extended else EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
|
|
||||||
return start <= local_minutes < end
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ Single source of truth for the leaf checks walked by
|
|||||||
mixins delegate their read-only checks to the functions here; this module
|
mixins delegate their read-only checks to the functions here; this module
|
||||||
never touches ADB, sudo, subprocess, or the network, and never writes to
|
never touches ADB, sudo, subprocess, or the network, and never writes to
|
||||||
disk.
|
disk.
|
||||||
|
|
||||||
``explain_lock_decision`` re-derives the same ordered chain for the status
|
``explain_lock_decision`` re-derives the same ordered chain for the status
|
||||||
view. One branch is a deliberate simplification: when an early-bird pending
|
view. One branch is a deliberate simplification: when an early-bird pending
|
||||||
marker has expired, the real code attempts a live phone/RunnerUp
|
marker has expired, the real code attempts a live phone/RunnerUp
|
||||||
@ -18,11 +17,11 @@ surfaces the pending opportunity separately via ``AutoUpgradeOpportunity``.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from screen_locker._compliance_predicates import (
|
from screen_locker._compliance_predicates import (
|
||||||
_early_bird_window_open,
|
|
||||||
has_logged_today,
|
has_logged_today,
|
||||||
is_early_bird_pending,
|
is_early_bird_pending,
|
||||||
is_relaxed_day_skipped_today,
|
is_relaxed_day_skipped_today,
|
||||||
@ -39,13 +38,13 @@ from screen_locker._compliance_trace import (
|
|||||||
describe_degraded_sources,
|
describe_degraded_sources,
|
||||||
)
|
)
|
||||||
from screen_locker._day import today_str as local_day
|
from screen_locker._day import today_str as local_day
|
||||||
|
from screen_locker._hold import workout_hold_active
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from screen_locker._degraded_sources import DegradedSource
|
from screen_locker._degraded_sources import DegradedSource
|
||||||
from screen_locker._sick_tracker import SickHistory
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AutoUpgradeOpportunity",
|
"AutoUpgradeOpportunity",
|
||||||
@ -61,15 +60,150 @@ __all__ = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Step:
|
||||||
|
"""One predicate of the lock-decision chain, in evaluation order."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
reason_true: str
|
||||||
|
reason_false: str
|
||||||
|
terminal_reason: str | None = None
|
||||||
|
note_when_false: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# Order mirrors ``_check_non_verify_exits`` / ``_check_today_state_exits``.
|
||||||
|
# ``early_bird_pending_expired`` is recorded but never terminal on its own: an
|
||||||
|
# expired pending marker still falls through toward a full lock, with the
|
||||||
|
# upgrade opportunity surfaced separately (see module docstring).
|
||||||
|
_CHAIN: tuple[_Step, ...] = (
|
||||||
|
_Step(
|
||||||
|
"scheduled_skip",
|
||||||
|
"Today is in scheduled_skips.json (unconditional manual skip).",
|
||||||
|
"Today is not a manually scheduled skip day.",
|
||||||
|
"Manually scheduled skip day — no lock, no workout required.",
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
"early_bird_pending_expired",
|
||||||
|
"Logged in during the early-bird window and it has since closed "
|
||||||
|
"with no workout confirmed yet.",
|
||||||
|
"No expired early-bird pending marker.",
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
"early_bird_window_open",
|
||||||
|
"Logged in during the early-bird window earlier today; still "
|
||||||
|
"waiting to see a workout before the window closes.",
|
||||||
|
"Not pending inside an open early-bird window.",
|
||||||
|
"Early-bird window still open — lock skipped while waiting for a workout.",
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
"sick_day",
|
||||||
|
"Today is a sick day in the shared sickdays pool.",
|
||||||
|
"Today is not a sick day.",
|
||||||
|
"Sick day today — lock skipped.",
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
"already_logged",
|
||||||
|
"A validly-signed workout is already logged for today.",
|
||||||
|
"No workout logged for today yet.",
|
||||||
|
"Workout already logged today — lock skipped.",
|
||||||
|
note_when_false=True,
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
"wake_alarm_skip",
|
||||||
|
"The morning session earned a workout skip for now.",
|
||||||
|
"No morning-session skip in force.",
|
||||||
|
"Morning session earned a skip — lock skipped.",
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
"test_hold_active",
|
||||||
|
"A declared test hold is active.",
|
||||||
|
"No test hold active.",
|
||||||
|
"Test hold active — no workout window until it ends.",
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
"relaxed_day_already_skipped",
|
||||||
|
"Relaxed day already dismissed via Skip — No Penalty today.",
|
||||||
|
"No same-day relaxed-day dismissal on record.",
|
||||||
|
"Relaxed day already skipped today — lock skipped, prompt not shown again.",
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
"relaxed_day",
|
||||||
|
"Today is a relaxed day (Tue/Wed/Thu).",
|
||||||
|
"Today is not a relaxed day.",
|
||||||
|
"Relaxed day (Tue/Wed/Thu) — workout optional, lock skipped.",
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
"weekly_minimum_met",
|
||||||
|
"Weekly workout minimum already reached.",
|
||||||
|
"Weekly workout minimum not yet reached.",
|
||||||
|
"Weekly workout minimum already met — lock skipped.",
|
||||||
|
),
|
||||||
|
_Step(
|
||||||
|
"evening_lockdown_today",
|
||||||
|
"Tonight's night lockdown already fired (>= 12:00 today).",
|
||||||
|
"No evening night lockdown yet today.",
|
||||||
|
"Night lockdown already happened tonight — the workout lock does not re-arm.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _chain_flags(
|
||||||
|
*,
|
||||||
|
log_file: Path,
|
||||||
|
weekly_minimum_met: bool,
|
||||||
|
relaxed_day: bool,
|
||||||
|
wake_skip: bool,
|
||||||
|
evening_lockdown: bool,
|
||||||
|
local_dt: datetime,
|
||||||
|
pending: bool,
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
"""Evaluate every predicate once; the keys are the ``_CHAIN`` step names."""
|
||||||
|
today_str = local_day(local_dt)
|
||||||
|
# The early-bird window IS the wake-alarm carrot (EarlyBirdMixin).
|
||||||
|
return {
|
||||||
|
"scheduled_skip": is_scheduled_skip_today(local_dt.date()),
|
||||||
|
"early_bird_pending_expired": pending and not wake_skip,
|
||||||
|
"early_bird_window_open": pending and wake_skip,
|
||||||
|
"sick_day": is_sick_day_today(local_dt.date()),
|
||||||
|
"already_logged": has_logged_today(log_file, today=today_str),
|
||||||
|
"wake_alarm_skip": wake_skip,
|
||||||
|
"test_hold_active": workout_hold_active(local_dt),
|
||||||
|
"relaxed_day_already_skipped": relaxed_day
|
||||||
|
and is_relaxed_day_skipped_today(log_file, today=today_str),
|
||||||
|
"relaxed_day": relaxed_day,
|
||||||
|
"weekly_minimum_met": weekly_minimum_met,
|
||||||
|
"evening_lockdown_today": evening_lockdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_chain(
|
||||||
|
fired: dict[str, bool], context: StageContext, degraded_note: str
|
||||||
|
) -> LockExplanation | None:
|
||||||
|
"""Record each step in ``context.trace``; return the first terminal one."""
|
||||||
|
for step in _CHAIN:
|
||||||
|
hit = fired[step.name]
|
||||||
|
reason = step.reason_true if hit else step.reason_false
|
||||||
|
if step.note_when_false and not hit:
|
||||||
|
reason += degraded_note
|
||||||
|
context.trace.append(PredicateResult(step.name, hit, reason))
|
||||||
|
if hit and step.terminal_reason is not None:
|
||||||
|
return _stage(
|
||||||
|
fired=False,
|
||||||
|
stage=step.name,
|
||||||
|
reason=step.terminal_reason,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def explain_lock_decision(
|
def explain_lock_decision(
|
||||||
*,
|
*,
|
||||||
log_file: Path,
|
log_file: Path,
|
||||||
early_bird_pending_file: Path,
|
early_bird_pending_file: Path,
|
||||||
sick_history: SickHistory,
|
|
||||||
extended_early_bird: bool,
|
|
||||||
weekly_minimum_met: bool,
|
weekly_minimum_met: bool,
|
||||||
relaxed_day: bool,
|
relaxed_day: bool,
|
||||||
wake_skip: bool = False,
|
wake_skip: bool = False,
|
||||||
|
evening_lockdown: bool = False,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
degraded_sources: Sequence[DegradedSource] = (),
|
degraded_sources: Sequence[DegradedSource] = (),
|
||||||
) -> LockExplanation:
|
) -> LockExplanation:
|
||||||
@ -80,161 +214,25 @@ def explain_lock_decision(
|
|||||||
reported via ``heat_skip_evaluated=False``, not guessed.
|
reported via ``heat_skip_evaluated=False``, not guessed.
|
||||||
"""
|
"""
|
||||||
instant = now if now is not None else datetime.now(tz=UTC)
|
instant = now if now is not None else datetime.now(tz=UTC)
|
||||||
today_str = local_day(instant)
|
pending = is_early_bird_pending(early_bird_pending_file, today=local_day(instant))
|
||||||
local_dt = instant.astimezone()
|
fired = _chain_flags(
|
||||||
local_minutes = local_dt.hour * 60 + local_dt.minute
|
log_file=log_file,
|
||||||
|
weekly_minimum_met=weekly_minimum_met,
|
||||||
scheduled = is_scheduled_skip_today(local_dt.date())
|
relaxed_day=relaxed_day,
|
||||||
pending = is_early_bird_pending(early_bird_pending_file, today=today_str)
|
wake_skip=wake_skip,
|
||||||
window_open = _early_bird_window_open(
|
evening_lockdown=evening_lockdown,
|
||||||
extended=extended_early_bird, local_minutes=local_minutes
|
local_dt=instant.astimezone(),
|
||||||
|
pending=pending,
|
||||||
)
|
)
|
||||||
expired = pending and not window_open
|
|
||||||
early_bird_open_and_pending = pending and window_open
|
|
||||||
window_end_label = "09:00" if extended_early_bird else "08:30"
|
|
||||||
sick_today = is_sick_day_today(sick_history, today=today_str)
|
|
||||||
logged = has_logged_today(log_file, today=today_str)
|
|
||||||
relaxed_day_already_skipped = relaxed_day and is_relaxed_day_skipped_today(
|
|
||||||
log_file, today=today_str
|
|
||||||
)
|
|
||||||
|
|
||||||
auto_upgrade = describe_auto_upgrade_opportunity(
|
auto_upgrade = describe_auto_upgrade_opportunity(
|
||||||
early_bird_pending=pending,
|
early_bird_pending=pending,
|
||||||
early_bird_window_open=window_open,
|
early_bird_window_open=wake_skip,
|
||||||
is_sick_day=sick_today,
|
|
||||||
)
|
)
|
||||||
|
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 marked as a sick day in sick_history.json.",
|
|
||||||
reason_false="Today is not marked as a sick day.",
|
|
||||||
terminal_reason="Sick day logged today — lock skipped.",
|
|
||||||
)
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
degraded_note = describe_degraded_sources(degraded_sources)
|
degraded_note = describe_degraded_sources(degraded_sources)
|
||||||
result = _check(
|
terminal = _walk_chain(fired, context, degraded_note)
|
||||||
"already_logged",
|
if terminal is not None:
|
||||||
fired=logged,
|
return terminal
|
||||||
reason_true="A validly-signed workout is already logged for today.",
|
|
||||||
reason_false="No workout logged for today yet." + degraded_note,
|
|
||||||
terminal_reason="Workout already logged today — lock skipped.",
|
|
||||||
)
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
result = _check(
|
|
||||||
"wake_alarm_skip",
|
|
||||||
fired=wake_skip,
|
|
||||||
reason_true="Wake-alarm earned a workout skip for today.",
|
|
||||||
reason_false="No wake-alarm workout skip earned today.",
|
|
||||||
terminal_reason="Wake-alarm earned a workout skip — lock skipped.",
|
|
||||||
)
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
result = _check(
|
|
||||||
"early_bird_time_fresh",
|
|
||||||
fired=window_open,
|
|
||||||
reason_true=(
|
|
||||||
f"Currently inside the early-bird window (05:00-{window_end_label})."
|
|
||||||
),
|
|
||||||
reason_false="Not currently inside the early-bird window.",
|
|
||||||
terminal_reason="Inside the early-bird window — lock skipped, pending marker "
|
|
||||||
f"would be saved for the {window_end_label} re-check.",
|
|
||||||
)
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
result = _check(
|
|
||||||
"relaxed_day_already_skipped",
|
|
||||||
fired=relaxed_day_already_skipped,
|
|
||||||
reason_true="Relaxed day already dismissed via Skip — No Penalty today.",
|
|
||||||
reason_false="No same-day relaxed-day dismissal on record.",
|
|
||||||
terminal_reason="Relaxed day already skipped today — lock skipped, "
|
|
||||||
"prompt not shown again.",
|
|
||||||
)
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
result = _check(
|
|
||||||
"relaxed_day",
|
|
||||||
fired=relaxed_day,
|
|
||||||
reason_true="Today is a relaxed day (Tue/Wed/Thu).",
|
|
||||||
reason_false="Today is not a relaxed day.",
|
|
||||||
terminal_reason="Relaxed day (Tue/Wed/Thu) — workout optional, lock skipped.",
|
|
||||||
)
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
result = _check(
|
|
||||||
"weekly_minimum_met",
|
|
||||||
fired=weekly_minimum_met,
|
|
||||||
reason_true="Weekly workout minimum already reached.",
|
|
||||||
reason_false="Weekly workout minimum not yet reached.",
|
|
||||||
terminal_reason="Weekly workout minimum already met — lock skipped.",
|
|
||||||
)
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Named for the conclusion; "..._pending_heat_check" read as "stuck".
|
# Named for the conclusion; "..._pending_heat_check" read as "stuck".
|
||||||
return _stage(
|
return _stage(
|
||||||
@ -246,5 +244,5 @@ def explain_lock_decision(
|
|||||||
"temperature is checked, and a run may or may not be happening "
|
"temperature is checked, and a run may or may not be happening "
|
||||||
"right now (see Health)." + degraded_note
|
"right now (see Health)." + degraded_note
|
||||||
),
|
),
|
||||||
context=stage_context,
|
context=context,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -32,7 +32,7 @@ class AutoUpgradeOpportunity:
|
|||||||
"""Describes whether the real locker would attempt a live auto-upgrade."""
|
"""Describes whether the real locker would attempt a live auto-upgrade."""
|
||||||
|
|
||||||
would_attempt: bool
|
would_attempt: bool
|
||||||
via: str # "early_bird_expired" | "sick_day" | "none"
|
via: str # "early_bird_expired" | "none"
|
||||||
reason: str
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
@ -63,7 +63,6 @@ def describe_auto_upgrade_opportunity(
|
|||||||
*,
|
*,
|
||||||
early_bird_pending: bool,
|
early_bird_pending: bool,
|
||||||
early_bird_window_open: bool,
|
early_bird_window_open: bool,
|
||||||
is_sick_day: bool,
|
|
||||||
) -> AutoUpgradeOpportunity:
|
) -> AutoUpgradeOpportunity:
|
||||||
"""Describe what the real ``_try_auto_upgrade_*`` methods would attempt.
|
"""Describe what the real ``_try_auto_upgrade_*`` methods would attempt.
|
||||||
|
|
||||||
@ -81,15 +80,6 @@ def describe_auto_upgrade_opportunity(
|
|||||||
"falling back to a full lock."
|
"falling back to a full lock."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if is_sick_day:
|
|
||||||
return AutoUpgradeOpportunity(
|
|
||||||
would_attempt=True,
|
|
||||||
via="sick_day",
|
|
||||||
reason=(
|
|
||||||
"Sick day logged — the next lock run will try phone/RunnerUp "
|
|
||||||
"verification to upgrade it to a real workout."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return _NO_UPGRADE
|
return _NO_UPGRADE
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -9,39 +9,31 @@ from gatelock.log_integrity import DEFAULT_HMAC_KEY_FILE
|
|||||||
|
|
||||||
# Ephemeral runtime state (self-clears on reboot, same directory gatelock's
|
# Ephemeral runtime state (self-clears on reboot, same directory gatelock's
|
||||||
# own Arbiter uses) -- not repo-tracked data, so it does not belong next to
|
# own Arbiter uses) -- not repo-tracked data, so it does not belong next to
|
||||||
# log.json/sick_history.json below.
|
# log.json below.
|
||||||
INSTANCE_LOCK_FILE = default_runtime_dir() / "screen_locker-instance.lock"
|
INSTANCE_LOCK_FILE = default_runtime_dir() / "screen_locker-instance.lock"
|
||||||
|
|
||||||
# Single-sourced from gatelock so the literal key path can't drift.
|
# Single-sourced from gatelock so the literal key path can't drift. The key is
|
||||||
|
# root:root 0644 ON PURPOSE, never 0600: leetcode-guard, book-guard and
|
||||||
|
# anki-guard run as user timers and sign their ledgers with it, and this
|
||||||
|
# locker signs log.json, rest_days.json and the workout ledger as the user.
|
||||||
|
# Nothing in this repo may chmod it; root ownership keeps it from being
|
||||||
|
# replaced, which is what the signatures rely on.
|
||||||
HMAC_KEY_FILE = DEFAULT_HMAC_KEY_FILE
|
HMAC_KEY_FILE = DEFAULT_HMAC_KEY_FILE
|
||||||
SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage)
|
# The workout ledger (earned_time.WORKOUT.ledger): one signed credit row per
|
||||||
|
# verified RunnerUp TCX credit and per rest day, resolved at call time.
|
||||||
|
WORKOUT_LEDGER_RELATIVE = Path(".local/share/workout_locker/ledger.json")
|
||||||
PHONE_PENALTY_DELAY_DEMO = 10
|
PHONE_PENALTY_DELAY_DEMO = 10
|
||||||
PHONE_PENALTY_DELAY_PRODUCTION = 100
|
PHONE_PENALTY_DELAY_PRODUCTION = 100
|
||||||
# Penalty added to phone-penalty timer when ADB / phone unavailable
|
# Penalty added to phone-penalty timer when ADB / phone unavailable
|
||||||
# (so unplugging phone does not become an easy escape into sick mode).
|
# (so unplugging the phone does not become an easy path to the manual log).
|
||||||
NO_PHONE_EXTRA_LOCKOUT_SECONDS = 480 # extra 8 minutes on top of base
|
NO_PHONE_EXTRA_LOCKOUT_SECONDS = 480 # extra 8 minutes on top of base
|
||||||
# Sick day rate-limiting (rolling windows). Once any window is exhausted
|
# Manual (unverified) workout rate-limiting. Once either
|
||||||
# the "I'm sick" button disappears entirely.
|
|
||||||
SICK_BUDGET_PER_7_DAYS = 1
|
|
||||||
SICK_BUDGET_PER_30_DAYS = 3
|
|
||||||
SICK_BUDGET_PER_90_DAYS = 10
|
|
||||||
# Each sick day in the trailing 30 days doubles the wait countdown.
|
|
||||||
SICK_LOCKOUT_MULTIPLIER_PER_RECENT = 2
|
|
||||||
# Minimum chars in the freeform sick justification.
|
|
||||||
SICK_JUSTIFICATION_MIN_CHARS = 120
|
|
||||||
# How many past sick justifications to show on the dialog (read-only).
|
|
||||||
SICK_HISTORY_REVIEW_COUNT = 10
|
|
||||||
# Forced read-only delay before SUBMIT enables when a commitment was made.
|
|
||||||
SICK_COMMITMENT_FORCED_READ_SECONDS = 5
|
|
||||||
# Breaking a commitment counts as this many sick budget days.
|
|
||||||
SICK_COMMITMENT_PENALTY_DAYS = 2
|
|
||||||
# How long the commitment prompt stays visible after a workout unlock.
|
|
||||||
COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15
|
|
||||||
# Manual (unverified) workout rate-limiting — looser than sick days since
|
|
||||||
# activities like table tennis recur more often than illness. Once either
|
|
||||||
# window is exhausted the "Log Manual Workout" option disappears entirely.
|
# window is exhausted the "Log Manual Workout" option disappears entirely.
|
||||||
MANUAL_WORKOUT_BUDGET_PER_7_DAYS = 2
|
MANUAL_WORKOUT_BUDGET_PER_7_DAYS = 2
|
||||||
MANUAL_WORKOUT_BUDGET_PER_30_DAYS = 10
|
MANUAL_WORKOUT_BUDGET_PER_30_DAYS = 10
|
||||||
|
# A manual workout may be logged AHEAD for a date up to this many days from
|
||||||
|
# today (never a past date); it earns its credit on that date, not before.
|
||||||
|
MANUAL_WORKOUT_MAX_DAYS_AHEAD = 7
|
||||||
# Manual entries use the shared MIN_WORKOUT_DURATION_MINUTES bar below —
|
# Manual entries use the shared MIN_WORKOUT_DURATION_MINUTES bar below —
|
||||||
# every workout type answers to the same minimum, whatever the sport.
|
# every workout type answers to the same minimum, whatever the sport.
|
||||||
# Minimum chars for the "what was done" activity-details field.
|
# Minimum chars for the "what was done" activity-details field.
|
||||||
@ -61,13 +53,13 @@ WORKOUT_APP_JSON_REMOTES = (
|
|||||||
)
|
)
|
||||||
# Port the workout app's HTTP server listens on (no ADB/developer-options needed).
|
# Port the workout app's HTTP server listens on (no ADB/developer-options needed).
|
||||||
WORKOUT_HTTP_PORT = 8765
|
WORKOUT_HTTP_PORT = 8765
|
||||||
# The ONE duration bar every workout type answers to — weightlifting, manual
|
# The ONE duration bar every activity answers to — weightlifting, manual
|
||||||
# entries and runs alike. This is the ADVERTISED number: it is the only one
|
# entries, runs and walks (summed moving time) alike. This is the ADVERTISED
|
||||||
# that may ever appear in a message the user reads.
|
# number: it is the only one that may ever appear in a message the user reads.
|
||||||
MIN_WORKOUT_DURATION_MINUTES = 40
|
MIN_WORKOUT_DURATION_MINUTES = 30
|
||||||
# ...and this is the leeway that is deliberately INVISIBLE. A session is
|
# ...and this is the leeway that is deliberately INVISIBLE. A session is
|
||||||
# actually accepted at MIN_WORKOUT_DURATION_MINUTES - this, so 35 minutes
|
# actually accepted at MIN_WORKOUT_DURATION_MINUTES - this, so 25 minutes
|
||||||
# passes while every string still says 40. That gap is intentional (the user
|
# passes while every string still says 30. That gap is intentional (the user
|
||||||
# asked for it explicitly): do NOT "fix" the inconsistency by advertising the
|
# asked for it explicitly): do NOT "fix" the inconsistency by advertising the
|
||||||
# real cutoff, and never interpolate WORKOUT_DURATION_ACCEPT_MINUTES into an
|
# real cutoff, and never interpolate WORKOUT_DURATION_ACCEPT_MINUTES into an
|
||||||
# f-string.
|
# f-string.
|
||||||
@ -75,17 +67,17 @@ WORKOUT_DURATION_LEEWAY_MINUTES = 5
|
|||||||
# Derived so the advertised bar and the accept bar cannot drift apart.
|
# Derived so the advertised bar and the accept bar cannot drift apart.
|
||||||
# Comparisons use this; messages use MIN_WORKOUT_DURATION_MINUTES. Every
|
# Comparisons use this; messages use MIN_WORKOUT_DURATION_MINUTES. Every
|
||||||
# caller compares with `>=` — one operator across all workout types, so a
|
# caller compares with `>=` — one operator across all workout types, so a
|
||||||
# 35.0-minute session cannot pass one gate and fail another.
|
# 25.0-minute session cannot pass one gate and fail another.
|
||||||
WORKOUT_DURATION_ACCEPT_MINUTES = (
|
WORKOUT_DURATION_ACCEPT_MINUTES = (
|
||||||
MIN_WORKOUT_DURATION_MINUTES - WORKOUT_DURATION_LEEWAY_MINUTES
|
MIN_WORKOUT_DURATION_MINUTES - WORKOUT_DURATION_LEEWAY_MINUTES
|
||||||
)
|
)
|
||||||
RUNNERUP_PACKAGES = ("org.runnerup", "org.runnerup.free")
|
RUNNERUP_PACKAGES = ("org.runnerup", "org.runnerup.free")
|
||||||
RUNNERUP_DB_SDCARD_TMP = "/sdcard/.runnerup_tmp_verification.db"
|
RUNNERUP_DB_SDCARD_TMP = "/sdcard/.runnerup_tmp_verification.db"
|
||||||
# A run qualifies on EITHER criterion — they are an OR, not an AND. A short
|
# A day's activity (run or walk alike) qualifies on EITHER criterion — they are
|
||||||
# but long-lasting run counts, and so does a fast 5 km that takes under the
|
# an OR, not an AND. A short but long-lasting session counts, and so does a
|
||||||
# duration bar. Do not reinstate an AND here: it would reject exactly the
|
# fast 5 km that takes under the duration bar. Do not reinstate an AND here:
|
||||||
# slow-run case this pair exists to allow. The duration arm is the shared
|
# it would reject exactly the slow case this pair exists to allow. The
|
||||||
# MIN_WORKOUT_DURATION_MINUTES above; only the distance arm is run-specific.
|
# duration arm is the shared MIN_WORKOUT_DURATION_MINUTES above (moving time).
|
||||||
MIN_RUN_DISTANCE_KM = 3.5 # inclusive: "as low as 3.5 km"
|
MIN_RUN_DISTANCE_KM = 3.5 # inclusive: "as low as 3.5 km"
|
||||||
# GPS distance tracking undershoots on tight turns/tree cover/urban canyons;
|
# GPS distance tracking undershoots on tight turns/tree cover/urban canyons;
|
||||||
# a run within 5% of the minimum is accepted rather than rejected on
|
# a run within 5% of the minimum is accepted rather than rejected on
|
||||||
@ -102,35 +94,62 @@ HEAT_SKIP_CITY: str = "Warsaw"
|
|||||||
SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf")
|
SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf")
|
||||||
# Helper script path (relative to this file)
|
# Helper script path (relative to this file)
|
||||||
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
|
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
|
||||||
# State file to track sick day usage and original config values
|
|
||||||
SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json"
|
|
||||||
# Persistent sick-day history (rate-limit, debt, commitments, justifications).
|
|
||||||
# Distinct from SICK_DAY_STATE_FILE which is a one-day shutdown-config snapshot.
|
|
||||||
SICK_HISTORY_FILE = Path(__file__).resolve().parent / "sick_history.json"
|
|
||||||
# JSON list of ISO date strings ("YYYY-MM-DD") for which the screen lock is skipped.
|
# JSON list of ISO date strings ("YYYY-MM-DD") for which the screen lock is skipped.
|
||||||
SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json"
|
SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json"
|
||||||
# State file tracking streak, skip credits, and early-bird extension weeks.
|
# State file tracking streak, skip credits, and early-bird extension weeks.
|
||||||
EXTRA_BENEFITS_FILE = Path(__file__).resolve().parent / "extra_benefits_state.json"
|
EXTRA_BENEFITS_FILE = Path(__file__).resolve().parent / "extra_benefits_state.json"
|
||||||
# State file storing the base (pre-bonus) shutdown hours and last reset date.
|
# State file storing the base (pre-bonus) shutdown hours and last reset date.
|
||||||
SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json"
|
SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json"
|
||||||
|
# HMAC-signed rest days, declared ahead (see _rest_day), and how far the grace
|
||||||
|
# floor has lifted today's shutdown above the earned time (see _grace_floor).
|
||||||
|
REST_DAY_FILE = Path(__file__).resolve().parent / "rest_days.json"
|
||||||
|
REST_DAYS_PER_ISO_WEEK = 2
|
||||||
|
GRACE_STATE_FILE = Path(__file__).resolve().parent / "grace_floor.json"
|
||||||
|
# Every GPS activity (run or walk, whatever its TCX sport) is judged the same
|
||||||
|
# way: a day's activities count as the workout once their summed distance
|
||||||
|
# reaches MIN_RUN_DISTANCE_KM OR their summed MOVING time reaches
|
||||||
|
# WORKOUT_DURATION_ACCEPT_MINUTES. Moving = trackpoint pairs seen within
|
||||||
|
# WALK_MAX_GAP_SECONDS and travelled at least this fast (slower is standing
|
||||||
|
# still). A pair faster than MAX_MOVING_SPEED_MPS (25 km/h, the app's running
|
||||||
|
# jump filter) is a vehicle or a GPS jump and counts neither moving time NOR
|
||||||
|
# distance, for every activity. The Dart app mirrors the bounds.
|
||||||
|
MIN_MOVING_SPEED_MPS = 0.5
|
||||||
|
MAX_MOVING_SPEED_MPS = 25 / 3.6
|
||||||
|
WALK_MAX_GAP_SECONDS = 30
|
||||||
|
# Speed is judged over windows of at least this long (1 s fixes jitter), and a
|
||||||
|
# window longer than WALK_MAX_GAP_SECONDS (a GPS dropout) only counts as
|
||||||
|
# moving time at this speed or faster, so standing through a gap earns nothing.
|
||||||
|
TRACK_WINDOW_SECONDS = 10
|
||||||
|
MIN_DROPOUT_SPEED_MPS = 1.0
|
||||||
|
# Workout records this PC must keep deleted in the sync store, pushed as
|
||||||
|
# tombstones on every tick (see _sync_tombstones for why once is not enough).
|
||||||
|
SYNC_TOMBSTONES_FILE = Path(__file__).resolve().parent / "sync_tombstones.json"
|
||||||
|
# Future-dated manual workouts logged on THIS PC, re-published on every push
|
||||||
|
# until their date (see _manual_pending for why a one-shot publish is erased).
|
||||||
|
MANUAL_PENDING_FILE = Path(__file__).resolve().parent / "manual_pending.json"
|
||||||
# Self-expiring marker: "logged in during today's early-bird window, still
|
# Self-expiring marker: "logged in during today's early-bird window, still
|
||||||
# waiting to see if a real workout shows up." Not a log.json entry —
|
# waiting to see if a real workout shows up." Not a log.json entry —
|
||||||
# it's a same-day pending flag, checked against its own "date" field.
|
# it's a same-day pending flag, checked against its own "date" field.
|
||||||
EARLY_BIRD_PENDING_FILE = Path(__file__).resolve().parent / "early_bird_pending.json"
|
EARLY_BIRD_PENDING_FILE = Path(__file__).resolve().parent / "early_bird_pending.json"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# The PC's copy of the phone's morning session, written HMAC-signed by
|
||||||
# Wake-alarm integration (originally from wake_alarm._constants / _state).
|
# wake-alarm (`python -m wake_alarm._session --refresh`, every 2 min through
|
||||||
# These must match the values used by the companion wake_alarm service.
|
# the morning). Read here, never fetched: wake-alarm is the only Firebase
|
||||||
# ---------------------------------------------------------------------------
|
# reader. XDG state rather than a sibling checkout, because the previous
|
||||||
# Days on which the wake alarm fires (0=Mon … 6=Sun).
|
# reader pointed at "../wake_alarm/wake_state.json" -- a path that stopped
|
||||||
ALARM_DAYS: frozenset[int] = frozenset({0, 4, 5, 6})
|
# existing when the repos split -- and returned "no skip" for a year without
|
||||||
# How many hours after midnight the alarm triggers (configurable in wake_alarm).
|
# a single error. Keep the path in step with wake_alarm/_constants.py.
|
||||||
WAKE_AFTER_HOURS: int = 8
|
MORNING_SESSION_FILE = (
|
||||||
# Path to the rtcwake binary.
|
Path.home() / ".local" / "state" / "wake_alarm" / "morning_session.json"
|
||||||
RTCWAKE_BIN: str = "/usr/sbin/rtcwake"
|
)
|
||||||
# State file written by wake_alarm; read here to check for workout skip.
|
|
||||||
WAKE_STATE_FILE = (
|
# A separate file from MORNING_SESSION_FILE: that one always describes
|
||||||
Path(__file__).resolve().parent.parent / "wake_alarm" / "wake_state.json"
|
# *today* and is overwritten at the date rollover, so it cannot carry a
|
||||||
|
# marker forward to tomorrow. wake-alarm writes this one when a Tue/Wed/Thu
|
||||||
|
# ring goes unanswered; today == penalty_date means today's relaxed-day
|
||||||
|
# leniency is off. Keep the path in step with wake_alarm/_constants.py.
|
||||||
|
WORKDAY_PENALTY_FILE = (
|
||||||
|
Path.home() / ".local" / "state" / "wake_alarm" / "workday_penalty.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Directories where RunnerUp writes per-activity TCX exports (File Synchronizer).
|
# Directories where RunnerUp writes per-activity TCX exports (File Synchronizer).
|
||||||
@ -140,16 +159,24 @@ RUNNERUP_EXPORT_DIRS: tuple[str, ...] = (
|
|||||||
"/storage/emulated/0/Documents/RunnerUp",
|
"/storage/emulated/0/Documents/RunnerUp",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Where RunnerUp's WebDAV synchroniser drops the same exports on this machine
|
||||||
|
# (dufs, see ~/src/dufs-cloud), and where endurain-import moves them once
|
||||||
|
# Endurain has them. Read straight off the disk, so a run is credited without
|
||||||
|
# the phone being adb-reachable -- on 2026-09-16 the TCX landed here at 20:08
|
||||||
|
# and the phone was only plugged in at 20:25. Same filenames as the phone
|
||||||
|
# export, optionally prefixed with the device name (RunnerUp_Pixel_6a_...).
|
||||||
|
RUNNERUP_WEBDAV_DIRS: tuple[Path, ...] = (
|
||||||
|
Path.home() / "data" / "cloud" / "RunnerUp",
|
||||||
|
Path.home() / "data" / "cloud" / "RunnerUp" / "processed",
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sync (phone workout data) — Workstream C, built on the shared crdt-sync
|
# Sync (phone workout data) — built on the shared crdt-sync library, Firebase
|
||||||
# library. GitHub is used purely as dumb file storage via the REST Contents
|
# RTDB only since 2026-10-09 (the shared ~/.config/crdt-sync/ credential). The
|
||||||
# API (not a git clone), same pattern as diet_guard's cross-device sync. The
|
# old GitHub repo kuhyx/syncs/screen-locker-sync is a frozen archive: nothing
|
||||||
# phone app pushes to devices/<SYNC_PHONE_DEVICE_ID>/log.json; the PC reads
|
# reads or writes it. Every device pushes to devices/<device id>/log.json; the
|
||||||
# every device's log and pushes its OWN manual workouts to devices/pc/log.json
|
# PC reads every device's log and pushes its OWN workouts (see _manual_push).
|
||||||
# (see _manual_push) — which needs a token with contents:write, not read-only.
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
SYNC_REPO_OWNER: str = "kuhyx"
|
|
||||||
SYNC_REPO_NAME: str = "syncs"
|
|
||||||
# The phone's pre-uuid device id. Still read (the phone may not have migrated
|
# The phone's pre-uuid device id. Still read (the phone may not have migrated
|
||||||
# yet), but no longer the only path the reader knows about -- it discovers
|
# yet), but no longer the only path the reader knows about -- it discovers
|
||||||
# every device directory.
|
# every device directory.
|
||||||
@ -164,12 +191,6 @@ SYNC_LEGACY_DEVICE_ID: str | None = "pc"
|
|||||||
SYNC_DEVICE_ID_FILE: Path = (
|
SYNC_DEVICE_ID_FILE: Path = (
|
||||||
Path.home() / ".local" / "share" / "screen_locker" / ".device_id"
|
Path.home() / ".local" / "share" / "screen_locker" / ".device_id"
|
||||||
)
|
)
|
||||||
# A fine-grained GitHub PAT, scoped to just SYNC_REPO_NAME's contents. The
|
|
||||||
# user creates this once via github.com and saves it here, mode 600. Never
|
|
||||||
# committed — this path is outside the repo entirely. Unlike diet_guard,
|
|
||||||
# this file being absent is a normal, expected state (sync is optional here).
|
|
||||||
SYNC_TOKEN_FILE: Path = Path.home() / ".config" / "screen_locker" / "sync_token"
|
|
||||||
|
|
||||||
# Revision cache for cross-device sync. Without it every push re-uploads and
|
# Revision cache for cross-device sync. Without it every push re-uploads and
|
||||||
# every pull re-downloads regardless of change, which is the traffic the
|
# every pull re-downloads regardless of change, which is the traffic the
|
||||||
# Firebase free tier's monthly budget depends on not happening.
|
# Firebase free tier's monthly budget depends on not happening.
|
||||||
|
|||||||
130
screen_locker/_credit_notify.py
Normal file
130
screen_locker/_credit_notify.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
"""Desktop notification the moment a workout earns its credit.
|
||||||
|
|
||||||
|
The reward is the point: a workout should be *felt* as paid the second the PC
|
||||||
|
credits it, not discovered at the next look at the status window. Every path
|
||||||
|
that credits a workout ends in
|
||||||
|
:meth:`~screen_locker._workout_credit.WorkoutCreditMixin._apply_credit_for_written_entry`,
|
||||||
|
which calls :func:`notify_workout_credit` once per newly earned credit slot.
|
||||||
|
|
||||||
|
The gaming figure is ``earned_time.resolve`` over screen-locker's own answers
|
||||||
|
(:func:`screen_locker._shutdown_target.gather` -- the same sum the daily reset
|
||||||
|
writes), so no budget math lives here. steam-backlog-enforcer resolves the
|
||||||
|
same registry with its own transports and holds a day's high-water mark, so
|
||||||
|
its figure can sit above this one, never below on a normal day.
|
||||||
|
|
||||||
|
A notification is a courtesy, never part of crediting: any failure (no DBus,
|
||||||
|
no notify-send, a hang) is logged at warning and swallowed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
|
from screen_locker._day import today_str
|
||||||
|
from screen_locker._earned import hhmm, span
|
||||||
|
from screen_locker._grace_floor import tonight_minutes
|
||||||
|
from screen_locker._shutdown_target import gather
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_logger: Final = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 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}"
|
||||||
|
_NOTIFY_TIMEOUT_SECONDS: Final = 5
|
||||||
|
_MINUTES_PER_HOUR: Final = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def gaming_minutes_today(log_file: Path | None) -> int | None:
|
||||||
|
"""Today's gaming budget per the earned_time registry, or ``None``.
|
||||||
|
|
||||||
|
``None`` (logged) when the inputs cannot be read; the notification then
|
||||||
|
shows the shutdown time alone.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
target = gather(date.fromisoformat(today_str()), log_file)
|
||||||
|
except (OSError, ValueError, KeyError) as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"Could not compute today's gaming budget for the credit "
|
||||||
|
"notification (%s) — showing the shutdown time only",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return int(target.resolution.gaming_minutes)
|
||||||
|
|
||||||
|
|
||||||
|
def credit_body(
|
||||||
|
before: tuple[int, int, int] | None,
|
||||||
|
after: tuple[int, int, int] | None,
|
||||||
|
gaming_minutes: int | None,
|
||||||
|
) -> str:
|
||||||
|
"""``⏻ 23:00 (+2h) · 🎮 7.0h`` -- whichever parts are known.
|
||||||
|
|
||||||
|
``before``/``after`` are the shutdown config around the credit; the delta
|
||||||
|
is tonight's actual move, so a capped or rest-day credit shows ``+0h``.
|
||||||
|
"""
|
||||||
|
parts: list[str] = []
|
||||||
|
if after is None:
|
||||||
|
_logger.warning(
|
||||||
|
"Shutdown config unreadable after a workout credit — the "
|
||||||
|
"notification shows no shutdown time"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
day = date.fromisoformat(today_str())
|
||||||
|
tonight = tonight_minutes(after, day)
|
||||||
|
text = f"{_SHUTDOWN} {hhmm(tonight)}"
|
||||||
|
if before is not None:
|
||||||
|
moved = max(0, tonight - tonight_minutes(before, day))
|
||||||
|
text += f" (+{span(moved)})"
|
||||||
|
parts.append(text)
|
||||||
|
if gaming_minutes is not None:
|
||||||
|
parts.append(f"{_GAMING} {gaming_minutes / _MINUTES_PER_HOUR:.1f}h")
|
||||||
|
return " \N{MIDDLE DOT} ".join(parts) or "\N{CHECK MARK}"
|
||||||
|
|
||||||
|
|
||||||
|
def send_notification(title: str, body: str) -> bool:
|
||||||
|
"""Show a desktop notification via ``notify-send``; False (logged) on failure."""
|
||||||
|
binary = shutil.which("notify-send")
|
||||||
|
if binary is None:
|
||||||
|
_logger.warning("notify-send not found — no desktop notification: %s", body)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[binary, "--app-name=screen-locker", title, body],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=_NOTIFY_TIMEOUT_SECONDS,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
_logger.warning("Desktop notification failed (%s): %s", exc, body)
|
||||||
|
return False
|
||||||
|
if result.returncode:
|
||||||
|
_logger.warning(
|
||||||
|
"notify-send exited %d (%s) — no desktop notification: %s",
|
||||||
|
result.returncode,
|
||||||
|
result.stderr.strip() or "no stderr; is a DBus session reachable?",
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
_logger.info("Desktop notification: %s — %s", title, body)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def notify_workout_credit(
|
||||||
|
before: tuple[int, int, int] | None,
|
||||||
|
after: tuple[int, int, int] | None,
|
||||||
|
log_file: Path | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Notify a newly earned workout credit. Never raises."""
|
||||||
|
body = credit_body(before, after, gaming_minutes_today(log_file))
|
||||||
|
return send_notification(TITLE, body)
|
||||||
86
screen_locker/_credit_slot.py
Normal file
86
screen_locker/_credit_slot.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"""Which credit a newly written workout earns, decided by slot like the reset.
|
||||||
|
|
||||||
|
The live credit path used to ask "was any counted workout already logged
|
||||||
|
today?" -- so the two StrongLifts ingestion paths of ONE session
|
||||||
|
(``pc_workout_verified`` from the session sync, ``phone_verified`` from the
|
||||||
|
early-bird auto-upgrade) earned the first-unit +2h and then a further-unit +1h
|
||||||
|
(2026-10-09; also 07-17 and 07-27 in ``duplicate_credits.json``). The daily
|
||||||
|
reset counts the same day with :func:`~screen_locker._weekly_check.count_day_credits`,
|
||||||
|
which collapses them onto one slot. Deciding the live credit with that same
|
||||||
|
counter makes the two agree by construction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import ExitStack, contextmanager
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any, Final
|
||||||
|
|
||||||
|
from screen_locker._bonus_lock import bonus_lock
|
||||||
|
from screen_locker._weekly_check import count_day_credits
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterator, Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_logger: Final = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# count_day_credits only uses the date to build slot keys, and both counts
|
||||||
|
# below are taken over the same day, so any fixed label is equivalent.
|
||||||
|
_SAME_DAY: Final = "day"
|
||||||
|
|
||||||
|
|
||||||
|
class CreditSlot(enum.Enum):
|
||||||
|
"""What the newest entry of a day earns."""
|
||||||
|
|
||||||
|
NONE = "earns no credit (not a counted workout type)"
|
||||||
|
FIRST = "first credit slot of the day"
|
||||||
|
FURTHER = "a new, distinct credit slot after the first"
|
||||||
|
OCCUPIED = "a slot an earlier entry of the day already credited"
|
||||||
|
|
||||||
|
|
||||||
|
def credit_slot(
|
||||||
|
prior_entries: list[dict[str, Any]], workout_data: Mapping[str, Any]
|
||||||
|
) -> CreditSlot:
|
||||||
|
"""Classify ``workout_data`` against the day's entries written before it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prior_entries: The day's log entries before the new one, as returned
|
||||||
|
by the write chokepoint (snapshotted under the log lock).
|
||||||
|
workout_data: The new entry's ``workout_data``.
|
||||||
|
"""
|
||||||
|
new_entry = {"workout_data": dict(workout_data)}
|
||||||
|
day = [e for e in prior_entries if isinstance(e, dict)]
|
||||||
|
before = count_day_credits(_SAME_DAY, day)
|
||||||
|
with_new = count_day_credits(_SAME_DAY, [*day, new_entry])
|
||||||
|
if with_new == before:
|
||||||
|
# No new slot: either it earns nothing at all, or its slot is taken.
|
||||||
|
return CreditSlot.OCCUPIED if _counts_alone(new_entry) else CreditSlot.NONE
|
||||||
|
return CreditSlot.FURTHER if before else CreditSlot.FIRST
|
||||||
|
|
||||||
|
|
||||||
|
def _counts_alone(entry: Mapping[str, Any]) -> bool:
|
||||||
|
"""Whether ``entry`` would earn a credit on an otherwise empty day."""
|
||||||
|
return count_day_credits(_SAME_DAY, [entry]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def locked_shutdown_state(state_file: Path) -> Iterator[None]:
|
||||||
|
"""Hold :func:`bonus_lock` on ``state_file``; on failure, warn and go on.
|
||||||
|
|
||||||
|
The reset and the flat-bonus pass hold the same lock, so a credit can no
|
||||||
|
longer lose its minutes to their read-add-write. Never nest inside them:
|
||||||
|
a second flock on a new fd in the same process deadlocks.
|
||||||
|
"""
|
||||||
|
with ExitStack() as stack:
|
||||||
|
try:
|
||||||
|
stack.enter_context(bonus_lock(state_file))
|
||||||
|
except OSError as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"Could not lock %s (%s) — applying the workout credit without "
|
||||||
|
"it; a reset running at the same moment could overwrite it",
|
||||||
|
state_file,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
yield
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"""The one definition of "what day is it" for every day-keyed file.
|
"""The one definition of "what day is it" for every day-keyed file.
|
||||||
|
|
||||||
``log.json``, ``sick_day_state.json``, ``early_bird_pending.json``,
|
``log.json``, ``early_bird_pending.json`` and ``wake_state.json`` are all
|
||||||
``wake_state.json`` and the sick history are all keyed by ``YYYY-MM-DD``.
|
keyed by ``YYYY-MM-DD``.
|
||||||
Until 2026-09-13 each writer and reader derived that key on its own, and
|
Until 2026-09-13 each writer and reader derived that key on its own, and
|
||||||
most of them used ``datetime.now(tz=UTC)`` while the lock window, the phone
|
most of them used ``datetime.now(tz=UTC)`` while the lock window, the phone
|
||||||
app, RunnerUp verification and the status window used LOCAL time. In CEST
|
app, RunnerUp verification and the status window used LOCAL time. In CEST
|
||||||
|
|||||||
@ -4,27 +4,22 @@ The early-bird "still waiting to see if a real workout shows up" flag is a
|
|||||||
same-day pending marker, not a workout — it is intentionally kept out of
|
same-day pending marker, not a workout — it is intentionally kept out of
|
||||||
log.json (which is reserved for real outcomes) and instead lives in
|
log.json (which is reserved for real outcomes) and instead lives in
|
||||||
its own self-expiring, HMAC-signed state file, mirroring the pattern used by
|
its own self-expiring, HMAC-signed state file, mirroring the pattern used by
|
||||||
``_wake_state.py`` for the companion wake_alarm service.
|
``wake_alarm._state`` in the companion wake-alarm service. It is written when
|
||||||
|
the wake-alarm carrot defers the lock, so that when the carrot ends the next
|
||||||
|
run tries a phone/RunnerUp workout before locking.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from gatelock.log_integrity import compute_entry_hmac
|
from gatelock.log_integrity import compute_entry_hmac
|
||||||
|
|
||||||
from screen_locker._compliance_state import is_early_bird_pending
|
from screen_locker._compliance_state import is_early_bird_pending
|
||||||
from screen_locker._constants import (
|
from screen_locker._constants import EARLY_BIRD_PENDING_FILE
|
||||||
EARLY_BIRD_END_HOUR,
|
|
||||||
EARLY_BIRD_END_MINUTE,
|
|
||||||
EARLY_BIRD_PENDING_FILE,
|
|
||||||
EARLY_BIRD_START_HOUR,
|
|
||||||
EXTRA_BENEFITS_FILE,
|
|
||||||
)
|
|
||||||
from screen_locker._day import today_str
|
from screen_locker._day import today_str
|
||||||
from screen_locker._extra_benefits import has_extended_early_bird
|
from screen_locker._morning_session import MorningSkip, morning_skip_today
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -35,27 +30,27 @@ def _today_str() -> str:
|
|||||||
|
|
||||||
|
|
||||||
class EarlyBirdMixin:
|
class EarlyBirdMixin:
|
||||||
"""Mixin providing early-bird time window checks and pending-state helpers."""
|
"""Mixin providing the early-bird window check and pending-state helpers.
|
||||||
|
|
||||||
def _get_local_time_minutes(self) -> int:
|
Since 2026-09-20 the window is not a wall clock but the wake-alarm
|
||||||
"""Return current local time as minutes from midnight."""
|
morning-session carrot: open exactly while wake-alarm's signed
|
||||||
now = datetime.now(tz=UTC).astimezone()
|
``morning_session.json`` says the morning is being earned (before the
|
||||||
return now.hour * 60 + now.minute
|
alarm, during the session, and until 11:00 once it completed in time).
|
||||||
|
The old 05:00-08:30 clock locked someone who got up at 07:00 -- the
|
||||||
|
opposite of the goal -- and it was a second mechanism next to the one
|
||||||
|
that actually knows whether the user is up.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_morning_skip: MorningSkip | None = None
|
||||||
|
|
||||||
def _is_early_bird_time(self) -> bool:
|
def _is_early_bird_time(self) -> bool:
|
||||||
"""Return True if current local time is in the early bird window.
|
"""Whether the wake-alarm carrot is in force right now.
|
||||||
|
|
||||||
Normally the window closes at 08:30. When the current ISO week has an
|
Waits (bounded) for a freshly booted PC's refresher; the verdict is
|
||||||
extended early-bird reward (earned by 5+ workouts the prior week) the
|
kept on the instance so the skip detail can quote its ``exempt_until``.
|
||||||
window extends to 09:00.
|
|
||||||
"""
|
"""
|
||||||
minutes = self._get_local_time_minutes()
|
self._morning_skip = morning_skip_today(wait=True)
|
||||||
start = EARLY_BIRD_START_HOUR * 60
|
return self._morning_skip is not None
|
||||||
if has_extended_early_bird(EXTRA_BENEFITS_FILE):
|
|
||||||
end = 9 * 60 # 09:00
|
|
||||||
else:
|
|
||||||
end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
|
|
||||||
return start <= minutes < end
|
|
||||||
|
|
||||||
def _is_early_bird_pending(self) -> bool:
|
def _is_early_bird_pending(self) -> bool:
|
||||||
"""Check if today has an unresolved early-bird pending marker."""
|
"""Check if today has an unresolved early-bird pending marker."""
|
||||||
|
|||||||
241
screen_locker/_earned.py
Normal file
241
screen_locker/_earned.py
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
"""Shutdown hours from the shared earner registry (``earned_time``, kuhyx/utils).
|
||||||
|
|
||||||
|
Every gate's reward -- the workout, the LeetCode hour, the reading hour and
|
||||||
|
whatever is registered next -- is defined once in ``earned_time``, which
|
||||||
|
steam-backlog-enforcer reads for gaming time too. This module is the
|
||||||
|
screen-locker side of it: where the ledgers live, and how minutes are shown.
|
||||||
|
|
||||||
|
The gates publish facts only; none writes the shutdown config. Their ledgers
|
||||||
|
are HMAC-verified against :data:`HMAC_KEY_FILE` and *fail closed* -- an
|
||||||
|
unreadable ledger or key earns nothing, and says so.
|
||||||
|
|
||||||
|
Minutes end to end: the registry, ``/etc/shutdown-schedule.conf`` (its
|
||||||
|
``*_MINUTES`` keys) and every shutdown time in this package are minutes after
|
||||||
|
midnight, so a 30-minute earner is applied exactly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
import functools
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
import earned_time
|
||||||
|
|
||||||
|
from screen_locker._constants import HMAC_KEY_FILE
|
||||||
|
from screen_locker._day import today_str
|
||||||
|
from screen_locker._gate_off import gate_off
|
||||||
|
|
||||||
|
_logger: Final = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Every earner's ``ledger`` is relative to this. Read-only from here; resolved
|
||||||
|
# at call time so the test suite's redirect (tests/_isolated_state.py) applies.
|
||||||
|
LEDGER_HOME: Final = Path.home()
|
||||||
|
|
||||||
|
_MINUTES_PER_HOUR: Final = 60
|
||||||
|
|
||||||
|
|
||||||
|
def hhmm(minutes: int) -> str:
|
||||||
|
"""A time of day, given in minutes after midnight, as ``HH:MM``."""
|
||||||
|
hours, rest = divmod(minutes, _MINUTES_PER_HOUR)
|
||||||
|
return f"{hours:02d}:{rest:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def span(minutes: int) -> str:
|
||||||
|
"""A duration in minutes as ``2h``, ``30m`` or ``1h30m``."""
|
||||||
|
hours, rest = divmod(minutes, _MINUTES_PER_HOUR)
|
||||||
|
if not rest:
|
||||||
|
return f"{hours}h"
|
||||||
|
if not hours:
|
||||||
|
return f"{rest}m"
|
||||||
|
return f"{hours}h{rest:02d}m"
|
||||||
|
|
||||||
|
|
||||||
|
def ledger_file(earner: earned_time.Earner) -> Path:
|
||||||
|
"""Where ``earner``'s gate keeps its ledger."""
|
||||||
|
if earner.ledger is None:
|
||||||
|
msg = f"earner {earner.name!r} has no ledger"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return LEDGER_HOME / earner.ledger
|
||||||
|
|
||||||
|
|
||||||
|
def registry(day: date | None = None) -> tuple[earned_time.Earner, ...]:
|
||||||
|
"""The earners in force on ``day`` (default today).
|
||||||
|
|
||||||
|
``earners_for`` (earned_time >= 0.5) switches the registry on
|
||||||
|
``TUTOR_FROM``; an older earned_time has one static registry.
|
||||||
|
"""
|
||||||
|
fn = getattr(earned_time, "earners_for", None)
|
||||||
|
return tuple(earned_time.EARNERS if fn is None else fn(day))
|
||||||
|
|
||||||
|
|
||||||
|
def is_gate(earner: earned_time.Earner) -> bool:
|
||||||
|
"""A ledger-backed earner whose gate publishes credits (not the workout).
|
||||||
|
|
||||||
|
The workout has a ledger too, but screen-locker writes it and
|
||||||
|
``WorkoutCreditMixin`` applies it.
|
||||||
|
"""
|
||||||
|
return earner.ledger is not None and earner.name != earned_time.WORKOUT.name
|
||||||
|
|
||||||
|
|
||||||
|
def flat_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
|
||||||
|
"""The once-per-day, ledger-backed earners in force on ``day``."""
|
||||||
|
return tuple(e for e in registry(day) if e.kind == "flat" and is_gate(e))
|
||||||
|
|
||||||
|
|
||||||
|
def gate_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
|
||||||
|
"""Every gate earner in force on ``day``, flat and counted, in registry order."""
|
||||||
|
return tuple(e for e in registry(day) if is_gate(e))
|
||||||
|
|
||||||
|
|
||||||
|
def counted_gate_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
|
||||||
|
"""Gate earners paid per unit on ``day`` (the Automation tutor's blocks)."""
|
||||||
|
return tuple(e for e in registry(day) if e.kind == "counted" and is_gate(e))
|
||||||
|
|
||||||
|
|
||||||
|
def watched_earners() -> tuple[earned_time.Earner, ...]:
|
||||||
|
"""Every gate earner of every registry, so a cutover misses no ledger."""
|
||||||
|
fn = getattr(earned_time, "all_earners", None)
|
||||||
|
pool = earned_time.EARNERS if fn is None else fn()
|
||||||
|
return tuple(e for e in pool if is_gate(e))
|
||||||
|
|
||||||
|
|
||||||
|
def earned_today(
|
||||||
|
earner: earned_time.Earner, now: datetime | None = None
|
||||||
|
) -> bool | None:
|
||||||
|
"""Whether ``earner``'s gate recorded today's credit; ``None`` if unknown."""
|
||||||
|
day = (now or datetime.now(tz=UTC)).astimezone().date()
|
||||||
|
return earned_time.done_today(
|
||||||
|
earner,
|
||||||
|
ledger_file(earner),
|
||||||
|
HMAC_KEY_FILE,
|
||||||
|
now=now,
|
||||||
|
gate_off=gate_off(earner, day),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def earned_units(earner: earned_time.Earner, day: date) -> int | None:
|
||||||
|
"""How many of a counted earner's units count for ``day``; ``None`` if unknown."""
|
||||||
|
return earned_time.credit_units(
|
||||||
|
earner,
|
||||||
|
ledger_file(earner),
|
||||||
|
HMAC_KEY_FILE,
|
||||||
|
day,
|
||||||
|
gate_off=gate_off(earner, day),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gate_answers(day: date | None = None) -> dict[str, int | bool | None]:
|
||||||
|
"""Every gate earner's answer for ``day`` (default today), unknown logged.
|
||||||
|
|
||||||
|
Flat earners answer "done today"; counted ones (the tutor) their units.
|
||||||
|
On an earned_time without counted gates this is exactly
|
||||||
|
:func:`flat_answers`.
|
||||||
|
"""
|
||||||
|
target = day or datetime.now().astimezone().date()
|
||||||
|
answers: dict[str, int | bool | None] = dict(flat_answers())
|
||||||
|
for earner in counted_gate_earners(target):
|
||||||
|
units = earned_units(earner, target)
|
||||||
|
if units is None:
|
||||||
|
_warn_unknown(earner)
|
||||||
|
answers[earner.name] = units
|
||||||
|
return answers
|
||||||
|
|
||||||
|
|
||||||
|
def flat_answers() -> dict[str, bool | None]:
|
||||||
|
"""Today's answer from every flat earner, an unknown one logged."""
|
||||||
|
answers: dict[str, bool | None] = {}
|
||||||
|
for earner in flat_earners():
|
||||||
|
answer = earned_today(earner)
|
||||||
|
if answer is None:
|
||||||
|
_warn_unknown(earner)
|
||||||
|
answers[earner.name] = answer
|
||||||
|
return answers
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_unknown(earner: earned_time.Earner) -> None:
|
||||||
|
_logger.warning(
|
||||||
|
"%s state could not be checked; no shutdown time for it", earner.label
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# The day-aware ladder API (earned_time >= 0.4, from 2026-10-10). Looked up at
|
||||||
|
# call time so the installed 0.3.0 -- which has none of it -- still runs, on
|
||||||
|
# the raw fields it always used.
|
||||||
|
def first_minutes(earner: earned_time.Earner, day: date) -> int:
|
||||||
|
"""What ``earner``'s first unit pushes shutdown later by on ``day``."""
|
||||||
|
fn = getattr(earned_time, "shutdown_minutes_for", None)
|
||||||
|
return earner.shutdown_minutes if fn is None else int(fn(earner, day))
|
||||||
|
|
||||||
|
|
||||||
|
def extra_minutes(earner: earned_time.Earner, day: date) -> int:
|
||||||
|
"""What each further unit of a counted earner earns on ``day``."""
|
||||||
|
fn = getattr(earned_time, "extra_shutdown_minutes_for", None)
|
||||||
|
return earner.extra_shutdown_minutes if fn is None else int(fn(earner, day))
|
||||||
|
|
||||||
|
|
||||||
|
def first_credit_time(earner: earned_time.Earner, day: date) -> float | None:
|
||||||
|
"""Unix time of ``earner``'s first verified credit counting for ``day``.
|
||||||
|
|
||||||
|
``None`` when there is none, when it cannot be checked, and on an
|
||||||
|
``earned_time`` without ``first_credit_at`` (0.3.0) -- logged, because
|
||||||
|
then a late ledger credit counts from now, not from its row.
|
||||||
|
"""
|
||||||
|
fn = getattr(earned_time, "first_credit_at", None)
|
||||||
|
if fn is None:
|
||||||
|
_warn_no_first_credit()
|
||||||
|
return None
|
||||||
|
stamp: float | None = fn(
|
||||||
|
earner, ledger_file(earner), HMAC_KEY_FILE, day, gate_off=gate_off(earner, day)
|
||||||
|
)
|
||||||
|
return stamp
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def _warn_no_first_credit() -> None:
|
||||||
|
"""Once per process: the installed earned_time predates first_credit_at."""
|
||||||
|
_logger.warning(
|
||||||
|
"earned_time has no first_credit_at (needs >= 0.4); a late ledger "
|
||||||
|
"credit counts from now, not from its own time"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def first_credits(
|
||||||
|
earners: tuple[earned_time.Earner, ...], day: date
|
||||||
|
) -> dict[str, date | None] | None:
|
||||||
|
"""Each penalised ledger earner's first real credit up to ``day``, for resolve.
|
||||||
|
|
||||||
|
``earned_time.resolve`` / ``base_for`` take it as ``first_credits=`` (>= 0.6)
|
||||||
|
and then start a penalty no earlier than the day after the gate first
|
||||||
|
paid out. Keyed by name over ``earners`` -- the registry the answers came
|
||||||
|
from, so the two ``automation`` earners (either side of ``TUTOR_FROM``)
|
||||||
|
never collide. An earner without ``penalty_from``, a ledger or a matcher
|
||||||
|
is left out (``resolve`` falls back to its ``confirmed_on``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The map; ``None`` for a day before today -- history keeps the shutdown
|
||||||
|
it was enforced at, never re-resolved -- and on an earned_time
|
||||||
|
without ``maturity`` (< 0.6, logged), where callers resolve as before.
|
||||||
|
"""
|
||||||
|
if day < date.fromisoformat(today_str()):
|
||||||
|
return None
|
||||||
|
fn = getattr(earned_time, "maturity", None)
|
||||||
|
if fn is None:
|
||||||
|
_warn_no_maturity()
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
e.name: fn(e, ledger_file(e), HMAC_KEY_FILE, day).first_credit
|
||||||
|
for e in earners
|
||||||
|
if e.penalty_from is not None and e.ledger is not None and e.match is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def _warn_no_maturity() -> None:
|
||||||
|
"""Once per process: the installed earned_time predates maturity."""
|
||||||
|
_logger.warning(
|
||||||
|
"earned_time has no maturity (needs >= 0.6); every penalty starts on "
|
||||||
|
"its penalty_from, even for a gate that never paid out"
|
||||||
|
)
|
||||||
106
screen_locker/_earner_units.py
Normal file
106
screen_locker/_earner_units.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"""The ``earner-bonus.path`` unit: apply a flat bonus the moment it is earned.
|
||||||
|
|
||||||
|
The live bonus pass used to run only on the 15-minute ``workout-sync`` timer,
|
||||||
|
so a LeetCode solve could wait a quarter of an hour for its shutdown hour
|
||||||
|
while the gaming hour landed in seconds (2026-10-06). The path unit watches
|
||||||
|
every flat earner's ledger and starts ``earner-bonus.service``
|
||||||
|
(``--apply-bonuses``) when a gate writes one.
|
||||||
|
|
||||||
|
The watch list is rendered from the ``earned_time`` registry, not kept by
|
||||||
|
hand. Re-pinning the registry is a pip install that never re-runs the
|
||||||
|
installer, so :func:`sync_path_unit` -- called from every ``--sync-only``
|
||||||
|
pass -- rewrites an installed unit that has drifted from the registry. A new
|
||||||
|
earner is watched within 15 minutes with no step to remember.
|
||||||
|
|
||||||
|
``PathChanged=`` and not ``PathModified=``: the gates write a temp file and
|
||||||
|
rename it into place, which ``PathChanged`` sees, including the first write
|
||||||
|
into a directory that does not exist yet. ``PathModified`` would also fire
|
||||||
|
mid-write on a gate that writes in place, and a half-written ledger reads as
|
||||||
|
"could not check".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from screen_locker._earned import ledger_file, watched_earners
|
||||||
|
|
||||||
|
__all__ = ["PATH_UNIT_NAME", "USER_UNIT_DIR", "render_path_unit", "sync_path_unit"]
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PATH_UNIT_NAME: Final = "earner-bonus.path"
|
||||||
|
USER_UNIT_DIR: Final = Path.home() / ".config" / "systemd" / "user"
|
||||||
|
|
||||||
|
_HEADER: Final = """\
|
||||||
|
# Generated by screen_locker._earner_units from the earned_time registry.
|
||||||
|
# Do not edit: the next --sync-only pass rewrites a unit that has drifted.
|
||||||
|
[Unit]
|
||||||
|
Description=Apply a flat earner's shutdown bonus as soon as its ledger changes
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
"""
|
||||||
|
_FOOTER: Final = """
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def render_path_unit() -> str:
|
||||||
|
"""The unit text: one ``PathChanged=`` per gate earner's ledger.
|
||||||
|
|
||||||
|
Every registry's gates, not just today's (``watched_earners``), so a
|
||||||
|
dated cutover (the Automation tutor's ``TUTOR_FROM``) needs no re-render
|
||||||
|
on the day. The header keeps saying "flat": changing it would rewrite the
|
||||||
|
installed unit for no behavioural change.
|
||||||
|
"""
|
||||||
|
ledgers = dict.fromkeys(ledger_file(e) for e in watched_earners())
|
||||||
|
watches = "".join(f"PathChanged={path}\n" for path in ledgers)
|
||||||
|
return _HEADER + watches + _FOOTER
|
||||||
|
|
||||||
|
|
||||||
|
def sync_path_unit(unit_dir: Path | None = None) -> bool:
|
||||||
|
"""Rewrite an installed path unit that no longer matches the registry.
|
||||||
|
|
||||||
|
An absent unit is left absent: it was never installed, or it was removed
|
||||||
|
on purpose, and installing units is the installer's job. *unit_dir*
|
||||||
|
defaults to :data:`USER_UNIT_DIR`, read at call time.
|
||||||
|
|
||||||
|
Returns True if the unit was rewritten.
|
||||||
|
"""
|
||||||
|
unit = (unit_dir or USER_UNIT_DIR) / PATH_UNIT_NAME
|
||||||
|
if not unit.is_file():
|
||||||
|
return False
|
||||||
|
wanted = render_path_unit()
|
||||||
|
try:
|
||||||
|
if unit.read_text(encoding="utf-8") == wanted:
|
||||||
|
return False
|
||||||
|
unit.write_text(wanted, encoding="utf-8")
|
||||||
|
subprocess.run(
|
||||||
|
["/usr/bin/systemctl", "--user", "daemon-reload"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.CalledProcessError) as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"%s differs from the earner registry and could not be refreshed: %s"
|
||||||
|
" -- a newly registered earner waits for the 15-minute sync",
|
||||||
|
unit,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
_logger.warning(
|
||||||
|
"%s was out of date with the earner registry; rewritten and reloaded",
|
||||||
|
unit,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# install_systemd.sh / arm.sh: write the unit into the given directory.
|
||||||
|
target = Path(sys.argv[1]) / PATH_UNIT_NAME
|
||||||
|
target.write_text(render_path_unit(), encoding="utf-8")
|
||||||
@ -2,18 +2,23 @@
|
|||||||
|
|
||||||
Tracks:
|
Tracks:
|
||||||
- Consecutive weeks with 5+ workouts (streak counter).
|
- Consecutive weeks with 5+ workouts (streak counter).
|
||||||
- Banked shutdown-time bonus hours earned from extra workouts, applied
|
- ISO weeks in which the early-bird window is extended to 09:00. Display-only
|
||||||
every day of the following week. This never reduces enforcement (unlike
|
bookkeeping: nothing reads it to decide a lock (early bird is wake-alarm's
|
||||||
a banked "skip a workout" credit would) — it only grants extra comfort
|
signed exemption since 2026-09-20).
|
||||||
time on top of a floor you still have to earn each day.
|
- 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
|
||||||
|
``earned_time`` ladder and stops at its daily ceiling. A legacy
|
||||||
|
``weekly_shutdown_bonus_hours`` map in an old state file is ignored, and
|
||||||
|
dropped at the next week transition's write.
|
||||||
|
|
||||||
State is persisted in ``extra_benefits_state.json`` next to this file.
|
State is persisted in ``extra_benefits_state.json`` next to this file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@ -25,8 +30,9 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_MILESTONE_INTERVAL = 4 # every 4-week streak → +1h extra shutdown bonus
|
_BONUS_THRESHOLD = 5 # workouts/week required to extend the streak + early-bird
|
||||||
_BONUS_THRESHOLD = 5 # workouts/week required to earn extra rewards
|
# The removed weekly shutdown bonus's map; ignored on read, dropped on write.
|
||||||
|
_LEGACY_BONUS_KEY = "weekly_shutdown_bonus_hours"
|
||||||
|
|
||||||
|
|
||||||
def _load_state(state_file: Path) -> dict[str, Any]:
|
def _load_state(state_file: Path) -> dict[str, Any]:
|
||||||
@ -39,7 +45,7 @@ def _load_state(state_file: Path) -> dict[str, Any]:
|
|||||||
except (OSError, json.JSONDecodeError) as exc:
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"Could not read extra-benefits state from %s: %s — starting from "
|
"Could not read extra-benefits state from %s: %s — starting from "
|
||||||
"empty state, so the workout streak and any banked bonus hours are "
|
"empty state, so the workout streak and early-bird extension are "
|
||||||
"lost for this run",
|
"lost for this run",
|
||||||
state_file,
|
state_file,
|
||||||
exc,
|
exc,
|
||||||
@ -56,38 +62,19 @@ def _save_state(state_file: Path, state: dict[str, Any]) -> None:
|
|||||||
_logger.warning("Failed to save extra benefits state: %s", exc)
|
_logger.warning("Failed to save extra benefits state: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def _current_iso_week(now: datetime) -> str:
|
def _current_iso_week(now: date) -> str:
|
||||||
"""Return *now*'s ISO week as ``YYYY-Www``."""
|
"""Return *now*'s ISO week as ``YYYY-Www``."""
|
||||||
year, week, _ = now.isocalendar()
|
year, week, _ = now.isocalendar()
|
||||||
return f"{year}-W{week:02d}"
|
return f"{year}-W{week:02d}"
|
||||||
|
|
||||||
|
|
||||||
def preview_bonus_if_week_ended_now(
|
|
||||||
current_week_count: int, current_streak: int
|
|
||||||
) -> tuple[int, int]:
|
|
||||||
"""(would_be_new_streak, would_be_bonus_hours) if the ISO week ended now.
|
|
||||||
|
|
||||||
Pure extraction of ``process_week_transition``'s threshold math — no file
|
|
||||||
I/O, safe to call from a read-only status view for a "next week preview."
|
|
||||||
"""
|
|
||||||
if current_week_count < _BONUS_THRESHOLD:
|
|
||||||
return 0, 0
|
|
||||||
streak = current_streak + 1
|
|
||||||
bonus_hours = current_week_count - 4
|
|
||||||
if streak % _MILESTONE_INTERVAL == 0:
|
|
||||||
bonus_hours += 1
|
|
||||||
return streak, bonus_hours
|
|
||||||
|
|
||||||
|
|
||||||
def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
||||||
"""Process last week's results if we've entered a new ISO week.
|
"""Process last week's results if we've entered a new ISO week.
|
||||||
|
|
||||||
Counts workouts from the previous ISO week. If count >= 5:
|
Counts workouts from the previous ISO week. If count >= 5:
|
||||||
- Increments the consecutive-streak counter.
|
- Increments the consecutive-streak counter.
|
||||||
- Banks (count - 4) hours of shutdown-time bonus for the current week,
|
|
||||||
applied once per day on top of the daily base reset.
|
|
||||||
- Marks the *current* ISO week as having extended early-bird (09:00).
|
- Marks the *current* ISO week as having extended early-bird (09:00).
|
||||||
- Adds +1h more bonus every ``_MILESTONE_INTERVAL`` streak weeks.
|
Otherwise the streak resets to 0. No shutdown-time hours are banked.
|
||||||
|
|
||||||
Returns a list of human-readable reward strings (empty if no transition).
|
Returns a list of human-readable reward strings (empty if no transition).
|
||||||
"""
|
"""
|
||||||
@ -113,44 +100,38 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
|||||||
prev_week_count = count_weekly_workouts(log_file, today=prev_week_dt)
|
prev_week_count = count_weekly_workouts(log_file, today=prev_week_dt)
|
||||||
|
|
||||||
streak = int(state.get("consecutive_5plus_weeks", 0))
|
streak = int(state.get("consecutive_5plus_weeks", 0))
|
||||||
weekly_bonus_hours: dict[str, int] = dict(
|
|
||||||
state.get("weekly_shutdown_bonus_hours", {})
|
|
||||||
)
|
|
||||||
eb_weeks: list[str] = list(state.get("extended_early_bird_iso_weeks", []))
|
eb_weeks: list[str] = list(state.get("extended_early_bird_iso_weeks", []))
|
||||||
|
|
||||||
rewards: list[str] = []
|
rewards: list[str] = []
|
||||||
prev_year, prev_week, _ = sunday_prev_week.isocalendar()
|
prev_week_str = _current_iso_week(sunday_prev_week)
|
||||||
prev_week_str = f"{prev_year}-W{prev_week:02d}"
|
|
||||||
|
|
||||||
if prev_week_count >= _BONUS_THRESHOLD:
|
if prev_week_count >= _BONUS_THRESHOLD:
|
||||||
extra = prev_week_count - 4
|
streak += 1
|
||||||
streak, bonus_hours = preview_bonus_if_week_ended_now(prev_week_count, streak)
|
|
||||||
if current_week_str not in eb_weeks:
|
if current_week_str not in eb_weeks:
|
||||||
eb_weeks.append(current_week_str)
|
eb_weeks.append(current_week_str)
|
||||||
rewards.append(
|
rewards.append(
|
||||||
f"{prev_week_count} workouts in {prev_week_str}! "
|
f"{prev_week_count} workouts in {prev_week_str}! {streak}-week streak"
|
||||||
f"+{extra}h shutdown bonus this week, early-bird extended to 09:00"
|
|
||||||
)
|
)
|
||||||
if streak % _MILESTONE_INTERVAL == 0:
|
|
||||||
rewards.append(f"{streak}-week streak milestone! +1h extra shutdown bonus")
|
|
||||||
weekly_bonus_hours[current_week_str] = bonus_hours
|
|
||||||
else:
|
else:
|
||||||
if streak > 0:
|
if streak > 0:
|
||||||
rewards.append(f"Streak reset (was {streak} weeks of 5+ workouts)")
|
rewards.append(f"Streak reset (was {streak} weeks of 5+ workouts)")
|
||||||
streak = 0
|
streak = 0
|
||||||
|
|
||||||
# Merge onto the loaded state rather than rebuilding it from these four
|
# Merge onto the loaded state rather than rebuilding it from these three
|
||||||
# keys: sibling fields written by the recovery scripts (e.g.
|
# keys: sibling fields written by the recovery scripts (e.g.
|
||||||
# ``shutdown_bonus_granted_for``, the guard that stops a compensation
|
# ``shutdown_bonus_granted_for``) live in this file too, and rebuilding
|
||||||
# bonus being granted twice) live in this file too, and rebuilding would
|
# would drop them at the next weekly rollover. Only the dead weekly-bonus
|
||||||
# drop them at the next weekly rollover — silently re-arming a double-grant.
|
# map is dropped, so no reader can mistake it for live state.
|
||||||
updated = dict(state)
|
updated = dict(state)
|
||||||
|
updated.pop(_LEGACY_BONUS_KEY, None)
|
||||||
updated.update(
|
updated.update(
|
||||||
{
|
{
|
||||||
"consecutive_5plus_weeks": streak,
|
"consecutive_5plus_weeks": streak,
|
||||||
"last_processed_iso_week": current_week_str,
|
"last_processed_iso_week": current_week_str,
|
||||||
"weekly_shutdown_bonus_hours": weekly_bonus_hours,
|
|
||||||
"extended_early_bird_iso_weeks": eb_weeks,
|
"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)
|
_save_state(state_file, updated)
|
||||||
@ -162,18 +143,6 @@ def current_streak(state_file: Path) -> int:
|
|||||||
return int(_load_state(state_file).get("consecutive_5plus_weeks", 0))
|
return int(_load_state(state_file).get("consecutive_5plus_weeks", 0))
|
||||||
|
|
||||||
|
|
||||||
def weekly_shutdown_bonus_hours(
|
|
||||||
state_file: Path, *, today: datetime | None = None
|
|
||||||
) -> int:
|
|
||||||
"""Return the banked shutdown-time bonus (hours) for the current ISO week."""
|
|
||||||
now = today if today is not None else datetime.now(tz=UTC).astimezone()
|
|
||||||
current_week_str = _current_iso_week(now)
|
|
||||||
bonus_hours: dict[str, int] = _load_state(state_file).get(
|
|
||||||
"weekly_shutdown_bonus_hours", {}
|
|
||||||
)
|
|
||||||
return int(bonus_hours.get(current_week_str, 0))
|
|
||||||
|
|
||||||
|
|
||||||
def has_extended_early_bird(state_file: Path, *, today: datetime | None = None) -> bool:
|
def has_extended_early_bird(state_file: Path, *, today: datetime | None = None) -> bool:
|
||||||
"""Return True if the current ISO week has an extended early-bird window (09:00)."""
|
"""Return True if the current ISO week has an extended early-bird window (09:00)."""
|
||||||
now = today if today is not None else datetime.now(tz=UTC).astimezone()
|
now = today if today is not None else datetime.now(tz=UTC).astimezone()
|
||||||
@ -182,3 +151,15 @@ def has_extended_early_bird(state_file: Path, *, today: datetime | None = None)
|
|||||||
"extended_early_bird_iso_weeks", []
|
"extended_early_bird_iso_weeks", []
|
||||||
)
|
)
|
||||||
return current_week_str in eb_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", []))
|
||||||
|
|||||||
101
screen_locker/_gate_bonus.py
Normal file
101
screen_locker/_gate_bonus.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"""Live shutdown credit for counted gate earners: paid per unit, as it lands.
|
||||||
|
|
||||||
|
A flat earner (LeetCode, reading) pays once a day, stamped
|
||||||
|
``<name>_bonus_date``. A counted gate earner -- the Automation tutor, one
|
||||||
|
signed ledger row per verified 15-minute block -- pays per unit, so its stamp
|
||||||
|
is ``<name>_bonus_units``: the day, and how many units the config already
|
||||||
|
holds. The live pass pays only the difference, priced by
|
||||||
|
``Earner.shutdown_for`` so the rung's per-unit split (15 per tutor block) and the
|
||||||
|
``max_units`` cap come from the registry, never from here.
|
||||||
|
|
||||||
|
The workout is counted too, but screen-locker owns its log and
|
||||||
|
``WorkoutCreditMixin`` applies it; it is never in this pass.
|
||||||
|
|
||||||
|
State I/O stays in :mod:`screen_locker._shutdown_base`; these functions read
|
||||||
|
and update the state dict it hands over.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from screen_locker._earned import earned_units, is_gate, span
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
|
||||||
|
import earned_time
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def date_stamp(earner: earned_time.Earner) -> str:
|
||||||
|
"""The once-per-day key of a flat earner's live pass in the state file."""
|
||||||
|
return f"{earner.name}_bonus_date"
|
||||||
|
|
||||||
|
|
||||||
|
def units_stamp(earner: earned_time.Earner) -> str:
|
||||||
|
"""The per-day key of ``earner``'s applied units in the state file."""
|
||||||
|
return f"{earner.name}_bonus_units"
|
||||||
|
|
||||||
|
|
||||||
|
def applied_units(state: dict[str, Any], earner: earned_time.Earner, today: str) -> int:
|
||||||
|
"""Units of ``earner`` the config already holds today (0 if none stamped)."""
|
||||||
|
raw = state.get(units_stamp(earner))
|
||||||
|
if not isinstance(raw, dict) or raw.get("date") != today:
|
||||||
|
return 0
|
||||||
|
units = raw.get("units")
|
||||||
|
return units if isinstance(units, int) and units > 0 else 0
|
||||||
|
|
||||||
|
|
||||||
|
def reset_stamps(terms: Iterable[earned_time.Term], today: str) -> dict[str, object]:
|
||||||
|
"""The stamps for what a daily reset already wrote into the config.
|
||||||
|
|
||||||
|
A flat earner that paid is stamped with the date, a counted gate with
|
||||||
|
its units, so the live pass does not add either a second time today.
|
||||||
|
"""
|
||||||
|
stamps: dict[str, object] = {}
|
||||||
|
for t in terms:
|
||||||
|
if t.earner.kind == "flat" and t.shutdown_minutes:
|
||||||
|
stamps[date_stamp(t.earner)] = today
|
||||||
|
elif t.earner.kind == "counted" and is_gate(t.earner) and t.answer:
|
||||||
|
stamps[units_stamp(t.earner)] = {"date": today, "units": t.answer}
|
||||||
|
return stamps
|
||||||
|
|
||||||
|
|
||||||
|
def apply_counted_bonus(
|
||||||
|
state: dict[str, Any],
|
||||||
|
adjust: Callable[[int], bool],
|
||||||
|
earner: earned_time.Earner,
|
||||||
|
today: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Push shutdown later by ``earner``'s units credited since the last pass.
|
||||||
|
|
||||||
|
``adjust`` is the mixin's ``_adjust_shutdown_time_by``. Updates ``state``
|
||||||
|
(the caller saves it) and returns True when it changed. An unreadable
|
||||||
|
ledger or key earns nothing and is logged.
|
||||||
|
"""
|
||||||
|
day = date.fromisoformat(today)
|
||||||
|
units = earned_units(earner, day)
|
||||||
|
if units is None:
|
||||||
|
_logger.warning(
|
||||||
|
"%s state could not be checked; no shutdown time for it", earner.label
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
done = applied_units(state, earner, today)
|
||||||
|
if units <= done:
|
||||||
|
return False
|
||||||
|
minutes = earner.shutdown_for(units, day) - earner.shutdown_for(done, day)
|
||||||
|
if minutes > 0 and not adjust(minutes):
|
||||||
|
_logger.warning("%s bonus: failed to write shutdown config.", earner.label)
|
||||||
|
return False
|
||||||
|
state[units_stamp(earner)] = {"date": today, "units": units}
|
||||||
|
_logger.info(
|
||||||
|
"%s bonus: %d unit(s) today, +%s shutdown time.",
|
||||||
|
earner.label,
|
||||||
|
units,
|
||||||
|
span(minutes),
|
||||||
|
)
|
||||||
|
return True
|
||||||
28
screen_locker/_gate_off.py
Normal file
28
screen_locker/_gate_off.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"""Whether an earner's gate stands down today, asked live (earned_time 0.18.0).
|
||||||
|
|
||||||
|
Only LeetCode has a price: while its gate is on, earned_time pays only the
|
||||||
|
signed charge that covers the whole day, never one solve of five (kuhy,
|
||||||
|
2026-10-24). A free day or a sick day not keeping ``leetcode`` has no price,
|
||||||
|
so one verified solve earns. Never stored: clearing the sick day takes the
|
||||||
|
exception back on the next read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import earned_time
|
||||||
|
import freedays
|
||||||
|
import sickdays
|
||||||
|
|
||||||
|
from screen_locker._sick import stands_down
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
def gate_off(earner: earned_time.Earner, day: date) -> bool:
|
||||||
|
"""Whether ``earner``'s gate is off on ``day`` (always False but LeetCode)."""
|
||||||
|
if earner.name != earned_time.LEETCODE.name:
|
||||||
|
return False
|
||||||
|
return freedays.is_free_day(day) or stands_down(sickdays.LOCKER_LEETCODE, day)
|
||||||
98
screen_locker/_glyphs.py
Normal file
98
screen_locker/_glyphs.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"""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)
|
||||||
127
screen_locker/_grace_floor.py
Normal file
127
screen_locker/_grace_floor.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
"""When the day's first task was done, and the day's floor lift.
|
||||||
|
|
||||||
|
The grace floor (first task + 60 min) is gone since 2026-10-17: a credit at
|
||||||
|
or after tonight's shutdown now counts from its own time
|
||||||
|
(:mod:`screen_locker._late_credit`), which is what the floor approximated,
|
||||||
|
and the floor's lift then absorbed minutes the late credit had already
|
||||||
|
restarted from (LeetCode late at 19:00, reading at 19:30: 20:10, not 20:20).
|
||||||
|
|
||||||
|
What stays: :func:`first_done_at` (shown by the preview and the reset log)
|
||||||
|
and the lift file. The lift is ``0`` on every day now (no floor lifts the
|
||||||
|
config above the earned sum), but ``plan_absorb`` still spends a stale one
|
||||||
|
recorded before the change, once.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import earned_time
|
||||||
|
|
||||||
|
from screen_locker._constants import GRACE_STATE_FILE
|
||||||
|
from screen_locker._day import today_str
|
||||||
|
from screen_locker._earned import first_credit_time, gate_earners
|
||||||
|
from screen_locker._log_io import load_workout_log
|
||||||
|
from screen_locker._weekly_check import credit_key, day_workout_index
|
||||||
|
from screen_locker._workout_ledger import entry_done_at
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MON_WED = frozenset({0, 1, 2})
|
||||||
|
|
||||||
|
|
||||||
|
def workout_done_at(log_file: Path, day: date) -> float | None:
|
||||||
|
"""The completion time of ``day``'s first counted workout, if any."""
|
||||||
|
iso = day.isoformat()
|
||||||
|
entries = [
|
||||||
|
e for e in load_workout_log(log_file).get(iso, []) if isinstance(e, dict)
|
||||||
|
]
|
||||||
|
siblings = day_workout_index(entries)
|
||||||
|
times = [
|
||||||
|
stamp
|
||||||
|
for index, entry in enumerate(entries)
|
||||||
|
if credit_key(iso, index, entry, siblings) is not None
|
||||||
|
and (stamp := entry_done_at(entry, day)) is not None
|
||||||
|
]
|
||||||
|
return min(times, default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _workout_time(day: date, log_file: Path | None, *, rest_day: bool) -> float | None:
|
||||||
|
"""When ``day``'s first workout was done: the ledger's and the log's view.
|
||||||
|
|
||||||
|
The workout ledger (``earned_time`` >= 0.4) holds only RunnerUp and rest
|
||||||
|
days, so the log still answers for manual and StrongLifts workouts. On a
|
||||||
|
rest day the ledger is skipped: its rest row is stamped at the day's
|
||||||
|
midnight, which is not a completion and would otherwise erase the floor.
|
||||||
|
"""
|
||||||
|
times: list[float | None] = []
|
||||||
|
if log_file is not None:
|
||||||
|
times.append(workout_done_at(log_file, day))
|
||||||
|
if not rest_day and earned_time.WORKOUT.ledger is not None:
|
||||||
|
times.append(first_credit_time(earned_time.WORKOUT, day))
|
||||||
|
return min((t for t in times if t is not None), default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def first_done_at(
|
||||||
|
day: date, log_file: Path | None, *, rest_day: bool = False
|
||||||
|
) -> float | None:
|
||||||
|
"""The earliest moment any earner was done on ``day``; ``None`` if none.
|
||||||
|
|
||||||
|
Every gate in force on ``day`` counts, flat or counted: the Automation
|
||||||
|
tutor's first verified block starts the floor like a LeetCode solve does.
|
||||||
|
"""
|
||||||
|
times = [first_credit_time(e, day) for e in gate_earners(day)]
|
||||||
|
times.append(_workout_time(day, log_file, rest_day=rest_day))
|
||||||
|
on_day = [
|
||||||
|
t
|
||||||
|
for t in times
|
||||||
|
if t is not None and datetime.fromtimestamp(t).astimezone().date() == day
|
||||||
|
]
|
||||||
|
return min(on_day, default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def load_lift(state_file: Path, today: str) -> int:
|
||||||
|
"""Today's recorded floor lift (grace or sick), 0 when none or unreadable."""
|
||||||
|
if not state_file.exists():
|
||||||
|
return 0 # no lift recorded yet: nothing to absorb
|
||||||
|
try:
|
||||||
|
raw = json.loads(state_file.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"Grace state %s unreadable (%s); lift taken as 0", state_file, exc
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
if not isinstance(raw, dict) or raw.get("date") != today:
|
||||||
|
return 0
|
||||||
|
return max(0, int(raw.get("lift", 0)))
|
||||||
|
|
||||||
|
|
||||||
|
def save_lift(lift: int, state_file: Path | None = None) -> None:
|
||||||
|
"""Record how far the floor lifts today's shutdown above the earned time."""
|
||||||
|
state_file = state_file or GRACE_STATE_FILE
|
||||||
|
payload = {"date": today_str(), "lift": max(0, lift)}
|
||||||
|
try:
|
||||||
|
state_file.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
_logger.warning("Could not record the grace lift in %s (%s)", state_file, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_absorb(minutes: int, state_file: Path | None = None) -> tuple[int, int]:
|
||||||
|
"""How much of an ``minutes`` addition moves the config, and the lift left.
|
||||||
|
|
||||||
|
Commit the lift with :func:`save_lift` only once the write succeeded.
|
||||||
|
"""
|
||||||
|
lift = load_lift(state_file or GRACE_STATE_FILE, today_str())
|
||||||
|
return max(0, minutes - lift), max(0, lift - minutes)
|
||||||
|
|
||||||
|
|
||||||
|
def tonight_minutes(config: tuple[int, int, int], day: date) -> int:
|
||||||
|
"""``day``'s shutdown from a (mon_wed, thu_sun, morning_end) config."""
|
||||||
|
mon_wed, thu_sun, _ = config
|
||||||
|
return mon_wed if day.weekday() in _MON_WED else thu_sun
|
||||||
@ -42,39 +42,35 @@ def build_heat_skip_content(
|
|||||||
outer = tk.Frame(parent, bg=_COLORS.palette.bg)
|
outer = tk.Frame(parent, bg=_COLORS.palette.bg)
|
||||||
outer.place(relx=0.5, rely=0.5, anchor="center")
|
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(
|
tk.Label(
|
||||||
outer,
|
outer,
|
||||||
text="\u2600 Too hot to workout?",
|
text="\u25b2",
|
||||||
font=_COLORS.font("body", bold=True, family=_FONT),
|
font=_COLORS.font("display", bold=True, family=_FONT),
|
||||||
bg=_COLORS.palette.bg,
|
bg=_COLORS.palette.bg,
|
||||||
fg=_COLORS.palette.warning,
|
fg=_COLORS.palette.warning,
|
||||||
).pack(pady=(0, _COLORS.space("sm")))
|
).pack(pady=(0, _COLORS.space("sm")))
|
||||||
|
|
||||||
tk.Label(
|
tk.Label(
|
||||||
outer,
|
outer,
|
||||||
text=(
|
# The city name was dropped too: the reading is what matters.
|
||||||
f"{HEAT_SKIP_CITY}: {temp:.0f}\u00b0C"
|
text=f"{temp:.0f}\u00b0C \u2265 {HEAT_SKIP_TEMP_THRESHOLD}\u00b0C",
|
||||||
f" (threshold: {HEAT_SKIP_TEMP_THRESHOLD}\u00b0C)"
|
|
||||||
),
|
|
||||||
font=_COLORS.font("body", family=_FONT),
|
font=_COLORS.font("body", family=_FONT),
|
||||||
bg=_COLORS.palette.bg,
|
bg=_COLORS.palette.bg,
|
||||||
fg=_COLORS.palette.muted,
|
fg=_COLORS.palette.warning,
|
||||||
).pack(pady=_COLORS.space("xs"))
|
).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 = tk.Frame(outer, bg=_COLORS.palette.bg)
|
||||||
btn_frame.pack(pady=_COLORS.space("lg"))
|
btn_frame.pack(pady=_COLORS.space("lg"))
|
||||||
|
|
||||||
|
# Was "Skip workout" (warning fill) / "No, I'll workout" (neutral fill).
|
||||||
skip_button = tk.Button(
|
skip_button = tk.Button(
|
||||||
btn_frame,
|
btn_frame,
|
||||||
text="Skip workout",
|
name="skip",
|
||||||
|
text="\u21b7",
|
||||||
command=on_skip,
|
command=on_skip,
|
||||||
bg=_COLORS.palette.warning,
|
bg=_COLORS.palette.warning,
|
||||||
fg=_COLORS.palette.on_fill,
|
fg=_COLORS.palette.on_fill,
|
||||||
@ -89,7 +85,8 @@ def build_heat_skip_content(
|
|||||||
|
|
||||||
decline_button = tk.Button(
|
decline_button = tk.Button(
|
||||||
btn_frame,
|
btn_frame,
|
||||||
text="No, I'll workout",
|
name="decline",
|
||||||
|
text="▶",
|
||||||
command=on_decline,
|
command=on_decline,
|
||||||
bg=_COLORS.palette.field_bg,
|
bg=_COLORS.palette.field_bg,
|
||||||
fg=_COLORS.palette.fg,
|
fg=_COLORS.palette.fg,
|
||||||
@ -130,7 +127,7 @@ class HeatSkipMixin:
|
|||||||
# Use the root itself (not a Toplevel) so we can go fullscreen.
|
# Use the root itself (not a Toplevel) so we can go fullscreen.
|
||||||
# This window is destroyed before the main GateRoot is created.
|
# This window is destroyed before the main GateRoot is created.
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
root.title("Extreme Heat")
|
root.title("")
|
||||||
root.configure(bg=_COLORS.palette.bg)
|
root.configure(bg=_COLORS.palette.bg)
|
||||||
root.attributes("-fullscreen", True)
|
root.attributes("-fullscreen", True)
|
||||||
root.attributes("-topmost", True)
|
root.attributes("-topmost", True)
|
||||||
|
|||||||
86
screen_locker/_hold.py
Normal file
86
screen_locker/_hold.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
"""A declared test hold keeps the workout locker from showing any window.
|
||||||
|
|
||||||
|
``holds`` (kuhyx/utils, ``holds-v0.1.0``) is the shared "a proctored test is
|
||||||
|
running, leave the PC usable" switch. Every workout-locker path -- the relaxed
|
||||||
|
day's optional prompt, the strict lock and the early-bird re-check -- runs
|
||||||
|
through ``_startup_checks._check_non_verify_exits``, so one rung there covers
|
||||||
|
them all. It sits BELOW the reset/bonus/ingest/early-bird bookkeeping: a hold
|
||||||
|
only suppresses the window, never a credit or a penalty term.
|
||||||
|
|
||||||
|
Fail closed: if ``holds`` cannot be imported or read, there is no hold and the
|
||||||
|
locker behaves exactly as it did before, with an ERROR in the journal. A hold
|
||||||
|
that fails open would be a free escape from the gate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
from tkinter import Misc
|
||||||
|
|
||||||
|
__all__ = ["HOLD_REASON", "skip_during_hold", "watch_for_hold", "workout_hold_active"]
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
HOLD_REASON = "test_hold_active"
|
||||||
|
_GATE = "workout"
|
||||||
|
# Well inside the 60 s a hold may take to lift an open window.
|
||||||
|
POLL_MS = 30_000
|
||||||
|
|
||||||
|
|
||||||
|
def workout_hold_active(now: datetime | None = None) -> bool:
|
||||||
|
"""Whether a declared hold covers the workout gate right now."""
|
||||||
|
try:
|
||||||
|
# Imported at call time so a missing package fails closed, per call.
|
||||||
|
holds = importlib.import_module("holds")
|
||||||
|
return bool(holds.covers(_GATE, now))
|
||||||
|
except ImportError, OSError, ValueError, TypeError, KeyError:
|
||||||
|
_logger.exception(
|
||||||
|
"Could not read the test holds — treating it as no hold, so "
|
||||||
|
"the workout locker behaves as normal"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def skip_during_hold(record_skip: Callable[..., None]) -> bool:
|
||||||
|
"""Skip enforcement while a hold is active.
|
||||||
|
|
||||||
|
Takes the locker's ``_record_skip`` (which exits the process) rather than
|
||||||
|
the locker, like ``_night_lockdown``. Returns True when the skip was
|
||||||
|
recorded, False when the ladder may go on.
|
||||||
|
"""
|
||||||
|
if not workout_hold_active():
|
||||||
|
return False
|
||||||
|
record_skip(
|
||||||
|
HOLD_REASON,
|
||||||
|
"A declared test hold is active — no workout prompt or lock window "
|
||||||
|
"until it ends (credit and penalty bookkeeping already ran).",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def watch_for_hold(root: Misc, close: Callable[[], None]) -> None:
|
||||||
|
"""Close an already-open window within one poll of a hold starting.
|
||||||
|
|
||||||
|
``close`` is the locker's own ``close`` -- it tears the window down and
|
||||||
|
exits 0 without writing a credit or a penalty, so a hold that interrupts
|
||||||
|
an open prompt/lock changes nothing but the screen. Polls on the Tk loop
|
||||||
|
(no thread) and stops re-arming once it fires.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _tick() -> None:
|
||||||
|
if workout_hold_active():
|
||||||
|
_logger.warning(
|
||||||
|
"A test hold started while the workout window was open — "
|
||||||
|
"closing it without credit or penalty."
|
||||||
|
)
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
root.after(POLL_MS, _tick)
|
||||||
|
|
||||||
|
root.after(POLL_MS, _tick)
|
||||||
@ -10,7 +10,7 @@ app serves the same JSON over ``WORKOUT_HTTP_PORT``.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from concurrent.futures import ( # pylint: disable=no-name-in-module
|
from concurrent.futures import (
|
||||||
ThreadPoolExecutor,
|
ThreadPoolExecutor,
|
||||||
as_completed,
|
as_completed,
|
||||||
)
|
)
|
||||||
|
|||||||
106
screen_locker/_late_credit.py
Normal file
106
screen_locker/_late_credit.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"""A credit at or after tonight's shutdown counts from its own time.
|
||||||
|
|
||||||
|
Shutting down must never make a finished task impossible to submit (kuhy,
|
||||||
|
2026-10-17). The config is read-add-write, so a credit landing after the
|
||||||
|
shutdown used to add its minutes to a time already past: LeetCode at 19:00
|
||||||
|
after an 18:00 shutdown made 19:00, and the machine stayed locked. Now such
|
||||||
|
a credit moves tonight to ``its own time + its reward``, capped at the
|
||||||
|
ceiling -- only the ceiling (23:00) is hard.
|
||||||
|
|
||||||
|
The anchor is the credit's own time (a run's end, a ledger row's), never the
|
||||||
|
moment a sync applied it, so a late upload does not buy extra time. Past the
|
||||||
|
shutdown the grace and sick lifts are spent: the caller clears the lift.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from screen_locker._ceiling import ceiling
|
||||||
|
from screen_locker._day import today_str
|
||||||
|
from screen_locker._earned import hhmm
|
||||||
|
from screen_locker._grace_floor import save_lift, tonight_minutes
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> float:
|
||||||
|
"""The clock a credit without its own time is stamped with."""
|
||||||
|
return time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def late_tonight(
|
||||||
|
config: tuple[int, int, int], day: date, at: float, minutes: int
|
||||||
|
) -> int | None:
|
||||||
|
"""Tonight's new shutdown for a late credit, or ``None`` if it is not late.
|
||||||
|
|
||||||
|
Late means timed on ``day`` at or after tonight's shutdown: the check
|
||||||
|
script locks at ``now >= shutdown``, so a credit exactly on it is late.
|
||||||
|
"""
|
||||||
|
moment = datetime.fromtimestamp(at).astimezone()
|
||||||
|
if moment.date() != day:
|
||||||
|
return None
|
||||||
|
done = moment.hour * 60 + moment.minute
|
||||||
|
if done < tonight_minutes(config, day):
|
||||||
|
return None
|
||||||
|
return min(ceiling(day), done + minutes)
|
||||||
|
|
||||||
|
|
||||||
|
class LateCreditMixin:
|
||||||
|
"""Late credits and sick-day credits; composed into ShutdownMixin."""
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
|
||||||
|
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def _write_shutdown_config(
|
||||||
|
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ...
|
||||||
|
) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def _apply_sick_floor(self) -> bool: ...
|
||||||
|
|
||||||
|
def _raise_on_sick_day(self, minutes: int, at: float | None) -> bool:
|
||||||
|
"""A sick day's credit: raise to the derived target, never add.
|
||||||
|
|
||||||
|
The credit's row is already in the target (``_sick_floor``), so adding
|
||||||
|
its minutes on top would pay it twice; a late one still counts from
|
||||||
|
its own time. True even when nothing moved (capped, or already there):
|
||||||
|
the credit is accounted for, and a failed write is retried by the next
|
||||||
|
tick's sick pass.
|
||||||
|
"""
|
||||||
|
self._apply_sick_floor()
|
||||||
|
late = self._late_write(minutes, at)
|
||||||
|
return True if late is None else late
|
||||||
|
|
||||||
|
def _late_write(self, minutes: int, at: float | None) -> bool | None:
|
||||||
|
"""Write a late credit's shutdown; ``None`` when it is not late."""
|
||||||
|
day = date.fromisoformat(today_str())
|
||||||
|
try:
|
||||||
|
config = self._read_shutdown_config()
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
# The add path reads it again inside its own guard and fails there.
|
||||||
|
_logger.warning("Late credit: cannot read shutdown config (%s)", exc)
|
||||||
|
return None
|
||||||
|
if config is None:
|
||||||
|
return None # the add path reads it again and reports it
|
||||||
|
target = late_tonight(config, day, _now() if at is None else at, minutes)
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
mon_wed, thu_sun, morning = config
|
||||||
|
if not self._write_shutdown_config(
|
||||||
|
max(mon_wed, target), max(thu_sun, target), morning, restore=True
|
||||||
|
):
|
||||||
|
_logger.warning("Late credit: failed to write shutdown config")
|
||||||
|
return False
|
||||||
|
save_lift(0) # past the shutdown, the floors' lift is spent
|
||||||
|
_logger.info(
|
||||||
|
"Late credit: shutdown %s -> %s (earned at or after tonight's).",
|
||||||
|
hhmm(tonight_minutes(config, day)),
|
||||||
|
hhmm(target),
|
||||||
|
)
|
||||||
|
return True
|
||||||
59
screen_locker/_lock_invariant.py
Normal file
59
screen_locker/_lock_invariant.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
"""The one thing the enforce path must never do: lock over a logged workout.
|
||||||
|
|
||||||
|
Every skip in ``_startup_checks._check_non_verify_exits`` is a rung of a
|
||||||
|
first-match-wins ladder, and the ladder's ORDER is what decides whether a
|
||||||
|
logged workout counts. That order has been wrong before: on 2026-09-18 an
|
||||||
|
expired early-bird marker sat one rung above ``workout_logged_today``, so a
|
||||||
|
walk logged on the lock screen at 09:32 closed the lock and the 09:35 tick
|
||||||
|
locked again -- and would have every five minutes for the rest of the day.
|
||||||
|
The DECISION line even printed ``also=workout_logged_today``; the evidence
|
||||||
|
was there, only the gate was missing.
|
||||||
|
|
||||||
|
This module is that gate. It runs right before the ``enforced`` decision is
|
||||||
|
recorded and refuses to build a lock screen when a counted workout for today
|
||||||
|
is already in the log, whatever the ladder above it concluded. Reaching it
|
||||||
|
with a workout logged is a bug in the ladder, so it is reported at ERROR --
|
||||||
|
the locker must fail safe (no lock) and say so, not fail closed on the user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
GUARD_REASON = "workout_logged_today_guard"
|
||||||
|
GUARD_DETAIL = (
|
||||||
|
"A counted workout is already logged today but the exemption ladder fell "
|
||||||
|
"through to enforcement — refusing to lock (ladder bug, see journal ERROR)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def refuse_to_lock_over_logged_workout(
|
||||||
|
has_logged_today: Callable[[], bool],
|
||||||
|
record_skip: Callable[[str, str], None],
|
||||||
|
) -> bool:
|
||||||
|
"""Skip enforcement if today already holds a counted workout.
|
||||||
|
|
||||||
|
Takes the locker's two relevant methods rather than the locker itself so
|
||||||
|
the guard stays a pure function of "is there a workout?" and "how do I
|
||||||
|
skip?". Returns True when the guard fired (the skip was recorded through
|
||||||
|
*record_skip*, which exits the process), False when the lock may go
|
||||||
|
ahead. The ERROR is deliberate: a fired guard means a rung above it is
|
||||||
|
misordered, and that has to be visible in the journal rather than quietly
|
||||||
|
absorbed as one more skip.
|
||||||
|
"""
|
||||||
|
if not has_logged_today():
|
||||||
|
return False
|
||||||
|
_logger.error(
|
||||||
|
"Enforcement reached with a workout already logged today — the "
|
||||||
|
"early-exit ladder in _startup_checks/_auto_upgrade is misordered. "
|
||||||
|
"Refusing to lock; fix the ladder so workout_logged_today is reached."
|
||||||
|
)
|
||||||
|
record_skip(GUARD_REASON, GUARD_DETAIL)
|
||||||
|
return True
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import ExitStack
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
import json
|
import json
|
||||||
@ -11,9 +12,11 @@ from typing import TYPE_CHECKING
|
|||||||
from gatelock.log_integrity import compute_entry_hmac
|
from gatelock.log_integrity import compute_entry_hmac
|
||||||
|
|
||||||
from screen_locker import _compliance_state
|
from screen_locker import _compliance_state
|
||||||
|
from screen_locker._bonus_lock import bonus_lock
|
||||||
from screen_locker._day import today_str
|
from screen_locker._day import today_str
|
||||||
from screen_locker._log_io import load_workout_log
|
from screen_locker._log_io import load_workout_log
|
||||||
from screen_locker._manual_workout import MANUAL_WORKOUT_TYPE, manual_sync_record_id
|
from screen_locker._manual_workout import MANUAL_WORKOUT_TYPE, manual_sync_record_id
|
||||||
|
from screen_locker._workout_ledger import record_log_entry
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
@ -111,7 +114,30 @@ def write_signed_entry(
|
|||||||
|
|
||||||
Returns a :class:`RecordResult` so the credit path can tell an appended
|
Returns a :class:`RecordResult` so the credit path can tell an appended
|
||||||
workout from a duplicate and a first-of-day workout from an additional one.
|
workout from a duplicate and a first-of-day workout from an additional one.
|
||||||
|
|
||||||
|
The read-dedup-write runs under an exclusive lock beside the log: the
|
||||||
|
5-minute locker pass, the 15-minute sync and the RunnerUp upload watcher
|
||||||
|
all backfill the same run, and two of them reading before either wrote
|
||||||
|
would both see it missing -- two entries and, since only an appended
|
||||||
|
result is credited, two shutdown rewards.
|
||||||
"""
|
"""
|
||||||
|
with ExitStack() as stack:
|
||||||
|
try:
|
||||||
|
stack.enter_context(bonus_lock(log_file))
|
||||||
|
except OSError as e:
|
||||||
|
_logger.warning(
|
||||||
|
"Could not lock workout log %s (%s) — writing without the lock; "
|
||||||
|
"a pass running at the same moment could record this twice",
|
||||||
|
log_file,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return _write_signed_entry_locked(log_file, date, workout_data)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_signed_entry_locked(
|
||||||
|
log_file: Path, date: str, workout_data: Mapping[str, object]
|
||||||
|
) -> RecordResult:
|
||||||
|
"""Body of :func:`write_signed_entry`; the caller holds the log lock."""
|
||||||
logs = load_workout_log(log_file)
|
logs = load_workout_log(log_file)
|
||||||
entries = logs.setdefault(date, [])
|
entries = logs.setdefault(date, [])
|
||||||
prior = list(entries)
|
prior = list(entries)
|
||||||
@ -137,6 +163,10 @@ def write_signed_entry(
|
|||||||
json.dump(logs, f, indent=2)
|
json.dump(logs, f, indent=2)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
_logger.warning("Could not save workout log: %s", e)
|
_logger.warning("Could not save workout log: %s", e)
|
||||||
|
else:
|
||||||
|
# The one chokepoint every credit passes, so the signed workout
|
||||||
|
# ledger (earned_time's view of the workout) can never miss one.
|
||||||
|
record_log_entry(date, entries)
|
||||||
return RecordResult(appended=True, prior_entries=prior)
|
return RecordResult(appended=True, prior_entries=prior)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,11 @@ it bypassed those checks; this routes through the ordinary sync-ingest path
|
|||||||
(``ingest_manual_records``), so validation, the rolling budget, HMAC signing
|
(``ingest_manual_records``), so validation, the rolling budget, HMAC signing
|
||||||
and workout_id dedup are the shared implementation rather than a second copy
|
and workout_id dedup are the shared implementation rather than a second copy
|
||||||
that can drift.
|
that can drift.
|
||||||
|
|
||||||
|
``--date`` may name a FUTURE day (up to 7 days ahead; past dates are refused).
|
||||||
|
Such an entry is not ingested: it is held and published by
|
||||||
|
``_manual_schedule`` and ingested -- and credited -- on its date by the
|
||||||
|
ordinary sync pass.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -22,6 +27,8 @@ import logging
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from screen_locker._day import today_str
|
from screen_locker._day import today_str
|
||||||
|
from screen_locker._manual_future import is_future_date, validate_workout_date
|
||||||
|
from screen_locker._manual_schedule import budget_error, schedule_future_workout
|
||||||
from screen_locker._manual_sync import ingest_manual_records
|
from screen_locker._manual_sync import ingest_manual_records
|
||||||
from screen_locker._manual_workout import (
|
from screen_locker._manual_workout import (
|
||||||
SPORT_CHOICES,
|
SPORT_CHOICES,
|
||||||
@ -73,7 +80,14 @@ def add_manual_arguments(parser: argparse.ArgumentParser) -> None:
|
|||||||
default="",
|
default="",
|
||||||
help=f"what the activity was; required when --sport {SPORT_OTHER}",
|
help=f"what the activity was; required when --sport {SPORT_OTHER}",
|
||||||
)
|
)
|
||||||
parser.add_argument("--date", default="", help="ISO date; defaults to today")
|
parser.add_argument(
|
||||||
|
"--date",
|
||||||
|
default="",
|
||||||
|
help=(
|
||||||
|
"ISO date, today up to 7 days ahead (no past dates); defaults to "
|
||||||
|
"today. A future date is held and credited automatically on it."
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument("--start", required=True, help="HH:MM")
|
parser.add_argument("--start", required=True, help="HH:MM")
|
||||||
parser.add_argument("--end", required=True, help="HH:MM")
|
parser.add_argument("--end", required=True, help="HH:MM")
|
||||||
parser.add_argument("--location", required=True)
|
parser.add_argument("--location", required=True)
|
||||||
@ -114,6 +128,52 @@ def _draft(args: argparse.Namespace) -> ManualWorkoutDraft:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule(log_file: Path, draft: ManualWorkoutDraft, date: str) -> int:
|
||||||
|
"""Hold a FUTURE-dated workout and publish it; exit code for the CLI.
|
||||||
|
|
||||||
|
Never written to log.json or signed here: it is ingested by the ordinary
|
||||||
|
sync pass on its date.
|
||||||
|
"""
|
||||||
|
scheduled = schedule_future_workout(log_file, draft, date)
|
||||||
|
if not scheduled.scheduled:
|
||||||
|
_logger.error(
|
||||||
|
"Manual workout %s was NOT scheduled — %s",
|
||||||
|
scheduled.record_id,
|
||||||
|
scheduled.reason,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
_logger.info("Manual workout %s: %s", scheduled.record_id, scheduled.reason)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _log_today(log_file: Path, draft: ManualWorkoutDraft, date: str, start: str) -> int:
|
||||||
|
"""Log today's workout through the ordinary ingest; exit code for the CLI."""
|
||||||
|
record_id = manual_sync_record_id(date, start)
|
||||||
|
payload = build_sync_payload(draft, date)
|
||||||
|
# Today's entry takes the ordinary path, but its budget now also counts
|
||||||
|
# pending future entries (a window containing today contains tomorrow too).
|
||||||
|
over_budget = budget_error(log_file, date)
|
||||||
|
if over_budget is not None:
|
||||||
|
_logger.error("Manual workout %s was NOT logged — %s", record_id, over_budget)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
ingested = ingest_manual_records(log_file, [(record_id, payload)], today=date)
|
||||||
|
if not ingested:
|
||||||
|
_logger.error(
|
||||||
|
"Manual workout %s was NOT logged — see the reason logged above "
|
||||||
|
"(invalid field, exhausted budget, or already logged)",
|
||||||
|
record_id,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
_logger.info(
|
||||||
|
"Logged manual workout %s. It publishes to the other devices on the "
|
||||||
|
"next sync pass (workout-sync.timer, every 15 minutes); run "
|
||||||
|
"--sync-only to push it now.",
|
||||||
|
record_id,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def run_manual_log(log_file: Path, argv: Sequence[str]) -> int:
|
def run_manual_log(log_file: Path, argv: Sequence[str]) -> int:
|
||||||
"""Log one manual workout headlessly. Returns a process exit code.
|
"""Log one manual workout headlessly. Returns a process exit code.
|
||||||
|
|
||||||
@ -142,21 +202,10 @@ def run_manual_log(log_file: Path, argv: Sequence[str]) -> int:
|
|||||||
_logger.error("Manual workout REFUSED — %s. Nothing was written.", error)
|
_logger.error("Manual workout REFUSED — %s. Nothing was written.", error)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
record_id = manual_sync_record_id(date, args.start)
|
date_error = validate_workout_date(date)
|
||||||
payload = build_sync_payload(draft, date)
|
if date_error is not None:
|
||||||
|
_logger.error("Manual workout REFUSED — %s. Nothing was written.", date_error)
|
||||||
ingested = ingest_manual_records(log_file, [(record_id, payload)], today=date)
|
|
||||||
if not ingested:
|
|
||||||
_logger.error(
|
|
||||||
"Manual workout %s was NOT logged — see the reason logged above "
|
|
||||||
"(invalid field, exhausted budget, or already logged)",
|
|
||||||
record_id,
|
|
||||||
)
|
|
||||||
return 1
|
return 1
|
||||||
_logger.info(
|
if is_future_date(date):
|
||||||
"Logged manual workout %s. It publishes to the other devices on the "
|
return _schedule(log_file, draft, date)
|
||||||
"next sync pass (workout-sync.timer, every 15 minutes); run "
|
return _log_today(log_file, draft, date, args.start)
|
||||||
"--sync-only to push it now.",
|
|
||||||
record_id,
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|||||||
222
screen_locker/_manual_future.py
Normal file
222
screen_locker/_manual_future.py
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
"""Future-dated manual workouts: the date rule and the per-date budget rule.
|
||||||
|
|
||||||
|
A manual workout may be logged AHEAD of time (``today .. today + 7 days``, never
|
||||||
|
a past date) and earns its normal credit on that date, not before. Two pure
|
||||||
|
rules live here, shared by every entry point (Tk dialog, CLI, sync ingest):
|
||||||
|
|
||||||
|
* :func:`validate_workout_date` -- the one 0..7-days-ahead check;
|
||||||
|
* :func:`budget_error_for_date` -- the rolling budget (2 per 7 days, 10 per 30)
|
||||||
|
counted against the WORKOUT's date, over logged credits AND pending future
|
||||||
|
entries, so a future entry holds its slot from the moment it is logged.
|
||||||
|
|
||||||
|
No I/O beyond reading ``log.json``; the pending ledger and the Firebase pull
|
||||||
|
are in ``_manual_pending`` / ``_manual_schedule``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from screen_locker._constants import (
|
||||||
|
MANUAL_WORKOUT_BUDGET_PER_7_DAYS,
|
||||||
|
MANUAL_WORKOUT_BUDGET_PER_30_DAYS,
|
||||||
|
MANUAL_WORKOUT_MAX_DAYS_AHEAD,
|
||||||
|
)
|
||||||
|
from screen_locker._day import today_str
|
||||||
|
from screen_locker._manual_workout_types import manual_credit_dates
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BudgetUsage",
|
||||||
|
"budget_closed_for_all_days",
|
||||||
|
"budget_error_for_date",
|
||||||
|
"budget_usage_for_date",
|
||||||
|
"is_future_date",
|
||||||
|
"parse_workout_date",
|
||||||
|
"validate_workout_date",
|
||||||
|
]
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# (window length in days, cap) -- a window of N days is [start, start + N - 1].
|
||||||
|
_WINDOWS: tuple[tuple[int, int], ...] = (
|
||||||
|
(7, MANUAL_WORKOUT_BUDGET_PER_7_DAYS),
|
||||||
|
(30, MANUAL_WORKOUT_BUDGET_PER_30_DAYS),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_workout_date(value: str) -> date | None:
|
||||||
|
"""Parse a strict ``YYYY-MM-DD`` string, or return None (and warn)."""
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value.strip(), "%Y-%m-%d").replace(tzinfo=UTC).date()
|
||||||
|
except ValueError as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"Date %r is not YYYY-MM-DD (%s) — treated as invalid", value, exc
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_future_date(value: str, *, today: str | None = None) -> bool:
|
||||||
|
"""True if ``value`` is a valid date strictly after today (local day)."""
|
||||||
|
parsed = parse_workout_date(value)
|
||||||
|
reference = parse_workout_date(today or today_str())
|
||||||
|
return parsed is not None and reference is not None and parsed > reference
|
||||||
|
|
||||||
|
|
||||||
|
def validate_workout_date(value: str, *, today: str | None = None) -> str | None:
|
||||||
|
"""Return an error message if ``value`` is not today..today+7 days, else None.
|
||||||
|
|
||||||
|
THE date rule for manual workouts: no past dates (a back-dated entry is the
|
||||||
|
easiest way to fake a missed week) and no more than
|
||||||
|
``MANUAL_WORKOUT_MAX_DAYS_AHEAD`` days ahead.
|
||||||
|
"""
|
||||||
|
parsed = parse_workout_date(value)
|
||||||
|
if parsed is None:
|
||||||
|
return "Workout date must be YYYY-MM-DD"
|
||||||
|
reference = parse_workout_date(today or today_str())
|
||||||
|
if reference is None:
|
||||||
|
return f"Today's date {today!r} is not YYYY-MM-DD"
|
||||||
|
ahead = (parsed - reference).days
|
||||||
|
if ahead < 0:
|
||||||
|
return (
|
||||||
|
f"Workout date {value.strip()} is in the past -- only today or up "
|
||||||
|
f"to {MANUAL_WORKOUT_MAX_DAYS_AHEAD} days ahead"
|
||||||
|
)
|
||||||
|
if ahead > MANUAL_WORKOUT_MAX_DAYS_AHEAD:
|
||||||
|
return (
|
||||||
|
f"Workout date {value.strip()} is {ahead} days ahead -- at most "
|
||||||
|
f"{MANUAL_WORKOUT_MAX_DAYS_AHEAD} days"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ordinals(dates: Iterable[str]) -> list[int]:
|
||||||
|
"""Day ordinals of every parsable date; unparsable ones are skipped."""
|
||||||
|
days: list[int] = []
|
||||||
|
for value in dates:
|
||||||
|
parsed = parse_workout_date(value)
|
||||||
|
if parsed is not None:
|
||||||
|
days.append(parsed.toordinal())
|
||||||
|
return days
|
||||||
|
|
||||||
|
|
||||||
|
def _fullest_window(days: list[int], ordinal: int, length: int) -> tuple[int, int]:
|
||||||
|
"""Return ``(count, start)`` of the fullest ``length``-day window holding D.
|
||||||
|
|
||||||
|
Counts the entries in ``days`` only (the new entry is not among them), over
|
||||||
|
every window ``[start, start + length - 1]`` that contains ``ordinal``.
|
||||||
|
"""
|
||||||
|
return max(
|
||||||
|
(
|
||||||
|
(sum(1 for day in days if start <= day < start + length), start)
|
||||||
|
for start in range(ordinal - length + 1, ordinal + 1)
|
||||||
|
),
|
||||||
|
key=lambda found: found[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_days(log_file: Path, pending_dates: Iterable[str]) -> list[int]:
|
||||||
|
"""Ordinals of every logged credit and every pending future entry."""
|
||||||
|
return [*_ordinals(manual_credit_dates(log_file)), *_ordinals(pending_dates)]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BudgetUsage:
|
||||||
|
"""How full the fullest windows around one workout date already are."""
|
||||||
|
|
||||||
|
week: int
|
||||||
|
month: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exhausted(self) -> bool:
|
||||||
|
"""True if one more entry on that date would break either cap."""
|
||||||
|
return (
|
||||||
|
self.week >= MANUAL_WORKOUT_BUDGET_PER_7_DAYS
|
||||||
|
or self.month >= MANUAL_WORKOUT_BUDGET_PER_30_DAYS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def budget_usage_for_date(
|
||||||
|
log_file: Path,
|
||||||
|
workout_date: str,
|
||||||
|
pending_dates: Iterable[str] = (),
|
||||||
|
) -> BudgetUsage | None:
|
||||||
|
"""Return the fullest 7- and 30-day windows containing ``workout_date``.
|
||||||
|
|
||||||
|
The PC's admission rule as numbers: the shared cross-language fixture
|
||||||
|
(``fixtures/manual_budget_merge.json``, ``planned.expected_by_date``) pins
|
||||||
|
these to what the phone computes. None if the date does not parse.
|
||||||
|
"""
|
||||||
|
target = parse_workout_date(workout_date)
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
days = _existing_days(log_file, pending_dates)
|
||||||
|
ordinal = target.toordinal()
|
||||||
|
return BudgetUsage(
|
||||||
|
week=_fullest_window(days, ordinal, 7)[0],
|
||||||
|
month=_fullest_window(days, ordinal, 30)[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def budget_error_for_date(
|
||||||
|
log_file: Path,
|
||||||
|
workout_date: str,
|
||||||
|
pending_dates: Iterable[str] = (),
|
||||||
|
) -> str | None:
|
||||||
|
"""Return why a manual workout dated ``workout_date`` does not fit, else None.
|
||||||
|
|
||||||
|
Adding an entry dated D is allowed only if EVERY rolling window containing
|
||||||
|
D -- each 7-day window and each 30-day window -- stays within its cap
|
||||||
|
counting the credits already in ``log_file``, the pending future entries
|
||||||
|
in ``pending_dates``, and the new entry itself. Counting against D rather
|
||||||
|
than against today is what lets a full week still schedule next week, and
|
||||||
|
what keeps tomorrow's entry from being free of the cap.
|
||||||
|
|
||||||
|
``pending_dates`` must already exclude the entry being checked (an entry
|
||||||
|
that holds its slot would otherwise reject itself on its own date).
|
||||||
|
"""
|
||||||
|
target = parse_workout_date(workout_date)
|
||||||
|
if target is None:
|
||||||
|
return f"Workout date {workout_date!r} must be YYYY-MM-DD"
|
||||||
|
days = _existing_days(log_file, pending_dates)
|
||||||
|
for length, cap in _WINDOWS:
|
||||||
|
used, start = _fullest_window(days, target.toordinal(), length)
|
||||||
|
if used >= cap:
|
||||||
|
first = date.fromordinal(start)
|
||||||
|
last = first + timedelta(days=length - 1)
|
||||||
|
return (
|
||||||
|
f"Manual-workout budget: {used} already counted/scheduled "
|
||||||
|
f"between {first} and {last}, max {cap} per {length} days"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def budget_closed_for_all_days(
|
||||||
|
log_file: Path,
|
||||||
|
pending_dates: Iterable[str] = (),
|
||||||
|
*,
|
||||||
|
today: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""True if no date in today..today+7 could take another manual workout.
|
||||||
|
|
||||||
|
The "Log Manual Workout" button hides only then; "today's window is full"
|
||||||
|
no longer closes it, since a later day may still have room.
|
||||||
|
"""
|
||||||
|
reference = parse_workout_date(today or today_str())
|
||||||
|
if reference is None:
|
||||||
|
_logger.warning("Today %r is not YYYY-MM-DD -- budget treated as closed", today)
|
||||||
|
return True
|
||||||
|
pending = list(pending_dates)
|
||||||
|
return all(
|
||||||
|
budget_error_for_date(
|
||||||
|
log_file, (reference + timedelta(days=offset)).isoformat(), pending
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
for offset in range(MANUAL_WORKOUT_MAX_DAYS_AHEAD + 1)
|
||||||
|
)
|
||||||
217
screen_locker/_manual_pending.py
Normal file
217
screen_locker/_manual_pending.py
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
"""Durable ledger of future-dated manual workouts logged on THIS PC.
|
||||||
|
|
||||||
|
A manual workout logged for a later day must not enter ``log.json`` until that
|
||||||
|
day (the weekly check, the budget, the status view and the shutdown derivation
|
||||||
|
all read the log, and would count it early). It has to live somewhere else, and
|
||||||
|
be visible to the phone for budget and deletion -- so it is published to this
|
||||||
|
device's sync log.
|
||||||
|
|
||||||
|
Publishing once is not enough, for the reason ``_sync_tombstones`` documents:
|
||||||
|
``_manual_push`` re-derives this device's log from ``log.json`` on every tick
|
||||||
|
and ``crdt_sync.sync_log`` never reads the device's OWN remote log back, so
|
||||||
|
anything published only once is overwritten by the next push. This ledger is
|
||||||
|
what every push re-publishes (``records_from_workout_log`` merges it in), and
|
||||||
|
ingestion on the workout's date reads the record back through the ordinary
|
||||||
|
pull -- the PC does ingest its own device log -- or from here if Firebase is
|
||||||
|
unreachable that day.
|
||||||
|
|
||||||
|
``{record_id: {payload, hlc, scheduled_at}}`` in ``MANUAL_PENDING_FILE``. The HLC
|
||||||
|
is minted once and stored, so re-pushing the same entry does not churn the
|
||||||
|
store. An entry leaves the ledger once it is in ``log.json`` (its own copy is
|
||||||
|
then published from there) or once it is tombstoned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from crdt_sync import Hlc, Record
|
||||||
|
|
||||||
|
from screen_locker._constants import MANUAL_PENDING_FILE
|
||||||
|
from screen_locker._day import today_str
|
||||||
|
from screen_locker._log_io import load_workout_log
|
||||||
|
from screen_locker._log_mixin import _entry_workout_id
|
||||||
|
from screen_locker._manual_future import parse_workout_date
|
||||||
|
from screen_locker._manual_workout import MANUAL_WORKOUT_SYNC_KIND
|
||||||
|
from screen_locker._sync_tombstones import tombstone_records
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"add_pending",
|
||||||
|
"local_pending_records",
|
||||||
|
"pending_future_dates",
|
||||||
|
"pending_ledger_records",
|
||||||
|
"settle_pending",
|
||||||
|
]
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_PAYLOAD_FIELD = "payload"
|
||||||
|
|
||||||
|
|
||||||
|
def _ledger_path(path: Path | None) -> Path:
|
||||||
|
"""Resolve the ledger path at call time, so a patched module global applies."""
|
||||||
|
return MANUAL_PENDING_FILE if path is None else path
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path: Path) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Read the ledger; a missing file is empty, a corrupt one is loud."""
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text())
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"Pending manual-workout ledger %s is unreadable (%s) -- treating it "
|
||||||
|
"as EMPTY, so future-dated workouts logged on this PC will not be "
|
||||||
|
"re-published or credited from here",
|
||||||
|
path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
_logger.warning(
|
||||||
|
"Pending manual-workout ledger %s is not a JSON object -- ignoring it",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
return {str(rid): entry for rid, entry in raw.items() if isinstance(entry, dict)}
|
||||||
|
|
||||||
|
|
||||||
|
def _save(path: Path, ledger: dict[str, dict[str, Any]]) -> None:
|
||||||
|
"""Write the ledger as sorted, indented JSON."""
|
||||||
|
path.write_text(json.dumps(ledger, indent=2, sort_keys=True) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def add_pending(
|
||||||
|
record_id: str,
|
||||||
|
payload: dict[str, object],
|
||||||
|
device_id: str,
|
||||||
|
path: Path | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Record a future-dated manual workout. Returns False if already held."""
|
||||||
|
path = _ledger_path(path)
|
||||||
|
ledger = _load(path)
|
||||||
|
if record_id in ledger:
|
||||||
|
return False
|
||||||
|
ledger[record_id] = {
|
||||||
|
"payload": payload,
|
||||||
|
"hlc": Hlc.new_tick(device_id).to_str(),
|
||||||
|
"scheduled_at": datetime.now(tz=UTC).isoformat(),
|
||||||
|
}
|
||||||
|
_save(path, ledger)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def local_pending_records(path: Path | None = None) -> list[tuple[str, dict]]:
|
||||||
|
"""Return ``(record_id, payload)`` for every ledgered entry not tombstoned.
|
||||||
|
|
||||||
|
The shape ``ingest_manual_records`` takes, so this PC's own future entries
|
||||||
|
can be ingested on their date even when the Firebase pull is empty or down.
|
||||||
|
"""
|
||||||
|
dead = tombstone_records().keys()
|
||||||
|
return [
|
||||||
|
(rid, entry["payload"])
|
||||||
|
for rid, entry in _load(_ledger_path(path)).items()
|
||||||
|
if rid not in dead and isinstance(entry.get("payload"), dict)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def pending_ledger_records(path: Path | None = None) -> dict[str, Record]:
|
||||||
|
"""Return the ledger as crdt_sync records, for ``_manual_push`` to publish.
|
||||||
|
|
||||||
|
An entry without a parsable ``hlc`` is skipped with a warning: publishing
|
||||||
|
it clockless would churn the store (or lose to a stale copy).
|
||||||
|
"""
|
||||||
|
records: dict[str, Record] = {}
|
||||||
|
for record_id, entry in _load(_ledger_path(path)).items():
|
||||||
|
payload = entry.get("payload")
|
||||||
|
try:
|
||||||
|
hlc = Hlc.from_str(str(entry.get("hlc")))
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"Pending manual workout %s has no usable hlc (%r: %s) -- NOT "
|
||||||
|
"published, so the phone will not see it",
|
||||||
|
record_id,
|
||||||
|
entry.get("hlc"),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
records[record_id] = Record(
|
||||||
|
id=record_id, fields={_PAYLOAD_FIELD: (payload, hlc)}
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _logged_ids(log_file: Path) -> set[str]:
|
||||||
|
"""Every workout id already in ``log.json`` (stored, derived or synced)."""
|
||||||
|
ids: set[str] = set()
|
||||||
|
for day, entries in load_workout_log(log_file).items():
|
||||||
|
for entry in entries:
|
||||||
|
if (own := _entry_workout_id(day, entry)) is not None:
|
||||||
|
ids.add(own)
|
||||||
|
data = entry.get("workout_data", {})
|
||||||
|
if isinstance(data, dict) and data.get("sync_record_id"):
|
||||||
|
ids.add(str(data["sync_record_id"]))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def settle_pending(
|
||||||
|
log_file: Path,
|
||||||
|
deleted_ids: Iterable[str] = (),
|
||||||
|
path: Path | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Drop ledger entries that are in ``log.json`` or deleted; return their ids.
|
||||||
|
|
||||||
|
``deleted_ids`` come from the merged sync log (a tombstone written on the
|
||||||
|
phone reaches it) and the local tombstone ledger is consulted too.
|
||||||
|
"""
|
||||||
|
path = _ledger_path(path)
|
||||||
|
ledger = _load(path)
|
||||||
|
if not ledger:
|
||||||
|
return []
|
||||||
|
gone = {*deleted_ids, *tombstone_records().keys(), *_logged_ids(log_file)}
|
||||||
|
settled = [rid for rid in ledger if rid in gone]
|
||||||
|
for rid in settled:
|
||||||
|
del ledger[rid]
|
||||||
|
if settled:
|
||||||
|
_save(path, ledger)
|
||||||
|
_logger.info("Pending manual workout(s) settled and dropped: %s", settled)
|
||||||
|
return settled
|
||||||
|
|
||||||
|
|
||||||
|
def pending_future_dates(
|
||||||
|
log_file: Path,
|
||||||
|
records: Iterable[tuple[str, Mapping[str, object]]],
|
||||||
|
*,
|
||||||
|
today: str | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Return ``{record_id: date}`` of manual records dated AFTER today.
|
||||||
|
|
||||||
|
``records`` is the merged Firebase manual pull and/or the local ledger
|
||||||
|
(the same record id in both counts once). Ids already in ``log.json`` are
|
||||||
|
not pending. Records dated today or earlier are not pending either: they
|
||||||
|
were ingested or rejected, and a budget-skipped old record must not keep
|
||||||
|
occupying a slot for everyone else.
|
||||||
|
"""
|
||||||
|
reference = today or today_str()
|
||||||
|
logged = _logged_ids(log_file)
|
||||||
|
pending: dict[str, str] = {}
|
||||||
|
for record_id, payload in records:
|
||||||
|
day = payload.get("date")
|
||||||
|
if (
|
||||||
|
payload.get("kind") == MANUAL_WORKOUT_SYNC_KIND
|
||||||
|
and isinstance(day, str)
|
||||||
|
and parse_workout_date(day) is not None
|
||||||
|
and day > reference
|
||||||
|
and record_id not in logged
|
||||||
|
):
|
||||||
|
pending[record_id] = day
|
||||||
|
return pending
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"""Push the PC's workouts to the shared sync repo.
|
"""Push the PC's workouts to the shared Firebase sync store.
|
||||||
|
|
||||||
``log.json`` is the single source of truth: this module derives the
|
``log.json`` is the single source of truth: this module derives the
|
||||||
crdt-sync log directly from it and pushes to ``devices/pc/log.json``, so the
|
crdt-sync log directly from it and pushes to this device's log, so the
|
||||||
phone converges on the SAME history the PC has — manual workouts *and*
|
phone converges on the SAME history the PC has — manual workouts *and*
|
||||||
machine-verified ones (StrongLifts sessions, RunnerUp runs).
|
machine-verified ones (StrongLifts sessions, RunnerUp runs).
|
||||||
|
|
||||||
@ -10,11 +10,11 @@ workout is published automatically no matter when or how it was recorded,
|
|||||||
including entries logged before this module existed. Idempotence comes from two
|
including entries logged before this module existed. Idempotence comes from two
|
||||||
properties: the record id is the workout's own stable ``workout_id``, and its
|
properties: the record id is the workout's own stable ``workout_id``, and its
|
||||||
HLC is derived deterministically from the entry's own timestamp — so re-pushing
|
HLC is derived deterministically from the entry's own timestamp — so re-pushing
|
||||||
an unchanged log produces a byte-identical record set and no repo churn.
|
an unchanged log produces a byte-identical record set and no store churn.
|
||||||
|
|
||||||
This reverses the old "the PC only ever reads, never pushes" invariant (it had
|
This reverses the old "the PC only ever reads, never pushes" invariant (it had
|
||||||
no data of its own to contribute). Pushing needs a sync token with
|
no data of its own to contribute). Firebase is the only transport since
|
||||||
**contents:write** on the repo — a read-only token 403s. Nothing here fails
|
2026-10-09; the old GitHub mirror is a frozen archive. Nothing here fails
|
||||||
silently: every path returns a :class:`PushResult` whose ``reason`` says exactly
|
silently: every path returns a :class:`PushResult` whose ``reason`` says exactly
|
||||||
what happened, and logs it.
|
what happened, and logs it.
|
||||||
"""
|
"""
|
||||||
@ -29,32 +29,24 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from crdt_sync import (
|
from crdt_sync import (
|
||||||
FileSyncStateStore,
|
FileSyncStateStore,
|
||||||
GitHubSyncClient,
|
|
||||||
GitHubSyncError,
|
|
||||||
Hlc,
|
Hlc,
|
||||||
LogCodec,
|
LogCodec,
|
||||||
Record,
|
Record,
|
||||||
RemoteSyncError,
|
RemoteSyncError,
|
||||||
RepoNotFoundError,
|
|
||||||
RevisionTracking,
|
RevisionTracking,
|
||||||
SyncTarget,
|
SyncTarget,
|
||||||
sync_log,
|
sync_log,
|
||||||
)
|
)
|
||||||
|
|
||||||
from screen_locker._constants import (
|
from screen_locker._constants import SYNC_STATE_FILE
|
||||||
SYNC_REPO_NAME,
|
|
||||||
SYNC_REPO_OWNER,
|
|
||||||
SYNC_STATE_FILE,
|
|
||||||
SYNC_TIMEOUT_SECONDS,
|
|
||||||
SYNC_TOKEN_FILE,
|
|
||||||
)
|
|
||||||
from screen_locker._device import device_identity
|
from screen_locker._device import device_identity
|
||||||
from screen_locker._log_io import load_workout_log
|
from screen_locker._log_io import load_workout_log
|
||||||
from screen_locker._log_mixin import _derive_workout_id
|
from screen_locker._log_mixin import _derive_workout_id
|
||||||
from screen_locker._push_outcome import describe_push
|
from screen_locker._manual_pending import pending_ledger_records, settle_pending
|
||||||
from screen_locker._sync_retry import with_sync_retry
|
from screen_locker._sync_retry import with_sync_retry
|
||||||
|
from screen_locker._sync_tombstones import tombstone_records
|
||||||
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES
|
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES
|
||||||
from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token, remote_client
|
from screen_locker._workout_sync import _DEVICES_PREFIX, sync_client_or_reason
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -157,6 +149,14 @@ def records_from_workout_log(log_file: Path) -> dict[str, Record]:
|
|||||||
log[str(record_id)] = Record(
|
log[str(record_id)] = Record(
|
||||||
id=str(record_id), fields={_PAYLOAD_FIELD: (payload, hlc)}
|
id=str(record_id), fields={_PAYLOAD_FIELD: (payload, hlc)}
|
||||||
)
|
)
|
||||||
|
# Future-dated manual workouts logged here are not in log.json until their
|
||||||
|
# day; the ledger is what keeps them published (and visible to the phone's
|
||||||
|
# budget and delete button). The log's own copy wins once ingested.
|
||||||
|
for record_id, record in pending_ledger_records().items():
|
||||||
|
log.setdefault(record_id, record)
|
||||||
|
# Re-pushed every tick: sync_log never reads this device's own remote
|
||||||
|
# log, so a tombstone written once is overwritten by the next push.
|
||||||
|
log.update(tombstone_records())
|
||||||
return log
|
return log
|
||||||
|
|
||||||
|
|
||||||
@ -168,34 +168,22 @@ def push_pc_workouts(log_file: Path) -> PushResult:
|
|||||||
the returned :class:`PushResult` and a WARNING say why a push did not happen.
|
the returned :class:`PushResult` and a WARNING say why a push did not happen.
|
||||||
"""
|
"""
|
||||||
identity = device_identity()
|
identity = device_identity()
|
||||||
token = read_sync_token()
|
|
||||||
if token is None:
|
|
||||||
reason = f"no sync token at {SYNC_TOKEN_FILE}"
|
|
||||||
_logger.warning(
|
|
||||||
"Workouts NOT synced: %s — create a fine-grained GitHub PAT with "
|
|
||||||
"contents:write on %s/%s and save it there (chmod 600)",
|
|
||||||
reason,
|
|
||||||
SYNC_REPO_OWNER,
|
|
||||||
SYNC_REPO_NAME,
|
|
||||||
)
|
|
||||||
return PushResult(pushed=False, record_count=0, reason=reason)
|
|
||||||
|
|
||||||
log = records_from_workout_log(log_file)
|
log = records_from_workout_log(log_file)
|
||||||
if not log:
|
if not log:
|
||||||
reason = f"no counted workouts in {log_file}"
|
reason = f"no counted workouts in {log_file}"
|
||||||
_logger.warning("Workouts NOT synced: %s", reason)
|
_logger.warning("Workouts NOT synced: %s", reason)
|
||||||
return PushResult(pushed=False, record_count=0, reason=reason)
|
return PushResult(pushed=False, record_count=0, reason=reason)
|
||||||
|
|
||||||
client = remote_client(
|
client, reason = sync_client_or_reason()
|
||||||
GitHubSyncClient(
|
if client is None:
|
||||||
SYNC_REPO_OWNER,
|
# sync_client_or_reason has already said why, in full; this line
|
||||||
SYNC_REPO_NAME,
|
# ties that cause to the records it just stranded.
|
||||||
token,
|
_logger.warning(
|
||||||
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
"Workouts NOT synced: %d workout(s) stay local — %s", len(log), reason
|
||||||
)
|
)
|
||||||
)
|
return PushResult(pushed=False, record_count=len(log), reason=reason)
|
||||||
try:
|
try:
|
||||||
with_sync_retry(
|
merged = with_sync_retry(
|
||||||
lambda: sync_log(
|
lambda: sync_log(
|
||||||
SyncTarget(
|
SyncTarget(
|
||||||
client=client,
|
client=client,
|
||||||
@ -214,32 +202,17 @@ def push_pc_workouts(log_file: Path) -> PushResult:
|
|||||||
),
|
),
|
||||||
description="push PC workouts",
|
description="push PC workouts",
|
||||||
)
|
)
|
||||||
except (GitHubSyncError, RemoteSyncError) as exc:
|
except RemoteSyncError as exc:
|
||||||
|
# Report what actually happened, nothing more: this used to assert
|
||||||
|
# "a 403 means the token lacks contents:write" for every failure,
|
||||||
|
# including a plain network error, which sent a 2026-07-20
|
||||||
|
# investigation chasing a permissions bug that did not exist.
|
||||||
reason = f"sync error: {exc}"
|
reason = f"sync error: {exc}"
|
||||||
# Report what actually happened. This used to assert "a 403 here means
|
_logger.warning("Workout sync push FAILED for %d workout(s): %s", len(log), exc)
|
||||||
# the token lacks contents:write" for *every* failure, including a
|
|
||||||
# plain network error — which sent a 2026-07-20 investigation chasing a
|
|
||||||
# permissions bug that did not exist (the token could write fine; the
|
|
||||||
# network was simply not up yet). Only RepoNotFoundError actually
|
|
||||||
# implicates the repo or the token.
|
|
||||||
if isinstance(exc, RepoNotFoundError):
|
|
||||||
_logger.warning(
|
|
||||||
"Workout sync push FAILED for %d workout(s): %s — check the "
|
|
||||||
"token has contents:write on %s/%s",
|
|
||||||
len(log),
|
|
||||||
exc,
|
|
||||||
SYNC_REPO_OWNER,
|
|
||||||
SYNC_REPO_NAME,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_logger.warning(
|
|
||||||
"Workout sync push FAILED for %d workout(s): %s",
|
|
||||||
len(log),
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return PushResult(pushed=False, record_count=len(log), reason=reason)
|
return PushResult(pushed=False, record_count=len(log), reason=reason)
|
||||||
|
|
||||||
# Delegated so a half-landed push can never be logged as a clean one;
|
# A future entry deleted on the phone arrives as a tombstone in the merge;
|
||||||
# see _push_outcome for why that distinction is load-bearing.
|
# one already ingested is in log.json. Either way it leaves the ledger.
|
||||||
_, reason = describe_push(len(log))
|
settle_pending(log_file, [rid for rid, record in merged.items() if record.deleted])
|
||||||
return PushResult(pushed=True, record_count=len(log), reason=reason)
|
_logger.info("Synced %d workout(s) to Firebase", len(log))
|
||||||
|
return PushResult(pushed=True, record_count=len(log), reason="pushed")
|
||||||
|
|||||||
115
screen_locker/_manual_schedule.py
Normal file
115
screen_locker/_manual_schedule.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"""Log a manual workout for a FUTURE day from this PC (Tk dialog and CLI).
|
||||||
|
|
||||||
|
A future-dated entry never touches ``log.json`` or ``write_signed_entry`` and
|
||||||
|
earns nothing today. It is held in the pending ledger (``_manual_pending``),
|
||||||
|
published to this device's sync log so the phone sees it (budget, delete), and
|
||||||
|
ingested on its date by the ordinary sync pass, which pays exactly what a
|
||||||
|
workout logged that day would.
|
||||||
|
|
||||||
|
This module is the one place that decides whether such an entry may be logged:
|
||||||
|
the date window and the per-date budget, over the credits in ``log.json`` plus
|
||||||
|
every pending future entry -- this PC's ledger AND the merged Firebase manual
|
||||||
|
records (a phone-logged one holds a slot too).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from screen_locker._device import device_identity
|
||||||
|
from screen_locker._manual_future import (
|
||||||
|
budget_error_for_date,
|
||||||
|
is_future_date,
|
||||||
|
validate_workout_date,
|
||||||
|
)
|
||||||
|
from screen_locker._manual_pending import (
|
||||||
|
add_pending,
|
||||||
|
local_pending_records,
|
||||||
|
pending_future_dates,
|
||||||
|
)
|
||||||
|
from screen_locker._manual_push import push_pc_workouts
|
||||||
|
from screen_locker._manual_workout import build_sync_payload, manual_sync_record_id
|
||||||
|
from screen_locker._workout_sync import pull_all_manual_records
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from screen_locker._manual_workout import ManualWorkoutDraft
|
||||||
|
|
||||||
|
__all__ = ["ScheduleResult", "budget_error", "schedule_future_workout"]
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScheduleResult:
|
||||||
|
"""Outcome of :func:`schedule_future_workout`; ``reason`` is for a human."""
|
||||||
|
|
||||||
|
scheduled: bool
|
||||||
|
record_id: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def budget_error(
|
||||||
|
log_file: Path, workout_date: str, *, exclude_id: str | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""Why a manual workout on ``workout_date`` does not fit the budget, else None.
|
||||||
|
|
||||||
|
Reads the pending future entries from this PC's ledger and from the merged
|
||||||
|
Firebase pull. The pull is best-effort (``pull_all_manual_records`` warns
|
||||||
|
and returns nothing when Firebase is unreachable), so offline this still
|
||||||
|
sees the ledger -- only a phone-logged pending entry can be missed, and the
|
||||||
|
ingest on that day re-checks.
|
||||||
|
"""
|
||||||
|
records = [*pull_all_manual_records(), *local_pending_records()]
|
||||||
|
pending = pending_future_dates(log_file, records)
|
||||||
|
if exclude_id is not None:
|
||||||
|
pending.pop(exclude_id, None)
|
||||||
|
return budget_error_for_date(log_file, workout_date, pending.values())
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_future_workout(
|
||||||
|
log_file: Path, draft: ManualWorkoutDraft, workout_date: str
|
||||||
|
) -> ScheduleResult:
|
||||||
|
"""Hold a validated draft for ``workout_date`` and publish it.
|
||||||
|
|
||||||
|
The caller has run ``validate_manual_workout`` on ``draft``. Refuses (and
|
||||||
|
says why) a date outside today+1..today+7, an exhausted budget, or an
|
||||||
|
entry already scheduled under the same id; otherwise stores it in the
|
||||||
|
pending ledger and pushes immediately, so the phone sees it now rather
|
||||||
|
than on the next 15-minute tick.
|
||||||
|
"""
|
||||||
|
record_id = manual_sync_record_id(workout_date, draft.start_time)
|
||||||
|
date_error = validate_workout_date(workout_date)
|
||||||
|
if date_error is None and not is_future_date(workout_date):
|
||||||
|
date_error = f"{workout_date} is today, not a future date"
|
||||||
|
if date_error is not None:
|
||||||
|
return ScheduleResult(scheduled=False, record_id=record_id, reason=date_error)
|
||||||
|
over_budget = budget_error(log_file, workout_date)
|
||||||
|
if over_budget is not None:
|
||||||
|
return ScheduleResult(scheduled=False, record_id=record_id, reason=over_budget)
|
||||||
|
payload = build_sync_payload(draft, workout_date)
|
||||||
|
if not add_pending(record_id, payload, device_identity().device_id):
|
||||||
|
return ScheduleResult(
|
||||||
|
scheduled=False,
|
||||||
|
record_id=record_id,
|
||||||
|
reason=f"{record_id} is already scheduled",
|
||||||
|
)
|
||||||
|
pushed = push_pc_workouts(log_file)
|
||||||
|
_logger.info(
|
||||||
|
"Scheduled manual workout %s for %s (published=%s: %s)",
|
||||||
|
record_id,
|
||||||
|
workout_date,
|
||||||
|
pushed.pushed,
|
||||||
|
pushed.reason,
|
||||||
|
)
|
||||||
|
if pushed.pushed:
|
||||||
|
reason = f"scheduled for {workout_date}; counts automatically that day"
|
||||||
|
else:
|
||||||
|
reason = (
|
||||||
|
f"scheduled for {workout_date} on this PC (not yet published: "
|
||||||
|
f"{pushed.reason}); it counts automatically that day"
|
||||||
|
)
|
||||||
|
return ScheduleResult(scheduled=True, record_id=record_id, reason=reason)
|
||||||
@ -5,7 +5,7 @@ from elsewhere) arrives via crdt-sync as a ``kind="manual_workout"`` payload
|
|||||||
(see :func:`screen_locker._manual_workout.build_sync_payload`). This module
|
(see :func:`screen_locker._manual_workout.build_sync_payload`). This module
|
||||||
turns those synced payloads into ordinary signed ``manual_workout`` entries in
|
turns those synced payloads into ordinary signed ``manual_workout`` entries in
|
||||||
``log.json`` so they count toward the weekly minimum and — via the
|
``log.json`` so they count toward the weekly minimum and — via the
|
||||||
optional ``on_ingested`` callback — earn the same shutdown/debt reward a
|
optional ``on_ingested`` callback — earn the same shutdown reward a
|
||||||
live-logged workout would.
|
live-logged workout would.
|
||||||
|
|
||||||
Trust model: the PC never trusts the phone's derived fields. Every synced
|
Trust model: the PC never trusts the phone's derived fields. Every synced
|
||||||
@ -21,14 +21,23 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from screen_locker._day import today_str
|
||||||
from screen_locker._log_io import load_workout_log
|
from screen_locker._log_io import load_workout_log
|
||||||
from screen_locker._log_mixin import write_signed_entry
|
from screen_locker._log_mixin import _entry_workout_id, write_signed_entry
|
||||||
|
from screen_locker._manual_future import (
|
||||||
|
budget_error_for_date,
|
||||||
|
is_future_date,
|
||||||
|
parse_workout_date,
|
||||||
|
)
|
||||||
|
from screen_locker._manual_pending import pending_future_dates
|
||||||
|
from screen_locker._manual_sync_draft import (
|
||||||
|
_draft_or_report,
|
||||||
|
is_empty_stub,
|
||||||
|
reconstruct_draft,
|
||||||
|
)
|
||||||
from screen_locker._manual_workout import (
|
from screen_locker._manual_workout import (
|
||||||
MANUAL_WORKOUT_SYNC_KIND,
|
MANUAL_WORKOUT_SYNC_KIND,
|
||||||
SPORT_OTHER,
|
|
||||||
ManualWorkoutDraft,
|
|
||||||
build_entry,
|
build_entry,
|
||||||
is_budget_exhausted,
|
|
||||||
validate_manual_workout,
|
validate_manual_workout,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -36,142 +45,36 @@ if TYPE_CHECKING:
|
|||||||
from collections.abc import Callable, Iterable, Mapping
|
from collections.abc import Callable, Iterable, Mapping
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from screen_locker._log_mixin import RecordResult
|
||||||
|
from screen_locker._manual_workout import ManualWorkoutDraft
|
||||||
|
|
||||||
OnIngestedCallback = Callable[[dict, "list[dict]"], None]
|
OnIngestedCallback = Callable[[dict, "list[dict]"], None]
|
||||||
|
|
||||||
|
__all__ = ["ingest_manual_records", "is_empty_stub", "reconstruct_draft"]
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Key under which the source sync record id is stored in the ingested
|
# Key under which the source sync record id is stored in the ingested
|
||||||
# ``workout_data`` — makes re-ingestion of the same record idempotent.
|
# ``workout_data`` — makes re-ingestion of the same record idempotent.
|
||||||
_SYNC_ID_FIELD = "sync_record_id"
|
_SYNC_ID_FIELD = "sync_record_id"
|
||||||
|
|
||||||
# The raw user inputs every real manual workout carries. A payload holding none
|
|
||||||
# of them is a metadata stub, not a damaged workout: there is nothing to
|
|
||||||
# recover and no amount of retrying will change that.
|
|
||||||
_SUBSTANTIVE_FIELDS = frozenset(
|
|
||||||
{
|
|
||||||
"sport",
|
|
||||||
"start_time",
|
|
||||||
"end_time",
|
|
||||||
"location_name",
|
|
||||||
"transport_method",
|
|
||||||
"cost",
|
|
||||||
"rpe",
|
|
||||||
"went_well",
|
|
||||||
"to_improve",
|
|
||||||
"overall_feeling",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def is_empty_stub(payload: Mapping[str, object]) -> bool:
|
|
||||||
"""True when ``payload`` carries no workout content at all.
|
|
||||||
|
|
||||||
Observed in the wild as ``{"type", "kind", "date"}`` and nothing else --
|
|
||||||
a record the phone created without ever attaching the workout. Every sync
|
|
||||||
cycle re-reported it as malformed, so a permanent, unfixable condition
|
|
||||||
produced an unbounded stream of identical warnings (four every 15 minutes),
|
|
||||||
which is how the genuinely actionable lines got lost in the noise.
|
|
||||||
"""
|
|
||||||
return not (_SUBSTANTIVE_FIELDS & set(payload))
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_int(value: object) -> int:
|
|
||||||
"""Coerce a JSON scalar to int; raise for a non-numeric (skips the record)."""
|
|
||||||
if isinstance(value, (int, str)):
|
|
||||||
return int(value)
|
|
||||||
raise TypeError(value)
|
|
||||||
|
|
||||||
|
|
||||||
def reconstruct_draft(payload: Mapping[str, object]) -> ManualWorkoutDraft | None:
|
|
||||||
"""Rebuild a :class:`ManualWorkoutDraft` from a synced manual payload.
|
|
||||||
|
|
||||||
Returns None if a required raw field is missing or mistyped, so a malformed
|
|
||||||
record is skipped rather than crashing ingestion. Only the raw user inputs
|
|
||||||
are read back — the derived fields (``source``, ``duration_minutes``,
|
|
||||||
``type``) are recomputed by :func:`build_entry` on the PC.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
sport = str(payload["sport"])
|
|
||||||
activity_type_other = (
|
|
||||||
str(payload.get("activity_type", "")) if sport == SPORT_OTHER else ""
|
|
||||||
)
|
|
||||||
return ManualWorkoutDraft(
|
|
||||||
sport=sport,
|
|
||||||
start_time=str(payload["start_time"]),
|
|
||||||
end_time=str(payload["end_time"]),
|
|
||||||
location_name=str(payload["location_name"]),
|
|
||||||
transport_method=str(payload["transport_method"]),
|
|
||||||
cost=str(payload["cost"]),
|
|
||||||
rpe=_coerce_int(payload["rpe"]),
|
|
||||||
went_well=str(payload["went_well"]),
|
|
||||||
to_improve=str(payload["to_improve"]),
|
|
||||||
overall_feeling=str(payload["overall_feeling"]),
|
|
||||||
reservation_phone=str(payload.get("reservation_phone", "")),
|
|
||||||
techniques_practiced=str(payload.get("techniques_practiced", "")),
|
|
||||||
warm_up_minutes=str(payload.get("warm_up_minutes", "")),
|
|
||||||
pain_or_injury=str(payload.get("pain_or_injury", "none")),
|
|
||||||
matches_won=_coerce_int(payload.get("matches_won", 0)),
|
|
||||||
matches_lost=_coerce_int(payload.get("matches_lost", 0)),
|
|
||||||
sets_won=_coerce_int(payload.get("sets_won", 0)),
|
|
||||||
sets_lost=_coerce_int(payload.get("sets_lost", 0)),
|
|
||||||
racket=str(payload.get("racket", "")),
|
|
||||||
balls=str(payload.get("balls", "")),
|
|
||||||
activity_type_other=activity_type_other,
|
|
||||||
activity_details=str(payload.get("activity_details", "")),
|
|
||||||
equipment=str(payload.get("equipment", "")),
|
|
||||||
)
|
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
|
||||||
_logger.warning(
|
|
||||||
"Synced manual workout payload is malformed (%s: %s) — SKIPPING "
|
|
||||||
"this record, so it will not be logged or counted on the PC",
|
|
||||||
type(exc).__name__,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _draft_or_report(
|
|
||||||
record_id: str, payload: Mapping[str, object]
|
|
||||||
) -> ManualWorkoutDraft | None:
|
|
||||||
"""Rebuild the draft, or report why it cannot be rebuilt and return None.
|
|
||||||
|
|
||||||
The two failure modes are deliberately reported at different levels, because
|
|
||||||
only one of them is actionable:
|
|
||||||
|
|
||||||
* an **empty stub** carries no workout at all (seen as ``{type, kind,
|
|
||||||
date}``) -- nothing to recover, nothing to fix, so re-reporting it at
|
|
||||||
``warning`` on every 15-minute sync cycle only teaches the reader to
|
|
||||||
ignore this logger;
|
|
||||||
* a **malformed** payload has real workout fields that would not parse,
|
|
||||||
which means genuine credit may be going missing -- that stays loud.
|
|
||||||
"""
|
|
||||||
if is_empty_stub(payload):
|
|
||||||
_logger.info(
|
|
||||||
"Manual record %s is an empty stub (keys: %s) — no workout data to "
|
|
||||||
"ingest; skipping permanently, no credit is being lost",
|
|
||||||
record_id,
|
|
||||||
sorted(payload),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
draft = reconstruct_draft(payload)
|
|
||||||
if draft is None:
|
|
||||||
_logger.warning(
|
|
||||||
"Manual record %s is malformed — it HAS workout fields but they "
|
|
||||||
"could not be parsed, so real credit may be lost",
|
|
||||||
record_id,
|
|
||||||
)
|
|
||||||
return draft
|
|
||||||
|
|
||||||
|
|
||||||
def _already_ingested(logs: dict[str, list[dict]], record_id: str) -> bool:
|
def _already_ingested(logs: dict[str, list[dict]], record_id: str) -> bool:
|
||||||
"""True if any logged entry already carries this sync record id.
|
"""True if any logged entry already IS this sync record.
|
||||||
|
|
||||||
Iterates the per-day lists (a day may hold several workouts); a cheap
|
Matches either way a record can already be here: an entry ingested from
|
||||||
early-out before reconstructing the draft. The write chokepoint also dedups
|
sync carries the id in ``sync_record_id``; an entry logged on this PC
|
||||||
by ``workout_id``, so this and that are two guards on the same idempotency.
|
(form or CLI) and then published carries it as its own ``workout_id``,
|
||||||
|
since the publisher keys records on exactly that. Checking only the first
|
||||||
|
is how the 2026-09-13 football was re-ingested: it had been logged locally
|
||||||
|
(no ``sync_record_id``), pushed as ``manual:2026-09-12T14:30``, and pulled
|
||||||
|
back as a "new" record. A cheap early-out before reconstructing the draft;
|
||||||
|
the write chokepoint dedups by ``workout_id`` as a second guard.
|
||||||
"""
|
"""
|
||||||
for entries in logs.values():
|
for day, entries in logs.items():
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
|
if _entry_workout_id(day, entry) == record_id:
|
||||||
|
return True
|
||||||
workout_data = entry.get("workout_data", {})
|
workout_data = entry.get("workout_data", {})
|
||||||
if (
|
if (
|
||||||
isinstance(workout_data, dict)
|
isinstance(workout_data, dict)
|
||||||
@ -181,6 +84,108 @@ def _already_ingested(logs: dict[str, list[dict]], record_id: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _due_date(
|
||||||
|
record_id: str, payload: Mapping[str, object], reference: str
|
||||||
|
) -> str | None:
|
||||||
|
"""Return the record's date if it is a manual record due for ingest, else None.
|
||||||
|
|
||||||
|
None covers every reason to leave it alone this pass: not a manual record,
|
||||||
|
no/unparsable date (warned), or dated after ``reference`` -- deferred, and
|
||||||
|
retried on every later pass so it lands on its date.
|
||||||
|
"""
|
||||||
|
if payload.get("kind") != MANUAL_WORKOUT_SYNC_KIND:
|
||||||
|
return None
|
||||||
|
date = payload.get("date")
|
||||||
|
if not isinstance(date, str):
|
||||||
|
_logger.warning("Manual record %s has no date — skipped", record_id)
|
||||||
|
return None
|
||||||
|
if parse_workout_date(date) is None:
|
||||||
|
_logger.warning(
|
||||||
|
"Manual record %s has an unparsable date %r — skipped", record_id, date
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if is_future_date(date, today=reference):
|
||||||
|
_logger.info(
|
||||||
|
"Manual record %s is dated %s, after today (%s) — deferred; it "
|
||||||
|
"is ingested and credited on its date",
|
||||||
|
record_id,
|
||||||
|
date,
|
||||||
|
reference,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return date
|
||||||
|
|
||||||
|
|
||||||
|
def _new_valid_draft(
|
||||||
|
log_file: Path,
|
||||||
|
record_id: str,
|
||||||
|
payload: Mapping[str, object],
|
||||||
|
reference: str,
|
||||||
|
) -> tuple[str, ManualWorkoutDraft] | None:
|
||||||
|
"""The due date and valid draft of a not-yet-ingested record, else ``None``."""
|
||||||
|
date = _due_date(record_id, payload, reference)
|
||||||
|
if date is None:
|
||||||
|
return None
|
||||||
|
if _already_ingested(load_workout_log(log_file), record_id):
|
||||||
|
return None
|
||||||
|
draft = _draft_or_report(record_id, payload)
|
||||||
|
if draft is None:
|
||||||
|
return None
|
||||||
|
error = validate_manual_workout(draft)
|
||||||
|
if error is not None:
|
||||||
|
_logger.warning("Manual record %s invalid: %s", record_id, error)
|
||||||
|
return None
|
||||||
|
return date, draft
|
||||||
|
|
||||||
|
|
||||||
|
def _ingest_one(
|
||||||
|
log_file: Path,
|
||||||
|
record_id: str,
|
||||||
|
payload: Mapping[str, object],
|
||||||
|
reference: str,
|
||||||
|
pending: Mapping[str, str],
|
||||||
|
) -> tuple[dict[str, object], RecordResult] | None:
|
||||||
|
"""Validate, budget-check and append one record; ``None`` when skipped."""
|
||||||
|
ready = _new_valid_draft(log_file, record_id, payload, reference)
|
||||||
|
if ready is None:
|
||||||
|
return None
|
||||||
|
date, draft = ready
|
||||||
|
over_budget = budget_error_for_date(
|
||||||
|
log_file,
|
||||||
|
date,
|
||||||
|
[day for rid, day in pending.items() if rid != record_id],
|
||||||
|
)
|
||||||
|
if over_budget is not None:
|
||||||
|
_logger.info(
|
||||||
|
"Manual-workout budget exhausted — %s not ingested (%s)",
|
||||||
|
record_id,
|
||||||
|
over_budget,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
entry = build_entry(draft)
|
||||||
|
entry[_SYNC_ID_FIELD] = record_id
|
||||||
|
# The wire id IS the workout's id: the device that logged it minted
|
||||||
|
# ``manual:<date>T<start>`` once, and the publisher keys on the same
|
||||||
|
# id. Without this the chokepoint re-derived one from ``date`` +
|
||||||
|
# start, and when the two dates disagreed (a record whose day key was
|
||||||
|
# later corrected to the local day) the same workout landed twice
|
||||||
|
# under two ids -- and then consumed two manual-budget slots.
|
||||||
|
entry["workout_id"] = record_id
|
||||||
|
result = write_signed_entry(log_file, date, entry)
|
||||||
|
if not result.appended:
|
||||||
|
# This is dedup working, not credit being lost: the workout is
|
||||||
|
# already in the log under this same workout_id. It was logged at
|
||||||
|
# warning and re-reported on every 15-minute cycle, which read like
|
||||||
|
# a recurring fault and buried the lines that did need attention.
|
||||||
|
_logger.info(
|
||||||
|
"Manual record %s is already logged under the same workout_id "
|
||||||
|
"— skipped as a duplicate (its credit is already counted)",
|
||||||
|
record_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return entry, result
|
||||||
|
|
||||||
|
|
||||||
def ingest_manual_records(
|
def ingest_manual_records(
|
||||||
log_file: Path,
|
log_file: Path,
|
||||||
records: Iterable[tuple[str, Mapping[str, object]]],
|
records: Iterable[tuple[str, Mapping[str, object]]],
|
||||||
@ -196,51 +201,39 @@ def ingest_manual_records(
|
|||||||
workouts). Idempotent: dedup by ``record_id`` here and by ``workout_id`` in
|
workouts). Idempotent: dedup by ``record_id`` here and by ``workout_id`` in
|
||||||
the write chokepoint, so re-syncing the same record is a no-op.
|
the write chokepoint, so re-syncing the same record is a no-op.
|
||||||
|
|
||||||
|
A record dated AFTER ``today`` (default: the local day) is DEFERRED, not
|
||||||
|
ingested: it stays in the sync store, earns nothing and is not written to
|
||||||
|
``log.json`` (the weekly check, the budget, the status view and the
|
||||||
|
shutdown derivation would all count a future day key early). It is retried
|
||||||
|
on every later pass, so it is ingested -- and credited -- on its date.
|
||||||
|
|
||||||
|
The budget is counted against the workout's own date: the new entry fits
|
||||||
|
only if every rolling window containing that date stays within its cap,
|
||||||
|
over the credits in the log and every OTHER pending future record in
|
||||||
|
``records`` (a future entry holds its slot from the moment it is logged).
|
||||||
|
|
||||||
When ``on_ingested`` is given, it's called ``on_ingested(entry, prior_entries)``
|
When ``on_ingested`` is given, it's called ``on_ingested(entry, prior_entries)``
|
||||||
right after each newly-appended record — ``prior_entries`` being that
|
right after each newly-appended record — ``prior_entries`` being that
|
||||||
entry's own date's entries written before it — so the caller (see
|
entry's own date's entries written before it — so the caller (see
|
||||||
``screen_lock.ScreenLocker._ingest_synced_manual_workouts``) can apply the
|
``screen_lock.ScreenLocker._ingest_synced_manual_workouts``) can apply the
|
||||||
identical live-workout shutdown/debt reward. This applies regardless of
|
identical live-workout shutdown reward. This applies regardless of
|
||||||
whether ``date`` is today or back-dated: there's only one current shutdown
|
whether ``date`` is today or back-dated: there's only one current shutdown
|
||||||
config, so a back-dated sync still pushes it exactly as a live workout
|
config, so a back-dated sync still pushes it exactly as a live workout
|
||||||
would have that day.
|
would have that day.
|
||||||
|
|
||||||
Returns the record ids actually ingested.
|
Returns the record ids actually ingested.
|
||||||
"""
|
"""
|
||||||
|
batch = list(records)
|
||||||
|
if not batch:
|
||||||
|
return []
|
||||||
|
reference = today or today_str()
|
||||||
|
pending = pending_future_dates(log_file, batch, today=reference)
|
||||||
ingested: list[str] = []
|
ingested: list[str] = []
|
||||||
for record_id, payload in records:
|
for record_id, payload in batch:
|
||||||
if payload.get("kind") != MANUAL_WORKOUT_SYNC_KIND:
|
outcome = _ingest_one(log_file, record_id, payload, reference, pending)
|
||||||
continue
|
if outcome is None:
|
||||||
date = payload.get("date")
|
|
||||||
if not isinstance(date, str):
|
|
||||||
_logger.warning("Manual record %s has no date — skipped", record_id)
|
|
||||||
continue
|
|
||||||
if _already_ingested(load_workout_log(log_file), record_id):
|
|
||||||
continue
|
|
||||||
draft = _draft_or_report(record_id, payload)
|
|
||||||
if draft is None:
|
|
||||||
continue
|
|
||||||
error = validate_manual_workout(draft)
|
|
||||||
if error is not None:
|
|
||||||
_logger.warning("Manual record %s invalid: %s", record_id, error)
|
|
||||||
continue
|
|
||||||
if is_budget_exhausted(log_file, today=today):
|
|
||||||
_logger.info("Manual-workout budget exhausted — %s not ingested", record_id)
|
|
||||||
continue
|
|
||||||
entry = build_entry(draft)
|
|
||||||
entry[_SYNC_ID_FIELD] = record_id
|
|
||||||
result = write_signed_entry(log_file, date, entry)
|
|
||||||
if not result.appended:
|
|
||||||
# This is dedup working, not credit being lost: the workout is
|
|
||||||
# already in the log under this same workout_id. It was logged at
|
|
||||||
# warning and re-reported on every 15-minute cycle, which read like
|
|
||||||
# a recurring fault and buried the lines that did need attention.
|
|
||||||
_logger.info(
|
|
||||||
"Manual record %s is already logged under the same workout_id "
|
|
||||||
"— skipped as a duplicate (its credit is already counted)",
|
|
||||||
record_id,
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
entry, result = outcome
|
||||||
ingested.append(record_id)
|
ingested.append(record_id)
|
||||||
if on_ingested is not None:
|
if on_ingested is not None:
|
||||||
on_ingested(entry, result.prior_entries)
|
on_ingested(entry, result.prior_entries)
|
||||||
|
|||||||
140
screen_locker/_manual_sync_draft.py
Normal file
140
screen_locker/_manual_sync_draft.py
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
"""Rebuild a :class:`ManualWorkoutDraft` from a synced manual-workout payload.
|
||||||
|
|
||||||
|
Split out of :mod:`screen_locker._manual_sync` to keep every file under the
|
||||||
|
250-line cap; that module re-exports :func:`reconstruct_draft` and
|
||||||
|
:func:`is_empty_stub`, so callers and patch targets are unchanged.
|
||||||
|
|
||||||
|
Only the raw user inputs are read back — the derived fields (``source``,
|
||||||
|
``duration_minutes``, ``type``) are recomputed on the PC by ``build_entry``,
|
||||||
|
which is what keeps a phone from ever dictating what a workout is worth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from screen_locker._manual_workout import SPORT_OTHER, ManualWorkoutDraft
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# The raw user inputs every real manual workout carries. A payload holding none
|
||||||
|
# of them is a metadata stub, not a damaged workout: there is nothing to
|
||||||
|
# recover and no amount of retrying will change that.
|
||||||
|
_SUBSTANTIVE_FIELDS = frozenset(
|
||||||
|
{
|
||||||
|
"sport",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"location_name",
|
||||||
|
"transport_method",
|
||||||
|
"cost",
|
||||||
|
"rpe",
|
||||||
|
"went_well",
|
||||||
|
"to_improve",
|
||||||
|
"overall_feeling",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_empty_stub(payload: Mapping[str, object]) -> bool:
|
||||||
|
"""True when ``payload`` carries no workout content at all.
|
||||||
|
|
||||||
|
Observed in the wild as ``{"type", "kind", "date"}`` and nothing else --
|
||||||
|
a record the phone created without ever attaching the workout. Every sync
|
||||||
|
cycle re-reported it as malformed, so a permanent, unfixable condition
|
||||||
|
produced an unbounded stream of identical warnings (four every 15 minutes),
|
||||||
|
which is how the genuinely actionable lines got lost in the noise.
|
||||||
|
"""
|
||||||
|
return not _SUBSTANTIVE_FIELDS & set(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_int(value: object) -> int:
|
||||||
|
"""Coerce a JSON scalar to int; raise for a non-numeric (skips the record)."""
|
||||||
|
if isinstance(value, (int, str)):
|
||||||
|
return int(value)
|
||||||
|
raise TypeError(value)
|
||||||
|
|
||||||
|
|
||||||
|
def reconstruct_draft(payload: Mapping[str, object]) -> ManualWorkoutDraft | None:
|
||||||
|
"""Rebuild a :class:`ManualWorkoutDraft` from a synced manual payload.
|
||||||
|
|
||||||
|
Returns None if a required raw field is missing or mistyped, so a malformed
|
||||||
|
record is skipped rather than crashing ingestion. Only the raw user inputs
|
||||||
|
are read back — the derived fields (``source``, ``duration_minutes``,
|
||||||
|
``type``) are recomputed by :func:`build_entry` on the PC.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sport = str(payload["sport"])
|
||||||
|
activity_type_other = (
|
||||||
|
str(payload.get("activity_type", "")) if sport == SPORT_OTHER else ""
|
||||||
|
)
|
||||||
|
return ManualWorkoutDraft(
|
||||||
|
sport=sport,
|
||||||
|
start_time=str(payload["start_time"]),
|
||||||
|
end_time=str(payload["end_time"]),
|
||||||
|
location_name=str(payload["location_name"]),
|
||||||
|
transport_method=str(payload["transport_method"]),
|
||||||
|
cost=str(payload["cost"]),
|
||||||
|
rpe=_coerce_int(payload["rpe"]),
|
||||||
|
went_well=str(payload["went_well"]),
|
||||||
|
to_improve=str(payload["to_improve"]),
|
||||||
|
overall_feeling=str(payload["overall_feeling"]),
|
||||||
|
reservation_phone=str(payload.get("reservation_phone", "")),
|
||||||
|
techniques_practiced=str(payload.get("techniques_practiced", "")),
|
||||||
|
warm_up_minutes=str(payload.get("warm_up_minutes", "")),
|
||||||
|
pain_or_injury=str(payload.get("pain_or_injury", "none")),
|
||||||
|
matches_won=_coerce_int(payload.get("matches_won", 0)),
|
||||||
|
matches_lost=_coerce_int(payload.get("matches_lost", 0)),
|
||||||
|
sets_won=_coerce_int(payload.get("sets_won", 0)),
|
||||||
|
sets_lost=_coerce_int(payload.get("sets_lost", 0)),
|
||||||
|
racket=str(payload.get("racket", "")),
|
||||||
|
balls=str(payload.get("balls", "")),
|
||||||
|
activity_type_other=activity_type_other,
|
||||||
|
activity_details=str(payload.get("activity_details", "")),
|
||||||
|
equipment=str(payload.get("equipment", "")),
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"Synced manual workout payload is malformed (%s: %s) — SKIPPING "
|
||||||
|
"this record, so it will not be logged or counted on the PC",
|
||||||
|
type(exc).__name__,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _draft_or_report(
|
||||||
|
record_id: str, payload: Mapping[str, object]
|
||||||
|
) -> ManualWorkoutDraft | None:
|
||||||
|
"""Rebuild the draft, or report why it cannot be rebuilt and return None.
|
||||||
|
|
||||||
|
The two failure modes are deliberately reported at different levels, because
|
||||||
|
only one of them is actionable:
|
||||||
|
|
||||||
|
* an **empty stub** carries no workout at all (seen as ``{type, kind,
|
||||||
|
date}``) -- nothing to recover, nothing to fix, so re-reporting it at
|
||||||
|
``warning`` on every 15-minute sync cycle only teaches the reader to
|
||||||
|
ignore this logger;
|
||||||
|
* a **malformed** payload has real workout fields that would not parse,
|
||||||
|
which means genuine credit may be going missing -- that stays loud.
|
||||||
|
"""
|
||||||
|
if is_empty_stub(payload):
|
||||||
|
_logger.info(
|
||||||
|
"Manual record %s is an empty stub (keys: %s) — no workout data to "
|
||||||
|
"ingest; skipping permanently, no credit is being lost",
|
||||||
|
record_id,
|
||||||
|
sorted(payload),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
draft = reconstruct_draft(payload)
|
||||||
|
if draft is None:
|
||||||
|
_logger.warning(
|
||||||
|
"Manual record %s is malformed — it HAS workout fields but they "
|
||||||
|
"could not be parsed, so real credit may be lost",
|
||||||
|
record_id,
|
||||||
|
)
|
||||||
|
return draft
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"""Manual (unverified) workout rate-limiting, validation, and entry-building.
|
"""Manual (unverified) workout rate-limiting, validation, and entry-building.
|
||||||
|
|
||||||
Pure logic — no Tk imports. Unlike sick days, manual-workout entries live
|
Pure logic — no Tk imports. Manual-workout entries live directly in
|
||||||
directly in ``log.json`` (not a separate history file): a manual
|
``log.json`` (not a separate history file): a manual
|
||||||
entry is just another ``workout_data`` dict with ``type="manual_workout"``,
|
entry is just another ``workout_data`` dict with ``type="manual_workout"``,
|
||||||
so ``LogMixin.save_workout_log`` already HMAC-signs it for free and the
|
so ``LogMixin.save_workout_log`` already HMAC-signs it for free and the
|
||||||
weekly-minimum/bonus logic already counts it once wired into
|
weekly-minimum/bonus logic already counts it once wired into
|
||||||
@ -20,15 +20,19 @@ import logging
|
|||||||
|
|
||||||
from screen_locker._constants import (
|
from screen_locker._constants import (
|
||||||
MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS,
|
MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS,
|
||||||
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
|
||||||
MANUAL_WORKOUT_RPE_MAX,
|
MANUAL_WORKOUT_RPE_MAX,
|
||||||
MANUAL_WORKOUT_RPE_MIN,
|
MANUAL_WORKOUT_RPE_MIN,
|
||||||
MIN_WORKOUT_DURATION_MINUTES,
|
MIN_WORKOUT_DURATION_MINUTES,
|
||||||
WORKOUT_DURATION_ACCEPT_MINUTES,
|
WORKOUT_DURATION_ACCEPT_MINUTES,
|
||||||
)
|
)
|
||||||
|
from screen_locker._manual_workout_checks import (
|
||||||
|
validate_reflections,
|
||||||
|
validate_required,
|
||||||
|
)
|
||||||
from screen_locker._manual_workout_types import (
|
from screen_locker._manual_workout_types import (
|
||||||
MANUAL_WORKOUT_TYPE,
|
MANUAL_WORKOUT_TYPE,
|
||||||
SPORT_CHOICES,
|
SPORT_CHOICES,
|
||||||
|
SPORT_GLYPHS,
|
||||||
SPORT_LABELS,
|
SPORT_LABELS,
|
||||||
SPORT_OTHER,
|
SPORT_OTHER,
|
||||||
SPORT_TABLE_TENNIS,
|
SPORT_TABLE_TENNIS,
|
||||||
@ -41,6 +45,7 @@ from screen_locker._manual_workout_types import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"MANUAL_WORKOUT_TYPE",
|
"MANUAL_WORKOUT_TYPE",
|
||||||
"SPORT_CHOICES",
|
"SPORT_CHOICES",
|
||||||
|
"SPORT_GLYPHS",
|
||||||
"SPORT_LABELS",
|
"SPORT_LABELS",
|
||||||
"SPORT_OTHER",
|
"SPORT_OTHER",
|
||||||
"SPORT_TABLE_TENNIS",
|
"SPORT_TABLE_TENNIS",
|
||||||
@ -82,19 +87,9 @@ def _duration_minutes(draft: ManualWorkoutDraft) -> float | None:
|
|||||||
|
|
||||||
def validate_manual_workout(draft: ManualWorkoutDraft) -> str | None:
|
def validate_manual_workout(draft: ManualWorkoutDraft) -> str | None:
|
||||||
"""Return an error message if the draft is invalid, else None."""
|
"""Return an error message if the draft is invalid, else None."""
|
||||||
if draft.sport not in SPORT_CHOICES:
|
error = validate_required(draft)
|
||||||
return "Please choose a sport"
|
if error is not None:
|
||||||
|
return error
|
||||||
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)
|
duration = _duration_minutes(draft)
|
||||||
if duration is None:
|
if duration is None:
|
||||||
@ -119,18 +114,7 @@ def validate_manual_workout(draft: ManualWorkoutDraft) -> str | None:
|
|||||||
if error is not None:
|
if error is not None:
|
||||||
return error
|
return error
|
||||||
|
|
||||||
for label, value in (
|
return validate_reflections(draft)
|
||||||
("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:
|
def _validate_table_tennis(draft: ManualWorkoutDraft) -> str | None:
|
||||||
@ -143,7 +127,7 @@ def _validate_table_tennis(draft: ManualWorkoutDraft) -> str | None:
|
|||||||
):
|
):
|
||||||
if value < 0:
|
if value < 0:
|
||||||
return f"{label} cannot be negative"
|
return f"{label} cannot be negative"
|
||||||
if draft.matches_won + draft.matches_lost == 0:
|
if not draft.matches_won + draft.matches_lost:
|
||||||
return "Enter at least one match played (won + lost)"
|
return "Enter at least one match played (won + lost)"
|
||||||
if not draft.racket.strip():
|
if not draft.racket.strip():
|
||||||
return "Racket is required"
|
return "Racket is required"
|
||||||
|
|||||||
44
screen_locker/_manual_workout_checks.py
Normal file
44
screen_locker/_manual_workout_checks.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"""Field-level checks shared by ``validate_manual_workout``.
|
||||||
|
|
||||||
|
Split out of :mod:`screen_locker._manual_workout` to keep every file under the
|
||||||
|
250-line cap.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from screen_locker._constants import MANUAL_WORKOUT_REFLECTION_MIN_CHARS
|
||||||
|
from screen_locker._manual_workout_types import SPORT_CHOICES, ManualWorkoutDraft
|
||||||
|
|
||||||
|
|
||||||
|
def validate_required(draft: ManualWorkoutDraft) -> str | None:
|
||||||
|
"""Return an error if the sport is unchosen or a required field is blank."""
|
||||||
|
if draft.sport not in SPORT_CHOICES:
|
||||||
|
return "Please choose a sport"
|
||||||
|
|
||||||
|
required = {
|
||||||
|
"Start time": draft.start_time,
|
||||||
|
"End time": draft.end_time,
|
||||||
|
"Location name": draft.location_name,
|
||||||
|
"Transport method": draft.transport_method,
|
||||||
|
"Cost": draft.cost,
|
||||||
|
}
|
||||||
|
for label, value in required.items():
|
||||||
|
if not value.strip():
|
||||||
|
return f"{label} is required"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_reflections(draft: ManualWorkoutDraft) -> str | None:
|
||||||
|
"""Return an error if a reflection field is shorter than the minimum."""
|
||||||
|
for label, value in (
|
||||||
|
("What went well", draft.went_well),
|
||||||
|
("What to improve", draft.to_improve),
|
||||||
|
("Overall feeling", draft.overall_feeling),
|
||||||
|
):
|
||||||
|
if len(value.strip()) < MANUAL_WORKOUT_REFLECTION_MIN_CHARS:
|
||||||
|
return (
|
||||||
|
f"{label} must be at least {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} "
|
||||||
|
f"characters (currently {len(value.strip())})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
137
screen_locker/_manual_workout_date.py
Normal file
137
screen_locker/_manual_workout_date.py
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
"""Workout-date field of the manual-workout form, and the future-dated path.
|
||||||
|
|
||||||
|
Split out of :mod:`screen_locker._manual_workout_dialog` to keep every file
|
||||||
|
under the 250-line cap; composed back into ``ManualWorkoutDialogMixin`` there,
|
||||||
|
so both hosts (``ScreenLocker`` and ``StatusWindow``) are unchanged.
|
||||||
|
|
||||||
|
A manual workout may be dated today up to 7 days ahead. Today's takes the
|
||||||
|
host's ordinary save path (which, on the lock screen, unlocks). A FUTURE one
|
||||||
|
must not: it earns nothing today, so it is handed to ``_manual_schedule`` and
|
||||||
|
the form shows a "scheduled" screen that returns to the host's cancelled hook
|
||||||
|
-- never ``_on_manual_workout_saved``, which would unlock today.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import tkinter as tk
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from screen_locker._day import today_str
|
||||||
|
from screen_locker._manual_future import (
|
||||||
|
budget_closed_for_all_days,
|
||||||
|
is_future_date,
|
||||||
|
validate_workout_date,
|
||||||
|
)
|
||||||
|
from screen_locker._manual_pending import local_pending_records, pending_future_dates
|
||||||
|
from screen_locker._manual_schedule import budget_error, schedule_future_workout
|
||||||
|
from screen_locker._ui_widgets import disable_paste
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from screen_locker._manual_workout import ManualWorkoutDraft
|
||||||
|
|
||||||
|
# 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."""
|
||||||
|
|
||||||
|
def _mw_budget_closed(self) -> bool:
|
||||||
|
"""True if no day in today..today+7 has room (local ledger, no network)."""
|
||||||
|
pending = pending_future_dates(self.log_file, local_pending_records())
|
||||||
|
return budget_closed_for_all_days(self.log_file, pending.values())
|
||||||
|
|
||||||
|
def _mw_date_in_row(self, row: tk.Frame) -> None:
|
||||||
|
"""Pack the workout-date entry (prefilled with today) into ``row``.
|
||||||
|
|
||||||
|
Called from the sport row rather than given a row of its own: the
|
||||||
|
lock cannot scroll, and the form already fills a 768px panel
|
||||||
|
(``scripts/verify_screen_fits.py``), so the field has to cost no height.
|
||||||
|
"""
|
||||||
|
var = tk.StringVar(value=today_str())
|
||||||
|
self._mw_vars["date"] = var
|
||||||
|
entry = tk.Entry(
|
||||||
|
row,
|
||||||
|
textvariable=var,
|
||||||
|
width=11,
|
||||||
|
font=self._colors.font("label"),
|
||||||
|
bg=self._colors.palette.field_bg,
|
||||||
|
fg=self._colors.palette.fg,
|
||||||
|
insertbackground=self._colors.palette.fg,
|
||||||
|
**self._colors.focus_kwargs(),
|
||||||
|
)
|
||||||
|
entry.pack(side="right", padx=self._colors.space("xs"))
|
||||||
|
disable_paste(entry)
|
||||||
|
# Was "Workout date (today to +7 days):" -- a day square + the range.
|
||||||
|
tk.Label(
|
||||||
|
row,
|
||||||
|
text="□ +0…+7",
|
||||||
|
font=self._colors.font("label"),
|
||||||
|
fg=self._colors.palette.fg,
|
||||||
|
bg=self._colors.palette.bg,
|
||||||
|
).pack(side="right", padx=self._colors.space("xs"))
|
||||||
|
|
||||||
|
def _mw_handled_by_date(self, draft: ManualWorkoutDraft) -> bool:
|
||||||
|
"""Apply the date and budget rules; True if the submit is fully handled.
|
||||||
|
|
||||||
|
Handled means an error is on screen or a future entry was scheduled;
|
||||||
|
False means the date is today and within budget, so the caller
|
||||||
|
carries on with the host's ordinary save.
|
||||||
|
"""
|
||||||
|
date = self._mw_vars["date"].get().strip()
|
||||||
|
error = validate_workout_date(date)
|
||||||
|
future = error is None and is_future_date(date)
|
||||||
|
if error is None and not future:
|
||||||
|
error = budget_error(self.log_file, date)
|
||||||
|
if error is not None:
|
||||||
|
self._mw_reject(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)
|
||||||
|
return True
|
||||||
|
_logger.info("Manual workout scheduled for %s: %s", date, result.reason)
|
||||||
|
self._show_manual_workout_scheduled(date)
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
role="display",
|
||||||
|
scale=1.3,
|
||||||
|
color=self._colors.palette.success,
|
||||||
|
pad="lg",
|
||||||
|
)
|
||||||
|
self._text(date, role="subtitle", color=self._colors.palette.success)
|
||||||
|
row = self._button_row()
|
||||||
|
self._button(
|
||||||
|
row,
|
||||||
|
BACK_GLYPH,
|
||||||
|
bg=self._colors.palette.accent,
|
||||||
|
command=self._on_manual_workout_cancelled,
|
||||||
|
width=12,
|
||||||
|
).pack(side="left", padx=self._colors.space("sm"))
|
||||||
@ -23,17 +23,31 @@ from screen_locker import _manual_workout
|
|||||||
from screen_locker._constants import (
|
from screen_locker._constants import (
|
||||||
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
||||||
)
|
)
|
||||||
|
from screen_locker._manual_workout_date import (
|
||||||
|
BACK_GLYPH,
|
||||||
|
REJECTED_GLYPH,
|
||||||
|
ManualWorkoutDateMixin,
|
||||||
|
)
|
||||||
from screen_locker._manual_workout_sport_fields import ManualWorkoutSportFieldsMixin
|
from screen_locker._manual_workout_sport_fields import ManualWorkoutSportFieldsMixin
|
||||||
from screen_locker._manual_workout_widgets import ManualWorkoutFormWidgetsMixin
|
from screen_locker._manual_workout_widgets import ManualWorkoutFormWidgetsMixin
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_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 = {
|
_SPORT_LABEL_TO_CODE = {
|
||||||
label: code for code, label in _manual_workout.SPORT_LABELS.items()
|
label: code for code, label in _manual_workout.SPORT_LABELS.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ManualWorkoutDialogMixin(
|
class ManualWorkoutDialogMixin(
|
||||||
|
ManualWorkoutDateMixin,
|
||||||
ManualWorkoutFormWidgetsMixin,
|
ManualWorkoutFormWidgetsMixin,
|
||||||
ManualWorkoutSportFieldsMixin,
|
ManualWorkoutSportFieldsMixin,
|
||||||
):
|
):
|
||||||
@ -42,12 +56,11 @@ class ManualWorkoutDialogMixin(
|
|||||||
def _show_manual_workout_form(self) -> None:
|
def _show_manual_workout_form(self) -> None:
|
||||||
"""Render the manual-workout evidence form, or a budget-exhausted note."""
|
"""Render the manual-workout evidence form, or a budget-exhausted note."""
|
||||||
self.clear_container()
|
self.clear_container()
|
||||||
self._label("Log Manual Workout", color=self._colors.palette.accent, pad="xs")
|
# textgate: glyphs, colours second. Was "Log Manual Workout" heading /
|
||||||
if _manual_workout.is_budget_exhausted(self.log_file):
|
# "Manual-workout budget exhausted for this window." / "BACK".
|
||||||
self._text(
|
self._label(FORM_GLYPH, color=self._colors.palette.accent, pad="xs")
|
||||||
"Manual-workout budget exhausted for this window.",
|
if self._mw_budget_closed():
|
||||||
color=self._colors.palette.danger,
|
self._label(EXHAUSTED_GLYPH, color=self._colors.palette.danger, pad="xs")
|
||||||
)
|
|
||||||
self._text(
|
self._text(
|
||||||
_manual_workout.budget_summary(self.log_file),
|
_manual_workout.budget_summary(self.log_file),
|
||||||
color=self._colors.palette.muted,
|
color=self._colors.palette.muted,
|
||||||
@ -55,7 +68,7 @@ class ManualWorkoutDialogMixin(
|
|||||||
row = self._button_row()
|
row = self._button_row()
|
||||||
self._button(
|
self._button(
|
||||||
row,
|
row,
|
||||||
"BACK",
|
BACK_GLYPH,
|
||||||
bg=self._colors.palette.field_bg,
|
bg=self._colors.palette.field_bg,
|
||||||
command=self._on_manual_workout_cancelled,
|
command=self._on_manual_workout_cancelled,
|
||||||
width=12,
|
width=12,
|
||||||
@ -85,24 +98,23 @@ class ManualWorkoutDialogMixin(
|
|||||||
self._mw_rpe_var = tk.IntVar(value=5)
|
self._mw_rpe_var = tk.IntVar(value=5)
|
||||||
self._mw_text_widgets: dict[str, tk.Text] = {}
|
self._mw_text_widgets: dict[str, tk.Text] = {}
|
||||||
|
|
||||||
self._mw_section(form, "Basics")
|
# 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_sport_row(form)
|
self._mw_sport_row(form)
|
||||||
self._mw_vars["start_time"] = self._mw_entry(
|
self._mw_vars["start_time"] = self._mw_entry(form, "◷▶", focus=True)
|
||||||
form, "Start time (HH:MM):", focus=True
|
self._mw_vars["end_time"] = self._mw_entry(form, "◷■")
|
||||||
)
|
|
||||||
self._mw_vars["end_time"] = self._mw_entry(form, "End time (HH:MM):")
|
|
||||||
|
|
||||||
self._mw_section(form, "Location & logistics")
|
self._mw_section(form)
|
||||||
self._mw_vars["location_name"] = self._mw_entry(form, "Location name:")
|
self._mw_vars["location_name"] = self._mw_entry(form, "⌖")
|
||||||
self._mw_vars["transport_method"] = self._mw_entry(
|
self._mw_vars["transport_method"] = self._mw_entry(form, "⇄")
|
||||||
form, "How did you get there?"
|
self._mw_vars["cost"] = self._mw_entry(form, "¤")
|
||||||
)
|
self._mw_vars["reservation_phone"] = self._mw_entry(form, "(▯#)")
|
||||||
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, "Activity details")
|
self._mw_section(form)
|
||||||
# Both sport sub-frames occupy the same full-width grid slot; only the
|
# Both sport sub-frames occupy the same full-width grid slot; only the
|
||||||
# selected one is shown (see _on_mw_sport_changed).
|
# selected one is shown (see _on_mw_sport_changed).
|
||||||
act_row = self._mw_next_full_row(form)
|
act_row = self._mw_next_full_row(form)
|
||||||
@ -122,33 +134,22 @@ class ManualWorkoutDialogMixin(
|
|||||||
self._build_table_tennis_fields(self._mw_tt_frame)
|
self._build_table_tennis_fields(self._mw_tt_frame)
|
||||||
self._build_other_sport_fields(self._mw_other_frame)
|
self._build_other_sport_fields(self._mw_other_frame)
|
||||||
self._mw_rpe_row(form)
|
self._mw_rpe_row(form)
|
||||||
self._mw_vars["techniques_practiced"] = self._mw_entry(
|
# Was "Techniques/focus areas practiced (optional):", "Warm-up
|
||||||
form, "Techniques/focus areas practiced (optional):"
|
# 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(
|
self._mw_vars["warm_up_minutes"] = self._mw_entry(form, "(∿)")
|
||||||
form, "Warm-up duration (optional):"
|
self._mw_vars["pain_or_injury"] = self._mw_entry(form, "(↯)")
|
||||||
)
|
|
||||||
self._mw_vars["pain_or_injury"] = self._mw_entry(
|
|
||||||
form, "Pain or injury notes (optional, default none):"
|
|
||||||
)
|
|
||||||
self._on_mw_sport_changed(
|
self._on_mw_sport_changed(
|
||||||
_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
|
_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
|
||||||
)
|
)
|
||||||
|
|
||||||
self._mw_section(form, "Reflection")
|
# Was "Reflection", "What went well / What to improve / Overall
|
||||||
self._mw_text_widgets["went_well"] = self._mw_textbox(
|
# feeling about the session (min N chars):".
|
||||||
form,
|
self._mw_section(form)
|
||||||
f"What went well (min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):",
|
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(
|
self._mw_text_widgets["to_improve"] = self._mw_textbox(form, f"↑ {least}")
|
||||||
form,
|
self._mw_text_widgets["overall_feeling"] = self._mw_textbox(form, f"~ {least}")
|
||||||
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(
|
self._mw_error_label = self._text(
|
||||||
"", role="label", color=self._colors.palette.danger, pad="xs"
|
"", role="label", color=self._colors.palette.danger, pad="xs"
|
||||||
@ -156,14 +157,14 @@ class ManualWorkoutDialogMixin(
|
|||||||
button_row = self._button_row()
|
button_row = self._button_row()
|
||||||
self._button(
|
self._button(
|
||||||
button_row,
|
button_row,
|
||||||
"SUBMIT",
|
SUBMIT_GLYPH,
|
||||||
bg=self._colors.palette.accent,
|
bg=self._colors.palette.accent,
|
||||||
command=self._submit_manual_workout_form,
|
command=self._submit_manual_workout_form,
|
||||||
width=12,
|
width=12,
|
||||||
).pack(side="left", padx=self._colors.space("sm"))
|
).pack(side="left", padx=self._colors.space("sm"))
|
||||||
self._button(
|
self._button(
|
||||||
button_row,
|
button_row,
|
||||||
"BACK",
|
BACK_GLYPH,
|
||||||
bg=self._colors.palette.field_bg,
|
bg=self._colors.palette.field_bg,
|
||||||
command=self._on_manual_workout_cancelled,
|
command=self._on_manual_workout_cancelled,
|
||||||
width=12,
|
width=12,
|
||||||
@ -210,7 +211,11 @@ class ManualWorkoutDialogMixin(
|
|||||||
)
|
)
|
||||||
error = _manual_workout.validate_manual_workout(draft)
|
error = _manual_workout.validate_manual_workout(draft)
|
||||||
if error is not None:
|
if error is not None:
|
||||||
self._mw_error_label.config(text=error)
|
# 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)
|
||||||
|
return
|
||||||
|
if self._mw_handled_by_date(draft):
|
||||||
return
|
return
|
||||||
entry = _manual_workout.build_entry(draft)
|
entry = _manual_workout.build_entry(draft)
|
||||||
# Record the manual for cross-device sync (published on the next lock
|
# Record the manual for cross-device sync (published on the next lock
|
||||||
|
|||||||
@ -49,17 +49,11 @@ class ManualWorkoutSportFieldsMixin:
|
|||||||
value=_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
|
value=_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
|
||||||
)
|
)
|
||||||
row = tk.Frame(parent, bg=self._colors.palette.bg)
|
row = tk.Frame(parent, bg=self._colors.palette.bg)
|
||||||
tk.Label(
|
# textgate: the "Sport:" caption is gone; the radio row is the cue.
|
||||||
row,
|
for code, label in _manual_workout.SPORT_LABELS.items():
|
||||||
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(
|
tk.Radiobutton(
|
||||||
row,
|
row,
|
||||||
text=label,
|
text=_manual_workout.SPORT_GLYPHS[code],
|
||||||
value=label,
|
value=label,
|
||||||
variable=self._mw_sport_var,
|
variable=self._mw_sport_var,
|
||||||
# Radiobutton's command takes no argument -- bind the label.
|
# Radiobutton's command takes no argument -- bind the label.
|
||||||
@ -75,6 +69,8 @@ class ManualWorkoutSportFieldsMixin:
|
|||||||
selectcolor=self._colors.palette.field_bg,
|
selectcolor=self._colors.palette.field_bg,
|
||||||
highlightthickness=0,
|
highlightthickness=0,
|
||||||
).pack(side="left", padx=self._colors.space("xs"))
|
).pack(side="left", padx=self._colors.space("xs"))
|
||||||
|
# The workout-date field rides in this row, at no extra height.
|
||||||
|
self._mw_date_in_row(row)
|
||||||
self._mw_grid(parent, row, full=True)
|
self._mw_grid(parent, row, full=True)
|
||||||
|
|
||||||
def _on_mw_sport_changed(self, selected_label: str) -> None:
|
def _on_mw_sport_changed(self, selected_label: str) -> None:
|
||||||
@ -95,25 +91,25 @@ class ManualWorkoutSportFieldsMixin:
|
|||||||
|
|
||||||
def _build_table_tennis_fields(self, parent: tk.Widget) -> None:
|
def _build_table_tennis_fields(self, parent: tk.Widget) -> None:
|
||||||
"""Build the table-tennis-specific score/equipment fields."""
|
"""Build the table-tennis-specific score/equipment fields."""
|
||||||
self._mw_int_vars["matches_won"] = self._mw_int_field(parent, "Matches won:")
|
# textgate: was "Matches won/lost:", "Sets won/lost:", "Racket used:",
|
||||||
self._mw_int_vars["matches_lost"] = self._mw_int_field(parent, "Matches lost:")
|
# "Balls used:". Match / set glyph + win / loss mark.
|
||||||
self._mw_int_vars["sets_won"] = self._mw_int_field(parent, "Sets won:")
|
self._mw_int_vars["matches_won"] = self._mw_int_field(parent, "◍✓")
|
||||||
self._mw_int_vars["sets_lost"] = self._mw_int_field(parent, "Sets lost:")
|
self._mw_int_vars["matches_lost"] = self._mw_int_field(parent, "◍✗")
|
||||||
self._mw_vars["racket"] = self._mw_entry(parent, "Racket used:")
|
self._mw_int_vars["sets_won"] = self._mw_int_field(parent, "▤✓")
|
||||||
self._mw_vars["balls"] = self._mw_entry(parent, "Balls used:")
|
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, "•")
|
||||||
|
|
||||||
def _build_other_sport_fields(self, parent: tk.Widget) -> None:
|
def _build_other_sport_fields(self, parent: tk.Widget) -> None:
|
||||||
"""Build the generic "other sport" fields."""
|
"""Build the generic "other sport" fields."""
|
||||||
self._mw_vars["activity_type_other"] = self._mw_entry(
|
# Was "What sport/activity:" / "What was done (min N chars):" /
|
||||||
parent, "What sport/activity:"
|
# "Equipment used (optional):"; optional fields are in parentheses.
|
||||||
)
|
self._mw_vars["activity_type_other"] = self._mw_entry(parent, "✱")
|
||||||
self._mw_text_widgets["activity_details"] = self._mw_textbox(
|
self._mw_text_widgets["activity_details"] = self._mw_textbox(
|
||||||
parent,
|
parent,
|
||||||
f"What was done (min {MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS} chars):",
|
f"✱≡ ≥{MANUAL_WORKOUT_DESCRIPTION_MIN_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:
|
def _current_mw_sport(self) -> str:
|
||||||
"""Return the internal sport code for the currently-selected label."""
|
"""Return the internal sport code for the currently-selected label."""
|
||||||
|
|||||||
@ -21,6 +21,7 @@ from screen_locker._constants import (
|
|||||||
)
|
)
|
||||||
from screen_locker._day import today_str
|
from screen_locker._day import today_str
|
||||||
from screen_locker._log_io import load_workout_log
|
from screen_locker._log_io import load_workout_log
|
||||||
|
from screen_locker._weekly_check import credit_key, day_workout_index
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -36,6 +37,12 @@ SPORT_LABELS: dict[str, str] = {
|
|||||||
SPORT_TABLE_TENNIS: "Table tennis",
|
SPORT_TABLE_TENNIS: "Table tennis",
|
||||||
SPORT_OTHER: "Other",
|
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:
|
def _today_iso() -> str:
|
||||||
@ -57,36 +64,61 @@ def _parse_iso(date_str: str) -> datetime | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def manual_credit_dates(log_file: Path) -> list[str]:
|
||||||
|
"""Return the ``YYYY-MM-DD`` of every manual-workout CREDIT in the log.
|
||||||
|
|
||||||
|
One element per workout, decided by
|
||||||
|
:func:`screen_locker._weekly_check.credit_key` -- the single rule every
|
||||||
|
counter shares -- so a synced copy of a workout already logged here is the
|
||||||
|
same credit, not a second budget slot (2026-09-13: a manual rekeyed to its
|
||||||
|
local day, then pulled back in under a fresh workout_id, consumed two
|
||||||
|
slots while the weekly count correctly saw one). Dates that do not parse
|
||||||
|
are warned about by :func:`_parse_iso` and left out.
|
||||||
|
"""
|
||||||
|
dates: list[str] = []
|
||||||
|
for date_str, entries in load_workout_log(log_file).items():
|
||||||
|
if _parse_iso(date_str) is None:
|
||||||
|
continue
|
||||||
|
siblings = day_workout_index(entries)
|
||||||
|
keys = {
|
||||||
|
key
|
||||||
|
for index, entry in enumerate(entries)
|
||||||
|
if (key := credit_key(date_str, index, entry, siblings)) is not None
|
||||||
|
and key[0] == MANUAL_WORKOUT_TYPE
|
||||||
|
}
|
||||||
|
dates.extend([date_str] * len(keys))
|
||||||
|
return dates
|
||||||
|
|
||||||
|
|
||||||
def count_in_window(
|
def count_in_window(
|
||||||
log_file: Path,
|
log_file: Path,
|
||||||
days: int,
|
days: int,
|
||||||
*,
|
*,
|
||||||
today: str | None = None,
|
today: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Return how many ``manual_workout`` ENTRIES fall in the trailing window.
|
"""Return how many manual-workout CREDITS fall in the trailing window.
|
||||||
|
|
||||||
Counted per entry, not per day: each logged manual workout consumes its
|
Counted per workout, not per day: each logged manual workout consumes its
|
||||||
own budget slot, matching how each one also earns its own weekly-count
|
own budget slot, matching how each one also earns its own weekly-count
|
||||||
and shutdown credit (see ``screen_locker._weekly_check.COUNTED_WORKOUT_TYPES``
|
and shutdown credit (see ``screen_locker._weekly_check.COUNTED_WORKOUT_TYPES``
|
||||||
and ``screen_locker._workout_credit``) — the budget is the sole limiter on
|
and ``screen_locker._workout_credit``) -- the budget is the sole limiter on
|
||||||
how many you can log, not a once-per-day collapse.
|
how many you can log, not a once-per-day collapse. See
|
||||||
|
:func:`manual_credit_dates` for what "one workout" means.
|
||||||
|
|
||||||
|
The window is the ``days`` days ending at ``today`` (inclusive); entries
|
||||||
|
dated after today are NOT in it. Whether a new entry fits is a different
|
||||||
|
question, answered per workout date by ``_manual_future``.
|
||||||
"""
|
"""
|
||||||
today_str = today or _today_iso()
|
day = today or _today_iso()
|
||||||
today_dt = _parse_iso(today_str)
|
today_dt = _parse_iso(day)
|
||||||
if today_dt is None:
|
if today_dt is None:
|
||||||
return 0
|
return 0
|
||||||
cutoff = today_dt - timedelta(days=days)
|
cutoff = today_dt - timedelta(days=days)
|
||||||
count = 0
|
return sum(
|
||||||
for date_str, entries in load_workout_log(log_file).items():
|
1
|
||||||
parsed = _parse_iso(date_str)
|
for date_str in manual_credit_dates(log_file)
|
||||||
if parsed is None or not (cutoff < parsed <= today_dt):
|
if (parsed := _parse_iso(date_str)) is not None and cutoff < parsed <= today_dt
|
||||||
continue
|
)
|
||||||
count += sum(
|
|
||||||
1
|
|
||||||
for entry in entries
|
|
||||||
if entry.get("workout_data", {}).get("type") == MANUAL_WORKOUT_TYPE
|
|
||||||
)
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def is_budget_exhausted(
|
def is_budget_exhausted(
|
||||||
@ -107,12 +139,16 @@ def budget_summary(
|
|||||||
*,
|
*,
|
||||||
today: str | None = None,
|
today: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""One-line UI summary string for the manual-workout budget."""
|
"""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.
|
||||||
|
"""
|
||||||
week = count_in_window(log_file, 7, today=today)
|
week = count_in_window(log_file, 7, today=today)
|
||||||
month = count_in_window(log_file, 30, today=today)
|
month = count_in_window(log_file, 30, today=today)
|
||||||
return (
|
return (
|
||||||
f"Manual: {week}/{MANUAL_WORKOUT_BUDGET_PER_7_DAYS}w · "
|
f"≡ {week}/{MANUAL_WORKOUT_BUDGET_PER_7_DAYS} · "
|
||||||
f"{month}/{MANUAL_WORKOUT_BUDGET_PER_30_DAYS}m"
|
f"{month}/{MANUAL_WORKOUT_BUDGET_PER_30_DAYS}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -66,17 +66,10 @@ class ManualWorkoutFormWidgetsMixin:
|
|||||||
counters[id(parent)] = idx + 2
|
counters[id(parent)] = idx + 2
|
||||||
return idx // 2
|
return idx // 2
|
||||||
|
|
||||||
def _mw_section(self, parent: tk.Widget, title: str) -> None:
|
def _mw_section(self, parent: tk.Widget) -> None:
|
||||||
"""Add a full-width section heading inside the form."""
|
"""Add a full-width rule between form sections (was a heading)."""
|
||||||
label = tk.Label(
|
rule = tk.Frame(parent, bg=self._colors.palette.accent, height=2)
|
||||||
parent,
|
self._mw_grid(parent, rule, full=True)
|
||||||
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(
|
def _mw_entry(
|
||||||
self, parent: tk.Widget, label: str, *, focus: bool = False
|
self, parent: tk.Widget, label: str, *, focus: bool = False
|
||||||
@ -84,7 +77,7 @@ class ManualWorkoutFormWidgetsMixin:
|
|||||||
"""Add a half-width label+entry cell and return its backing StringVar.
|
"""Add a half-width label+entry cell and return its backing StringVar.
|
||||||
|
|
||||||
Builds its own cell inline (rather than reusing ``_add_label_entry``,
|
Builds its own cell inline (rather than reusing ``_add_label_entry``,
|
||||||
which is pack-based and shared with the sick dialog) so it slots into
|
which is pack-based) so it slots into
|
||||||
the two-column grid. Paste stays disabled via ``disable_paste``.
|
the two-column grid. Paste stays disabled via ``disable_paste``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -190,10 +183,8 @@ class ManualWorkoutFormWidgetsMixin:
|
|||||||
row = tk.Frame(parent, bg=self._colors.palette.bg)
|
row = tk.Frame(parent, bg=self._colors.palette.bg)
|
||||||
tk.Label(
|
tk.Label(
|
||||||
row,
|
row,
|
||||||
text=(
|
# Was "RPE — perceived exertion (1-10):": effort bars + range.
|
||||||
f"RPE — perceived exertion "
|
text=f"▁▃▅▇ {MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}",
|
||||||
f"({MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}):"
|
|
||||||
),
|
|
||||||
font=self._colors.font("label"),
|
font=self._colors.font("label"),
|
||||||
fg=self._colors.palette.fg,
|
fg=self._colors.palette.fg,
|
||||||
bg=self._colors.palette.bg,
|
bg=self._colors.palette.bg,
|
||||||
|
|||||||
@ -23,8 +23,8 @@ Safety invariants (do not break when adding tools):
|
|||||||
calls must never write to stdout. All logging is routed to STDERR below,
|
calls must never write to stdout. All logging is routed to STDERR below,
|
||||||
and no tool calls ``screen_lock.py`` / ``status_view.main`` (which
|
and no tool calls ``screen_lock.py`` / ``status_view.main`` (which
|
||||||
``print`` / open Tk windows / ``sys.exit``).
|
``print`` / open Tk windows / ``sys.exit``).
|
||||||
* **No secret ever leaves.** No tool returns the sync token
|
* **No secret ever leaves.** No tool returns the Firebase session
|
||||||
(``~/.config/screen_locker/sync_token``) or any HMAC key; the predicates
|
(``~/.config/screen_locker/firebase_auth.json``) or any HMAC key; the predicates
|
||||||
load only status/state and return booleans, and the status snapshot
|
load only status/state and return booleans, and the status snapshot
|
||||||
carries no secret material.
|
carries no secret material.
|
||||||
"""
|
"""
|
||||||
@ -49,7 +49,6 @@ from screen_locker._compliance_state import (
|
|||||||
from screen_locker._constants import (
|
from screen_locker._constants import (
|
||||||
EARLY_BIRD_PENDING_FILE,
|
EARLY_BIRD_PENDING_FILE,
|
||||||
)
|
)
|
||||||
from screen_locker._sick_tracker import load_history
|
|
||||||
from screen_locker._status_data import format_summary_line, gather_status
|
from screen_locker._status_data import format_summary_line, gather_status
|
||||||
from screen_locker._status_projection import compliance_state_word
|
from screen_locker._status_projection import compliance_state_word
|
||||||
|
|
||||||
@ -64,7 +63,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
mcp = MCPServer("screen-locker")
|
mcp = MCPServer("screen-locker")
|
||||||
|
|
||||||
# Every tool here only reads local state: the workout log, the sick history
|
# Every tool here only reads local state: the workout log, the sickdays pool
|
||||||
# and the clock. Saying so lets a client run them without asking -- and the
|
# and the clock. Saying so lets a client run them without asking -- and the
|
||||||
# day one of them stops being read-only, this annotation is the thing that
|
# day one of them stops being read-only, this annotation is the thing that
|
||||||
# has to change with it. (See the section banner below: "never mutate" was
|
# has to change with it. (See the section banner below: "never mutate" was
|
||||||
@ -92,7 +91,7 @@ def get_status() -> dict[str, Any]:
|
|||||||
|
|
||||||
A JSON-friendly projection of ``gather_status()``: today's and this week's
|
A JSON-friendly projection of ``gather_status()``: today's and this week's
|
||||||
workout outcomes, the shutdown-time projection, the lock-decision trace,
|
workout outcomes, the shutdown-time projection, the lock-decision trace,
|
||||||
and the rolling sick-day / manual-workout budgets. Reads on-disk state
|
and the rolling manual-workout budget. Reads on-disk state
|
||||||
only (no ADB, sudo, or network) and never mutates anything.
|
only (no ADB, sudo, or network) and never mutates anything.
|
||||||
"""
|
"""
|
||||||
return asdict(gather_status())
|
return asdict(gather_status())
|
||||||
@ -137,13 +136,16 @@ def get_flags() -> dict[str, bool]:
|
|||||||
``scheduled_skips.json``: it is now the shared, fleet-wide free-day pool
|
``scheduled_skips.json``: it is now the shared, fleet-wide free-day pool
|
||||||
(``~/src/utils/freedays``), so a true here also means diet-guard, wake-alarm,
|
(``~/src/utils/freedays``), so a true here also means diet-guard, wake-alarm,
|
||||||
leetcode-guard and home-guard are standing down today.
|
leetcode-guard and home-guard are standing down today.
|
||||||
|
|
||||||
|
``is_sick_day_today`` reads the shared ``sickdays`` pool and answers "is the
|
||||||
|
workout lock switched off today": a sick day that keeps the ``workout``
|
||||||
|
locker reads False. Never a screen-locker file.
|
||||||
"""
|
"""
|
||||||
history = load_history()
|
|
||||||
return {
|
return {
|
||||||
"has_logged_today": has_logged_today(_LOG_FILE),
|
"has_logged_today": has_logged_today(_LOG_FILE),
|
||||||
"is_scheduled_skip_today": is_scheduled_skip_today(),
|
"is_scheduled_skip_today": is_scheduled_skip_today(),
|
||||||
"is_early_bird_pending": is_early_bird_pending(EARLY_BIRD_PENDING_FILE),
|
"is_early_bird_pending": is_early_bird_pending(EARLY_BIRD_PENDING_FILE),
|
||||||
"is_sick_day_today": is_sick_day_today(history),
|
"is_sick_day_today": is_sick_day_today(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
56
screen_locker/_morning_session.py
Normal file
56
screen_locker/_morning_session.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
"""Read the morning-session carrot the wake-alarm service signed for today.
|
||||||
|
|
||||||
|
The reader itself is ``gatelock.morning_session``, shared with leetcode-guard
|
||||||
|
and book-guard; this module only binds screen-locker's path and retry budget,
|
||||||
|
which the test suite redirects. The policy lives in wake-alarm: the only
|
||||||
|
decision anywhere is *HMAC ok, dated today, now < exempt_until*.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from gatelock.morning_session import (
|
||||||
|
MORNING_RETRY_SECONDS as _SHARED_RETRY_SECONDS,
|
||||||
|
)
|
||||||
|
from gatelock.morning_session import (
|
||||||
|
MorningSkip,
|
||||||
|
morning_skip,
|
||||||
|
)
|
||||||
|
|
||||||
|
from screen_locker._constants import MORNING_SESSION_FILE
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
__all__ = ["MorningSkip", "has_workout_skip_today", "morning_skip_today"]
|
||||||
|
|
||||||
|
MORNING_RETRY_SECONDS: float = _SHARED_RETRY_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def morning_skip_today(
|
||||||
|
now: datetime | None = None,
|
||||||
|
*,
|
||||||
|
wait: bool,
|
||||||
|
sleep: Callable[[float], None] = time.sleep,
|
||||||
|
) -> MorningSkip | None:
|
||||||
|
"""The skip the morning session earned, or None.
|
||||||
|
|
||||||
|
With ``wait`` (the enforce path only), a file that is not today's during
|
||||||
|
the morning window is retried so a freshly booted PC gives wake-alarm's
|
||||||
|
catch-up run a chance to land.
|
||||||
|
"""
|
||||||
|
return morning_skip(
|
||||||
|
MORNING_SESSION_FILE,
|
||||||
|
now,
|
||||||
|
wait=wait,
|
||||||
|
retry_seconds=MORNING_RETRY_SECONDS,
|
||||||
|
sleep=sleep,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_workout_skip_today() -> bool:
|
||||||
|
"""Whether the morning session earned a skip right now. Never waits."""
|
||||||
|
return morning_skip_today(wait=False) is not None
|
||||||
70
screen_locker/_night_lockdown.py
Normal file
70
screen_locker/_night_lockdown.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""The workout lock does not re-arm after tonight's evening night-lockdown.
|
||||||
|
|
||||||
|
The predicate itself is ``gatelock.night_lockdown``, shared with
|
||||||
|
leetcode-guard and book-guard (diet-guard deliberately does not opt in); this
|
||||||
|
module only binds screen-locker's path, which the test suite redirects, and
|
||||||
|
the ladder rung that honours it.
|
||||||
|
|
||||||
|
Why: once tonight's lockdown has fired, a late credit (``_late_credit``) can
|
||||||
|
lift it -- and re-locking behind the workout gate the same evening would make
|
||||||
|
a finished workout impossible to submit. Only an EVENING lock counts (local
|
||||||
|
date today, >= 12:00); the 04:01 morning re-lock does not. Missing or garbage
|
||||||
|
stamp means "no lockdown": the workout lock behaves exactly as before.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from gatelock.night_lockdown import (
|
||||||
|
NIGHT_LOCKDOWN_STAMP as _SHARED_STAMP,
|
||||||
|
)
|
||||||
|
from gatelock.night_lockdown import (
|
||||||
|
evening_lockdown_today,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EVENING_LOCKDOWN_REASON",
|
||||||
|
"NIGHT_LOCKDOWN_STAMP",
|
||||||
|
"has_evening_lockdown_today",
|
||||||
|
"skip_after_evening_lockdown",
|
||||||
|
"tonights_lockdown",
|
||||||
|
]
|
||||||
|
|
||||||
|
NIGHT_LOCKDOWN_STAMP: Path = _SHARED_STAMP
|
||||||
|
|
||||||
|
EVENING_LOCKDOWN_REASON = "evening_lockdown_today"
|
||||||
|
|
||||||
|
|
||||||
|
def tonights_lockdown(now: datetime | None = None) -> datetime | None:
|
||||||
|
"""When tonight's evening lockdown locked, or None if it has not yet."""
|
||||||
|
return evening_lockdown_today(NIGHT_LOCKDOWN_STAMP, now)
|
||||||
|
|
||||||
|
|
||||||
|
def has_evening_lockdown_today() -> bool:
|
||||||
|
"""Whether tonight's evening lockdown already happened. Read-only."""
|
||||||
|
return tonights_lockdown() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def skip_after_evening_lockdown(record_skip: Callable[..., None]) -> bool:
|
||||||
|
"""Skip enforcement once tonight's evening lockdown has fired.
|
||||||
|
|
||||||
|
Takes the locker's ``_record_skip`` (which exits the process) rather than
|
||||||
|
the locker, like ``_lock_invariant``. Returns True when the skip was
|
||||||
|
recorded, False when the lock may go ahead.
|
||||||
|
"""
|
||||||
|
locked_at = tonights_lockdown()
|
||||||
|
if locked_at is None:
|
||||||
|
return False
|
||||||
|
record_skip(
|
||||||
|
EVENING_LOCKDOWN_REASON,
|
||||||
|
f"Tonight's night lockdown already locked at {locked_at:%H:%M}; the "
|
||||||
|
"workout lock does not re-arm after it.",
|
||||||
|
locked_at=locked_at.isoformat(),
|
||||||
|
)
|
||||||
|
return True
|
||||||
@ -1,4 +1,4 @@
|
|||||||
"""Phone workout verification: GitHub sync first, ADB/HTTP as fallback."""
|
"""Phone workout verification: Firebase sync first, ADB/HTTP as fallback."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -28,6 +28,18 @@ _HTTP_OK = _http_client.OK
|
|||||||
_logger = logging.getLogger(__name__)
|
_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):
|
class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||||
"""Mixin providing phone-based workout verification via ADB."""
|
"""Mixin providing phone-based workout verification via ADB."""
|
||||||
|
|
||||||
@ -78,8 +90,10 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
|||||||
if duration_min < WORKOUT_DURATION_ACCEPT_MINUTES:
|
if duration_min < WORKOUT_DURATION_ACCEPT_MINUTES:
|
||||||
return (
|
return (
|
||||||
"too_short",
|
"too_short",
|
||||||
f"Workout too short! {duration_min:.0f} min logged, "
|
(
|
||||||
f"need at least {MIN_WORKOUT_DURATION_MINUTES} min.",
|
f"Workout too short! {duration_min:.0f} min logged, "
|
||||||
|
f"need at least {MIN_WORKOUT_DURATION_MINUTES} min."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
flag = "all succeeded" if data.get("succeeded") else "partial"
|
flag = "all succeeded" if data.get("succeeded") else "partial"
|
||||||
return "verified", f"Workout verified! ({duration_min:.0f} min, {flag})"
|
return "verified", f"Workout verified! ({duration_min:.0f} min, {flag})"
|
||||||
@ -117,16 +131,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
|||||||
return 0
|
return 0
|
||||||
data = self._pull_workout_app_json()
|
data = self._pull_workout_app_json()
|
||||||
date_str = data.get("date") if data else None
|
date_str = data.get("date") if data else None
|
||||||
if not date_str:
|
if not date_str or not _in_current_week(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
|
return 0
|
||||||
status, msg = self._validate_json_content(data)
|
status, msg = self._validate_json_content(data)
|
||||||
if status != "verified":
|
if status != "verified":
|
||||||
@ -143,8 +148,35 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
|||||||
|
|
||||||
# ── HTTP fallback (no ADB / developer options required) ───────────────────
|
# ── 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]:
|
def _verify_phone_workout(self) -> tuple[str, str]:
|
||||||
"""Verify today's workout: GitHub sync first, ADB/HTTP as fallback.
|
"""Verify today's workout: Firebase sync first, ADB/HTTP as fallback.
|
||||||
|
|
||||||
Returns (status, message). Status values:
|
Returns (status, message). Status values:
|
||||||
verified / too_short / not_verified / no_phone / sync_failed /
|
verified / too_short / not_verified / no_phone / sync_failed /
|
||||||
@ -154,28 +186,9 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
|||||||
if not clock_ok:
|
if not clock_ok:
|
||||||
return "clock_tampered", clock_msg
|
return "clock_tampered", clock_msg
|
||||||
|
|
||||||
# GitHub sync is the primary channel — it works without the phone
|
sync_result, sync_error = self._pull_sync_result()
|
||||||
# being on the same network as the PC. Only a *verified* sync result
|
if sync_result is not None and sync_result[0] == "verified":
|
||||||
# short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the
|
return sync_result
|
||||||
# phone's last push predates today, or a later push failed offline)
|
|
||||||
# must not preempt a fresher ADB pull, so anything less than
|
|
||||||
# "verified" is kept as a fallback-of-last-resort candidate instead
|
|
||||||
# of being returned immediately. A sync error never blocks unlocking
|
|
||||||
# either; `sync_failed` only surfaces if ADB/HTTP also come up empty.
|
|
||||||
synced_data, sync_error = pull_synced_workout()
|
|
||||||
sync_result: tuple[str, str] | None = None
|
|
||||||
if synced_data is not None:
|
|
||||||
sync_result = self._validate_json_data(synced_data)
|
|
||||||
if sync_result[0] == "verified":
|
|
||||||
return sync_result
|
|
||||||
_logger.info(
|
|
||||||
"Synced data not fully verified (%s) — trying ADB/HTTP...",
|
|
||||||
sync_result[0],
|
|
||||||
)
|
|
||||||
elif sync_error is not None:
|
|
||||||
_logger.info(
|
|
||||||
"GitHub sync unavailable (%s) — trying ADB/HTTP...", sync_error
|
|
||||||
)
|
|
||||||
|
|
||||||
# Prefer ADB when a device is visible, but if the pull yields no usable
|
# 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
|
# JSON, fall through to the HTTP/WiFi scan — the app's in-memory HTTP
|
||||||
@ -192,13 +205,23 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
|||||||
data = self._fetch_http_workout()
|
data = self._fetch_http_workout()
|
||||||
if data is not None:
|
if data is not None:
|
||||||
return self._validate_json_data(data)
|
return self._validate_json_data(data)
|
||||||
if sync_result is not None:
|
return _no_data_result(sync_result, sync_error, adb_connected=adb_connected)
|
||||||
return sync_result
|
|
||||||
if sync_error is not None:
|
|
||||||
return "sync_failed", sync_error
|
def _no_data_result(
|
||||||
if adb_connected:
|
sync_result: tuple[str, str] | None,
|
||||||
return (
|
sync_error: str | None,
|
||||||
"not_verified",
|
*,
|
||||||
"Workout app JSON not found. Complete a workout in the app first.",
|
adb_connected: bool,
|
||||||
)
|
) -> tuple[str, str]:
|
||||||
return "no_phone", "Phone not reachable via ADB or HTTP on local network"
|
"""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"
|
||||||
|
|||||||
242
screen_locker/_poke_credit.py
Normal file
242
screen_locker/_poke_credit.py
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
"""Credit one poked StrongLifts session: the sync's ingest, minus the network.
|
||||||
|
|
||||||
|
The phone POSTs a finished session straight to the PC
|
||||||
|
(``docs/DOCS-workout-poke-contract.md``); this applies it through exactly the
|
||||||
|
path a synced session takes -- :func:`ingest_session_records`, which
|
||||||
|
re-validates and dedups under ``log.lock``, then the shared
|
||||||
|
:meth:`~screen_locker._workout_credit.WorkoutCreditMixin._apply_credit_for_written_entry`
|
||||||
|
(slot-based credit, shutdown push, the "Workout credited" notification).
|
||||||
|
Nothing here computes a reward of its own.
|
||||||
|
|
||||||
|
Each call builds a FRESH headless locker, the same object
|
||||||
|
``--sync-only`` uses, so no state leaks from one request into the next.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Final, cast
|
||||||
|
|
||||||
|
from screen_locker._cli import _headless_locker
|
||||||
|
from screen_locker._constants import (
|
||||||
|
EXTRA_BENEFITS_FILE,
|
||||||
|
SHUTDOWN_BASE_FILE,
|
||||||
|
SHUTDOWN_CONFIG_FILE,
|
||||||
|
)
|
||||||
|
from screen_locker._credit_notify import gaming_minutes_today
|
||||||
|
from screen_locker._day import today_str
|
||||||
|
from screen_locker._earned import hhmm
|
||||||
|
from screen_locker._extra_benefits import process_week_transition
|
||||||
|
from screen_locker._grace_floor import tonight_minutes
|
||||||
|
from screen_locker._manual_sync import ingest_manual_records
|
||||||
|
from screen_locker._session_sync import ingest_session_records, validate_session
|
||||||
|
from screen_locker._shutdown import read_shutdown_config
|
||||||
|
from screen_locker._shutdown_base import reset_to_base_if_new_day
|
||||||
|
from screen_locker.screen_lock import ScreenLocker
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from screen_locker._workout_credit import WorkoutCreditResult
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BatchOutcome",
|
||||||
|
"PokeOutcome",
|
||||||
|
"credit_manual_workouts",
|
||||||
|
"credit_session",
|
||||||
|
"credit_sessions",
|
||||||
|
"current_gaming_minutes",
|
||||||
|
"current_shutdown",
|
||||||
|
]
|
||||||
|
|
||||||
|
_logger: Final = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PokeOutcome:
|
||||||
|
"""What crediting one poked session did; ``reason`` is for the phone's UI."""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
credited: bool
|
||||||
|
duplicate: bool
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BatchOutcome:
|
||||||
|
"""What one batch did: newly logged ids, and how many of those earned time."""
|
||||||
|
|
||||||
|
ingested: tuple[str, ...]
|
||||||
|
credited: int
|
||||||
|
|
||||||
|
|
||||||
|
class PokeLocker(ScreenLocker):
|
||||||
|
"""The headless locker plus the two steps a poke needs, as public calls."""
|
||||||
|
|
||||||
|
def start_the_day(self) -> None:
|
||||||
|
"""Run the new-day reset the locker's startup runs, in the same order.
|
||||||
|
|
||||||
|
Mirrors ``StartupChecksMixin._check_non_verify_exits``, not
|
||||||
|
``sync_now`` (which never resets): the week transition (streak,
|
||||||
|
early-bird window) first, then the daily reset.
|
||||||
|
"""
|
||||||
|
for reward in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE):
|
||||||
|
_logger.info("Weekly reward: %s", reward)
|
||||||
|
reset_to_base_if_new_day(
|
||||||
|
SHUTDOWN_BASE_FILE,
|
||||||
|
self,
|
||||||
|
log_file=self.log_file,
|
||||||
|
)
|
||||||
|
# A future-dated manual workout logged on this PC whose day is today
|
||||||
|
# earns its credit on the first PC action of the day, not at the next
|
||||||
|
# 15-minute tick. Local ledger only: no network on the instant path.
|
||||||
|
self._ingest_due_local_manual_workouts()
|
||||||
|
|
||||||
|
def credit_written(
|
||||||
|
self, entry: dict[str, str], prior_entries: list[dict[str, object]]
|
||||||
|
) -> WorkoutCreditResult:
|
||||||
|
"""``SyncMixin._credit_ingested_workout``, keeping the result."""
|
||||||
|
self.workout_data = entry
|
||||||
|
return self._apply_credit_for_written_entry(prior_entries)
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_locker() -> PokeLocker:
|
||||||
|
"""The ``--sync-only`` headless locker: no Tk, no window, no lock."""
|
||||||
|
return cast("PokeLocker", _headless_locker(PokeLocker))
|
||||||
|
|
||||||
|
|
||||||
|
def current_shutdown() -> str | None:
|
||||||
|
"""Tonight's shutdown from ``/etc/shutdown-schedule.conf`` as ``HH:MM``."""
|
||||||
|
config = read_shutdown_config(SHUTDOWN_CONFIG_FILE)
|
||||||
|
if config is None:
|
||||||
|
_logger.warning(
|
||||||
|
"Workout poke: shutdown config unreadable, replying shutdown=null"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return hhmm(tonight_minutes(config, date.fromisoformat(today_str())))
|
||||||
|
|
||||||
|
|
||||||
|
def current_gaming_minutes(log_file: Path) -> int | None:
|
||||||
|
"""Today's gaming budget (``None`` and a warning when it cannot be summed)."""
|
||||||
|
return gaming_minutes_today(log_file)
|
||||||
|
|
||||||
|
|
||||||
|
def _precheck(record_id: str, payload: Mapping[str, object]) -> str | None:
|
||||||
|
"""Why the session can never count, or ``None`` if it can.
|
||||||
|
|
||||||
|
``ingest_session_records`` applies the same rules but only returns what
|
||||||
|
it ingested, so a rejection would be indistinguishable from a duplicate.
|
||||||
|
"""
|
||||||
|
day = payload.get("date")
|
||||||
|
if not isinstance(day, str) or not day:
|
||||||
|
return f"session {record_id} has no usable date ({day!r}), so it cannot count"
|
||||||
|
valid, detail = validate_session(payload)
|
||||||
|
if not valid:
|
||||||
|
return f"session {record_id} does not count: {detail}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def credit_session(record_id: str, payload: Mapping[str, object]) -> PokeOutcome:
|
||||||
|
"""Reset the day if new, then ingest and credit one session.
|
||||||
|
|
||||||
|
The caller serialises calls; the ingest also holds ``log.lock`` and the
|
||||||
|
credit the shutdown-state lock, so a concurrent sync or locker pass is safe.
|
||||||
|
"""
|
||||||
|
locker = fresh_locker()
|
||||||
|
locker.start_the_day()
|
||||||
|
problem = _precheck(record_id, payload)
|
||||||
|
if problem is not None:
|
||||||
|
_logger.warning("Workout poke not credited: %s", problem)
|
||||||
|
return PokeOutcome(ok=False, credited=False, duplicate=False, reason=problem)
|
||||||
|
|
||||||
|
results: list[WorkoutCreditResult] = []
|
||||||
|
|
||||||
|
def on_ingested(
|
||||||
|
entry: dict[str, str], prior_entries: list[dict[str, object]]
|
||||||
|
) -> None:
|
||||||
|
results.append(locker.credit_written(entry, prior_entries))
|
||||||
|
|
||||||
|
ingested = ingest_session_records(
|
||||||
|
locker.log_file, [(record_id, payload)], on_ingested=on_ingested
|
||||||
|
)
|
||||||
|
if not ingested or not results:
|
||||||
|
reason = (
|
||||||
|
f"session {record_id} is already in the PC's log (the sync or an "
|
||||||
|
"earlier poke got there first); nothing new to credit"
|
||||||
|
)
|
||||||
|
_logger.info("Workout poke duplicate: %s", reason)
|
||||||
|
return PokeOutcome(ok=True, credited=False, duplicate=True, reason=reason)
|
||||||
|
if results[0].already_counted_today:
|
||||||
|
reason = (
|
||||||
|
f"session {record_id} was logged, but today's slot for it was already "
|
||||||
|
"paid by another copy of the same workout; no extra time"
|
||||||
|
)
|
||||||
|
_logger.warning("Workout poke not credited: %s", reason)
|
||||||
|
return PokeOutcome(ok=True, credited=False, duplicate=False, reason=reason)
|
||||||
|
_logger.info("Workout poke credited session %s", record_id)
|
||||||
|
return PokeOutcome(
|
||||||
|
ok=True, credited=True, duplicate=False, reason="workout credited"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def credit_sessions(
|
||||||
|
records: Iterable[tuple[str, Mapping[str, object]]],
|
||||||
|
) -> BatchOutcome:
|
||||||
|
"""Reset the day if new, then ingest and credit a batch of synced sessions.
|
||||||
|
|
||||||
|
``SyncMixin._ingest_synced_sessions`` with one fresh locker for the whole
|
||||||
|
batch -- what the Firebase stream (:mod:`screen_locker._session_stream`)
|
||||||
|
runs per event. Already-logged ids cost one ``log.json`` read and write
|
||||||
|
nothing; invalid ones are warned about by the ingest itself.
|
||||||
|
"""
|
||||||
|
locker = fresh_locker()
|
||||||
|
locker.start_the_day()
|
||||||
|
results: list[WorkoutCreditResult] = []
|
||||||
|
|
||||||
|
def on_ingested(
|
||||||
|
entry: dict[str, str], prior_entries: list[dict[str, object]]
|
||||||
|
) -> None:
|
||||||
|
results.append(locker.credit_written(entry, prior_entries))
|
||||||
|
|
||||||
|
ingested = ingest_session_records(locker.log_file, records, on_ingested=on_ingested)
|
||||||
|
return _outcome(ingested, results, "session")
|
||||||
|
|
||||||
|
|
||||||
|
def credit_manual_workouts(
|
||||||
|
records: Iterable[tuple[str, Mapping[str, object]]],
|
||||||
|
*,
|
||||||
|
today: str | None = None,
|
||||||
|
) -> BatchOutcome:
|
||||||
|
"""``credit_sessions`` for manual workouts, via ``ingest_manual_records``."""
|
||||||
|
locker = fresh_locker()
|
||||||
|
locker.start_the_day()
|
||||||
|
results: list[WorkoutCreditResult] = []
|
||||||
|
|
||||||
|
def on_ingested(
|
||||||
|
entry: dict[str, str], prior_entries: list[dict[str, object]]
|
||||||
|
) -> None:
|
||||||
|
results.append(locker.credit_written(entry, prior_entries))
|
||||||
|
|
||||||
|
ingested = ingest_manual_records(
|
||||||
|
locker.log_file, records, today=today, on_ingested=on_ingested
|
||||||
|
)
|
||||||
|
return _outcome(ingested, results, "manual workout")
|
||||||
|
|
||||||
|
|
||||||
|
def _outcome(
|
||||||
|
ingested: list[str], results: list[WorkoutCreditResult], what: str
|
||||||
|
) -> BatchOutcome:
|
||||||
|
"""Count what earned time; warn about logged copies that earned none."""
|
||||||
|
credited = sum(not result.already_counted_today for result in results)
|
||||||
|
if len(results) > credited:
|
||||||
|
_logger.warning(
|
||||||
|
"%d streamed %s(s) were logged but earned no time: today's slot "
|
||||||
|
"was already paid by another copy of the same workout",
|
||||||
|
len(results) - credited,
|
||||||
|
what,
|
||||||
|
)
|
||||||
|
return BatchOutcome(ingested=tuple(ingested), credited=credited)
|
||||||
218
screen_locker/_poke_server.py
Normal file
218
screen_locker/_poke_server.py
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
"""The resident LAN listener that credits a phone workout within a second.
|
||||||
|
|
||||||
|
``workout-poke.service`` runs ``python3 -m screen_locker.poke_server``: a
|
||||||
|
stdlib ``ThreadingHTTPServer`` on ``0.0.0.0:8774`` (the firewall admits only
|
||||||
|
192.168.1.0/24) answering ``POST /v1/workout`` per
|
||||||
|
``docs/DOCS-workout-poke-contract.md``. Every import -- the locker, the
|
||||||
|
earned-time registry, gatelock -- is paid once at startup, so a request costs
|
||||||
|
only the credit itself.
|
||||||
|
|
||||||
|
The wire checks are pure (:mod:`screen_locker._poke_wire`); the crediting is
|
||||||
|
the sync's own ingest (:mod:`screen_locker._poke_credit`). One lock serialises
|
||||||
|
the nonce cache and every credit in this process; cross-process safety comes
|
||||||
|
from the locks the ingest and credit already hold.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
|
from screen_locker import _poke_credit
|
||||||
|
from screen_locker._poke_wire import (
|
||||||
|
NonceCache,
|
||||||
|
Rejection,
|
||||||
|
check_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from screen_locker._poke_wire import PokeRequest
|
||||||
|
|
||||||
|
__all__ = ["MAX_BODY_BYTES", "POKE_PATH", "PokeServer"]
|
||||||
|
|
||||||
|
_logger: Final = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_BODY_BYTES: Final = 64 * 1024
|
||||||
|
POKE_PATH: Final = "/v1/workout"
|
||||||
|
_HANDLER_TIMEOUT_S: Final = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _reply(
|
||||||
|
outcome: _poke_credit.PokeOutcome,
|
||||||
|
*,
|
||||||
|
sandbox: bool = False,
|
||||||
|
snapshot: tuple[str | None, int | None] = (None, None),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""The contract's response shape; ``pc_ms`` is filled in by the handler.
|
||||||
|
|
||||||
|
``snapshot`` is (tonight's shutdown ``HH:MM``, gaming minutes); refusals
|
||||||
|
leave it null rather than tell an unverified caller anything.
|
||||||
|
"""
|
||||||
|
shutdown, gaming = snapshot
|
||||||
|
return {
|
||||||
|
"ok": outcome.ok,
|
||||||
|
"credited": outcome.credited,
|
||||||
|
"duplicate": outcome.duplicate,
|
||||||
|
"sandbox": sandbox,
|
||||||
|
"shutdown": shutdown,
|
||||||
|
"gaming_budget_minutes": gaming,
|
||||||
|
"pc_ms": 0,
|
||||||
|
"reason": outcome.reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _refused(reason: str) -> dict[str, object]:
|
||||||
|
"""A refusal's reply: not ok, nothing credited."""
|
||||||
|
return _reply(
|
||||||
|
_poke_credit.PokeOutcome(
|
||||||
|
ok=False, credited=False, duplicate=False, reason=reason
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _process(request: PokeRequest) -> dict[str, object]:
|
||||||
|
"""Credit (or, for a sandbox request, only answer) one verified poke."""
|
||||||
|
locker = _poke_credit.fresh_locker()
|
||||||
|
if request.sandbox:
|
||||||
|
outcome = _poke_credit.PokeOutcome(
|
||||||
|
ok=True,
|
||||||
|
credited=False,
|
||||||
|
duplicate=False,
|
||||||
|
reason="sandbox request verified; nothing written or credited",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
outcome = _poke_credit.credit_session(request.record_id, request.payload)
|
||||||
|
return _reply(
|
||||||
|
outcome,
|
||||||
|
sandbox=request.sandbox,
|
||||||
|
snapshot=(
|
||||||
|
_poke_credit.current_shutdown(),
|
||||||
|
_poke_credit.current_gaming_minutes(locker.log_file),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PokeServer(ThreadingHTTPServer):
|
||||||
|
"""Holds the key, the nonce cache and the one credit lock."""
|
||||||
|
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
def __init__(self, address: tuple[str, int], key: bytes) -> None:
|
||||||
|
"""Bind ``address`` and serve :class:`PokeHandler` with ``key``."""
|
||||||
|
super().__init__(address, PokeHandler)
|
||||||
|
self.key = key
|
||||||
|
self.nonces = NonceCache()
|
||||||
|
self.gate = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class PokeHandler(BaseHTTPRequestHandler):
|
||||||
|
"""``POST /v1/workout``; anything else is answered 404/405 in JSON."""
|
||||||
|
|
||||||
|
server: PokeServer
|
||||||
|
timeout = _HANDLER_TIMEOUT_S
|
||||||
|
server_version = "workout-poke/1"
|
||||||
|
|
||||||
|
# The default access log (one stderr line per request) is kept: the
|
||||||
|
# journal is where it lands, and every refusal is also logged at warning.
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
"""Only POST exists."""
|
||||||
|
self._send(
|
||||||
|
*self._refusal(
|
||||||
|
Rejection(
|
||||||
|
HTTPStatus.METHOD_NOT_ALLOWED, f"only POST {POKE_PATH} exists"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
"""Verify, credit and answer one poke; never let an error go unanswered."""
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
status, reply = self._handle()
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught # handler boundary: logged, answered 500
|
||||||
|
_logger.exception("Workout poke from %s crashed", self.client_address[0])
|
||||||
|
status = HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
reply = _refused(
|
||||||
|
"the PC hit an internal error (see its journal); the "
|
||||||
|
"regular sync will still credit this session"
|
||||||
|
)
|
||||||
|
reply["pc_ms"] = round((time.perf_counter() - started) * 1000)
|
||||||
|
self._send(status, reply)
|
||||||
|
|
||||||
|
def _refusal(self, rejection: Rejection) -> tuple[HTTPStatus, dict[str, object]]:
|
||||||
|
"""Log a refusal at warning; the reply carries the same reason."""
|
||||||
|
_logger.warning(
|
||||||
|
"Workout poke from %s rejected (%d): %s",
|
||||||
|
self.client_address[0],
|
||||||
|
rejection.status,
|
||||||
|
rejection.reason,
|
||||||
|
)
|
||||||
|
return rejection.status, _refused(rejection.reason)
|
||||||
|
|
||||||
|
def _read_body(self) -> bytes | Rejection:
|
||||||
|
"""Exactly Content-Length bytes of ``POST /v1/workout``, capped.
|
||||||
|
|
||||||
|
Over :data:`MAX_BODY_BYTES` is refused before reading a byte of it.
|
||||||
|
"""
|
||||||
|
if self.path != POKE_PATH:
|
||||||
|
return Rejection(
|
||||||
|
HTTPStatus.NOT_FOUND, f"unknown path {self.path!r}; use {POKE_PATH}"
|
||||||
|
)
|
||||||
|
raw = self.headers.get("Content-Length")
|
||||||
|
if raw is None:
|
||||||
|
return Rejection(HTTPStatus.LENGTH_REQUIRED, "Content-Length is required")
|
||||||
|
length = int(raw) if raw.strip().isdigit() else -1
|
||||||
|
if length < 0:
|
||||||
|
return Rejection(HTTPStatus.BAD_REQUEST, f"bad Content-Length {raw!r}")
|
||||||
|
if length > MAX_BODY_BYTES:
|
||||||
|
return Rejection(
|
||||||
|
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
f"body is {length} bytes; the limit is {MAX_BODY_BYTES}",
|
||||||
|
)
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
if len(body) != length:
|
||||||
|
return Rejection(
|
||||||
|
HTTPStatus.BAD_REQUEST,
|
||||||
|
f"body ended after {len(body)} of {length} bytes",
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
def _handle(self) -> tuple[HTTPStatus, dict[str, object]]:
|
||||||
|
"""Run the contract's checks, then credit; returns (status, reply)."""
|
||||||
|
body = self._read_body()
|
||||||
|
if isinstance(body, Rejection):
|
||||||
|
return self._refusal(body)
|
||||||
|
with self.server.gate:
|
||||||
|
verdict = check_request(
|
||||||
|
self.server.key,
|
||||||
|
body,
|
||||||
|
self.headers.get("X-Poke-Signature"),
|
||||||
|
self.server.nonces,
|
||||||
|
now_ms=time.time_ns() // 1_000_000,
|
||||||
|
)
|
||||||
|
if isinstance(verdict, Rejection):
|
||||||
|
return self._refusal(verdict)
|
||||||
|
reply = _process(verdict)
|
||||||
|
_logger.info(
|
||||||
|
"Workout poke %s (sandbox=%s): %s",
|
||||||
|
verdict.record_id,
|
||||||
|
verdict.sandbox,
|
||||||
|
reply["reason"],
|
||||||
|
)
|
||||||
|
return HTTPStatus.OK, reply
|
||||||
|
|
||||||
|
def _send(self, status: HTTPStatus, reply: dict[str, object]) -> None:
|
||||||
|
"""Write ``reply`` as a JSON response and close the connection."""
|
||||||
|
data = json.dumps(reply, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
218
screen_locker/_poke_wire.py
Normal file
218
screen_locker/_poke_wire.py
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
"""Phone -> PC workout poke: the wire checks, pure and stdlib-only.
|
||||||
|
|
||||||
|
The binding contract is ``docs/DOCS-workout-poke-contract.md`` and its literal
|
||||||
|
fixture ``contracts/workout_poke_v1.json``. Everything here is a pure function
|
||||||
|
of its inputs (``now_ms`` is a parameter, never read from a clock), so the
|
||||||
|
shared-fixture test can feed it the fixture's bytes as-is. The HTTP plumbing
|
||||||
|
lives in :mod:`screen_locker._poke_server`, the crediting in
|
||||||
|
:mod:`screen_locker._poke_credit`.
|
||||||
|
|
||||||
|
The MAC is computed over the raw body bytes *as received*; nothing here ever
|
||||||
|
re-serializes JSON, because a Dart and a Python encoder disagree on floats and
|
||||||
|
non-ASCII escaping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from http import HTTPStatus
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"REPLAY_WINDOW_MS",
|
||||||
|
"NonceCache",
|
||||||
|
"PokeRequest",
|
||||||
|
"Rejection",
|
||||||
|
"check_request",
|
||||||
|
"parse_key",
|
||||||
|
"verify_signature",
|
||||||
|
]
|
||||||
|
|
||||||
|
PROTOCOL_VERSION: Final = 1
|
||||||
|
REPLAY_WINDOW_MS: Final = 120_000
|
||||||
|
KEY_HEX_LENGTH: Final = 64
|
||||||
|
|
||||||
|
_logger: Final = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PokeRequest:
|
||||||
|
"""A request that passed every contract check.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
sent_at_ms: The phone's send time, epoch milliseconds.
|
||||||
|
nonce: The request's one-time id.
|
||||||
|
sandbox: Verify and answer, but write and credit nothing.
|
||||||
|
record_id: The session's sync record id.
|
||||||
|
payload: The session exactly as the phone synced it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sent_at_ms: int
|
||||||
|
nonce: str
|
||||||
|
sandbox: bool
|
||||||
|
record_id: str
|
||||||
|
payload: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Rejection:
|
||||||
|
"""Why a request was refused, and with which HTTP status."""
|
||||||
|
|
||||||
|
status: HTTPStatus
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_key(text: str) -> bytes:
|
||||||
|
"""Decode the shared key file's content (64 hex chars, 32 bytes).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: With a sentence saying what is wrong with the key.
|
||||||
|
"""
|
||||||
|
stripped = text.strip()
|
||||||
|
if len(stripped) != KEY_HEX_LENGTH:
|
||||||
|
msg = f"key must be {KEY_HEX_LENGTH} hex characters, got {len(stripped)}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
try:
|
||||||
|
return bytes.fromhex(stripped)
|
||||||
|
except ValueError as exc:
|
||||||
|
msg = f"key is not valid hex ({exc})"
|
||||||
|
raise ValueError(msg) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signature(key: bytes, body: bytes, signature_header: str | None) -> bool:
|
||||||
|
"""True iff ``signature_header`` is hex HMAC-SHA256(key, body).
|
||||||
|
|
||||||
|
Compared as bytes: the header arrives latin-1 decoded, and
|
||||||
|
``hmac.compare_digest`` raises on a non-ASCII ``str``.
|
||||||
|
"""
|
||||||
|
if not signature_header:
|
||||||
|
return False
|
||||||
|
expected = hmac.new(key, body, hashlib.sha256).hexdigest().encode("ascii")
|
||||||
|
given = signature_header.strip().lower().encode("latin-1", errors="replace")
|
||||||
|
return hmac.compare_digest(expected, given)
|
||||||
|
|
||||||
|
|
||||||
|
class NonceCache:
|
||||||
|
"""Nonces seen inside the replay window, pruned by their ``sent_at``.
|
||||||
|
|
||||||
|
Pruned by the request's own send time, not by when it arrived: a request
|
||||||
|
stamped up to 120 s in the future stays acceptable until ``sent_at + 120
|
||||||
|
s``, so its nonce must be remembered at least that long.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Start empty; the window is :data:`REPLAY_WINDOW_MS`."""
|
||||||
|
self._seen: dict[str, int] = {}
|
||||||
|
|
||||||
|
def check_and_add(self, nonce: str, sent_at_ms: int, now_ms: int) -> bool:
|
||||||
|
"""Record ``nonce``; False if it was already seen inside the window."""
|
||||||
|
self._seen = {
|
||||||
|
seen: sent
|
||||||
|
for seen, sent in self._seen.items()
|
||||||
|
if now_ms - sent <= REPLAY_WINDOW_MS
|
||||||
|
}
|
||||||
|
if nonce in self._seen:
|
||||||
|
return False
|
||||||
|
self._seen[nonce] = sent_at_ms
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
"""How many nonces are remembered right now."""
|
||||||
|
return len(self._seen)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_int(value: object) -> bool:
|
||||||
|
"""An ``int`` that is not a ``bool`` (JSON ``true`` decodes to one)."""
|
||||||
|
return isinstance(value, int) and not isinstance(value, bool)
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_error(doc: dict[str, object]) -> str | None:
|
||||||
|
"""The first missing or mistyped field after the replay checks, if any."""
|
||||||
|
if not _is_int(doc.get("v")) or doc.get("v") != PROTOCOL_VERSION:
|
||||||
|
return f"unsupported protocol version {doc.get('v')!r}; expected 1"
|
||||||
|
if not isinstance(doc.get("sandbox"), bool):
|
||||||
|
return "field 'sandbox' must be true or false"
|
||||||
|
record_id = doc.get("record_id")
|
||||||
|
if not isinstance(record_id, str) or not record_id:
|
||||||
|
return "field 'record_id' must be a non-empty string"
|
||||||
|
if not isinstance(doc.get("payload"), dict):
|
||||||
|
return "field 'payload' must be a JSON object"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _decode(body: bytes) -> dict[str, object] | Rejection:
|
||||||
|
"""The verified body as a JSON object, or a 400."""
|
||||||
|
try:
|
||||||
|
doc = json.loads(body.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
# Signed by the key holder yet unparsable: a sender bug, not noise.
|
||||||
|
_logger.warning("Poke body is signed but not UTF-8 JSON: %s", exc)
|
||||||
|
return Rejection(HTTPStatus.BAD_REQUEST, f"body is not UTF-8 JSON ({exc})")
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
return Rejection(HTTPStatus.BAD_REQUEST, "body is not a JSON object")
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def _replay_check(
|
||||||
|
doc: dict[str, object], nonces: NonceCache, now_ms: int
|
||||||
|
) -> tuple[int, str] | Rejection:
|
||||||
|
"""Checks 2 and 3: fresh enough (401), nonce unseen (409).
|
||||||
|
|
||||||
|
Returns ``(sent_at_ms, nonce)``; a missing or mistyped field either check
|
||||||
|
needs is a 400 at that point.
|
||||||
|
"""
|
||||||
|
sent_at = doc.get("sent_at_ms")
|
||||||
|
if not isinstance(sent_at, int) or isinstance(sent_at, bool):
|
||||||
|
return Rejection(HTTPStatus.BAD_REQUEST, "field 'sent_at_ms' must be an int")
|
||||||
|
if abs(now_ms - sent_at) > REPLAY_WINDOW_MS:
|
||||||
|
return Rejection(
|
||||||
|
HTTPStatus.UNAUTHORIZED,
|
||||||
|
f"request is stale: sent_at_ms is {(now_ms - sent_at) / 1000:+.0f} s "
|
||||||
|
"off the PC clock (+ = in the past), the window is 120 s; check "
|
||||||
|
"the phone's clock",
|
||||||
|
)
|
||||||
|
nonce = doc.get("nonce")
|
||||||
|
if not isinstance(nonce, str) or not nonce:
|
||||||
|
return Rejection(HTTPStatus.BAD_REQUEST, "field 'nonce' must be a string")
|
||||||
|
if not nonces.check_and_add(nonce, sent_at, now_ms):
|
||||||
|
return Rejection(HTTPStatus.CONFLICT, f"nonce {nonce} was already used")
|
||||||
|
return sent_at, nonce
|
||||||
|
|
||||||
|
|
||||||
|
def check_request(
|
||||||
|
key: bytes,
|
||||||
|
body: bytes,
|
||||||
|
signature_header: str | None,
|
||||||
|
nonces: NonceCache,
|
||||||
|
now_ms: int,
|
||||||
|
) -> PokeRequest | Rejection:
|
||||||
|
"""Run the contract's checks in order; the first failure wins.
|
||||||
|
|
||||||
|
1. signature -> 401, 2. ``|now - sent_at| <= 120 s`` -> 401,
|
||||||
|
3. nonce unseen -> 409, 4. ``v == 1`` and fields present -> 400.
|
||||||
|
JSON is parsed only after the MAC over the raw bytes matched.
|
||||||
|
"""
|
||||||
|
if not verify_signature(key, body, signature_header):
|
||||||
|
return Rejection(HTTPStatus.UNAUTHORIZED, "signature does not match the key")
|
||||||
|
doc = _decode(body)
|
||||||
|
if isinstance(doc, Rejection):
|
||||||
|
return doc
|
||||||
|
replay = _replay_check(doc, nonces, now_ms)
|
||||||
|
if isinstance(replay, Rejection):
|
||||||
|
return replay
|
||||||
|
problem = _schema_error(doc)
|
||||||
|
if problem is not None:
|
||||||
|
return Rejection(HTTPStatus.BAD_REQUEST, problem)
|
||||||
|
sent_at, nonce = replay
|
||||||
|
payload = doc["payload"]
|
||||||
|
return PokeRequest(
|
||||||
|
sent_at_ms=sent_at,
|
||||||
|
nonce=nonce,
|
||||||
|
sandbox=doc["sandbox"] is True,
|
||||||
|
record_id=str(doc["record_id"]),
|
||||||
|
payload=dict(payload) if isinstance(payload, dict) else {},
|
||||||
|
)
|
||||||
@ -1,54 +0,0 @@
|
|||||||
"""Turning a completed push into an honest result.
|
|
||||||
|
|
||||||
Split out of :mod:`screen_locker._manual_push` to keep every file under the
|
|
||||||
250-line cap.
|
|
||||||
|
|
||||||
A push that the primary backend refused still reaches the GitHub mirror, and
|
|
||||||
the records are not lost -- the phone reads the union of both. What must not
|
|
||||||
happen is that half-landed push logging the same line as a whole one, which is
|
|
||||||
how weeks of broken Firebase pushes looked healthy in the journal.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from screen_locker._constants import SYNC_REPO_NAME, SYNC_REPO_OWNER
|
|
||||||
from screen_locker._degraded_sources import degraded_sources
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def describe_push(record_count: int) -> tuple[bool, str]:
|
|
||||||
"""Report whether every backend received the records, and say so.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
record_count: How many records the push carried.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``(complete, reason)`` -- ``reason`` reads like a sentence either way,
|
|
||||||
because it is what ``PushResult`` carries back to the caller.
|
|
||||||
"""
|
|
||||||
missing = [source.name for source in degraded_sources()]
|
|
||||||
if not missing:
|
|
||||||
_logger.info(
|
|
||||||
"Synced %d workout(s) to %s/%s",
|
|
||||||
record_count,
|
|
||||||
SYNC_REPO_OWNER,
|
|
||||||
SYNC_REPO_NAME,
|
|
||||||
)
|
|
||||||
return True, "pushed"
|
|
||||||
names = ", ".join(missing)
|
|
||||||
reason = (
|
|
||||||
f"pushed to {SYNC_REPO_OWNER}/{SYNC_REPO_NAME} only; "
|
|
||||||
f"{names} did NOT receive these records"
|
|
||||||
)
|
|
||||||
_logger.warning(
|
|
||||||
"Workout sync push INCOMPLETE for %d workout(s): %s — the phone reads "
|
|
||||||
"both backends, so this still syncs, but %s is broken and must be "
|
|
||||||
"fixed before it is the only path left",
|
|
||||||
record_count,
|
|
||||||
reason,
|
|
||||||
names,
|
|
||||||
)
|
|
||||||
return False, reason
|
|
||||||
193
screen_locker/_rest_day.py
Normal file
193
screen_locker/_rest_day.py
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
"""Rest days: declared AHEAD of time, at most two per ISO week, HMAC-signed.
|
||||||
|
|
||||||
|
A declared rest day earns the workout's first-unit shutdown bonus, so the day
|
||||||
|
resolves as if its workout were done. Two rules keep it from being an escape:
|
||||||
|
|
||||||
|
* **Ahead only.** A rest day must be declared on an earlier local day than
|
||||||
|
the one it names -- never "today", so it cannot be reached for at the
|
||||||
|
moment the shutdown bites. Checked when declaring AND again when reading
|
||||||
|
(``declared_on < day``), so a row written by any other route still fails.
|
||||||
|
The CLI refuses to run on a skewed clock (``check_clock_skew``), the one
|
||||||
|
way to make tomorrow look like today.
|
||||||
|
* **Two per ISO week**, counted by the rest day's own week over the verified
|
||||||
|
rows, oldest declaration first; a third row in a week is ignored on read.
|
||||||
|
|
||||||
|
Each declaration is mirrored as a ``rest_day`` credit row in the workout
|
||||||
|
ledger (:mod:`screen_locker._workout_ledger`), which ``earned_time`` reads.
|
||||||
|
|
||||||
|
Rows live in ``rest_days.json`` as ``{"entries": [...]}``, each signed with
|
||||||
|
``earned_time.entry_signature`` and the shared key, the same as every gate's
|
||||||
|
ledger: editing the file without the key breaks the row, and a broken row
|
||||||
|
earns nothing.
|
||||||
|
|
||||||
|
Unlike a sick day -- a date in the shared ``sickdays`` pool that stands
|
||||||
|
every gate down -- a rest day is planned and only touches shutdown time. It
|
||||||
|
does not stand down the screen lock or the weekly minimum (``freedays`` and
|
||||||
|
``sickdays`` do that).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import earned_time
|
||||||
|
|
||||||
|
from screen_locker import _earned
|
||||||
|
from screen_locker._constants import REST_DAY_FILE, REST_DAYS_PER_ISO_WEEK
|
||||||
|
from screen_locker._workout_ledger import append_rows, rest_row
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_KIND = "rest_day"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Declared:
|
||||||
|
"""Outcome of :func:`declare`: ``ok`` and a sentence a human can act on."""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def _key(key_file: Path) -> bytes | None:
|
||||||
|
try:
|
||||||
|
key = key_file.read_bytes().strip()
|
||||||
|
except OSError as exc:
|
||||||
|
_logger.warning("Cannot read the rest-day signing key %s (%s)", key_file, exc)
|
||||||
|
return None
|
||||||
|
return key or None
|
||||||
|
|
||||||
|
|
||||||
|
def _rows(path: Path) -> list[Any] | None:
|
||||||
|
"""The file's ``entries``; ``[]`` if it does not exist, ``None`` if unreadable."""
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
_logger.warning("Rest-day file %s is unreadable (%s)", path, exc)
|
||||||
|
return None
|
||||||
|
rows = raw.get("entries") if isinstance(raw, dict) else None
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
_logger.warning("Rest-day file %s has no entries array", path)
|
||||||
|
return None
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_week(day: date) -> tuple[int, int]:
|
||||||
|
year, week, _ = day.isocalendar()
|
||||||
|
return year, week
|
||||||
|
|
||||||
|
|
||||||
|
def _valid(row: object, key: bytes) -> bool:
|
||||||
|
"""A signed rest-day row, declared on an earlier day than it names."""
|
||||||
|
if not isinstance(row, dict) or row.get("kind") != _KIND:
|
||||||
|
return False
|
||||||
|
if not earned_time.verified(row, key):
|
||||||
|
_logger.warning("Rest-day row %r fails its signature; ignored", row.get("day"))
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(str(row["declared_on"])) < date.fromisoformat(
|
||||||
|
str(row["day"])
|
||||||
|
)
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
_logger.warning("Rest-day row %r is malformed (%s); ignored", row, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def rest_day_declarations(
|
||||||
|
path: Path | None = None, key_file: Path | None = None
|
||||||
|
) -> dict[date, float]:
|
||||||
|
"""Every rest day that counts, with the unix time it was declared.
|
||||||
|
|
||||||
|
Counted: verified, declared ahead, within the weekly cap (oldest first).
|
||||||
|
"""
|
||||||
|
key = _key(key_file or _earned.HMAC_KEY_FILE)
|
||||||
|
rows = _rows(path or REST_DAY_FILE)
|
||||||
|
if key is None or rows is None:
|
||||||
|
return {}
|
||||||
|
good = sorted(
|
||||||
|
(r for r in rows if _valid(r, key)), key=lambda r: float(r["declared_at"])
|
||||||
|
)
|
||||||
|
counted: dict[date, float] = {}
|
||||||
|
for row in good:
|
||||||
|
day = date.fromisoformat(str(row["day"]))
|
||||||
|
same_week = sum(1 for d in counted if _iso_week(d) == _iso_week(day))
|
||||||
|
if day not in counted and same_week < REST_DAYS_PER_ISO_WEEK:
|
||||||
|
counted[day] = float(row["declared_at"])
|
||||||
|
return counted
|
||||||
|
|
||||||
|
|
||||||
|
def rest_days(path: Path | None = None, key_file: Path | None = None) -> set[date]:
|
||||||
|
"""Every rest day that counts (see :func:`rest_day_declarations`)."""
|
||||||
|
return set(rest_day_declarations(path, key_file))
|
||||||
|
|
||||||
|
|
||||||
|
def is_rest_day(
|
||||||
|
day: date, path: Path | None = None, key_file: Path | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Whether ``day`` is a declared rest day (``False`` when unverifiable)."""
|
||||||
|
return day in rest_days(path, key_file)
|
||||||
|
|
||||||
|
|
||||||
|
def declare(
|
||||||
|
day: date,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
path: Path | None = None,
|
||||||
|
key_file: Path | None = None,
|
||||||
|
ledger: Path | None = None,
|
||||||
|
) -> Declared:
|
||||||
|
"""Sign and append a rest day for ``day``, if the rules allow it.
|
||||||
|
|
||||||
|
Paths default at call time to ``REST_DAY_FILE`` and the key ``_earned``
|
||||||
|
verifies every ledger with, so the test suite's redirects apply.
|
||||||
|
"""
|
||||||
|
path = path or REST_DAY_FILE
|
||||||
|
key_file = key_file or _earned.HMAC_KEY_FILE
|
||||||
|
moment = (now or datetime.now(tz=UTC)).astimezone()
|
||||||
|
if day <= moment.date():
|
||||||
|
return Declared(
|
||||||
|
ok=False,
|
||||||
|
reason=f"{day} is not in the future: rest days are declared ahead",
|
||||||
|
)
|
||||||
|
existing = rest_days(path, key_file)
|
||||||
|
if day in existing:
|
||||||
|
return Declared(ok=True, reason=f"{day} is already a rest day")
|
||||||
|
used = sum(1 for d in existing if _iso_week(d) == _iso_week(day))
|
||||||
|
if used >= REST_DAYS_PER_ISO_WEEK:
|
||||||
|
return Declared(
|
||||||
|
ok=False,
|
||||||
|
reason=f"ISO week {_iso_week(day)[1]} already has "
|
||||||
|
f"{REST_DAYS_PER_ISO_WEEK} rest days",
|
||||||
|
)
|
||||||
|
key = _key(key_file)
|
||||||
|
rows = _rows(path)
|
||||||
|
if key is None or rows is None:
|
||||||
|
return Declared(ok=False, reason="rest-day file or signing key unreadable")
|
||||||
|
row: dict[str, object] = {
|
||||||
|
"kind": _KIND,
|
||||||
|
"day": day.isoformat(),
|
||||||
|
"declared_on": moment.date().isoformat(),
|
||||||
|
"declared_at": moment.timestamp(),
|
||||||
|
}
|
||||||
|
row["hmac"] = earned_time.entry_signature(row, key)
|
||||||
|
rows.append(row)
|
||||||
|
try:
|
||||||
|
path.write_text(json.dumps({"entries": rows}, indent=2), encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
_logger.warning("Could not write rest day to %s (%s)", path, exc)
|
||||||
|
return Declared(ok=False, reason=f"could not write {path}: {exc}")
|
||||||
|
append_rows([rest_row(day, moment.timestamp())], ledger, key_file)
|
||||||
|
left = REST_DAYS_PER_ISO_WEEK - used - 1
|
||||||
|
return Declared(
|
||||||
|
ok=True,
|
||||||
|
reason=f"{day} is a rest day ({left} left in ISO week {_iso_week(day)[1]})",
|
||||||
|
)
|
||||||
81
screen_locker/_rest_day_cli.py
Normal file
81
screen_locker/_rest_day_cli.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"""``--declare-rest-day``: plan a rest day from the command line.
|
||||||
|
|
||||||
|
python -m screen_locker.screen_lock --declare-rest-day 2026-10-12
|
||||||
|
python -m screen_locker.screen_lock --declare-rest-day tomorrow
|
||||||
|
python -m screen_locker.screen_lock --declare-rest-day --list
|
||||||
|
|
||||||
|
The clock is checked against NTP first and an unconfirmable clock is a
|
||||||
|
refusal, not a pass: unlike the lock's own check (which fails open when NTP
|
||||||
|
is unreachable), a wound-back clock with the network off is exactly how
|
||||||
|
"today" would be passed off as tomorrow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from screen_locker._constants import MAX_CLOCK_SKEW_SECONDS
|
||||||
|
from screen_locker._day import today_str
|
||||||
|
from screen_locker._rest_day import declare, rest_days
|
||||||
|
from screen_locker._time_check import _query_ntp_offset
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _say(message: str) -> None:
|
||||||
|
sys.stdout.write(f"{message}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_day(raw: str) -> date:
|
||||||
|
today = date.fromisoformat(today_str())
|
||||||
|
if raw == "tomorrow":
|
||||||
|
return today + timedelta(days=1)
|
||||||
|
return date.fromisoformat(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _clock_confirmed() -> str | None:
|
||||||
|
"""``None`` if NTP confirms the clock, else why the declaration is refused."""
|
||||||
|
offset = _query_ntp_offset()
|
||||||
|
if offset is None:
|
||||||
|
return "NTP is unreachable, so the clock cannot be confirmed; try online"
|
||||||
|
if abs(offset) > MAX_CLOCK_SKEW_SECONDS:
|
||||||
|
return f"the clock is off by {abs(offset):.0f}s from NTP"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_declare_rest_day(argv: Sequence[str]) -> int:
|
||||||
|
"""Declare (or list) rest days. Returns a process exit code."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="screen-locker --declare-rest-day",
|
||||||
|
description="Declare a FUTURE rest day (max 2 per ISO week).",
|
||||||
|
)
|
||||||
|
parser.add_argument("day", nargs="?", help="YYYY-MM-DD or 'tomorrow'")
|
||||||
|
parser.add_argument("--list", action="store_true", help="list rest days")
|
||||||
|
args = parser.parse_args(list(argv))
|
||||||
|
if args.list or args.day is None:
|
||||||
|
days = sorted(rest_days())
|
||||||
|
_say("\n".join(d.isoformat() for d in days) or "no rest days declared")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
day = _parse_day(args.day)
|
||||||
|
except ValueError as exc:
|
||||||
|
_logger.warning("Unparsable rest day %r: %s", args.day, exc)
|
||||||
|
_say(f"error: {exc}")
|
||||||
|
return 2
|
||||||
|
refusal = _clock_confirmed()
|
||||||
|
if refusal is not None:
|
||||||
|
_logger.warning("Rest day refused: %s", refusal)
|
||||||
|
_say(f"refused: {refusal}")
|
||||||
|
return 1
|
||||||
|
result = declare(day)
|
||||||
|
if not result.ok:
|
||||||
|
_logger.warning("Rest day refused: %s", result.reason)
|
||||||
|
_say(result.reason if result.ok else f"refused: {result.reason}")
|
||||||
|
return 0 if result.ok else 1
|
||||||
119
screen_locker/_rtdb_stream.py
Normal file
119
screen_locker/_rtdb_stream.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"""Pure parsing for a Firebase RTDB REST stream of the device-log node.
|
||||||
|
|
||||||
|
``GET <db>/screen-locker-sync/devices.json`` with ``Accept: text/event-stream``
|
||||||
|
answers with Server-Sent Events: ``put``/``patch`` carry
|
||||||
|
``{"path": ..., "data": ...}`` relative to the streamed node, plus
|
||||||
|
``keep-alive``, ``cancel`` and ``auth_revoked``. Everything here is network-free
|
||||||
|
so it can be unit-tested; the connection itself is
|
||||||
|
:mod:`screen_locker._session_stream`.
|
||||||
|
|
||||||
|
Each device log is ONE JSON-string leaf at ``<device>/log~2Ejson``
|
||||||
|
(``crdt_sync._fbkeys`` escapes the dot), so an event normalises to "these
|
||||||
|
devices now hold this text (or nothing)".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
|
from crdt_sync._firebase import decode_key
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable, Iterator
|
||||||
|
|
||||||
|
__all__ = ["LOG_LEAF", "SseEvent", "device_updates", "parse_sse"]
|
||||||
|
|
||||||
|
LOG_LEAF: Final = "log.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SseEvent:
|
||||||
|
"""One Server-Sent Event: its name and its (joined) data lines."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
data: str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sse(lines: Iterable[str]) -> Iterator[SseEvent]:
|
||||||
|
"""Yield events from decoded SSE lines; a blank line ends each event."""
|
||||||
|
name = ""
|
||||||
|
data: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
if not line:
|
||||||
|
if name or data:
|
||||||
|
yield SseEvent(name or "message", "\n".join(data))
|
||||||
|
name, data = "", []
|
||||||
|
elif line.startswith("event:"):
|
||||||
|
name = line[len("event:") :].strip()
|
||||||
|
elif line.startswith("data:"):
|
||||||
|
data.append(line[len("data:") :].lstrip())
|
||||||
|
|
||||||
|
|
||||||
|
def _segments(path: str) -> list[str]:
|
||||||
|
"""``/a/b~2Ec`` -> ``["a", "b.c"]`` (decoded, empty segments dropped)."""
|
||||||
|
return [decode_key(part) for part in path.split("/") if part]
|
||||||
|
|
||||||
|
|
||||||
|
def _texts_under(segments: list[str], data: object) -> dict[str, str | None]:
|
||||||
|
"""Device -> log text for a ``put`` of ``data`` at ``segments``.
|
||||||
|
|
||||||
|
``None`` means that device's log is gone (deleted, or not a text blob --
|
||||||
|
the latter is the caller's to warn about, see :func:`device_updates`).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: The data does not have the device-node shape at all.
|
||||||
|
"""
|
||||||
|
if not segments: # the whole node: {device: {leaf: text}}
|
||||||
|
if data is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
msg = f"devices node is {type(data).__name__}, not an object"
|
||||||
|
raise TypeError(msg)
|
||||||
|
return {decode_key(key): _leaf_text(value) for key, value in data.items()}
|
||||||
|
device = segments[0]
|
||||||
|
if len(segments) == 1:
|
||||||
|
return {device: _leaf_text(data)}
|
||||||
|
if segments[1:] == [LOG_LEAF]:
|
||||||
|
return {device: data if isinstance(data, str) else None}
|
||||||
|
return {} # something below a device that is not its log: irrelevant
|
||||||
|
|
||||||
|
|
||||||
|
def _leaf_text(device_node: object) -> str | None:
|
||||||
|
"""The log text inside one ``{leaf: text}`` device node, if any."""
|
||||||
|
if not isinstance(device_node, dict):
|
||||||
|
return None
|
||||||
|
for key, value in device_node.items():
|
||||||
|
if decode_key(key) == LOG_LEAF and isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def device_updates(event: SseEvent) -> tuple[bool, dict[str, str | None]]:
|
||||||
|
"""Normalise a ``put``/``patch`` into ``(is_full_snapshot, updates)``.
|
||||||
|
|
||||||
|
``updates`` maps a device id to its log's new full text, or ``None`` when
|
||||||
|
it no longer has one. A ``put`` at ``/`` is a full snapshot: the caller
|
||||||
|
must REPLACE its cache, since devices missing from it are gone. A
|
||||||
|
``patch`` carries several children whose keys may be multi-segment paths.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: The data is not JSON.
|
||||||
|
TypeError: The JSON is not the documented ``{path, data}`` shape.
|
||||||
|
"""
|
||||||
|
body = json.loads(event.data)
|
||||||
|
if not isinstance(body, dict) or not isinstance(body.get("path"), str):
|
||||||
|
msg = f"{event.name} event without a path: {event.data[:120]!r}"
|
||||||
|
raise TypeError(msg)
|
||||||
|
base = _segments(body["path"])
|
||||||
|
if event.name == "put":
|
||||||
|
return not base, _texts_under(base, body.get("data"))
|
||||||
|
children = body.get("data")
|
||||||
|
if not isinstance(children, dict):
|
||||||
|
msg = f"patch data is {type(children).__name__}, not an object"
|
||||||
|
raise TypeError(msg)
|
||||||
|
updates: dict[str, str | None] = {}
|
||||||
|
for key, value in children.items():
|
||||||
|
updates.update(_texts_under([*base, *_segments(key)], value))
|
||||||
|
return False, updates
|
||||||
@ -12,20 +12,49 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from screen_locker._log_mixin import write_signed_entry
|
from screen_locker._log_mixin import write_signed_entry
|
||||||
|
from screen_locker._walk_tcx import activity_day, activity_shortfall
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Same shape as ``_manual_sync.OnIngestedCallback``: the appended
|
||||||
|
# ``workout_data`` and that day's entries *before* it.
|
||||||
|
OnIngestedCallback = Callable[[dict, "list[dict]"], None]
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
log_file: Path,
|
||||||
|
date_str: str,
|
||||||
|
workout_data: dict[str, Any],
|
||||||
|
on_ingested: OnIngestedCallback | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Append one verified RunnerUp entry and credit it; False if a duplicate."""
|
||||||
|
result = write_signed_entry(log_file, date_str, workout_data)
|
||||||
|
if not result.appended:
|
||||||
|
return False
|
||||||
|
_logger.info(
|
||||||
|
"Auto-filled RunnerUp entry for %s: %s", date_str, workout_data["source"]
|
||||||
|
)
|
||||||
|
if on_ingested is not None:
|
||||||
|
on_ingested(workout_data, result.prior_entries)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class RunnerUpBackfillMixin:
|
class RunnerUpBackfillMixin:
|
||||||
"""Fills the log from RunnerUp exports for a date or a whole week."""
|
"""Fills the log from RunnerUp exports for a date or a whole week."""
|
||||||
|
|
||||||
def _try_fill_runnerup_for_date(self, date_str: str, log_file: Path) -> bool:
|
def _try_fill_runnerup_for_date(
|
||||||
|
self,
|
||||||
|
date_str: str,
|
||||||
|
log_file: Path,
|
||||||
|
on_ingested: OnIngestedCallback | None = None,
|
||||||
|
) -> bool:
|
||||||
"""Append a verified RunnerUp entry for ``date_str`` if not already logged.
|
"""Append a verified RunnerUp entry for ``date_str`` if not already logged.
|
||||||
|
|
||||||
Appends via the write chokepoint, which dedups by ``workout_id``
|
Appends via the write chokepoint, which dedups by ``workout_id``
|
||||||
@ -33,42 +62,74 @@ class RunnerUpBackfillMixin:
|
|||||||
recorded is a no-op, but a day that only holds, say, a manual workout
|
recorded is a no-op, but a day that only holds, say, a manual workout
|
||||||
still gets the run appended alongside it (multiple workouts per day).
|
still gets the run appended alongside it (multiple workouts per day).
|
||||||
|
|
||||||
|
``on_ingested(workout_data, prior_entries)`` fires once per appended
|
||||||
|
entry, exactly like manual-sync ingestion, so a run pulled off the
|
||||||
|
phone earns the same day-scaled reward as every other source. It used
|
||||||
|
to earn only a weekly-surplus bonus (+1h per workout above the weekly
|
||||||
|
minimum), which is 0 for most of the week: on 2026-09-16 a run synced
|
||||||
|
at 20:25 left the config at 20:00 while the daily reset would have
|
||||||
|
derived 22:00 from the same log.
|
||||||
|
|
||||||
Returns True if a new verified entry was appended for ``date_str``.
|
Returns True if a new verified entry was appended for ``date_str``.
|
||||||
"""
|
"""
|
||||||
for remote in self._find_runnerup_exports_for_date(date_str):
|
activities = [
|
||||||
data = self._pull_and_parse_tcx(remote)
|
data
|
||||||
if data is None:
|
for remote in self._find_runnerup_exports_for_date(date_str)
|
||||||
continue
|
if (data := self._pull_and_parse_tcx(remote)) is not None
|
||||||
status, msg = self._validate_runnerup_data(data)
|
]
|
||||||
if status != "verified":
|
return self._try_fill_day(date_str, activities, log_file, on_ingested)
|
||||||
_logger.warning(
|
|
||||||
"RunnerUp export %s for %s did not qualify (%s): %s",
|
|
||||||
remote,
|
|
||||||
date_str,
|
|
||||||
status,
|
|
||||||
msg,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
workout_data = {
|
|
||||||
"type": "runnerup_verified",
|
|
||||||
"source": f"Auto-scanned: {msg}",
|
|
||||||
"distance_km": round(data["distance_m"] / 1000, 2),
|
|
||||||
"duration_minutes": round(data["duration_seconds"] / 60, 1),
|
|
||||||
}
|
|
||||||
if write_signed_entry(log_file, date_str, workout_data).appended:
|
|
||||||
_logger.info("Auto-filled RunnerUp entry for %s: %s", date_str, msg)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _scan_and_fill_week_runnerup(self, log_file: Path) -> int:
|
@staticmethod
|
||||||
|
def _try_fill_day(
|
||||||
|
date_str: str,
|
||||||
|
activities: list[dict[str, Any]],
|
||||||
|
log_file: Path,
|
||||||
|
on_ingested: OnIngestedCallback | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Append the day's activity as its RunnerUp workout, once it adds up.
|
||||||
|
|
||||||
|
One rule for runs and walks alike (see :mod:`screen_locker._walk_tcx`):
|
||||||
|
the day's activities are summed, never judged one file at a time. They
|
||||||
|
share the ``runnerup_verified:{date}`` slot, so a walk and a run on
|
||||||
|
one day are one RunnerUp credit. ``activity: walking`` is kept for the
|
||||||
|
phone's history display only; it never decides credit.
|
||||||
|
"""
|
||||||
|
if not activities:
|
||||||
|
return False
|
||||||
|
day = activity_day(activities)
|
||||||
|
if not day.qualifies:
|
||||||
|
_logger.warning(
|
||||||
|
"RunnerUp activity for %s does not count yet: %s",
|
||||||
|
date_str,
|
||||||
|
activity_shortfall(day),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
workout_data: dict[str, Any] = {
|
||||||
|
"type": "runnerup_verified",
|
||||||
|
"source": f"Auto-scanned: {day.message()}",
|
||||||
|
"distance_km": round(day.distance_km, 2),
|
||||||
|
"duration_minutes": round(day.moving_minutes, 1),
|
||||||
|
"completed_at": day.completed_at,
|
||||||
|
}
|
||||||
|
if day.all_walks:
|
||||||
|
workout_data["activity"] = "walking"
|
||||||
|
return _record(log_file, date_str, workout_data, on_ingested)
|
||||||
|
|
||||||
|
def _scan_and_fill_week_runnerup(
|
||||||
|
self, log_file: Path, on_ingested: OnIngestedCallback | None = None
|
||||||
|
) -> int:
|
||||||
"""Scan the current ISO week for RunnerUp runs and append any not logged.
|
"""Scan the current ISO week for RunnerUp runs and append any not logged.
|
||||||
|
|
||||||
Returns the count of newly appended entries (0 if phone not connected).
|
``on_ingested`` is passed through to every appended entry; see
|
||||||
|
:meth:`_try_fill_runnerup_for_date`.
|
||||||
|
|
||||||
|
Returns the count of newly appended entries (0 if no export source --
|
||||||
|
neither the WebDAV drop directory nor an adb-visible phone).
|
||||||
"""
|
"""
|
||||||
if not self._has_adb_device():
|
if not self._has_runnerup_source():
|
||||||
_logger.info(
|
_logger.info(
|
||||||
"Phone not connected; skipping auto-scan for past RunnerUp exports."
|
"No RunnerUp source (no WebDAV drop dir, phone not connected); "
|
||||||
|
"skipping auto-scan for past RunnerUp exports."
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@ -79,7 +140,9 @@ class RunnerUpBackfillMixin:
|
|||||||
filled = 0
|
filled = 0
|
||||||
current = week_start
|
current = week_start
|
||||||
while current <= today:
|
while current <= today:
|
||||||
if self._try_fill_runnerup_for_date(current.strftime("%Y-%m-%d"), log_file):
|
if self._try_fill_runnerup_for_date(
|
||||||
|
current.strftime("%Y-%m-%d"), log_file, on_ingested
|
||||||
|
):
|
||||||
filled += 1
|
filled += 1
|
||||||
current += timedelta(days=1)
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
|||||||
@ -151,9 +151,11 @@ class RunnerUpDbMixin:
|
|||||||
if db_path is None:
|
if db_path is None:
|
||||||
return (
|
return (
|
||||||
"not_verified",
|
"not_verified",
|
||||||
"No TCX export found and root DB access is unavailable on this "
|
(
|
||||||
"device — enable RunnerUp's File Synchronizer auto-export so "
|
"No TCX export found and root DB access is unavailable on "
|
||||||
"runs land in /sdcard/Documents/RunnerUp/",
|
"this device — enable RunnerUp's File Synchronizer "
|
||||||
|
"auto-export so runs land in /sdcard/Documents/RunnerUp/"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
215
screen_locker/_runnerup_retire.py
Normal file
215
screen_locker/_runnerup_retire.py
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
"""Retire the stock RunnerUp app a week after the PC first credits an app run.
|
||||||
|
|
||||||
|
kuhy's workout_app records runs itself now. Once the PC has credited one of
|
||||||
|
them, RunnerUp is redundant: seven days later (``GRACE``) it is backed up and
|
||||||
|
uninstalled, but only if the app has imported the RunnerUp history (a marker
|
||||||
|
the app writes) and that import matches what ``processed/`` holds. With no
|
||||||
|
anchor (no app run ever credited) it never uninstalls.
|
||||||
|
|
||||||
|
The phone is not always on USB, so ``runnerup-retire.timer`` retries daily
|
||||||
|
until this succeeds. Every outcome is a typed :class:`RetireResult` whose
|
||||||
|
reason is logged at the level it deserves: ``info`` for the normal "not due
|
||||||
|
yet", ``warning`` for a skip that needs attention, ``error`` for a failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from screen_locker._runnerup_retire_adb import PACKAGE, AdbError
|
||||||
|
from screen_locker._runnerup_retire_result import LEVEL, Outcome, RetireResult
|
||||||
|
from screen_locker._runnerup_retire_state import load_state, save_state
|
||||||
|
from screen_locker._runnerup_retire_tcx import app_run_ends, check_marker, find_anchor
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from screen_locker._runnerup_retire_adb import PhoneLike
|
||||||
|
from screen_locker._runnerup_retire_state import RetireState
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
GRACE = timedelta(days=7)
|
||||||
|
STATE_FILE = Path.home() / ".local" / "state" / "screen-locker" / "runnerup_retire.json"
|
||||||
|
WATCH_DIR = Path.home() / "data" / "cloud" / "RunnerUp"
|
||||||
|
LOG_FILE = Path(__file__).resolve().parent / "log.json"
|
||||||
|
MARKER_NAME = ".workout_app_import.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Paths:
|
||||||
|
"""Every on-disk location, so tests (and ``--status``) can redirect them."""
|
||||||
|
|
||||||
|
state_file: Path
|
||||||
|
watch_dir: Path
|
||||||
|
log_file: Path
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default(cls) -> Paths:
|
||||||
|
"""The live locations (read at call time so tests can patch them)."""
|
||||||
|
return cls(STATE_FILE, WATCH_DIR, LOG_FILE)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def processed(self) -> Path:
|
||||||
|
"""Where endurain-import files the RunnerUp exports it has handled."""
|
||||||
|
return self.watch_dir / "processed"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def marker(self) -> Path:
|
||||||
|
"""The app's historic-import marker."""
|
||||||
|
return self.watch_dir / MARKER_NAME
|
||||||
|
|
||||||
|
def anchor_dirs(self) -> tuple[Path, Path]:
|
||||||
|
"""Where an app run's TCX can sit: the drop dir, then ``processed/``."""
|
||||||
|
return (self.watch_dir, self.processed)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_anchor(paths: Paths, state: RetireState) -> datetime | None:
|
||||||
|
"""The stored anchor, else the first credit of an app run found on disk."""
|
||||||
|
return state.anchor or find_anchor(paths.log_file, paths.anchor_dirs())
|
||||||
|
|
||||||
|
|
||||||
|
def _finish(paths: Paths, state: RetireState, now: datetime, why: str) -> None:
|
||||||
|
save_state(
|
||||||
|
paths.state_file, replace(state, done_at=now.isoformat(), done_reason=why)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _probe(phone: PhoneLike) -> RetireResult | tuple[str, bool]:
|
||||||
|
"""``(serial, installed)`` if the phone answers, else the result explaining why."""
|
||||||
|
if not phone.available():
|
||||||
|
return RetireResult(
|
||||||
|
Outcome.ADB_MISSING, "adb is not installed (pacman -S android-tools)"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
device = phone.find_device()
|
||||||
|
if device.serial is None:
|
||||||
|
return RetireResult(
|
||||||
|
Outcome.DEVICE_ABSENT, f"{device.reason}; retrying next run"
|
||||||
|
)
|
||||||
|
return device.serial, phone.installed(device.serial)
|
||||||
|
except AdbError as exc:
|
||||||
|
_logger.exception("RunnerUp retirement: asking the phone failed")
|
||||||
|
return RetireResult(
|
||||||
|
Outcome.PHONE_ERROR, f"asking the phone failed: {exc}", logged=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _backup(
|
||||||
|
paths: Paths, phone: PhoneLike, state: RetireState, serial: str, now: datetime
|
||||||
|
) -> RetireState | RetireResult:
|
||||||
|
"""Back the phone's exports up once; a later retry reuses a finished backup."""
|
||||||
|
if state.backup_path and Path(state.backup_path).is_dir():
|
||||||
|
return state
|
||||||
|
final = paths.watch_dir / f"runnerup_phone_backup_{now:%Y-%m-%d}"
|
||||||
|
try:
|
||||||
|
made, files = phone.backup(serial, final)
|
||||||
|
except (AdbError, OSError) as exc:
|
||||||
|
_logger.exception("RunnerUp retirement: backup failed")
|
||||||
|
return RetireResult(Outcome.BACKUP_FAILED, f"backup failed: {exc}", logged=True)
|
||||||
|
_logger.info("Backed up %d RunnerUp TCX files to %s", files, made)
|
||||||
|
state = replace(state, backup_path=str(made))
|
||||||
|
save_state(paths.state_file, state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _retire(
|
||||||
|
paths: Paths, phone: PhoneLike, state: RetireState, now: datetime, *, dry_run: bool
|
||||||
|
) -> RetireResult:
|
||||||
|
"""Everything after "it is due and the marker is good": the phone steps."""
|
||||||
|
probed = _probe(phone)
|
||||||
|
if isinstance(probed, RetireResult):
|
||||||
|
return probed
|
||||||
|
serial, present = probed
|
||||||
|
if not present:
|
||||||
|
if not dry_run:
|
||||||
|
_finish(paths, state, now, f"{PACKAGE} was already absent")
|
||||||
|
return RetireResult(Outcome.ALREADY_GONE, f"{PACKAGE} is already absent")
|
||||||
|
if dry_run:
|
||||||
|
return RetireResult(
|
||||||
|
Outcome.DRY_RUN, "would back up and uninstall (no changes made)"
|
||||||
|
)
|
||||||
|
backed = _backup(paths, phone, state, serial, now)
|
||||||
|
if isinstance(backed, RetireResult):
|
||||||
|
return backed
|
||||||
|
try:
|
||||||
|
phone.uninstall(serial)
|
||||||
|
except AdbError as exc:
|
||||||
|
_logger.exception("RunnerUp retirement: uninstall failed")
|
||||||
|
return RetireResult(
|
||||||
|
Outcome.UNINSTALL_FAILED, f"uninstall failed: {exc}", logged=True
|
||||||
|
)
|
||||||
|
_finish(paths, backed, now, f"uninstalled after backup to {backed.backup_path}")
|
||||||
|
return RetireResult(
|
||||||
|
Outcome.UNINSTALLED,
|
||||||
|
f"{PACKAGE} uninstalled; backup in {backed.backup_path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _no_anchor(paths: Paths) -> RetireResult:
|
||||||
|
"""Why there is no anchor; a missing source is a warning, not "nothing yet"."""
|
||||||
|
absent = [str(p) for p in (paths.watch_dir, paths.log_file) if not p.exists()]
|
||||||
|
if absent:
|
||||||
|
return RetireResult(
|
||||||
|
Outcome.SOURCES_MISSING,
|
||||||
|
f"cannot look for app runs, missing: {', '.join(absent)}",
|
||||||
|
)
|
||||||
|
seen = len(app_run_ends(paths.anchor_dirs()))
|
||||||
|
return RetireResult(
|
||||||
|
Outcome.NO_ANCHOR,
|
||||||
|
f"no app-recorded run credited yet ({seen} app TCX on disk); nothing to retire",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _gate(
|
||||||
|
paths: Paths, state: RetireState, now: datetime, *, dry_run: bool
|
||||||
|
) -> RetireState | RetireResult:
|
||||||
|
"""The no-phone checks; a state back means "due, marker good: go on"."""
|
||||||
|
if state.done_at:
|
||||||
|
return RetireResult(
|
||||||
|
Outcome.ALREADY_DONE, f"done {state.done_at}: {state.done_reason}"
|
||||||
|
)
|
||||||
|
anchor = derive_anchor(paths, state)
|
||||||
|
if anchor is None:
|
||||||
|
return _no_anchor(paths)
|
||||||
|
if not state.anchor_at:
|
||||||
|
state = replace(state, anchor_at=anchor.isoformat())
|
||||||
|
if not dry_run:
|
||||||
|
save_state(paths.state_file, state)
|
||||||
|
due = anchor + GRACE
|
||||||
|
if now < due:
|
||||||
|
return RetireResult(
|
||||||
|
Outcome.NOT_DUE, f"due {due:%Y-%m-%d %H:%M} (anchor {anchor:%Y-%m-%d})"
|
||||||
|
)
|
||||||
|
marker = check_marker(paths.marker, paths.processed)
|
||||||
|
if not marker.ok:
|
||||||
|
return RetireResult(
|
||||||
|
Outcome.MARKER_INVALID, f"{marker.reason}; NOT uninstalling"
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def run_retire(
|
||||||
|
paths: Paths,
|
||||||
|
phone: PhoneLike,
|
||||||
|
now: datetime | None = None,
|
||||||
|
*,
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> RetireResult:
|
||||||
|
"""One pass: uninstall RunnerUp if (and only if) every condition holds."""
|
||||||
|
when = now or datetime.now(UTC)
|
||||||
|
gated = _gate(paths, load_state(paths.state_file), when, dry_run=dry_run)
|
||||||
|
if isinstance(gated, RetireResult):
|
||||||
|
result = gated
|
||||||
|
else:
|
||||||
|
result = _retire(paths, phone, gated, when, dry_run=dry_run)
|
||||||
|
if not result.logged:
|
||||||
|
_logger.log(
|
||||||
|
LEVEL.get(result.outcome, logging.INFO),
|
||||||
|
"RunnerUp retirement: %s: %s",
|
||||||
|
result.outcome.value,
|
||||||
|
result.reason,
|
||||||
|
)
|
||||||
|
return result
|
||||||
181
screen_locker/_runnerup_retire_adb.py
Normal file
181
screen_locker/_runnerup_retire_adb.py
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
"""The phone side of the RunnerUp retirement: find it, back it up, uninstall.
|
||||||
|
|
||||||
|
Every call carries ``-s <serial>`` and goes through one ``run`` seam, so tests
|
||||||
|
never touch a device. adb's exit code is not trusted for the uninstall: it
|
||||||
|
counts only when the output says ``Success`` AND ``pm list packages`` no
|
||||||
|
longer lists the package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import TYPE_CHECKING, Any, NoReturn, Protocol
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PHONE_SERIAL = "23181JEGR08034"
|
||||||
|
PACKAGE = "org.runnerup.free"
|
||||||
|
REMOTE_DIR = "/sdcard/Documents/RunnerUp"
|
||||||
|
_ADB_TIMEOUT_S = 50
|
||||||
|
# The whole export history (TCX + GPX) over wifi adb; the oneshot unit has no
|
||||||
|
# start timeout, so a slow pull is allowed to finish.
|
||||||
|
_PULL_TIMEOUT_S = 900
|
||||||
|
|
||||||
|
|
||||||
|
class AdbError(RuntimeError):
|
||||||
|
"""An adb call failed; the message says what and why."""
|
||||||
|
|
||||||
|
|
||||||
|
def _fail(message: str) -> NoReturn:
|
||||||
|
raise AdbError(message)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Device:
|
||||||
|
"""Outcome of looking for the phone: the adb serial to use, or why not."""
|
||||||
|
|
||||||
|
serial: str | None
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneLike(Protocol):
|
||||||
|
"""What the retirement needs from a phone (tests substitute a fake)."""
|
||||||
|
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Whether adb can be run at all."""
|
||||||
|
|
||||||
|
def find_device(self) -> Device:
|
||||||
|
"""The attached phone, or why there is none."""
|
||||||
|
|
||||||
|
def installed(self, dev: str, *, hidden: bool = True) -> bool:
|
||||||
|
"""Whether RunnerUp is on the phone."""
|
||||||
|
|
||||||
|
def backup(self, dev: str, final: Path) -> tuple[Path, int]:
|
||||||
|
"""Pull the exports to ``final``."""
|
||||||
|
|
||||||
|
def uninstall(self, dev: str) -> None:
|
||||||
|
"""Uninstall RunnerUp or raise ``AdbError``."""
|
||||||
|
|
||||||
|
|
||||||
|
class Phone:
|
||||||
|
"""adb calls against one phone, with the process runner injectable."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
serial: str = PHONE_SERIAL,
|
||||||
|
*,
|
||||||
|
which: Callable[[str], str | None] = shutil.which,
|
||||||
|
run: Callable[..., Any] = subprocess.run,
|
||||||
|
) -> None:
|
||||||
|
"""Pin the phone's serial; ``which``/``run`` are the test seams."""
|
||||||
|
self.serial = serial
|
||||||
|
self._which = which
|
||||||
|
self._run = run
|
||||||
|
|
||||||
|
def available(self) -> bool:
|
||||||
|
"""Whether an ``adb`` binary is on PATH."""
|
||||||
|
return self._which("adb") is not None
|
||||||
|
|
||||||
|
def _adb(self, *args: str, timeout: int = _ADB_TIMEOUT_S) -> str:
|
||||||
|
cmd = ["adb", *args]
|
||||||
|
try:
|
||||||
|
done = self._run(
|
||||||
|
cmd, capture_output=True, text=True, timeout=timeout, check=False
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
msg = f"{' '.join(cmd)} did not run: {exc}"
|
||||||
|
raise AdbError(msg) from exc
|
||||||
|
if done.returncode:
|
||||||
|
detail = (done.stdout + done.stderr).strip()
|
||||||
|
_fail(f"{' '.join(cmd)} exited {done.returncode}: {detail}")
|
||||||
|
return str(done.stdout)
|
||||||
|
|
||||||
|
def _is_phone(self, serial: str) -> bool:
|
||||||
|
"""Whether an ``adb devices`` entry is the phone.
|
||||||
|
|
||||||
|
USB and mDNS serials embed the phone's serial; a plain ``ip:port``
|
||||||
|
wifi entry does not, so that one is asked for its ``ro.serialno``.
|
||||||
|
"""
|
||||||
|
if self.serial in serial:
|
||||||
|
return True
|
||||||
|
if ":" not in serial:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
asked = self._adb("-s", serial, "shell", "getprop", "ro.serialno")
|
||||||
|
except AdbError as exc:
|
||||||
|
_logger.warning("Could not read the serial of %s (%s)", serial, exc)
|
||||||
|
return False
|
||||||
|
return asked.strip() == self.serial
|
||||||
|
|
||||||
|
def find_device(self) -> Device:
|
||||||
|
"""The attached phone, over USB or wifi (a wireless serial embeds it)."""
|
||||||
|
listing = self._adb("devices")
|
||||||
|
states: list[str] = []
|
||||||
|
for line in listing.splitlines()[1:]:
|
||||||
|
serial, _, state = line.partition("\t")
|
||||||
|
if not self._is_phone(serial):
|
||||||
|
continue
|
||||||
|
if state.strip() == "device":
|
||||||
|
return Device(serial, "attached")
|
||||||
|
states.append(state.strip())
|
||||||
|
if states:
|
||||||
|
return Device(None, f"phone is {'/'.join(states)}, not ready")
|
||||||
|
return Device(None, f"phone {self.serial} is not attached")
|
||||||
|
|
||||||
|
def installed(self, dev: str, *, hidden: bool = True) -> bool:
|
||||||
|
"""Whether the package is on the phone (exact ``package:`` line).
|
||||||
|
|
||||||
|
``hidden`` adds ``-u`` so an app that focus mode hides or disables
|
||||||
|
still counts as present; it must not be mistaken for an uninstall.
|
||||||
|
"""
|
||||||
|
flag = ["-u"] if hidden else []
|
||||||
|
out = self._adb("-s", dev, "shell", "pm", "list", "packages", *flag, PACKAGE)
|
||||||
|
return f"package:{PACKAGE}" in out.split()
|
||||||
|
|
||||||
|
def backup(self, dev: str, final: Path) -> tuple[Path, int]:
|
||||||
|
"""Pull the phone's export folder next to ``final``; return (dir, tcx count).
|
||||||
|
|
||||||
|
Pulled into a staging dir and renamed only once every phone ``.tcx``
|
||||||
|
is on disk, so a half backup never looks like a finished one. The
|
||||||
|
result is a subfolder, never top-level: a top-level ``*.tcx`` would be
|
||||||
|
credited by runnerup-watch.
|
||||||
|
"""
|
||||||
|
listing = self._adb("-s", dev, "shell", "ls", REMOTE_DIR)
|
||||||
|
wanted = sum(1 for n in listing.split() if n.endswith(".tcx"))
|
||||||
|
staging = final.with_name(final.name + ".partial")
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
staging.mkdir(parents=True)
|
||||||
|
self._adb("-s", dev, "pull", REMOTE_DIR, str(staging), timeout=_PULL_TIMEOUT_S)
|
||||||
|
pulled = staging / Path(REMOTE_DIR).name
|
||||||
|
got = len(list(pulled.rglob("*.tcx"))) if pulled.is_dir() else -1
|
||||||
|
if got < wanted:
|
||||||
|
_fail(f"backup pulled {got} of {wanted} TCX files into {staging}")
|
||||||
|
target, n = final, 1
|
||||||
|
while target.exists():
|
||||||
|
n += 1
|
||||||
|
target = final.with_name(f"{final.name}-{n}")
|
||||||
|
pulled.rename(target)
|
||||||
|
staging.rmdir()
|
||||||
|
return target, got
|
||||||
|
|
||||||
|
def uninstall(self, dev: str) -> None:
|
||||||
|
"""Uninstall the package and prove it is gone, or raise ``AdbError``."""
|
||||||
|
try:
|
||||||
|
out = self._adb("-s", dev, "uninstall", PACKAGE)
|
||||||
|
except AdbError as exc:
|
||||||
|
# adb exits 1 with "Failure [DELETE_FAILED_...]": keep that reason
|
||||||
|
# (a device-owner policy can block it) in the error.
|
||||||
|
msg = f"uninstall refused: {exc}"
|
||||||
|
raise AdbError(msg) from exc
|
||||||
|
if "Success" not in out:
|
||||||
|
_fail(f"uninstall did not report Success: {out.strip()!r}")
|
||||||
|
if self.installed(dev, hidden=False):
|
||||||
|
_fail(f"{PACKAGE} is still listed after uninstall")
|
||||||
|
_logger.info("Uninstalled %s from %s", PACKAGE, dev)
|
||||||
62
screen_locker/_runnerup_retire_result.py
Normal file
62
screen_locker/_runnerup_retire_result.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""Outcomes of one RunnerUp-retirement pass, and how loudly each is logged."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import StrEnum
|
||||||
|
import logging
|
||||||
|
|
||||||
|
__all__ = ["LEVEL", "Outcome", "RetireResult"]
|
||||||
|
|
||||||
|
|
||||||
|
class Outcome(StrEnum):
|
||||||
|
"""How a run ended; the value is what the journal shows."""
|
||||||
|
|
||||||
|
UNINSTALLED = "uninstalled"
|
||||||
|
ALREADY_GONE = "already-gone"
|
||||||
|
ALREADY_DONE = "already-done"
|
||||||
|
NO_ANCHOR = "no-anchor"
|
||||||
|
SOURCES_MISSING = "sources-missing"
|
||||||
|
NOT_DUE = "not-due"
|
||||||
|
DRY_RUN = "dry-run"
|
||||||
|
DEVICE_ABSENT = "device-absent"
|
||||||
|
MARKER_INVALID = "marker-invalid"
|
||||||
|
ADB_MISSING = "adb-missing"
|
||||||
|
PHONE_ERROR = "phone-error"
|
||||||
|
BACKUP_FAILED = "backup-failed"
|
||||||
|
UNINSTALL_FAILED = "uninstall-failed"
|
||||||
|
|
||||||
|
|
||||||
|
# Level per outcome; the failures also make the unit exit non-zero.
|
||||||
|
LEVEL = {
|
||||||
|
Outcome.DEVICE_ABSENT: logging.WARNING,
|
||||||
|
Outcome.SOURCES_MISSING: logging.WARNING,
|
||||||
|
Outcome.MARKER_INVALID: logging.WARNING,
|
||||||
|
Outcome.ADB_MISSING: logging.ERROR,
|
||||||
|
Outcome.PHONE_ERROR: logging.ERROR,
|
||||||
|
Outcome.BACKUP_FAILED: logging.ERROR,
|
||||||
|
Outcome.UNINSTALL_FAILED: logging.ERROR,
|
||||||
|
}
|
||||||
|
_NONZERO = frozenset(
|
||||||
|
{
|
||||||
|
Outcome.MARKER_INVALID,
|
||||||
|
Outcome.ADB_MISSING,
|
||||||
|
Outcome.PHONE_ERROR,
|
||||||
|
Outcome.BACKUP_FAILED,
|
||||||
|
Outcome.UNINSTALL_FAILED,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RetireResult:
|
||||||
|
"""What happened and why, in a sentence a human can act on."""
|
||||||
|
|
||||||
|
outcome: Outcome
|
||||||
|
reason: str
|
||||||
|
logged: bool = False # the failure was already logged where it was caught
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
"""False for outcomes the unit should surface as failed."""
|
||||||
|
return self.outcome not in _NONZERO
|
||||||
71
screen_locker/_runnerup_retire_state.py
Normal file
71
screen_locker/_runnerup_retire_state.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"""The retirement's own memory: the anchor, the backup, the done stamp.
|
||||||
|
|
||||||
|
One small JSON file (path: ``_runnerup_retire.STATE_FILE``). Written
|
||||||
|
atomically and only by a real run (``--status`` and ``--dry-run`` never write),
|
||||||
|
so the file is also the proof that a run happened. The anchor is stored once
|
||||||
|
and never moved: a later, earlier-looking credit must not push the deadline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, fields
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RetireState:
|
||||||
|
"""What a previous run recorded (all ISO strings, empty until known)."""
|
||||||
|
|
||||||
|
anchor_at: str = ""
|
||||||
|
backup_path: str = ""
|
||||||
|
done_at: str = ""
|
||||||
|
done_reason: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def anchor(self) -> datetime | None:
|
||||||
|
"""The stored anchor as an aware datetime, or None."""
|
||||||
|
return datetime.fromisoformat(self.anchor_at) if self.anchor_at else None
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(path: Path) -> RetireState:
|
||||||
|
"""Read the state; a missing file is a fresh start, a bad one is loud."""
|
||||||
|
if not path.exists():
|
||||||
|
return RetireState()
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"Retire state %s is unreadable (%s): starting from scratch, so the "
|
||||||
|
"anchor is re-derived and the uninstall re-checked",
|
||||||
|
path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return RetireState()
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
_logger.warning("Retire state %s is not a JSON object: ignored", path)
|
||||||
|
return RetireState()
|
||||||
|
known = {f.name for f in fields(RetireState)}
|
||||||
|
clean = {k: v for k, v in raw.items() if k in known and isinstance(v, str)}
|
||||||
|
try:
|
||||||
|
state = RetireState(**clean)
|
||||||
|
_ = state.anchor
|
||||||
|
except ValueError as exc:
|
||||||
|
_logger.warning("Retire state %s holds a bad anchor (%s): ignored", path, exc)
|
||||||
|
return RetireState()
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(path: Path, state: RetireState) -> None:
|
||||||
|
"""Atomically replace the state file (creates the directory on first write)."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".retire-", suffix=".tmp")
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(asdict(state), handle, indent=2)
|
||||||
|
Path(tmp).replace(path)
|
||||||
210
screen_locker/_runnerup_retire_tcx.py
Normal file
210
screen_locker/_runnerup_retire_tcx.py
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
"""What the RunnerUp retirement reads off disk: app runs, the marker, the anchor.
|
||||||
|
|
||||||
|
Split from :mod:`screen_locker._runnerup_retire` (250-line cap). Everything
|
||||||
|
here is read-only; nothing touches the phone.
|
||||||
|
|
||||||
|
Telling the two sources apart is done on the TCX *content* (``<Author>`` or
|
||||||
|
``<Creator>`` named ``kuhy workout_app``, the app's ``kRunSourceName``), never
|
||||||
|
on the file name: the app may name its files however it likes.
|
||||||
|
|
||||||
|
The anchor is "when the PC first CREDITED an app run". The credit chokepoint
|
||||||
|
(``log.json``) stores a run's TCX end as ``completed_at`` on its
|
||||||
|
``runnerup_verified`` entry and stamps the entry itself with the moment the PC
|
||||||
|
wrote it. So the anchor is the ``timestamp`` of the entry whose
|
||||||
|
``completed_at`` equals an app TCX's last trackpoint. Joining on the end time
|
||||||
|
(not the day) is what makes a same-day RunnerUp run, which fills the day's one
|
||||||
|
credit slot first, correctly yield no anchor for the app run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from screen_locker._log_io import load_workout_log
|
||||||
|
from screen_locker._runnerup_tcx import parse_tcx_file
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
APP_SOURCE_NAME = "kuhy workout_app"
|
||||||
|
_APP_RE = re.compile(
|
||||||
|
rb"<(?:Author|Creator)\b[^>]*>\s*<Name>\s*"
|
||||||
|
+ re.escape(APP_SOURCE_NAME.encode())
|
||||||
|
+ rb"\s*</Name>"
|
||||||
|
)
|
||||||
|
# The WebDAV copy of a RunnerUp export may carry the device name
|
||||||
|
# (RunnerUp_Pixel_6a_2026-...) that the phone's own copy lacks.
|
||||||
|
_DEVICE_PREFIX = re.compile(r"^RunnerUp_(?:[A-Za-z0-9]+_)*?(?=\d{4}-\d{2}-\d{2}-)")
|
||||||
|
_END_TOLERANCE_S = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def run_key(name: str) -> str:
|
||||||
|
"""A run's identity: its file name without any device-name prefix."""
|
||||||
|
return _DEVICE_PREFIX.sub("RunnerUp_", name)
|
||||||
|
|
||||||
|
|
||||||
|
def list_tcx(directory: Path) -> list[Path]:
|
||||||
|
"""Finished ``*.tcx`` files directly in ``directory`` (dotfiles are partial)."""
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
p for p in directory.glob("*.tcx") if p.is_file() and not p.name.startswith(".")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_app_tcx(path: Path) -> bool:
|
||||||
|
"""Whether the TCX names the new app as its author or creator."""
|
||||||
|
try:
|
||||||
|
data = path.read_bytes()
|
||||||
|
except OSError as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"Cannot read %s (%s): counting it as a RunnerUp file", path, exc
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return bool(_APP_RE.search(data))
|
||||||
|
|
||||||
|
|
||||||
|
def _ended_at(path: Path) -> float | None:
|
||||||
|
"""The run's end exactly as the credit path computed it (same parser)."""
|
||||||
|
data = parse_tcx_file(str(path))
|
||||||
|
if data is None or data.get("ended_at") is None:
|
||||||
|
_logger.warning("App TCX %s has no usable end time: it cannot anchor", path)
|
||||||
|
return None
|
||||||
|
return float(data["ended_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def _credited_at(entry: dict[str, Any]) -> datetime | None:
|
||||||
|
try:
|
||||||
|
stamp = datetime.fromisoformat(str(entry["timestamp"]))
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
_logger.warning("Credit entry has no usable timestamp (%r): skipped", exc)
|
||||||
|
return None
|
||||||
|
return stamp if stamp.tzinfo else stamp.astimezone()
|
||||||
|
|
||||||
|
|
||||||
|
def _as_unix(raw: object) -> float | None:
|
||||||
|
"""A unix time from a number or numeric string (the log stores both)."""
|
||||||
|
if isinstance(raw, int | float) and not isinstance(raw, bool):
|
||||||
|
return float(raw)
|
||||||
|
if isinstance(raw, str) and re.fullmatch(r"\d+(\.\d+)?", raw):
|
||||||
|
return float(raw)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _credit_ends(log_file: Path) -> list[tuple[float, datetime]]:
|
||||||
|
"""``(TCX end, PC credit time)`` of every credited run that recorded its end."""
|
||||||
|
found: list[tuple[float, datetime]] = []
|
||||||
|
for entries in load_workout_log(log_file).values():
|
||||||
|
for entry in entries:
|
||||||
|
data = entry.get("workout_data") or {}
|
||||||
|
if data.get("type") != "runnerup_verified":
|
||||||
|
continue
|
||||||
|
end = _as_unix(data.get("completed_at"))
|
||||||
|
if end is None:
|
||||||
|
continue # an older entry that never stored its run's end
|
||||||
|
when = _credited_at(entry)
|
||||||
|
if when is not None:
|
||||||
|
found.append((end, when))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def app_run_ends(dirs: Iterable[Path]) -> list[float]:
|
||||||
|
"""The end time of every app-authored TCX that has a usable one."""
|
||||||
|
return [
|
||||||
|
end
|
||||||
|
for directory in dirs
|
||||||
|
for path in list_tcx(directory)
|
||||||
|
if is_app_tcx(path) and (end := _ended_at(path)) is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def find_anchor(log_file: Path, dirs: Iterable[Path]) -> datetime | None:
|
||||||
|
"""When the PC first credited an app-recorded run, or None if it never did."""
|
||||||
|
ends = app_run_ends(dirs)
|
||||||
|
credited = [
|
||||||
|
when
|
||||||
|
for run_end, when in _credit_ends(log_file)
|
||||||
|
if any(abs(run_end - end) <= _END_TOLERANCE_S for end in ends)
|
||||||
|
]
|
||||||
|
return min(credited) if credited else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MarkerCheck:
|
||||||
|
"""Verdict on the app's historic-import marker."""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def _bad(reason: str) -> MarkerCheck:
|
||||||
|
return MarkerCheck(ok=False, reason=reason)
|
||||||
|
|
||||||
|
|
||||||
|
def _marker_names(marker: object) -> tuple[list[str] | None, str]:
|
||||||
|
"""The marker's ``tcx_names`` if its shape is valid, else a reason."""
|
||||||
|
if not isinstance(marker, dict):
|
||||||
|
return None, "marker is not a JSON object"
|
||||||
|
names, count = marker.get("tcx_names"), marker.get("count")
|
||||||
|
if not isinstance(marker.get("completed_at"), str) or not marker["completed_at"]:
|
||||||
|
return None, "marker has no completed_at"
|
||||||
|
if not isinstance(names, list) or not all(isinstance(n, str) for n in names):
|
||||||
|
return None, "marker tcx_names is not a list of names"
|
||||||
|
if isinstance(count, bool) or not isinstance(count, int) or count != len(names):
|
||||||
|
return None, f"marker count {count!r} != {len(names)} listed names"
|
||||||
|
if any("/" in n or n.startswith(".") for n in names):
|
||||||
|
return None, "marker tcx_names must be bare file names"
|
||||||
|
return names, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _read_marker(marker_file: Path) -> tuple[object, str]:
|
||||||
|
"""The parsed marker JSON, or ``(None, why it could not be read)``."""
|
||||||
|
if not marker_file.exists():
|
||||||
|
why = f"{marker_file} is missing: the app has not imported yet"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return json.loads(marker_file.read_text(encoding="utf-8")), ""
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
why = f"{marker_file} is unreadable ({exc})"
|
||||||
|
_logger.warning("Import marker problem: %s", why)
|
||||||
|
return None, why
|
||||||
|
_logger.warning("Import marker problem: %s", why)
|
||||||
|
return None, why
|
||||||
|
|
||||||
|
|
||||||
|
def _compare(names: list[str], processed: Path) -> MarkerCheck:
|
||||||
|
listed = {run_key(n) for n in names}
|
||||||
|
if len(listed) != len(names):
|
||||||
|
return _bad("marker lists the same run more than once")
|
||||||
|
have = {run_key(p.name) for p in list_tcx(processed) if not is_app_tcx(p)}
|
||||||
|
if absent := sorted(listed - have):
|
||||||
|
return _bad(f"{len(absent)} listed run(s) not in processed/: {absent[:3]}")
|
||||||
|
if unlisted := sorted(have - listed):
|
||||||
|
return _bad(
|
||||||
|
f"{len(unlisted)} RunnerUp run(s) in processed/ are not in the "
|
||||||
|
f"marker ({unlisted[:3]}): re-run the app's import"
|
||||||
|
)
|
||||||
|
return MarkerCheck(ok=True, reason=f"{len(listed)} runs match")
|
||||||
|
|
||||||
|
|
||||||
|
def check_marker(marker_file: Path, processed: Path) -> MarkerCheck:
|
||||||
|
"""Validate the app's import marker against what ``processed/`` really holds.
|
||||||
|
|
||||||
|
Valid means: the file parses, ``count`` equals the number of listed names,
|
||||||
|
and the listed runs are exactly the RunnerUp-recorded (non-app) runs in
|
||||||
|
``processed/``. A run is counted once however many copies exist (the
|
||||||
|
device-prefixed WebDAV twin and the phone-named copy are one run).
|
||||||
|
"""
|
||||||
|
marker, why = _read_marker(marker_file)
|
||||||
|
if why:
|
||||||
|
return _bad(why)
|
||||||
|
names, reason = _marker_names(marker)
|
||||||
|
return _compare(names, processed) if names is not None else _bad(reason)
|
||||||
@ -17,6 +17,8 @@ import tempfile
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
from screen_locker._walk_tcx import is_walking_export, track_summary
|
||||||
|
|
||||||
# TCX XML namespace used by Garmin/RunnerUp.
|
# TCX XML namespace used by Garmin/RunnerUp.
|
||||||
_TCX_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
_TCX_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
||||||
|
|
||||||
@ -41,7 +43,16 @@ class RunnerUpTcxMixin:
|
|||||||
"""Pulls and parses RunnerUp's TCX exports over adb."""
|
"""Pulls and parses RunnerUp's TCX exports over adb."""
|
||||||
|
|
||||||
def _pull_and_parse_tcx(self, remote_path: str) -> dict[str, Any] | None:
|
def _pull_and_parse_tcx(self, remote_path: str) -> dict[str, Any] | None:
|
||||||
"""Pull a remote TCX file and parse it. Returns activity dict or None."""
|
"""Parse a TCX export, pulling it over adb first unless it is local.
|
||||||
|
|
||||||
|
A path that exists on this machine (the WebDAV drop directory, see
|
||||||
|
``RUNNERUP_WEBDAV_DIRS``) is parsed in place; anything else is a
|
||||||
|
phone path and goes through ``adb pull``. Returns the activity dict
|
||||||
|
or None.
|
||||||
|
"""
|
||||||
|
name = Path(remote_path).name
|
||||||
|
if Path(remote_path).is_file():
|
||||||
|
return _mark_walk(self._parse_tcx(remote_path), name)
|
||||||
tmp_dir = tempfile.mkdtemp(prefix="runnerup_tcx_")
|
tmp_dir = tempfile.mkdtemp(prefix="runnerup_tcx_")
|
||||||
local_path = str(Path(tmp_dir) / "activity.tcx")
|
local_path = str(Path(tmp_dir) / "activity.tcx")
|
||||||
try:
|
try:
|
||||||
@ -49,48 +60,62 @@ class RunnerUpTcxMixin:
|
|||||||
if not ok or not Path(local_path).exists():
|
if not ok or not Path(local_path).exists():
|
||||||
_logger.info("Failed to pull TCX file: %s", remote_path)
|
_logger.info("Failed to pull TCX file: %s", remote_path)
|
||||||
return None
|
return None
|
||||||
return self._parse_tcx(local_path)
|
return _mark_walk(self._parse_tcx(local_path), name)
|
||||||
finally:
|
finally:
|
||||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
|
||||||
def _parse_tcx(self, tcx_path: str) -> dict[str, Any] | None:
|
def _parse_tcx(self, tcx_path: str) -> dict[str, Any] | None:
|
||||||
"""Parse a local TCX file and return activity summary dict.
|
"""Parse a local TCX file (see :func:`parse_tcx_file`)."""
|
||||||
|
return parse_tcx_file(tcx_path)
|
||||||
|
|
||||||
Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so
|
|
||||||
multi-segment runs (pause/resume) are counted in full.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
tree = ET.parse(tcx_path)
|
|
||||||
except ET.ParseError as exc:
|
|
||||||
_logger.warning(
|
|
||||||
"TCX file %s is not valid XML (%s) — the run it describes CANNOT "
|
|
||||||
"be verified and will not count",
|
|
||||||
tcx_path,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
root = tree.getroot()
|
def parse_tcx_file(tcx_path: str) -> dict[str, Any] | None:
|
||||||
activity = root.find(f".//{{{_TCX_NS}}}Activity")
|
"""Parse a local TCX file and return activity summary dict.
|
||||||
if activity is None:
|
|
||||||
_logger.info("No Activity element in TCX file")
|
|
||||||
return None
|
|
||||||
|
|
||||||
sport_str = activity.get("Sport", "")
|
Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so
|
||||||
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
|
multi-segment runs (pause/resume) are counted in full.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tree = ET.parse(tcx_path)
|
||||||
|
except ET.ParseError as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"TCX file %s is not valid XML (%s) — the run it describes CANNOT "
|
||||||
|
"be verified and will not count",
|
||||||
|
tcx_path,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
total_seconds = 0.0
|
root = tree.getroot()
|
||||||
total_distance = 0.0
|
activity = root.find(f".//{{{_TCX_NS}}}Activity")
|
||||||
for lap in activity.findall(f"{{{_TCX_NS}}}Lap"):
|
if activity is None:
|
||||||
t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds")
|
_logger.info("No Activity element in TCX file")
|
||||||
d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters")
|
return None
|
||||||
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_str = activity.get("Sport", "")
|
||||||
"sport": sport_int,
|
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
|
||||||
"duration_seconds": int(total_seconds),
|
|
||||||
"distance_m": total_distance,
|
total_seconds = 0.0
|
||||||
}
|
total_distance = 0.0
|
||||||
|
for lap in activity.findall(f"{{{_TCX_NS}}}Lap"):
|
||||||
|
t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds")
|
||||||
|
d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters")
|
||||||
|
if t_elem is not None and t_elem.text:
|
||||||
|
total_seconds += float(t_elem.text)
|
||||||
|
if d_elem is not None and d_elem.text:
|
||||||
|
total_distance += float(d_elem.text)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sport": sport_int,
|
||||||
|
"duration_seconds": int(total_seconds),
|
||||||
|
"distance_m": total_distance,
|
||||||
|
"walking": is_walking_export(Path(tcx_path).name, sport_str),
|
||||||
|
**track_summary(activity),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_walk(data: dict[str, Any] | None, name: str) -> dict[str, Any] | None:
|
||||||
|
"""Flag a walk by its ORIGINAL file name: an adb pull renames the copy."""
|
||||||
|
if data is not None:
|
||||||
|
data["walking"] = data["walking"] or is_walking_export(name, "")
|
||||||
|
return data
|
||||||
|
|||||||
@ -12,29 +12,26 @@ from typing import Any
|
|||||||
|
|
||||||
from screen_locker._constants import (
|
from screen_locker._constants import (
|
||||||
MIN_RUN_DISTANCE_KM,
|
MIN_RUN_DISTANCE_KM,
|
||||||
MIN_WORKOUT_DURATION_MINUTES,
|
|
||||||
RUNNERUP_DISTANCE_TOLERANCE,
|
RUNNERUP_DISTANCE_TOLERANCE,
|
||||||
RUNNERUP_EXPORT_DIRS,
|
RUNNERUP_EXPORT_DIRS,
|
||||||
WORKOUT_DURATION_ACCEPT_MINUTES,
|
RUNNERUP_WEBDAV_DIRS,
|
||||||
)
|
)
|
||||||
from screen_locker._day import today_str
|
from screen_locker._day import today_str
|
||||||
from screen_locker._runnerup_backfill import RunnerUpBackfillMixin
|
from screen_locker._runnerup_backfill import RunnerUpBackfillMixin
|
||||||
from screen_locker._runnerup_db import RunnerUpDbMixin
|
from screen_locker._runnerup_db import RunnerUpDbMixin
|
||||||
from screen_locker._runnerup_tcx import RunnerUpTcxMixin
|
from screen_locker._runnerup_tcx import RunnerUpTcxMixin
|
||||||
from screen_locker._time_check import check_clock_skew
|
from screen_locker._time_check import check_clock_skew
|
||||||
|
from screen_locker._walk_tcx import (
|
||||||
|
SPORT_NAMES,
|
||||||
|
activity_day,
|
||||||
|
activity_shortfall,
|
||||||
|
distance_qualifies,
|
||||||
|
)
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_SPORT_NAMES: dict[int, str] = {
|
# Kept for importers; the labels live next to the one crediting rule.
|
||||||
0: "Running",
|
_SPORT_NAMES = SPORT_NAMES
|
||||||
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.
|
# TCX uses sport name strings; map back to integer codes for unified validation.
|
||||||
|
|
||||||
@ -50,14 +47,43 @@ class RunnerUpVerificationMixin(
|
|||||||
# File-based path (no root required)
|
# File-based path (no root required)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _has_runnerup_source(self) -> bool:
|
||||||
|
"""Whether any RunnerUp export source is reachable right now.
|
||||||
|
|
||||||
|
The WebDAV drop directory on this disk counts as much as an
|
||||||
|
adb-visible phone: both hold the same TCX files.
|
||||||
|
"""
|
||||||
|
return self._has_local_runnerup_dir() or self._has_adb_device()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_local_runnerup_dir() -> bool:
|
||||||
|
return any(d.is_dir() for d in RUNNERUP_WEBDAV_DIRS)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_local_runnerup_exports_for_date(date_str: str) -> list[str]:
|
||||||
|
"""TCX files for ``date_str`` in the WebDAV drop directories, sorted."""
|
||||||
|
found: list[str] = []
|
||||||
|
for local_dir in RUNNERUP_WEBDAV_DIRS:
|
||||||
|
if not local_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for path in sorted(local_dir.glob("*.tcx")):
|
||||||
|
if date_str in path.name and str(path) not in found:
|
||||||
|
found.append(str(path))
|
||||||
|
return found
|
||||||
|
|
||||||
def _find_runnerup_exports_for_date(self, date_str: str) -> list[str]:
|
def _find_runnerup_exports_for_date(self, date_str: str) -> list[str]:
|
||||||
"""Return adb paths of RunnerUp TCX exports for the given date, or empty list.
|
"""Return paths of RunnerUp TCX exports for the given date, or empty list.
|
||||||
|
|
||||||
|
Local WebDAV copies come first (no phone needed), then the phone's
|
||||||
|
export directories over adb when a device is attached.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
date_str: ISO date string in ``YYYY-MM-DD`` format matched against
|
date_str: ISO date string in ``YYYY-MM-DD`` format matched against
|
||||||
TCX filenames (``RunnerUp_YYYY-MM-DD-HH-MM-SS_xxx.tcx``).
|
TCX filenames (``RunnerUp_YYYY-MM-DD-HH-MM-SS_xxx.tcx``).
|
||||||
"""
|
"""
|
||||||
found: list[str] = []
|
found = self._find_local_runnerup_exports_for_date(date_str)
|
||||||
|
if not self._has_adb_device():
|
||||||
|
return found
|
||||||
for dirpath in RUNNERUP_EXPORT_DIRS:
|
for dirpath in RUNNERUP_EXPORT_DIRS:
|
||||||
ok, out = self._run_adb(["shell", "ls", dirpath])
|
ok, out = self._run_adb(["shell", "ls", dirpath])
|
||||||
if not ok or not out.strip():
|
if not ok or not out.strip():
|
||||||
@ -90,63 +116,46 @@ class RunnerUpVerificationMixin(
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Try each file; return the best result (verified > validation error).
|
# One rule for the whole day, whatever the sport or file name: the
|
||||||
best: tuple[str, str] | None = None
|
# day's activities are summed (see _walk_tcx), never judged one file
|
||||||
for remote in exports:
|
# at a time.
|
||||||
data = self._pull_and_parse_tcx(remote)
|
activities = [
|
||||||
if data is None:
|
data
|
||||||
continue
|
for remote in exports
|
||||||
status, msg = self._validate_runnerup_data(data)
|
if (data := self._pull_and_parse_tcx(remote)) is not None
|
||||||
if status == "verified":
|
]
|
||||||
return status, msg
|
if not activities:
|
||||||
if best is None:
|
return (
|
||||||
best = (status, msg)
|
"not_verified",
|
||||||
|
"RunnerUp TCX export found but could not be read",
|
||||||
# All files found but none passed validation.
|
)
|
||||||
return best or (
|
day = activity_day(activities)
|
||||||
"not_verified",
|
if day.qualifies:
|
||||||
"RunnerUp TCX export found but could not be read",
|
return "verified", day.message()
|
||||||
)
|
return "too_short", activity_shortfall(day)
|
||||||
|
|
||||||
def _validate_runnerup_data(self, data: dict[str, Any]) -> tuple[str, str]:
|
def _validate_runnerup_data(self, data: dict[str, Any]) -> tuple[str, str]:
|
||||||
"""Validate a RunnerUp activity against configured thresholds.
|
"""Validate a RunnerUp activity against configured thresholds.
|
||||||
|
|
||||||
Returns ``(status, message)`` following the same contract as
|
Returns ``(status, message)`` following the same contract as
|
||||||
``PhoneVerificationMixin._verify_phone_workout``.
|
``PhoneVerificationMixin._verify_phone_workout``. The one rule is
|
||||||
|
:func:`screen_locker._walk_tcx.activity_day`; a dict without
|
||||||
|
``moving_seconds`` (the root-DB path) is judged by its duration.
|
||||||
"""
|
"""
|
||||||
sport = data["sport"]
|
day = activity_day([data])
|
||||||
|
if not day.qualifies:
|
||||||
duration_min = data["duration_seconds"] / 60
|
return "too_short", activity_shortfall(day)
|
||||||
distance_km = data["distance_m"] / 1000
|
if day.distance_km < MIN_RUN_DISTANCE_KM and distance_qualifies(
|
||||||
|
day.distance_km
|
||||||
# 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(
|
_logger.info(
|
||||||
"RunnerUp distance %.2f km is under the %.1f km minimum but "
|
"RunnerUp distance %.2f km is under the %.1f km minimum but "
|
||||||
"within %.0f%% GPS tolerance — accepted.",
|
"within %.0f%% GPS tolerance — accepted.",
|
||||||
distance_km,
|
day.distance_km,
|
||||||
MIN_RUN_DISTANCE_KM,
|
MIN_RUN_DISTANCE_KM,
|
||||||
RUNNERUP_DISTANCE_TOLERANCE * 100,
|
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
|
# Main entry point
|
||||||
@ -165,13 +174,13 @@ class RunnerUpVerificationMixin(
|
|||||||
if not skew_ok:
|
if not skew_ok:
|
||||||
return "clock_tampered", skew_msg
|
return "clock_tampered", skew_msg
|
||||||
|
|
||||||
if not self._has_adb_device():
|
if not self._has_runnerup_source():
|
||||||
return (
|
return (
|
||||||
"no_phone",
|
"no_phone",
|
||||||
"Phone not connected — plug in via USB or enable wireless ADB",
|
"Phone not connected — plug in via USB or enable wireless ADB",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Path 1: file-based (no root needed).
|
# Path 1: file-based (no root needed; WebDAV copies first, then adb).
|
||||||
file_result = self._verify_runnerup_via_files()
|
file_result = self._verify_runnerup_via_files()
|
||||||
if file_result is not None:
|
if file_result is not None:
|
||||||
_logger.info("RunnerUp file-based result: %s", file_result[0])
|
_logger.info("RunnerUp file-based result: %s", file_result[0])
|
||||||
@ -179,5 +188,13 @@ class RunnerUpVerificationMixin(
|
|||||||
|
|
||||||
# Path 2: root DB pull (fallback when no export files found yet).
|
# Path 2: root DB pull (fallback when no export files found yet).
|
||||||
# On a non-rooted device this path cannot succeed; _verify_runnerup_via_db
|
# On a non-rooted device this path cannot succeed; _verify_runnerup_via_db
|
||||||
# reports that plainly rather than as a generic failure.
|
# reports that plainly rather than as a generic failure. Needs adb.
|
||||||
|
if not self._has_adb_device():
|
||||||
|
return (
|
||||||
|
"not_verified",
|
||||||
|
(
|
||||||
|
f"No RunnerUp export for today in {RUNNERUP_WEBDAV_DIRS} "
|
||||||
|
"and the phone is not adb-reachable"
|
||||||
|
),
|
||||||
|
)
|
||||||
return self._verify_runnerup_via_db()
|
return self._verify_runnerup_via_db()
|
||||||
|
|||||||
232
screen_locker/_session_stream.py
Normal file
232
screen_locker/_session_stream.py
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
"""Off-LAN instant credit: hold a Firebase RTDB stream of the device logs.
|
||||||
|
|
||||||
|
Off the home LAN the phone's only transport is Firebase, so without this a
|
||||||
|
finished session waits up to 15 minutes for ``workout-sync.timer``. This
|
||||||
|
thread, inside ``workout-poke.service``, keeps ``GET
|
||||||
|
<db>/screen-locker-sync/devices.json`` open as Server-Sent Events and credits
|
||||||
|
each newly streamed session within about a second
|
||||||
|
(:class:`screen_locker._stream_cache.DeviceLogCache`).
|
||||||
|
|
||||||
|
Free-tier budget (Spark: 10 GB/month download, no per-read quota). Every
|
||||||
|
(re)connect downloads the whole devices node once: 375,755 bytes measured on
|
||||||
|
2026-10-09 (18 device dirs; logged with each snapshot, after transfer
|
||||||
|
decoding, so an upper bound on the wire). The ID token lives one hour, so
|
||||||
|
that is ~24 snapshots a day, ~9 MB: less than the 15-min sync, which fetches
|
||||||
|
every device log on every tick. After the snapshot, a write streams only the
|
||||||
|
written device's whole log: ~83 KB for the current phone, ~43 KB for each PC
|
||||||
|
push. RTDB sends NO event for a byte-identical write (observed), and the
|
||||||
|
phone's home-screen ``syncNow`` skips the write when its log is unchanged, so
|
||||||
|
no-op syncs cost nothing. Nothing here issues a read beyond the stream.
|
||||||
|
|
||||||
|
Every failure is logged at warning or above with what it means, the thread
|
||||||
|
backs off (1 s doubling to 60 s, jittered) and never touches the HTTP
|
||||||
|
listener. The sync stays the safety net: the stream only makes credit faster.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
|
|
||||||
|
from screen_locker._device import device_identity
|
||||||
|
from screen_locker._rtdb_stream import parse_sse
|
||||||
|
from screen_locker._stream_auth import StreamError, stream_auth
|
||||||
|
from screen_locker._stream_cache import DeviceLogCache
|
||||||
|
from screen_locker._workout_sync import _DEVICES_PREFIX
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
from screen_locker._rtdb_stream import SseEvent
|
||||||
|
|
||||||
|
__all__ = ["SessionStream"]
|
||||||
|
|
||||||
|
_logger: Final = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_FALLBACK: Final = (
|
||||||
|
"off-LAN workouts will only be credited by the 15-min workout-sync until "
|
||||||
|
"this recovers"
|
||||||
|
)
|
||||||
|
_BACKOFF_MIN_S: Final = 1.0
|
||||||
|
_BACKOFF_MAX_S: Final = 60.0
|
||||||
|
# RTDB sends a keep-alive every ~30 s; silence past this means a dead socket.
|
||||||
|
_TIMEOUT_S: Final = (10, 90)
|
||||||
|
_LOGGED_KEEPALIVES: Final = 3
|
||||||
|
_HTTP_AUTH_REJECTED: Final = (401, 403)
|
||||||
|
# A connection that ends sooner than this pays a backoff even when the end was
|
||||||
|
# planned (auth_revoked, renewal): no path may reconnect in a tight loop, and
|
||||||
|
# each reconnect re-downloads the snapshot.
|
||||||
|
_MIN_LIFETIME_S: Final = 300.0
|
||||||
|
_STREAM_ERRORS: Final = (
|
||||||
|
requests.RequestException,
|
||||||
|
urllib3.exceptions.HTTPError,
|
||||||
|
OSError,
|
||||||
|
)
|
||||||
|
_JITTER: Final = random.SystemRandom()
|
||||||
|
|
||||||
|
|
||||||
|
class SessionStream:
|
||||||
|
"""A daemon thread holding the stream; one connection at a time."""
|
||||||
|
|
||||||
|
def __init__(self, gate: threading.Lock) -> None:
|
||||||
|
"""``gate`` is the poke server's credit lock, so credits never race."""
|
||||||
|
identity = device_identity()
|
||||||
|
self._cache = DeviceLogCache(
|
||||||
|
_DEVICES_PREFIX,
|
||||||
|
frozenset({identity.device_id, identity.legacy_id}),
|
||||||
|
gate,
|
||||||
|
)
|
||||||
|
self._http = requests.Session()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._got_snapshot = False
|
||||||
|
|
||||||
|
def start(self) -> threading.Thread:
|
||||||
|
"""Run :meth:`run_forever` on a daemon thread and return it."""
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=self.run_forever, name="firebase-session-stream", daemon=True
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Ask the loop to exit after the current connection."""
|
||||||
|
self._stop.set()
|
||||||
|
|
||||||
|
def run_forever(self) -> None:
|
||||||
|
"""Connect, stream, back off on failure; never raise out of the thread."""
|
||||||
|
delay = _BACKOFF_MIN_S
|
||||||
|
while not self._stop.is_set():
|
||||||
|
self._got_snapshot = False
|
||||||
|
opened = time.monotonic()
|
||||||
|
planned = self._run_one()
|
||||||
|
lived = time.monotonic() - opened >= _MIN_LIFETIME_S
|
||||||
|
if self._got_snapshot and lived:
|
||||||
|
delay = _BACKOFF_MIN_S
|
||||||
|
if planned and lived:
|
||||||
|
continue # a long-lived connection ending on schedule
|
||||||
|
pause = delay * _JITTER.uniform(0.5, 1.0)
|
||||||
|
delay = min(delay * 2, _BACKOFF_MAX_S)
|
||||||
|
_logger.warning("Reconnecting the session stream in %.1f s", pause)
|
||||||
|
self._stop.wait(pause)
|
||||||
|
|
||||||
|
def _run_one(self) -> bool:
|
||||||
|
"""One connection; True if it ended on purpose (renewal, auth_revoked)."""
|
||||||
|
try:
|
||||||
|
self._connect_once()
|
||||||
|
except StreamError as exc:
|
||||||
|
_logger.warning("Firebase session stream failed: %s; %s", exc, _FALLBACK)
|
||||||
|
return False
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught # daemon boundary: logged, loop reconnects
|
||||||
|
_logger.exception("Firebase session stream crashed; %s", _FALLBACK)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _connect_once(self) -> None:
|
||||||
|
"""One connection; returns only when it ended on purpose.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StreamError: Auth, HTTP or protocol failure.
|
||||||
|
"""
|
||||||
|
auth = stream_auth(_DEVICES_PREFIX)
|
||||||
|
try:
|
||||||
|
response = self._http.get(
|
||||||
|
auth.url,
|
||||||
|
params={"auth": auth.token},
|
||||||
|
headers={"Accept": "text/event-stream"},
|
||||||
|
stream=True,
|
||||||
|
timeout=_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
except _STREAM_ERRORS as exc:
|
||||||
|
msg = f"network error opening the stream: {exc}"
|
||||||
|
raise StreamError(msg) from exc
|
||||||
|
with response:
|
||||||
|
if not response.ok:
|
||||||
|
kind = (
|
||||||
|
"rejected (token or rules)"
|
||||||
|
if response.status_code in _HTTP_AUTH_REJECTED
|
||||||
|
else "failed"
|
||||||
|
)
|
||||||
|
msg = (
|
||||||
|
f"stream {kind}: HTTP {response.status_code} {response.text[:200]}"
|
||||||
|
)
|
||||||
|
raise StreamError(msg)
|
||||||
|
_logger.info("Firebase session stream connected to %s", _DEVICES_PREFIX)
|
||||||
|
self._consume(response, auth.renew_at)
|
||||||
|
|
||||||
|
def _consume(self, response: requests.Response, renew_at: datetime) -> None:
|
||||||
|
"""Dispatch events until the server or the renew deadline ends it.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StreamError: The stream broke, was cancelled or closed unasked.
|
||||||
|
"""
|
||||||
|
meter = _ByteMeter()
|
||||||
|
keepalives = 0
|
||||||
|
try:
|
||||||
|
for event in parse_sse(meter.lines(response)):
|
||||||
|
if event.name == "keep-alive":
|
||||||
|
keepalives += 1
|
||||||
|
if keepalives <= _LOGGED_KEEPALIVES:
|
||||||
|
_logger.info("Session stream keep-alive #%d", keepalives)
|
||||||
|
elif event.name in {"put", "patch"}:
|
||||||
|
self._dispatch(event, meter.total)
|
||||||
|
elif event.name == "auth_revoked":
|
||||||
|
_logger.warning(
|
||||||
|
"Session stream token revoked/expired (%s); reconnecting",
|
||||||
|
event.data,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
elif event.name == "cancel":
|
||||||
|
msg = f"the database cancelled the stream ({event.data})"
|
||||||
|
raise StreamError(msg)
|
||||||
|
else:
|
||||||
|
_logger.warning("Session stream ignored event %r", event.name)
|
||||||
|
if datetime.now(UTC) >= renew_at:
|
||||||
|
_logger.info("Session stream renewing its ID token before expiry")
|
||||||
|
return
|
||||||
|
except _STREAM_ERRORS as exc:
|
||||||
|
msg = f"stream broke after {meter.total} bytes: {exc}"
|
||||||
|
raise StreamError(msg) from exc
|
||||||
|
msg = f"the server closed the stream after {meter.total} bytes"
|
||||||
|
raise StreamError(msg)
|
||||||
|
|
||||||
|
def _dispatch(self, event: SseEvent, total_bytes: int) -> None:
|
||||||
|
"""Hand one put/patch to the cache and log what it did."""
|
||||||
|
try:
|
||||||
|
result = self._cache.handle(event)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
_logger.warning("Session stream event undecodable (%s); %s", exc, _FALLBACK)
|
||||||
|
return
|
||||||
|
self._got_snapshot |= result.snapshot
|
||||||
|
_logger.info(
|
||||||
|
"Session stream %s (%d bytes read on this connection): %s",
|
||||||
|
"snapshot" if result.snapshot else event.name,
|
||||||
|
total_bytes,
|
||||||
|
result.summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _ByteMeter:
|
||||||
|
"""Splits the raw stream into lines as bytes arrive, counting them."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.total = 0
|
||||||
|
|
||||||
|
def lines(self, response: requests.Response) -> Iterator[str]:
|
||||||
|
"""Yield decoded lines without waiting for a full read buffer.
|
||||||
|
|
||||||
|
``iter_lines`` reads fixed-size chunks and would sit on a 30-byte
|
||||||
|
keep-alive until more data came; ``read1`` returns what has arrived.
|
||||||
|
"""
|
||||||
|
pending = b""
|
||||||
|
while chunk := response.raw.read1(65536):
|
||||||
|
self.total += len(chunk)
|
||||||
|
*complete, pending = (pending + chunk).split(b"\n")
|
||||||
|
for line in complete:
|
||||||
|
yield line.rstrip(b"\r").decode("utf-8")
|
||||||
@ -2,29 +2,50 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import earned_time
|
||||||
|
|
||||||
|
from screen_locker._ceiling import ceiling
|
||||||
from screen_locker._constants import (
|
from screen_locker._constants import (
|
||||||
ADJUST_SHUTDOWN_SCRIPT,
|
ADJUST_SHUTDOWN_SCRIPT,
|
||||||
SHUTDOWN_CONFIG_FILE,
|
SHUTDOWN_CONFIG_FILE,
|
||||||
)
|
)
|
||||||
from screen_locker._day import today_str
|
from screen_locker._day import today_str
|
||||||
from screen_locker._shutdown_sick_state import SickDayStateMixin
|
from screen_locker._earned import first_minutes, hhmm, span
|
||||||
from screen_locker._wake_alarm import WakeAlarmMixin
|
from screen_locker._grace_floor import plan_absorb, save_lift
|
||||||
from screen_locker._workout_credit import FIRST_WORKOUT_BONUS_HOURS
|
from screen_locker._late_credit import LateCreditMixin
|
||||||
|
from screen_locker._sick import is_sick
|
||||||
|
from screen_locker._sick_floor import SickFloorMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_SHUTDOWN_CONFIG_KEYS = ("MON_WED_HOUR", "THU_SUN_HOUR", "MORNING_END_HOUR")
|
# Every time below is minutes after midnight. The config's *_MINUTES keys are
|
||||||
|
# authoritative; a file written before the minutes migration has only the
|
||||||
|
# whole-hour *_HOUR keys, read as HOUR * 60.
|
||||||
|
_SHUTDOWN_CONFIG_NAMES = ("MON_WED", "THU_SUN", "MORNING_END")
|
||||||
|
_MINUTES_PER_HOUR = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_config(path: Path) -> dict[str, int]:
|
||||||
|
"""Every integer ``KEY=value`` line of *path*."""
|
||||||
|
parsed: dict[str, int] = {}
|
||||||
|
with path.open() as f:
|
||||||
|
for line in f:
|
||||||
|
key, sep, value = line.strip().partition("=")
|
||||||
|
if sep and value.strip().isdigit():
|
||||||
|
parsed[key] = int(value)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
|
def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
|
||||||
"""Read shutdown config from *path*. Returns (mw_hour, ts_hour, me_hour) or None.
|
"""Read shutdown config from *path* as (mon_wed, thu_sun, morning_end) minutes.
|
||||||
|
|
||||||
Reading needs no privilege (only writing does, via
|
Reading needs no privilege (only writing does, via
|
||||||
``adjust_shutdown_schedule.sh``) — safe to call from a read-only status view.
|
``adjust_shutdown_schedule.sh``) — safe to call from a read-only status view.
|
||||||
@ -32,112 +53,96 @@ def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
_logger.warning("Config not found: %s", path)
|
_logger.warning("Config not found: %s", path)
|
||||||
return None
|
return None
|
||||||
parsed: dict[str, int] = {}
|
parsed = _parse_config(path)
|
||||||
with path.open() as f:
|
values: list[int] = []
|
||||||
for line in f:
|
for name in _SHUTDOWN_CONFIG_NAMES:
|
||||||
stripped = line.strip()
|
if f"{name}_MINUTES" in parsed:
|
||||||
for key in _SHUTDOWN_CONFIG_KEYS:
|
values.append(parsed[f"{name}_MINUTES"])
|
||||||
if stripped.startswith(f"{key}="):
|
elif f"{name}_HOUR" in parsed:
|
||||||
parsed[key] = int(stripped.split("=")[1])
|
values.append(parsed[f"{name}_HOUR"] * _MINUTES_PER_HOUR)
|
||||||
if len(parsed) < len(_SHUTDOWN_CONFIG_KEYS):
|
else:
|
||||||
_logger.warning("Shutdown config missing required values")
|
_logger.warning("Shutdown config missing required values")
|
||||||
return None
|
return None
|
||||||
return (
|
return values[0], values[1], values[2]
|
||||||
parsed["MON_WED_HOUR"],
|
|
||||||
parsed["THU_SUN_HOUR"],
|
|
||||||
parsed["MORNING_END_HOUR"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
|
class ShutdownMixin(SickFloorMixin, LateCreditMixin):
|
||||||
"""Mixin providing shutdown schedule adjustment functionality."""
|
"""Mixin providing shutdown schedule adjustment functionality."""
|
||||||
|
|
||||||
def _apply_earlier_shutdown(self, today: str) -> bool:
|
def _adjust_shutdown_time_later(self, at: float | None = None) -> bool:
|
||||||
"""Read config, save state, and write earlier shutdown hours."""
|
"""Push shutdown later by the first workout's reward, capped at the ceiling.
|
||||||
config_values = self._read_shutdown_config()
|
|
||||||
if config_values is None:
|
|
||||||
return False
|
|
||||||
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
|
|
||||||
if not self._save_sick_day_state(today, mon_wed_hour, thu_sun_hour):
|
|
||||||
_logger.error("Failed to save state - aborting adjustment")
|
|
||||||
return False
|
|
||||||
new_mon_wed = max(18, mon_wed_hour - 1)
|
|
||||||
new_thu_sun = max(18, thu_sun_hour - 1)
|
|
||||||
return self._write_shutdown_config(
|
|
||||||
new_mon_wed,
|
|
||||||
new_thu_sun,
|
|
||||||
morning_end_hour,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _adjust_shutdown_time_earlier(self) -> bool:
|
``at`` is when the workout ended (default now): a late one counts from
|
||||||
"""Adjust shutdown schedule 1.5 hours earlier (stricter).
|
it (:mod:`._late_credit`). Returns True if successful, False otherwise.
|
||||||
|
|
||||||
This can only be used once per day. Original values are saved and
|
|
||||||
automatically restored when checked the next day.
|
|
||||||
|
|
||||||
Returns True if successful, False otherwise.
|
|
||||||
"""
|
|
||||||
today = today_str()
|
|
||||||
self._restore_original_config_if_needed()
|
|
||||||
if self._sick_mode_used_today():
|
|
||||||
_logger.warning("Sick mode already used today")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return self._apply_earlier_shutdown(today)
|
|
||||||
except (OSError, ValueError) as e:
|
|
||||||
_logger.warning("Failed to adjust shutdown time: %s", e)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _adjust_shutdown_time_later(self) -> bool:
|
|
||||||
"""Adjust shutdown schedule 2 hours later as workout reward.
|
|
||||||
|
|
||||||
Returns True if successful, False otherwise.
|
|
||||||
"""
|
"""
|
||||||
|
day = date.fromisoformat(today_str())
|
||||||
|
reward = first_minutes(earned_time.WORKOUT, day)
|
||||||
|
if is_sick(day):
|
||||||
|
return self._raise_on_sick_day(reward, at)
|
||||||
|
late = self._late_write(reward, at)
|
||||||
|
if late is not None:
|
||||||
|
return late
|
||||||
|
bonus, lift = plan_absorb(reward)
|
||||||
try:
|
try:
|
||||||
config_values = self._read_shutdown_config()
|
config_values = self._read_shutdown_config()
|
||||||
if config_values is None:
|
if config_values is None:
|
||||||
return False
|
return False
|
||||||
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
|
mon_wed, thu_sun, morning_end = config_values
|
||||||
new_mon_wed = min(23, mon_wed_hour + FIRST_WORKOUT_BONUS_HOURS)
|
ok = self._write_shutdown_config(
|
||||||
new_thu_sun = min(23, thu_sun_hour + FIRST_WORKOUT_BONUS_HOURS)
|
min(ceiling(day), mon_wed + bonus),
|
||||||
return self._write_shutdown_config(
|
min(ceiling(day), thu_sun + bonus),
|
||||||
new_mon_wed,
|
morning_end,
|
||||||
new_thu_sun,
|
|
||||||
morning_end_hour,
|
|
||||||
restore=True,
|
restore=True,
|
||||||
)
|
)
|
||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
_logger.warning("Failed to adjust shutdown time for workout: %s", e)
|
_logger.warning("Failed to adjust shutdown time for workout: %s", e)
|
||||||
return False
|
return False
|
||||||
|
if ok:
|
||||||
|
save_lift(lift)
|
||||||
|
return ok
|
||||||
|
|
||||||
def _adjust_shutdown_time_by(self, extra_hours: int) -> bool:
|
def _adjust_shutdown_time_by(
|
||||||
"""Adjust shutdown hours by *extra_hours*, capped at 24 (midnight).
|
self, extra_minutes: int, at: float | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Push shutdown later by *extra_minutes*, capped at the day's ceiling.
|
||||||
|
|
||||||
Used for extra-workout bonuses beyond the weekly minimum. A cap of 24
|
Used for earner and extra-workout bonuses. The cap is
|
||||||
works because ``day-specific-shutdown-check.sh`` fires at 00:00 and
|
``earned_time.shutdown_ceiling_for(today)`` (23:00 today, lower if
|
||||||
catches it via the morning-window condition (0 <= 300 minutes).
|
``WAKE_MINUTES`` ever moves); the helper's 23:00 stays the hard bound.
|
||||||
|
|
||||||
Returns True if successful, False otherwise.
|
``at`` is when the credit was earned (default now): a late one counts
|
||||||
|
from it (:mod:`._late_credit`). Returns True if successful.
|
||||||
"""
|
"""
|
||||||
|
day = date.fromisoformat(today_str())
|
||||||
|
if is_sick(day):
|
||||||
|
return self._raise_on_sick_day(extra_minutes, at)
|
||||||
|
late = self._late_write(extra_minutes, at)
|
||||||
|
if late is not None:
|
||||||
|
return late
|
||||||
|
# A stale lift recorded before the grace floor went is spent first.
|
||||||
|
moved, lift = plan_absorb(extra_minutes)
|
||||||
try:
|
try:
|
||||||
config_values = self._read_shutdown_config()
|
config_values = self._read_shutdown_config()
|
||||||
if config_values is None:
|
if config_values is None:
|
||||||
return False
|
return False
|
||||||
mw, ts, morning = config_values
|
mw, ts, morning = config_values
|
||||||
return self._write_shutdown_config(
|
ok = self._write_shutdown_config(
|
||||||
min(24, mw + extra_hours),
|
min(ceiling(day), mw + moved),
|
||||||
min(24, ts + extra_hours),
|
min(ceiling(day), ts + moved),
|
||||||
morning,
|
morning,
|
||||||
restore=True,
|
restore=True,
|
||||||
)
|
)
|
||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"Failed to adjust shutdown time by %d h: %s", extra_hours, e
|
"Failed to adjust shutdown time by %s: %s", span(extra_minutes), e
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
if ok:
|
||||||
|
save_lift(lift)
|
||||||
|
return ok
|
||||||
|
|
||||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
|
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
|
||||||
"""Read shutdown config. Returns (mw_hour, ts_hour, me_hour) or None."""
|
"""Read shutdown config as (mon_wed, thu_sun, morning_end) minutes, or None."""
|
||||||
return read_shutdown_config(SHUTDOWN_CONFIG_FILE)
|
return read_shutdown_config(SHUTDOWN_CONFIG_FILE)
|
||||||
|
|
||||||
def _build_shutdown_cmd(
|
def _build_shutdown_cmd(
|
||||||
@ -152,23 +157,23 @@ class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
|
|||||||
cmd = ["/usr/bin/sudo", str(ADJUST_SHUTDOWN_SCRIPT)]
|
cmd = ["/usr/bin/sudo", str(ADJUST_SHUTDOWN_SCRIPT)]
|
||||||
if restore:
|
if restore:
|
||||||
cmd.append("--restore")
|
cmd.append("--restore")
|
||||||
cmd.extend([str(mon_wed), str(thu_sun), str(morning)])
|
cmd.extend([hhmm(mon_wed), hhmm(thu_sun), hhmm(morning)])
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
def _write_shutdown_config(
|
def _write_shutdown_config(
|
||||||
self,
|
self,
|
||||||
mon_wed_hour: int,
|
mon_wed: int,
|
||||||
thu_sun_hour: int,
|
thu_sun: int,
|
||||||
morning_end_hour: int,
|
morning_end: int,
|
||||||
*,
|
*,
|
||||||
restore: bool = False,
|
restore: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Write new shutdown config values using helper script.
|
"""Write new shutdown config values using helper script.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mon_wed_hour: Shutdown hour for Monday-Wednesday.
|
mon_wed: Monday-Wednesday shutdown, minutes after midnight.
|
||||||
thu_sun_hour: Shutdown hour for Thursday-Sunday.
|
thu_sun: Thursday-Sunday shutdown, minutes after midnight.
|
||||||
morning_end_hour: Morning end hour.
|
morning_end: End of the morning window, minutes after midnight.
|
||||||
restore: If True, allows restoring to later times.
|
restore: If True, allows restoring to later times.
|
||||||
|
|
||||||
Returns True if successful, False otherwise.
|
Returns True if successful, False otherwise.
|
||||||
@ -180,18 +185,18 @@ class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
cmd = self._build_shutdown_cmd(
|
cmd = self._build_shutdown_cmd(
|
||||||
mon_wed_hour,
|
mon_wed,
|
||||||
thu_sun_hour,
|
thu_sun,
|
||||||
morning_end_hour,
|
morning_end,
|
||||||
restore=restore,
|
restore=restore,
|
||||||
)
|
)
|
||||||
return self._run_shutdown_cmd(cmd, mon_wed_hour, thu_sun_hour)
|
return self._run_shutdown_cmd(cmd, mon_wed, thu_sun)
|
||||||
|
|
||||||
def _run_shutdown_cmd(
|
def _run_shutdown_cmd(
|
||||||
self,
|
self,
|
||||||
cmd: list[str],
|
cmd: list[str],
|
||||||
mon_wed_hour: int,
|
mon_wed: int,
|
||||||
thu_sun_hour: int,
|
thu_sun: int,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Execute the shutdown adjustment command."""
|
"""Execute the shutdown adjustment command."""
|
||||||
try:
|
try:
|
||||||
@ -205,9 +210,9 @@ class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
|
|||||||
_logger.warning("Failed to adjust shutdown config: %s", e)
|
_logger.warning("Failed to adjust shutdown config: %s", e)
|
||||||
return False
|
return False
|
||||||
_logger.info(
|
_logger.info(
|
||||||
"Adjusted shutdown: Mon-Wed=%d, Thu-Sun=%d. %s",
|
"Adjusted shutdown: Mon-Wed=%s, Thu-Sun=%s. %s",
|
||||||
mon_wed_hour,
|
hhmm(mon_wed),
|
||||||
thu_sun_hour,
|
hhmm(thu_sun),
|
||||||
result.stdout.strip(),
|
result.stdout.strip(),
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user