mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 22:47:13 +01:00
Compare commits
161 Commits
v1.0.0-bui
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
23
.github/workflows/pre-commit.yml
vendored
23
.github/workflows/pre-commit.yml
vendored
@ -57,12 +57,11 @@ jobs:
|
||||
# The flutter-analyze hook shells out to `flutter analyze` in
|
||||
# stronglift_replacement/workout_app. Pin to the version the app is
|
||||
# developed against (Dart SDK ^3.12.2) so CI's analyzer matches local.
|
||||
- uses: subosito/flutter-action@v2
|
||||
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
|
||||
if: matrix.group == 'flutter'
|
||||
with:
|
||||
flutter-version: 3.47.5
|
||||
channel: stable
|
||||
cache: true
|
||||
flutter-version: 3.47.9
|
||||
working-directory: stronglift_replacement/workout_app
|
||||
# analyze needs resolved packages (.dart_tool); the hook runs analyze
|
||||
# only, so fetch deps here first.
|
||||
- name: Resolve Flutter packages
|
||||
@ -70,20 +69,26 @@ jobs:
|
||||
run: flutter pub get
|
||||
working-directory: stronglift_replacement/workout_app
|
||||
|
||||
# The web-lint / web-typecheck hooks run npm inside web/. Without its
|
||||
# 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: "24.21.0"
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
node-version: "26.13.0"
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
- name: Install status-UI packages
|
||||
if: matrix.group == 'rest'
|
||||
run: npm ci
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: web
|
||||
|
||||
# Every hook brings its own environment except the language: system
|
||||
|
||||
35
.github/workflows/python-tests.yml
vendored
35
.github/workflows/python-tests.yml
vendored
@ -8,7 +8,10 @@ on:
|
||||
|
||||
env:
|
||||
# Bucket count for the Flutter suite; the matrix below lists 0..N-1.
|
||||
FLUTTER_SHARDS: 8
|
||||
# 10, not more: the account runs at most 20 jobs at once, and a push also
|
||||
# starts ~10 others (pre-commit, freshness, the rest of this file). At 12
|
||||
# three shards queued for up to 34 s and the workflow took 91 s.
|
||||
FLUTTER_SHARDS: 10
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@ -55,11 +58,11 @@ jobs:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: requirements.txt
|
||||
|
||||
# No apt step: the runner image already ships xvfb-run, and
|
||||
# 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-run, and the import fails
|
||||
# skip -- _xvfb_reexec returns 1 without Xvfb, and the import fails
|
||||
# without tkinter.
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@ -87,18 +90,17 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [0, 1, 2, 3, 4, 5, 6, 7]
|
||||
shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
name: flutter test (shard ${{ matrix.shard }})
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# cache: true keeps the SDK and ~/.pub-cache between runs; without it
|
||||
# the SDK download alone was ~57 s.
|
||||
- uses: subosito/flutter-action@v2
|
||||
# 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.5
|
||||
channel: stable
|
||||
cache: true
|
||||
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"
|
||||
@ -129,13 +131,16 @@ jobs:
|
||||
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: 24.21.0
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
node-version: 26.13.0
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: vitest --coverage, 100% (status web UI)
|
||||
run: |
|
||||
npm --prefix web ci
|
||||
npm --prefix web run coverage
|
||||
pnpm --dir web install --frozen-lockfile
|
||||
pnpm --dir web run coverage
|
||||
|
||||
12
.github/workflows/release-apk.yml
vendored
12
.github/workflows/release-apk.yml
vendored
@ -3,6 +3,14 @@ name: release-apk
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# A publisher, not a gate: build only when something that ends up in
|
||||
# the APK changed. versionCode is the commit count, so skipped pushes
|
||||
# still leave it monotonic.
|
||||
paths:
|
||||
- 'stronglift_replacement/workout_app/**'
|
||||
- '.fvmrc'
|
||||
- '.versioncode-offset'
|
||||
- '.github/workflows/release-apk.yml'
|
||||
# Lets a release be rebuilt from the current main without a dummy commit.
|
||||
workflow_dispatch:
|
||||
|
||||
@ -47,10 +55,12 @@ jobs:
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
# ~/.gradle caches + wrapper, keyed on the Gradle files.
|
||||
cache: gradle
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: 3.47.5
|
||||
flutter-version: 3.47.9
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
|
||||
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
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -27,6 +27,8 @@ screen_locker/sick_history.json
|
||||
screen_locker/extra_benefits_state.json
|
||||
screen_locker/scheduled_skips.json
|
||||
screen_locker/shutdown_base.json
|
||||
screen_locker/shutdown_base.lock
|
||||
screen_locker/log.lock
|
||||
screen_locker/sick_day_state.json
|
||||
screen_locker/early_bird_pending.json
|
||||
|
||||
@ -36,6 +38,11 @@ screen_locker/early_bird_pending.json
|
||||
# Web status UI (web/) build and tooling output.
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/.astro/
|
||||
web/coverage/
|
||||
web/node_modules
|
||||
web/dist
|
||||
screen_locker/rest_days.json
|
||||
screen_locker/grace_floor.json
|
||||
screen_locker/grace_floor.lock
|
||||
screen_locker/rest_days.lock
|
||||
|
||||
@ -8,14 +8,21 @@ default_language_version:
|
||||
default_stages: [pre-commit]
|
||||
fail_fast: false
|
||||
|
||||
default_install_hook_types: [pre-commit, pre-push, post-commit, post-merge, post-rewrite]
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
# run_tcx holds byte-exact RunnerUp exports; the golden test compares the
|
||||
# encoder's output with them, so the fixers must never rewrite them.
|
||||
- id: trailing-whitespace
|
||||
args: [--markdown-linebreak-ext=md]
|
||||
exclude: ^screen_locker/tests/fixtures/run_tcx/
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^screen_locker/tests/fixtures/run_tcx/
|
||||
- id: check-yaml
|
||||
# pnpm-lock.yaml is a two-document stream by design.
|
||||
exclude: ^web/pnpm-lock\.yaml$
|
||||
- id: check-json
|
||||
- id: check-toml
|
||||
- id: check-added-large-files
|
||||
@ -36,6 +43,12 @@ repos:
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: repo-contract
|
||||
name: repo contract (CLAUDE.md Commands + test_changed)
|
||||
entry: scripts/check_repo_contract.sh
|
||||
language: system
|
||||
pass_filenames: false
|
||||
files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$|(^|/)(AndroidManifest\.xml|build\.gradle(\.kts)?|strings\.xml|pubspec\.yaml|repo-contract\.toml|[^/]+\.desktop|assets/icon/[^/]+\.png)$
|
||||
- id: no-noqa
|
||||
name: Block noqa comments
|
||||
entry: '(?i)#\s*(noqa|type:\s*ignore)'
|
||||
@ -56,7 +69,7 @@ repos:
|
||||
files: ^(screen_locker/.*\.py|requirements\.txt)$
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.2
|
||||
rev: v0.17.1
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
|
||||
@ -98,7 +111,10 @@ repos:
|
||||
- types-requests>=2.31.0
|
||||
|
||||
- repo: https://github.com/pylint-dev/pylint
|
||||
rev: 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:
|
||||
# Two hooks, because the three checks below are not defects in a test
|
||||
# suite -- they are how pytest and unit-testing private mixins work --
|
||||
@ -111,34 +127,36 @@ repos:
|
||||
# duplicate-code near-identical `with patch(...)` preambles; a test
|
||||
# should read top-to-bottom without chasing a fixture.
|
||||
# Splitting also removes the old failure mode where the score depended on
|
||||
# what else happened to be staged alongside. See
|
||||
# todo_fix_pylint_10_prompt.md for raising both to a hard 10.00.
|
||||
# what else happened to be staged alongside. Both are a hard 10.00.
|
||||
- id: pylint
|
||||
name: pylint (source)
|
||||
name: pylint (source, 10.00)
|
||||
alias: pylint-source
|
||||
exclude: ^screen_locker/tests/
|
||||
args:
|
||||
- --rcfile=pyproject.toml
|
||||
- --fail-under=8.0
|
||||
- --jobs=4
|
||||
- --fail-under=10
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
# Kept in step with pyproject.toml: pip refuses two different
|
||||
# direct URLs for one distribution, so any pin that drifts from
|
||||
# the manifest becomes an unresolvable hook environment rather
|
||||
# than merely an old one.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
|
||||
# Same pin as workout_app/tools/render_finish_chime.sh installs.
|
||||
- music_theory @ git+https://github.com/kuhyx/utils@music-theory-v0.1.0#subdirectory=music_theory
|
||||
- mcp>=2,<3
|
||||
- pydantic
|
||||
- id: pylint
|
||||
name: pylint (tests)
|
||||
name: pylint (tests, 10.00)
|
||||
alias: pylint-tests
|
||||
files: ^screen_locker/tests/
|
||||
args:
|
||||
- --rcfile=pyproject.toml
|
||||
- --fail-under=8.0
|
||||
- --jobs=4
|
||||
- --fail-under=10
|
||||
- --disable=protected-access,unused-argument,duplicate-code
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
@ -146,9 +164,11 @@ repos:
|
||||
# direct URLs for one distribution, so any pin that drifts from
|
||||
# the manifest becomes an unresolvable hook environment rather
|
||||
# than merely an old one.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
|
||||
- mcp>=2,<3
|
||||
- pydantic
|
||||
|
||||
@ -167,9 +187,55 @@ repos:
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ci-mirror
|
||||
name: CI mirror (clean-venv pytest + pre-commit --all-files)
|
||||
entry: bash scripts/ci_mirror.sh
|
||||
# The CI mirror, one step per hook so each call stays under the 60 s
|
||||
# action cap. Order matters: ci-venv (re)builds .ci-mirror-venv from
|
||||
# requirements.txt when it changed, so ci-pytest only reuses it.
|
||||
# `pre-commit run --all-files` is split into four ci-precommit-* steps
|
||||
# (each well under the cap); together they run every default-stage hook
|
||||
# exactly once -- see run_precommit_group in scripts/ci_mirror.sh.
|
||||
- id: ci-venv
|
||||
name: CI mirror (clean requirements.txt venv)
|
||||
entry: bash scripts/ci_mirror.sh venv
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
require_serial: true
|
||||
stages: [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
|
||||
@ -181,7 +247,7 @@ repos:
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --skip=*.json,*.lock,.git,__pycache__,.venv
|
||||
- --skip=*.json,*.lock,*pnpm-lock.yaml,.git,__pycache__,.venv
|
||||
- --ignore-words-list=als,ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile,tabel,pary,blok,bloc,proces,serwer,parametr,adres,hart,dout,metod,tekst,synonim,grup,mosty,lokal,skalar,milion,nowe,tre,hel,alph
|
||||
|
||||
- repo: local
|
||||
@ -208,11 +274,13 @@ repos:
|
||||
# the gate it was being refactored to satisfy. ts/tsx joined on
|
||||
# 2026-08-29 with web/, for exactly the same reason: a language absent
|
||||
# from this list is a directory the cap silently does not apply to.
|
||||
types_or: [python, shell, dart, ts, tsx]
|
||||
# c/c++ and markdown joined on 2026-11-01 (the Linux runner had grown
|
||||
# to 258; prose is under the same cap per the 250-line rule).
|
||||
types_or: [python, shell, dart, ts, tsx, c, c++, markdown]
|
||||
exclude: '^web/(node_modules|dist|coverage)/'
|
||||
- id: no-silent-failures
|
||||
name: Block silent failures (swallowed exceptions and silent sys.exit(0) must say why)
|
||||
entry: python3 scripts/check_silent_failures.py
|
||||
entry: python3 -m scripts.check_silent_failures
|
||||
language: system
|
||||
types: [python]
|
||||
- id: no-silent-failures-dart
|
||||
@ -220,7 +288,7 @@ repos:
|
||||
# single Dart file — in the app where a swallowed sync error costs the
|
||||
# user a workout with nothing anywhere saying so.
|
||||
name: Block silent failures in Dart (swallowed errors must log why)
|
||||
entry: python3 scripts/check_silent_failures_dart.py
|
||||
entry: python3 -m scripts.check_silent_failures_dart
|
||||
language: system
|
||||
types: [dart]
|
||||
- id: screens-fit
|
||||
@ -240,12 +308,46 @@ repos:
|
||||
language: system
|
||||
files: '^stronglift_replacement/workout_app/.*\.(dart|yaml)$'
|
||||
pass_filenames: false
|
||||
- id: flutter-test-coverage
|
||||
name: flutter test --coverage 100% (workout app)
|
||||
entry: bash scripts/check_flutter_coverage.sh
|
||||
- id: flutter-test-coverage-shard-0
|
||||
name: flutter test bucket 0/4 (workout app, local gate)
|
||||
entry: bash scripts/check_flutter_coverage.sh --gate-shard 0
|
||||
language: system
|
||||
files: '^stronglift_replacement/workout_app/.*\.dart$'
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
require_serial: true
|
||||
stages: [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]
|
||||
|
||||
@ -254,21 +356,21 @@ repos:
|
||||
# can rot without anything failing. All three run here.
|
||||
- id: web-lint
|
||||
name: eslint (status web UI)
|
||||
entry: npm --prefix web run lint
|
||||
entry: pnpm --dir web run lint
|
||||
language: system
|
||||
files: '^web/(src/.*\.(ts|tsx)|eslint\.config\.js|package\.json)$'
|
||||
files: '^web/(src/.*\.(ts|astro)|eslint\.config\.js|package\.json)$'
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
- id: web-typecheck
|
||||
name: tsc --build (status web UI)
|
||||
entry: npm --prefix web run build
|
||||
name: astro check + build (status web UI)
|
||||
entry: pnpm --dir web run build
|
||||
language: system
|
||||
files: '^web/(src/.*\.(ts|tsx)|tsconfig.*\.json|vite\.config\.ts)$'
|
||||
files: '^web/(src/.*\.(ts|astro)|tsconfig\.json|astro\.config\.mjs|vitest\.config\.ts)$'
|
||||
pass_filenames: false
|
||||
require_serial: true
|
||||
- id: web-coverage
|
||||
name: vitest --coverage 100% (status web UI)
|
||||
entry: npm --prefix web run coverage
|
||||
entry: pnpm --dir web run coverage
|
||||
language: system
|
||||
files: '^web/src/'
|
||||
pass_filenames: false
|
||||
@ -285,6 +387,30 @@ repos:
|
||||
language: system
|
||||
files: \.(md|markdown)$
|
||||
|
||||
- id: todo-closer
|
||||
name: close Todo trailer tasks
|
||||
entry: scripts/todo_hooks.sh closer
|
||||
language: system
|
||||
stages: [post-commit]
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
- id: todo-import-pr-merge
|
||||
name: import PR Todo sections (pull)
|
||||
entry: scripts/todo_hooks.sh import-pr post-merge
|
||||
language: system
|
||||
stages: [post-merge]
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
- id: todo-import-pr-rewrite
|
||||
name: import PR Todo sections (pull --rebase)
|
||||
entry: scripts/todo_hooks.sh import-pr post-rewrite
|
||||
language: system
|
||||
stages: [post-rewrite]
|
||||
always_run: true
|
||||
pass_filenames: false
|
||||
|
||||
# Every dependency on its ecosystem's newest STABLE release, exact-pinned
|
||||
# so the question is mechanically answerable at all.
|
||||
#
|
||||
|
||||
81
AGENTS.md
81
AGENTS.md
@ -35,29 +35,63 @@ weeks because a missing token `return`ed `[]` and real errors only logged at
|
||||
## 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:
|
||||
`BASE_HOUR (19) + today's workout hours + the LeetCode hour + the reading hour`,
|
||||
capped at 23.
|
||||
`_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. Add a new bonus source as a term first, then
|
||||
(optionally) as a live pass stamped in `shutdown_base.json` so the two do not
|
||||
double-apply. The base is the constant, not the state file: the file once
|
||||
persisted `base_*_hour`, which made the code's default dead.
|
||||
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.
|
||||
|
||||
The LeetCode hour is read from leetcode-guard's ledger
|
||||
(`_leetcode_bonus.py`): HMAC-verified `credit` entries, keyed on LeetCode's
|
||||
own `submitted_at`, flat +1h/day, and *fail closed* -- an unreadable ledger or
|
||||
key earns nothing. leetcode-guard itself stays read-only; it never writes
|
||||
the config.
|
||||
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.
|
||||
|
||||
The reading hour is the same shape, from book-guard's ledger
|
||||
(`_reading_bonus.py`): HMAC-verified `credit` entries with `detail.bonus ==
|
||||
"1"`, dated by `detail.ended_at` (when the reading happened, not when the quiz
|
||||
was passed), flat +1h/day, fail closed. The base moves 20 -> 19 on
|
||||
2026-10-01 (`base_hour()`, `READING_BASE_FROM`) -- book-guard's start date, so
|
||||
the cut never lands before the hour that pays for it; the 23:00 best case is
|
||||
unchanged.
|
||||
## 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
|
||||
|
||||
@ -79,3 +113,12 @@ 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.
|
||||
50
README.md
50
README.md
@ -1,12 +1,36 @@
|
||||
# screen-locker
|
||||
|
||||
Tkinter/systemd screen locker with workout tracking, sick-day management, and
|
||||
Tkinter/systemd screen locker with workout tracking, shared sick days, and
|
||||
wake-alarm integration. It enforces a workout cadence by locking the screen
|
||||
until a RunnerUp-verified (or explicitly justified) workout is logged.
|
||||
until a verified (RunnerUp, StrongLifts) or budgeted manual workout is logged.
|
||||
|
||||
## Sick days
|
||||
|
||||
A sick day is a date in the fleet-wide [`sickdays`](https://github.com/kuhyx/utils/tree/main/sickdays)
|
||||
pool, nothing else: no budget, countdown, justification or debt. The lock
|
||||
screen's **I'm sick** button (retry screen, no-phone screen and the Tue-Thu
|
||||
prompt) marks today with one tap and unlocks; `sickdays add` or the phone do
|
||||
the same. On a sick day the workout lock stands down,
|
||||
wake-alarm's workday penalty is void, and shutdown is 20:00 plus the gaming
|
||||
pay of everything credited that day, capped at 23:00
|
||||
(`earned_time.sick_shutdown_from`). A day marked after its daily reset is
|
||||
raised to it by the next live pass (`_sick_floor`), and every later credit
|
||||
that day raises it again.
|
||||
|
||||
A credit landing at or after tonight's shutdown is never lost: tonight
|
||||
becomes `min(23:00, the credit's own time + its reward)` (`_late_credit`). `install.sh` migrates the old
|
||||
`sick_history.json` dates into the pool once (`scripts/migrate_sick_history.py`).
|
||||
|
||||
A sick day may *keep* lockers (`sickdays add --keep workout`). A kept workout
|
||||
lock behaves exactly as on a normal day (lock, quota, relaxed-day penalty on
|
||||
that date); only the 20:00 shutdown floor still applies, since it belongs to
|
||||
the day, not to a locker. The one-tap button keeps nothing. The missed-alarm
|
||||
penalty is void on its ring day only if `alarm` is off, and on its penalty day
|
||||
only if `workout` is off. All of it goes through `_sick.stands_down_workout`.
|
||||
|
||||
## Shutdown time
|
||||
|
||||
Every day starts at **19:00** from 2026-10-01, 20:00 before it (`base_hour()` in
|
||||
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,
|
||||
@ -24,6 +48,26 @@ so a run finished after 20:00 pushes the hour later without the phone being
|
||||
plugged in; the per-minute shutdown check then lifts the night lockdown on its
|
||||
own once the hour is past "now" (digital-wellbeing's check script).
|
||||
|
||||
Runs and walks are credited by ONE rule, whatever the TCX sport or the
|
||||
`_Walking` file-name suffix (RunnerUp's files and the workout app's alike): a
|
||||
day's activities are summed, and the day counts once their distance reaches
|
||||
3.5 km (5% GPS tolerance) OR their *moving* time reaches the shared duration
|
||||
bar (30 min minus the hidden leeway). Both are summed over the track cut into
|
||||
windows of at least 10 s, each judged by its average speed: faster than 25 km/h
|
||||
(a vehicle or a GPS jump) counts neither time nor distance; moving time needs
|
||||
0.5 m/s or more (1 m/s across a signal dropout longer than 30 s, so a pause
|
||||
earns nothing but a run through a dropout keeps its time). Files without
|
||||
trackpoints earn nothing (the lap totals are never used). The reward is the usual one
|
||||
workout credit per day (`runnerup_verified:<date>`); the log's `activity`
|
||||
field is display only. Code: `screen_locker/_walk_tcx.py`.
|
||||
|
||||
**Retiring RunnerUp.** A week after the PC first credits a run recorded by the
|
||||
workout app (TCX author `kuhy workout_app`), `runnerup-retire.timer` (daily, retries
|
||||
until the phone is attached) backs `/sdcard/Documents/RunnerUp` up to
|
||||
`~/data/cloud/RunnerUp/runnerup_phone_backup_<date>/` and uninstalls `org.runnerup.free`,
|
||||
once the app's `.workout_app_import.json` matches `processed/`; no app run, no uninstall.
|
||||
`python3 -m screen_locker.runnerup_retire --status` / `--dry-run` change nothing.
|
||||
|
||||
## MCP server (Claude Code integration)
|
||||
|
||||
screen-locker exposes a **read-only** MCP server (`screen_locker._mcp`) so
|
||||
|
||||
@ -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"
|
||||
"workout-sync.service"
|
||||
"workout-sync.timer"
|
||||
"earner-bonus.service"
|
||||
)
|
||||
|
||||
# Only the timers get enabled; the service is triggered by them and by login.
|
||||
# Only the timers (and the ledger watch) get enabled; the services are
|
||||
# triggered by them and by login.
|
||||
readonly TIMERS=(
|
||||
"early-bird-workout-check.timer"
|
||||
"workout-locker.timer"
|
||||
"workout-sync.timer"
|
||||
"earner-bonus.path"
|
||||
)
|
||||
|
||||
SKIP_TODAY=0
|
||||
@ -87,6 +90,8 @@ main() {
|
||||
for unit in "${UNITS[@]}"; do
|
||||
cp "$SCRIPT_DIR/$unit" "$USER_UNIT_DIR/$unit"
|
||||
done
|
||||
# Rendered from the earned_time registry rather than copied.
|
||||
PYTHONPATH="$SCRIPT_DIR" python3 -m screen_locker._earner_units "$USER_UNIT_DIR"
|
||||
|
||||
systemctl --user daemon-reload
|
||||
|
||||
|
||||
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
|
||||
@ -2,6 +2,14 @@
|
||||
|
||||
## Status
|
||||
|
||||
**Superseded (2026-10-09): screen-locker sync is Firebase RTDB only.** The
|
||||
phone stopped writing the GitHub repo on 2026-08-21, and from 2026-10-09 the
|
||||
PC does not use it either: the push, `sync_client()` and
|
||||
`scripts/tombstone_sync_record.py` all use `firebase_client_for("screen_locker")`,
|
||||
and the sync_token / GitHub client / mirror code paths are deleted.
|
||||
`kuhyx/syncs/screen-locker-sync` stays as a frozen archive that no code reads
|
||||
and nothing deletes. The rest of this document is the historical design record.
|
||||
|
||||
**Blocked on Workstream D** (2026-07-04 user decision — see "Sequencing
|
||||
decision" below). No code has been written for this workstream. The design
|
||||
questions below (sync pattern, repo name, scope) have been pre-decided so the
|
||||
|
||||
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
|
||||
echo " ok: screen_locker and gatelock both import under $PROD_PYTHON"
|
||||
|
||||
# One-shot, idempotent: the old sick_history.json's dates move into the shared
|
||||
# sickdays pool and the file is removed once they read back. Rerunning (or a
|
||||
# machine that never had the file) is a no-op. A failure keeps the file and
|
||||
# is retried by the next install; it does not block the locker itself.
|
||||
echo "Migrating the legacy sick history into the sickdays pool..."
|
||||
if ! "$PROD_PYTHON" "$REPO_DIR/scripts/migrate_sick_history.py"; then
|
||||
echo " WARNING: sick-history migration failed (file kept); rerun" >&2
|
||||
echo " $REPO_DIR/scripts/migrate_sick_history.py" >&2
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== Done ==="
|
||||
echo "The locker is live. Check when it next fires with:"
|
||||
|
||||
@ -13,6 +13,12 @@ EARLY_BIRD_TIMER_NAME="early-bird-workout-check.timer"
|
||||
LOCKER_TIMER_NAME="workout-locker.timer"
|
||||
SYNC_SERVICE_NAME="workout-sync.service"
|
||||
SYNC_TIMER_NAME="workout-sync.timer"
|
||||
BONUS_SERVICE_NAME="earner-bonus.service"
|
||||
BONUS_PATH_NAME="earner-bonus.path"
|
||||
RUNNERUP_WATCH_NAME="runnerup-watch.service"
|
||||
WORKOUT_POKE_NAME="workout-poke.service"
|
||||
RETIRE_SERVICE_NAME="runnerup-retire.service"
|
||||
RETIRE_TIMER_NAME="runnerup-retire.timer"
|
||||
|
||||
# Runtime dependencies. screen_lock.py imports tkinter at module scope. On Arch
|
||||
# the tkinter MODULE ships inside the `python` package, but the shared library
|
||||
@ -35,6 +41,20 @@ ensure_runtime_deps() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# inotifywait drives runnerup-watch.service (instant RunnerUp credit).
|
||||
if ! command -v inotifywait >/dev/null 2>&1; then
|
||||
if command -v pacman >/dev/null 2>&1; then
|
||||
echo "Installing missing system dependency: inotify-tools"
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
pacman -S --needed --noconfirm inotify-tools
|
||||
else
|
||||
sudo pacman -S --needed --noconfirm inotify-tools
|
||||
fi
|
||||
else
|
||||
echo "WARNING: inotifywait missing and pacman unavailable - RunnerUp runs are credited only by the 15-min timer" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# The two shared RUNTIME dependencies, both pinned git deps in
|
||||
# pyproject.toml: gatelock (lock-window/HMAC backend) and crdt-sync (the
|
||||
# workout-sync transport). Nothing here used to install either, so a fresh
|
||||
@ -53,9 +73,9 @@ ensure_runtime_deps() {
|
||||
echo "WARNING: could not install $1 automatically" >&2
|
||||
}
|
||||
_pip_runtime_dep gatelock \
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock"
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.0#subdirectory=gatelock"
|
||||
_pip_runtime_dep crdt_sync \
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.0#subdirectory=crdt-sync"
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync"
|
||||
}
|
||||
ensure_runtime_deps
|
||||
|
||||
@ -89,7 +109,7 @@ fi
|
||||
rm -f "$USER_SERVICE_DIR/workout-locker.timer"
|
||||
|
||||
# Seed shutdown_base.json if not already present. It holds only date stamps;
|
||||
# the base hour itself is BASE_HOUR in screen_locker/_shutdown_base.py.
|
||||
# the base hour itself comes from the earned_time registry (kuhyx/utils).
|
||||
SHUTDOWN_BASE="$SCRIPT_DIR/screen_locker/shutdown_base.json"
|
||||
if [[ ! -f "$SHUTDOWN_BASE" ]]; then
|
||||
printf '{\n "last_reset_date": ""\n}\n' > "$SHUTDOWN_BASE"
|
||||
@ -113,6 +133,28 @@ cp "$LOCKER_TIMER_FILE" "$USER_SERVICE_DIR/$LOCKER_TIMER_NAME"
|
||||
cp "$SYNC_SERVICE_FILE" "$USER_SERVICE_DIR/$SYNC_SERVICE_NAME"
|
||||
cp "$SYNC_TIMER_FILE" "$USER_SERVICE_DIR/$SYNC_TIMER_NAME"
|
||||
|
||||
# The ledger watch: a gate writing its ledger applies the earned shutdown
|
||||
# bonus at once instead of at the next 15-minute sync. The .path unit is
|
||||
# rendered from the earned_time registry, so it is generated, not copied.
|
||||
cp "$SCRIPT_DIR/$BONUS_SERVICE_NAME" "$USER_SERVICE_DIR/$BONUS_SERVICE_NAME"
|
||||
PYTHONPATH="$SCRIPT_DIR" python3 -m screen_locker._earner_units "$USER_SERVICE_DIR"
|
||||
|
||||
# The RunnerUp upload watch: a TCX landing in ~/data/cloud/RunnerUp starts
|
||||
# workout-sync at once instead of waiting for the next timer tick.
|
||||
cp "$SCRIPT_DIR/$RUNNERUP_WATCH_NAME" "$USER_SERVICE_DIR/$RUNNERUP_WATCH_NAME"
|
||||
|
||||
# The phone's LAN poke: a finished StrongLifts session is credited within a
|
||||
# second instead of at the next sync. The key must exist before the unit
|
||||
# starts, or it refuses to (docs/DOCS-workout-poke-contract.md).
|
||||
bash "$SCRIPT_DIR/scripts/workout_poke_keygen.sh"
|
||||
cp "$SCRIPT_DIR/$WORKOUT_POKE_NAME" "$USER_SERVICE_DIR/$WORKOUT_POKE_NAME"
|
||||
|
||||
# The RunnerUp retirement: a week after the workout app's first credited run
|
||||
# it backs the old app's exports up and uninstalls it from the phone. A daily
|
||||
# timer, because the phone is not always attached; a no-op until then.
|
||||
cp "$SCRIPT_DIR/$RETIRE_SERVICE_NAME" "$USER_SERVICE_DIR/$RETIRE_SERVICE_NAME"
|
||||
cp "$SCRIPT_DIR/$RETIRE_TIMER_NAME" "$USER_SERVICE_DIR/$RETIRE_TIMER_NAME"
|
||||
|
||||
# Update paths in the service file to use absolute paths
|
||||
REPO_ROOT="$SCRIPT_DIR"
|
||||
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
||||
@ -125,14 +167,17 @@ systemctl --user daemon-reload
|
||||
# Enable the service to start on login (one-shot, no periodic timer)
|
||||
systemctl --user enable "$SERVICE_NAME"
|
||||
|
||||
# Enable the early bird re-check timer
|
||||
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME"
|
||||
# Timers: early bird re-check, periodic re-check, workout sync, the instant
|
||||
# bonus pass on ledger writes, and the daily RunnerUp retirement retry
|
||||
systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME" "$LOCKER_TIMER_NAME" \
|
||||
"$SYNC_TIMER_NAME" "$BONUS_PATH_NAME" "$RETIRE_TIMER_NAME"
|
||||
|
||||
# Enable the periodic re-check timer
|
||||
systemctl --user enable --now "$LOCKER_TIMER_NAME"
|
||||
|
||||
# Enable the periodic workout sync
|
||||
systemctl --user enable --now "$SYNC_TIMER_NAME"
|
||||
# Instant RunnerUp credit on upload, and the LAN workout poke listener
|
||||
# (restart picks up new code)
|
||||
for unit in "$RUNNERUP_WATCH_NAME" "$WORKOUT_POKE_NAME"; do
|
||||
systemctl --user enable "$unit"
|
||||
systemctl --user restart "$unit"
|
||||
done
|
||||
|
||||
# Verify enforcement is actually armed. enable can silently no-op when systemd
|
||||
# breaks an ordering cycle by deleting the timer's job -- exactly what happened
|
||||
|
||||
@ -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]
|
||||
name = "screen-locker"
|
||||
version = "1.0.0"
|
||||
description = "Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration"
|
||||
description = "Tkinter/systemd screen locker with workout tracking, shared sick days, and wake-alarm integration"
|
||||
requires-python = ">=3.14"
|
||||
# gatelock: shared lock-window/HMAC backend, also used by diet_guard and wake_alarm
|
||||
# crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport,
|
||||
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
|
||||
# crdt-sync: shared CRDT merge scheme + sync transports, also used by
|
||||
# diet_guard; here for phone-workout sync, Firebase RTDB only since 2026-10-09
|
||||
dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.2#subdirectory=gatelock",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
|
||||
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays",
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
|
||||
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
|
||||
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time",
|
||||
"holds @ git+https://github.com/kuhyx/utils@holds-v0.1.0#subdirectory=holds",
|
||||
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@ -22,7 +25,7 @@ screen-locker-web = "screen_locker._web_server:main"
|
||||
# so the systemd/CLI system-python path stays dependency-light and never has
|
||||
# to import the MCP SDK.
|
||||
[project.optional-dependencies]
|
||||
mcp = ["mcp==2.2.0"]
|
||||
mcp = ["mcp==2.3.0"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
# This repo also holds stronglift_replacement/ (an unrelated Flutter app, not
|
||||
@ -42,6 +45,9 @@ ignore = [
|
||||
"D203",
|
||||
"D213",
|
||||
"COM812",
|
||||
# Personal repo, no copyright policy (ruff 0.17 made CPY001 stable under
|
||||
# select=ALL).
|
||||
"CPY001",
|
||||
"ISC001",
|
||||
"S603",
|
||||
]
|
||||
@ -92,17 +98,7 @@ unfixable = []
|
||||
# -truth reuse status_view.py above is exempted for — and _make_bare_verifier
|
||||
# imports ScreenLocker inside the function to avoid an import cycle.
|
||||
"screen_locker/_status_view_verify.py" = ["PLC0415", "SLF001"]
|
||||
# _workout_handoff.py must pause gatelock's recovery watchdog while the Flutter
|
||||
# app holds the screen. The loop re-takes any grab it finds missing every
|
||||
# 1000ms -- by design, so that pulling a cable is not an escape -- and
|
||||
# holds_grab() is `grab_current() is not None`, which is False once another X
|
||||
# client holds it. Left running it would steal the screen back from the workout
|
||||
# app within a second. gatelock exposes no public pause (only close(), which
|
||||
# also restores VT switching and would hand the user Ctrl+Alt+F2), and adding
|
||||
# one there would contradict _recovery.py's monotonicity proof that no
|
||||
# transition ever decreases the grab.
|
||||
"screen_locker/_workout_handoff.py" = ["SLF001"]
|
||||
# explain_lock_decision takes one arg per real file path + sick history, and
|
||||
# explain_lock_decision takes one arg per real file path and input, and
|
||||
# returns early per lock-decision-chain stage (see module docstring) — both
|
||||
# are the deliberate design, not something to collapse into a config object.
|
||||
"screen_locker/_compliance_state.py" = ["C901", "PLR0911", "PLR0913"]
|
||||
@ -175,17 +171,14 @@ exclude = [".venv/"]
|
||||
init-hook = "import sys; sys.path.insert(0, '.')"
|
||||
analyse-fallback-blocks = true
|
||||
persistent = true
|
||||
jobs = 0
|
||||
# Serial on purpose. With jobs != 1 pylint ships the linter to its workers via
|
||||
# dill.dumps(), and dill 0.4.1 (newest release) reads code.co_lnotab, which
|
||||
# CPython 3.15 removed -- every run crashed once setup-python's "3.x" became
|
||||
# 3.15.1. The fix sits on dill master, unreleased; return to 0 once a dill
|
||||
# release past 0.4.1 ships. The pre-commit hooks take this value too.
|
||||
jobs = 1
|
||||
py-version = "3.14"
|
||||
ignore = [".venv", "__pycache__"]
|
||||
# Test suites intentionally use patterns (protected-access, fixture-arg
|
||||
# shadowing) that don't apply to source code; ruff's per-file-ignores carry
|
||||
# the equivalent exemptions, so tests stay linted -- by ruff. This is the same
|
||||
# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has
|
||||
# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the
|
||||
# basename of what pylint is *given*, and pre-commit hands it explicit file
|
||||
# paths, so a directory name in `ignore` never fires under the hook.
|
||||
ignore-paths = ["^(.*/)?tests/.*$"]
|
||||
ignore-patterns = [".*\\.pyi$"]
|
||||
|
||||
[tool.pylint.messages_control]
|
||||
@ -195,7 +188,42 @@ disable = []
|
||||
[tool.pylint.design]
|
||||
min-public-methods = 0
|
||||
max-module-lines = 1000
|
||||
max-attributes = 10
|
||||
# ScreenLocker composes ~30 mixins; collapsing them is not the goal.
|
||||
max-parents = 40
|
||||
# Keyword-only arguments count: the gather_*/explain_* entry points take one
|
||||
# per real file path so tests can override each.
|
||||
max-args = 8
|
||||
# ManualWorkoutDraft is a flat 23-field record mirroring the evidence form and
|
||||
# the wire payload; StatusWindow / ScreenLocker hold UI and injected state.
|
||||
max-attributes = 25
|
||||
|
||||
[tool.pylint.similarities]
|
||||
# The remaining overlaps are 6-11 line fragments of deliberately parallel code
|
||||
# (Tk button rows, the two JSON ledgers' loaders, mixin host stubs); a real
|
||||
# copy-paste block is longer than this.
|
||||
min-similarity-lines = 12
|
||||
|
||||
[tool.pylint.format]
|
||||
# BaseHTTPRequestHandler dispatches on the method names do_GET / do_POST.
|
||||
good-names-rgxs = "^do_(GET|POST)$"
|
||||
|
||||
[tool.pylint.classes]
|
||||
# The mixin-host surface: module-level helpers (shutdown_base, status, the
|
||||
# status window's verify flow) drive the ScreenLocker's own mixin methods, the
|
||||
# same single-source-of-truth reuse ruff exempts via per-file SLF001.
|
||||
exclude-protected = [
|
||||
"_asdict", "_fields", "_replace", "_source", "_make", "os._exit",
|
||||
"_adjust_shutdown_time_by",
|
||||
"_apply_sick_floor",
|
||||
"_apply_workout_credit",
|
||||
"_credit_ingested_workout",
|
||||
"_read_shutdown_config",
|
||||
"_scan_and_fill_week_runnerup",
|
||||
"_try_fill_stronglifts_for_week",
|
||||
"_verify_phone_workout",
|
||||
"_verify_runnerup_workout",
|
||||
"_write_shutdown_config",
|
||||
]
|
||||
|
||||
[tool.pylint.typecheck]
|
||||
generated-members = [
|
||||
@ -226,6 +254,14 @@ addopts = [
|
||||
"--cov-branch",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=lcov",
|
||||
# The commit gate runs plain `python -m pytest -q`; serial it took 26 s of
|
||||
# its 60 s budget, 4 workers 9 s. Fixed, not `auto`: auto is 24 workers
|
||||
# here, past capped.sh's 4 GiB. CI's own `-n auto` comes later and wins;
|
||||
# `-n0` gets a serial run back.
|
||||
"-n",
|
||||
"4",
|
||||
"--dist",
|
||||
"worksteal",
|
||||
# A fixture-only plugin module, not conftest.py (already at the
|
||||
# repo's 400-line-per-file cap) -- see its own docstring.
|
||||
"-p",
|
||||
@ -240,6 +276,9 @@ filterwarnings = [
|
||||
[tool.coverage.run]
|
||||
source = ["screen_locker"]
|
||||
branch = true
|
||||
# sys.monitoring (PEP 669) rather than the C tracer: test_shutdown_ceiling_
|
||||
# invariant.py alone measured 13 s vs 22 s, for the same line+branch data.
|
||||
core = "sysmon"
|
||||
omit = [
|
||||
"*/__pycache__/*",
|
||||
"*/tests/*",
|
||||
|
||||
@ -13,6 +13,12 @@ UNITS=(
|
||||
"early-bird-workout-check.timer"
|
||||
"workout-sync.timer"
|
||||
"workout-sync.service"
|
||||
"earner-bonus.path"
|
||||
"earner-bonus.service"
|
||||
"runnerup-watch.service"
|
||||
"workout-poke.service"
|
||||
"runnerup-retire.timer"
|
||||
"runnerup-retire.service"
|
||||
)
|
||||
|
||||
for unit in "${UNITS[@]}"; do
|
||||
|
||||
@ -2,22 +2,29 @@
|
||||
# Runtime: tkinter/subprocess/socket/sqlite3 (stdlib) plus gatelock (below).
|
||||
bandit==1.9.4
|
||||
codespell==2.4.3
|
||||
coverage==7.16.1
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.2#subdirectory=gatelock
|
||||
mcp==2.2.0
|
||||
mypy==2.3.1
|
||||
pre-commit==4.6.2
|
||||
pylint==4.0.9
|
||||
pytest==9.1.1
|
||||
coverage==7.17.2
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.21.0#subdirectory=earned_time
|
||||
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
|
||||
holds @ git+https://github.com/kuhyx/utils@holds-v0.1.0#subdirectory=holds
|
||||
mcp==2.3.0
|
||||
mypy==2.4.1
|
||||
pre-commit==4.7.0
|
||||
pylint==4.1.3
|
||||
pytest==9.2.0
|
||||
pytest-cov==7.1.0
|
||||
pytest-randomly==5.0.0
|
||||
pytest-randomly==5.1.0
|
||||
pytest-sugar==1.1.1
|
||||
pytest-xdist==3.8.0
|
||||
# Direct dependency since the Firebase session recovery exchanges a refresh
|
||||
# token over the REST endpoint; crdt-sync already pulls requests in, but this
|
||||
# module imports it itself, so it is declared rather than assumed.
|
||||
requests==2.34.2
|
||||
ruff==0.16.9
|
||||
types-requests==2.33.0.20260906
|
||||
ruff==0.17.4
|
||||
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays
|
||||
types-requests==2.33.0.20261030
|
||||
# Direct dependency since the Firebase session stream (_session_stream) catches
|
||||
# urllib3's own errors on the long-lived SSE read; requests pulls it in, but
|
||||
# the module imports it itself, so it is declared rather than assumed.
|
||||
urllib3==2.8.0
|
||||
|
||||
15
runnerup-retire.service
Normal file
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 concurrent.futures import ( # pylint: disable=no-name-in-module
|
||||
from concurrent.futures import (
|
||||
ThreadPoolExecutor,
|
||||
as_completed,
|
||||
)
|
||||
|
||||
@ -174,7 +174,7 @@ def locker_unit_active() -> bool | None:
|
||||
if not systemctl_available():
|
||||
return None
|
||||
code, out = _run(["systemctl", "--user", "is-active", LOCKER_UNIT])
|
||||
if code != 0 and not out.strip():
|
||||
if code and not out.strip():
|
||||
return None
|
||||
return out.strip() == "active"
|
||||
|
||||
@ -186,7 +186,7 @@ def _login_target_active() -> bool:
|
||||
True when ``graphical-session.target`` is active.
|
||||
"""
|
||||
code, out = _run(["systemctl", "--user", "is-active", _LOGIN_TARGET])
|
||||
return code == 0 and out.strip() == "active"
|
||||
return not code and out.strip() == "active"
|
||||
|
||||
|
||||
def collect_states() -> list[TimerState]:
|
||||
@ -205,7 +205,7 @@ def collect_states() -> list[TimerState]:
|
||||
states.append(
|
||||
TimerState(
|
||||
name=name,
|
||||
enabled=code == 0 and raw == "enabled",
|
||||
enabled=not code and raw == "enabled",
|
||||
enabled_raw=raw,
|
||||
# For a timer this is "systemd has a next trigger for it";
|
||||
# for the login-run service it is "the target it is wanted by
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
"""Mixin: auto-upgrade early_bird/sick_day pending states via phone or RunnerUp.
|
||||
"""Mixin: the today-state rungs of the lock ladder, and the early-bird upgrade.
|
||||
|
||||
Neither early_bird (a same-day pending marker, see ``_early_bird.py``) nor
|
||||
sick_day (tracked in ``sick_history.json`` via ``_sick_tracker.py``) live in
|
||||
log.json — this module only checks their pending state and, on
|
||||
success, writes the *real* outcome (phone_verified/runnerup_verified) there.
|
||||
early_bird is a same-day pending marker (``_early_bird.py``), not a log.json
|
||||
entry — this module only checks its pending state and, on success, writes
|
||||
the *real* outcome (phone_verified/runnerup_verified) there.
|
||||
|
||||
A sick day is a date in the shared ``sickdays`` pool (``_sick.py``): the
|
||||
lock stands down for it and nothing is written. A workout done on a sick
|
||||
day still reaches the log through the ordinary sync paths and earns
|
||||
normally, so there is no sick-day upgrade any more.
|
||||
|
||||
The upgrade credits through ``_apply_workout_credit`` -- save first, reward
|
||||
only if the entry was actually appended. It used to push the shutdown hour
|
||||
@ -18,10 +22,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from screen_locker import _sick_tracker
|
||||
from screen_locker._decision_log import LockDecision, record_decision
|
||||
from screen_locker._decision_reasons import reasons_extra
|
||||
from screen_locker._morning_session import has_workout_skip_today
|
||||
from screen_locker._hold import workout_hold_active
|
||||
from screen_locker._morning_session import MorningSkip, has_workout_skip_today
|
||||
from screen_locker._night_lockdown import has_evening_lockdown_today
|
||||
from screen_locker._sick import stands_down_workout
|
||||
from screen_locker._weekly_check import has_weekly_minimum, is_relaxed_day
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@ -67,6 +73,8 @@ class _ReasonsMixin:
|
||||
"wake_alarm_skip": has_workout_skip_today,
|
||||
"relaxed_day": is_relaxed_day,
|
||||
"weekly_minimum_met": lambda: has_weekly_minimum(self.log_file),
|
||||
"evening_lockdown_today": has_evening_lockdown_today,
|
||||
"test_hold_active": workout_hold_active,
|
||||
},
|
||||
)
|
||||
|
||||
@ -79,15 +87,14 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
||||
"""
|
||||
|
||||
def _is_sick_day_today(self) -> bool:
|
||||
"""Check if today is marked as a sick day in sick_history.json."""
|
||||
return _sick_tracker.is_sick_day(_sick_tracker.load_history())
|
||||
"""Whether the sickdays pool switches the workout lock off today.
|
||||
|
||||
def _check_early_exits(self, *, verify_only: bool) -> None:
|
||||
A sick day that keeps the workout locker is a normal day here.
|
||||
"""
|
||||
return stands_down_workout()
|
||||
|
||||
def _check_early_exits(self) -> None:
|
||||
"""Check startup conditions and exit early when appropriate."""
|
||||
if verify_only:
|
||||
if not self._is_sick_day_today():
|
||||
_skip("no_sick_day_to_verify", "No sick day logged today.")
|
||||
return
|
||||
self._check_non_verify_exits()
|
||||
|
||||
def _check_today_state_exits(self) -> bool:
|
||||
@ -119,17 +126,11 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
||||
**self._other_conditions("early_bird_window_active"),
|
||||
)
|
||||
elif self._is_sick_day_today():
|
||||
if self._try_auto_upgrade_sick_day():
|
||||
_skip(
|
||||
"sick_day_auto_upgraded",
|
||||
"Auto-upgraded today's sick_day entry to phone_verified.",
|
||||
)
|
||||
else:
|
||||
_skip(
|
||||
"sick_day",
|
||||
"Sick day already logged today.",
|
||||
**self._other_conditions("sick_day"),
|
||||
)
|
||||
_skip(
|
||||
"sick_day",
|
||||
"Today is a sick day (sickdays pool); the workout lock is off.",
|
||||
**self._other_conditions("sick_day"),
|
||||
)
|
||||
elif self.has_logged_today():
|
||||
_skip(
|
||||
"workout_logged_today",
|
||||
@ -152,43 +153,33 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _try_auto_upgrade_sick_day(self) -> bool:
|
||||
"""Upgrade sick_day entry when phone or RunnerUp detects a valid workout."""
|
||||
try:
|
||||
status, message = self._verify_phone_workout()
|
||||
except (OSError, RuntimeError) as exc:
|
||||
_logger.warning(
|
||||
"Sick-day auto-upgrade could not reach the phone (%s) — today's "
|
||||
"sick_day entry stays unverified; trying RunnerUp next",
|
||||
exc,
|
||||
def _stand_down_for_carrot(self, skip: MorningSkip) -> None:
|
||||
"""Take an armed lock down because the carrot landed after it armed.
|
||||
|
||||
Does what the ``wake_alarm_skip`` rung above does -- bank the pending
|
||||
marker so the post-carrot run still tries the phone workout, record the
|
||||
decision -- then leaves through the locker's normal ``close`` (the same
|
||||
one a credited workout and a test hold use): lock window released,
|
||||
instance lock dropped, exit 0. Nothing is credited or penalised.
|
||||
"""
|
||||
self._save_early_bird_pending()
|
||||
record_decision(
|
||||
LockDecision(
|
||||
locked=False,
|
||||
reason="wake_alarm_skip_late",
|
||||
detail=f"Morning session carrot landed after the lock armed: {skip}.",
|
||||
extra={
|
||||
"exempt_until": skip.exempt_until.isoformat(),
|
||||
"outcome": skip.outcome,
|
||||
},
|
||||
)
|
||||
status, message = "error", str(exc)
|
||||
if status == "verified":
|
||||
self.workout_data["type"] = "phone_verified"
|
||||
self.workout_data["source"] = message
|
||||
self.workout_data["after_sick_day"] = "true"
|
||||
self._apply_workout_credit()
|
||||
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._apply_workout_credit()
|
||||
return True
|
||||
)
|
||||
_logger.warning(
|
||||
"Morning-session carrot appeared while the lock was up (%s) — "
|
||||
"taking the lock down; the early-bird marker is banked.",
|
||||
skip,
|
||||
)
|
||||
self.close()
|
||||
|
||||
def _try_auto_upgrade_early_bird(self) -> bool:
|
||||
"""Try phone then RunnerUp to upgrade an early_bird log entry."""
|
||||
|
||||
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
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._constants import SHUTDOWN_BASE_FILE
|
||||
from screen_locker._decision_log import record_no_decision
|
||||
from screen_locker._manual_cli import run_manual_log
|
||||
from screen_locker._rest_day_cli import run_declare_rest_day
|
||||
from screen_locker._shutdown_base import apply_flat_bonuses_if_new
|
||||
from screen_locker._status import run_status
|
||||
from screen_locker._workout_ledger_cli import run_backfill
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from screen_locker.screen_lock import ScreenLocker
|
||||
|
||||
__all__ = ["main"]
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_LOG_FILE_NAME = "log.json"
|
||||
_LOG_MANUAL_FLAG = "--log-manual-workout"
|
||||
_REST_DAY_FLAG = "--declare-rest-day"
|
||||
_BACKFILL_FLAG = "--backfill-workout-ledger"
|
||||
|
||||
# Subcommands that own every argument after them (each has its own argparse,
|
||||
# which also answers its own --help).
|
||||
_TRAILING_ARG_FLAGS = (_LOG_MANUAL_FLAG, _REST_DAY_FLAG, _BACKFILL_FLAG)
|
||||
_MODE_FLAGS = frozenset({"--status", "--sync-only", "--apply-bonuses", "--production"})
|
||||
_HELP_FLAGS = frozenset({"-h", "--help"})
|
||||
_EXIT_USAGE = 2
|
||||
|
||||
_USAGE = """\
|
||||
usage: python3 -m screen_locker.screen_lock [MODE]
|
||||
|
||||
With no mode: the demo lock screen (Tk). Modes:
|
||||
--production the real lock screen (what workout-locker.service runs)
|
||||
--status print this week's workout status and exit
|
||||
--sync-only headless: pull synced workouts, apply credit, exit
|
||||
--apply-bonuses headless: apply newly earned flat bonuses, exit
|
||||
--log-manual-workout ARGS headless manual workout log (see its --help)
|
||||
--declare-rest-day ARGS sign a future rest day (see its --help)
|
||||
--backfill-workout-ledger ARGS
|
||||
backfill the workout ledger (see its --help)
|
||||
-h, --help show this help and exit
|
||||
"""
|
||||
|
||||
|
||||
def _own_args(argv: list[str]) -> list[str]:
|
||||
"""``argv[1:]`` up to the first subcommand that owns the rest."""
|
||||
args = argv[1:]
|
||||
for index, arg in enumerate(args):
|
||||
if arg in _TRAILING_ARG_FLAGS:
|
||||
return args[:index]
|
||||
return args
|
||||
|
||||
|
||||
def _handle_help_and_unknown(argv: list[str]) -> None:
|
||||
"""Exit 0 on ``--help``; exit 2 on an unknown argument.
|
||||
|
||||
Before this, ``--help`` matched no branch and fell through to building
|
||||
the demo lock screen, and a typo'd flag did the same silently.
|
||||
"""
|
||||
own = _own_args(argv)
|
||||
if _HELP_FLAGS & set(own):
|
||||
# The usage on stdout IS the work --help asked for, so exiting 0 here
|
||||
# abandons nothing. SystemExit is raised directly, not via sys.exit:
|
||||
# the suite stubs sys.exit process-wide, and a help request must never
|
||||
# fall through to building the lock screen even then.
|
||||
sys.stdout.write(_USAGE)
|
||||
raise SystemExit(0)
|
||||
unknown = [arg for arg in own if arg not in _MODE_FLAGS]
|
||||
if unknown:
|
||||
sys.stderr.write(f"error: unknown argument(s): {' '.join(unknown)}\n{_USAGE}")
|
||||
sys.exit(_EXIT_USAGE)
|
||||
|
||||
|
||||
def _headless_locker(locker_cls: type[ScreenLocker]) -> ScreenLocker:
|
||||
@ -41,6 +100,7 @@ def _headless_locker(locker_cls: type[ScreenLocker]) -> ScreenLocker:
|
||||
|
||||
def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None:
|
||||
"""Dispatch on ``argv`` and run the requested mode."""
|
||||
_handle_help_and_unknown(argv)
|
||||
# Configure logging for EVERY mode, not just --sync-only. This used to sit
|
||||
# inside the --sync-only branch, so `--production` -- the mode systemd
|
||||
# actually runs -- had no handler and fell back to lastResort, which drops
|
||||
@ -61,6 +121,14 @@ def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None:
|
||||
rest = argv[argv.index(_LOG_MANUAL_FLAG) + 1 :]
|
||||
sys.exit(run_manual_log(_headless_locker(locker_cls).log_file, rest))
|
||||
|
||||
if _REST_DAY_FLAG in argv:
|
||||
# Headless, no Tk: sign a FUTURE rest day (see screen_locker._rest_day).
|
||||
sys.exit(run_declare_rest_day(argv[argv.index(_REST_DAY_FLAG) + 1 :]))
|
||||
|
||||
if _BACKFILL_FLAG in argv:
|
||||
rest = argv[argv.index(_BACKFILL_FLAG) + 1 :]
|
||||
sys.exit(run_backfill(_headless_locker(locker_cls).log_file, rest))
|
||||
|
||||
if "--sync-only" in argv:
|
||||
# Headless sync for the timer unit: pull other devices' workouts and
|
||||
# apply their credit, with no Tk and no lock screen. Sync used to run
|
||||
@ -73,8 +141,14 @@ def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None:
|
||||
record_no_decision("--sync-only")
|
||||
sys.exit(0)
|
||||
|
||||
locker = locker_cls(
|
||||
demo_mode="--production" not in argv,
|
||||
verify_only="--verify-workout" in argv,
|
||||
)
|
||||
if "--apply-bonuses" in argv:
|
||||
# earner-bonus.service, started by earner-bonus.path when a gate
|
||||
# writes its ledger: only the flat-bonus pass, no phone sync, so a
|
||||
# solve moves shutdown within seconds rather than at the next sync.
|
||||
_logger.info("Ledger changed: applying any newly earned flat bonus.")
|
||||
apply_flat_bonuses_if_new(SHUTDOWN_BASE_FILE, _headless_locker(locker_cls))
|
||||
record_no_decision("--apply-bonuses")
|
||||
sys.exit(0)
|
||||
|
||||
locker = locker_cls(demo_mode="--production" not in argv)
|
||||
locker.run()
|
||||
|
||||
@ -23,15 +23,13 @@ from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._sick_tracker import is_sick_day as _is_sick_day
|
||||
from screen_locker._sick import stands_down_workout
|
||||
from screen_locker._weekly_check import RELAXED_DAY_SKIP_TYPE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from screen_locker._sick_tracker import SickHistory
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -129,10 +127,11 @@ def is_early_bird_pending(pending_file: Path, *, today: str | None = None) -> bo
|
||||
return False
|
||||
|
||||
|
||||
def is_sick_day_today(history: SickHistory, *, today: str | None = None) -> bool:
|
||||
"""Return True if today is recorded as a sick day.
|
||||
def is_sick_day_today(today: date | None = None) -> bool:
|
||||
"""Return True if the sickdays pool switches the workout lock off that day.
|
||||
|
||||
Thin wrapper around ``_sick_tracker.is_sick_day`` kept here so callers of
|
||||
the status layer have one predicate module to import from.
|
||||
Kept here so callers of the status layer have one predicate module to
|
||||
import from; the answer itself is ``_sick.stands_down_workout``'s, so a
|
||||
sick day that keeps the workout locker reads False.
|
||||
"""
|
||||
return _is_sick_day(history, today=today)
|
||||
return stands_down_workout(today)
|
||||
|
||||
@ -6,7 +6,6 @@ Single source of truth for the leaf checks walked by
|
||||
mixins delegate their read-only checks to the functions here; this module
|
||||
never touches ADB, sudo, subprocess, or the network, and never writes to
|
||||
disk.
|
||||
|
||||
``explain_lock_decision`` re-derives the same ordered chain for the status
|
||||
view. One branch is a deliberate simplification: when an early-bird pending
|
||||
marker has expired, the real code attempts a live phone/RunnerUp
|
||||
@ -18,6 +17,7 @@ surfaces the pending opportunity separately via ``AutoUpgradeOpportunity``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@ -38,13 +38,13 @@ from screen_locker._compliance_trace import (
|
||||
describe_degraded_sources,
|
||||
)
|
||||
from screen_locker._day import today_str as local_day
|
||||
from screen_locker._hold import workout_hold_active
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from screen_locker._degraded_sources import DegradedSource
|
||||
from screen_locker._sick_tracker import SickHistory
|
||||
|
||||
__all__ = [
|
||||
"AutoUpgradeOpportunity",
|
||||
@ -60,14 +60,150 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Step:
|
||||
"""One predicate of the lock-decision chain, in evaluation order."""
|
||||
|
||||
name: str
|
||||
reason_true: str
|
||||
reason_false: str
|
||||
terminal_reason: str | None = None
|
||||
note_when_false: bool = False
|
||||
|
||||
|
||||
# Order mirrors ``_check_non_verify_exits`` / ``_check_today_state_exits``.
|
||||
# ``early_bird_pending_expired`` is recorded but never terminal on its own: an
|
||||
# expired pending marker still falls through toward a full lock, with the
|
||||
# upgrade opportunity surfaced separately (see module docstring).
|
||||
_CHAIN: tuple[_Step, ...] = (
|
||||
_Step(
|
||||
"scheduled_skip",
|
||||
"Today is in scheduled_skips.json (unconditional manual skip).",
|
||||
"Today is not a manually scheduled skip day.",
|
||||
"Manually scheduled skip day — no lock, no workout required.",
|
||||
),
|
||||
_Step(
|
||||
"early_bird_pending_expired",
|
||||
"Logged in during the early-bird window and it has since closed "
|
||||
"with no workout confirmed yet.",
|
||||
"No expired early-bird pending marker.",
|
||||
),
|
||||
_Step(
|
||||
"early_bird_window_open",
|
||||
"Logged in during the early-bird window earlier today; still "
|
||||
"waiting to see a workout before the window closes.",
|
||||
"Not pending inside an open early-bird window.",
|
||||
"Early-bird window still open — lock skipped while waiting for a workout.",
|
||||
),
|
||||
_Step(
|
||||
"sick_day",
|
||||
"Today is a sick day in the shared sickdays pool.",
|
||||
"Today is not a sick day.",
|
||||
"Sick day today — lock skipped.",
|
||||
),
|
||||
_Step(
|
||||
"already_logged",
|
||||
"A validly-signed workout is already logged for today.",
|
||||
"No workout logged for today yet.",
|
||||
"Workout already logged today — lock skipped.",
|
||||
note_when_false=True,
|
||||
),
|
||||
_Step(
|
||||
"wake_alarm_skip",
|
||||
"The morning session earned a workout skip for now.",
|
||||
"No morning-session skip in force.",
|
||||
"Morning session earned a skip — lock skipped.",
|
||||
),
|
||||
_Step(
|
||||
"test_hold_active",
|
||||
"A declared test hold is active.",
|
||||
"No test hold active.",
|
||||
"Test hold active — no workout window until it ends.",
|
||||
),
|
||||
_Step(
|
||||
"relaxed_day_already_skipped",
|
||||
"Relaxed day already dismissed via Skip — No Penalty today.",
|
||||
"No same-day relaxed-day dismissal on record.",
|
||||
"Relaxed day already skipped today — lock skipped, prompt not shown again.",
|
||||
),
|
||||
_Step(
|
||||
"relaxed_day",
|
||||
"Today is a relaxed day (Tue/Wed/Thu).",
|
||||
"Today is not a relaxed day.",
|
||||
"Relaxed day (Tue/Wed/Thu) — workout optional, lock skipped.",
|
||||
),
|
||||
_Step(
|
||||
"weekly_minimum_met",
|
||||
"Weekly workout minimum already reached.",
|
||||
"Weekly workout minimum not yet reached.",
|
||||
"Weekly workout minimum already met — lock skipped.",
|
||||
),
|
||||
_Step(
|
||||
"evening_lockdown_today",
|
||||
"Tonight's night lockdown already fired (>= 12:00 today).",
|
||||
"No evening night lockdown yet today.",
|
||||
"Night lockdown already happened tonight — the workout lock does not re-arm.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _chain_flags(
|
||||
*,
|
||||
log_file: Path,
|
||||
weekly_minimum_met: bool,
|
||||
relaxed_day: bool,
|
||||
wake_skip: bool,
|
||||
evening_lockdown: bool,
|
||||
local_dt: datetime,
|
||||
pending: bool,
|
||||
) -> dict[str, bool]:
|
||||
"""Evaluate every predicate once; the keys are the ``_CHAIN`` step names."""
|
||||
today_str = local_day(local_dt)
|
||||
# The early-bird window IS the wake-alarm carrot (EarlyBirdMixin).
|
||||
return {
|
||||
"scheduled_skip": is_scheduled_skip_today(local_dt.date()),
|
||||
"early_bird_pending_expired": pending and not wake_skip,
|
||||
"early_bird_window_open": pending and wake_skip,
|
||||
"sick_day": is_sick_day_today(local_dt.date()),
|
||||
"already_logged": has_logged_today(log_file, today=today_str),
|
||||
"wake_alarm_skip": wake_skip,
|
||||
"test_hold_active": workout_hold_active(local_dt),
|
||||
"relaxed_day_already_skipped": relaxed_day
|
||||
and is_relaxed_day_skipped_today(log_file, today=today_str),
|
||||
"relaxed_day": relaxed_day,
|
||||
"weekly_minimum_met": weekly_minimum_met,
|
||||
"evening_lockdown_today": evening_lockdown,
|
||||
}
|
||||
|
||||
|
||||
def _walk_chain(
|
||||
fired: dict[str, bool], context: StageContext, degraded_note: str
|
||||
) -> LockExplanation | None:
|
||||
"""Record each step in ``context.trace``; return the first terminal one."""
|
||||
for step in _CHAIN:
|
||||
hit = fired[step.name]
|
||||
reason = step.reason_true if hit else step.reason_false
|
||||
if step.note_when_false and not hit:
|
||||
reason += degraded_note
|
||||
context.trace.append(PredicateResult(step.name, hit, reason))
|
||||
if hit and step.terminal_reason is not None:
|
||||
return _stage(
|
||||
fired=False,
|
||||
stage=step.name,
|
||||
reason=step.terminal_reason,
|
||||
context=context,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def explain_lock_decision(
|
||||
*,
|
||||
log_file: Path,
|
||||
early_bird_pending_file: Path,
|
||||
sick_history: SickHistory,
|
||||
weekly_minimum_met: bool,
|
||||
relaxed_day: bool,
|
||||
wake_skip: bool = False,
|
||||
evening_lockdown: bool = False,
|
||||
now: datetime | None = None,
|
||||
degraded_sources: Sequence[DegradedSource] = (),
|
||||
) -> LockExplanation:
|
||||
@ -78,145 +214,25 @@ def explain_lock_decision(
|
||||
reported via ``heat_skip_evaluated=False``, not guessed.
|
||||
"""
|
||||
instant = now if now is not None else datetime.now(tz=UTC)
|
||||
today_str = local_day(instant)
|
||||
local_dt = instant.astimezone()
|
||||
|
||||
scheduled = is_scheduled_skip_today(local_dt.date())
|
||||
pending = is_early_bird_pending(early_bird_pending_file, today=today_str)
|
||||
# The early-bird window IS the wake-alarm carrot (EarlyBirdMixin).
|
||||
window_open = wake_skip
|
||||
expired = pending and not window_open
|
||||
early_bird_open_and_pending = pending and window_open
|
||||
sick_today = is_sick_day_today(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
|
||||
pending = is_early_bird_pending(early_bird_pending_file, today=local_day(instant))
|
||||
fired = _chain_flags(
|
||||
log_file=log_file,
|
||||
weekly_minimum_met=weekly_minimum_met,
|
||||
relaxed_day=relaxed_day,
|
||||
wake_skip=wake_skip,
|
||||
evening_lockdown=evening_lockdown,
|
||||
local_dt=instant.astimezone(),
|
||||
pending=pending,
|
||||
)
|
||||
|
||||
auto_upgrade = describe_auto_upgrade_opportunity(
|
||||
early_bird_pending=pending,
|
||||
early_bird_window_open=window_open,
|
||||
is_sick_day=sick_today,
|
||||
early_bird_window_open=wake_skip,
|
||||
)
|
||||
|
||||
trace: list[PredicateResult] = []
|
||||
stage_context = StageContext(trace, auto_upgrade, bool(degraded_sources))
|
||||
|
||||
def _check(
|
||||
name: str,
|
||||
*,
|
||||
fired: bool,
|
||||
reason_true: str,
|
||||
reason_false: str,
|
||||
terminal_reason: str | None = None,
|
||||
) -> LockExplanation | None:
|
||||
"""Record one trace step; return an explanation if it terminates the chain."""
|
||||
reason = reason_true if fired else reason_false
|
||||
trace.append(PredicateResult(name, fired, reason))
|
||||
if fired and terminal_reason is not None:
|
||||
return _stage(
|
||||
fired=False,
|
||||
stage=name,
|
||||
reason=terminal_reason,
|
||||
context=stage_context,
|
||||
)
|
||||
return None
|
||||
|
||||
result = _check(
|
||||
"scheduled_skip",
|
||||
fired=scheduled,
|
||||
reason_true="Today is in scheduled_skips.json (unconditional manual skip).",
|
||||
reason_false="Today is not a manually scheduled skip day.",
|
||||
terminal_reason="Manually scheduled skip day — no lock, no workout required.",
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# Recorded but never terminal on its own: an expired pending marker still
|
||||
# falls through toward a full lock, with the upgrade opportunity surfaced
|
||||
# separately via `auto_upgrade` (see module docstring).
|
||||
_check(
|
||||
"early_bird_pending_expired",
|
||||
fired=expired,
|
||||
reason_true="Logged in during the early-bird window and it has since closed "
|
||||
"with no workout confirmed yet.",
|
||||
reason_false="No expired early-bird pending marker.",
|
||||
)
|
||||
|
||||
result = _check(
|
||||
"early_bird_window_open",
|
||||
fired=early_bird_open_and_pending,
|
||||
reason_true="Logged in during the early-bird window earlier today; still "
|
||||
"waiting to see a workout before the window closes.",
|
||||
reason_false="Not pending inside an open early-bird window.",
|
||||
terminal_reason=(
|
||||
"Early-bird window still open — lock skipped while waiting for a workout."
|
||||
),
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
result = _check(
|
||||
"sick_day",
|
||||
fired=sick_today,
|
||||
reason_true="Today is marked as a sick day in sick_history.json.",
|
||||
reason_false="Today is not marked as a sick day.",
|
||||
terminal_reason="Sick day logged today — lock skipped.",
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
context = StageContext([], auto_upgrade, bool(degraded_sources))
|
||||
degraded_note = describe_degraded_sources(degraded_sources)
|
||||
result = _check(
|
||||
"already_logged",
|
||||
fired=logged,
|
||||
reason_true="A validly-signed workout is already logged for today.",
|
||||
reason_false="No workout logged for today yet." + degraded_note,
|
||||
terminal_reason="Workout already logged today — lock skipped.",
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
result = _check(
|
||||
"wake_alarm_skip",
|
||||
fired=wake_skip,
|
||||
reason_true="The morning session earned a workout skip for now.",
|
||||
reason_false="No morning-session skip in force.",
|
||||
terminal_reason="Morning session earned a skip — lock skipped.",
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
result = _check(
|
||||
"relaxed_day_already_skipped",
|
||||
fired=relaxed_day_already_skipped,
|
||||
reason_true="Relaxed day already dismissed via Skip — No Penalty today.",
|
||||
reason_false="No same-day relaxed-day dismissal on record.",
|
||||
terminal_reason="Relaxed day already skipped today — lock skipped, "
|
||||
"prompt not shown again.",
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
result = _check(
|
||||
"relaxed_day",
|
||||
fired=relaxed_day,
|
||||
reason_true="Today is a relaxed day (Tue/Wed/Thu).",
|
||||
reason_false="Today is not a relaxed day.",
|
||||
terminal_reason="Relaxed day (Tue/Wed/Thu) — workout optional, lock skipped.",
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
result = _check(
|
||||
"weekly_minimum_met",
|
||||
fired=weekly_minimum_met,
|
||||
reason_true="Weekly workout minimum already reached.",
|
||||
reason_false="Weekly workout minimum not yet reached.",
|
||||
terminal_reason="Weekly workout minimum already met — lock skipped.",
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
terminal = _walk_chain(fired, context, degraded_note)
|
||||
if terminal is not None:
|
||||
return terminal
|
||||
|
||||
# Named for the conclusion; "..._pending_heat_check" read as "stuck".
|
||||
return _stage(
|
||||
@ -228,5 +244,5 @@ def explain_lock_decision(
|
||||
"temperature is checked, and a run may or may not be happening "
|
||||
"right now (see Health)." + degraded_note
|
||||
),
|
||||
context=stage_context,
|
||||
context=context,
|
||||
)
|
||||
|
||||
@ -32,7 +32,7 @@ class AutoUpgradeOpportunity:
|
||||
"""Describes whether the real locker would attempt a live auto-upgrade."""
|
||||
|
||||
would_attempt: bool
|
||||
via: str # "early_bird_expired" | "sick_day" | "none"
|
||||
via: str # "early_bird_expired" | "none"
|
||||
reason: str
|
||||
|
||||
|
||||
@ -63,7 +63,6 @@ def describe_auto_upgrade_opportunity(
|
||||
*,
|
||||
early_bird_pending: bool,
|
||||
early_bird_window_open: bool,
|
||||
is_sick_day: bool,
|
||||
) -> AutoUpgradeOpportunity:
|
||||
"""Describe what the real ``_try_auto_upgrade_*`` methods would attempt.
|
||||
|
||||
@ -81,15 +80,6 @@ def describe_auto_upgrade_opportunity(
|
||||
"falling back to a full lock."
|
||||
),
|
||||
)
|
||||
if is_sick_day:
|
||||
return AutoUpgradeOpportunity(
|
||||
would_attempt=True,
|
||||
via="sick_day",
|
||||
reason=(
|
||||
"Sick day logged — the next lock run will try phone/RunnerUp "
|
||||
"verification to upgrade it to a real workout."
|
||||
),
|
||||
)
|
||||
return _NO_UPGRADE
|
||||
|
||||
|
||||
|
||||
@ -9,46 +9,31 @@ from gatelock.log_integrity import DEFAULT_HMAC_KEY_FILE
|
||||
|
||||
# Ephemeral runtime state (self-clears on reboot, same directory gatelock's
|
||||
# own Arbiter uses) -- not repo-tracked data, so it does not belong next to
|
||||
# log.json/sick_history.json below.
|
||||
# log.json below.
|
||||
INSTANCE_LOCK_FILE = default_runtime_dir() / "screen_locker-instance.lock"
|
||||
|
||||
# Single-sourced from gatelock so the literal key path can't drift.
|
||||
# Single-sourced from gatelock so the literal key path can't drift. The key is
|
||||
# root:root 0644 ON PURPOSE, never 0600: leetcode-guard, book-guard and
|
||||
# anki-guard run as user timers and sign their ledgers with it, and this
|
||||
# locker signs log.json, rest_days.json and the workout ledger as the user.
|
||||
# Nothing in this repo may chmod it; root ownership keeps it from being
|
||||
# replaced, which is what the signatures rely on.
|
||||
HMAC_KEY_FILE = DEFAULT_HMAC_KEY_FILE
|
||||
SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage)
|
||||
# The workout ledger (earned_time.WORKOUT.ledger): one signed credit row per
|
||||
# verified RunnerUp TCX credit and per rest day, resolved at call time.
|
||||
WORKOUT_LEDGER_RELATIVE = Path(".local/share/workout_locker/ledger.json")
|
||||
PHONE_PENALTY_DELAY_DEMO = 10
|
||||
PHONE_PENALTY_DELAY_PRODUCTION = 100
|
||||
# Penalty added to phone-penalty timer when ADB / phone unavailable
|
||||
# (so unplugging phone does not become an easy escape into sick mode).
|
||||
# (so unplugging the phone does not become an easy path to the manual log).
|
||||
NO_PHONE_EXTRA_LOCKOUT_SECONDS = 480 # extra 8 minutes on top of base
|
||||
# Sick day rate-limiting (rolling windows). Once any window is exhausted
|
||||
# the "I'm sick" button disappears entirely.
|
||||
SICK_BUDGET_PER_7_DAYS = 1
|
||||
SICK_BUDGET_PER_30_DAYS = 3
|
||||
SICK_BUDGET_PER_90_DAYS = 10
|
||||
# Each sick day in the trailing 30 days doubles the wait countdown.
|
||||
SICK_LOCKOUT_MULTIPLIER_PER_RECENT = 2
|
||||
# Minimum chars in the freeform sick justification.
|
||||
SICK_JUSTIFICATION_MIN_CHARS = 120
|
||||
# How many past sick justifications to show on the dialog (read-only).
|
||||
SICK_HISTORY_REVIEW_COUNT = 10
|
||||
# The sick screen lists past justifications one per line, at most this many
|
||||
# and each cut to this many characters: six full-length ones pushed the
|
||||
# screen 7 px past 1366x768, which the screens-fit gate only sees with a real
|
||||
# history on disk. The review *count* above is for the budget logic, not the
|
||||
# screen.
|
||||
SICK_SCREEN_RECENT_LINES = 4
|
||||
SICK_JUSTIFICATION_PREVIEW_CHARS = 36
|
||||
# Forced read-only delay before SUBMIT enables when a commitment was made.
|
||||
SICK_COMMITMENT_FORCED_READ_SECONDS = 5
|
||||
# Breaking a commitment counts as this many sick budget days.
|
||||
SICK_COMMITMENT_PENALTY_DAYS = 2
|
||||
# How long the commitment prompt stays visible after a workout unlock.
|
||||
COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15
|
||||
# Manual (unverified) workout rate-limiting — looser than sick days since
|
||||
# activities like table tennis recur more often than illness. Once either
|
||||
# Manual (unverified) workout rate-limiting. Once either
|
||||
# window is exhausted the "Log Manual Workout" option disappears entirely.
|
||||
MANUAL_WORKOUT_BUDGET_PER_7_DAYS = 2
|
||||
MANUAL_WORKOUT_BUDGET_PER_30_DAYS = 10
|
||||
# A manual workout may be logged AHEAD for a date up to this many days from
|
||||
# today (never a past date); it earns its credit on that date, not before.
|
||||
MANUAL_WORKOUT_MAX_DAYS_AHEAD = 7
|
||||
# Manual entries use the shared MIN_WORKOUT_DURATION_MINUTES bar below —
|
||||
# every workout type answers to the same minimum, whatever the sport.
|
||||
# Minimum chars for the "what was done" activity-details field.
|
||||
@ -68,13 +53,13 @@ WORKOUT_APP_JSON_REMOTES = (
|
||||
)
|
||||
# Port the workout app's HTTP server listens on (no ADB/developer-options needed).
|
||||
WORKOUT_HTTP_PORT = 8765
|
||||
# The ONE duration bar every workout type answers to — weightlifting, manual
|
||||
# entries and runs alike. This is the ADVERTISED number: it is the only one
|
||||
# that may ever appear in a message the user reads.
|
||||
MIN_WORKOUT_DURATION_MINUTES = 40
|
||||
# The ONE duration bar every activity answers to — weightlifting, manual
|
||||
# entries, runs and walks (summed moving time) alike. This is the ADVERTISED
|
||||
# number: it is the only one that may ever appear in a message the user reads.
|
||||
MIN_WORKOUT_DURATION_MINUTES = 30
|
||||
# ...and this is the leeway that is deliberately INVISIBLE. A session is
|
||||
# actually accepted at MIN_WORKOUT_DURATION_MINUTES - this, so 35 minutes
|
||||
# passes while every string still says 40. That gap is intentional (the user
|
||||
# actually accepted at MIN_WORKOUT_DURATION_MINUTES - this, so 25 minutes
|
||||
# passes while every string still says 30. That gap is intentional (the user
|
||||
# asked for it explicitly): do NOT "fix" the inconsistency by advertising the
|
||||
# real cutoff, and never interpolate WORKOUT_DURATION_ACCEPT_MINUTES into an
|
||||
# f-string.
|
||||
@ -82,17 +67,17 @@ WORKOUT_DURATION_LEEWAY_MINUTES = 5
|
||||
# Derived so the advertised bar and the accept bar cannot drift apart.
|
||||
# Comparisons use this; messages use MIN_WORKOUT_DURATION_MINUTES. Every
|
||||
# caller compares with `>=` — one operator across all workout types, so a
|
||||
# 35.0-minute session cannot pass one gate and fail another.
|
||||
# 25.0-minute session cannot pass one gate and fail another.
|
||||
WORKOUT_DURATION_ACCEPT_MINUTES = (
|
||||
MIN_WORKOUT_DURATION_MINUTES - WORKOUT_DURATION_LEEWAY_MINUTES
|
||||
)
|
||||
RUNNERUP_PACKAGES = ("org.runnerup", "org.runnerup.free")
|
||||
RUNNERUP_DB_SDCARD_TMP = "/sdcard/.runnerup_tmp_verification.db"
|
||||
# A run qualifies on EITHER criterion — they are an OR, not an AND. A short
|
||||
# but long-lasting run counts, and so does a fast 5 km that takes under the
|
||||
# duration bar. Do not reinstate an AND here: it would reject exactly the
|
||||
# slow-run case this pair exists to allow. The duration arm is the shared
|
||||
# MIN_WORKOUT_DURATION_MINUTES above; only the distance arm is run-specific.
|
||||
# A day's activity (run or walk alike) qualifies on EITHER criterion — they are
|
||||
# an OR, not an AND. A short but long-lasting session counts, and so does a
|
||||
# fast 5 km that takes under the duration bar. Do not reinstate an AND here:
|
||||
# it would reject exactly the slow case this pair exists to allow. The
|
||||
# duration arm is the shared MIN_WORKOUT_DURATION_MINUTES above (moving time).
|
||||
MIN_RUN_DISTANCE_KM = 3.5 # inclusive: "as low as 3.5 km"
|
||||
# GPS distance tracking undershoots on tight turns/tree cover/urban canyons;
|
||||
# a run within 5% of the minimum is accepted rather than rejected on
|
||||
@ -109,35 +94,44 @@ HEAT_SKIP_CITY: str = "Warsaw"
|
||||
SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf")
|
||||
# Helper script path (relative to this file)
|
||||
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
|
||||
# State file to track sick day usage and original config values
|
||||
SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json"
|
||||
# Persistent sick-day history (rate-limit, debt, commitments, justifications).
|
||||
# Distinct from SICK_DAY_STATE_FILE which is a one-day shutdown-config snapshot.
|
||||
SICK_HISTORY_FILE = Path(__file__).resolve().parent / "sick_history.json"
|
||||
# JSON list of ISO date strings ("YYYY-MM-DD") for which the screen lock is skipped.
|
||||
SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json"
|
||||
# State file tracking streak, skip credits, and early-bird extension weeks.
|
||||
EXTRA_BENEFITS_FILE = Path(__file__).resolve().parent / "extra_benefits_state.json"
|
||||
# State file storing the base (pre-bonus) shutdown hours and last reset date.
|
||||
SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json"
|
||||
# HMAC-signed rest days, declared ahead (see _rest_day), and how far the grace
|
||||
# floor has lifted today's shutdown above the earned time (see _grace_floor).
|
||||
REST_DAY_FILE = Path(__file__).resolve().parent / "rest_days.json"
|
||||
REST_DAYS_PER_ISO_WEEK = 2
|
||||
GRACE_STATE_FILE = Path(__file__).resolve().parent / "grace_floor.json"
|
||||
# Every GPS activity (run or walk, whatever its TCX sport) is judged the same
|
||||
# way: a day's activities count as the workout once their summed distance
|
||||
# reaches MIN_RUN_DISTANCE_KM OR their summed MOVING time reaches
|
||||
# WORKOUT_DURATION_ACCEPT_MINUTES. Moving = trackpoint pairs seen within
|
||||
# WALK_MAX_GAP_SECONDS and travelled at least this fast (slower is standing
|
||||
# still). A pair faster than MAX_MOVING_SPEED_MPS (25 km/h, the app's running
|
||||
# jump filter) is a vehicle or a GPS jump and counts neither moving time NOR
|
||||
# distance, for every activity. The Dart app mirrors the bounds.
|
||||
MIN_MOVING_SPEED_MPS = 0.5
|
||||
MAX_MOVING_SPEED_MPS = 25 / 3.6
|
||||
WALK_MAX_GAP_SECONDS = 30
|
||||
# Speed is judged over windows of at least this long (1 s fixes jitter), and a
|
||||
# window longer than WALK_MAX_GAP_SECONDS (a GPS dropout) only counts as
|
||||
# moving time at this speed or faster, so standing through a gap earns nothing.
|
||||
TRACK_WINDOW_SECONDS = 10
|
||||
MIN_DROPOUT_SPEED_MPS = 1.0
|
||||
# Workout records this PC must keep deleted in the sync store, pushed as
|
||||
# tombstones on every tick (see _sync_tombstones for why once is not enough).
|
||||
SYNC_TOMBSTONES_FILE = Path(__file__).resolve().parent / "sync_tombstones.json"
|
||||
# Future-dated manual workouts logged on THIS PC, re-published on every push
|
||||
# until their date (see _manual_pending for why a one-shot publish is erased).
|
||||
MANUAL_PENDING_FILE = Path(__file__).resolve().parent / "manual_pending.json"
|
||||
# Self-expiring marker: "logged in during today's early-bird window, still
|
||||
# waiting to see if a real workout shows up." Not a log.json entry —
|
||||
# it's a same-day pending flag, checked against its own "date" field.
|
||||
EARLY_BIRD_PENDING_FILE = Path(__file__).resolve().parent / "early_bird_pending.json"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wake-alarm integration (originally from wake_alarm._constants / _state).
|
||||
# These must match the values used by the companion wake_alarm service.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Days on which the wake alarm fires (0=Mon … 6=Sun).
|
||||
ALARM_DAYS: frozenset[int] = frozenset({0, 4, 5, 6})
|
||||
# How many hours after midnight the alarm triggers (configurable in wake_alarm).
|
||||
WAKE_AFTER_HOURS: int = 8
|
||||
# Path to the rtcwake binary.
|
||||
RTCWAKE_BIN: str = "/usr/sbin/rtcwake"
|
||||
# The PC's copy of the phone's morning session, written HMAC-signed by
|
||||
# wake-alarm (`python -m wake_alarm._session --refresh`, every 2 min through
|
||||
# the morning). Read here, never fetched: wake-alarm is the only Firebase
|
||||
@ -177,15 +171,12 @@ RUNNERUP_WEBDAV_DIRS: tuple[Path, ...] = (
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync (phone workout data) — Workstream C, built on the shared crdt-sync
|
||||
# library. GitHub is used purely as dumb file storage via the REST Contents
|
||||
# API (not a git clone), same pattern as diet_guard's cross-device sync. The
|
||||
# phone app pushes to devices/<SYNC_PHONE_DEVICE_ID>/log.json; the PC reads
|
||||
# every device's log and pushes its OWN manual workouts to devices/pc/log.json
|
||||
# (see _manual_push) — which needs a token with contents:write, not read-only.
|
||||
# Sync (phone workout data) — built on the shared crdt-sync library, Firebase
|
||||
# RTDB only since 2026-10-09 (the shared ~/.config/crdt-sync/ credential). The
|
||||
# old GitHub repo kuhyx/syncs/screen-locker-sync is a frozen archive: nothing
|
||||
# reads or writes it. Every device pushes to devices/<device id>/log.json; the
|
||||
# PC reads every device's log and pushes its OWN workouts (see _manual_push).
|
||||
# ---------------------------------------------------------------------------
|
||||
SYNC_REPO_OWNER: str = "kuhyx"
|
||||
SYNC_REPO_NAME: str = "syncs"
|
||||
# The phone's pre-uuid device id. Still read (the phone may not have migrated
|
||||
# yet), but no longer the only path the reader knows about -- it discovers
|
||||
# every device directory.
|
||||
@ -200,12 +191,6 @@ SYNC_LEGACY_DEVICE_ID: str | None = "pc"
|
||||
SYNC_DEVICE_ID_FILE: Path = (
|
||||
Path.home() / ".local" / "share" / "screen_locker" / ".device_id"
|
||||
)
|
||||
# A fine-grained GitHub PAT, scoped to just SYNC_REPO_NAME's contents. The
|
||||
# user creates this once via github.com and saves it here, mode 600. Never
|
||||
# committed — this path is outside the repo entirely. Unlike diet_guard,
|
||||
# this file being absent is a normal, expected state (sync is optional here).
|
||||
SYNC_TOKEN_FILE: Path = Path.home() / ".config" / "screen_locker" / "sync_token"
|
||||
|
||||
# Revision cache for cross-device sync. Without it every push re-uploads and
|
||||
# every pull re-downloads regardless of change, which is the traffic the
|
||||
# Firebase free tier's monthly budget depends on not happening.
|
||||
|
||||
125
screen_locker/_credit_notify.py
Normal file
125
screen_locker/_credit_notify.py
Normal file
@ -0,0 +1,125 @@
|
||||
"""Desktop notification the moment a workout earns its credit.
|
||||
|
||||
The reward is the point: a workout should be *felt* as paid the second the PC
|
||||
credits it, not discovered at the next look at the status window. Every path
|
||||
that credits a workout ends in
|
||||
:meth:`~screen_locker._workout_credit.WorkoutCreditMixin._apply_credit_for_written_entry`,
|
||||
which calls :func:`notify_workout_credit` once per newly earned credit slot.
|
||||
|
||||
The gaming figure is ``earned_time.resolve`` over screen-locker's own answers
|
||||
(:func:`screen_locker._shutdown_target.gather` -- the same sum the daily reset
|
||||
writes), so no budget math lives here. steam-backlog-enforcer resolves the
|
||||
same registry with its own transports and holds a day's high-water mark, so
|
||||
its figure can sit above this one, never below on a normal day.
|
||||
|
||||
A notification is a courtesy, never part of crediting: any failure (no DBus,
|
||||
no notify-send, a hang) is logged at warning and swallowed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import hhmm, span
|
||||
from screen_locker._grace_floor import tonight_minutes
|
||||
from screen_locker._shutdown_target import gather
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_logger: Final = logging.getLogger(__name__)
|
||||
|
||||
TITLE: Final = "Workout credited \N{FLEXED BICEPS}"
|
||||
_NOTIFY_TIMEOUT_SECONDS: Final = 5
|
||||
_MINUTES_PER_HOUR: Final = 60.0
|
||||
|
||||
|
||||
def gaming_minutes_today(log_file: Path | None) -> int | None:
|
||||
"""Today's gaming budget per the earned_time registry, or ``None``.
|
||||
|
||||
``None`` (logged) when the inputs cannot be read; the notification then
|
||||
shows the shutdown time alone.
|
||||
"""
|
||||
try:
|
||||
target = gather(date.fromisoformat(today_str()), log_file)
|
||||
except (OSError, ValueError, KeyError) as exc:
|
||||
_logger.warning(
|
||||
"Could not compute today's gaming budget for the credit "
|
||||
"notification (%s) — showing the shutdown time only",
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
return int(target.resolution.gaming_minutes)
|
||||
|
||||
|
||||
def credit_body(
|
||||
before: tuple[int, int, int] | None,
|
||||
after: tuple[int, int, int] | None,
|
||||
gaming_minutes: int | None,
|
||||
) -> str:
|
||||
"""``Shutdown 23:00 (+2h) · gaming 7.0h`` -- whichever parts are known.
|
||||
|
||||
``before``/``after`` are the shutdown config around the credit; the delta
|
||||
is tonight's actual move, so a capped or rest-day credit shows ``+0h``.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
if after is None:
|
||||
_logger.warning(
|
||||
"Shutdown config unreadable after a workout credit — the "
|
||||
"notification shows no shutdown time"
|
||||
)
|
||||
else:
|
||||
day = date.fromisoformat(today_str())
|
||||
tonight = tonight_minutes(after, day)
|
||||
text = f"Shutdown {hhmm(tonight)}"
|
||||
if before is not None:
|
||||
moved = max(0, tonight - tonight_minutes(before, day))
|
||||
text += f" (+{span(moved)})"
|
||||
parts.append(text)
|
||||
if gaming_minutes is not None:
|
||||
parts.append(f"gaming {gaming_minutes / _MINUTES_PER_HOUR:.1f}h")
|
||||
return " \N{MIDDLE DOT} ".join(parts) or "Credit applied (details unavailable)"
|
||||
|
||||
|
||||
def send_notification(title: str, body: str) -> bool:
|
||||
"""Show a desktop notification via ``notify-send``; False (logged) on failure."""
|
||||
binary = shutil.which("notify-send")
|
||||
if binary is None:
|
||||
_logger.warning("notify-send not found — no desktop notification: %s", body)
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[binary, "--app-name=screen-locker", title, body],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_NOTIFY_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
_logger.warning("Desktop notification failed (%s): %s", exc, body)
|
||||
return False
|
||||
if result.returncode:
|
||||
_logger.warning(
|
||||
"notify-send exited %d (%s) — no desktop notification: %s",
|
||||
result.returncode,
|
||||
result.stderr.strip() or "no stderr; is a DBus session reachable?",
|
||||
body,
|
||||
)
|
||||
return False
|
||||
_logger.info("Desktop notification: %s — %s", title, body)
|
||||
return True
|
||||
|
||||
|
||||
def notify_workout_credit(
|
||||
before: tuple[int, int, int] | None,
|
||||
after: tuple[int, int, int] | None,
|
||||
log_file: Path | None,
|
||||
) -> bool:
|
||||
"""Notify a newly earned workout credit. Never raises."""
|
||||
body = credit_body(before, after, gaming_minutes_today(log_file))
|
||||
return send_notification(TITLE, body)
|
||||
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.
|
||||
|
||||
``log.json``, ``sick_day_state.json``, ``early_bird_pending.json``,
|
||||
``wake_state.json`` and the sick history are all keyed by ``YYYY-MM-DD``.
|
||||
``log.json``, ``early_bird_pending.json`` and ``wake_state.json`` are all
|
||||
keyed by ``YYYY-MM-DD``.
|
||||
Until 2026-09-13 each writer and reader derived that key on its own, and
|
||||
most of them used ``datetime.now(tz=UTC)`` while the lock window, the phone
|
||||
app, RunnerUp verification and the status window used LOCAL time. In CEST
|
||||
|
||||
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:
|
||||
- Consecutive weeks with 5+ workouts (streak counter).
|
||||
- Banked shutdown-time bonus hours earned from extra workouts, applied
|
||||
every day of the following week. This never reduces enforcement (unlike
|
||||
a banked "skip a workout" credit would) — it only grants extra comfort
|
||||
time on top of a floor you still have to earn each day.
|
||||
- ISO weeks in which the early-bird window is extended to 09:00.
|
||||
- ISO weeks in which the early-bird window is extended to 09:00. Display-only
|
||||
bookkeeping: nothing reads it to decide a lock (early bird is wake-alarm's
|
||||
signed exemption since 2026-09-20).
|
||||
- The week-transition reward messages, for the status window.
|
||||
|
||||
There is no weekly shutdown-time bonus (dropped entirely 2026-10-10, hours
|
||||
already banked for 2026-W41 included): every shutdown minute comes from the
|
||||
``earned_time`` ladder and stops at its daily ceiling. A legacy
|
||||
``weekly_shutdown_bonus_hours`` map in an old state file is ignored, and
|
||||
dropped at the next week transition's write.
|
||||
|
||||
State is persisted in ``extra_benefits_state.json`` next to this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@ -25,8 +30,9 @@ if TYPE_CHECKING:
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_MILESTONE_INTERVAL = 4 # every 4-week streak → +1h extra shutdown bonus
|
||||
_BONUS_THRESHOLD = 5 # workouts/week required to earn extra rewards
|
||||
_BONUS_THRESHOLD = 5 # workouts/week required to extend the streak + early-bird
|
||||
# The removed weekly shutdown bonus's map; ignored on read, dropped on write.
|
||||
_LEGACY_BONUS_KEY = "weekly_shutdown_bonus_hours"
|
||||
|
||||
|
||||
def _load_state(state_file: Path) -> dict[str, Any]:
|
||||
@ -39,7 +45,7 @@ def _load_state(state_file: Path) -> dict[str, Any]:
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_logger.warning(
|
||||
"Could not read extra-benefits state from %s: %s — starting from "
|
||||
"empty state, so the workout streak and any banked bonus hours are "
|
||||
"empty state, so the workout streak and early-bird extension are "
|
||||
"lost for this run",
|
||||
state_file,
|
||||
exc,
|
||||
@ -56,38 +62,19 @@ def _save_state(state_file: Path, state: dict[str, Any]) -> None:
|
||||
_logger.warning("Failed to save extra benefits state: %s", exc)
|
||||
|
||||
|
||||
def _current_iso_week(now: datetime) -> str:
|
||||
def _current_iso_week(now: date) -> str:
|
||||
"""Return *now*'s ISO week as ``YYYY-Www``."""
|
||||
year, week, _ = now.isocalendar()
|
||||
return f"{year}-W{week:02d}"
|
||||
|
||||
|
||||
def preview_bonus_if_week_ended_now(
|
||||
current_week_count: int, current_streak: int
|
||||
) -> tuple[int, int]:
|
||||
"""(would_be_new_streak, would_be_bonus_hours) if the ISO week ended now.
|
||||
|
||||
Pure extraction of ``process_week_transition``'s threshold math — no file
|
||||
I/O, safe to call from a read-only status view for a "next week preview."
|
||||
"""
|
||||
if current_week_count < _BONUS_THRESHOLD:
|
||||
return 0, 0
|
||||
streak = current_streak + 1
|
||||
bonus_hours = current_week_count - 4
|
||||
if streak % _MILESTONE_INTERVAL == 0:
|
||||
bonus_hours += 1
|
||||
return streak, bonus_hours
|
||||
|
||||
|
||||
def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
||||
"""Process last week's results if we've entered a new ISO week.
|
||||
|
||||
Counts workouts from the previous ISO week. If count >= 5:
|
||||
- Increments the consecutive-streak counter.
|
||||
- Banks (count - 4) hours of shutdown-time bonus for the current week,
|
||||
applied once per day on top of the daily base reset.
|
||||
- Marks the *current* ISO week as having extended early-bird (09:00).
|
||||
- Adds +1h more bonus every ``_MILESTONE_INTERVAL`` streak weeks.
|
||||
Otherwise the streak resets to 0. No shutdown-time hours are banked.
|
||||
|
||||
Returns a list of human-readable reward strings (empty if no transition).
|
||||
"""
|
||||
@ -113,44 +100,38 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
||||
prev_week_count = count_weekly_workouts(log_file, today=prev_week_dt)
|
||||
|
||||
streak = int(state.get("consecutive_5plus_weeks", 0))
|
||||
weekly_bonus_hours: dict[str, int] = dict(
|
||||
state.get("weekly_shutdown_bonus_hours", {})
|
||||
)
|
||||
eb_weeks: list[str] = list(state.get("extended_early_bird_iso_weeks", []))
|
||||
|
||||
rewards: list[str] = []
|
||||
prev_year, prev_week, _ = sunday_prev_week.isocalendar()
|
||||
prev_week_str = f"{prev_year}-W{prev_week:02d}"
|
||||
prev_week_str = _current_iso_week(sunday_prev_week)
|
||||
|
||||
if prev_week_count >= _BONUS_THRESHOLD:
|
||||
extra = prev_week_count - 4
|
||||
streak, bonus_hours = preview_bonus_if_week_ended_now(prev_week_count, streak)
|
||||
streak += 1
|
||||
if current_week_str not in eb_weeks:
|
||||
eb_weeks.append(current_week_str)
|
||||
rewards.append(
|
||||
f"{prev_week_count} workouts in {prev_week_str}! "
|
||||
f"+{extra}h shutdown bonus this week, early-bird extended to 09:00"
|
||||
f"{prev_week_count} workouts in {prev_week_str}! {streak}-week streak"
|
||||
)
|
||||
if streak % _MILESTONE_INTERVAL == 0:
|
||||
rewards.append(f"{streak}-week streak milestone! +1h extra shutdown bonus")
|
||||
weekly_bonus_hours[current_week_str] = bonus_hours
|
||||
else:
|
||||
if streak > 0:
|
||||
rewards.append(f"Streak reset (was {streak} weeks of 5+ workouts)")
|
||||
streak = 0
|
||||
|
||||
# Merge onto the loaded state rather than rebuilding it from these four
|
||||
# Merge onto the loaded state rather than rebuilding it from these three
|
||||
# keys: sibling fields written by the recovery scripts (e.g.
|
||||
# ``shutdown_bonus_granted_for``, the guard that stops a compensation
|
||||
# bonus being granted twice) live in this file too, and rebuilding would
|
||||
# drop them at the next weekly rollover — silently re-arming a double-grant.
|
||||
# ``shutdown_bonus_granted_for``) live in this file too, and rebuilding
|
||||
# would drop them at the next weekly rollover. Only the dead weekly-bonus
|
||||
# map is dropped, so no reader can mistake it for live state.
|
||||
updated = dict(state)
|
||||
updated.pop(_LEGACY_BONUS_KEY, None)
|
||||
updated.update(
|
||||
{
|
||||
"consecutive_5plus_weeks": streak,
|
||||
"last_processed_iso_week": current_week_str,
|
||||
"weekly_shutdown_bonus_hours": weekly_bonus_hours,
|
||||
"extended_early_bird_iso_weeks": eb_weeks,
|
||||
# Kept for the status window (display only): the strings above
|
||||
# used to go to the log and nowhere else.
|
||||
"week_rewards": {"iso_week": current_week_str, "messages": rewards},
|
||||
}
|
||||
)
|
||||
_save_state(state_file, updated)
|
||||
@ -162,18 +143,6 @@ def current_streak(state_file: Path) -> int:
|
||||
return int(_load_state(state_file).get("consecutive_5plus_weeks", 0))
|
||||
|
||||
|
||||
def weekly_shutdown_bonus_hours(
|
||||
state_file: Path, *, today: datetime | None = None
|
||||
) -> int:
|
||||
"""Return the banked shutdown-time bonus (hours) for the current ISO week."""
|
||||
now = today if today is not None else datetime.now(tz=UTC).astimezone()
|
||||
current_week_str = _current_iso_week(now)
|
||||
bonus_hours: dict[str, int] = _load_state(state_file).get(
|
||||
"weekly_shutdown_bonus_hours", {}
|
||||
)
|
||||
return int(bonus_hours.get(current_week_str, 0))
|
||||
|
||||
|
||||
def has_extended_early_bird(state_file: Path, *, today: datetime | None = None) -> bool:
|
||||
"""Return True if the current ISO week has an extended early-bird window (09:00)."""
|
||||
now = today if today is not None else datetime.now(tz=UTC).astimezone()
|
||||
@ -182,3 +151,15 @@ def has_extended_early_bird(state_file: Path, *, today: datetime | None = None)
|
||||
"extended_early_bird_iso_weeks", []
|
||||
)
|
||||
return current_week_str in eb_weeks
|
||||
|
||||
|
||||
def week_rewards(state_file: Path, *, today: datetime | None = None) -> tuple[str, ...]:
|
||||
"""The reward messages this ISO week's transition produced, for display.
|
||||
|
||||
Empty once the week has passed, or when nothing was stored.
|
||||
"""
|
||||
now = today if today is not None else datetime.now(tz=UTC).astimezone()
|
||||
stored = _load_state(state_file).get("week_rewards", {})
|
||||
if not isinstance(stored, dict) or stored.get("iso_week") != _current_iso_week(now):
|
||||
return ()
|
||||
return tuple(str(m) for m in stored.get("messages", []))
|
||||
|
||||
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)
|
||||
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
|
||||
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 concurrent.futures import ( # pylint: disable=no-name-in-module
|
||||
from concurrent.futures import (
|
||||
ThreadPoolExecutor,
|
||||
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
|
||||
@ -1,183 +0,0 @@
|
||||
"""One extra shutdown hour on a day with an accepted LeetCode submission.
|
||||
|
||||
leetcode-guard never writes the shutdown config itself; it only publishes the
|
||||
fact through its ledger, the same way ``steam-backlog-enforcer`` reads it for
|
||||
an hour of gaming budget. The write has to live here because
|
||||
:func:`~screen_locker._shutdown_base.reset_to_base_if_new_day` recomputes the
|
||||
config from scratch on every new day -- a bonus written by another program
|
||||
would be wiped by that reset, exactly as a pre-tick workout credit was on
|
||||
2026-09-13. So the hour is a *term* of the derived target, not an increment
|
||||
that survives by luck.
|
||||
|
||||
Three things are pinned deliberately, mirroring leetcode-guard's own
|
||||
``solved_today`` contract:
|
||||
|
||||
**Only ``credit`` entries count.** A ``seen`` entry is first-run seeding worth
|
||||
zero, and a ``charge`` proves only that the day was *settled* -- which also
|
||||
happens from banked credit, from the escape hatch and from a classified
|
||||
outage, so it can never stand in for a solve.
|
||||
|
||||
**The solve time is ``detail["submitted_at"]``, not ``day``.** ``day`` is the
|
||||
*harvesting* run's date, and a problem solved at 23:50 and harvested the next
|
||||
morning carries the next day's key. It earns for the day it was solved on,
|
||||
which by then is over -- never for the next one.
|
||||
|
||||
**"Cannot check" is not "not solved".** An unreadable ledger or key yields
|
||||
``None``, and the caller fails closed to no hour. Failing open would let the
|
||||
coupling be defeated by deleting a file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, time
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from gatelock.log_integrity import verify_entry_hmac
|
||||
|
||||
from screen_locker._constants import HMAC_KEY_FILE
|
||||
|
||||
_logger: Final = logging.getLogger(__name__)
|
||||
|
||||
# Flat, once per day, regardless of how many problems were solved -- the
|
||||
# workout is the harder thing and keeps the bigger, per-session reward.
|
||||
LEETCODE_BONUS_HOURS: Final = 1
|
||||
|
||||
# leetcode-guard's real ledger (its DATA_DIR / "ledger.json"). Read-only from
|
||||
# here; resolved at call time so the test suite's path redirect applies.
|
||||
LEETCODE_LEDGER_FILE: Final = Path.home() / ".local/share/leetcode_guard/ledger.json"
|
||||
|
||||
_CREDIT: Final = "credit"
|
||||
|
||||
|
||||
def _today_window(now: datetime | None = None) -> tuple[float, float]:
|
||||
"""Local midnight and now, as unix seconds.
|
||||
|
||||
Args:
|
||||
now: Stand-in for the current local time, for tests.
|
||||
|
||||
Returns:
|
||||
The inclusive window a solve must fall in to count for today.
|
||||
"""
|
||||
moment = (now or datetime.now(tz=UTC)).astimezone()
|
||||
midnight = datetime.combine(moment.date(), time.min, moment.tzinfo)
|
||||
return midnight.timestamp(), moment.timestamp()
|
||||
|
||||
|
||||
def _landed_today(entry: dict[str, object], *, window: tuple[float, float]) -> bool:
|
||||
"""Whether this verified credit's accepted submission happened today.
|
||||
|
||||
Args:
|
||||
entry: One ledger entry, already known to be a verified credit.
|
||||
window: Local midnight and now, as unix seconds.
|
||||
|
||||
Returns:
|
||||
Whether the solve falls inside today. Without a usable
|
||||
``submitted_at`` the harvest ``day`` is the fallback: it runs late,
|
||||
never early, so it can miss a late solve but never invent one.
|
||||
"""
|
||||
detail = entry.get("detail")
|
||||
raw = detail.get("submitted_at") if isinstance(detail, dict) else None
|
||||
if raw is not None:
|
||||
try:
|
||||
stamp = float(str(raw))
|
||||
except ValueError:
|
||||
_logger.warning(
|
||||
"LeetCode credit %r has an unparsable submitted_at (%r); "
|
||||
"falling back to its day key",
|
||||
entry.get("entry_id"),
|
||||
raw,
|
||||
)
|
||||
else:
|
||||
start, end = window
|
||||
return start <= stamp <= end
|
||||
start, _ = window
|
||||
today = datetime.fromtimestamp(start).astimezone().date().isoformat()
|
||||
return entry.get("day") == today
|
||||
|
||||
|
||||
def _read_entries(ledger_path: Path, label: str = "LeetCode") -> list[object] | None:
|
||||
"""Read the ledger's entry list, or ``None`` when it cannot be read.
|
||||
|
||||
Args:
|
||||
ledger_path: leetcode-guard's (or book-guard's) ledger file.
|
||||
label: Whose ledger, for the log line.
|
||||
|
||||
Returns:
|
||||
The raw entries, or ``None`` -- which is never "no entries".
|
||||
"""
|
||||
try:
|
||||
raw = json.loads(ledger_path.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
_logger.warning("Cannot read the %s ledger at %s (%s)", label, ledger_path, exc)
|
||||
return None
|
||||
except ValueError as exc:
|
||||
_logger.warning(
|
||||
"%s ledger at %s is not valid JSON (%s)", label, ledger_path, exc
|
||||
)
|
||||
return None
|
||||
rows = raw.get("entries") if isinstance(raw, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
_logger.warning("%s ledger at %s has no entries array", label, ledger_path)
|
||||
return None
|
||||
return rows
|
||||
|
||||
|
||||
def leetcode_solved_today(
|
||||
ledger_path: Path | None = None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> bool | None:
|
||||
"""Whether a verified accepted submission landed today, locally.
|
||||
|
||||
Args:
|
||||
ledger_path: The ledger to read. None resolves
|
||||
:data:`LEETCODE_LEDGER_FILE` at call time.
|
||||
now: Stand-in for the current local time, for tests.
|
||||
|
||||
Returns:
|
||||
True or False when the ledger could be read and verified, ``None``
|
||||
when it could not -- which is never "nothing was solved".
|
||||
"""
|
||||
target = LEETCODE_LEDGER_FILE if ledger_path is None else ledger_path
|
||||
try:
|
||||
key_usable = bool(HMAC_KEY_FILE.read_bytes().strip())
|
||||
except OSError as exc:
|
||||
_logger.warning("Cannot read the integrity key at %s (%s)", HMAC_KEY_FILE, exc)
|
||||
return None
|
||||
if not key_usable:
|
||||
_logger.warning("Integrity key at %s is empty", HMAC_KEY_FILE)
|
||||
return None
|
||||
|
||||
rows = _read_entries(target)
|
||||
if rows is None:
|
||||
return None
|
||||
|
||||
window = _today_window(now)
|
||||
return any(
|
||||
isinstance(row, dict)
|
||||
and row.get("kind") == _CREDIT
|
||||
and verify_entry_hmac(row, key_file=HMAC_KEY_FILE)
|
||||
and _landed_today(row, window=window)
|
||||
for row in rows
|
||||
)
|
||||
|
||||
|
||||
def leetcode_bonus_hours(ledger_path: Path | None = None) -> int:
|
||||
"""The shutdown hours today's LeetCode solve has earned, failing closed.
|
||||
|
||||
Args:
|
||||
ledger_path: The ledger to read. None resolves the real one.
|
||||
|
||||
Returns:
|
||||
:data:`LEETCODE_BONUS_HOURS` on a day with a verified solve, else 0.
|
||||
A ledger that cannot be checked is 0 too, and logged -- it must never
|
||||
be mistaken for a confident "not solved", but it earns nothing either.
|
||||
"""
|
||||
solved = leetcode_solved_today(ledger_path)
|
||||
if solved is None:
|
||||
_logger.warning("LeetCode solve state could not be checked; no shutdown hour")
|
||||
return 0
|
||||
return LEETCODE_BONUS_HOURS if solved else 0
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
@ -11,9 +12,11 @@ from typing import TYPE_CHECKING
|
||||
from gatelock.log_integrity import compute_entry_hmac
|
||||
|
||||
from screen_locker import _compliance_state
|
||||
from screen_locker._bonus_lock import bonus_lock
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._manual_workout import MANUAL_WORKOUT_TYPE, manual_sync_record_id
|
||||
from screen_locker._workout_ledger import record_log_entry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
@ -111,7 +114,30 @@ def write_signed_entry(
|
||||
|
||||
Returns a :class:`RecordResult` so the credit path can tell an appended
|
||||
workout from a duplicate and a first-of-day workout from an additional one.
|
||||
|
||||
The read-dedup-write runs under an exclusive lock beside the log: the
|
||||
5-minute locker pass, the 15-minute sync and the RunnerUp upload watcher
|
||||
all backfill the same run, and two of them reading before either wrote
|
||||
would both see it missing -- two entries and, since only an appended
|
||||
result is credited, two shutdown rewards.
|
||||
"""
|
||||
with ExitStack() as stack:
|
||||
try:
|
||||
stack.enter_context(bonus_lock(log_file))
|
||||
except OSError as e:
|
||||
_logger.warning(
|
||||
"Could not lock workout log %s (%s) — writing without the lock; "
|
||||
"a pass running at the same moment could record this twice",
|
||||
log_file,
|
||||
e,
|
||||
)
|
||||
return _write_signed_entry_locked(log_file, date, workout_data)
|
||||
|
||||
|
||||
def _write_signed_entry_locked(
|
||||
log_file: Path, date: str, workout_data: Mapping[str, object]
|
||||
) -> RecordResult:
|
||||
"""Body of :func:`write_signed_entry`; the caller holds the log lock."""
|
||||
logs = load_workout_log(log_file)
|
||||
entries = logs.setdefault(date, [])
|
||||
prior = list(entries)
|
||||
@ -137,6 +163,10 @@ def write_signed_entry(
|
||||
json.dump(logs, f, indent=2)
|
||||
except OSError as e:
|
||||
_logger.warning("Could not save workout log: %s", e)
|
||||
else:
|
||||
# The one chokepoint every credit passes, so the signed workout
|
||||
# ledger (earned_time's view of the workout) can never miss one.
|
||||
record_log_entry(date, entries)
|
||||
return RecordResult(appended=True, prior_entries=prior)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
and workout_id dedup are the shared implementation rather than a second copy
|
||||
that can drift.
|
||||
|
||||
``--date`` may name a FUTURE day (up to 7 days ahead; past dates are refused).
|
||||
Such an entry is not ingested: it is held and published by
|
||||
``_manual_schedule`` and ingested -- and credited -- on its date by the
|
||||
ordinary sync pass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -22,6 +27,8 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._manual_future import is_future_date, validate_workout_date
|
||||
from screen_locker._manual_schedule import budget_error, schedule_future_workout
|
||||
from screen_locker._manual_sync import ingest_manual_records
|
||||
from screen_locker._manual_workout import (
|
||||
SPORT_CHOICES,
|
||||
@ -73,7 +80,14 @@ def add_manual_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
default="",
|
||||
help=f"what the activity was; required when --sport {SPORT_OTHER}",
|
||||
)
|
||||
parser.add_argument("--date", default="", help="ISO date; defaults to today")
|
||||
parser.add_argument(
|
||||
"--date",
|
||||
default="",
|
||||
help=(
|
||||
"ISO date, today up to 7 days ahead (no past dates); defaults to "
|
||||
"today. A future date is held and credited automatically on it."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--start", required=True, help="HH:MM")
|
||||
parser.add_argument("--end", required=True, help="HH:MM")
|
||||
parser.add_argument("--location", required=True)
|
||||
@ -114,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:
|
||||
"""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)
|
||||
return 1
|
||||
|
||||
record_id = manual_sync_record_id(date, args.start)
|
||||
payload = build_sync_payload(draft, date)
|
||||
|
||||
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,
|
||||
)
|
||||
date_error = validate_workout_date(date)
|
||||
if date_error is not None:
|
||||
_logger.error("Manual workout REFUSED — %s. Nothing was written.", date_error)
|
||||
return 1
|
||||
_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
|
||||
if is_future_date(date):
|
||||
return _schedule(log_file, draft, date)
|
||||
return _log_today(log_file, draft, date, args.start)
|
||||
|
||||
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
|
||||
crdt-sync log directly from it and pushes to ``devices/pc/log.json``, so the
|
||||
crdt-sync log directly from it and pushes to this device's log, so the
|
||||
phone converges on the SAME history the PC has — manual workouts *and*
|
||||
machine-verified ones (StrongLifts sessions, RunnerUp runs).
|
||||
|
||||
@ -10,11 +10,11 @@ workout is published automatically no matter when or how it was recorded,
|
||||
including entries logged before this module existed. Idempotence comes from two
|
||||
properties: the record id is the workout's own stable ``workout_id``, and its
|
||||
HLC is derived deterministically from the entry's own timestamp — so re-pushing
|
||||
an unchanged log produces a byte-identical record set and no repo churn.
|
||||
an unchanged log produces a byte-identical record set and no store churn.
|
||||
|
||||
This reverses the old "the PC only ever reads, never pushes" invariant (it had
|
||||
no data of its own to contribute). Pushing needs a sync token with
|
||||
**contents:write** on the repo — a read-only token 403s. Nothing here fails
|
||||
no data of its own to contribute). Firebase is the only transport since
|
||||
2026-10-09; the old GitHub mirror is a frozen archive. Nothing here fails
|
||||
silently: every path returns a :class:`PushResult` whose ``reason`` says exactly
|
||||
what happened, and logs it.
|
||||
"""
|
||||
@ -29,33 +29,24 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import (
|
||||
FileSyncStateStore,
|
||||
GitHubSyncClient,
|
||||
GitHubSyncError,
|
||||
Hlc,
|
||||
LogCodec,
|
||||
Record,
|
||||
RemoteSyncError,
|
||||
RepoNotFoundError,
|
||||
RevisionTracking,
|
||||
SyncTarget,
|
||||
sync_log,
|
||||
)
|
||||
|
||||
from screen_locker._constants import (
|
||||
SYNC_REPO_NAME,
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_STATE_FILE,
|
||||
SYNC_TIMEOUT_SECONDS,
|
||||
SYNC_TOKEN_FILE,
|
||||
)
|
||||
from screen_locker._constants import SYNC_STATE_FILE
|
||||
from screen_locker._device import device_identity
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._log_mixin import _derive_workout_id
|
||||
from screen_locker._push_outcome import describe_push
|
||||
from screen_locker._manual_pending import pending_ledger_records, settle_pending
|
||||
from screen_locker._sync_retry import with_sync_retry
|
||||
from screen_locker._sync_tombstones import tombstone_records
|
||||
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES
|
||||
from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token, remote_client
|
||||
from screen_locker._workout_sync import _DEVICES_PREFIX, sync_client_or_reason
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -158,6 +149,11 @@ def records_from_workout_log(log_file: Path) -> dict[str, Record]:
|
||||
log[str(record_id)] = Record(
|
||||
id=str(record_id), fields={_PAYLOAD_FIELD: (payload, hlc)}
|
||||
)
|
||||
# Future-dated manual workouts logged here are not in log.json until their
|
||||
# day; the ledger is what keeps them published (and visible to the phone's
|
||||
# budget and delete button). The log's own copy wins once ingested.
|
||||
for record_id, record in pending_ledger_records().items():
|
||||
log.setdefault(record_id, record)
|
||||
# Re-pushed every tick: sync_log never reads this device's own remote
|
||||
# log, so a tombstone written once is overwritten by the next push.
|
||||
log.update(tombstone_records())
|
||||
@ -172,34 +168,22 @@ def push_pc_workouts(log_file: Path) -> PushResult:
|
||||
the returned :class:`PushResult` and a WARNING say why a push did not happen.
|
||||
"""
|
||||
identity = device_identity()
|
||||
token = read_sync_token()
|
||||
if token is None:
|
||||
reason = f"no sync token at {SYNC_TOKEN_FILE}"
|
||||
_logger.warning(
|
||||
"Workouts NOT synced: %s — create a fine-grained GitHub PAT with "
|
||||
"contents:write on %s/%s and save it there (chmod 600)",
|
||||
reason,
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_REPO_NAME,
|
||||
)
|
||||
return PushResult(pushed=False, record_count=0, reason=reason)
|
||||
|
||||
log = records_from_workout_log(log_file)
|
||||
if not log:
|
||||
reason = f"no counted workouts in {log_file}"
|
||||
_logger.warning("Workouts NOT synced: %s", reason)
|
||||
return PushResult(pushed=False, record_count=0, reason=reason)
|
||||
|
||||
client = remote_client(
|
||||
GitHubSyncClient(
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_REPO_NAME,
|
||||
token,
|
||||
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
||||
client, reason = sync_client_or_reason()
|
||||
if client is None:
|
||||
# sync_client_or_reason has already said why, in full; this line
|
||||
# ties that cause to the records it just stranded.
|
||||
_logger.warning(
|
||||
"Workouts NOT synced: %d workout(s) stay local — %s", len(log), reason
|
||||
)
|
||||
)
|
||||
return PushResult(pushed=False, record_count=len(log), reason=reason)
|
||||
try:
|
||||
with_sync_retry(
|
||||
merged = with_sync_retry(
|
||||
lambda: sync_log(
|
||||
SyncTarget(
|
||||
client=client,
|
||||
@ -218,32 +202,17 @@ def push_pc_workouts(log_file: Path) -> PushResult:
|
||||
),
|
||||
description="push PC workouts",
|
||||
)
|
||||
except (GitHubSyncError, RemoteSyncError) as exc:
|
||||
except RemoteSyncError as exc:
|
||||
# Report what actually happened, nothing more: this used to assert
|
||||
# "a 403 means the token lacks contents:write" for every failure,
|
||||
# including a plain network error, which sent a 2026-07-20
|
||||
# investigation chasing a permissions bug that did not exist.
|
||||
reason = f"sync error: {exc}"
|
||||
# Report what actually happened. This used to assert "a 403 here means
|
||||
# the token lacks contents:write" for *every* failure, including a
|
||||
# plain network error — which sent a 2026-07-20 investigation chasing a
|
||||
# permissions bug that did not exist (the token could write fine; the
|
||||
# network was simply not up yet). Only RepoNotFoundError actually
|
||||
# implicates the repo or the token.
|
||||
if isinstance(exc, RepoNotFoundError):
|
||||
_logger.warning(
|
||||
"Workout sync push FAILED for %d workout(s): %s — check the "
|
||||
"token has contents:write on %s/%s",
|
||||
len(log),
|
||||
exc,
|
||||
SYNC_REPO_OWNER,
|
||||
SYNC_REPO_NAME,
|
||||
)
|
||||
else:
|
||||
_logger.warning(
|
||||
"Workout sync push FAILED for %d workout(s): %s",
|
||||
len(log),
|
||||
exc,
|
||||
)
|
||||
_logger.warning("Workout sync push FAILED for %d workout(s): %s", len(log), exc)
|
||||
return PushResult(pushed=False, record_count=len(log), reason=reason)
|
||||
|
||||
# Delegated so a half-landed push can never be logged as a clean one;
|
||||
# see _push_outcome for why that distinction is load-bearing.
|
||||
_, reason = describe_push(len(log))
|
||||
return PushResult(pushed=True, record_count=len(log), reason=reason)
|
||||
# A future entry deleted on the phone arrives as a tombstone in the merge;
|
||||
# one already ingested is in log.json. Either way it leaves the ledger.
|
||||
settle_pending(log_file, [rid for rid, record in merged.items() if record.deleted])
|
||||
_logger.info("Synced %d workout(s) to Firebase", len(log))
|
||||
return PushResult(pushed=True, record_count=len(log), reason="pushed")
|
||||
|
||||
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
|
||||
turns those synced payloads into ordinary signed ``manual_workout`` entries in
|
||||
``log.json`` so they count toward the weekly minimum and — via the
|
||||
optional ``on_ingested`` callback — earn the same shutdown/debt reward a
|
||||
optional ``on_ingested`` callback — earn the same shutdown reward a
|
||||
live-logged workout would.
|
||||
|
||||
Trust model: the PC never trusts the phone's derived fields. Every synced
|
||||
@ -21,8 +21,15 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._log_mixin import _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,
|
||||
@ -31,7 +38,6 @@ from screen_locker._manual_sync_draft import (
|
||||
from screen_locker._manual_workout import (
|
||||
MANUAL_WORKOUT_SYNC_KIND,
|
||||
build_entry,
|
||||
is_budget_exhausted,
|
||||
validate_manual_workout,
|
||||
)
|
||||
|
||||
@ -39,6 +45,9 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from pathlib import Path
|
||||
|
||||
from screen_locker._log_mixin import RecordResult
|
||||
from screen_locker._manual_workout import ManualWorkoutDraft
|
||||
|
||||
OnIngestedCallback = Callable[[dict, "list[dict]"], None]
|
||||
|
||||
__all__ = ["ingest_manual_records", "is_empty_stub", "reconstruct_draft"]
|
||||
@ -75,6 +84,108 @@ def _already_ingested(logs: dict[str, list[dict]], record_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _due_date(
|
||||
record_id: str, payload: Mapping[str, object], reference: str
|
||||
) -> str | None:
|
||||
"""Return the record's date if it is a manual record due for ingest, else None.
|
||||
|
||||
None covers every reason to leave it alone this pass: not a manual record,
|
||||
no/unparsable date (warned), or dated after ``reference`` -- deferred, and
|
||||
retried on every later pass so it lands on its date.
|
||||
"""
|
||||
if payload.get("kind") != MANUAL_WORKOUT_SYNC_KIND:
|
||||
return None
|
||||
date = payload.get("date")
|
||||
if not isinstance(date, str):
|
||||
_logger.warning("Manual record %s has no date — skipped", record_id)
|
||||
return None
|
||||
if parse_workout_date(date) is None:
|
||||
_logger.warning(
|
||||
"Manual record %s has an unparsable date %r — skipped", record_id, date
|
||||
)
|
||||
return None
|
||||
if is_future_date(date, today=reference):
|
||||
_logger.info(
|
||||
"Manual record %s is dated %s, after today (%s) — deferred; it "
|
||||
"is ingested and credited on its date",
|
||||
record_id,
|
||||
date,
|
||||
reference,
|
||||
)
|
||||
return None
|
||||
return date
|
||||
|
||||
|
||||
def _new_valid_draft(
|
||||
log_file: Path,
|
||||
record_id: str,
|
||||
payload: Mapping[str, object],
|
||||
reference: str,
|
||||
) -> tuple[str, ManualWorkoutDraft] | None:
|
||||
"""The due date and valid draft of a not-yet-ingested record, else ``None``."""
|
||||
date = _due_date(record_id, payload, reference)
|
||||
if date is None:
|
||||
return None
|
||||
if _already_ingested(load_workout_log(log_file), record_id):
|
||||
return None
|
||||
draft = _draft_or_report(record_id, payload)
|
||||
if draft is None:
|
||||
return None
|
||||
error = validate_manual_workout(draft)
|
||||
if error is not None:
|
||||
_logger.warning("Manual record %s invalid: %s", record_id, error)
|
||||
return None
|
||||
return date, draft
|
||||
|
||||
|
||||
def _ingest_one(
|
||||
log_file: Path,
|
||||
record_id: str,
|
||||
payload: Mapping[str, object],
|
||||
reference: str,
|
||||
pending: Mapping[str, str],
|
||||
) -> tuple[dict[str, object], RecordResult] | None:
|
||||
"""Validate, budget-check and append one record; ``None`` when skipped."""
|
||||
ready = _new_valid_draft(log_file, record_id, payload, reference)
|
||||
if ready is None:
|
||||
return None
|
||||
date, draft = ready
|
||||
over_budget = budget_error_for_date(
|
||||
log_file,
|
||||
date,
|
||||
[day for rid, day in pending.items() if rid != record_id],
|
||||
)
|
||||
if over_budget is not None:
|
||||
_logger.info(
|
||||
"Manual-workout budget exhausted — %s not ingested (%s)",
|
||||
record_id,
|
||||
over_budget,
|
||||
)
|
||||
return None
|
||||
entry = build_entry(draft)
|
||||
entry[_SYNC_ID_FIELD] = record_id
|
||||
# The wire id IS the workout's id: the device that logged it minted
|
||||
# ``manual:<date>T<start>`` once, and the publisher keys on the same
|
||||
# id. Without this the chokepoint re-derived one from ``date`` +
|
||||
# start, and when the two dates disagreed (a record whose day key was
|
||||
# later corrected to the local day) the same workout landed twice
|
||||
# under two ids -- and then consumed two manual-budget slots.
|
||||
entry["workout_id"] = record_id
|
||||
result = write_signed_entry(log_file, date, entry)
|
||||
if not result.appended:
|
||||
# This is dedup working, not credit being lost: the workout is
|
||||
# already in the log under this same workout_id. It was logged at
|
||||
# warning and re-reported on every 15-minute cycle, which read like
|
||||
# a recurring fault and buried the lines that did need attention.
|
||||
_logger.info(
|
||||
"Manual record %s is already logged under the same workout_id "
|
||||
"— skipped as a duplicate (its credit is already counted)",
|
||||
record_id,
|
||||
)
|
||||
return None
|
||||
return entry, result
|
||||
|
||||
|
||||
def ingest_manual_records(
|
||||
log_file: Path,
|
||||
records: Iterable[tuple[str, Mapping[str, object]]],
|
||||
@ -90,58 +201,39 @@ def ingest_manual_records(
|
||||
workouts). Idempotent: dedup by ``record_id`` here and by ``workout_id`` in
|
||||
the write chokepoint, so re-syncing the same record is a no-op.
|
||||
|
||||
A record dated AFTER ``today`` (default: the local day) is DEFERRED, not
|
||||
ingested: it stays in the sync store, earns nothing and is not written to
|
||||
``log.json`` (the weekly check, the budget, the status view and the
|
||||
shutdown derivation would all count a future day key early). It is retried
|
||||
on every later pass, so it is ingested -- and credited -- on its date.
|
||||
|
||||
The budget is counted against the workout's own date: the new entry fits
|
||||
only if every rolling window containing that date stays within its cap,
|
||||
over the credits in the log and every OTHER pending future record in
|
||||
``records`` (a future entry holds its slot from the moment it is logged).
|
||||
|
||||
When ``on_ingested`` is given, it's called ``on_ingested(entry, prior_entries)``
|
||||
right after each newly-appended record — ``prior_entries`` being that
|
||||
entry's own date's entries written before it — so the caller (see
|
||||
``screen_lock.ScreenLocker._ingest_synced_manual_workouts``) can apply the
|
||||
identical live-workout shutdown/debt reward. This applies regardless of
|
||||
identical live-workout shutdown reward. This applies regardless of
|
||||
whether ``date`` is today or back-dated: there's only one current shutdown
|
||||
config, so a back-dated sync still pushes it exactly as a live workout
|
||||
would have that day.
|
||||
|
||||
Returns the record ids actually ingested.
|
||||
"""
|
||||
batch = list(records)
|
||||
if not batch:
|
||||
return []
|
||||
reference = today or today_str()
|
||||
pending = pending_future_dates(log_file, batch, today=reference)
|
||||
ingested: list[str] = []
|
||||
for record_id, payload in records:
|
||||
if payload.get("kind") != MANUAL_WORKOUT_SYNC_KIND:
|
||||
continue
|
||||
date = payload.get("date")
|
||||
if not isinstance(date, str):
|
||||
_logger.warning("Manual record %s has no date — skipped", record_id)
|
||||
continue
|
||||
if _already_ingested(load_workout_log(log_file), record_id):
|
||||
continue
|
||||
draft = _draft_or_report(record_id, payload)
|
||||
if draft is None:
|
||||
continue
|
||||
error = validate_manual_workout(draft)
|
||||
if error is not None:
|
||||
_logger.warning("Manual record %s invalid: %s", record_id, error)
|
||||
continue
|
||||
if is_budget_exhausted(log_file, today=today):
|
||||
_logger.info("Manual-workout budget exhausted — %s not ingested", record_id)
|
||||
continue
|
||||
entry = build_entry(draft)
|
||||
entry[_SYNC_ID_FIELD] = record_id
|
||||
# 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,
|
||||
)
|
||||
for record_id, payload in batch:
|
||||
outcome = _ingest_one(log_file, record_id, payload, reference, pending)
|
||||
if outcome is None:
|
||||
continue
|
||||
entry, result = outcome
|
||||
ingested.append(record_id)
|
||||
if on_ingested is not None:
|
||||
on_ingested(entry, result.prior_entries)
|
||||
|
||||
@ -49,7 +49,7 @@ def is_empty_stub(payload: Mapping[str, object]) -> bool:
|
||||
produced an unbounded stream of identical warnings (four every 15 minutes),
|
||||
which is how the genuinely actionable lines got lost in the noise.
|
||||
"""
|
||||
return not (_SUBSTANTIVE_FIELDS & set(payload))
|
||||
return not _SUBSTANTIVE_FIELDS & set(payload)
|
||||
|
||||
|
||||
def _coerce_int(value: object) -> int:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""Manual (unverified) workout rate-limiting, validation, and entry-building.
|
||||
|
||||
Pure logic — no Tk imports. Unlike sick days, manual-workout entries live
|
||||
directly in ``log.json`` (not a separate history file): a manual
|
||||
Pure logic — no Tk imports. Manual-workout entries live directly in
|
||||
``log.json`` (not a separate history file): a manual
|
||||
entry is just another ``workout_data`` dict with ``type="manual_workout"``,
|
||||
so ``LogMixin.save_workout_log`` already HMAC-signs it for free and the
|
||||
weekly-minimum/bonus logic already counts it once wired into
|
||||
@ -20,12 +20,15 @@ import logging
|
||||
|
||||
from screen_locker._constants import (
|
||||
MANUAL_WORKOUT_DESCRIPTION_MIN_CHARS,
|
||||
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
||||
MANUAL_WORKOUT_RPE_MAX,
|
||||
MANUAL_WORKOUT_RPE_MIN,
|
||||
MIN_WORKOUT_DURATION_MINUTES,
|
||||
WORKOUT_DURATION_ACCEPT_MINUTES,
|
||||
)
|
||||
from screen_locker._manual_workout_checks import (
|
||||
validate_reflections,
|
||||
validate_required,
|
||||
)
|
||||
from screen_locker._manual_workout_types import (
|
||||
MANUAL_WORKOUT_TYPE,
|
||||
SPORT_CHOICES,
|
||||
@ -82,19 +85,9 @@ def _duration_minutes(draft: ManualWorkoutDraft) -> float | None:
|
||||
|
||||
def validate_manual_workout(draft: ManualWorkoutDraft) -> str | None:
|
||||
"""Return an error message if the draft is invalid, else None."""
|
||||
if draft.sport not in SPORT_CHOICES:
|
||||
return "Please choose a sport"
|
||||
|
||||
required = {
|
||||
"Start time": draft.start_time,
|
||||
"End time": draft.end_time,
|
||||
"Location name": draft.location_name,
|
||||
"Transport method": draft.transport_method,
|
||||
"Cost": draft.cost,
|
||||
}
|
||||
for label, value in required.items():
|
||||
if not value.strip():
|
||||
return f"{label} is required"
|
||||
error = validate_required(draft)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
duration = _duration_minutes(draft)
|
||||
if duration is None:
|
||||
@ -119,18 +112,7 @@ def validate_manual_workout(draft: ManualWorkoutDraft) -> str | None:
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
for label, value in (
|
||||
("What went well", draft.went_well),
|
||||
("What to improve", draft.to_improve),
|
||||
("Overall feeling", draft.overall_feeling),
|
||||
):
|
||||
if len(value.strip()) < MANUAL_WORKOUT_REFLECTION_MIN_CHARS:
|
||||
return (
|
||||
f"{label} must be at least {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} "
|
||||
f"characters (currently {len(value.strip())})"
|
||||
)
|
||||
|
||||
return None
|
||||
return validate_reflections(draft)
|
||||
|
||||
|
||||
def _validate_table_tennis(draft: ManualWorkoutDraft) -> str | None:
|
||||
@ -143,7 +125,7 @@ def _validate_table_tennis(draft: ManualWorkoutDraft) -> str | None:
|
||||
):
|
||||
if value < 0:
|
||||
return f"{label} cannot be negative"
|
||||
if draft.matches_won + draft.matches_lost == 0:
|
||||
if not draft.matches_won + draft.matches_lost:
|
||||
return "Enter at least one match played (won + lost)"
|
||||
if not draft.racket.strip():
|
||||
return "Racket is required"
|
||||
|
||||
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
|
||||
117
screen_locker/_manual_workout_date.py
Normal file
117
screen_locker/_manual_workout_date.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""Workout-date field of the manual-workout form, and the future-dated path.
|
||||
|
||||
Split out of :mod:`screen_locker._manual_workout_dialog` to keep every file
|
||||
under the 250-line cap; composed back into ``ManualWorkoutDialogMixin`` there,
|
||||
so both hosts (``ScreenLocker`` and ``StatusWindow``) are unchanged.
|
||||
|
||||
A manual workout may be dated today up to 7 days ahead. Today's takes the
|
||||
host's ordinary save path (which, on the lock screen, unlocks). A FUTURE one
|
||||
must not: it earns nothing today, so it is handed to ``_manual_schedule`` and
|
||||
the form shows a "scheduled" screen that returns to the host's cancelled hook
|
||||
-- never ``_on_manual_workout_saved``, which would unlock today.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._manual_future import (
|
||||
budget_closed_for_all_days,
|
||||
is_future_date,
|
||||
validate_workout_date,
|
||||
)
|
||||
from screen_locker._manual_pending import local_pending_records, pending_future_dates
|
||||
from screen_locker._manual_schedule import budget_error, schedule_future_workout
|
||||
from screen_locker._ui_widgets import disable_paste
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from screen_locker._manual_workout import ManualWorkoutDraft
|
||||
|
||||
|
||||
class ManualWorkoutDateMixin:
|
||||
"""Date row, budget gate and future-dated submission of the manual form."""
|
||||
|
||||
def _mw_budget_closed(self) -> bool:
|
||||
"""True if no day in today..today+7 has room (local ledger, no network)."""
|
||||
pending = pending_future_dates(self.log_file, local_pending_records())
|
||||
return budget_closed_for_all_days(self.log_file, pending.values())
|
||||
|
||||
def _mw_date_in_row(self, row: tk.Frame) -> None:
|
||||
"""Pack the workout-date entry (prefilled with today) into ``row``.
|
||||
|
||||
Called from the sport row rather than given a row of its own: the
|
||||
lock cannot scroll, and the form already fills a 768px panel
|
||||
(``scripts/verify_screen_fits.py``), so the field has to cost no height.
|
||||
"""
|
||||
var = tk.StringVar(value=today_str())
|
||||
self._mw_vars["date"] = var
|
||||
entry = tk.Entry(
|
||||
row,
|
||||
textvariable=var,
|
||||
width=11,
|
||||
font=self._colors.font("label"),
|
||||
bg=self._colors.palette.field_bg,
|
||||
fg=self._colors.palette.fg,
|
||||
insertbackground=self._colors.palette.fg,
|
||||
**self._colors.focus_kwargs(),
|
||||
)
|
||||
entry.pack(side="right", padx=self._colors.space("xs"))
|
||||
disable_paste(entry)
|
||||
tk.Label(
|
||||
row,
|
||||
text="Workout date (today to +7 days):",
|
||||
font=self._colors.font("label"),
|
||||
fg=self._colors.palette.fg,
|
||||
bg=self._colors.palette.bg,
|
||||
).pack(side="right", padx=self._colors.space("xs"))
|
||||
|
||||
def _mw_handled_by_date(self, draft: ManualWorkoutDraft) -> bool:
|
||||
"""Apply the date and budget rules; True if the submit is fully handled.
|
||||
|
||||
Handled means an error is on screen or a future entry was scheduled;
|
||||
False means the date is today and within budget, so the caller
|
||||
carries on with the host's ordinary save.
|
||||
"""
|
||||
date = self._mw_vars["date"].get().strip()
|
||||
error = validate_workout_date(date)
|
||||
future = error is None and is_future_date(date)
|
||||
if error is None and not future:
|
||||
error = budget_error(self.log_file, date)
|
||||
if error is not None:
|
||||
self._mw_error_label.config(text=error)
|
||||
return True
|
||||
if not future:
|
||||
return False
|
||||
result = schedule_future_workout(self.log_file, draft, date)
|
||||
if not result.scheduled:
|
||||
self._mw_error_label.config(text=result.reason)
|
||||
return True
|
||||
self._show_manual_workout_scheduled(result.reason)
|
||||
return True
|
||||
|
||||
def _show_manual_workout_scheduled(self, reason: str) -> None:
|
||||
"""Confirm a future entry and go back; it does NOT unlock anything."""
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"✓ Workout Scheduled",
|
||||
role="display",
|
||||
scale=1.3,
|
||||
color=self._colors.palette.success,
|
||||
pad="lg",
|
||||
)
|
||||
self._text(reason, role="subtitle", color=self._colors.palette.success)
|
||||
self._text(
|
||||
"Nothing is credited today. It counts by itself on that date, and "
|
||||
"can be deleted in the phone app until then.",
|
||||
color=self._colors.palette.muted,
|
||||
)
|
||||
row = self._button_row()
|
||||
self._button(
|
||||
row,
|
||||
"OK",
|
||||
bg=self._colors.palette.accent,
|
||||
command=self._on_manual_workout_cancelled,
|
||||
width=12,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
@ -23,6 +23,7 @@ from screen_locker import _manual_workout
|
||||
from screen_locker._constants import (
|
||||
MANUAL_WORKOUT_REFLECTION_MIN_CHARS,
|
||||
)
|
||||
from screen_locker._manual_workout_date import ManualWorkoutDateMixin
|
||||
from screen_locker._manual_workout_sport_fields import ManualWorkoutSportFieldsMixin
|
||||
from screen_locker._manual_workout_widgets import ManualWorkoutFormWidgetsMixin
|
||||
|
||||
@ -34,6 +35,7 @@ _SPORT_LABEL_TO_CODE = {
|
||||
|
||||
|
||||
class ManualWorkoutDialogMixin(
|
||||
ManualWorkoutDateMixin,
|
||||
ManualWorkoutFormWidgetsMixin,
|
||||
ManualWorkoutSportFieldsMixin,
|
||||
):
|
||||
@ -43,7 +45,7 @@ class ManualWorkoutDialogMixin(
|
||||
"""Render the manual-workout evidence form, or a budget-exhausted note."""
|
||||
self.clear_container()
|
||||
self._label("Log Manual Workout", color=self._colors.palette.accent, pad="xs")
|
||||
if _manual_workout.is_budget_exhausted(self.log_file):
|
||||
if self._mw_budget_closed():
|
||||
self._text(
|
||||
"Manual-workout budget exhausted for this window.",
|
||||
color=self._colors.palette.danger,
|
||||
@ -212,6 +214,8 @@ class ManualWorkoutDialogMixin(
|
||||
if error is not None:
|
||||
self._mw_error_label.config(text=error)
|
||||
return
|
||||
if self._mw_handled_by_date(draft):
|
||||
return
|
||||
entry = _manual_workout.build_entry(draft)
|
||||
# Record the manual for cross-device sync (published on the next lock
|
||||
# startup). Local + best-effort — never blocks saving/unlocking.
|
||||
|
||||
@ -75,6 +75,8 @@ class ManualWorkoutSportFieldsMixin:
|
||||
selectcolor=self._colors.palette.field_bg,
|
||||
highlightthickness=0,
|
||||
).pack(side="left", padx=self._colors.space("xs"))
|
||||
# The workout-date field rides in this row, at no extra height.
|
||||
self._mw_date_in_row(row)
|
||||
self._mw_grid(parent, row, full=True)
|
||||
|
||||
def _on_mw_sport_changed(self, selected_label: str) -> None:
|
||||
|
||||
@ -58,6 +58,32 @@ def _parse_iso(date_str: str) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
def manual_credit_dates(log_file: Path) -> list[str]:
|
||||
"""Return the ``YYYY-MM-DD`` of every manual-workout CREDIT in the log.
|
||||
|
||||
One element per workout, decided by
|
||||
:func:`screen_locker._weekly_check.credit_key` -- the single rule every
|
||||
counter shares -- so a synced copy of a workout already logged here is the
|
||||
same credit, not a second budget slot (2026-09-13: a manual rekeyed to its
|
||||
local day, then pulled back in under a fresh workout_id, consumed two
|
||||
slots while the weekly count correctly saw one). Dates that do not parse
|
||||
are warned about by :func:`_parse_iso` and left out.
|
||||
"""
|
||||
dates: list[str] = []
|
||||
for date_str, entries in load_workout_log(log_file).items():
|
||||
if _parse_iso(date_str) is None:
|
||||
continue
|
||||
siblings = day_workout_index(entries)
|
||||
keys = {
|
||||
key
|
||||
for index, entry in enumerate(entries)
|
||||
if (key := credit_key(date_str, index, entry, siblings)) is not None
|
||||
and key[0] == MANUAL_WORKOUT_TYPE
|
||||
}
|
||||
dates.extend([date_str] * len(keys))
|
||||
return dates
|
||||
|
||||
|
||||
def count_in_window(
|
||||
log_file: Path,
|
||||
days: int,
|
||||
@ -69,37 +95,24 @@ def count_in_window(
|
||||
Counted per workout, not per day: each logged manual workout consumes its
|
||||
own budget slot, matching how each one also earns its own weekly-count
|
||||
and shutdown credit (see ``screen_locker._weekly_check.COUNTED_WORKOUT_TYPES``
|
||||
and ``screen_locker._workout_credit``) — the budget is the sole limiter on
|
||||
how many you can log, not a once-per-day collapse.
|
||||
and ``screen_locker._workout_credit``) -- the budget is the sole limiter on
|
||||
how many you can log, not a once-per-day collapse. See
|
||||
:func:`manual_credit_dates` for what "one workout" means.
|
||||
|
||||
"One workout" is decided by :func:`screen_locker._weekly_check.credit_key`,
|
||||
the single rule every counter must share. This used to count raw entries
|
||||
instead, so a synced copy of a workout already in the log (2026-09-13: a
|
||||
manual rekeyed to its local day, then pulled back in under a fresh
|
||||
workout_id) consumed a second budget slot while the weekly count, going
|
||||
through ``credit_key``, correctly saw one — and the budget read exhausted
|
||||
for a week over a workout that was logged once.
|
||||
The window is the ``days`` days ending at ``today`` (inclusive); entries
|
||||
dated after today are NOT in it. Whether a new entry fits is a different
|
||||
question, answered per workout date by ``_manual_future``.
|
||||
"""
|
||||
today_str = today or _today_iso()
|
||||
today_dt = _parse_iso(today_str)
|
||||
day = today or _today_iso()
|
||||
today_dt = _parse_iso(day)
|
||||
if today_dt is None:
|
||||
return 0
|
||||
cutoff = today_dt - timedelta(days=days)
|
||||
count = 0
|
||||
for date_str, entries in load_workout_log(log_file).items():
|
||||
parsed = _parse_iso(date_str)
|
||||
if parsed is None or not (cutoff < parsed <= today_dt):
|
||||
continue
|
||||
siblings = day_workout_index(entries)
|
||||
count += len(
|
||||
{
|
||||
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
|
||||
}
|
||||
)
|
||||
return count
|
||||
return sum(
|
||||
1
|
||||
for date_str in manual_credit_dates(log_file)
|
||||
if (parsed := _parse_iso(date_str)) is not None and cutoff < parsed <= today_dt
|
||||
)
|
||||
|
||||
|
||||
def is_budget_exhausted(
|
||||
|
||||
@ -84,7 +84,7 @@ class ManualWorkoutFormWidgetsMixin:
|
||||
"""Add a half-width label+entry cell and return its backing StringVar.
|
||||
|
||||
Builds its own cell inline (rather than reusing ``_add_label_entry``,
|
||||
which is pack-based and shared with the sick dialog) so it slots into
|
||||
which is pack-based) so it slots into
|
||||
the two-column grid. Paste stays disabled via ``disable_paste``.
|
||||
|
||||
Args:
|
||||
|
||||
@ -23,8 +23,8 @@ Safety invariants (do not break when adding tools):
|
||||
calls must never write to stdout. All logging is routed to STDERR below,
|
||||
and no tool calls ``screen_lock.py`` / ``status_view.main`` (which
|
||||
``print`` / open Tk windows / ``sys.exit``).
|
||||
* **No secret ever leaves.** No tool returns the sync token
|
||||
(``~/.config/screen_locker/sync_token``) or any HMAC key; the predicates
|
||||
* **No secret ever leaves.** No tool returns the Firebase session
|
||||
(``~/.config/screen_locker/firebase_auth.json``) or any HMAC key; the predicates
|
||||
load only status/state and return booleans, and the status snapshot
|
||||
carries no secret material.
|
||||
"""
|
||||
@ -49,7 +49,6 @@ from screen_locker._compliance_state import (
|
||||
from screen_locker._constants import (
|
||||
EARLY_BIRD_PENDING_FILE,
|
||||
)
|
||||
from screen_locker._sick_tracker import load_history
|
||||
from screen_locker._status_data import format_summary_line, gather_status
|
||||
from screen_locker._status_projection import compliance_state_word
|
||||
|
||||
@ -64,7 +63,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
mcp = MCPServer("screen-locker")
|
||||
|
||||
# Every tool here only reads local state: the workout log, the sick history
|
||||
# Every tool here only reads local state: the workout log, the sickdays pool
|
||||
# and the clock. Saying so lets a client run them without asking -- and the
|
||||
# day one of them stops being read-only, this annotation is the thing that
|
||||
# has to change with it. (See the section banner below: "never mutate" was
|
||||
@ -92,7 +91,7 @@ def get_status() -> dict[str, Any]:
|
||||
|
||||
A JSON-friendly projection of ``gather_status()``: today's and this week's
|
||||
workout outcomes, the shutdown-time projection, the lock-decision trace,
|
||||
and the rolling sick-day / manual-workout budgets. Reads on-disk state
|
||||
and the rolling manual-workout budget. Reads on-disk state
|
||||
only (no ADB, sudo, or network) and never mutates anything.
|
||||
"""
|
||||
return asdict(gather_status())
|
||||
@ -137,13 +136,16 @@ def get_flags() -> dict[str, bool]:
|
||||
``scheduled_skips.json``: it is now the shared, fleet-wide free-day pool
|
||||
(``~/src/utils/freedays``), so a true here also means diet-guard, wake-alarm,
|
||||
leetcode-guard and home-guard are standing down today.
|
||||
|
||||
``is_sick_day_today`` reads the shared ``sickdays`` pool and answers "is the
|
||||
workout lock switched off today": a sick day that keeps the ``workout``
|
||||
locker reads False. Never a screen-locker file.
|
||||
"""
|
||||
history = load_history()
|
||||
return {
|
||||
"has_logged_today": has_logged_today(_LOG_FILE),
|
||||
"is_scheduled_skip_today": is_scheduled_skip_today(),
|
||||
"is_early_bird_pending": is_early_bird_pending(EARLY_BIRD_PENDING_FILE),
|
||||
"is_sick_day_today": is_sick_day_today(history),
|
||||
"is_sick_day_today": is_sick_day_today(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,101 +1,33 @@
|
||||
"""Read the morning-session carrot the wake-alarm service signed for today.
|
||||
|
||||
The phone's morning (weigh-in, shower, dress, desk) is published to the PC
|
||||
and distilled by wake-alarm into ``MORNING_SESSION_FILE``. This module never
|
||||
re-derives the policy: the file carries a signed ``exempt_until``, and the
|
||||
only decision here is *HMAC ok, dated today, now < exempt_until*. Anything
|
||||
else -- missing file, stale date, failed session, bad signature -- is "no
|
||||
skip", i.e. today's ordinary ladder.
|
||||
|
||||
The one wrinkle is a PC booted mid-morning: the file is missing or yesterday's
|
||||
until wake-alarm's ``Persistent=true`` timer catches up, which needs the
|
||||
network. The enforce path waits for that, bounded; status paths never do.
|
||||
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
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock.log_integrity import verify_entry_hmac
|
||||
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
|
||||
from screen_locker._day import today_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
__all__ = ["MorningSkip", "has_workout_skip_today", "morning_skip_today"]
|
||||
|
||||
# Only inside this local window is a non-today file worth waiting for: the
|
||||
# refresher runs 05:00-11:00, so outside it "not today's" is simply the truth.
|
||||
MORNING_WINDOW: tuple[tuple[int, int], tuple[int, int]] = ((5, 0), (11, 0))
|
||||
MORNING_RETRY_SECONDS: float = 30.0
|
||||
MORNING_POLL_SECONDS: float = 5.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MorningSkip:
|
||||
"""A skip the morning session earned: what the phone said, and until when."""
|
||||
|
||||
outcome: str
|
||||
exempt_until: datetime
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.outcome} session, no lock until {self.exempt_until:%H:%M}"
|
||||
|
||||
|
||||
def _read_verified() -> dict[str, object] | None:
|
||||
"""The file's entry when it exists, parses and verifies; else None.
|
||||
|
||||
Missing is debug (it is the normal state outside the morning); anything
|
||||
else is a warning, because it means the refresher or the key is broken.
|
||||
"""
|
||||
if not MORNING_SESSION_FILE.exists():
|
||||
_logger.debug("No morning session file at %s", MORNING_SESSION_FILE)
|
||||
return None
|
||||
try:
|
||||
entry = json.loads(MORNING_SESSION_FILE.read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_logger.warning("Cannot read %s: %s", MORNING_SESSION_FILE, exc)
|
||||
return None
|
||||
if not isinstance(entry, dict) or not verify_entry_hmac(entry):
|
||||
_logger.warning("Morning session file is not a signed object")
|
||||
return None
|
||||
return entry
|
||||
|
||||
|
||||
def _load_today(now: datetime) -> dict[str, object] | None:
|
||||
"""Today's verified entry, or None."""
|
||||
entry = _read_verified()
|
||||
if entry is None or entry.get("date") != today_str(now):
|
||||
return None
|
||||
return entry
|
||||
|
||||
|
||||
def _skip_from(entry: dict[str, object], now: datetime) -> MorningSkip | None:
|
||||
"""The skip an entry grants at ``now``, if its window is still open."""
|
||||
until = entry.get("exempt_until")
|
||||
if not isinstance(until, str):
|
||||
return None
|
||||
try:
|
||||
exempt_until = datetime.fromisoformat(until)
|
||||
except ValueError:
|
||||
_logger.warning("Morning session exempt_until unreadable: %r", until)
|
||||
return None
|
||||
if now >= exempt_until:
|
||||
return None
|
||||
return MorningSkip(outcome=str(entry.get("outcome")), exempt_until=exempt_until)
|
||||
|
||||
|
||||
def _in_window(now: datetime) -> bool:
|
||||
(start_h, start_m), (end_h, end_m) = MORNING_WINDOW
|
||||
minutes = now.hour * 60 + now.minute
|
||||
return start_h * 60 + start_m <= minutes < end_h * 60 + end_m
|
||||
MORNING_RETRY_SECONDS: float = _SHARED_RETRY_SECONDS
|
||||
|
||||
|
||||
def morning_skip_today(
|
||||
@ -107,26 +39,16 @@ def morning_skip_today(
|
||||
"""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 for up to ``MORNING_RETRY_SECONDS`` so a
|
||||
freshly booted PC gives wake-alarm's catch-up run a chance to land.
|
||||
the morning window is retried so a freshly booted PC gives wake-alarm's
|
||||
catch-up run a chance to land.
|
||||
"""
|
||||
now = (now or datetime.now(tz=UTC)).astimezone()
|
||||
entry = _load_today(now)
|
||||
waited = 0.0
|
||||
while entry is None and wait and _in_window(now) and waited < MORNING_RETRY_SECONDS:
|
||||
sleep(MORNING_POLL_SECONDS)
|
||||
waited += MORNING_POLL_SECONDS
|
||||
now = datetime.now(tz=UTC).astimezone()
|
||||
entry = _load_today(now)
|
||||
if entry is None:
|
||||
if waited:
|
||||
_logger.warning(
|
||||
"No morning session for today after %.0fs; "
|
||||
"is wake-alarm-session.timer running?",
|
||||
waited,
|
||||
)
|
||||
return None
|
||||
return _skip_from(entry, now)
|
||||
return morning_skip(
|
||||
MORNING_SESSION_FILE,
|
||||
now,
|
||||
wait=wait,
|
||||
retry_seconds=MORNING_RETRY_SECONDS,
|
||||
sleep=sleep,
|
||||
)
|
||||
|
||||
|
||||
def has_workout_skip_today() -> bool:
|
||||
|
||||
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
|
||||
|
||||
@ -28,6 +28,18 @@ _HTTP_OK = _http_client.OK
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _in_current_week(date_str: str) -> bool:
|
||||
"""True if *date_str* parses and falls in this ISO week up to today."""
|
||||
today = datetime.now(tz=UTC).astimezone().date()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
try:
|
||||
entry_date = date.fromisoformat(date_str)
|
||||
except ValueError:
|
||||
_logger.warning("StrongLifts JSON has an unparsable date: %r", date_str)
|
||||
return False
|
||||
return week_start <= entry_date <= today
|
||||
|
||||
|
||||
class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
"""Mixin providing phone-based workout verification via ADB."""
|
||||
|
||||
@ -78,8 +90,10 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
if duration_min < WORKOUT_DURATION_ACCEPT_MINUTES:
|
||||
return (
|
||||
"too_short",
|
||||
f"Workout too short! {duration_min:.0f} min logged, "
|
||||
f"need at least {MIN_WORKOUT_DURATION_MINUTES} min.",
|
||||
(
|
||||
f"Workout too short! {duration_min:.0f} min logged, "
|
||||
f"need at least {MIN_WORKOUT_DURATION_MINUTES} min."
|
||||
),
|
||||
)
|
||||
flag = "all succeeded" if data.get("succeeded") else "partial"
|
||||
return "verified", f"Workout verified! ({duration_min:.0f} min, {flag})"
|
||||
@ -117,16 +131,7 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
return 0
|
||||
data = self._pull_workout_app_json()
|
||||
date_str = data.get("date") if data else None
|
||||
if not date_str:
|
||||
return 0
|
||||
today = datetime.now(tz=UTC).astimezone().date()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
try:
|
||||
entry_date = date.fromisoformat(date_str)
|
||||
except ValueError:
|
||||
_logger.warning("StrongLifts JSON has an unparsable date: %r", date_str)
|
||||
return 0
|
||||
if not week_start <= entry_date <= today:
|
||||
if not date_str or not _in_current_week(date_str):
|
||||
return 0
|
||||
status, msg = self._validate_json_content(data)
|
||||
if status != "verified":
|
||||
@ -143,8 +148,35 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
|
||||
# ── HTTP fallback (no ADB / developer options required) ───────────────────
|
||||
|
||||
def _pull_sync_result(self) -> tuple[tuple[str, str] | None, str | None]:
|
||||
"""Firebase sync first: the validated result (if any) and sync error.
|
||||
|
||||
Firebase sync is the primary channel -- it works without the phone
|
||||
being on the same network as the PC. Only a *verified* result
|
||||
short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the
|
||||
phone's last push predates today, or a later push failed offline)
|
||||
must not preempt a fresher ADB pull, so anything less than
|
||||
"verified" is kept by the caller as a fallback-of-last-resort
|
||||
candidate. A sync error never blocks unlocking either; `sync_failed`
|
||||
only surfaces if ADB/HTTP also come up empty.
|
||||
"""
|
||||
synced_data, sync_error = pull_synced_workout()
|
||||
if synced_data is None:
|
||||
if sync_error is not None:
|
||||
_logger.info(
|
||||
"Firebase sync unavailable (%s) — trying ADB/HTTP...", sync_error
|
||||
)
|
||||
return None, sync_error
|
||||
sync_result = self._validate_json_data(synced_data)
|
||||
if sync_result[0] != "verified":
|
||||
_logger.info(
|
||||
"Synced data not fully verified (%s) — trying ADB/HTTP...",
|
||||
sync_result[0],
|
||||
)
|
||||
return sync_result, sync_error
|
||||
|
||||
def _verify_phone_workout(self) -> tuple[str, str]:
|
||||
"""Verify today's workout: GitHub sync first, ADB/HTTP as fallback.
|
||||
"""Verify today's workout: Firebase sync first, ADB/HTTP as fallback.
|
||||
|
||||
Returns (status, message). Status values:
|
||||
verified / too_short / not_verified / no_phone / sync_failed /
|
||||
@ -154,28 +186,9 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
if not clock_ok:
|
||||
return "clock_tampered", clock_msg
|
||||
|
||||
# GitHub sync is the primary channel — it works without the phone
|
||||
# being on the same network as the PC. Only a *verified* sync result
|
||||
# short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the
|
||||
# phone's last push predates today, or a later push failed offline)
|
||||
# must not preempt a fresher ADB pull, so anything less than
|
||||
# "verified" is kept as a fallback-of-last-resort candidate instead
|
||||
# of being returned immediately. A sync error never blocks unlocking
|
||||
# either; `sync_failed` only surfaces if ADB/HTTP also come up empty.
|
||||
synced_data, sync_error = pull_synced_workout()
|
||||
sync_result: tuple[str, str] | None = None
|
||||
if synced_data is not None:
|
||||
sync_result = self._validate_json_data(synced_data)
|
||||
if sync_result[0] == "verified":
|
||||
return sync_result
|
||||
_logger.info(
|
||||
"Synced data not fully verified (%s) — trying ADB/HTTP...",
|
||||
sync_result[0],
|
||||
)
|
||||
elif sync_error is not None:
|
||||
_logger.info(
|
||||
"GitHub sync unavailable (%s) — trying ADB/HTTP...", sync_error
|
||||
)
|
||||
sync_result, sync_error = self._pull_sync_result()
|
||||
if sync_result is not None and sync_result[0] == "verified":
|
||||
return sync_result
|
||||
|
||||
# Prefer ADB when a device is visible, but if the pull yields no usable
|
||||
# JSON, fall through to the HTTP/WiFi scan — the app's in-memory HTTP
|
||||
@ -192,13 +205,23 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
data = self._fetch_http_workout()
|
||||
if data is not None:
|
||||
return self._validate_json_data(data)
|
||||
if sync_result is not None:
|
||||
return sync_result
|
||||
if sync_error is not None:
|
||||
return "sync_failed", sync_error
|
||||
if adb_connected:
|
||||
return (
|
||||
"not_verified",
|
||||
"Workout app JSON not found. Complete a workout in the app first.",
|
||||
)
|
||||
return "no_phone", "Phone not reachable via ADB or HTTP on local network"
|
||||
return _no_data_result(sync_result, sync_error, adb_connected=adb_connected)
|
||||
|
||||
|
||||
def _no_data_result(
|
||||
sync_result: tuple[str, str] | None,
|
||||
sync_error: str | None,
|
||||
*,
|
||||
adb_connected: bool,
|
||||
) -> tuple[str, str]:
|
||||
"""The verdict when neither ADB nor HTTP produced a workout JSON."""
|
||||
if sync_result is not None:
|
||||
return sync_result
|
||||
if sync_error is not None:
|
||||
return "sync_failed", sync_error
|
||||
if adb_connected:
|
||||
return (
|
||||
"not_verified",
|
||||
"Workout app JSON not found. Complete a workout in the app first.",
|
||||
)
|
||||
return "no_phone", "Phone not reachable via ADB or HTTP on local network"
|
||||
|
||||
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
|
||||
@ -1,106 +0,0 @@
|
||||
"""One extra shutdown hour on a day with a credited paper-book reading session.
|
||||
|
||||
book-guard (``~/src/book-guard``) publishes reading through its ledger, the
|
||||
same way leetcode-guard publishes solves, and steam-backlog-enforcer reads the
|
||||
same fact for an hour of gaming. Like the LeetCode hour it is a *term* of the
|
||||
daily derivation in :mod:`screen_locker._shutdown_base`, never an increment
|
||||
another program writes into the config.
|
||||
|
||||
The contract, pinned in book-guard's ``_quiz.record_verdict``:
|
||||
|
||||
**Only ``credit`` rows count**, and only those with ``detail["bonus"] ==
|
||||
"1"`` -- book-guard sets that when the session passed its quiz *and* was big
|
||||
enough (20+ pages, 20+ minutes). A ``reject`` is a failed quiz; an
|
||||
``escape`` forgives a lock and earns nothing.
|
||||
|
||||
**The reading time is ``detail["ended_at"]``**, not the row's creation: a
|
||||
session read at 22:30 and quizzed next morning earns for the evening it was
|
||||
read, which by then is over -- never for the quiz day.
|
||||
|
||||
**"Cannot check" is not "did not read"**: an unreadable ledger or key yields
|
||||
``None`` and the caller fails closed to no hour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from gatelock.log_integrity import verify_entry_hmac
|
||||
|
||||
from screen_locker._constants import HMAC_KEY_FILE
|
||||
from screen_locker._leetcode_bonus import _read_entries, _today_window
|
||||
|
||||
_logger: Final = logging.getLogger(__name__)
|
||||
|
||||
READING_BONUS_HOURS: Final = 1
|
||||
|
||||
# book-guard's real ledger. Read-only from here; resolved at call time so the
|
||||
# test suite's path redirect (tests/_isolated_state.py) applies.
|
||||
READING_LEDGER_FILE: Final = Path.home() / ".local/share/book_guard/ledger.json"
|
||||
|
||||
|
||||
def _read_today(entry: dict[str, object], *, window: tuple[float, float]) -> bool:
|
||||
"""Whether this verified credit is bonus-eligible and was read today."""
|
||||
detail = entry.get("detail")
|
||||
if not isinstance(detail, dict) or detail.get("bonus") != "1":
|
||||
return False
|
||||
try:
|
||||
ended = float(str(detail.get("ended_at")))
|
||||
except ValueError:
|
||||
_logger.warning(
|
||||
"reading credit %r has no usable ended_at", entry.get("entry_id")
|
||||
)
|
||||
return False
|
||||
start, end = window
|
||||
return start <= ended <= end
|
||||
|
||||
|
||||
def read_today(
|
||||
ledger_path: Path | None = None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> bool | None:
|
||||
"""Whether a verified, bonus-eligible reading session ended today, locally.
|
||||
|
||||
Returns:
|
||||
True or False when the ledger could be read and verified, ``None``
|
||||
when it could not -- which is never "did not read".
|
||||
"""
|
||||
target = READING_LEDGER_FILE if ledger_path is None else ledger_path
|
||||
try:
|
||||
key_usable = bool(HMAC_KEY_FILE.read_bytes().strip())
|
||||
except OSError as exc:
|
||||
_logger.warning("Cannot read the integrity key at %s (%s)", HMAC_KEY_FILE, exc)
|
||||
return None
|
||||
if not key_usable:
|
||||
_logger.warning("Integrity key at %s is empty", HMAC_KEY_FILE)
|
||||
return None
|
||||
if not target.exists():
|
||||
return False # book-guard never registered a book: no reading, no fault
|
||||
rows = _read_entries(target, "book-guard")
|
||||
if rows is None:
|
||||
return None
|
||||
window = _today_window(now or datetime.now(tz=UTC))
|
||||
return any(
|
||||
isinstance(row, dict)
|
||||
and row.get("kind") == "credit"
|
||||
and verify_entry_hmac(row, key_file=HMAC_KEY_FILE)
|
||||
and _read_today(row, window=window)
|
||||
for row in rows
|
||||
)
|
||||
|
||||
|
||||
def reading_bonus_hours(ledger_path: Path | None = None) -> int:
|
||||
"""The shutdown hours today's reading has earned, failing closed.
|
||||
|
||||
A ledger that does not exist yet (book-guard never ran) is a plain 0,
|
||||
logged by :func:`_read_entries` like any unreadable one.
|
||||
"""
|
||||
done = read_today(ledger_path)
|
||||
if done is None:
|
||||
_logger.warning("Reading state could not be checked; no shutdown hour")
|
||||
return 0
|
||||
return READING_BONUS_HOURS if done else 0
|
||||
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,9 +12,10 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from screen_locker._log_mixin import write_signed_entry
|
||||
from screen_locker._walk_tcx import activity_day, activity_shortfall
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@ -27,6 +28,24 @@ if TYPE_CHECKING:
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _record(
|
||||
log_file: Path,
|
||||
date_str: str,
|
||||
workout_data: dict[str, Any],
|
||||
on_ingested: OnIngestedCallback | None,
|
||||
) -> bool:
|
||||
"""Append one verified RunnerUp entry and credit it; False if a duplicate."""
|
||||
result = write_signed_entry(log_file, date_str, workout_data)
|
||||
if not result.appended:
|
||||
return False
|
||||
_logger.info(
|
||||
"Auto-filled RunnerUp entry for %s: %s", date_str, workout_data["source"]
|
||||
)
|
||||
if on_ingested is not None:
|
||||
on_ingested(workout_data, result.prior_entries)
|
||||
return True
|
||||
|
||||
|
||||
class RunnerUpBackfillMixin:
|
||||
"""Fills the log from RunnerUp exports for a date or a whole week."""
|
||||
|
||||
@ -53,34 +72,48 @@ class RunnerUpBackfillMixin:
|
||||
|
||||
Returns True if a new verified entry was appended for ``date_str``.
|
||||
"""
|
||||
for remote in self._find_runnerup_exports_for_date(date_str):
|
||||
data = self._pull_and_parse_tcx(remote)
|
||||
if data is None:
|
||||
continue
|
||||
status, msg = self._validate_runnerup_data(data)
|
||||
if status != "verified":
|
||||
_logger.warning(
|
||||
"RunnerUp export %s for %s did not qualify (%s): %s",
|
||||
remote,
|
||||
date_str,
|
||||
status,
|
||||
msg,
|
||||
)
|
||||
continue
|
||||
workout_data = {
|
||||
"type": "runnerup_verified",
|
||||
"source": f"Auto-scanned: {msg}",
|
||||
"distance_km": round(data["distance_m"] / 1000, 2),
|
||||
"duration_minutes": round(data["duration_seconds"] / 60, 1),
|
||||
}
|
||||
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, msg)
|
||||
if on_ingested is not None:
|
||||
on_ingested(workout_data, result.prior_entries)
|
||||
return True
|
||||
return False
|
||||
activities = [
|
||||
data
|
||||
for remote in self._find_runnerup_exports_for_date(date_str)
|
||||
if (data := self._pull_and_parse_tcx(remote)) is not None
|
||||
]
|
||||
return self._try_fill_day(date_str, activities, log_file, on_ingested)
|
||||
|
||||
@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
|
||||
|
||||
@ -151,9 +151,11 @@ class RunnerUpDbMixin:
|
||||
if db_path is None:
|
||||
return (
|
||||
"not_verified",
|
||||
"No TCX export found and root DB access is unavailable on this "
|
||||
"device — enable RunnerUp's File Synchronizer auto-export so "
|
||||
"runs land in /sdcard/Documents/RunnerUp/",
|
||||
(
|
||||
"No TCX export found and root DB access is unavailable on "
|
||||
"this device — enable RunnerUp's File Synchronizer "
|
||||
"auto-export so runs land in /sdcard/Documents/RunnerUp/"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
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
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from screen_locker._walk_tcx import is_walking_export, track_summary
|
||||
|
||||
# TCX XML namespace used by Garmin/RunnerUp.
|
||||
_TCX_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
||||
|
||||
@ -48,8 +50,9 @@ class RunnerUpTcxMixin:
|
||||
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 self._parse_tcx(remote_path)
|
||||
return _mark_walk(self._parse_tcx(remote_path), name)
|
||||
tmp_dir = tempfile.mkdtemp(prefix="runnerup_tcx_")
|
||||
local_path = str(Path(tmp_dir) / "activity.tcx")
|
||||
try:
|
||||
@ -57,48 +60,62 @@ class RunnerUpTcxMixin:
|
||||
if not ok or not Path(local_path).exists():
|
||||
_logger.info("Failed to pull TCX file: %s", remote_path)
|
||||
return None
|
||||
return self._parse_tcx(local_path)
|
||||
return _mark_walk(self._parse_tcx(local_path), name)
|
||||
finally:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
def _parse_tcx(self, tcx_path: str) -> dict[str, Any] | None:
|
||||
"""Parse a local TCX file and return activity summary dict.
|
||||
"""Parse a local TCX file (see :func:`parse_tcx_file`)."""
|
||||
return parse_tcx_file(tcx_path)
|
||||
|
||||
Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so
|
||||
multi-segment runs (pause/resume) are counted in full.
|
||||
"""
|
||||
try:
|
||||
tree = ET.parse(tcx_path)
|
||||
except ET.ParseError as exc:
|
||||
_logger.warning(
|
||||
"TCX file %s is not valid XML (%s) — the run it describes CANNOT "
|
||||
"be verified and will not count",
|
||||
tcx_path,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
root = tree.getroot()
|
||||
activity = root.find(f".//{{{_TCX_NS}}}Activity")
|
||||
if activity is None:
|
||||
_logger.info("No Activity element in TCX file")
|
||||
return None
|
||||
def parse_tcx_file(tcx_path: str) -> dict[str, Any] | None:
|
||||
"""Parse a local TCX file and return activity summary dict.
|
||||
|
||||
sport_str = activity.get("Sport", "")
|
||||
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
|
||||
Sums ``TotalTimeSeconds`` and ``DistanceMeters`` across all Laps so
|
||||
multi-segment runs (pause/resume) are counted in full.
|
||||
"""
|
||||
try:
|
||||
tree = ET.parse(tcx_path)
|
||||
except ET.ParseError as exc:
|
||||
_logger.warning(
|
||||
"TCX file %s is not valid XML (%s) — the run it describes CANNOT "
|
||||
"be verified and will not count",
|
||||
tcx_path,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
total_seconds = 0.0
|
||||
total_distance = 0.0
|
||||
for lap in activity.findall(f"{{{_TCX_NS}}}Lap"):
|
||||
t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds")
|
||||
d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters")
|
||||
if t_elem is not None and t_elem.text:
|
||||
total_seconds += float(t_elem.text)
|
||||
if d_elem is not None and d_elem.text:
|
||||
total_distance += float(d_elem.text)
|
||||
root = tree.getroot()
|
||||
activity = root.find(f".//{{{_TCX_NS}}}Activity")
|
||||
if activity is None:
|
||||
_logger.info("No Activity element in TCX file")
|
||||
return None
|
||||
|
||||
return {
|
||||
"sport": sport_int,
|
||||
"duration_seconds": int(total_seconds),
|
||||
"distance_m": total_distance,
|
||||
}
|
||||
sport_str = activity.get("Sport", "")
|
||||
sport_int = _TCX_SPORT_TO_INT.get(sport_str, -1)
|
||||
|
||||
total_seconds = 0.0
|
||||
total_distance = 0.0
|
||||
for lap in activity.findall(f"{{{_TCX_NS}}}Lap"):
|
||||
t_elem = lap.find(f"{{{_TCX_NS}}}TotalTimeSeconds")
|
||||
d_elem = lap.find(f"{{{_TCX_NS}}}DistanceMeters")
|
||||
if t_elem is not None and t_elem.text:
|
||||
total_seconds += float(t_elem.text)
|
||||
if d_elem is not None and d_elem.text:
|
||||
total_distance += float(d_elem.text)
|
||||
|
||||
return {
|
||||
"sport": sport_int,
|
||||
"duration_seconds": int(total_seconds),
|
||||
"distance_m": total_distance,
|
||||
"walking": is_walking_export(Path(tcx_path).name, sport_str),
|
||||
**track_summary(activity),
|
||||
}
|
||||
|
||||
|
||||
def _mark_walk(data: dict[str, Any] | None, name: str) -> dict[str, Any] | None:
|
||||
"""Flag a walk by its ORIGINAL file name: an adb pull renames the copy."""
|
||||
if data is not None:
|
||||
data["walking"] = data["walking"] or is_walking_export(name, "")
|
||||
return data
|
||||
|
||||
@ -12,30 +12,26 @@ from typing import Any
|
||||
|
||||
from screen_locker._constants import (
|
||||
MIN_RUN_DISTANCE_KM,
|
||||
MIN_WORKOUT_DURATION_MINUTES,
|
||||
RUNNERUP_DISTANCE_TOLERANCE,
|
||||
RUNNERUP_EXPORT_DIRS,
|
||||
RUNNERUP_WEBDAV_DIRS,
|
||||
WORKOUT_DURATION_ACCEPT_MINUTES,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._runnerup_backfill import RunnerUpBackfillMixin
|
||||
from screen_locker._runnerup_db import RunnerUpDbMixin
|
||||
from screen_locker._runnerup_tcx import RunnerUpTcxMixin
|
||||
from screen_locker._time_check import check_clock_skew
|
||||
from screen_locker._walk_tcx import (
|
||||
SPORT_NAMES,
|
||||
activity_day,
|
||||
activity_shortfall,
|
||||
distance_qualifies,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_SPORT_NAMES: dict[int, str] = {
|
||||
0: "Running",
|
||||
1: "Biking",
|
||||
2: "Other",
|
||||
3: "Orienteering",
|
||||
4: "Walking",
|
||||
5: "Treadmill",
|
||||
6: "Gym",
|
||||
7: "Stationary Bike",
|
||||
}
|
||||
# Kept for importers; the labels live next to the one crediting rule.
|
||||
_SPORT_NAMES = SPORT_NAMES
|
||||
|
||||
# TCX uses sport name strings; map back to integer codes for unified validation.
|
||||
|
||||
@ -120,63 +116,46 @@ class RunnerUpVerificationMixin(
|
||||
)
|
||||
return None
|
||||
|
||||
# Try each file; return the best result (verified > validation error).
|
||||
best: tuple[str, str] | None = None
|
||||
for remote in exports:
|
||||
data = self._pull_and_parse_tcx(remote)
|
||||
if data is None:
|
||||
continue
|
||||
status, msg = self._validate_runnerup_data(data)
|
||||
if status == "verified":
|
||||
return status, msg
|
||||
if best is None:
|
||||
best = (status, msg)
|
||||
|
||||
# All files found but none passed validation.
|
||||
return best or (
|
||||
"not_verified",
|
||||
"RunnerUp TCX export found but could not be read",
|
||||
)
|
||||
# One rule for the whole day, whatever the sport or file name: the
|
||||
# day's activities are summed (see _walk_tcx), never judged one file
|
||||
# at a time.
|
||||
activities = [
|
||||
data
|
||||
for remote in exports
|
||||
if (data := self._pull_and_parse_tcx(remote)) is not None
|
||||
]
|
||||
if not activities:
|
||||
return (
|
||||
"not_verified",
|
||||
"RunnerUp TCX export found but could not be read",
|
||||
)
|
||||
day = activity_day(activities)
|
||||
if day.qualifies:
|
||||
return "verified", day.message()
|
||||
return "too_short", activity_shortfall(day)
|
||||
|
||||
def _validate_runnerup_data(self, data: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Validate a RunnerUp activity against configured thresholds.
|
||||
|
||||
Returns ``(status, message)`` following the same contract as
|
||||
``PhoneVerificationMixin._verify_phone_workout``.
|
||||
``PhoneVerificationMixin._verify_phone_workout``. The one rule is
|
||||
:func:`screen_locker._walk_tcx.activity_day`; a dict without
|
||||
``moving_seconds`` (the root-DB path) is judged by its duration.
|
||||
"""
|
||||
sport = data["sport"]
|
||||
|
||||
duration_min = data["duration_seconds"] / 60
|
||||
distance_km = data["distance_m"] / 1000
|
||||
|
||||
# Either criterion qualifies the run on its own (see _constants.py).
|
||||
# Distance gets the GPS tolerance (a measurement correction); duration
|
||||
# gets the shared hidden leeway (a deliberate concession). Different
|
||||
# reasons, so they stay separate constants.
|
||||
distance_ok = (
|
||||
distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) >= MIN_RUN_DISTANCE_KM
|
||||
)
|
||||
duration_ok = duration_min >= WORKOUT_DURATION_ACCEPT_MINUTES
|
||||
if not distance_ok and not duration_ok:
|
||||
msg = (
|
||||
f"Run was {distance_km:.1f} km / {duration_min:.0f} min — need "
|
||||
f"{MIN_RUN_DISTANCE_KM:g}+ km or {MIN_WORKOUT_DURATION_MINUTES}+ min"
|
||||
)
|
||||
return "too_short", msg
|
||||
if distance_ok and distance_km < MIN_RUN_DISTANCE_KM:
|
||||
day = activity_day([data])
|
||||
if not day.qualifies:
|
||||
return "too_short", activity_shortfall(day)
|
||||
if day.distance_km < MIN_RUN_DISTANCE_KM and distance_qualifies(
|
||||
day.distance_km
|
||||
):
|
||||
_logger.info(
|
||||
"RunnerUp distance %.2f km is under the %.1f km minimum but "
|
||||
"within %.0f%% GPS tolerance — accepted.",
|
||||
distance_km,
|
||||
day.distance_km,
|
||||
MIN_RUN_DISTANCE_KM,
|
||||
RUNNERUP_DISTANCE_TOLERANCE * 100,
|
||||
)
|
||||
|
||||
sport_name = _SPORT_NAMES.get(sport, str(sport))
|
||||
return (
|
||||
"verified",
|
||||
f"{sport_name}: {distance_km:.1f} km in {duration_min:.0f} min",
|
||||
)
|
||||
return "verified", day.message()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main entry point
|
||||
@ -213,7 +192,9 @@ class RunnerUpVerificationMixin(
|
||||
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",
|
||||
(
|
||||
f"No RunnerUp export for today in {RUNNERUP_WEBDAV_DIRS} "
|
||||
"and the phone is not adb-reachable"
|
||||
),
|
||||
)
|
||||
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 datetime import date
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._ceiling import ceiling
|
||||
from screen_locker._constants import (
|
||||
ADJUST_SHUTDOWN_SCRIPT,
|
||||
SHUTDOWN_CONFIG_FILE,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._shutdown_sick_state import SickDayStateMixin
|
||||
from screen_locker._wake_alarm import WakeAlarmMixin
|
||||
from screen_locker._workout_credit import FIRST_WORKOUT_BONUS_HOURS
|
||||
from screen_locker._earned import first_minutes, hhmm, span
|
||||
from screen_locker._grace_floor import plan_absorb, save_lift
|
||||
from screen_locker._late_credit import LateCreditMixin
|
||||
from screen_locker._sick import is_sick
|
||||
from screen_locker._sick_floor import SickFloorMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_SHUTDOWN_CONFIG_KEYS = ("MON_WED_HOUR", "THU_SUN_HOUR", "MORNING_END_HOUR")
|
||||
# Every time below is minutes after midnight. The config's *_MINUTES keys are
|
||||
# authoritative; a file written before the minutes migration has only the
|
||||
# whole-hour *_HOUR keys, read as HOUR * 60.
|
||||
_SHUTDOWN_CONFIG_NAMES = ("MON_WED", "THU_SUN", "MORNING_END")
|
||||
_MINUTES_PER_HOUR = 60
|
||||
|
||||
|
||||
def _parse_config(path: Path) -> dict[str, int]:
|
||||
"""Every integer ``KEY=value`` line of *path*."""
|
||||
parsed: dict[str, int] = {}
|
||||
with path.open() as f:
|
||||
for line in f:
|
||||
key, sep, value = line.strip().partition("=")
|
||||
if sep and value.strip().isdigit():
|
||||
parsed[key] = int(value)
|
||||
return parsed
|
||||
|
||||
|
||||
def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
|
||||
"""Read shutdown config from *path*. Returns (mw_hour, ts_hour, me_hour) or None.
|
||||
"""Read shutdown config from *path* as (mon_wed, thu_sun, morning_end) minutes.
|
||||
|
||||
Reading needs no privilege (only writing does, via
|
||||
``adjust_shutdown_schedule.sh``) — safe to call from a read-only status view.
|
||||
@ -32,112 +53,96 @@ def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
|
||||
if not path.exists():
|
||||
_logger.warning("Config not found: %s", path)
|
||||
return None
|
||||
parsed: dict[str, int] = {}
|
||||
with path.open() as f:
|
||||
for line in f:
|
||||
stripped = line.strip()
|
||||
for key in _SHUTDOWN_CONFIG_KEYS:
|
||||
if stripped.startswith(f"{key}="):
|
||||
parsed[key] = int(stripped.split("=")[1])
|
||||
if len(parsed) < len(_SHUTDOWN_CONFIG_KEYS):
|
||||
_logger.warning("Shutdown config missing required values")
|
||||
return None
|
||||
return (
|
||||
parsed["MON_WED_HOUR"],
|
||||
parsed["THU_SUN_HOUR"],
|
||||
parsed["MORNING_END_HOUR"],
|
||||
)
|
||||
parsed = _parse_config(path)
|
||||
values: list[int] = []
|
||||
for name in _SHUTDOWN_CONFIG_NAMES:
|
||||
if f"{name}_MINUTES" in parsed:
|
||||
values.append(parsed[f"{name}_MINUTES"])
|
||||
elif f"{name}_HOUR" in parsed:
|
||||
values.append(parsed[f"{name}_HOUR"] * _MINUTES_PER_HOUR)
|
||||
else:
|
||||
_logger.warning("Shutdown config missing required values")
|
||||
return None
|
||||
return values[0], values[1], values[2]
|
||||
|
||||
|
||||
class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
|
||||
class ShutdownMixin(SickFloorMixin, LateCreditMixin):
|
||||
"""Mixin providing shutdown schedule adjustment functionality."""
|
||||
|
||||
def _apply_earlier_shutdown(self, today: str) -> bool:
|
||||
"""Read config, save state, and write earlier shutdown hours."""
|
||||
config_values = self._read_shutdown_config()
|
||||
if config_values is None:
|
||||
return False
|
||||
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
|
||||
if not self._save_sick_day_state(today, mon_wed_hour, thu_sun_hour):
|
||||
_logger.error("Failed to save state - aborting adjustment")
|
||||
return False
|
||||
new_mon_wed = max(18, mon_wed_hour - 1)
|
||||
new_thu_sun = max(18, thu_sun_hour - 1)
|
||||
return self._write_shutdown_config(
|
||||
new_mon_wed,
|
||||
new_thu_sun,
|
||||
morning_end_hour,
|
||||
)
|
||||
def _adjust_shutdown_time_later(self, at: float | None = None) -> bool:
|
||||
"""Push shutdown later by the first workout's reward, capped at the ceiling.
|
||||
|
||||
def _adjust_shutdown_time_earlier(self) -> bool:
|
||||
"""Adjust shutdown schedule 1.5 hours earlier (stricter).
|
||||
|
||||
This can only be used once per day. Original values are saved and
|
||||
automatically restored when checked the next day.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
today = 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.
|
||||
``at`` is when the workout ended (default now): a late one counts from
|
||||
it (:mod:`._late_credit`). Returns True if successful, False otherwise.
|
||||
"""
|
||||
day = date.fromisoformat(today_str())
|
||||
reward = first_minutes(earned_time.WORKOUT, day)
|
||||
if is_sick(day):
|
||||
return self._raise_on_sick_day(reward, at)
|
||||
late = self._late_write(reward, at)
|
||||
if late is not None:
|
||||
return late
|
||||
bonus, lift = plan_absorb(reward)
|
||||
try:
|
||||
config_values = self._read_shutdown_config()
|
||||
if config_values is None:
|
||||
return False
|
||||
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
|
||||
new_mon_wed = min(23, mon_wed_hour + FIRST_WORKOUT_BONUS_HOURS)
|
||||
new_thu_sun = min(23, thu_sun_hour + FIRST_WORKOUT_BONUS_HOURS)
|
||||
return self._write_shutdown_config(
|
||||
new_mon_wed,
|
||||
new_thu_sun,
|
||||
morning_end_hour,
|
||||
mon_wed, thu_sun, morning_end = config_values
|
||||
ok = self._write_shutdown_config(
|
||||
min(ceiling(day), mon_wed + bonus),
|
||||
min(ceiling(day), thu_sun + bonus),
|
||||
morning_end,
|
||||
restore=True,
|
||||
)
|
||||
except (OSError, ValueError) as e:
|
||||
_logger.warning("Failed to adjust shutdown time for workout: %s", e)
|
||||
return False
|
||||
if ok:
|
||||
save_lift(lift)
|
||||
return ok
|
||||
|
||||
def _adjust_shutdown_time_by(self, extra_hours: int) -> bool:
|
||||
"""Adjust shutdown hours by *extra_hours*, capped at 24 (midnight).
|
||||
def _adjust_shutdown_time_by(
|
||||
self, extra_minutes: int, at: float | None = None
|
||||
) -> bool:
|
||||
"""Push shutdown later by *extra_minutes*, capped at the day's ceiling.
|
||||
|
||||
Used for extra-workout bonuses beyond the weekly minimum. A cap of 24
|
||||
works because ``day-specific-shutdown-check.sh`` fires at 00:00 and
|
||||
catches it via the morning-window condition (0 <= 300 minutes).
|
||||
Used for earner and extra-workout bonuses. The cap is
|
||||
``earned_time.shutdown_ceiling_for(today)`` (23:00 today, lower if
|
||||
``WAKE_MINUTES`` ever moves); the helper's 23:00 stays the hard bound.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
``at`` is when the credit was earned (default now): a late one counts
|
||||
from it (:mod:`._late_credit`). Returns True if successful.
|
||||
"""
|
||||
day = date.fromisoformat(today_str())
|
||||
if is_sick(day):
|
||||
return self._raise_on_sick_day(extra_minutes, at)
|
||||
late = self._late_write(extra_minutes, at)
|
||||
if late is not None:
|
||||
return late
|
||||
# A stale lift recorded before the grace floor went is spent first.
|
||||
moved, lift = plan_absorb(extra_minutes)
|
||||
try:
|
||||
config_values = self._read_shutdown_config()
|
||||
if config_values is None:
|
||||
return False
|
||||
mw, ts, morning = config_values
|
||||
return self._write_shutdown_config(
|
||||
min(24, mw + extra_hours),
|
||||
min(24, ts + extra_hours),
|
||||
ok = self._write_shutdown_config(
|
||||
min(ceiling(day), mw + moved),
|
||||
min(ceiling(day), ts + moved),
|
||||
morning,
|
||||
restore=True,
|
||||
)
|
||||
except (OSError, ValueError) as e:
|
||||
_logger.warning(
|
||||
"Failed to adjust shutdown time by %d h: %s", extra_hours, e
|
||||
"Failed to adjust shutdown time by %s: %s", span(extra_minutes), e
|
||||
)
|
||||
return False
|
||||
if ok:
|
||||
save_lift(lift)
|
||||
return ok
|
||||
|
||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
|
||||
"""Read shutdown config. Returns (mw_hour, ts_hour, me_hour) or None."""
|
||||
"""Read shutdown config as (mon_wed, thu_sun, morning_end) minutes, or None."""
|
||||
return read_shutdown_config(SHUTDOWN_CONFIG_FILE)
|
||||
|
||||
def _build_shutdown_cmd(
|
||||
@ -152,23 +157,23 @@ class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
|
||||
cmd = ["/usr/bin/sudo", str(ADJUST_SHUTDOWN_SCRIPT)]
|
||||
if restore:
|
||||
cmd.append("--restore")
|
||||
cmd.extend([str(mon_wed), str(thu_sun), str(morning)])
|
||||
cmd.extend([hhmm(mon_wed), hhmm(thu_sun), hhmm(morning)])
|
||||
return cmd
|
||||
|
||||
def _write_shutdown_config(
|
||||
self,
|
||||
mon_wed_hour: int,
|
||||
thu_sun_hour: int,
|
||||
morning_end_hour: int,
|
||||
mon_wed: int,
|
||||
thu_sun: int,
|
||||
morning_end: int,
|
||||
*,
|
||||
restore: bool = False,
|
||||
) -> bool:
|
||||
"""Write new shutdown config values using helper script.
|
||||
|
||||
Args:
|
||||
mon_wed_hour: Shutdown hour for Monday-Wednesday.
|
||||
thu_sun_hour: Shutdown hour for Thursday-Sunday.
|
||||
morning_end_hour: Morning end hour.
|
||||
mon_wed: Monday-Wednesday shutdown, minutes after midnight.
|
||||
thu_sun: Thursday-Sunday shutdown, minutes after midnight.
|
||||
morning_end: End of the morning window, minutes after midnight.
|
||||
restore: If True, allows restoring to later times.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
@ -180,18 +185,18 @@ class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
|
||||
)
|
||||
return False
|
||||
cmd = self._build_shutdown_cmd(
|
||||
mon_wed_hour,
|
||||
thu_sun_hour,
|
||||
morning_end_hour,
|
||||
mon_wed,
|
||||
thu_sun,
|
||||
morning_end,
|
||||
restore=restore,
|
||||
)
|
||||
return self._run_shutdown_cmd(cmd, mon_wed_hour, thu_sun_hour)
|
||||
return self._run_shutdown_cmd(cmd, mon_wed, thu_sun)
|
||||
|
||||
def _run_shutdown_cmd(
|
||||
self,
|
||||
cmd: list[str],
|
||||
mon_wed_hour: int,
|
||||
thu_sun_hour: int,
|
||||
mon_wed: int,
|
||||
thu_sun: int,
|
||||
) -> bool:
|
||||
"""Execute the shutdown adjustment command."""
|
||||
try:
|
||||
@ -205,9 +210,9 @@ class ShutdownMixin(SickDayStateMixin, WakeAlarmMixin):
|
||||
_logger.warning("Failed to adjust shutdown config: %s", e)
|
||||
return False
|
||||
_logger.info(
|
||||
"Adjusted shutdown: Mon-Wed=%d, Thu-Sun=%d. %s",
|
||||
mon_wed_hour,
|
||||
thu_sun_hour,
|
||||
"Adjusted shutdown: Mon-Wed=%s, Thu-Sun=%s. %s",
|
||||
hhmm(mon_wed),
|
||||
hhmm(thu_sun),
|
||||
result.stdout.strip(),
|
||||
)
|
||||
return True
|
||||
|
||||
@ -1,26 +1,26 @@
|
||||
"""Daily shutdown-time base reset for the screen locker.
|
||||
|
||||
On each new calendar day the shutdown config is reset to the base hour
|
||||
(:data:`BASE_HOUR`, 19:00) so that the day's bonuses always layer on top of a
|
||||
known floor rather than accumulating indefinitely across days.
|
||||
On each new calendar day the shutdown config is reset to the day's base so
|
||||
that the day's bonuses always layer on top of a known floor rather than
|
||||
accumulating indefinitely across days.
|
||||
|
||||
The base is a constant, not state. It used to be persisted in the state file
|
||||
and read back on every reset, which made the code's default dead: the file
|
||||
kept re-writing whatever it was installed with, so changing the constant
|
||||
changed nothing on the machine. The state file now carries only date stamps.
|
||||
The base and every bonus come from the shared earner registry in force that
|
||||
day (``earned_time``, see :mod:`screen_locker._earned`). Nothing here is
|
||||
state: the file carries only stamps (``<name>_bonus_date`` for a flat
|
||||
earner, ``<name>_bonus_units`` for a counted gate, :mod:`._gate_bonus`).
|
||||
|
||||
The reset re-derives what today has ALREADY earned and writes base plus that
|
||||
in one go: the workout hours from today's log, the LeetCode hour from
|
||||
leetcode-guard's ledger (:mod:`screen_locker._leetcode_bonus`) and the reading
|
||||
hour from book-guard's (:mod:`screen_locker._reading_bonus`). Live credits
|
||||
are read-add-write against the config, so a workout credited between local
|
||||
midnight and the first locker tick of the day (a manual log at 00:02, say)
|
||||
had its hours wiped by the reset that followed -- on 2026-09-13 that left the
|
||||
bar at 21:00 with a counted workout on disk. Any bonus source that is not a
|
||||
term of this derivation has that bug.
|
||||
in one go: the workout hours from today's log and every flat earner's hour
|
||||
from its gate's ledger. Live credits are read-add-write against the config,
|
||||
so a workout credited between local midnight and the first locker tick of the
|
||||
day (a manual log at 00:02, say) had its hours wiped by the reset that
|
||||
followed -- on 2026-09-13 that left the bar at 21:00 with a counted workout on
|
||||
disk. Any bonus source that is not a term of this derivation has that bug;
|
||||
registering it in ``earned_time`` makes it one.
|
||||
|
||||
The sick-day state file is cleared on reset so the sick-restore path cannot
|
||||
overwrite the fresh base when it runs later in the same startup.
|
||||
A sick day (``sickdays``) is resolved with ``sick=True``: 20:00 plus the
|
||||
gaming pay of the day's credits, with no lift saved. A day marked sick after
|
||||
the reset is raised by the live pass (:mod:`screen_locker._sick_floor`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -30,61 +30,74 @@ import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._bonus_lock import bonus_lock
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._leetcode_bonus import LEETCODE_BONUS_HOURS, leetcode_bonus_hours
|
||||
from screen_locker._earned import (
|
||||
counted_gate_earners,
|
||||
earned_today,
|
||||
first_credit_time,
|
||||
first_credits,
|
||||
first_minutes,
|
||||
flat_earners,
|
||||
hhmm,
|
||||
registry,
|
||||
span,
|
||||
)
|
||||
from screen_locker._gate_bonus import apply_counted_bonus, date_stamp, reset_stamps
|
||||
from screen_locker._grace_floor import save_lift
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._reading_bonus import READING_BONUS_HOURS, reading_bonus_hours
|
||||
from screen_locker._shutdown_target import gather
|
||||
from screen_locker._sick import is_sick
|
||||
from screen_locker._weekly_check import count_day_credits
|
||||
from screen_locker._workout_credit import earned_shutdown_bonus_hours
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# The hour every day starts from before any bonus. Moved 21 -> 20 on
|
||||
# 2026-09-15, and 20 -> 19 together with book-guard's reading hour: the
|
||||
# ceiling stays 23:00, so every day's best case (workouts + LeetCode + reading)
|
||||
# is unchanged and a day without reading ends an hour earlier. The cut waits
|
||||
# for book-guard's gate (2026-10-01) -- taking an hour the reader cannot yet
|
||||
# earn back was a same-day loss when it first shipped on 2026-09-26.
|
||||
BASE_HOUR = 19
|
||||
READING_BASE_FROM = date(2026, 10, 1)
|
||||
# Morning end assumed when the live config cannot be read: 05:00.
|
||||
_DEFAULT_MORNING_END = 5 * 60
|
||||
|
||||
|
||||
def base_hour(day: date | None = None) -> int:
|
||||
"""The base for ``day`` (default today): 20 before the reading cut, then 19."""
|
||||
def base_minutes(day: date | None = None) -> int:
|
||||
"""The base shutdown for ``day`` (default today), in minutes after midnight.
|
||||
|
||||
Every penalty in force is already taken off; from today on, a gate's not
|
||||
before it has paid out (:func:`~screen_locker._earned.first_credits`). A
|
||||
sick day's base is raised to the sick floor, as ``earned_time.resolve``
|
||||
does for a sick day that earned nothing.
|
||||
"""
|
||||
target = day or datetime.now().astimezone().date()
|
||||
return BASE_HOUR if target >= READING_BASE_FROM else BASE_HOUR + 1
|
||||
earners = registry(target)
|
||||
starts = first_credits(earners, target)
|
||||
base = (
|
||||
earned_time.base_for(target, earners)
|
||||
if starts is None
|
||||
else earned_time.base_for(target, earners, first_credits=starts)
|
||||
).shutdown_minutes
|
||||
if is_sick(target):
|
||||
return max(base, earned_time.sick_shutdown_from(0, target))
|
||||
return base
|
||||
|
||||
|
||||
# Mirrors RESTORE_CEILING in adjust_shutdown_schedule.sh: the script clamps any
|
||||
# restore above it, so the target is computed against the same ceiling here
|
||||
# rather than asking for hours the write will silently cut.
|
||||
_RESTORE_CEILING_HOUR = 23
|
||||
|
||||
_LEETCODE_STAMP = "leetcode_bonus_date"
|
||||
_READING_STAMP = "reading_bonus_date"
|
||||
|
||||
|
||||
def today_earned_bonus_hours(log_file: Path, today: str) -> int:
|
||||
"""Return the shutdown hours today's logged workouts have already earned.
|
||||
def today_credit_count(log_file: Path, today: str) -> int:
|
||||
"""Return how many distinct workouts today's log counts.
|
||||
|
||||
Counted with the same :func:`~screen_locker._weekly_check.count_day_credits`
|
||||
rule as the weekly total, so a workout recorded twice earns once.
|
||||
"""
|
||||
entries = load_workout_log(log_file).get(today, [])
|
||||
credit_count = count_day_credits(today, [e for e in entries if isinstance(e, dict)])
|
||||
return earned_shutdown_bonus_hours(credit_count)
|
||||
return count_day_credits(today, [e for e in entries if isinstance(e, dict)])
|
||||
|
||||
|
||||
def _load_state(state_file: Path) -> dict[str, Any]:
|
||||
"""Return the reset state, or an empty dict when it is missing or corrupt.
|
||||
|
||||
A corrupt file is logged and treated as "nothing stamped": the reset then
|
||||
runs again, which is idempotent, and the LeetCode hour is re-applied at
|
||||
worst once, capped by the ceiling.
|
||||
runs again, which is idempotent, and a flat hour is re-applied at worst
|
||||
once, capped by the ceiling.
|
||||
"""
|
||||
if not state_file.exists():
|
||||
return {}
|
||||
@ -111,72 +124,67 @@ def _save_state(state_file: Path, state: dict[str, Any]) -> None:
|
||||
_logger.warning("Failed to write shutdown reset state: %s", exc)
|
||||
|
||||
|
||||
def _clear_sick_day_state(sick_day_state_file: Path | None) -> None:
|
||||
"""Remove stale sick-day state so it does not override the base reset."""
|
||||
if sick_day_state_file is None or not sick_day_state_file.exists():
|
||||
return
|
||||
try:
|
||||
sick_day_state_file.unlink()
|
||||
_logger.info("Daily base reset: cleared stale sick-day state.")
|
||||
except OSError as exc:
|
||||
_logger.warning("Daily base reset: could not remove sick-day state: %s", exc)
|
||||
|
||||
|
||||
def reset_to_base_if_new_day(
|
||||
state_file: Path,
|
||||
mixin: object,
|
||||
sick_day_state_file: Path | None = None,
|
||||
log_file: Path | None = None,
|
||||
) -> bool:
|
||||
"""Reset the shutdown config to the base hour if a new calendar day began.
|
||||
"""Reset the shutdown config to the base if a new calendar day began.
|
||||
|
||||
Writes :data:`BASE_HOUR` plus whatever today has already earned -- the
|
||||
workout hours in *log_file* (see :func:`today_earned_bonus_hours`) and the
|
||||
LeetCode hour -- via *mixin*._write_shutdown_config (with restore=True so
|
||||
the script allows moving the time earlier), stamps ``last_reset_date`` in
|
||||
*state_file*, and removes *sick_day_state_file* if it exists so the
|
||||
sick-restore path does not fight with the fresh base on the same startup.
|
||||
Writes the base plus whatever today has already earned -- the workout
|
||||
time in *log_file* (see :func:`today_credit_count`) and every flat
|
||||
earner's time -- via *mixin*._write_shutdown_config (with restore=True so
|
||||
the script allows moving the time earlier) and stamps ``last_reset_date``
|
||||
in *state_file*.
|
||||
|
||||
Returns True if a reset was performed, False if today was already reset.
|
||||
"""
|
||||
with bonus_lock(state_file):
|
||||
return _reset(state_file, mixin, log_file)
|
||||
|
||||
|
||||
def _reset(
|
||||
state_file: Path,
|
||||
mixin: object,
|
||||
log_file: Path | None,
|
||||
) -> bool:
|
||||
""":func:`reset_to_base_if_new_day`'s body; the caller holds the lock."""
|
||||
today = today_str()
|
||||
if _load_state(state_file).get("last_reset_date") == today:
|
||||
return False
|
||||
|
||||
earned = today_earned_bonus_hours(log_file, today) if log_file is not None else 0
|
||||
leetcode = leetcode_bonus_hours()
|
||||
reading = reading_bonus_hours()
|
||||
base = base_hour()
|
||||
target = min(_RESTORE_CEILING_HOUR, base + earned + leetcode + reading)
|
||||
derived = gather(date.fromisoformat(today), log_file)
|
||||
day = derived.resolution
|
||||
target = derived.minutes
|
||||
|
||||
# Preserve the morning-end hour from the live config.
|
||||
# Preserve the morning end from the live config.
|
||||
config = mixin._read_shutdown_config()
|
||||
morning_end = config[2] if config else 5
|
||||
morning_end = config[2] if config else _DEFAULT_MORNING_END
|
||||
|
||||
ok: bool = mixin._write_shutdown_config(target, target, morning_end, restore=True)
|
||||
if not ok:
|
||||
_logger.warning("Daily base reset: failed to write shutdown config.")
|
||||
return False
|
||||
|
||||
_clear_sick_day_state(sick_day_state_file)
|
||||
# 0 now that no floor lifts the sum; overwrites a stale lift from before.
|
||||
save_lift(derived.lift)
|
||||
|
||||
# The flat hours are stamped here too: the reset already included them,
|
||||
# so the live pass below must not add them a second time today.
|
||||
# The bonuses the reset already included are stamped, so the live pass
|
||||
# does not add them a second time today.
|
||||
new_state: dict[str, Any] = {"last_reset_date": today}
|
||||
if leetcode:
|
||||
new_state[_LEETCODE_STAMP] = today
|
||||
if reading:
|
||||
new_state[_READING_STAMP] = today
|
||||
new_state.update(reset_stamps(day.terms, today))
|
||||
_save_state(state_file, new_state)
|
||||
|
||||
earned = " + ".join(
|
||||
f"{span(t.shutdown_minutes)} {t.earner.label}" for t in day.terms
|
||||
)
|
||||
_logger.info(
|
||||
"Daily base reset: %02d:00 (base %d + %dh workout + %dh LeetCode + "
|
||||
"%dh reading already earned today).",
|
||||
target,
|
||||
base,
|
||||
"Daily base reset: %s (base %s + %s already earned today%s%s).",
|
||||
hhmm(target),
|
||||
hhmm(day.base.shutdown_minutes),
|
||||
earned,
|
||||
leetcode,
|
||||
reading,
|
||||
"; rest day" if derived.rest_day else "",
|
||||
"; sick day" if day.sick else "",
|
||||
)
|
||||
return True
|
||||
|
||||
@ -184,60 +192,57 @@ def reset_to_base_if_new_day(
|
||||
def _apply_flat_bonus(
|
||||
state_file: Path,
|
||||
mixin: object,
|
||||
*,
|
||||
stamp: str,
|
||||
hours: Callable[[], int],
|
||||
label: str,
|
||||
earner: earned_time.Earner,
|
||||
) -> bool:
|
||||
"""Push shutdown later by a once-per-day flat hour, if it is earned.
|
||||
"""Push shutdown later by one earner's once-per-day time, if it is earned.
|
||||
|
||||
The daily reset only sees a bonus earned before it ran; this is the live
|
||||
counterpart for the usual case, where the solve or the reading lands
|
||||
hours later and is picked up by the next timer tick. Idempotent through
|
||||
the ``stamp`` key in *state_file*, which the reset sets as well when it
|
||||
already included the hour.
|
||||
the earner's stamp in *state_file*, which the reset sets as well when it
|
||||
already included the bonus.
|
||||
|
||||
Returns True if the hour was applied on this call.
|
||||
Returns True if the bonus was applied on this call.
|
||||
"""
|
||||
today = today_str()
|
||||
state = _load_state(state_file)
|
||||
stamp = date_stamp(earner)
|
||||
if state.get(stamp) == today:
|
||||
return False
|
||||
earned = hours()
|
||||
if earned == 0:
|
||||
answer = earned_today(earner)
|
||||
if answer is None:
|
||||
_logger.warning(
|
||||
"%s state could not be checked; no shutdown time for it", earner.label
|
||||
)
|
||||
return False
|
||||
if not mixin._adjust_shutdown_time_by(earned):
|
||||
_logger.warning("%s bonus: failed to write shutdown config.", label)
|
||||
if not answer:
|
||||
return False
|
||||
day = date.fromisoformat(today)
|
||||
minutes = first_minutes(earner, day)
|
||||
# Anchored on the credit's own row, so a late solve counts from its time.
|
||||
if not mixin._adjust_shutdown_time_by(minutes, at=first_credit_time(earner, day)):
|
||||
_logger.warning("%s bonus: failed to write shutdown config.", earner.label)
|
||||
return False
|
||||
state[stamp] = today
|
||||
_save_state(state_file, state)
|
||||
_logger.info("%s bonus: +%dh shutdown time today.", label, earned)
|
||||
_logger.info("%s bonus: +%s shutdown time today.", earner.label, span(minutes))
|
||||
return True
|
||||
|
||||
|
||||
def apply_leetcode_bonus_if_new(state_file: Path, mixin: object) -> bool:
|
||||
"""The LeetCode hour, live (see :func:`_apply_flat_bonus`)."""
|
||||
return _apply_flat_bonus(
|
||||
state_file,
|
||||
mixin,
|
||||
stamp=_LEETCODE_STAMP,
|
||||
hours=lambda: LEETCODE_BONUS_HOURS if leetcode_bonus_hours() else 0,
|
||||
label="LeetCode",
|
||||
)
|
||||
|
||||
|
||||
def apply_reading_bonus_if_new(state_file: Path, mixin: object) -> bool:
|
||||
"""The reading hour, live (see :func:`_apply_flat_bonus`)."""
|
||||
return _apply_flat_bonus(
|
||||
state_file,
|
||||
mixin,
|
||||
stamp=_READING_STAMP,
|
||||
hours=lambda: READING_BONUS_HOURS if reading_bonus_hours() else 0,
|
||||
label="Reading",
|
||||
)
|
||||
|
||||
|
||||
def apply_flat_bonuses_if_new(state_file: Path, mixin: object) -> None:
|
||||
"""Every once-per-day flat hour: LeetCode, then reading."""
|
||||
apply_leetcode_bonus_if_new(state_file, mixin)
|
||||
apply_reading_bonus_if_new(state_file, mixin)
|
||||
"""Every once-per-day flat bonus, in registry order (LeetCode, reading, ...).
|
||||
|
||||
Then each counted gate's new units (:mod:`._gate_bonus`); the workout's
|
||||
live credit is ``WorkoutCreditMixin``'s.
|
||||
"""
|
||||
with bonus_lock(state_file):
|
||||
for earner in flat_earners():
|
||||
_apply_flat_bonus(state_file, mixin, earner)
|
||||
adjust = mixin._adjust_shutdown_time_by
|
||||
for earner in counted_gate_earners():
|
||||
state = _load_state(state_file)
|
||||
if apply_counted_bonus(state, adjust, earner, today_str()):
|
||||
_save_state(state_file, state)
|
||||
# Last, so the sick target sees everything earned so far; it covers a
|
||||
# day marked sick after today's reset.
|
||||
mixin._apply_sick_floor()
|
||||
|
||||
@ -1,119 +0,0 @@
|
||||
"""Sick-day shutdown state: persistence and next-day restoration.
|
||||
|
||||
Split out of :mod:`screen_locker._shutdown` to keep every file under the
|
||||
250-line cap. Composed back into ``ShutdownMixin`` there, so callers see no
|
||||
change.
|
||||
|
||||
Tracks the shutdown hours that were in force before a sick day moved them
|
||||
earlier, so the following day can put them back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from screen_locker._constants import SICK_DAY_STATE_FILE
|
||||
from screen_locker._day import today_str
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SickDayStateMixin:
|
||||
"""Persists and restores the pre-sick-day shutdown configuration."""
|
||||
|
||||
def _sick_mode_used_today(self) -> bool:
|
||||
"""Check if sick mode was already used today."""
|
||||
if not SICK_DAY_STATE_FILE.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
with SICK_DAY_STATE_FILE.open() as f:
|
||||
state = json.load(f)
|
||||
today = today_str()
|
||||
return state.get("date") == today
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_logger.warning(
|
||||
"Could not read sick-day state from %s: %s — treating sick mode "
|
||||
"as UNUSED today, which may allow a second sick day",
|
||||
SICK_DAY_STATE_FILE,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
def _save_sick_day_state(
|
||||
self,
|
||||
date: str,
|
||||
orig_mon_wed: int,
|
||||
orig_thu_sun: int,
|
||||
) -> bool:
|
||||
"""Save sick day state with original config values.
|
||||
|
||||
Returns True if saved successfully, False otherwise.
|
||||
"""
|
||||
state = {
|
||||
"date": date,
|
||||
"original_mon_wed_hour": orig_mon_wed,
|
||||
"original_thu_sun_hour": orig_thu_sun,
|
||||
}
|
||||
try:
|
||||
with SICK_DAY_STATE_FILE.open("w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
except OSError as e:
|
||||
_logger.warning("Failed to save sick day state: %s", e)
|
||||
return False
|
||||
|
||||
_logger.info("Saved sick day state for %s", date)
|
||||
return True
|
||||
|
||||
def _load_sick_day_state(self) -> tuple[str, int, int] | None:
|
||||
"""Load sick day state file.
|
||||
|
||||
Returns (date, orig_mon_wed_hour, orig_thu_sun_hour) or None.
|
||||
"""
|
||||
with SICK_DAY_STATE_FILE.open() as f:
|
||||
state = json.load(f)
|
||||
date = state.get("date")
|
||||
orig_mw = state.get("original_mon_wed_hour")
|
||||
orig_ts = state.get("original_thu_sun_hour")
|
||||
if date is None or orig_mw is None or orig_ts is None:
|
||||
return None
|
||||
return (str(date), int(orig_mw), int(orig_ts))
|
||||
|
||||
def _write_restored_config(
|
||||
self,
|
||||
orig_mw: int,
|
||||
orig_ts: int,
|
||||
state_date: str,
|
||||
) -> None:
|
||||
"""Write restored config values and clean up state file."""
|
||||
config_values = self._read_shutdown_config()
|
||||
if config_values:
|
||||
_, _, morning_end = config_values
|
||||
_logger.info(
|
||||
"Restoring original shutdown config from %s",
|
||||
state_date,
|
||||
)
|
||||
self._write_shutdown_config(
|
||||
orig_mw,
|
||||
orig_ts,
|
||||
morning_end,
|
||||
restore=True,
|
||||
)
|
||||
SICK_DAY_STATE_FILE.unlink()
|
||||
_logger.info("Removed stale sick day state from %s", state_date)
|
||||
|
||||
def _restore_original_config_if_needed(self) -> None:
|
||||
"""Restore original config if sick day state is from a previous day."""
|
||||
if not SICK_DAY_STATE_FILE.exists():
|
||||
return
|
||||
try:
|
||||
loaded = self._load_sick_day_state()
|
||||
if loaded is None:
|
||||
return
|
||||
state_date, orig_mw, orig_ts = loaded
|
||||
today = today_str()
|
||||
if state_date != today:
|
||||
self._write_restored_config(orig_mw, orig_ts, state_date)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
_logger.warning("Error checking sick day state: %s", e)
|
||||
155
screen_locker/_shutdown_target.py
Normal file
155
screen_locker/_shutdown_target.py
Normal file
@ -0,0 +1,155 @@
|
||||
"""Tonight's shutdown, derived from scratch: earned time and rest day.
|
||||
|
||||
:func:`derive` is pure, and is the one formula behind both the daily reset
|
||||
(:mod:`screen_locker._shutdown_base`) and the read-only preview
|
||||
(``scripts/preview_shutdown.py``), so a dry run exercises the real sum::
|
||||
|
||||
target = earned_time.resolve(answers, sick=...) # base + every earner, capped;
|
||||
# sick: 20:00 + gaming pay
|
||||
|
||||
A late credit (``_late_credit``) is live-only: it restarts tonight from its own
|
||||
time, and the next day's reset re-derives from scratch.
|
||||
|
||||
A declared rest day answers the workout with at least one unit, i.e. its
|
||||
first-unit bonus; a real workout on top of it earns only what a further unit
|
||||
earns. :func:`gather` reads today's real inputs; it writes nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._earned import first_credits, gate_answers, registry
|
||||
from screen_locker._grace_floor import first_done_at
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._rest_day import is_rest_day
|
||||
from screen_locker._sick import is_sick
|
||||
from screen_locker._weekly_check import count_day_credits
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Target:
|
||||
"""One day's derived shutdown and what made it.
|
||||
|
||||
Attributes:
|
||||
resolution: ``earned_time``'s sum (base + terms, capped; on a sick
|
||||
day raised to the 20:00 sick floor).
|
||||
rest_day: Whether the day is a declared rest day.
|
||||
first_done_at: Unix time the day's first earner was done, if any.
|
||||
minutes: The shutdown to write, minutes after midnight.
|
||||
"""
|
||||
|
||||
resolution: earned_time.Resolution
|
||||
rest_day: bool
|
||||
first_done_at: float | None
|
||||
minutes: int
|
||||
|
||||
@property
|
||||
def earned(self) -> int:
|
||||
"""The earned shutdown: base plus terms, before any floor."""
|
||||
return self.resolution.shutdown_minutes - self.resolution.sick_lift_minutes
|
||||
|
||||
@property
|
||||
def lift(self) -> int:
|
||||
"""How far a floor lifts the earned shutdown: 0 since the grace floor went.
|
||||
|
||||
Saved as the day's lift (``_grace_floor.save_lift``), so a credit that
|
||||
lands later is paid out of it first instead of stacking on 20:00.
|
||||
"""
|
||||
if self.resolution.sick:
|
||||
return 0 # earned_time 0.13.0: credits stack on 20:00
|
||||
return self.minutes - self.earned
|
||||
|
||||
|
||||
def workout_units(count: int, *, rest_day: bool) -> int:
|
||||
"""A rest day stands in for the first workout, never for a further one."""
|
||||
return max(count, 1) if rest_day else count
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DayInputs:
|
||||
"""Everything one day's shutdown is derived from.
|
||||
|
||||
Attributes:
|
||||
day: The day.
|
||||
flat: Each gate earner's answer: done (flat) or units (counted, the
|
||||
tutor); ``None`` = could not check.
|
||||
workout_credits: The log's distinct workout credits.
|
||||
rest_day: Declared rest day.
|
||||
first_done: Unix time the day's first earner was done, if any.
|
||||
sick_day: Sick day (``sickdays``): shutdown is at least 20:00
|
||||
plus the gaming pay of the credits
|
||||
(``earned_time.resolve(..., sick=True)``).
|
||||
first_credits: Each penalised gate's first real credit
|
||||
(:func:`~screen_locker._earned.first_credits`); ``None`` resolves
|
||||
on ``penalty_from`` alone, as before earned_time 0.6.
|
||||
"""
|
||||
|
||||
day: date
|
||||
flat: Mapping[str, int | bool | None]
|
||||
workout_credits: int = 0
|
||||
rest_day: bool = False
|
||||
first_done: float | None = None
|
||||
sick_day: bool = False
|
||||
first_credits: Mapping[str, date | None] | None = None
|
||||
|
||||
|
||||
def derive(inputs: DayInputs) -> Target:
|
||||
"""The pure formula: earned time, capped (a sick day stacks on 20:00)."""
|
||||
day = inputs.day
|
||||
answers: dict[str, int | bool | None] = dict(inputs.flat)
|
||||
answers[earned_time.WORKOUT.name] = workout_units(
|
||||
inputs.workout_credits, rest_day=inputs.rest_day
|
||||
)
|
||||
# The registry is passed explicitly: gate_answers iterated this same
|
||||
# day's registry, so the earners asked and the earners summed match.
|
||||
earners = registry(day)
|
||||
starts = inputs.first_credits
|
||||
sick = inputs.sick_day
|
||||
resolution = (
|
||||
earned_time.resolve(answers, day, earners, sick=sick)
|
||||
if starts is None
|
||||
else earned_time.resolve(answers, day, earners, first_credits=starts, sick=sick)
|
||||
)
|
||||
return Target(
|
||||
resolution=resolution,
|
||||
rest_day=inputs.rest_day,
|
||||
first_done_at=inputs.first_done,
|
||||
minutes=resolution.shutdown_minutes,
|
||||
)
|
||||
|
||||
|
||||
def day_credit_count(log_file: Path | None, day: date) -> int:
|
||||
"""How many distinct workouts ``day``'s log counts (0 without a log)."""
|
||||
if log_file is None:
|
||||
return 0
|
||||
iso = day.isoformat()
|
||||
entries = load_workout_log(log_file).get(iso, [])
|
||||
return count_day_credits(iso, [e for e in entries if isinstance(e, dict)])
|
||||
|
||||
|
||||
def gather_inputs(day: date, log_file: Path | None) -> DayInputs:
|
||||
"""Read ``day``'s real inputs: ledgers, log, rest days, sick pool."""
|
||||
rest_day = is_rest_day(day)
|
||||
return DayInputs(
|
||||
day=day,
|
||||
flat=gate_answers(day),
|
||||
workout_credits=day_credit_count(log_file, day),
|
||||
rest_day=rest_day,
|
||||
first_done=first_done_at(day, log_file, rest_day=rest_day),
|
||||
sick_day=is_sick(day),
|
||||
first_credits=first_credits(registry(day), day),
|
||||
)
|
||||
|
||||
|
||||
def gather(day: date, log_file: Path | None) -> Target:
|
||||
"""Derive ``day``'s target from the real ledgers, log and rest days."""
|
||||
return derive(gather_inputs(day, log_file))
|
||||
133
screen_locker/_sick.py
Normal file
133
screen_locker/_sick.py
Normal file
@ -0,0 +1,133 @@
|
||||
"""The one place screen-locker asks the shared ``sickdays`` pool.
|
||||
|
||||
A sick day is a date in that pool, nothing else: no budget, no countdown, no
|
||||
justification and no debt. Every consumer here (lock ladder, shutdown,
|
||||
status, MCP) goes through this module, so the answer cannot drift between
|
||||
them and tests have one seam.
|
||||
|
||||
Reading never raises: an unreadable pool is logged by ``sickdays`` and reads
|
||||
as *not sick* -- fail closed, the locks apply.
|
||||
|
||||
Two questions, deliberately not one: a sick day may *keep* some lockers active
|
||||
(``sickdays`` 0.2.0), so
|
||||
|
||||
* :func:`stands_down_workout` -- "is the workout lock off?" -- is sick AND the
|
||||
workout locker not kept. The lock ladder, its explanation, the status view
|
||||
and the missed-alarm penalty's penalised day ask this.
|
||||
* :func:`is_sick` -- "is the day sick at all?" -- feeds only the 20:00
|
||||
shutdown floor, which applies on every sick day whatever it keeps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sickdays
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_sick(day: date | None = None) -> bool:
|
||||
"""Whether ``day`` (default: today) is a sick day."""
|
||||
return sickdays.is_sick_day(day)
|
||||
|
||||
|
||||
def stands_down(locker: str, day: date | None = None) -> bool:
|
||||
"""Whether ``locker`` (a ``sickdays.LOCKER_*``) is off on ``day``.
|
||||
|
||||
For the few consumers that judge a date by a locker other than the workout
|
||||
one (the missed-alarm penalty's ring day is the alarm switch's).
|
||||
"""
|
||||
return sickdays.stands_down(locker, day)
|
||||
|
||||
|
||||
def stands_down_workout(day: date | None = None) -> bool:
|
||||
"""Whether the workout lock is off on ``day`` (default: today).
|
||||
|
||||
True iff the day is sick and does not keep ``sickdays.LOCKER_WORKOUT``.
|
||||
A kept workout lock behaves exactly as on a normal day.
|
||||
"""
|
||||
return stands_down(sickdays.LOCKER_WORKOUT, day)
|
||||
|
||||
|
||||
def sick_dates() -> frozenset[date]:
|
||||
"""Every date the pool marks sick, past and future."""
|
||||
return sickdays.sick_days()
|
||||
|
||||
|
||||
def workout_standdown_dates() -> frozenset[date]:
|
||||
"""Every sick date on which the workout lock is off (keep-aware)."""
|
||||
return frozenset(d for d in sickdays.sick_days() if stands_down_workout(d))
|
||||
|
||||
|
||||
def push_in_background() -> threading.Thread:
|
||||
"""Start one best-effort ``sickdays.sync_quietly`` on a background thread.
|
||||
|
||||
So the phone sees a lock-screen mark without waiting for the 15-minute
|
||||
sickdays-sync timer. Not a daemon: the lock window closes at once and the
|
||||
process lingers only until the push ends, instead of killing it mid-write.
|
||||
``sync_quietly`` never raises; a failed push is retried by that timer.
|
||||
"""
|
||||
worker = threading.Thread(
|
||||
target=sickdays.sync_quietly, name="sickdays-push", daemon=False
|
||||
)
|
||||
worker.start()
|
||||
return worker
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkResult:
|
||||
"""Outcome of :func:`mark_today_sick`.
|
||||
|
||||
Attributes:
|
||||
ok: Today is a sick day now (newly marked or already was).
|
||||
message: What to show the user: a confirmation, or why it was refused.
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
message: str
|
||||
|
||||
|
||||
def mark_today_sick() -> MarkResult:
|
||||
"""Mark today sick in the shared pool; every gate stands down for it.
|
||||
|
||||
The one tap is ALL-OFF. Already sick counts as success; if that sick day
|
||||
keeps some lockers, the keep set is cleared so every locker stands down,
|
||||
and a failure to clear it is a refusal (the lock stays up). Any other
|
||||
refusal (a free day, an unreadable pool, freedays not importable) is
|
||||
returned with sickdays' own explanation, so the lock screen can show it
|
||||
instead of unlocking.
|
||||
"""
|
||||
today = sickdays.today()
|
||||
try:
|
||||
sickdays.add_sick_day(today)
|
||||
except sickdays.AlreadySickError:
|
||||
if not sickdays.kept_lockers(today):
|
||||
_logger.warning("%s was already a sick day; unlocking", today)
|
||||
return MarkResult(ok=True, message="Today is already a sick day.")
|
||||
return _clear_kept_lockers(today)
|
||||
except sickdays.SickDayError as exc:
|
||||
_logger.warning("Could not mark %s as a sick day: %s", today, exc)
|
||||
return MarkResult(ok=False, message=str(exc))
|
||||
_logger.warning("Marked %s as a sick day from the lock screen", today)
|
||||
return MarkResult(ok=True, message="Today is a sick day. Get well soon.")
|
||||
|
||||
|
||||
def _clear_kept_lockers(today: date) -> MarkResult:
|
||||
"""Clear the keep set of an already-sick ``today`` so every locker stands down."""
|
||||
try:
|
||||
sickdays.set_kept_lockers(today, ())
|
||||
except sickdays.SickDayError as exc:
|
||||
_logger.warning("Could not clear the kept lockers of %s: %s", today, exc)
|
||||
return MarkResult(ok=False, message=str(exc))
|
||||
_logger.warning("%s was already a sick day; cleared its kept lockers", today)
|
||||
return MarkResult(
|
||||
ok=True,
|
||||
message="Today was already a sick day; every locker now stands down.",
|
||||
)
|
||||
@ -1,131 +0,0 @@
|
||||
"""The sick-day record and the rolling budget computed from it.
|
||||
|
||||
Split out of :mod:`screen_locker._sick_tracker` to keep every file under the
|
||||
250-line cap. Re-exported from there, so callers and their patch targets are
|
||||
unchanged.
|
||||
|
||||
Everything here is pure: it reads a :class:`SickHistory` already in memory.
|
||||
Loading and saving that history stays in ``_sick_tracker``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from screen_locker._constants import (
|
||||
SICK_BUDGET_PER_7_DAYS,
|
||||
SICK_BUDGET_PER_30_DAYS,
|
||||
SICK_BUDGET_PER_90_DAYS,
|
||||
SICK_LOCKOUT_MULTIPLIER_PER_RECENT,
|
||||
SICK_LOCKOUT_SECONDS,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SickHistory:
|
||||
"""Persistent sick-day bookkeeping."""
|
||||
|
||||
sick_days: list[str] = field(default_factory=list)
|
||||
debt: int = 0
|
||||
commitments: dict[str, bool] = field(default_factory=dict)
|
||||
broken_commitments: list[str] = field(default_factory=list)
|
||||
justifications: list[dict[str, Any]] = field(default_factory=list)
|
||||
# Sick days later disproved by a workout the locker could not read at the
|
||||
# time. Kept beside the justifications rather than folded into them: those
|
||||
# entries are HMAC-signed over the whole dict, so annotating one in place
|
||||
# would break verification and look like tampering.
|
||||
revocations: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
"""Return today's LOCAL date as ``YYYY-MM-DD`` (see ``screen_locker._day``)."""
|
||||
return today_str()
|
||||
|
||||
|
||||
def _parse_iso(date_str: str) -> datetime | None:
|
||||
"""Parse ``YYYY-MM-DD`` into a UTC datetime, or return None."""
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC)
|
||||
except ValueError as exc:
|
||||
_logger.warning(
|
||||
"Sick history holds an unparsable date %r (%s) — that entry is "
|
||||
"IGNORED when counting sick days",
|
||||
date_str,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def is_sick_day(history: SickHistory, *, today: str | None = None) -> bool:
|
||||
"""Return True if today is recorded as a sick day."""
|
||||
today_str = today or _today_iso()
|
||||
return today_str in history.sick_days
|
||||
|
||||
|
||||
def count_in_window(
|
||||
history: SickHistory,
|
||||
days: int,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> int:
|
||||
"""Return how many ``sick_days`` fall in the trailing ``days`` window."""
|
||||
today_str = today or _today_iso()
|
||||
today_dt = _parse_iso(today_str)
|
||||
if today_dt is None:
|
||||
return 0
|
||||
cutoff = today_dt - timedelta(days=days)
|
||||
count = 0
|
||||
for entry in history.sick_days:
|
||||
parsed = _parse_iso(entry)
|
||||
if parsed is None:
|
||||
continue
|
||||
if cutoff < parsed <= today_dt:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def is_budget_exhausted(
|
||||
history: SickHistory,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> bool:
|
||||
"""Return True if any rolling window has reached its sick budget."""
|
||||
return (
|
||||
count_in_window(history, 7, today=today) >= SICK_BUDGET_PER_7_DAYS
|
||||
or count_in_window(history, 30, today=today) >= SICK_BUDGET_PER_30_DAYS
|
||||
or count_in_window(history, 90, today=today) >= SICK_BUDGET_PER_90_DAYS
|
||||
)
|
||||
|
||||
|
||||
def compute_lockout_seconds(
|
||||
history: SickHistory,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> int:
|
||||
"""Escalating sick countdown: ``base * 2 ** recent_count_in_30d``."""
|
||||
recent = count_in_window(history, 30, today=today)
|
||||
multiplier = SICK_LOCKOUT_MULTIPLIER_PER_RECENT**recent
|
||||
return SICK_LOCKOUT_SECONDS * multiplier
|
||||
|
||||
|
||||
def budget_summary(
|
||||
history: SickHistory,
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> str:
|
||||
"""One-line UI summary string for budget + debt."""
|
||||
week = count_in_window(history, 7, today=today)
|
||||
month = count_in_window(history, 30, today=today)
|
||||
quarter = count_in_window(history, 90, today=today)
|
||||
return (
|
||||
f"Sick: {week}/{SICK_BUDGET_PER_7_DAYS}w · "
|
||||
f"{month}/{SICK_BUDGET_PER_30_DAYS}m · "
|
||||
f"{quarter}/{SICK_BUDGET_PER_90_DAYS}q · "
|
||||
f"Debt: {history.debt}"
|
||||
)
|
||||
@ -1,90 +0,0 @@
|
||||
"""The next-day commitment prompt shown after a sick day.
|
||||
|
||||
Split out of :mod:`screen_locker._sick_dialog` to keep every file under the
|
||||
250-line cap. Composed back into ``SickDialogMixin`` there, so callers see no
|
||||
change.
|
||||
|
||||
Taking a sick day asks for a commitment to train tomorrow; breaking it is
|
||||
recorded and costs budget, which is what stops sick days being free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _sick_tracker
|
||||
from screen_locker._constants import COMMITMENT_PROMPT_TIMEOUT_SECONDS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
class SickCommitmentMixin:
|
||||
"""Asks for, times out, and records the next-day training commitment."""
|
||||
|
||||
def _show_commitment_prompt(self, *, on_done: Callable[[], None]) -> None:
|
||||
"""Ask the user to commit to working out tomorrow.
|
||||
|
||||
Calls ``on_done()`` once the user answers or the timeout elapses.
|
||||
"""
|
||||
self.clear_container()
|
||||
self._label(
|
||||
"Commit to working out tomorrow?",
|
||||
role="display",
|
||||
color=self._colors.palette.warning,
|
||||
pad="md",
|
||||
)
|
||||
# ~80 chars at font_size 16 would render as one unbroken line past the
|
||||
# ~70-char readable range (rule 21) -- wrap it explicitly.
|
||||
prompt = self._text(
|
||||
"If you say YES and skip via 'I'm sick' tomorrow, "
|
||||
"the sick day costs 2x normal.",
|
||||
role="body",
|
||||
)
|
||||
prompt.config(wraplength=560, justify="center")
|
||||
self._commitment_done_fn = on_done
|
||||
self._commitment_remaining = COMMITMENT_PROMPT_TIMEOUT_SECONDS
|
||||
self._commitment_timer_label = self._text(
|
||||
f"Auto-skipping in {COMMITMENT_PROMPT_TIMEOUT_SECONDS}s",
|
||||
color=self._colors.palette.muted,
|
||||
)
|
||||
row = self._button_row()
|
||||
self._button(
|
||||
row,
|
||||
"YES",
|
||||
bg=self._colors.palette.success,
|
||||
command=lambda: self._answer_commitment(commit=True),
|
||||
width=12,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
self._button(
|
||||
row,
|
||||
"NO",
|
||||
bg=self._colors.palette.field_bg,
|
||||
command=lambda: self._answer_commitment(commit=False),
|
||||
width=12,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
self._tick_commitment_timeout()
|
||||
|
||||
def _tick_commitment_timeout(self) -> None:
|
||||
"""Advance commitment auto-skip timer; default to NO when it expires."""
|
||||
if self._commitment_remaining <= 0:
|
||||
self._answer_commitment(commit=False)
|
||||
return
|
||||
self._commitment_timer_label.config(
|
||||
text=f"Auto-skipping in {self._commitment_remaining}s",
|
||||
)
|
||||
self._commitment_remaining -= 1
|
||||
self.root.after(1000, self._tick_commitment_timeout)
|
||||
|
||||
def _answer_commitment(self, *, commit: bool) -> None:
|
||||
"""Persist the commitment answer and call the completion callback."""
|
||||
# Disable timer re-entry by zeroing remaining.
|
||||
self._commitment_remaining = -1
|
||||
if commit:
|
||||
history = _sick_tracker.load_history()
|
||||
_sick_tracker.record_commitment_for_tomorrow(history)
|
||||
_sick_tracker.save_history(history)
|
||||
done = getattr(self, "_commitment_done_fn", None)
|
||||
if done is not None:
|
||||
self._commitment_done_fn = None
|
||||
done()
|
||||
@ -1,219 +0,0 @@
|
||||
"""Sick-day justification + commitment dialog mixin for the screen locker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock import escape_text_tab_trap
|
||||
|
||||
from screen_locker import _sick_tracker
|
||||
from screen_locker._constants import (
|
||||
SICK_COMMITMENT_FORCED_READ_SECONDS,
|
||||
SICK_JUSTIFICATION_MIN_CHARS,
|
||||
SICK_SCREEN_RECENT_LINES,
|
||||
)
|
||||
from screen_locker._sick_commitment import SickCommitmentMixin
|
||||
from screen_locker._surface_group import FrameGroup, TextGroup
|
||||
from screen_locker._ui_widgets import disable_paste as _disable_paste
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from screen_locker._sick_tracker import SickHistory
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SickDialogMixin(SickCommitmentMixin):
|
||||
"""Renders the sick-day justification screen and commitment prompts."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sick-day justification dialog
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _show_sick_justification(self) -> None:
|
||||
"""Render the structured sick-day justification screen."""
|
||||
history = _sick_tracker.load_history()
|
||||
self._sick_history_cache: SickHistory = history
|
||||
self.clear_container()
|
||||
self._label("Sick Day Request", color=self._colors.palette.warning, pad="sm")
|
||||
self._text(
|
||||
_sick_tracker.budget_summary(history), color=self._colors.palette.warning
|
||||
)
|
||||
|
||||
recent = _sick_tracker.format_recent_justifications(
|
||||
history, n=SICK_SCREEN_RECENT_LINES
|
||||
)
|
||||
if recent:
|
||||
self._text(
|
||||
"Recent sick days:",
|
||||
role="label",
|
||||
color=self._colors.palette.muted,
|
||||
pad="sm",
|
||||
)
|
||||
self._text(recent, role="label", color=self._colors.palette.muted, pad="sm")
|
||||
|
||||
had_commitment = _sick_tracker.had_commitment_for_today(history)
|
||||
if had_commitment:
|
||||
self._text(
|
||||
"⚠ Yesterday you committed to working out today.",
|
||||
role="body",
|
||||
color=self._colors.palette.danger,
|
||||
)
|
||||
self._text(
|
||||
"Breaking the commitment costs 2 sick-budget days.",
|
||||
role="label",
|
||||
color=self._colors.palette.danger,
|
||||
)
|
||||
|
||||
self._build_justification_form(had_commitment=had_commitment)
|
||||
|
||||
def _build_justification_form(self, *, had_commitment: bool) -> None:
|
||||
"""Add justification form fields and submit button to the container."""
|
||||
form = self.container.child_frame(bg=self._colors.palette.bg)
|
||||
form.pack(pady=self._colors.space("sm"))
|
||||
|
||||
self._sick_symptom_var = tk.StringVar()
|
||||
self._sick_onset_var = tk.StringVar()
|
||||
self._sick_severity_var = tk.IntVar(value=5)
|
||||
self._sick_text_widget = self._add_form_widgets(form)
|
||||
|
||||
self._sick_error_label = self._text(
|
||||
"", color=self._colors.palette.danger, pad="sm"
|
||||
)
|
||||
|
||||
button_row = self._button_row()
|
||||
# Starts disabled during the forced-read delay -- field_bg (our
|
||||
# neutral/secondary token) signals "not yet actionable" better than
|
||||
# the accent used for an immediately-clickable submit.
|
||||
self._sick_submit_button = self._button(
|
||||
button_row,
|
||||
"SUBMIT",
|
||||
bg=self._colors.palette.field_bg,
|
||||
command=self._submit_sick_justification,
|
||||
width=12,
|
||||
)
|
||||
self._sick_submit_button.pack(side="left", padx=self._colors.space("sm"))
|
||||
self._button(
|
||||
button_row,
|
||||
"BACK",
|
||||
bg=self._colors.palette.field_bg,
|
||||
command=self._start_phone_check,
|
||||
width=12,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
|
||||
if had_commitment:
|
||||
self._sick_submit_button.config(state="disabled")
|
||||
self._commitment_forced_remaining = SICK_COMMITMENT_FORCED_READ_SECONDS
|
||||
self._update_commitment_forced_delay()
|
||||
|
||||
def _add_form_widgets(self, parent: FrameGroup) -> TextGroup:
|
||||
"""Create symptom/onset/severity/text widgets. Returns the text widget."""
|
||||
self._add_label_entry(
|
||||
parent,
|
||||
label="Symptom (e.g. fever, nausea):",
|
||||
variable=self._sick_symptom_var,
|
||||
focus=True,
|
||||
)
|
||||
self._add_label_entry(
|
||||
parent,
|
||||
label="When did it start? (e.g. last night):",
|
||||
variable=self._sick_onset_var,
|
||||
)
|
||||
sev_row = parent.child_frame(bg=self._colors.palette.bg)
|
||||
sev_row.pack(pady=self._colors.space("sm"))
|
||||
sev_row.child_widgets(
|
||||
tk.Label,
|
||||
text="Severity (1-10):",
|
||||
font=self._colors.font("label"),
|
||||
fg=self._colors.palette.fg,
|
||||
bg=self._colors.palette.bg,
|
||||
).pack(side="left", padx=self._colors.space("xs"))
|
||||
sev_row.child_widgets(
|
||||
tk.Spinbox,
|
||||
from_=1,
|
||||
to=10,
|
||||
textvariable=self._sick_severity_var,
|
||||
width=4,
|
||||
font=self._colors.font("label"),
|
||||
).pack(side="left", padx=self._colors.space("xs"))
|
||||
|
||||
parent.child_widgets(
|
||||
tk.Label,
|
||||
text=(f"Describe how you feel (min {SICK_JUSTIFICATION_MIN_CHARS} chars):"),
|
||||
font=self._colors.font("label"),
|
||||
fg=self._colors.palette.fg,
|
||||
bg=self._colors.palette.bg,
|
||||
).pack(pady=self._colors.space("sm"))
|
||||
text_widgets = TextGroup(
|
||||
list(
|
||||
parent.child_widgets(
|
||||
tk.Text,
|
||||
width=60,
|
||||
height=6,
|
||||
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(),
|
||||
)
|
||||
)
|
||||
)
|
||||
text_widgets.pack(pady=self._colors.space("sm"))
|
||||
for text_widget in text_widgets:
|
||||
_disable_paste(text_widget)
|
||||
# Tk traps <Tab> inside a Text (inserts a tab, refocuses itself) and
|
||||
# no-ops <Shift-Tab>, so without this a keyboard-only user who tabs
|
||||
# into the justification box can never reach SUBMIT -- inside a lock
|
||||
# whose only other exit is waiting out the countdown.
|
||||
escape_text_tab_trap(text_widget)
|
||||
return text_widgets
|
||||
|
||||
def _update_commitment_forced_delay(self) -> None:
|
||||
"""Tick down the forced-read delay then enable the submit button."""
|
||||
if self._commitment_forced_remaining > 0:
|
||||
self._sick_submit_button.config(
|
||||
text=f"WAIT {self._commitment_forced_remaining}s",
|
||||
)
|
||||
self._commitment_forced_remaining -= 1
|
||||
self.root.after(1000, self._update_commitment_forced_delay)
|
||||
else:
|
||||
self._sick_submit_button.config(text="SUBMIT", state="normal")
|
||||
|
||||
def _submit_sick_justification(self) -> None:
|
||||
"""Validate the form and either show an error or proceed to countdown."""
|
||||
symptom = self._sick_symptom_var.get()
|
||||
onset = self._sick_onset_var.get()
|
||||
try:
|
||||
severity = int(self._sick_severity_var.get())
|
||||
except (tk.TclError, ValueError) as exc:
|
||||
_logger.warning(
|
||||
"Sick-day severity is not an integer (%s) — submitting it as 0, "
|
||||
"which the justification validator will reject",
|
||||
exc,
|
||||
)
|
||||
severity = 0
|
||||
text = self._sick_text_widget.get("1.0", "end").strip()
|
||||
draft = _sick_tracker.JustificationDraft(
|
||||
symptom=symptom,
|
||||
onset=onset,
|
||||
severity=severity,
|
||||
text=text,
|
||||
)
|
||||
error = _sick_tracker.validate_justification(draft)
|
||||
if error is not None:
|
||||
self._sick_error_label.config(text=error)
|
||||
return
|
||||
|
||||
history = self._sick_history_cache
|
||||
_sick_tracker.add_justification(history, draft)
|
||||
if not _sick_tracker.save_history(history):
|
||||
self._sick_error_label.config(
|
||||
text="Could not persist sick history — try again",
|
||||
)
|
||||
return
|
||||
self._proceed_to_sick_countdown()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Commitment prompt (after a verified workout)
|
||||
# ------------------------------------------------------------------
|
||||
87
screen_locker/_sick_floor.py
Normal file
87
screen_locker/_sick_floor.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""Sick-day shutdown, applied live: 20:00 plus the day's gaming pay.
|
||||
|
||||
The daily reset already resolves a sick day with ``earned_time.resolve(...,
|
||||
sick=True)``. But a day is usually marked sick *after* that reset ran --
|
||||
at 10:00 from the lock screen or the phone -- and the reset is stamped once
|
||||
per day. This pass closes the gap: on every 5-minute locker tick, every
|
||||
``--sync-only`` / ``--apply-bonuses`` pass and right after the lock screen's
|
||||
"I'm sick" tap, it raises tonight's shutdown to the derived sick target
|
||||
(:func:`sick_target`: 20:00 plus every credit's gaming pay, earned_time 0.13.0).
|
||||
|
||||
Credits stack on 20:00 (kuhy, 2026-10-17: LeetCode + reading is 22:00, not
|
||||
the 20:10 that 0.11.0's non-stacking floor gave). The target is absolute,
|
||||
re-derived from the ledgers, so on a sick day a live credit raises to it
|
||||
instead of adding (``ShutdownMixin``): the new row is already in the sum.
|
||||
No lift is recorded, so nothing is absorbed.
|
||||
|
||||
Raise-only. Un-marking today does not lower the config until the next
|
||||
daily reset re-derives it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._bonus_lock import bonus_lock
|
||||
from screen_locker._constants import GRACE_STATE_FILE
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import hhmm
|
||||
from screen_locker._grace_floor import save_lift, tonight_minutes
|
||||
from screen_locker._shutdown_target import gather
|
||||
from screen_locker._sick import is_sick
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sick_target(day: date, log_file: Path | None) -> int:
|
||||
"""Tonight's sick-day shutdown, re-derived from the ledgers and the log."""
|
||||
return gather(day, log_file).minutes
|
||||
|
||||
|
||||
class SickFloorMixin:
|
||||
"""Lifts tonight's shutdown to the sick floor; 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:
|
||||
"""Raise tonight's shutdown to the sick target if today is sick.
|
||||
|
||||
Returns True when the config was raised on this call.
|
||||
"""
|
||||
today = today_str()
|
||||
day = date.fromisoformat(today)
|
||||
if not is_sick(day):
|
||||
return False
|
||||
with bonus_lock(GRACE_STATE_FILE):
|
||||
config = self._read_shutdown_config()
|
||||
if config is None:
|
||||
_logger.warning("Sick floor: shutdown config unreadable; not applied")
|
||||
return False
|
||||
current = tonight_minutes(config, day)
|
||||
floor = sick_target(day, getattr(self, "log_file", None))
|
||||
if floor <= current:
|
||||
return False
|
||||
mon_wed, thu_sun, morning = config
|
||||
if not self._write_shutdown_config(
|
||||
max(mon_wed, floor), max(thu_sun, floor), morning, restore=True
|
||||
):
|
||||
_logger.warning("Sick floor: failed to write shutdown config")
|
||||
return False
|
||||
save_lift(0, GRACE_STATE_FILE) # stacked: nothing to absorb
|
||||
_logger.info(
|
||||
"Sick floor: shutdown %s -> %s (sick day).", hhmm(current), hhmm(floor)
|
||||
)
|
||||
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