mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 21:27:13 +01:00
Compare commits
110 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 |
4
.github/workflows/pre-commit.yml
vendored
4
.github/workflows/pre-commit.yml
vendored
@ -60,7 +60,7 @@ jobs:
|
||||
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
|
||||
if: matrix.group == 'flutter'
|
||||
with:
|
||||
flutter-version: 3.47.7
|
||||
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.
|
||||
@ -83,7 +83,7 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
if: matrix.group == 'rest'
|
||||
with:
|
||||
node-version: "24.22.0"
|
||||
node-version: "26.13.0"
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
- name: Install status-UI packages
|
||||
|
||||
8
.github/workflows/python-tests.yml
vendored
8
.github/workflows/python-tests.yml
vendored
@ -58,11 +58,11 @@ jobs:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: requirements.txt
|
||||
|
||||
# No apt step: the runner image already ships xvfb-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: |
|
||||
@ -99,7 +99,7 @@ jobs:
|
||||
# cache cost ~38 s to restore even on a hit.
|
||||
- uses: kuhyx/utils/flutter-setup@flutter-setup-v1
|
||||
with:
|
||||
flutter-version: 3.47.7
|
||||
flutter-version: 3.47.9
|
||||
working-directory: stronglift_replacement/workout_app
|
||||
|
||||
- name: flutter test --coverage, bucket ${{ matrix.shard }}
|
||||
@ -136,7 +136,7 @@ jobs:
|
||||
package_json_file: web/package.json
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.22.0
|
||||
node-version: 26.13.0
|
||||
cache: pnpm
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
|
||||
2
.github/workflows/release-apk.yml
vendored
2
.github/workflows/release-apk.yml
vendored
@ -60,7 +60,7 @@ jobs:
|
||||
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: 3.47.7
|
||||
flutter-version: 3.47.9
|
||||
channel: stable
|
||||
cache: true
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -38,6 +38,7 @@ screen_locker/early_bird_pending.json
|
||||
# Web status UI (web/) build and tooling output.
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
web/.astro/
|
||||
web/coverage/
|
||||
web/node_modules
|
||||
web/dist
|
||||
|
||||
@ -8,13 +8,18 @@ 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$
|
||||
@ -43,7 +48,7 @@ repos:
|
||||
entry: scripts/check_repo_contract.sh
|
||||
language: system
|
||||
pass_filenames: false
|
||||
files: ^(CLAUDE\.md|AGENTS\.md|scripts/test_changed\.sh)$
|
||||
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)'
|
||||
@ -106,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 --
|
||||
@ -119,36 +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.9.0#subdirectory=gatelock
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time
|
||||
- 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.2.0#subdirectory=sickdays
|
||||
- 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
|
||||
@ -156,11 +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.9.0#subdirectory=gatelock
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time
|
||||
- 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.2.0#subdirectory=sickdays
|
||||
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
|
||||
- mcp>=2,<3
|
||||
- pydantic
|
||||
|
||||
@ -179,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
|
||||
@ -220,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
|
||||
@ -232,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
|
||||
@ -252,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]
|
||||
|
||||
@ -268,14 +358,14 @@ repos:
|
||||
name: eslint (status web UI)
|
||||
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)
|
||||
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
|
||||
@ -297,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.
|
||||
#
|
||||
|
||||
25
AGENTS.md
25
AGENTS.md
@ -59,12 +59,25 @@ made the code's default dead.
|
||||
A sick day is a date in the shared `sickdays` pool, read only through
|
||||
`_sick.py`. It may keep lockers: the workout lock asks
|
||||
`_sick.stands_down_workout` (keep-aware), while everything that only computes
|
||||
shutdown (this floor, `_grace_floor`, `_shutdown_base`, `_shutdown_target`,
|
||||
the preview scripts) asks `is_sick` and ignores keep. The reset resolves it with `earned_time.resolve(..., sick=True)`
|
||||
(shutdown >= 20:00) and saves the lift above the earned sum like the grace
|
||||
lift; a day marked after the reset is lifted by `_sick_floor` on the next
|
||||
live pass. Both are floors, never terms: later credits are paid out of the
|
||||
lift first (`_grace_floor.plan_absorb`), so nothing stacks on 20:00.
|
||||
shutdown (`_sick_floor`, `_shutdown_base`, `_shutdown_target`, the preview
|
||||
scripts) asks `is_sick` and ignores keep. A sick day's shutdown is absolute:
|
||||
20:00 plus the GAMING pay of everything credited that day, capped at 23:00
|
||||
(`earned_time.sick_shutdown_from`, 0.13). The reset writes it, `_sick_floor`
|
||||
re-derives it on every live pass from the ledgers, and a sick-day credit only
|
||||
raises to it (`_raise_on_sick_day`), never adds -- its row is already in the
|
||||
sum. No lift is saved on a sick day.
|
||||
|
||||
## A shutdown never blocks submitting a finished task
|
||||
|
||||
A credit timed at or after tonight's shutdown moves tonight to
|
||||
`min(23:00, the credit's OWN time + its reward)` (`_late_credit.late_tonight`):
|
||||
only the 23:00 ceiling is hard. The anchor is the credit's own time (the run's
|
||||
end via `_workout_ledger.entry_done_at`, the ledger row via
|
||||
`_earned.first_credit_time`), never the sync time, so a late upload buys
|
||||
nothing. It is live-only: the next day's reset re-derives from scratch. This
|
||||
replaced the first-credit +60 grace floor (gone 2026-10-17); `_grace_floor`
|
||||
keeps only `first_done_at`, `tonight_minutes` and the lift file, which
|
||||
`plan_absorb` spends once if a stale one is left.
|
||||
|
||||
Flat earners are read from their gate's ledger by `earned_time.done_today`
|
||||
via `_earned.py`: HMAC-verified `credit` rows matched by the earner's own
|
||||
|
||||
36
README.md
36
README.md
@ -10,13 +10,15 @@ A sick day is a date in the fleet-wide [`sickdays`](https://github.com/kuhyx/uti
|
||||
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 and the grace floor stand down,
|
||||
wake-alarm's workday penalty is void, and shutdown is
|
||||
`max(20:00, the normal ladder)` capped at 23:00 (`earned_time.resolve(...,
|
||||
sick=True)`). A day marked after its daily reset is lifted to 20:00 by the
|
||||
next 5-minute pass (`_sick_floor`); a workout that day still earns normally
|
||||
and is paid out of that lift first, so it lands on the ladder value (e.g.
|
||||
20:40), never 20:00 plus its whole reward. `install.sh` migrates the old
|
||||
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
|
||||
@ -46,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,24 +0,0 @@
|
||||
# Cap every live shutdown bonus at the day's ceiling, not 24:00 / 23:00
|
||||
|
||||
Carried over from the 2026-10-10 tutor-cutover handoff (item 1a, optional).
|
||||
|
||||
## what
|
||||
The live bonus paths clamp at 24:00 (`_shutdown.py` `_MIDNIGHT`, ~:152) and
|
||||
then at `adjust_shutdown_schedule.sh`'s fixed `RESTORE_CEILING=1380` (23:00).
|
||||
If `WAKE_MINUTES` ever moves the earned_time ceiling below 23:00, a bonus could
|
||||
push shutdown past the real ceiling. Cap them at
|
||||
`earned_time.shutdown_ceiling_for(day)` instead (already wrapped in
|
||||
`_earned.py` ~:167).
|
||||
|
||||
## where
|
||||
`screen_locker/_shutdown.py`, `screen_locker/adjust_shutdown_schedule.sh`,
|
||||
`scripts/_preview_sim.py` (`HELPER_CEILING` mirrors `RESTORE_CEILING`).
|
||||
|
||||
## must
|
||||
- The 168-case "do every task => 23:00" invariant test (fc54534) stays green.
|
||||
- must not: loosen anything — the shell ceiling remains a hard upper bound.
|
||||
|
||||
## done
|
||||
A test that sets a ceiling below 23:00 shows no bonus path exceeding it.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -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
|
||||
@ -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
|
||||
@ -17,6 +17,8 @@ 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
|
||||
@ -71,7 +73,7 @@ ensure_runtime_deps() {
|
||||
echo "WARNING: could not install $1 automatically" >&2
|
||||
}
|
||||
_pip_runtime_dep gatelock \
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#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.2#subdirectory=crdt-sync"
|
||||
}
|
||||
@ -147,6 +149,12 @@ cp "$SCRIPT_DIR/$RUNNERUP_WATCH_NAME" "$USER_SERVICE_DIR/$RUNNERUP_WATCH_NAME"
|
||||
bash "$SCRIPT_DIR/scripts/workout_poke_keygen.sh"
|
||||
cp "$SCRIPT_DIR/$WORKOUT_POKE_NAME" "$USER_SERVICE_DIR/$WORKOUT_POKE_NAME"
|
||||
|
||||
# The RunnerUp retirement: a week after the workout app's first credited run
|
||||
# it backs the old app's exports up and uninstalls it from the phone. A daily
|
||||
# timer, because the phone is not always attached; a no-op until then.
|
||||
cp "$SCRIPT_DIR/$RETIRE_SERVICE_NAME" "$USER_SERVICE_DIR/$RETIRE_SERVICE_NAME"
|
||||
cp "$SCRIPT_DIR/$RETIRE_TIMER_NAME" "$USER_SERVICE_DIR/$RETIRE_TIMER_NAME"
|
||||
|
||||
# Update paths in the service file to use absolute paths
|
||||
REPO_ROOT="$SCRIPT_DIR"
|
||||
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
||||
@ -159,25 +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"
|
||||
|
||||
# Enable the instant bonus pass on ledger writes
|
||||
systemctl --user enable --now "$BONUS_PATH_NAME"
|
||||
|
||||
# Enable the instant RunnerUp credit on upload (restart picks up a new script)
|
||||
systemctl --user enable "$RUNNERUP_WATCH_NAME"
|
||||
systemctl --user restart "$RUNNERUP_WATCH_NAME"
|
||||
|
||||
# Enable the LAN workout poke listener (restart picks up new code)
|
||||
systemctl --user enable "$WORKOUT_POKE_NAME"
|
||||
systemctl --user restart "$WORKOUT_POKE_NAME"
|
||||
# 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,211 +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` and `early_bird_extended` fields, filled by
|
||||
`_status_data.py` ~:210-214. **Grep confirms no renderer reads them.**
|
||||
- `_extra_benefits.py` `process_week_transition()` (~:82, reward strings built at
|
||||
~:130-139) produces fully-formed celebratory sentences — and
|
||||
`_startup_checks.py` ~:57-58 sends them to `_logger.info` and nowhere else.
|
||||
- The one success moment that exists is on the **lock screen**:
|
||||
`_unlock_view.py` `unlock_screen()` (~:25) paints "Great job! 💪" and
|
||||
`🔥 {streak}-week streak`, for `_UNLOCK_DELAY_MS = 1500` before routing away.
|
||||
|
||||
So the surface the user visits **voluntarily** — the status window, opened from
|
||||
the tray icon — shows enforcement and budget depletion, and no evidence of
|
||||
success at all. This is the cheapest high-value change in the whole project:
|
||||
the data already exists, is already true, and needs no new state.
|
||||
|
||||
## where
|
||||
|
||||
Repo: `~/src/screen-locker`.
|
||||
|
||||
Primary:
|
||||
- `screen_locker/_status_sections.py` — the section renderers used by the status
|
||||
window (`_section_*` functions). **177 lines; has headroom.** Add
|
||||
`_section_streak` here.
|
||||
- `screen_locker/status_view.py` — `StatusWindow`, `render()`. Call the new
|
||||
section from here. ⚠️ **246 lines — only 4 left under the 250 cap.** Expect to
|
||||
extract something, or add the call in a way that costs ~1 line.
|
||||
- `screen_locker/_status_types.py` — `StatusSnapshot` (~:88-101), the fields you
|
||||
are rendering. Read-only for this task.
|
||||
- `screen_locker/_extra_benefits.py` (179 lines) — `process_week_transition()`
|
||||
and its reward strings; `current_streak()`, `has_extended_early_bird()`.
|
||||
- `screen_locker/_startup_checks.py` ~:57-58 — where the reward strings are
|
||||
currently discarded into a log call.
|
||||
- `screen_locker/_unlock_view.py` (72 lines, lots of headroom) — the existing
|
||||
success moment, if you extend it.
|
||||
|
||||
## must
|
||||
|
||||
- **Render the already-populated fields.** Add `_section_streak` to
|
||||
`_status_sections.py` showing the weekly streak and the extended-early-bird
|
||||
state, and call it from `status_view.py`'s `render()`.
|
||||
Zero new state, zero conftest change, zero new dependency.
|
||||
- **Stop discarding the reward strings.** `process_week_transition()` returns
|
||||
human-readable celebratory sentences that currently only reach the log. Surface
|
||||
them where the user will actually see them — the status window is the obvious
|
||||
home; a milestone is worth more than a silent counter increment.
|
||||
- Match the existing positive-status styling rather than inventing one:
|
||||
`_status_sections.py` ~:73-78 already renders `"{n} above the weekly minimum!"`
|
||||
in success green, and uses `✓` marks at ~:36 and ~:57.
|
||||
- Keep the streak **true**. It counts real ISO weeks with ≥5 counted workouts
|
||||
(`_weekly_check.py`: `WEEKLY_WORKOUT_MINIMUM = 5`, `COUNTED_WORKOUT_TYPES =
|
||||
{phone_verified, runnerup_verified, manual_workout}`). Do not round, pad, or
|
||||
"encourage" the number.
|
||||
|
||||
- must not: **make any reward reduce enforcement.** This is recorded in the
|
||||
repo as a deliberate decision:
|
||||
> `_startup_checks.py` — "there is no banked 'skip a workout' credit —
|
||||
> that mechanic works against the goal of maximizing weekly workouts, so it was
|
||||
> removed."
|
||||
|
||||
Every reward here is **display only**. Do not reintroduce a
|
||||
banked skip, a streak freeze, a grace day, or any mechanic that lowers the
|
||||
floor. A dopamine loop that lets you skip the workout defeats the app.
|
||||
- must not: **grant shutdown time for a streak, a week or a milestone.** The
|
||||
weekly banked shutdown bonus (and the 4-week `_MILESTONE_INTERVAL` +1h) was
|
||||
removed on purpose (2026-10-10): every shutdown minute comes from the
|
||||
`earned_time` ladder and stops at its daily ceiling. Rewards here are display
|
||||
only — no "+Nh" weekly reward, no bonus-hours field, no new shutdown source.
|
||||
- must not: add **sound** to any enforcement or lock path. screen-locker is a
|
||||
self-restriction tool; a chime on lock is punishment audio, and a chime on
|
||||
unlock trains the wrong association. This repo stays silent. (Sound is
|
||||
opt-out-on elsewhere in the fleet; here it is simply absent.)
|
||||
- must not: add a write tool to the MCP server. `_mcp.py` ~:14-26 states it is
|
||||
read-only by design. Adding `streak` to the existing `get_status` output is
|
||||
fine; a new action tool violates a stated invariant.
|
||||
- must not: add `# noqa` (banned repo-wide) or a silent `except`
|
||||
(`scripts/check_silent_failures.py` — every handler re-raises or logs at
|
||||
warning or above).
|
||||
- must not: create a new on-disk state file unless genuinely unavoidable. If you
|
||||
do, you **must** add it to `_ISOLATED_STATE` in
|
||||
`screen_locker/tests/conftest.py` (~:134), listing *every* module that binds
|
||||
the path at import time — a missed binding lets tests write to kuhy's real
|
||||
state. Read the comment at ~:131-133 and the fixture docstring at ~:170-175.
|
||||
|
||||
- optional: a daily streak alongside the weekly one. Note this **would** be new
|
||||
state (the existing streak is consecutive-*weeks*), so it triggers the conftest
|
||||
requirement above. diet-guard's consecutive-*day* streak
|
||||
(`~/src/diet-guard/diet_guard/_daystatus.py` ~:144) is the precedent to mirror.
|
||||
|
||||
## done
|
||||
|
||||
1. Opening the status window (tray icon → click, or `screen-locker-status`) shows
|
||||
the current streak and early-bird state — with the real values
|
||||
from `extra_benefits_state.json`, not placeholders.
|
||||
2. A week-transition milestone reaches the UI instead of only `_logger.info`.
|
||||
3. `cd ~/src/screen-locker && python -m pytest` passes.
|
||||
4. `pre-commit run --files <changed files>` is clean — including the 250-line cap
|
||||
(`scripts/check_file_length.py`) and the silent-failure check.
|
||||
5. `git diff` touches no enforcement logic: no change to lock decisions, weekly
|
||||
minimums, or shutdown floors.
|
||||
|
||||
## verify
|
||||
|
||||
Desktop — this is a desktop Python app.
|
||||
|
||||
```
|
||||
cd ~/src/screen-locker
|
||||
python -m screen_locker --status # CLI: confirm the values it prints
|
||||
screen-locker-status # the Tk window: confirm they render
|
||||
```
|
||||
|
||||
Paste both outputs. The CLI already prints the streak and early-bird state
|
||||
(`_status.py`), so it is your ground truth: the window must show the **same numbers**.
|
||||
If they disagree, the renderer is wrong, not the data.
|
||||
|
||||
Current real state for comparison: `screen_locker/extra_benefits_state.json` and
|
||||
`screen_locker/log.json` (12 days of entries, 2026-07-12 → 2026-08-15).
|
||||
|
||||
## read first
|
||||
|
||||
- `screen_locker/_status_sections.py` — every existing `_section_*`. Match their
|
||||
signature and style; note the success-green precedent at ~:73-78.
|
||||
- `screen_locker/_status_types.py` ~:88-101 — the three fields you are rendering.
|
||||
- `screen_locker/_extra_benefits.py` — especially the module docstring ~:5-8
|
||||
(the enforcement invariant) and the reward strings ~:130-139.
|
||||
- `screen_locker/_unlock_view.py` — the existing celebration, for tone.
|
||||
- `screen_locker/status_view.py` — check its line count **before** editing.
|
||||
- `~/src/screen-locker/CLAUDE.md` — repo rules (no silent failures, ruff `select=ALL`).
|
||||
|
||||
## context you would otherwise rediscover
|
||||
|
||||
- **`log.json` is the single source of truth**, written only through
|
||||
`_log_mixin.py` `write_signed_entry()` (~:82), HMAC-signed via
|
||||
`gatelock.log_integrity`, deduped on `workout_id`. The Flutter app converges on
|
||||
it via `_manual_push.py`. There is no second history — a streak computed from
|
||||
it is authoritative.
|
||||
- All persistence is **JSON files, no sqlite**; paths declared in
|
||||
`screen_locker/_constants.py`. The streak store is
|
||||
`screen_locker/extra_benefits_state.json` (`consecutive_5plus_weeks`,
|
||||
`last_processed_iso_week`, `extended_early_bird_iso_weeks`; a legacy
|
||||
`weekly_shutdown_bonus_hours` map is ignored and dropped on the next write).
|
||||
- The threshold lives in `_extra_benefits.py`: `_BONUS_THRESHOLD = 5` (workouts
|
||||
per week, for the streak and the early-bird extension). There is no milestone
|
||||
constant any more.
|
||||
- **There is no config system** — no argparse, no env vars, no config file. Every
|
||||
tunable is a module-level constant. An opt-out toggle has no existing home;
|
||||
for a self-restriction tool, requiring a code edit to disable is arguably
|
||||
correct, so prefer a constant in `_constants.py` over new state.
|
||||
- `~/gatelock` is **not** a repo — it is `~/src/utils/gatelock/gatelock/`, consumed as
|
||||
a pinned git dep, and it holds no workout state.
|
||||
- Manual workouts were **not** removed: `_manual_workout.py` implements a
|
||||
rate-limited, evidence-gated subsystem (budget 2 per 7 days, 10 per 30).
|
||||
- Three UI surfaces exist: the Tk status window (`status_view.py`), a GTK tray
|
||||
icon at `~/.config/i3/scripts/screen_locker_tray.py` (outside the repo, shells
|
||||
out to the CLI, refreshes every 60s), and the lock screen itself. No i3blocks
|
||||
integration exists despite `format_summary_line` being described as one.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -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
|
||||
@ -7,11 +7,12 @@ requires-python = ">=3.14"
|
||||
# crdt-sync: shared CRDT merge scheme + sync transports, also used by
|
||||
# diet_guard; here for phone-workout sync, Firebase RTDB only since 2026-10-09
|
||||
dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock",
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.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.11.0#subdirectory=earned_time",
|
||||
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays",
|
||||
"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]
|
||||
@ -97,16 +98,6 @@ unfixable = []
|
||||
# -truth reuse status_view.py above is exempted for — and _make_bare_verifier
|
||||
# imports ScreenLocker inside the function to avoid an import cycle.
|
||||
"screen_locker/_status_view_verify.py" = ["PLC0415", "SLF001"]
|
||||
# _workout_handoff.py must pause gatelock's recovery watchdog while the Flutter
|
||||
# app holds the screen. The loop re-takes any grab it finds missing every
|
||||
# 1000ms -- by design, so that pulling a cable is not an escape -- and
|
||||
# holds_grab() is `grab_current() is not None`, which is False once another X
|
||||
# client holds it. Left running it would steal the screen back from the workout
|
||||
# app within a second. gatelock exposes no public pause (only close(), which
|
||||
# also restores VT switching and would hand the user Ctrl+Alt+F2), and adding
|
||||
# one there would contradict _recovery.py's monotonicity proof that no
|
||||
# transition ever decreases the grab.
|
||||
"screen_locker/_workout_handoff.py" = ["SLF001"]
|
||||
# explain_lock_decision takes one arg per real file path and input, and
|
||||
# returns early per lock-decision-chain stage (see module docstring) — both
|
||||
# are the deliberate design, not something to collapse into a config object.
|
||||
@ -180,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]
|
||||
@ -200,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 = [
|
||||
@ -231,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",
|
||||
@ -245,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/*",
|
||||
|
||||
@ -17,6 +17,8 @@ UNITS=(
|
||||
"earner-bonus.service"
|
||||
"runnerup-watch.service"
|
||||
"workout-poke.service"
|
||||
"runnerup-retire.timer"
|
||||
"runnerup-retire.service"
|
||||
)
|
||||
|
||||
for unit in "${UNITS[@]}"; do
|
||||
|
||||
@ -2,27 +2,28 @@
|
||||
# Runtime: tkinter/subprocess/socket/sqlite3 (stdlib) plus gatelock (below).
|
||||
bandit==1.9.4
|
||||
codespell==2.4.3
|
||||
coverage==7.16.2
|
||||
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.11.0#subdirectory=earned_time
|
||||
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.9.0#subdirectory=gatelock
|
||||
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.0
|
||||
mypy==2.4.1
|
||||
pre-commit==4.7.0
|
||||
pylint==4.1.3
|
||||
pytest==9.1.1
|
||||
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.17.1
|
||||
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays
|
||||
types-requests==2.33.0.20261006
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@ -24,7 +24,9 @@ import sys
|
||||
|
||||
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
|
||||
|
||||
@ -71,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,
|
||||
},
|
||||
)
|
||||
|
||||
@ -149,6 +153,34 @@ class AutoUpgradeMixin(_ReasonsMixin):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _stand_down_for_carrot(self, skip: MorningSkip) -> None:
|
||||
"""Take an armed lock down because the carrot landed after it armed.
|
||||
|
||||
Does what the ``wake_alarm_skip`` rung above does -- bank the pending
|
||||
marker so the post-carrot run still tries the phone workout, record the
|
||||
decision -- then leaves through the locker's normal ``close`` (the same
|
||||
one a credited workout and a test hold use): lock window released,
|
||||
instance lock dropped, exit 0. Nothing is credited or penalised.
|
||||
"""
|
||||
self._save_early_bird_pending()
|
||||
record_decision(
|
||||
LockDecision(
|
||||
locked=False,
|
||||
reason="wake_alarm_skip_late",
|
||||
detail=f"Morning session carrot landed after the lock armed: {skip}.",
|
||||
extra={
|
||||
"exempt_until": skip.exempt_until.isoformat(),
|
||||
"outcome": skip.outcome,
|
||||
},
|
||||
)
|
||||
)
|
||||
_logger.warning(
|
||||
"Morning-session carrot appeared while the lock was up (%s) — "
|
||||
"taking the lock down; the early-bird marker is banked.",
|
||||
skip,
|
||||
)
|
||||
self.close()
|
||||
|
||||
def _try_auto_upgrade_early_bird(self) -> bool:
|
||||
"""Try phone then RunnerUp to upgrade an early_bird log entry."""
|
||||
try:
|
||||
|
||||
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
|
||||
@ -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,6 +38,7 @@ 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
|
||||
@ -59,6 +60,142 @@ __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,
|
||||
@ -66,6 +203,7 @@ def explain_lock_decision(
|
||||
weekly_minimum_met: bool,
|
||||
relaxed_day: bool,
|
||||
wake_skip: bool = False,
|
||||
evening_lockdown: bool = False,
|
||||
now: datetime | None = None,
|
||||
degraded_sources: Sequence[DegradedSource] = (),
|
||||
) -> LockExplanation:
|
||||
@ -76,144 +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(local_dt.date())
|
||||
logged = has_logged_today(log_file, today=today_str)
|
||||
relaxed_day_already_skipped = relaxed_day and is_relaxed_day_skipped_today(
|
||||
log_file, today=today_str
|
||||
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,
|
||||
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 a sick day in the shared sickdays pool.",
|
||||
reason_false="Today is not a sick day.",
|
||||
terminal_reason="Sick day today — lock skipped.",
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
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(
|
||||
@ -225,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,
|
||||
)
|
||||
|
||||
@ -53,13 +53,13 @@ WORKOUT_APP_JSON_REMOTES = (
|
||||
)
|
||||
# Port the workout app's HTTP server listens on (no ADB/developer-options needed).
|
||||
WORKOUT_HTTP_PORT = 8765
|
||||
# The ONE duration bar every 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.
|
||||
@ -67,17 +67,17 @@ WORKOUT_DURATION_LEEWAY_MINUTES = 5
|
||||
# Derived so the advertised bar and the accept bar cannot drift apart.
|
||||
# Comparisons use this; messages use MIN_WORKOUT_DURATION_MINUTES. Every
|
||||
# caller compares with `>=` — one operator across all workout types, so a
|
||||
# 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
|
||||
@ -105,12 +105,22 @@ SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json"
|
||||
REST_DAY_FILE = Path(__file__).resolve().parent / "rest_days.json"
|
||||
REST_DAYS_PER_ISO_WEEK = 2
|
||||
GRACE_STATE_FILE = Path(__file__).resolve().parent / "grace_floor.json"
|
||||
GRACE_MINUTES = 60
|
||||
# A day's RunnerUp walks (commute legs) count as the workout once their
|
||||
# MOVING time sums to this; moving = trackpoint pairs at walking speed.
|
||||
WALK_MIN_MOVING_MINUTES = 40
|
||||
WALK_SPEED_MPS = (0.5, 3.0)
|
||||
# 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"
|
||||
|
||||
@ -103,7 +103,7 @@ def send_notification(title: str, body: str) -> bool:
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
_logger.warning("Desktop notification failed (%s): %s", exc, body)
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
if result.returncode:
|
||||
_logger.warning(
|
||||
"notify-send exited %d (%s) — no desktop notification: %s",
|
||||
result.returncode,
|
||||
|
||||
@ -57,7 +57,7 @@ def credit_slot(
|
||||
if with_new == before:
|
||||
# No new slot: either it earns nothing at all, or its slot is taken.
|
||||
return CreditSlot.OCCUPIED if _counts_alone(new_entry) else CreditSlot.NONE
|
||||
return CreditSlot.FIRST if before == 0 else CreditSlot.FURTHER
|
||||
return CreditSlot.FURTHER if before else CreditSlot.FIRST
|
||||
|
||||
|
||||
def _counts_alone(entry: Mapping[str, Any]) -> bool:
|
||||
|
||||
@ -16,7 +16,7 @@ midnight, so a 30-minute earner is applied exactly.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
import functools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@ -26,6 +26,7 @@ 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__)
|
||||
|
||||
@ -105,12 +106,25 @@ def earned_today(
|
||||
earner: earned_time.Earner, now: datetime | None = None
|
||||
) -> bool | None:
|
||||
"""Whether ``earner``'s gate recorded today's credit; ``None`` if unknown."""
|
||||
return earned_time.done_today(earner, ledger_file(earner), HMAC_KEY_FILE, now=now)
|
||||
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)
|
||||
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]:
|
||||
@ -162,24 +176,20 @@ def extra_minutes(earner: earned_time.Earner, day: date) -> int:
|
||||
return earner.extra_shutdown_minutes if fn is None else int(fn(earner, day))
|
||||
|
||||
|
||||
def ceiling(day: date) -> int:
|
||||
"""The latest shutdown ``day`` can earn, minutes after its midnight."""
|
||||
fn = getattr(earned_time, "shutdown_ceiling_for", None)
|
||||
return earned_time.SHUTDOWN_CEILING_MINUTES if fn is None else int(fn(day))
|
||||
|
||||
|
||||
def first_credit_time(earner: earned_time.Earner, day: date) -> float | None:
|
||||
"""Unix time of ``earner``'s first verified credit counting for ``day``.
|
||||
|
||||
``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 the grace floor sees no ledger earner at all.
|
||||
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)
|
||||
stamp: float | None = fn(
|
||||
earner, ledger_file(earner), HMAC_KEY_FILE, day, gate_off=gate_off(earner, day)
|
||||
)
|
||||
return stamp
|
||||
|
||||
|
||||
@ -187,8 +197,8 @@ def first_credit_time(earner: earned_time.Earner, day: date) -> float | None:
|
||||
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); the grace floor "
|
||||
"sees no ledger earner, only the workout log"
|
||||
"earned_time has no first_credit_at (needs >= 0.4); a late ledger "
|
||||
"credit counts from now, not from its own time"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
Tracks:
|
||||
- Consecutive weeks with 5+ workouts (streak counter).
|
||||
- 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
|
||||
@ -15,7 +18,7 @@ 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
|
||||
@ -59,7 +62,7 @@ def _save_state(state_file: Path, state: dict[str, Any]) -> None:
|
||||
_logger.warning("Failed to save extra benefits state: %s", exc)
|
||||
|
||||
|
||||
def _current_iso_week(now: 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}"
|
||||
@ -100,16 +103,14 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
||||
eb_weeks: list[str] = list(state.get("extended_early_bird_iso_weeks", []))
|
||||
|
||||
rewards: list[str] = []
|
||||
prev_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:
|
||||
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"{streak}-week streak, early-bird extended to 09:00 this week"
|
||||
f"{prev_week_count} workouts in {prev_week_str}! {streak}-week streak"
|
||||
)
|
||||
else:
|
||||
if streak > 0:
|
||||
@ -128,6 +129,9 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
||||
"consecutive_5plus_weeks": streak,
|
||||
"last_processed_iso_week": current_week_str,
|
||||
"extended_early_bird_iso_weeks": eb_weeks,
|
||||
# Kept for the status window (display only): the strings above
|
||||
# used to go to the log and nowhere else.
|
||||
"week_rewards": {"iso_week": current_week_str, "messages": rewards},
|
||||
}
|
||||
)
|
||||
_save_state(state_file, updated)
|
||||
@ -147,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", []))
|
||||
|
||||
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)
|
||||
@ -1,22 +1,15 @@
|
||||
"""Grace floor: shutdown is never earlier than the day's first task + 60 min.
|
||||
"""When the day's first task was done, and the day's floor lift.
|
||||
|
||||
Someone who gets home at 18:10 and finishes their first task at 18:55 is not
|
||||
cut off at 19:00. The floor is ``first done + GRACE_MINUTES``, capped at the
|
||||
day's ceiling. "First done" is the earliest of every gate earner's first
|
||||
verified credit, flat or counted (``earned_time.first_credit_at``), and the
|
||||
workout's own completion time -- the end of the run or walk, never the
|
||||
moment a sync happened to log it, so a late upload does not move the floor
|
||||
later.
|
||||
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).
|
||||
|
||||
**A floor, not a bonus.** The config is otherwise read-add-write: every
|
||||
earner adds its minutes to whatever is there. Lifting it to the floor and
|
||||
then letting the next earner add on top would pay the lift twice (anki at
|
||||
21:00 lifts to 22:00; reading then makes 22:30 where max(earned, floor) is
|
||||
22:00). So the lift is remembered for the day in ``grace_floor.json`` and
|
||||
later additions are paid out of it first: with ``E`` earned, floor ``G`` and
|
||||
lift ``L = max(0, G - E)``, adding ``m`` moves the config by
|
||||
``max(0, m - L)`` and leaves ``L' = max(0, L - m)`` -- which keeps the config
|
||||
at ``max(E + m, G)`` without ever needing to know ``E``.
|
||||
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
|
||||
@ -28,13 +21,10 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._bonus_lock import bonus_lock
|
||||
from screen_locker._constants import GRACE_MINUTES, GRACE_STATE_FILE
|
||||
from screen_locker._constants import GRACE_STATE_FILE
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import ceiling, first_credit_time, gate_earners, hhmm
|
||||
from screen_locker._earned import first_credit_time, gate_earners
|
||||
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 credit_key, day_workout_index
|
||||
from screen_locker._workout_ledger import entry_done_at
|
||||
|
||||
@ -96,17 +86,6 @@ def first_done_at(
|
||||
return min(on_day, default=None)
|
||||
|
||||
|
||||
def grace_for(first_done: float | None, day: date) -> int | None:
|
||||
"""The floor for ``day`` in minutes after midnight, or ``None`` if none."""
|
||||
if first_done is None:
|
||||
return None
|
||||
moment = datetime.fromtimestamp(first_done).astimezone()
|
||||
if moment.date() != day:
|
||||
return None
|
||||
done = moment.hour * 60 + moment.minute
|
||||
return min(ceiling(day), done + GRACE_MINUTES)
|
||||
|
||||
|
||||
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():
|
||||
@ -146,57 +125,3 @@ 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
|
||||
|
||||
|
||||
class GraceFloorMixin:
|
||||
"""Lifts tonight's shutdown to the grace floor; composed into ShutdownMixin."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None: ...
|
||||
|
||||
def _write_shutdown_config(
|
||||
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ...
|
||||
) -> bool: ...
|
||||
|
||||
def _apply_grace_floor(
|
||||
self, log_file: Path | None, *, first_done: float | None = None
|
||||
) -> bool:
|
||||
"""Lift tonight's shutdown to the grace floor if it sits below it.
|
||||
|
||||
``first_done`` overrides the ledgers' answer (the preview script's
|
||||
simulations). Returns True when the config was raised on this call.
|
||||
"""
|
||||
day = date.fromisoformat(today_str())
|
||||
if is_sick(day):
|
||||
# The sick floor (20:00, _sick_floor) stands in for it that day.
|
||||
_logger.info("Grace floor: skipped, today is a sick day")
|
||||
return False
|
||||
if first_done is None:
|
||||
first_done = first_done_at(day, log_file, rest_day=is_rest_day(day))
|
||||
floor = grace_for(first_done, day)
|
||||
if floor is None:
|
||||
return False
|
||||
with bonus_lock(GRACE_STATE_FILE):
|
||||
config = self._read_shutdown_config()
|
||||
if config is None:
|
||||
_logger.warning("Grace floor: shutdown config unreadable; not applied")
|
||||
return False
|
||||
current = tonight_minutes(config, day)
|
||||
if current >= floor:
|
||||
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("Grace floor: failed to write shutdown config")
|
||||
return False
|
||||
lift = load_lift(GRACE_STATE_FILE, today_str()) + floor - current
|
||||
save_lift(lift, GRACE_STATE_FILE)
|
||||
_logger.info(
|
||||
"Grace floor: shutdown %s -> %s (first task done + %d min).",
|
||||
hhmm(current),
|
||||
hhmm(floor),
|
||||
GRACE_MINUTES,
|
||||
)
|
||||
return True
|
||||
|
||||
86
screen_locker/_hold.py
Normal file
86
screen_locker/_hold.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""A declared test hold keeps the workout locker from showing any window.
|
||||
|
||||
``holds`` (kuhyx/utils, ``holds-v0.1.0``) is the shared "a proctored test is
|
||||
running, leave the PC usable" switch. Every workout-locker path -- the relaxed
|
||||
day's optional prompt, the strict lock and the early-bird re-check -- runs
|
||||
through ``_startup_checks._check_non_verify_exits``, so one rung there covers
|
||||
them all. It sits BELOW the reset/bonus/ingest/early-bird bookkeeping: a hold
|
||||
only suppresses the window, never a credit or a penalty term.
|
||||
|
||||
Fail closed: if ``holds`` cannot be imported or read, there is no hold and the
|
||||
locker behaves exactly as it did before, with an ERROR in the journal. A hold
|
||||
that fails open would be a free escape from the gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from tkinter import Misc
|
||||
|
||||
__all__ = ["HOLD_REASON", "skip_during_hold", "watch_for_hold", "workout_hold_active"]
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
HOLD_REASON = "test_hold_active"
|
||||
_GATE = "workout"
|
||||
# Well inside the 60 s a hold may take to lift an open window.
|
||||
POLL_MS = 30_000
|
||||
|
||||
|
||||
def workout_hold_active(now: datetime | None = None) -> bool:
|
||||
"""Whether a declared hold covers the workout gate right now."""
|
||||
try:
|
||||
# Imported at call time so a missing package fails closed, per call.
|
||||
holds = importlib.import_module("holds")
|
||||
return bool(holds.covers(_GATE, now))
|
||||
except ImportError, OSError, ValueError, TypeError, KeyError:
|
||||
_logger.exception(
|
||||
"Could not read the test holds — treating it as no hold, so "
|
||||
"the workout locker behaves as normal"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def skip_during_hold(record_skip: Callable[..., None]) -> bool:
|
||||
"""Skip enforcement while a hold is active.
|
||||
|
||||
Takes the locker's ``_record_skip`` (which exits the process) rather than
|
||||
the locker, like ``_night_lockdown``. Returns True when the skip was
|
||||
recorded, False when the ladder may go on.
|
||||
"""
|
||||
if not workout_hold_active():
|
||||
return False
|
||||
record_skip(
|
||||
HOLD_REASON,
|
||||
"A declared test hold is active — no workout prompt or lock window "
|
||||
"until it ends (credit and penalty bookkeeping already ran).",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def watch_for_hold(root: Misc, close: Callable[[], None]) -> None:
|
||||
"""Close an already-open window within one poll of a hold starting.
|
||||
|
||||
``close`` is the locker's own ``close`` -- it tears the window down and
|
||||
exits 0 without writing a credit or a penalty, so a hold that interrupts
|
||||
an open prompt/lock changes nothing but the screen. Polls on the Tk loop
|
||||
(no thread) and stops re-arming once it fires.
|
||||
"""
|
||||
|
||||
def _tick() -> None:
|
||||
if workout_hold_active():
|
||||
_logger.warning(
|
||||
"A test hold started while the workout window was open — "
|
||||
"closing it without credit or penalty."
|
||||
)
|
||||
close()
|
||||
return
|
||||
root.after(POLL_MS, _tick)
|
||||
|
||||
root.after(POLL_MS, _tick)
|
||||
@ -10,7 +10,7 @@ app serves the same JSON over ``WORKOUT_HTTP_PORT``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from 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
|
||||
@ -45,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"]
|
||||
@ -113,6 +116,76 @@ def _due_date(
|
||||
return date
|
||||
|
||||
|
||||
def _new_valid_draft(
|
||||
log_file: Path,
|
||||
record_id: str,
|
||||
payload: Mapping[str, object],
|
||||
reference: str,
|
||||
) -> tuple[str, ManualWorkoutDraft] | None:
|
||||
"""The due date and valid draft of a not-yet-ingested record, else ``None``."""
|
||||
date = _due_date(record_id, payload, reference)
|
||||
if date is None:
|
||||
return None
|
||||
if _already_ingested(load_workout_log(log_file), record_id):
|
||||
return None
|
||||
draft = _draft_or_report(record_id, payload)
|
||||
if draft is None:
|
||||
return None
|
||||
error = validate_manual_workout(draft)
|
||||
if error is not None:
|
||||
_logger.warning("Manual record %s invalid: %s", record_id, error)
|
||||
return None
|
||||
return date, draft
|
||||
|
||||
|
||||
def _ingest_one(
|
||||
log_file: Path,
|
||||
record_id: str,
|
||||
payload: Mapping[str, object],
|
||||
reference: str,
|
||||
pending: Mapping[str, str],
|
||||
) -> tuple[dict[str, object], RecordResult] | None:
|
||||
"""Validate, budget-check and append one record; ``None`` when skipped."""
|
||||
ready = _new_valid_draft(log_file, record_id, payload, reference)
|
||||
if ready is None:
|
||||
return None
|
||||
date, draft = ready
|
||||
over_budget = budget_error_for_date(
|
||||
log_file,
|
||||
date,
|
||||
[day for rid, day in pending.items() if rid != record_id],
|
||||
)
|
||||
if over_budget is not None:
|
||||
_logger.info(
|
||||
"Manual-workout budget exhausted — %s not ingested (%s)",
|
||||
record_id,
|
||||
over_budget,
|
||||
)
|
||||
return None
|
||||
entry = build_entry(draft)
|
||||
entry[_SYNC_ID_FIELD] = record_id
|
||||
# The wire id IS the workout's id: the device that logged it minted
|
||||
# ``manual:<date>T<start>`` once, and the publisher keys on the same
|
||||
# id. Without this the chokepoint re-derived one from ``date`` +
|
||||
# start, and when the two dates disagreed (a record whose day key was
|
||||
# later corrected to the local day) the same workout landed twice
|
||||
# under two ids -- and then consumed two manual-budget slots.
|
||||
entry["workout_id"] = record_id
|
||||
result = write_signed_entry(log_file, date, entry)
|
||||
if not result.appended:
|
||||
# This is dedup working, not credit being lost: the workout is
|
||||
# already in the log under this same workout_id. It was logged at
|
||||
# warning and re-reported on every 15-minute cycle, which read like
|
||||
# a recurring fault and buried the lines that did need attention.
|
||||
_logger.info(
|
||||
"Manual record %s is already logged under the same workout_id "
|
||||
"— skipped as a duplicate (its credit is already counted)",
|
||||
record_id,
|
||||
)
|
||||
return None
|
||||
return entry, result
|
||||
|
||||
|
||||
def ingest_manual_records(
|
||||
log_file: Path,
|
||||
records: Iterable[tuple[str, Mapping[str, object]]],
|
||||
@ -157,51 +230,10 @@ def ingest_manual_records(
|
||||
pending = pending_future_dates(log_file, batch, today=reference)
|
||||
ingested: list[str] = []
|
||||
for record_id, payload in batch:
|
||||
date = _due_date(record_id, payload, reference)
|
||||
if date is None:
|
||||
continue
|
||||
if _already_ingested(load_workout_log(log_file), record_id):
|
||||
continue
|
||||
draft = _draft_or_report(record_id, payload)
|
||||
if draft is None:
|
||||
continue
|
||||
error = validate_manual_workout(draft)
|
||||
if error is not None:
|
||||
_logger.warning("Manual record %s invalid: %s", record_id, error)
|
||||
continue
|
||||
over_budget = budget_error_for_date(
|
||||
log_file,
|
||||
date,
|
||||
[day for rid, day in pending.items() if rid != record_id],
|
||||
)
|
||||
if over_budget is not None:
|
||||
_logger.info(
|
||||
"Manual-workout budget exhausted — %s not ingested (%s)",
|
||||
record_id,
|
||||
over_budget,
|
||||
)
|
||||
continue
|
||||
entry = build_entry(draft)
|
||||
entry[_SYNC_ID_FIELD] = record_id
|
||||
# The wire id IS the workout's id: the device that logged it minted
|
||||
# ``manual:<date>T<start>`` once, and the publisher keys on the same
|
||||
# id. Without this the chokepoint re-derived one from ``date`` +
|
||||
# start, and when the two dates disagreed (a record whose day key was
|
||||
# later corrected to the local day) the same workout landed twice
|
||||
# under two ids -- and then consumed two manual-budget slots.
|
||||
entry["workout_id"] = record_id
|
||||
result = write_signed_entry(log_file, date, entry)
|
||||
if not result.appended:
|
||||
# This is dedup working, not credit being lost: the workout is
|
||||
# already in the log under this same workout_id. It was logged at
|
||||
# warning and re-reported on every 15-minute cycle, which read like
|
||||
# a recurring fault and buried the lines that did need attention.
|
||||
_logger.info(
|
||||
"Manual record %s is already logged under the same workout_id "
|
||||
"— skipped as a duplicate (its credit is already counted)",
|
||||
record_id,
|
||||
)
|
||||
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:
|
||||
|
||||
@ -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
|
||||
@ -103,8 +103,8 @@ def count_in_window(
|
||||
dated after today are NOT in it. Whether a new entry fits is a different
|
||||
question, answered per workout date by ``_manual_future``.
|
||||
"""
|
||||
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)
|
||||
|
||||
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
|
||||
@ -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."""
|
||||
|
||||
@ -119,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":
|
||||
@ -145,6 +148,33 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
|
||||
# ── HTTP fallback (no ADB / developer options required) ───────────────────
|
||||
|
||||
def _pull_sync_result(self) -> tuple[tuple[str, str] | None, str | None]:
|
||||
"""Firebase sync first: the validated result (if any) and sync error.
|
||||
|
||||
Firebase sync is the primary channel -- it works without the phone
|
||||
being on the same network as the PC. Only a *verified* result
|
||||
short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the
|
||||
phone's last push predates today, or a later push failed offline)
|
||||
must not preempt a fresher ADB pull, so anything less than
|
||||
"verified" is kept by the caller as a fallback-of-last-resort
|
||||
candidate. A sync error never blocks unlocking either; `sync_failed`
|
||||
only surfaces if ADB/HTTP also come up empty.
|
||||
"""
|
||||
synced_data, sync_error = pull_synced_workout()
|
||||
if synced_data is None:
|
||||
if sync_error is not None:
|
||||
_logger.info(
|
||||
"Firebase sync unavailable (%s) — trying ADB/HTTP...", sync_error
|
||||
)
|
||||
return None, sync_error
|
||||
sync_result = self._validate_json_data(synced_data)
|
||||
if sync_result[0] != "verified":
|
||||
_logger.info(
|
||||
"Synced data not fully verified (%s) — trying ADB/HTTP...",
|
||||
sync_result[0],
|
||||
)
|
||||
return sync_result, sync_error
|
||||
|
||||
def _verify_phone_workout(self) -> tuple[str, str]:
|
||||
"""Verify today's workout: Firebase sync first, ADB/HTTP as fallback.
|
||||
|
||||
@ -156,28 +186,9 @@ class PhoneVerificationMixin(AdbTransportMixin, HttpWorkoutFetchMixin):
|
||||
if not clock_ok:
|
||||
return "clock_tampered", clock_msg
|
||||
|
||||
# Firebase sync is the primary channel — it works without the phone
|
||||
# being on the same network as the PC. Only a *verified* sync result
|
||||
# short-circuits ADB/HTTP: a stale/incomplete cloud entry (e.g. the
|
||||
# phone's last push predates today, or a later push failed offline)
|
||||
# must not preempt a fresher ADB pull, so anything less than
|
||||
# "verified" is kept as a fallback-of-last-resort candidate instead
|
||||
# of being returned immediately. A sync error never blocks unlocking
|
||||
# either; `sync_failed` only surfaces if ADB/HTTP also come up empty.
|
||||
synced_data, sync_error = pull_synced_workout()
|
||||
sync_result: tuple[str, str] | None = None
|
||||
if synced_data is not None:
|
||||
sync_result = self._validate_json_data(synced_data)
|
||||
if sync_result[0] == "verified":
|
||||
return sync_result
|
||||
_logger.info(
|
||||
"Synced data not fully verified (%s) — trying ADB/HTTP...",
|
||||
sync_result[0],
|
||||
)
|
||||
elif sync_error is not None:
|
||||
_logger.info(
|
||||
"Firebase sync unavailable (%s) — trying ADB/HTTP...", sync_error
|
||||
)
|
||||
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
|
||||
@ -194,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"
|
||||
|
||||
@ -30,6 +30,7 @@ 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
|
||||
@ -45,6 +46,7 @@ if TYPE_CHECKING:
|
||||
__all__ = [
|
||||
"BatchOutcome",
|
||||
"PokeOutcome",
|
||||
"credit_manual_workouts",
|
||||
"credit_session",
|
||||
"credit_sessions",
|
||||
"current_gaming_minutes",
|
||||
@ -201,11 +203,40 @@ def credit_sessions(
|
||||
results.append(locker.credit_written(entry, prior_entries))
|
||||
|
||||
ingested = ingest_session_records(locker.log_file, records, on_ingested=on_ingested)
|
||||
return _outcome(ingested, results, "session")
|
||||
|
||||
|
||||
def credit_manual_workouts(
|
||||
records: Iterable[tuple[str, Mapping[str, object]]],
|
||||
*,
|
||||
today: str | None = None,
|
||||
) -> BatchOutcome:
|
||||
"""``credit_sessions`` for manual workouts, via ``ingest_manual_records``."""
|
||||
locker = fresh_locker()
|
||||
locker.start_the_day()
|
||||
results: list[WorkoutCreditResult] = []
|
||||
|
||||
def on_ingested(
|
||||
entry: dict[str, str], prior_entries: list[dict[str, object]]
|
||||
) -> None:
|
||||
results.append(locker.credit_written(entry, prior_entries))
|
||||
|
||||
ingested = ingest_manual_records(
|
||||
locker.log_file, records, today=today, on_ingested=on_ingested
|
||||
)
|
||||
return _outcome(ingested, results, "manual workout")
|
||||
|
||||
|
||||
def _outcome(
|
||||
ingested: list[str], results: list[WorkoutCreditResult], what: str
|
||||
) -> BatchOutcome:
|
||||
"""Count what earned time; warn about logged copies that earned none."""
|
||||
credited = sum(not result.already_counted_today for result in results)
|
||||
if len(results) > credited:
|
||||
_logger.warning(
|
||||
"%d streamed session(s) were logged but earned no time: today's slot "
|
||||
"%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)
|
||||
|
||||
@ -135,7 +135,7 @@ class PokeHandler(BaseHTTPRequestHandler):
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
status, reply = self._handle()
|
||||
except Exception:
|
||||
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(
|
||||
|
||||
@ -15,7 +15,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from screen_locker._log_mixin import write_signed_entry
|
||||
from screen_locker._walk_tcx import walk_day, walk_shortfall
|
||||
from screen_locker._walk_tcx import activity_day, activity_shortfall
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@ -72,66 +72,47 @@ class RunnerUpBackfillMixin:
|
||||
|
||||
Returns True if a new verified entry was appended for ``date_str``.
|
||||
"""
|
||||
walks: list[dict[str, Any]] = []
|
||||
for remote in self._find_runnerup_exports_for_date(date_str):
|
||||
data = self._pull_and_parse_tcx(remote)
|
||||
if data is None:
|
||||
continue
|
||||
if data.get("walking"):
|
||||
# Never one walk at a time: a day's walks are summed below.
|
||||
walks.append(data)
|
||||
continue
|
||||
status, msg = self._validate_runnerup_data(data)
|
||||
if status != "verified":
|
||||
_logger.warning(
|
||||
"RunnerUp export %s for %s did not qualify (%s): %s",
|
||||
remote,
|
||||
date_str,
|
||||
status,
|
||||
msg,
|
||||
)
|
||||
continue
|
||||
workout_data = {
|
||||
"type": "runnerup_verified",
|
||||
"source": f"Auto-scanned: {msg}",
|
||||
"distance_km": round(data["distance_m"] / 1000, 2),
|
||||
"duration_minutes": round(data["duration_seconds"] / 60, 1),
|
||||
"completed_at": data.get("ended_at"),
|
||||
}
|
||||
return _record(log_file, date_str, workout_data, on_ingested)
|
||||
return self._try_fill_walks(date_str, walks, log_file, on_ingested)
|
||||
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_walks(
|
||||
def _try_fill_day(
|
||||
date_str: str,
|
||||
walks: list[dict[str, Any]],
|
||||
activities: list[dict[str, Any]],
|
||||
log_file: Path,
|
||||
on_ingested: OnIngestedCallback | None,
|
||||
) -> bool:
|
||||
"""Append the day's walking as its RunnerUp workout, once it adds up.
|
||||
"""Append the day's activity as its RunnerUp workout, once it adds up.
|
||||
|
||||
Same type and so the same ``runnerup_verified:{date}`` slot as a run:
|
||||
a walk and a run on one day are one RunnerUp credit, exactly as two
|
||||
runs already were.
|
||||
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 walks:
|
||||
if not activities:
|
||||
return False
|
||||
day = walk_day(walks)
|
||||
day = activity_day(activities)
|
||||
if not day.qualifies:
|
||||
_logger.warning(
|
||||
"RunnerUp walks for %s do not count yet: %s",
|
||||
"RunnerUp activity for %s does not count yet: %s",
|
||||
date_str,
|
||||
walk_shortfall(day),
|
||||
activity_shortfall(day),
|
||||
)
|
||||
return False
|
||||
workout_data = {
|
||||
workout_data: dict[str, Any] = {
|
||||
"type": "runnerup_verified",
|
||||
"activity": "walking",
|
||||
"source": f"Auto-scanned: {day.message()}",
|
||||
"distance_km": round(day.distance_km, 2),
|
||||
"duration_minutes": round(day.moving_minutes, 1),
|
||||
"completed_at": day.completed_at,
|
||||
}
|
||||
if day.all_walks:
|
||||
workout_data["activity"] = "walking"
|
||||
return _record(log_file, date_str, workout_data, on_ingested)
|
||||
|
||||
def _scan_and_fill_week_runnerup(
|
||||
|
||||
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)
|
||||
@ -65,48 +65,53 @@ class RunnerUpTcxMixin:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
def _parse_tcx(self, tcx_path: str) -> dict[str, Any] | None:
|
||||
"""Parse a local TCX file 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,
|
||||
"walking": is_walking_export(Path(tcx_path).name, sport_str),
|
||||
**track_summary(activity),
|
||||
}
|
||||
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:
|
||||
|
||||
@ -12,31 +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 walk_day, walk_shortfall
|
||||
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.
|
||||
|
||||
@ -121,73 +116,46 @@ class RunnerUpVerificationMixin(
|
||||
)
|
||||
return None
|
||||
|
||||
# Try each file; return the best result (verified > validation error).
|
||||
# Walks never qualify one file at a time: they are summed below.
|
||||
best: tuple[str, str] | None = None
|
||||
walks: list[dict[str, Any]] = []
|
||||
for remote in exports:
|
||||
data = self._pull_and_parse_tcx(remote)
|
||||
if data is None:
|
||||
continue
|
||||
if data.get("walking"):
|
||||
walks.append(data)
|
||||
continue
|
||||
status, msg = self._validate_runnerup_data(data)
|
||||
if status == "verified":
|
||||
return status, msg
|
||||
if best is None:
|
||||
best = (status, msg)
|
||||
if walks:
|
||||
day = walk_day(walks)
|
||||
if day.qualifies:
|
||||
return "verified", day.message()
|
||||
best = best or ("too_short", walk_shortfall(day))
|
||||
|
||||
# All files found but none passed validation.
|
||||
return best or (
|
||||
"not_verified",
|
||||
"RunnerUp TCX export found but could not be read",
|
||||
)
|
||||
# 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
|
||||
|
||||
@ -123,7 +123,7 @@ class SessionStream:
|
||||
except StreamError as exc:
|
||||
_logger.warning("Firebase session stream failed: %s; %s", exc, _FALLBACK)
|
||||
return False
|
||||
except Exception:
|
||||
except Exception: # pylint: disable=broad-exception-caught # daemon boundary: logged, loop reconnects
|
||||
_logger.exception("Firebase session stream crashed; %s", _FALLBACK)
|
||||
return False
|
||||
return True
|
||||
|
||||
@ -9,13 +9,16 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._ceiling import ceiling
|
||||
from screen_locker._constants import (
|
||||
ADJUST_SHUTDOWN_SCRIPT,
|
||||
SHUTDOWN_CONFIG_FILE,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import ceiling, first_minutes, hhmm, span
|
||||
from screen_locker._grace_floor import GraceFloorMixin, plan_absorb, save_lift
|
||||
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:
|
||||
@ -29,9 +32,6 @@ _logger = logging.getLogger(__name__)
|
||||
_SHUTDOWN_CONFIG_NAMES = ("MON_WED", "THU_SUN", "MORNING_END")
|
||||
_MINUTES_PER_HOUR = 60
|
||||
|
||||
# Extra bonuses may name midnight; the helper still clamps them to 23:00.
|
||||
_MIDNIGHT = 24 * _MINUTES_PER_HOUR
|
||||
|
||||
|
||||
def _parse_config(path: Path) -> dict[str, int]:
|
||||
"""Every integer ``KEY=value`` line of *path*."""
|
||||
@ -63,20 +63,26 @@ def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
|
||||
else:
|
||||
_logger.warning("Shutdown config missing required values")
|
||||
return None
|
||||
mon_wed, thu_sun, morning_end = values
|
||||
return mon_wed, thu_sun, morning_end
|
||||
return values[0], values[1], values[2]
|
||||
|
||||
|
||||
class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
|
||||
class ShutdownMixin(SickFloorMixin, LateCreditMixin):
|
||||
"""Mixin providing shutdown schedule adjustment functionality."""
|
||||
|
||||
def _adjust_shutdown_time_later(self) -> bool:
|
||||
def _adjust_shutdown_time_later(self, at: float | None = None) -> bool:
|
||||
"""Push shutdown later by the first workout's reward, capped at the ceiling.
|
||||
|
||||
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())
|
||||
bonus, lift = plan_absorb(first_minutes(earned_time.WORKOUT, day))
|
||||
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:
|
||||
@ -95,16 +101,25 @@ class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
|
||||
save_lift(lift)
|
||||
return ok
|
||||
|
||||
def _adjust_shutdown_time_by(self, extra_minutes: int) -> bool:
|
||||
"""Push shutdown later by *extra_minutes*, capped at 24:00 (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 earner and extra-workout bonuses. A cap of midnight works
|
||||
because ``day-specific-shutdown-check.sh`` fires at 00:00 and catches it
|
||||
via the morning-window condition; the helper clamps it to 23:00 anyway.
|
||||
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.
|
||||
"""
|
||||
# Paid out of the grace lift first, so the floor is never stacked on.
|
||||
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()
|
||||
@ -112,8 +127,8 @@ class ShutdownMixin(GraceFloorMixin, SickFloorMixin):
|
||||
return False
|
||||
mw, ts, morning = config_values
|
||||
ok = self._write_shutdown_config(
|
||||
min(_MIDNIGHT, mw + moved),
|
||||
min(_MIDNIGHT, ts + moved),
|
||||
min(ceiling(day), mw + moved),
|
||||
min(ceiling(day), ts + moved),
|
||||
morning,
|
||||
restore=True,
|
||||
)
|
||||
|
||||
@ -18,10 +18,9 @@ followed -- on 2026-09-13 that left the bar at 21:00 with a counted workout on
|
||||
disk. Any bonus source that is not a term of this derivation has that bug;
|
||||
registering it in ``earned_time`` makes it one.
|
||||
|
||||
A sick day (``sickdays``) is resolved with ``sick=True``: shutdown is at least
|
||||
20:00, and the lift above the earned sum is saved like the grace lift. A day
|
||||
marked sick after the reset is lifted by the live pass
|
||||
(:mod:`screen_locker._sick_floor`).
|
||||
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
|
||||
@ -38,6 +37,7 @@ from screen_locker._day import today_str
|
||||
from screen_locker._earned import (
|
||||
counted_gate_earners,
|
||||
earned_today,
|
||||
first_credit_time,
|
||||
first_credits,
|
||||
first_minutes,
|
||||
flat_earners,
|
||||
@ -78,7 +78,7 @@ def base_minutes(day: date | None = None) -> int:
|
||||
else earned_time.base_for(target, earners, first_credits=starts)
|
||||
).shutdown_minutes
|
||||
if is_sick(target):
|
||||
return earned_time.sick_shutdown_minutes(base, target)
|
||||
return max(base, earned_time.sick_shutdown_from(0, target))
|
||||
return base
|
||||
|
||||
|
||||
@ -166,7 +166,7 @@ def _reset(
|
||||
_logger.warning("Daily base reset: failed to write shutdown config.")
|
||||
return False
|
||||
|
||||
# Later additions are paid out of the floor lift (grace or sick) first.
|
||||
# 0 now that no floor lifts the sum; overwrites a stale lift from before.
|
||||
save_lift(derived.lift)
|
||||
|
||||
# The bonuses the reset already included are stamped, so the live pass
|
||||
@ -179,13 +179,12 @@ def _reset(
|
||||
f"{span(t.shutdown_minutes)} {t.earner.label}" for t in day.terms
|
||||
)
|
||||
_logger.info(
|
||||
"Daily base reset: %s (base %s + %s already earned today%s%s%s).",
|
||||
"Daily base reset: %s (base %s + %s already earned today%s%s).",
|
||||
hhmm(target),
|
||||
hhmm(day.base.shutdown_minutes),
|
||||
earned,
|
||||
"; rest day" if derived.rest_day else "",
|
||||
"; sick day" if day.sick else "",
|
||||
f"; grace floor {hhmm(derived.grace)}" if derived.grace is not None else "",
|
||||
)
|
||||
return True
|
||||
|
||||
@ -218,8 +217,10 @@ def _apply_flat_bonus(
|
||||
return False
|
||||
if not answer:
|
||||
return False
|
||||
minutes = first_minutes(earner, date.fromisoformat(today))
|
||||
if not mixin._adjust_shutdown_time_by(minutes):
|
||||
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
|
||||
@ -242,7 +243,6 @@ def apply_flat_bonuses_if_new(state_file: Path, mixin: object) -> None:
|
||||
state = _load_state(state_file)
|
||||
if apply_counted_bonus(state, adjust, earner, today_str()):
|
||||
_save_state(state_file, state)
|
||||
# Last, so the floors compare against everything earned so far. The
|
||||
# sick floor covers a day marked sick after today's reset.
|
||||
# Last, so the sick target sees everything earned so far; it covers a
|
||||
# day marked sick after today's reset.
|
||||
mixin._apply_sick_floor()
|
||||
mixin._apply_grace_floor(getattr(mixin, "log_file", None))
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
"""Tonight's shutdown, derived from scratch: earned time, rest day, grace floor.
|
||||
"""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::
|
||||
|
||||
earned = earned_time.resolve(answers, sick=...) # base + every earner, capped;
|
||||
# sick: at least 20:00
|
||||
target = min(ceiling, max(earned, grace floor)) # no grace floor when sick
|
||||
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
|
||||
@ -20,8 +22,8 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._earned import ceiling, first_credits, gate_answers, registry
|
||||
from screen_locker._grace_floor import first_done_at, grace_for
|
||||
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
|
||||
@ -42,14 +44,12 @@ class Target:
|
||||
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.
|
||||
grace: The grace floor in minutes, or ``None``.
|
||||
minutes: The shutdown to write, minutes after midnight.
|
||||
"""
|
||||
|
||||
resolution: earned_time.Resolution
|
||||
rest_day: bool
|
||||
first_done_at: float | None
|
||||
grace: int | None
|
||||
minutes: int
|
||||
|
||||
@property
|
||||
@ -59,11 +59,13 @@ class Target:
|
||||
|
||||
@property
|
||||
def lift(self) -> int:
|
||||
"""How far the floors (grace or sick) lift the earned shutdown.
|
||||
"""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
|
||||
|
||||
|
||||
@ -84,7 +86,8 @@ class DayInputs:
|
||||
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
|
||||
(``earned_time.resolve(..., sick=True)``) and no grace floor.
|
||||
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.
|
||||
@ -100,7 +103,7 @@ class DayInputs:
|
||||
|
||||
|
||||
def derive(inputs: DayInputs) -> Target:
|
||||
"""The pure formula: earned time, then the grace floor, then the ceiling."""
|
||||
"""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(
|
||||
@ -116,16 +119,11 @@ def derive(inputs: DayInputs) -> Target:
|
||||
if starts is None
|
||||
else earned_time.resolve(answers, day, earners, first_credits=starts, sick=sick)
|
||||
)
|
||||
grace = None if inputs.sick_day else grace_for(inputs.first_done, day)
|
||||
minutes = resolution.shutdown_minutes
|
||||
if grace is not None:
|
||||
minutes = min(ceiling(day), max(minutes, grace))
|
||||
return Target(
|
||||
resolution=resolution,
|
||||
rest_day=inputs.rest_day,
|
||||
first_done_at=inputs.first_done,
|
||||
grace=grace,
|
||||
minutes=minutes,
|
||||
minutes=resolution.shutdown_minutes,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -97,18 +97,37 @@ class MarkResult:
|
||||
def mark_today_sick() -> MarkResult:
|
||||
"""Mark today sick in the shared pool; every gate stands down for it.
|
||||
|
||||
Already sick counts as success. 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.
|
||||
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:
|
||||
_logger.warning("%s was already a sick day; unlocking", today)
|
||||
return MarkResult(ok=True, message="Today is already a sick day.")
|
||||
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,18 +1,18 @@
|
||||
"""Sick-day shutdown floor, applied live: 20:00 the moment today turns sick.
|
||||
"""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 lifts tonight's shutdown to
|
||||
``earned_time.sick_shutdown_minutes`` (``max(20:00, current)``, capped at the
|
||||
day's ceiling).
|
||||
"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).
|
||||
|
||||
The lift is a floor, not a bonus, exactly like the grace floor: it is added
|
||||
to the same ``grace_floor.json`` lift, so a workout credited later is paid
|
||||
out of it first (``_grace_floor.plan_absorb``) and lands on
|
||||
``max(20:00, ladder)`` -- 20:40, not 20:00 + the workout's whole reward.
|
||||
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.
|
||||
@ -24,31 +24,40 @@ from datetime import date
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
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 load_lift, save_lift, tonight_minutes
|
||||
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: ...
|
||||
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: ...
|
||||
) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def _apply_sick_floor(self) -> bool:
|
||||
"""Raise tonight's shutdown to the sick floor if today is sick.
|
||||
"""Raise tonight's shutdown to the sick target if today is sick.
|
||||
|
||||
Returns True when the config was raised on this call.
|
||||
"""
|
||||
@ -62,7 +71,7 @@ class SickFloorMixin:
|
||||
_logger.warning("Sick floor: shutdown config unreadable; not applied")
|
||||
return False
|
||||
current = tonight_minutes(config, day)
|
||||
floor = earned_time.sick_shutdown_minutes(current, day)
|
||||
floor = sick_target(day, getattr(self, "log_file", None))
|
||||
if floor <= current:
|
||||
return False
|
||||
mon_wed, thu_sun, morning = config
|
||||
@ -71,8 +80,7 @@ class SickFloorMixin:
|
||||
):
|
||||
_logger.warning("Sick floor: failed to write shutdown config")
|
||||
return False
|
||||
lift = load_lift(GRACE_STATE_FILE, today) + floor - current
|
||||
save_lift(lift, GRACE_STATE_FILE)
|
||||
save_lift(0, GRACE_STATE_FILE) # stacked: nothing to absorb
|
||||
_logger.info(
|
||||
"Sick floor: shutdown %s -> %s (sick day).", hhmm(current), hhmm(floor)
|
||||
)
|
||||
|
||||
@ -28,7 +28,9 @@ from screen_locker._decision_log import (
|
||||
)
|
||||
from screen_locker._degraded_sources import degraded_sources
|
||||
from screen_locker._extra_benefits import process_week_transition
|
||||
from screen_locker._hold import skip_during_hold
|
||||
from screen_locker._lock_invariant import refuse_to_lock_over_logged_workout
|
||||
from screen_locker._night_lockdown import skip_after_evening_lockdown
|
||||
from screen_locker._shutdown_base import (
|
||||
apply_flat_bonuses_if_new,
|
||||
reset_to_base_if_new_day,
|
||||
@ -124,7 +126,8 @@ class StartupChecksMixin(SyncMixin):
|
||||
# before any early-exit check, so gaps are closed regardless of today's
|
||||
# logged state (early_bird, sick_day, etc.).
|
||||
self._auto_fill_week_runnerup_bonus()
|
||||
if self._check_today_state_exits():
|
||||
# Test hold: no prompt/lock/heat dialog; bookkeeping above is untouched.
|
||||
if self._check_today_state_exits() or skip_during_hold(self._record_skip):
|
||||
return
|
||||
# Day-of-week routing: Tue/Wed/Thu relaxed (optional), Fri-Mon enforced.
|
||||
if is_relaxed_day():
|
||||
@ -157,7 +160,11 @@ class StartupChecksMixin(SyncMixin):
|
||||
# there is no banked "skip a workout" credit — that mechanic works
|
||||
# against the goal of maximizing weekly workouts, so it was removed.
|
||||
# A logged workout outranks every rung above, whatever their order.
|
||||
if refuse_to_lock_over_logged_workout(self.has_logged_today, self._record_skip):
|
||||
# Then: once tonight's lockdown fired (a late credit may have lifted
|
||||
# it), never re-arm behind the workout gate the same evening.
|
||||
if refuse_to_lock_over_logged_workout(
|
||||
self.has_logged_today, self._record_skip
|
||||
) or skip_after_evening_lockdown(self._record_skip):
|
||||
return
|
||||
heat = self._check_heat_skip_exit()
|
||||
# Nothing excused today: falling through here means the lock WILL be
|
||||
|
||||
@ -71,55 +71,29 @@ def _print_day_line(d: date, entries: list[dict], sick_days: frozenset[date]) ->
|
||||
return any_counted
|
||||
|
||||
|
||||
def run_status(locker: ScreenLocker) -> None:
|
||||
"""Print weekly workout status, run RunnerUp scan, apply bonus, then exit."""
|
||||
today = datetime.now(tz=UTC).astimezone().date()
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
log_file: Path = locker.log_file
|
||||
log_data = load_workout_log(log_file)
|
||||
sick_days = workout_standdown_dates()
|
||||
def _scan_runnerup(locker: ScreenLocker, log_file: Path, before_count: int) -> int:
|
||||
"""Run the RunnerUp week scan, print what it filled, return the new count.
|
||||
|
||||
print("=== Weekly Workout Status ===")
|
||||
|
||||
# Per-day breakdown
|
||||
for i in range(7):
|
||||
d = monday + timedelta(days=i)
|
||||
if d > today:
|
||||
break
|
||||
_print_day_line(d, log_data.get(d.isoformat(), []), sick_days)
|
||||
|
||||
print()
|
||||
|
||||
# The weekly total, per the anti-gaming rule (each verified workout counts
|
||||
# individually, manual counts once/day) — NOT a per-day checkmark count,
|
||||
# which would undercount a day holding two verified workouts.
|
||||
before_count = count_weekly_workouts(log_file)
|
||||
|
||||
# RunnerUp scan. Each fill is credited by the shared ingestion callback
|
||||
# (+2h first counted workout of its day, +1h each further one), the same
|
||||
# rule the timer's sync pass and the daily base reset use.
|
||||
Each fill is credited by the shared ingestion callback (+2h first counted
|
||||
workout of its day, +1h each further one), the same rule the timer's sync
|
||||
pass and the daily base reset use.
|
||||
"""
|
||||
n_filled = locker._scan_and_fill_week_runnerup(
|
||||
log_file, on_ingested=locker._credit_ingested_workout
|
||||
)
|
||||
locker.workout_data = {}
|
||||
if n_filled > 0:
|
||||
print(f" Auto-filled {n_filled} workout(s) from RunnerUp exports.")
|
||||
after_count = count_weekly_workouts(log_file)
|
||||
_, thu_sun, _ = locker._read_shutdown_config() or (None, None, None)
|
||||
if thu_sun is not None:
|
||||
print(f" Shutdown tonight: {hhmm(thu_sun)}.")
|
||||
else:
|
||||
if n_filled <= 0:
|
||||
print(" No new workouts found via RunnerUp scan.")
|
||||
after_count = before_count
|
||||
return before_count
|
||||
print(f" Auto-filled {n_filled} workout(s) from RunnerUp exports.")
|
||||
_, thu_sun, _ = locker._read_shutdown_config() or (None, None, None)
|
||||
if thu_sun is not None:
|
||||
print(f" Shutdown tonight: {hhmm(thu_sun)}.")
|
||||
return count_weekly_workouts(log_file)
|
||||
|
||||
print()
|
||||
|
||||
# Extra benefits summary
|
||||
streak = current_streak(EXTRA_BENEFITS_FILE)
|
||||
eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE)
|
||||
eb_str = "Yes — until 09:00" if eb_ext else "No"
|
||||
|
||||
# Heat skips this month
|
||||
def _heat_skips_this_month(log_data: dict[str, list[dict]]) -> str:
|
||||
"""One-line summary of this month's heat skips: count and the latest."""
|
||||
this_month = datetime.now(tz=UTC).astimezone().date().strftime("%Y-%m")
|
||||
heat_entries = [
|
||||
(d, entry)
|
||||
@ -129,21 +103,17 @@ def run_status(locker: ScreenLocker) -> None:
|
||||
if isinstance(entry, dict)
|
||||
and entry.get("workout_data", {}).get("type") == "heat_skip"
|
||||
]
|
||||
if heat_entries:
|
||||
last_date, last_e = max(heat_entries, key=lambda de: de[0])
|
||||
last_temp = last_e.get("workout_data", {}).get("temperature_celsius", "?")
|
||||
heat_str = f"{len(heat_entries)} (last: {last_date}, {last_temp}°C)"
|
||||
else:
|
||||
heat_str = "0"
|
||||
if not heat_entries:
|
||||
return "0"
|
||||
last_date, last_e = max(heat_entries, key=lambda de: de[0])
|
||||
last_temp = last_e.get("workout_data", {}).get("temperature_celsius", "?")
|
||||
return f"{len(heat_entries)} (last: {last_date}, {last_temp}°C)"
|
||||
|
||||
print(f" Streak (5+ wks) : {streak}")
|
||||
print(f" Early-bird extended : {eb_str}")
|
||||
print(f" Heat skips (month) : {heat_str}")
|
||||
print()
|
||||
|
||||
def _print_minimum(after_count: int) -> None:
|
||||
"""Print how far the weekly count is from (or past) the minimum."""
|
||||
remaining = max(0, WEEKLY_WORKOUT_MINIMUM - after_count)
|
||||
extra = max(0, after_count - WEEKLY_WORKOUT_MINIMUM)
|
||||
|
||||
if remaining > 0:
|
||||
print(
|
||||
f" Need {remaining} more to reach the minimum ({WEEKLY_WORKOUT_MINIMUM})."
|
||||
@ -156,13 +126,37 @@ def run_status(locker: ScreenLocker) -> None:
|
||||
f" ({WEEKLY_WORKOUT_MINIMUM}/{WEEKLY_WORKOUT_MINIMUM})."
|
||||
)
|
||||
|
||||
# Shutdown config
|
||||
|
||||
def run_status(locker: ScreenLocker) -> None:
|
||||
"""Print weekly workout status, run RunnerUp scan, apply bonus, then exit."""
|
||||
today = datetime.now(tz=UTC).astimezone().date()
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
log_file: Path = locker.log_file
|
||||
log_data = load_workout_log(log_file)
|
||||
sick_days = workout_standdown_dates()
|
||||
|
||||
print("=== Weekly Workout Status ===")
|
||||
for i in range(7):
|
||||
d = monday + timedelta(days=i)
|
||||
if d > today:
|
||||
break
|
||||
_print_day_line(d, log_data.get(d.isoformat(), []), sick_days)
|
||||
print()
|
||||
|
||||
after_count = _scan_runnerup(locker, log_file, count_weekly_workouts(log_file))
|
||||
print()
|
||||
|
||||
eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE)
|
||||
print(f" Streak (5+ wks) : {current_streak(EXTRA_BENEFITS_FILE)}")
|
||||
print(f" Early-bird extended : {'Yes — until 09:00' if eb_ext else 'No'}")
|
||||
print(f" Heat skips (month) : {_heat_skips_this_month(log_data)}")
|
||||
print()
|
||||
|
||||
_print_minimum(after_count)
|
||||
|
||||
cfg = locker._read_shutdown_config()
|
||||
if cfg:
|
||||
_mw, _ts, _morning = cfg
|
||||
print(f" Shutdown tonight : {hhmm(_mw)}")
|
||||
print(f" Shutdown tonight : {hhmm(cfg[0])}")
|
||||
|
||||
# --status is a read-out, not an enforcement run; make that explicit so the
|
||||
# journal never reads as though the locker evaluated and declined to lock.
|
||||
record_no_decision("--status")
|
||||
sys.exit(0)
|
||||
|
||||
@ -48,9 +48,11 @@ from screen_locker._constants import (
|
||||
from screen_locker._extra_benefits import (
|
||||
current_streak,
|
||||
has_extended_early_bird,
|
||||
week_rewards,
|
||||
)
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._morning_session import has_workout_skip_today
|
||||
from screen_locker._night_lockdown import tonights_lockdown
|
||||
from screen_locker._sick import workout_standdown_dates
|
||||
from screen_locker._weekly_check import (
|
||||
COUNTED_WORKOUT_TYPES,
|
||||
@ -167,6 +169,7 @@ def gather_status(
|
||||
weekly_minimum_met=week.counted_count >= WEEKLY_WORKOUT_MINIMUM,
|
||||
relaxed_day=is_relaxed_day(today=today_local),
|
||||
wake_skip=has_workout_skip_today(),
|
||||
evening_lockdown=tonights_lockdown(instant) is not None,
|
||||
now=instant,
|
||||
)
|
||||
|
||||
@ -175,6 +178,7 @@ def gather_status(
|
||||
week=week,
|
||||
streak=current_streak(extra_benefits_file),
|
||||
early_bird_extended=early_bird_extended,
|
||||
week_rewards=week_rewards(extra_benefits_file, today=today_local),
|
||||
shutdown=_shutdown_projection(
|
||||
shutdown_config_file=shutdown_config_file,
|
||||
today_local=today_local,
|
||||
|
||||
@ -89,7 +89,7 @@ def _tonight_minutes(snapshot: StatusSnapshot) -> int | None:
|
||||
def format_summary_line(snapshot: StatusSnapshot) -> str:
|
||||
"""Cheap one-liner for the i3blocks summary (e.g. before/after a click)."""
|
||||
week = snapshot.week
|
||||
mark = "✓" if week.remaining == 0 else "…"
|
||||
mark = "…" if week.remaining else "✓"
|
||||
tonight = _tonight_minutes(snapshot)
|
||||
tonight_str = hhmm(tonight) if tonight is not None else "?"
|
||||
sick = " · sick day" if snapshot.today.is_sick_day else ""
|
||||
|
||||
@ -81,6 +81,41 @@ class StatusSectionsMixin:
|
||||
color=self._colors.palette.success,
|
||||
)
|
||||
|
||||
def _section_streak(
|
||||
self, parent: tk.Widget, streak: int, week_rewards: tuple[str, ...] = ()
|
||||
) -> None:
|
||||
"""Render the consecutive 5+-workout-week streak and this week's reward.
|
||||
|
||||
Display only, and exactly the stored values (``extra_benefits_state``):
|
||||
a streak of 0 is said plainly, never padded or rounded up. Nothing here
|
||||
grants time or relaxes a lock. The early-bird extension is not shown:
|
||||
it no longer changes anything (wake-alarm owns early bird).
|
||||
"""
|
||||
del parent
|
||||
self._label("Workout streak", role="body", pad="sm")
|
||||
if streak > 0:
|
||||
self._text(
|
||||
f"🔥 {streak}-week streak (5+ workouts a week)",
|
||||
role="body",
|
||||
color=self._colors.palette.success,
|
||||
)
|
||||
else:
|
||||
self._text(
|
||||
"No streak yet: 5+ workouts in a week starts one.",
|
||||
role="body",
|
||||
color=self._colors.palette.muted,
|
||||
)
|
||||
for message in week_rewards:
|
||||
# A reset is news, not a celebration: it must not be painted green.
|
||||
lost = message.startswith("Streak reset")
|
||||
self._text(
|
||||
message,
|
||||
role="body",
|
||||
color=self._colors.palette.warning
|
||||
if lost
|
||||
else self._colors.palette.success,
|
||||
)
|
||||
|
||||
def _section_lock_explanation(
|
||||
self, parent: tk.Widget, expl: LockExplanation
|
||||
) -> None:
|
||||
|
||||
@ -86,3 +86,4 @@ class StatusSnapshot:
|
||||
lock_explanation: LockExplanation
|
||||
manual_workout_budget: ManualWorkoutBudgetStatus
|
||||
generated_at: str
|
||||
week_rewards: tuple[str, ...] = ()
|
||||
|
||||
@ -8,7 +8,7 @@ screen's unlock does.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING
|
||||
@ -19,6 +19,7 @@ from screen_locker._day import today_str
|
||||
from screen_locker._earned import extra_minutes, span
|
||||
from screen_locker._status_data import gather_status
|
||||
from screen_locker._weekly_check import WEEKLY_WORKOUT_MINIMUM, count_weekly_workouts
|
||||
from screen_locker.screen_lock import ScreenLocker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@ -26,7 +27,6 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from screen_locker._workout_credit import WorkoutCreditResult
|
||||
from screen_locker.screen_lock import ScreenLocker
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -53,8 +53,6 @@ def _make_bare_verifier(log_file: Path) -> ScreenLocker:
|
||||
``--status`` — just enough state for the verification mixins' methods
|
||||
to run, no Tk lock UI, no ``__init__`` side effects.
|
||||
"""
|
||||
from screen_locker.screen_lock import ScreenLocker
|
||||
|
||||
verifier = object.__new__(ScreenLocker)
|
||||
verifier.log_file = log_file
|
||||
verifier.workout_data = {}
|
||||
|
||||
@ -8,7 +8,8 @@ tombstones ``_manual_push`` re-publishes every tick, and dropping it would
|
||||
let a retracted session be credited again -- but an event that touched only
|
||||
this PC's log never triggers a pass.
|
||||
|
||||
Crediting is :func:`screen_locker._poke_credit.credit_sessions` under the
|
||||
Crediting is :func:`screen_locker._poke_credit.credit_sessions` (and
|
||||
``credit_manual_workouts`` for phone-logged manual workouts) under the
|
||||
same in-process lock as the LAN poke handler. ``write_signed_entry`` already
|
||||
dedups by workout id; the ``seen`` set here only spares the file I/O for
|
||||
records this daemon has already handed to the ingest.
|
||||
@ -23,11 +24,19 @@ from typing import TYPE_CHECKING, Final
|
||||
|
||||
from screen_locker import _poke_credit
|
||||
from screen_locker._rtdb_stream import LOG_LEAF, device_updates
|
||||
from screen_locker._sync_records import _session_records, merge_device_texts
|
||||
from screen_locker._sync_records import (
|
||||
_manual_records,
|
||||
_session_records,
|
||||
merge_device_texts,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
import threading
|
||||
|
||||
from crdt_sync import Hlc
|
||||
|
||||
from screen_locker._poke_credit import BatchOutcome
|
||||
from screen_locker._rtdb_stream import SseEvent
|
||||
|
||||
__all__ = ["DeviceLogCache", "EventResult"]
|
||||
@ -78,15 +87,17 @@ class DeviceLogCache:
|
||||
self._texts[dev] = text
|
||||
return changed
|
||||
|
||||
def _pending(self) -> list[tuple[str, dict[str, object], str]]:
|
||||
"""Union records this daemon has not yet handed to the ingest."""
|
||||
def _pending(
|
||||
self, extract: Callable[[str], dict[str, tuple[dict, Hlc]]], what: str
|
||||
) -> list[tuple[str, dict[str, object], str]]:
|
||||
"""Union ``extract`` records this daemon has not yet handed to the ingest."""
|
||||
union = merge_device_texts(
|
||||
(
|
||||
(f"{self._prefix}/{dev}/{LOG_LEAF}", text)
|
||||
for dev, text in sorted(self._texts.items())
|
||||
),
|
||||
_session_records,
|
||||
"sessions",
|
||||
extract,
|
||||
what,
|
||||
)
|
||||
return [
|
||||
(rid, payload, str(hlc))
|
||||
@ -115,39 +126,62 @@ class DeviceLogCache:
|
||||
snapshot=False,
|
||||
summary=f"only this PC's own log changed ({devices}); no credit",
|
||||
)
|
||||
pending = self._pending()
|
||||
if not pending:
|
||||
sessions = self._pending(_session_records, "sessions")
|
||||
manuals = self._pending(_manual_records, "manual workouts")
|
||||
if not sessions and not manuals:
|
||||
return EventResult(
|
||||
snapshot=full,
|
||||
summary=f"{len(self._texts)} device log(s) cached, changed: {devices}; "
|
||||
f"every session already handed to the ingest -- no-op, no file I/O "
|
||||
f"({_ms(started)} ms)",
|
||||
f"every session and manual workout already handed to the ingest "
|
||||
f"-- no-op, no file I/O ({_ms(started)} ms)",
|
||||
)
|
||||
try:
|
||||
with self._gate:
|
||||
outcome = _poke_credit.credit_sessions(
|
||||
[(rid, payload) for rid, payload, _hlc in pending]
|
||||
)
|
||||
except Exception:
|
||||
done = [
|
||||
_credit(pending, credit, what)
|
||||
for pending, credit, what in (
|
||||
(sessions, _poke_credit.credit_sessions, "session"),
|
||||
(
|
||||
manuals,
|
||||
_poke_credit.credit_manual_workouts,
|
||||
"manual workout",
|
||||
),
|
||||
)
|
||||
if pending
|
||||
]
|
||||
except Exception: # pylint: disable=broad-exception-caught # one bad event must not drop the stream
|
||||
# Not a stream problem: keep the connection (a reconnect would
|
||||
# re-download the snapshot and fail the same way). Nothing is
|
||||
# marked seen, so the next event retries.
|
||||
_logger.exception(
|
||||
"Crediting %d streamed session(s) failed; the next stream event "
|
||||
"retries, and the 15-min workout-sync still covers them",
|
||||
len(pending),
|
||||
"Crediting %d streamed session(s) and %d manual workout(s) failed; "
|
||||
"the next stream event retries, and the 15-min workout-sync still "
|
||||
"covers them",
|
||||
len(sessions),
|
||||
len(manuals),
|
||||
)
|
||||
return EventResult(snapshot=full, summary="crediting failed (see above)")
|
||||
self._seen.update((rid, hlc) for rid, _payload, hlc in pending)
|
||||
self._seen.update((rid, hlc) for rid, _payload, hlc in [*sessions, *manuals])
|
||||
return EventResult(
|
||||
snapshot=full,
|
||||
summary=f"{len(self._texts)} device log(s) cached, changed: {devices}; "
|
||||
f"{len(pending)} session(s) checked, {len(outcome.ingested)} newly "
|
||||
f"logged {list(outcome.ingested)}, {outcome.credited} credited "
|
||||
f"({_ms(started)} ms)",
|
||||
f"{'; '.join(done)} ({_ms(started)} ms)",
|
||||
)
|
||||
|
||||
|
||||
def _credit(
|
||||
pending: list[tuple[str, dict[str, object], str]],
|
||||
credit: Callable[[list[tuple[str, dict[str, object]]]], BatchOutcome],
|
||||
what: str,
|
||||
) -> str:
|
||||
"""Run one batch credit step; describe what it did."""
|
||||
outcome = credit([(rid, payload) for rid, payload, _hlc in pending])
|
||||
return (
|
||||
f"{len(pending)} {what}(s) checked, {len(outcome.ingested)} newly "
|
||||
f"logged {list(outcome.ingested)}, {outcome.credited} credited"
|
||||
)
|
||||
|
||||
|
||||
def _ms(started: float) -> int:
|
||||
"""Milliseconds since ``started`` (a ``perf_counter`` reading)."""
|
||||
return round((time.perf_counter() - started) * 1000)
|
||||
|
||||
@ -2,19 +2,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _manual_workout
|
||||
from screen_locker._source_health import collect_source_findings, explain_findings
|
||||
from screen_locker._ui_flows_lockout import LockoutFlowMixin
|
||||
from screen_locker._ui_flows_sick import SickDayFlowMixin
|
||||
from screen_locker._ui_flows_sick_panel import SickPanelFlowMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
class UIFlowsMixin(SickDayFlowMixin, LockoutFlowMixin):
|
||||
class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin):
|
||||
"""Mixin providing UI flow logic for the screen locker.
|
||||
|
||||
The sick-day and lockout/penalty flows live in sibling modules and are
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from screen_locker._weekly_check import (
|
||||
RELAXED_DAY_SKIP_TYPE,
|
||||
|
||||
@ -13,6 +13,10 @@ the retry screen, the no-phone penalty screen and both relaxed-day screens.
|
||||
A refusal (a free day, an unreadable pool) is shown on screen and the lock
|
||||
stays up.
|
||||
|
||||
Next to the button sits a small "choose lockers…" link: the same day, but
|
||||
with some lockers kept (``_ui_flows_sick_panel``). The button itself still
|
||||
keeps nothing.
|
||||
|
||||
Split out of :mod:`screen_locker._ui_flows` to keep every file under the
|
||||
250-line cap. Composed back into ``UIFlowsMixin`` there.
|
||||
"""
|
||||
@ -41,6 +45,8 @@ class SickDayFlowMixin:
|
||||
command=self.mark_sick_and_unlock,
|
||||
width=12,
|
||||
).pack(side="left", padx=self._colors.space("sm"))
|
||||
# The keep-aware alternative (``_ui_flows_sick_panel``), next to it.
|
||||
self._choose_lockers_link(frame)
|
||||
|
||||
def mark_sick_and_unlock(self) -> None:
|
||||
"""Mark today sick, lift tonight's shutdown to 20:00 and unlock."""
|
||||
@ -48,17 +54,27 @@ class SickDayFlowMixin:
|
||||
if not result.ok:
|
||||
self._show_sick_refused(result.message)
|
||||
return
|
||||
self._raise_sick_floor()
|
||||
# Best effort and off the UI thread: the unlock never waits for it.
|
||||
push_in_background()
|
||||
self._unlock_sick_day(
|
||||
f"Marked today sick from the lock screen. {result.message}"
|
||||
)
|
||||
|
||||
def _raise_sick_floor(self) -> None:
|
||||
"""Lift tonight's shutdown to the sick floor (a no-op unless sick)."""
|
||||
# Now, not at the next 5-minute tick: while this window is up that
|
||||
# tick stands down at the instance lock.
|
||||
with locked_shutdown_state(SHUTDOWN_BASE_FILE):
|
||||
self._apply_sick_floor()
|
||||
# Best effort and off the UI thread: the unlock never waits for it.
|
||||
push_in_background()
|
||||
self._record_decision(
|
||||
locked=False,
|
||||
reason="sick_day_marked",
|
||||
detail=f"Marked today sick from the lock screen. {result.message}",
|
||||
)
|
||||
|
||||
def _unlock_sick_day(self, detail: str) -> None:
|
||||
"""Record the sick-day unlock and close the lock.
|
||||
|
||||
Shared by the one-tap button and the keep panel; neither pushes here
|
||||
(the panel starts its own sync).
|
||||
"""
|
||||
self._record_decision(locked=False, reason="sick_day_marked", detail=detail)
|
||||
self.close()
|
||||
|
||||
def _show_sick_refused(self, message: str) -> None:
|
||||
|
||||
171
screen_locker/_ui_flows_sick_panel.py
Normal file
171
screen_locker/_ui_flows_sick_panel.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""The "choose lockers…" link: sickdays' KeepPanel, inside the lock window.
|
||||
|
||||
The one-tap "I'm sick" button (``_ui_flows_sick``) keeps nothing. This link,
|
||||
next to it, opens sickdays' embeddable :class:`~sickdays.gui.KeepPanel` so
|
||||
today can be marked sick with some lockers still on. It is built in-process
|
||||
on the lock's own surfaces: a separate window would get no input under the
|
||||
lock's X grab.
|
||||
|
||||
The panel covers each surface on a full-size backdrop instead of replacing
|
||||
the screen under it, so closing it reveals exactly the screen it was opened
|
||||
from -- a running penalty countdown or phone check is neither restarted nor
|
||||
skipped. The panel does the write and starts its own background sync; what
|
||||
the lock does afterwards depends on the pool, not on the entry:
|
||||
|
||||
* sick and the workout lock stands down -> the one-tap path's after-effects
|
||||
(sick floor, decision, close), without a second mark or push;
|
||||
* sick but the workout lock kept -> sick floor (20:00 applies on any sick
|
||||
day), the lock stays up with a short note;
|
||||
* cleared, or an unchanged Save -> the panel closes, nothing else.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gatelock import bind_activate
|
||||
import sickdays
|
||||
from sickdays.gui import KeepPanel
|
||||
|
||||
from screen_locker._sick import stands_down_workout
|
||||
from screen_locker._surface_group import FrameGroup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from gatelock import LockConfig
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
LINK_TEXT = "choose lockers…"
|
||||
KEPT_NOTE = "Workout lock kept — finish the workout to unlock."
|
||||
|
||||
|
||||
def build_sick_panel(
|
||||
parent: tk.Misc,
|
||||
colors: LockConfig,
|
||||
*,
|
||||
on_saved: Callable[[sickdays.SickDay | None], None],
|
||||
on_cancel: Callable[[], None],
|
||||
paths: sickdays.Paths | None = None,
|
||||
) -> tk.Frame:
|
||||
"""Centre today's KeepPanel in ``parent`` and return the frame holding it.
|
||||
|
||||
Separate from the mixin so ``scripts/verify_screen_fits.py`` can measure
|
||||
it on a throwaway pool (``paths``) instead of the live one.
|
||||
"""
|
||||
outer = tk.Frame(parent, bg=colors.palette.bg)
|
||||
outer.place(relx=0.5, rely=0.5, anchor="center")
|
||||
KeepPanel(
|
||||
outer, sickdays.today(), on_saved=on_saved, on_cancel=on_cancel, paths=paths
|
||||
).pack()
|
||||
return outer
|
||||
|
||||
|
||||
class SickPanelFlowMixin:
|
||||
"""The keep-aware sick-day panel, layered over the current lock screen."""
|
||||
|
||||
_sick_overlay: FrameGroup | None = None
|
||||
|
||||
def _choose_lockers_link(self, frame: FrameGroup) -> None:
|
||||
"""Pack the small secondary link that opens the panel into ``frame``."""
|
||||
palette = self._colors.palette
|
||||
links = frame.child_widgets(
|
||||
tk.Button,
|
||||
text=LINK_TEXT,
|
||||
font=self._colors.font("label"),
|
||||
bg=palette.bg,
|
||||
fg=palette.muted,
|
||||
activebackground=palette.field_bg,
|
||||
activeforeground=palette.fg,
|
||||
command=self.open_sick_panel,
|
||||
cursor="hand2" if self.demo_mode else "",
|
||||
relief="flat",
|
||||
borderwidth=0,
|
||||
padx=self._colors.space("sm"),
|
||||
pady=self._colors.space("xs"),
|
||||
**self._colors.focus_kwargs(),
|
||||
)
|
||||
for link in links:
|
||||
bind_activate(link)
|
||||
links.pack(side="left", padx=self._colors.space("sm"))
|
||||
|
||||
def open_sick_panel(self) -> None:
|
||||
"""Lay today's KeepPanel over every monitor's surface."""
|
||||
overlay = self._new_sick_overlay()
|
||||
panels = [
|
||||
build_sick_panel(
|
||||
backdrop,
|
||||
self._colors,
|
||||
on_saved=self._on_sick_panel_saved,
|
||||
on_cancel=self._close_sick_panel,
|
||||
)
|
||||
for backdrop in overlay
|
||||
]
|
||||
# Into the panel on the primary monitor: left on the link, Tab and
|
||||
# Enter would reach the hidden buttons of the screen underneath. Tk
|
||||
# skips unmapped widgets when traversing, so lay the panel out first.
|
||||
for panel in panels[:1]:
|
||||
panel.update_idletasks()
|
||||
(panel.tk_focusNext() or panel).focus_set()
|
||||
|
||||
def _new_sick_overlay(self) -> FrameGroup:
|
||||
"""Replace any open overlay with a fresh full-surface backdrop group."""
|
||||
self._close_sick_panel()
|
||||
backdrops: list[tk.Frame] = []
|
||||
for surface in self.container.surfaces:
|
||||
backdrop = tk.Frame(surface.winfo_toplevel(), bg=self._colors.palette.bg)
|
||||
backdrop.place(relx=0, rely=0, relwidth=1, relheight=1)
|
||||
backdrop.lift()
|
||||
backdrops.append(backdrop)
|
||||
self._sick_overlay = FrameGroup(backdrops)
|
||||
return self._sick_overlay
|
||||
|
||||
def _close_sick_panel(self) -> None:
|
||||
"""Remove the overlay; the lock screen under it was never touched."""
|
||||
if self._sick_overlay is not None:
|
||||
self._sick_overlay.destroy()
|
||||
self._sick_overlay = None
|
||||
|
||||
def _on_sick_panel_saved(self, entry: sickdays.SickDay | None) -> None:
|
||||
"""React to the panel's write; the pool is already up to date."""
|
||||
self._close_sick_panel()
|
||||
if entry is None or not entry.is_sick:
|
||||
_logger.warning(
|
||||
"Sick panel saved without a sick day (%s); the lock stays as it was",
|
||||
"unchanged" if entry is None else "cleared",
|
||||
)
|
||||
return
|
||||
self._raise_sick_floor()
|
||||
kept = ", ".join(sorted(entry.keep)) or "nothing"
|
||||
if stands_down_workout():
|
||||
self._unlock_sick_day(
|
||||
f"Marked today sick from the lock screen's keep panel (kept: {kept})."
|
||||
)
|
||||
return
|
||||
_logger.warning("Sick day keeps the workout lock (kept: %s); staying up", kept)
|
||||
self._show_kept_note()
|
||||
|
||||
def _show_kept_note(self) -> None:
|
||||
"""Say the lock stays because the workout locker was kept."""
|
||||
box = self._new_sick_overlay().child_frame(bg=self._colors.palette.bg)
|
||||
for frame in box:
|
||||
frame.place(relx=0.5, rely=0.5, anchor="center")
|
||||
box.child_widgets(
|
||||
tk.Label,
|
||||
text=KEPT_NOTE,
|
||||
font=self._colors.font("title", bold=True),
|
||||
fg=self._colors.palette.warning,
|
||||
bg=self._colors.palette.bg,
|
||||
).pack(pady=self._colors.space("md"))
|
||||
back = self._button(
|
||||
box,
|
||||
"Back",
|
||||
bg=self._colors.palette.accent,
|
||||
command=self._close_sick_panel,
|
||||
width=12,
|
||||
)
|
||||
back.pack(pady=self._colors.space("sm"))
|
||||
back.first.focus_set()
|
||||
@ -1,41 +1,64 @@
|
||||
"""Walking in RunnerUp TCX exports: which files are walks, and their moving time.
|
||||
"""One crediting rule for every GPS activity, from its RunnerUp-style TCX export.
|
||||
|
||||
A day's walking counts as the workout once its walks -- usually separate
|
||||
commute legs, one file each -- add up to ``WALK_MIN_MOVING_MINUTES`` of
|
||||
*moving* time.
|
||||
A day's activities -- runs and walks, RunnerUp's files and the workout app's
|
||||
alike -- are summed, and the day counts as the workout once EITHER
|
||||
|
||||
**Which files are walks.** RunnerUp's WebDAV and File synchronisers both
|
||||
export with ``ExportOptions.getDefault()``, whose TCX writer knows only
|
||||
Running/Biking and writes ``Sport="Other"`` for a walk. The sport survives
|
||||
only in the file name (``Sport.TapiriikType()``): ``..._Walking.tcx``. The
|
||||
Strava export variant writes ``Sport="Walking"``, so that counts too.
|
||||
``Other`` alone is not a walk: it is every sport RunnerUp cannot name.
|
||||
* their distance reaches ``MIN_RUN_DISTANCE_KM`` (within the GPS tolerance), OR
|
||||
* their *moving* time reaches the shared duration bar
|
||||
(``WORKOUT_DURATION_ACCEPT_MINUTES``).
|
||||
|
||||
The TCX sport and the ``_Walking`` file-name suffix never change the outcome;
|
||||
they only decide the label a message and the log's ``activity`` field carry
|
||||
(the phone's history shows it).
|
||||
|
||||
**Which files are walks (label only).** RunnerUp's WebDAV and File
|
||||
synchronisers both export with ``ExportOptions.getDefault()``, whose TCX writer
|
||||
knows only Running/Biking and writes ``Sport="Other"`` for a walk. The sport
|
||||
survives only in the file name (``Sport.TapiriikType()``): ``..._Walking.tcx``.
|
||||
The Strava export variant writes ``Sport="Walking"``, so that counts too.
|
||||
|
||||
**Moving time, not elapsed.** A lap's ``TotalTimeSeconds`` is wall-clock: one
|
||||
real lap reads 101 min for 1 km of standing about. Moving time is summed over
|
||||
consecutive trackpoint pairs whose speed is walking speed
|
||||
(``WALK_SPEED_MPS``): standing still is too slow, a tram or a bus too fast.
|
||||
A pair further apart than ``WALK_MAX_GAP_SECONDS`` is a GPS dropout whose
|
||||
motion is unknown and earns nothing -- the walk is never credited for time
|
||||
nobody saw.
|
||||
real lap reads 101 min for 1 km of standing about. The track is cut into
|
||||
windows of at least ``TRACK_WINDOW_SECONDS`` (a fix every second jitters, so
|
||||
one raw pair can read 15 m/s inside a 3 m/s run) and each window is judged by
|
||||
its average speed. Faster than ``MAX_MOVING_SPEED_MPS`` (25 km/h) is a vehicle
|
||||
or a GPS jump: neither time nor distance counts, so a bus ride earns nothing.
|
||||
Otherwise its metres count, and its seconds count as moving time at
|
||||
``MIN_MOVING_SPEED_MPS`` or faster; standing still is too slow. A window longer
|
||||
than ``WALK_MAX_GAP_SECONDS`` is a signal dropout (real exports have 2 minute
|
||||
ones): the runner may have kept running unseen, so it counts as moving only at
|
||||
``MIN_DROPOUT_SPEED_MPS`` or faster, while a pause (no distance across the
|
||||
gap) earns nothing -- a pause cannot buy a run its duration.
|
||||
The day's distance is the sum of the windows (``track_distance_m``), never the
|
||||
lap's ``DistanceMeters`` total, and an export with no trackpoints cannot be
|
||||
speed-checked, so it credits nothing. Only the root-DB fallback, which has no
|
||||
trackpoints at all, is judged by its recorded distance and duration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import itertools
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from screen_locker._activity_day import (
|
||||
SPORT_NAMES,
|
||||
ActivityDay,
|
||||
activity_day,
|
||||
activity_shortfall,
|
||||
distance_qualifies,
|
||||
moving_qualifies,
|
||||
)
|
||||
from screen_locker._constants import (
|
||||
MAX_MOVING_SPEED_MPS,
|
||||
MIN_DROPOUT_SPEED_MPS,
|
||||
MIN_MOVING_SPEED_MPS,
|
||||
TRACK_WINDOW_SECONDS,
|
||||
WALK_MAX_GAP_SECONDS,
|
||||
WALK_MIN_MOVING_MINUTES,
|
||||
WALK_SPEED_MPS,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Iterator
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@ -72,73 +95,78 @@ def _trackpoints(activity: ET.Element) -> list[tuple[float, float]]:
|
||||
return points
|
||||
|
||||
|
||||
def _segments(
|
||||
points: list[tuple[float, float]],
|
||||
) -> Iterator[tuple[float, float]]:
|
||||
"""``(seconds, metres)`` of the track cut into windows of >= TRACK_WINDOW_SECONDS.
|
||||
|
||||
A fix every second jitters (one pair can read 15 m/s inside a 3 m/s run),
|
||||
so speed is judged over a window, never over a raw 1 s pair. Fixes that
|
||||
are already further apart than the window are their own segment.
|
||||
"""
|
||||
if not points:
|
||||
return
|
||||
anchor = points[0]
|
||||
for point in points[1:]:
|
||||
if point[0] - anchor[0] >= TRACK_WINDOW_SECONDS:
|
||||
yield point[0] - anchor[0], point[1] - anchor[1]
|
||||
anchor = point
|
||||
if points[-1] is not anchor:
|
||||
yield points[-1][0] - anchor[0], points[-1][1] - anchor[1]
|
||||
|
||||
|
||||
def track_summary(activity: ET.Element) -> dict[str, Any]:
|
||||
"""Moving seconds and end time of one activity, from its trackpoints."""
|
||||
"""Moving seconds, credited distance and end time, from the trackpoints.
|
||||
|
||||
A segment (see :func:`_segments`) counts unless it was travelled faster
|
||||
than ``MAX_MOVING_SPEED_MPS``: it then adds its metres to
|
||||
``track_distance_m``, and its seconds to ``moving_seconds`` too when it was
|
||||
travelled at least at ``MIN_MOVING_SPEED_MPS``. A segment longer than
|
||||
``WALK_MAX_GAP_SECONDS`` is a signal dropout whose motion is unknown; it
|
||||
only counts as moving time at ``MIN_DROPOUT_SPEED_MPS``, so a pause (no
|
||||
distance across the gap) earns no time while a runner whose GPS went away
|
||||
for two minutes keeps the time and metres the far side proves.
|
||||
"""
|
||||
points = _trackpoints(activity)
|
||||
slow, fast = WALK_SPEED_MPS
|
||||
moving = 0.0
|
||||
for (t0, d0), (t1, d1) in itertools.pairwise(points):
|
||||
gap = t1 - t0
|
||||
if 0 < gap <= WALK_MAX_GAP_SECONDS and slow <= (d1 - d0) / gap <= fast:
|
||||
moving += gap
|
||||
metres = 0.0
|
||||
for seconds, travelled in _segments(points):
|
||||
if seconds <= 0:
|
||||
continue
|
||||
speed = travelled / seconds
|
||||
if speed > MAX_MOVING_SPEED_MPS:
|
||||
continue
|
||||
metres += max(travelled, 0.0)
|
||||
floor = (
|
||||
MIN_MOVING_SPEED_MPS
|
||||
if seconds <= WALK_MAX_GAP_SECONDS
|
||||
else MIN_DROPOUT_SPEED_MPS
|
||||
)
|
||||
if speed >= floor:
|
||||
moving += seconds
|
||||
return {
|
||||
"activity_id": activity.findtext(f"{_TCX_NS}Id") or "",
|
||||
"moving_seconds": moving,
|
||||
"track_distance_m": metres,
|
||||
"ended_at": points[-1][0] if points else None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WalkDay:
|
||||
"""A day's walks, summed.
|
||||
|
||||
Attributes:
|
||||
legs: Distinct walks (deduplicated by the activity's ``Id``: the
|
||||
WebDAV copy and the phone's copy are the same walk).
|
||||
moving_minutes: Their summed moving time.
|
||||
distance_km: Their summed distance.
|
||||
completed_at: Unix time the walk that crossed the bar ended -- when
|
||||
the workout was actually done -- or ``None`` if it never did.
|
||||
"""
|
||||
|
||||
legs: int
|
||||
moving_minutes: float
|
||||
distance_km: float
|
||||
completed_at: float | None
|
||||
|
||||
@property
|
||||
def qualifies(self) -> bool:
|
||||
"""Whether the day's walking counts as its workout."""
|
||||
return self.moving_minutes >= WALK_MIN_MOVING_MINUTES
|
||||
|
||||
def message(self) -> str:
|
||||
"""The verification line, in the RunnerUp verifier's own style."""
|
||||
return (
|
||||
f"Walking: {self.legs} walk(s), {self.distance_km:.1f} km, "
|
||||
f"{self.moving_minutes:.0f} min moving"
|
||||
)
|
||||
|
||||
|
||||
def walk_shortfall(day: WalkDay) -> str:
|
||||
"""Why a day's walking did not count yet."""
|
||||
return f"{day.message()} -- need {WALK_MIN_MOVING_MINUTES}+ min moving"
|
||||
|
||||
|
||||
def walk_day(walks: Iterable[dict[str, Any]]) -> WalkDay:
|
||||
"""Sum parsed walk exports (``_parse_tcx`` dicts) into one :class:`WalkDay`."""
|
||||
unique: dict[str, dict[str, Any]] = {}
|
||||
for walk in walks:
|
||||
unique.setdefault(str(walk.get("activity_id") or id(walk)), walk)
|
||||
ordered = sorted(unique.values(), key=lambda w: w.get("ended_at") or 0.0)
|
||||
moving = 0.0
|
||||
completed_at: float | None = None
|
||||
for walk in ordered:
|
||||
moving += float(walk.get("moving_seconds", 0.0))
|
||||
if completed_at is None and moving / 60 >= WALK_MIN_MOVING_MINUTES:
|
||||
completed_at = walk.get("ended_at")
|
||||
return WalkDay(
|
||||
legs=len(ordered),
|
||||
moving_minutes=moving / 60,
|
||||
distance_km=sum(float(w.get("distance_m", 0.0)) for w in ordered) / 1000,
|
||||
completed_at=completed_at,
|
||||
)
|
||||
# The day aggregate lives in ``_activity_day``; these names stay importable here,
|
||||
# along with the walk-only names they replaced.
|
||||
WalkDay = ActivityDay
|
||||
walk_day = activity_day
|
||||
walk_shortfall = activity_shortfall
|
||||
__all__ = [
|
||||
"SPORT_NAMES",
|
||||
"ActivityDay",
|
||||
"WalkDay",
|
||||
"activity_day",
|
||||
"activity_shortfall",
|
||||
"distance_qualifies",
|
||||
"is_walking_export",
|
||||
"moving_qualifies",
|
||||
"track_summary",
|
||||
"walk_day",
|
||||
"walk_shortfall",
|
||||
]
|
||||
|
||||
@ -89,7 +89,7 @@ def _decision_limit(query: str) -> int:
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
"""Serve the status payloads and the static frontend bundle (read-only)."""
|
||||
|
||||
def log_message(self, fmt: str, *args: object) -> None:
|
||||
def log_message(self, fmt: str, /, *args: object) -> None:
|
||||
"""Route the default request log to ``logging`` at debug level.
|
||||
|
||||
Args:
|
||||
|
||||
@ -223,7 +223,7 @@ def count_weekly_workouts(
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
if not (week_start <= entry_date <= today_date):
|
||||
if entry_date < week_start or entry_date > today_date:
|
||||
continue
|
||||
count += count_day_credits(date_str, entries)
|
||||
return count
|
||||
|
||||
@ -49,9 +49,11 @@ if TYPE_CHECKING:
|
||||
stdout: IO[str] | None
|
||||
returncode: int | None
|
||||
|
||||
def terminate(self) -> None: ...
|
||||
def terminate(self) -> None:
|
||||
"""Ask the process to stop."""
|
||||
|
||||
def wait(self) -> int: ...
|
||||
def wait(self) -> int:
|
||||
"""Block until the process exits; return its exit code."""
|
||||
|
||||
class _Spawner(Protocol):
|
||||
"""``subprocess.Popen``, narrowed to the one call shape used here."""
|
||||
|
||||
@ -23,6 +23,7 @@ from screen_locker._day import today_str
|
||||
from screen_locker._earned import extra_minutes
|
||||
from screen_locker._rest_day import is_rest_day
|
||||
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES, count_weekly_workouts
|
||||
from screen_locker._workout_ledger import entry_done_at
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -66,12 +67,22 @@ class WorkoutCreditMixin:
|
||||
workout_data: dict[str, str]
|
||||
log_file: Path
|
||||
|
||||
def _done_at(self, day: date) -> float | None:
|
||||
"""When this workout ended; ``None`` (now) when it records no end.
|
||||
|
||||
``workout_data`` is the entry's inner dict, so it is wrapped as the
|
||||
log entry ``entry_done_at`` reads. The run's own end anchors a late
|
||||
workout, never the moment a sync applied it.
|
||||
"""
|
||||
return entry_done_at({"workout_data": self.workout_data}, day)
|
||||
|
||||
def _try_adjust_shutdown_for_workout(self) -> bool:
|
||||
"""Try to adjust shutdown time later for actual workouts."""
|
||||
workout_type = self.workout_data.get("type", "")
|
||||
if workout_type not in COUNTED_WORKOUT_TYPES:
|
||||
return False
|
||||
adjusted = self._adjust_shutdown_time_later()
|
||||
day = date.fromisoformat(today_str())
|
||||
adjusted = self._adjust_shutdown_time_later(at=self._done_at(day))
|
||||
if adjusted:
|
||||
_logger.info("Shutdown time moved later as workout reward")
|
||||
return adjusted
|
||||
@ -141,7 +152,7 @@ class WorkoutCreditMixin:
|
||||
slot is CreditSlot.FURTHER
|
||||
and extra
|
||||
and before
|
||||
and self._adjust_shutdown_time_by(extra)
|
||||
and self._adjust_shutdown_time_by(extra, at=self._done_at(day))
|
||||
):
|
||||
new_cfg = self._read_shutdown_config()
|
||||
if new_cfg:
|
||||
@ -172,7 +183,7 @@ class WorkoutCreditMixin:
|
||||
"""
|
||||
try:
|
||||
_credit_notify.notify_workout_credit(before, after, self.log_file)
|
||||
except Exception:
|
||||
except Exception: # pylint: disable=broad-exception-caught # notification must never fail the credit
|
||||
_logger.exception(
|
||||
"Workout credit notification failed — the credit itself is "
|
||||
"applied; only the desktop notification is missing"
|
||||
|
||||
@ -41,7 +41,11 @@ def lock_grab_handoff(lock: LockWindow) -> GrabHandoff:
|
||||
def release() -> None:
|
||||
# Stop first: a watchdog tick between the release and the app's own
|
||||
# grab would take the screen straight back off it.
|
||||
lock._recovery.stop()
|
||||
lock.recovery.stop()
|
||||
# Claim.grab_held false BEFORE the release (fail closed), so
|
||||
# leisure-watch never reads "held" while the workout app has input.
|
||||
# reacquire() -> grab_input() sets it true once the grab lands.
|
||||
lock.report_grab_held(held=False)
|
||||
with contextlib.suppress(tk.TclError):
|
||||
lock.root.grab_release()
|
||||
_logger.warning(
|
||||
|
||||
@ -4,8 +4,8 @@ Every other earner publishes its credits as HMAC-signed rows in a ledger that
|
||||
``earned_time`` reads (``first_credit_at``, ``credit_units``). This is the
|
||||
workout's: ``~/.local/share/workout_locker/ledger.json``, one row per
|
||||
|
||||
* workout credit slot in ``log.json`` -- RunnerUp (a run, or the day's walks
|
||||
summed), StrongLifts (phone or PC session), manual, any lock-screen or
|
||||
* workout credit slot in ``log.json`` -- RunnerUp (the day's runs and walks,
|
||||
summed by one rule), StrongLifts (phone or PC session), manual, any lock-screen or
|
||||
verify path -- appended by the one log write chokepoint
|
||||
(``_log_mixin.write_signed_entry``), so no path can credit without it; and
|
||||
* declared rest day -- appended when it is declared.
|
||||
|
||||
@ -38,9 +38,11 @@ if TYPE_CHECKING:
|
||||
stdout: IO[str] | None
|
||||
returncode: int | None
|
||||
|
||||
def poll(self) -> int | None: ...
|
||||
def poll(self) -> int | None:
|
||||
"""Return the exit code, or ``None`` while it still runs."""
|
||||
|
||||
def terminate(self) -> None: ...
|
||||
def terminate(self) -> None:
|
||||
"""Ask the process to stop."""
|
||||
|
||||
|
||||
__all__ = ["WorkoutSession"]
|
||||
|
||||
@ -69,7 +69,7 @@ def _start_stream(server: PokeServer) -> None:
|
||||
# Same lock as the HTTP handler: a poke and a streamed copy of one
|
||||
# session must never be credited concurrently.
|
||||
SessionStream(server.gate).start()
|
||||
except Exception:
|
||||
except Exception: # pylint: disable=broad-exception-caught # daemon boundary: logged, LAN poke must survive
|
||||
_logger.exception(
|
||||
"Could not start the Firebase session stream; off-LAN workouts will "
|
||||
"only be credited by the 15-min workout-sync until the daemon restarts"
|
||||
|
||||
99
screen_locker/runnerup_retire.py
Normal file
99
screen_locker/runnerup_retire.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""``python3 -m screen_locker.runnerup_retire``: uninstall RunnerUp, a week on.
|
||||
|
||||
What ``runnerup-retire.service`` runs. ``--status`` prints anchor, due date,
|
||||
marker validity and (with ``--query-phone``) whether RunnerUp is installed;
|
||||
it never writes and never talks to the phone unless asked. ``--dry-run`` runs
|
||||
every check, including the read-only adb queries, but writes nothing, pulls
|
||||
nothing and uninstalls nothing. The exit code is non-zero only for outcomes
|
||||
that need a human (bad marker, adb missing, failed backup or uninstall).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._runnerup_retire import (
|
||||
GRACE,
|
||||
Paths,
|
||||
derive_anchor,
|
||||
run_retire,
|
||||
)
|
||||
from screen_locker._runnerup_retire_adb import PHONE_SERIAL, AdbError, Phone
|
||||
from screen_locker._runnerup_retire_state import load_state
|
||||
from screen_locker._runnerup_retire_tcx import check_marker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from screen_locker._runnerup_retire_adb import PhoneLike
|
||||
|
||||
__all__ = ["main", "status_lines"]
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _installed_line(phone: PhoneLike, *, query: bool) -> str:
|
||||
if not query:
|
||||
return "installed: not queried (add --query-phone)"
|
||||
try:
|
||||
device = phone.find_device()
|
||||
if device.serial is None:
|
||||
return f"installed: unknown ({device.reason})"
|
||||
return f"installed: {'yes' if phone.installed(device.serial) else 'no'}"
|
||||
except AdbError as exc:
|
||||
_logger.warning("--status could not ask the phone: %s", exc)
|
||||
return f"installed: unknown ({exc})"
|
||||
|
||||
|
||||
def status_lines(
|
||||
paths: Paths, phone: PhoneLike, now: datetime, *, query_phone: bool = False
|
||||
) -> list[str]:
|
||||
"""The ``--status`` report. Read-only: nothing is created or saved."""
|
||||
state = load_state(paths.state_file)
|
||||
anchor = derive_anchor(paths, state)
|
||||
if anchor is None:
|
||||
anchor_line, due_line = "anchor: none (no app run credited yet)", "due: never"
|
||||
else:
|
||||
origin = "stored" if state.anchor_at else "derived, not yet stored"
|
||||
due = anchor + GRACE
|
||||
days = (due - now).total_seconds() / 86400
|
||||
anchor_line = f"anchor: {anchor.isoformat()} ({origin})"
|
||||
due_line = f"due: {due:%Y-%m-%d %H:%M} ({days:+.1f} days)"
|
||||
marker = check_marker(paths.marker, paths.processed)
|
||||
return [
|
||||
anchor_line,
|
||||
due_line,
|
||||
f"marker: {'valid' if marker.ok else 'INVALID'} ({marker.reason})",
|
||||
f"done: {state.done_at or 'no'}",
|
||||
_installed_line(phone, query=query_phone),
|
||||
]
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI entry; returns the process exit code."""
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n", maxsplit=1)[0])
|
||||
parser.add_argument(
|
||||
"--status", action="store_true", help="print state, change nothing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="check everything, change nothing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--query-phone", action="store_true", help="with --status: ask adb"
|
||||
)
|
||||
parser.add_argument("--serial", default=PHONE_SERIAL, help="phone adb serial")
|
||||
args = parser.parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
paths, phone = Paths.default(), Phone(args.serial)
|
||||
if args.status:
|
||||
now = datetime.now(UTC)
|
||||
lines = status_lines(paths, phone, now, query_phone=args.query_phone)
|
||||
sys.stdout.write("\n".join(lines) + "\n")
|
||||
return 0
|
||||
return 0 if run_retire(paths, phone, dry_run=args.dry_run).ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -22,6 +22,7 @@ from gatelock import (
|
||||
|
||||
from screen_locker import _instance
|
||||
from screen_locker._auto_upgrade import AutoUpgradeMixin
|
||||
from screen_locker._carrot_watch import watch_for_carrot
|
||||
from screen_locker._constants import (
|
||||
EARLY_BIRD_END_HOUR,
|
||||
EARLY_BIRD_END_MINUTE,
|
||||
@ -37,6 +38,7 @@ from screen_locker._constants import (
|
||||
from screen_locker._decision_log import record_run_aborted
|
||||
from screen_locker._early_bird import EarlyBirdMixin
|
||||
from screen_locker._heat_skip import HeatSkipMixin
|
||||
from screen_locker._hold import watch_for_hold
|
||||
from screen_locker._log_mixin import LogMixin
|
||||
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
|
||||
from screen_locker._phone_verification import PhoneVerificationMixin
|
||||
@ -208,6 +210,8 @@ class ScreenLocker(
|
||||
# across two separate if/elif/else statements).
|
||||
if self._lock is not None: # pragma: no branch
|
||||
self._lock.grab_input()
|
||||
watch_for_hold(self.root, self.close)
|
||||
watch_for_carrot(self.root, self._stand_down_for_carrot)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the application and exit."""
|
||||
|
||||
@ -115,6 +115,7 @@ class StatusWindow(
|
||||
self._label("Workout Status", role="title", pad="md")
|
||||
self._section_today(self.container, snapshot.today)
|
||||
self._section_week(self.container, snapshot.week)
|
||||
self._section_streak(self.container, snapshot.streak, snapshot.week_rewards)
|
||||
self._section_lock_explanation(self.container, snapshot.lock_explanation)
|
||||
self._section_temperature(self.container)
|
||||
self._section_manual_workout_budget(
|
||||
|
||||
96
screen_locker/tests/_activity_helpers.py
Normal file
96
screen_locker/tests/_activity_helpers.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""Helpers for the unified-activity tests: TCX builder and a phone-less verifier."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from screen_locker._runnerup_verification import RunnerUpVerificationMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
||||
DAY = "2026-10-10"
|
||||
|
||||
|
||||
def tcx(
|
||||
sport: str,
|
||||
start: str,
|
||||
*,
|
||||
moving_min: float,
|
||||
metres: float,
|
||||
pause_min: float = 0.0,
|
||||
) -> str:
|
||||
"""A RunnerUp-shaped export: ``moving_min`` of steady motion covering ``metres``.
|
||||
|
||||
A ``pause_min`` pause sits in the middle of the track as ONE gap between
|
||||
two trackpoints (the clock runs, the distance does not), so the lap's
|
||||
wall-clock time is ``moving_min + pause_min``.
|
||||
"""
|
||||
begin = datetime.fromisoformat(f"{DAY}T{start}:00+00:00")
|
||||
speed = metres / (moving_min * 60)
|
||||
total = int(moving_min * 60)
|
||||
half = total // 2 // 5 * 5
|
||||
pause = int(pause_min * 60)
|
||||
points = ""
|
||||
for s in range(0, total + 1, 5):
|
||||
offset = s + (pause if s > half else 0)
|
||||
points += (
|
||||
f"<Trackpoint><Time>{(begin + timedelta(seconds=offset)).isoformat()}"
|
||||
f"</Time><DistanceMeters>{s * speed:.1f}</DistanceMeters></Trackpoint>"
|
||||
)
|
||||
return (
|
||||
f'<TrainingCenterDatabase xmlns="{_NS}"><Activities>'
|
||||
f'<Activity Sport="{sport}"><Id>{DAY}T{start}</Id><Lap>'
|
||||
f"<TotalTimeSeconds>{total + pause}</TotalTimeSeconds>"
|
||||
f"<DistanceMeters>{metres}</DistanceMeters>"
|
||||
f"<Track>{points}</Track></Lap></Activity></Activities>"
|
||||
"</TrainingCenterDatabase>"
|
||||
)
|
||||
|
||||
|
||||
class Verifier(RunnerUpVerificationMixin):
|
||||
"""The real mixin reading one temporary drop directory and no phone."""
|
||||
|
||||
def __init__(self, folder: Path) -> None:
|
||||
self.folder = folder
|
||||
|
||||
def _has_adb_device(self) -> bool:
|
||||
return False
|
||||
|
||||
def _run_adb(self, args: list[str]) -> tuple[bool, str]:
|
||||
return False, ""
|
||||
|
||||
def _find_runnerup_exports_for_date(self, date_str: str) -> list[str]:
|
||||
return sorted(str(p) for p in self.folder.glob(f"*{date_str}*.tcx"))
|
||||
|
||||
|
||||
def drop(folder: Path, stamp: str, suffix: str, body: str) -> None:
|
||||
"""Write a RunnerUp-named export for the test day."""
|
||||
(folder / f"RunnerUp_Pixel_6a_{DAY}-{stamp}{suffix}.tcx").write_text(body)
|
||||
|
||||
|
||||
def verify(verifier: Verifier) -> tuple[str, str] | None:
|
||||
"""The file-based verdict for the test day."""
|
||||
with patch("screen_locker._runnerup_verification.today_str", return_value=DAY):
|
||||
return verifier._verify_runnerup_via_files()
|
||||
|
||||
|
||||
def tcx_points(sport: str, start: str, points: list[tuple[int, float]]) -> str:
|
||||
"""An export whose track is exactly ``(seconds after start, cumulative metres)``."""
|
||||
begin = datetime.fromisoformat(f"{DAY}T{start}:00+00:00")
|
||||
body = "".join(
|
||||
f"<Trackpoint><Time>{(begin + timedelta(seconds=s)).isoformat()}</Time>"
|
||||
f"<DistanceMeters>{m:.1f}</DistanceMeters></Trackpoint>"
|
||||
for s, m in points
|
||||
)
|
||||
return (
|
||||
f'<TrainingCenterDatabase xmlns="{_NS}"><Activities>'
|
||||
f'<Activity Sport="{sport}"><Id>{DAY}T{start}</Id><Lap>'
|
||||
f"<TotalTimeSeconds>{points[-1][0]}</TotalTimeSeconds>"
|
||||
f"<DistanceMeters>{points[-1][1]}</DistanceMeters>"
|
||||
f"<Track>{body}</Track></Lap></Activity></Activities>"
|
||||
"</TrainingCenterDatabase>"
|
||||
)
|
||||
28
screen_locker/tests/_hold_fakes.py
Normal file
28
screen_locker/tests/_hold_fakes.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""A ``holds`` stand-in for tests: ``covers`` returns a value or raises it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class FakeHolds:
|
||||
"""A ``holds`` stand-in: ``covers`` returns the value, or raises it."""
|
||||
|
||||
def __init__(self, covers: object) -> None:
|
||||
self._covers = covers
|
||||
self.calls: list[tuple[str, datetime | None]] = []
|
||||
|
||||
def covers(self, gate: str, now: datetime | None = None) -> bool:
|
||||
"""Record the call, then answer like ``holds.covers``."""
|
||||
self.calls.append((gate, now))
|
||||
if isinstance(self._covers, BaseException):
|
||||
raise self._covers
|
||||
return bool(self._covers)
|
||||
|
||||
|
||||
def fake_holds_module(*, covers: object) -> FakeHolds:
|
||||
"""A ``holds`` stand-in to install with ``monkeypatch.setitem(sys.modules, ...)``."""
|
||||
return FakeHolds(covers)
|
||||
@ -105,4 +105,20 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"workday_penalty.json",
|
||||
("_constants.WORKDAY_PENALTY_FILE", "_workday_penalty.WORKDAY_PENALTY_FILE"),
|
||||
),
|
||||
# RunnerUp retirement: the anchor/done stamp, the dufs drop directory it
|
||||
# reads the marker and TCX files from (and writes the phone backup into),
|
||||
# and the credit log the anchor is derived from. A test must never see, or
|
||||
# create files under, the user's real RunnerUp folder.
|
||||
(
|
||||
"runnerup_retire.json",
|
||||
("_runnerup_retire.STATE_FILE",),
|
||||
),
|
||||
(
|
||||
"RunnerUp",
|
||||
("_runnerup_retire.WATCH_DIR",),
|
||||
),
|
||||
(
|
||||
"retire_log.json",
|
||||
("_runnerup_retire.LOG_FILE",),
|
||||
),
|
||||
)
|
||||
|
||||
32
screen_locker/tests/_late_credit_fixtures.py
Normal file
32
screen_locker/tests/_late_credit_fixtures.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Pinning the clock an untimed credit is stamped with.
|
||||
|
||||
Re-exported from ``conftest`` rather than defined there so neither file
|
||||
exceeds the repo's line limit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker import _late_credit
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _untimed_credits_land_at_noon() -> Iterator[None]:
|
||||
"""A credit with no time of its own (``at=None``) lands at today's noon.
|
||||
|
||||
``at=None`` means "now", and a suite run after a test's configured
|
||||
shutdown (18:00, say) made every such credit late: the same tests went red
|
||||
at 18:01. Noon is before any shutdown, so only tests that pass ``at=``
|
||||
(or re-patch ``_late_credit._now``) take the late path.
|
||||
"""
|
||||
noon = datetime.now(tz=UTC).astimezone().replace(hour=12, minute=0, second=0)
|
||||
with patch.object(_late_credit, "_now", return_value=noon.timestamp()):
|
||||
yield
|
||||
@ -45,8 +45,8 @@ def mock_tk() -> Generator[MagicMock]:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sys_exit() -> Generator[MagicMock]:
|
||||
@pytest.fixture(name="mock_sys_exit")
|
||||
def mock_sys_exit_fixture() -> Generator[MagicMock]:
|
||||
"""Mock sys.exit to prevent test termination."""
|
||||
with patch("screen_locker.screen_lock.sys.exit") as mock:
|
||||
yield mock
|
||||
|
||||
@ -10,8 +10,10 @@ other. ``_isolate_home`` covers paths resolved at *call* time from
|
||||
``Path.home()``; ``_no_free_days_by_default`` covers paths bound into module
|
||||
constants at *import* time, which redirecting home afterwards cannot reach.
|
||||
|
||||
``pre_ladder`` is the one opt-in fixture here: it keeps a test off the real
|
||||
calendar's ladder cutover, the date-dependent half of the same question.
|
||||
``pre_ladder`` and ``leetcode_gate_off`` are the opt-in fixtures here:
|
||||
``pre_ladder`` keeps a test off the real calendar's ladder cutover, the
|
||||
date-dependent half of the same question; ``leetcode_gate_off`` keeps
|
||||
ledger-plumbing tests off LeetCode's paid-in-full rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -22,7 +24,12 @@ import sys
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import freedays._api
|
||||
import pytest
|
||||
import sickdays
|
||||
import sickdays.gui
|
||||
|
||||
import screen_locker._night_lockdown
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@ -42,7 +49,6 @@ def _no_free_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
|
||||
chain in every test here -- it is the very first check in the chain --
|
||||
and the failures would name early-bird and sick-day logic instead.
|
||||
"""
|
||||
import freedays._api
|
||||
|
||||
monkeypatch.setattr(
|
||||
freedays._api,
|
||||
@ -62,17 +68,33 @@ def _no_sick_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
|
||||
developer's real today sick. ``sync_quietly`` is stubbed so the button's
|
||||
background push never reaches Firebase.
|
||||
"""
|
||||
import sickdays
|
||||
|
||||
pool = sickdays.Paths.under(tmp_path / "sickdays")
|
||||
# Every sickdays module that bound ``resolve_paths`` by name: the readers
|
||||
# (``_api``, which ``stands_down`` / ``kept_lockers`` load through) and the
|
||||
# writers (``set_kept_lockers`` lives in ``_keep_api``). Patching only
|
||||
# ``_api`` once let a keep edit land in the developer's real pool.
|
||||
# (``_api``, which ``stands_down`` / ``kept_lockers`` load through), the
|
||||
# writers (``set_kept_lockers`` lives in ``_keep_api``) and the embedded
|
||||
# KeepPanel (``sickdays.gui._panel``). Patching only ``_api`` once let a
|
||||
# keep edit land in the developer's real pool. ``sync_quietly`` is bound
|
||||
# by name the same way (the panel's background push), so it goes too.
|
||||
for name, module in list(sys.modules.items()):
|
||||
if name.startswith("sickdays.") and hasattr(module, "resolve_paths"):
|
||||
if not name.startswith("sickdays"):
|
||||
continue
|
||||
if hasattr(module, "resolve_paths"):
|
||||
monkeypatch.setattr(module, "resolve_paths", lambda paths: paths or pool)
|
||||
monkeypatch.setattr(sickdays, "sync_quietly", lambda **_kw: False)
|
||||
if hasattr(module, "sync_quietly"):
|
||||
monkeypatch.setattr(module, "sync_quietly", lambda **_kw: False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_keep_panel() -> Iterator[None]:
|
||||
"""Never build sickdays' real ``KeepPanel`` (a real ``tk.Frame``) in a test.
|
||||
|
||||
The lock's "choose lockers" overlay embeds it; under the mocked ``tk`` a
|
||||
real one would still reach Tcl and write the pool on a click. Tests that
|
||||
drive the panel swap in their own recording fake on top of this one.
|
||||
"""
|
||||
with patch("screen_locker._ui_flows_sick_panel.KeepPanel"):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -152,3 +174,60 @@ def pre_ladder(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
Opt-in: tests that assert the ladder itself must keep the real cutover.
|
||||
"""
|
||||
monkeypatch.setattr("earned_time._ladder.LADDER_FROM", date.max)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def leetcode_gate_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Let a bare LeetCode credit earn, as on a free or stood-down sick day.
|
||||
|
||||
These tests pin the shared ledger plumbing with one credit row per earner.
|
||||
Since earned_time 0.18.0 a LeetCode credit alone earns only while the gate
|
||||
is off; the paid-in-full rule itself is ``test_leetcode_gate_off.py``.
|
||||
"""
|
||||
monkeypatch.setattr("screen_locker._earned.gate_off", lambda _earner, _day: True)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_night_lockdown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Never let the lock chain read the machine's real night-lockdown stamp.
|
||||
|
||||
``/var/lib/night-lockdown/locked_at_epoch`` is world-readable, so without
|
||||
this every enforce-path test would skip the lock on any evening after the
|
||||
real lockdown fired -- and pass in the morning.
|
||||
"""
|
||||
|
||||
monkeypatch.setattr(
|
||||
screen_locker._night_lockdown,
|
||||
"NIGHT_LOCKDOWN_STAMP",
|
||||
tmp_path / "locked_at_epoch",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_ntp() -> Iterator[None]:
|
||||
"""Never send a real NTP query to pool.ntp.org from a test.
|
||||
|
||||
``check_clock_skew`` (every phone and RunnerUp verification) opens a UDP
|
||||
socket, which ``_block_real_network``'s ``create_connection`` stub cannot
|
||||
reach. A lost reply cost a test the full 5 s ``_NTP_TIMEOUT``, at random.
|
||||
Stubbed as "unreachable", what the real query answers on any OSError.
|
||||
``test_time_check`` drives the real query through its own import.
|
||||
"""
|
||||
with patch("screen_locker._time_check._query_ntp_offset", return_value=None):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_live_focus_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No live focus project reshapes the registry (earned_time >= 0.19)."""
|
||||
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "focus_project"))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_holds(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
"""Point ``holds`` at an empty file: a real hold must not mute the ladder.
|
||||
|
||||
Without this the suite would read ``~/.local/share/holds/holds.json`` and
|
||||
every ladder test would start failing inside a declared test hold.
|
||||
"""
|
||||
monkeypatch.setenv("HOLDS_DATA", str(tmp_path / "holds-none.json"))
|
||||
|
||||
137
screen_locker/tests/_retire_helpers.py
Normal file
137
screen_locker/tests/_retire_helpers.py
Normal file
@ -0,0 +1,137 @@
|
||||
"""Builders for the RunnerUp retirement tests: TCX files, marker, credit log."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from screen_locker._runnerup_retire import Paths
|
||||
from screen_locker._runnerup_retire_adb import AdbError, Device
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
||||
APP = "kuhy workout_app"
|
||||
# 2026-11-01T10:30:00Z, the end of every TCX built by default.
|
||||
END_UNIX = 1793529000.0
|
||||
|
||||
|
||||
def tcx_text(
|
||||
*, author: str = "RunnerUp", creator: str = "", end: str = "2026-11-01T10:30:00Z"
|
||||
) -> str:
|
||||
"""A tiny TCX: two trackpoints (the last is the run's end) and an author."""
|
||||
creator_xml = f"<Creator><Name>{creator}</Name></Creator>" if creator else ""
|
||||
return (
|
||||
f'<?xml version="1.0"?><TrainingCenterDatabase xmlns="{_NS}"><Activities>'
|
||||
'<Activity Sport="Running"><Id>2026-11-01T10:00:00Z</Id>'
|
||||
"<Lap><TotalTimeSeconds>1800</TotalTimeSeconds>"
|
||||
"<DistanceMeters>5000</DistanceMeters><Track>"
|
||||
"<Trackpoint><Time>2026-11-01T10:00:00Z</Time>"
|
||||
"<DistanceMeters>0</DistanceMeters></Trackpoint>"
|
||||
f"<Trackpoint><Time>{end}</Time><DistanceMeters>5000</DistanceMeters>"
|
||||
f"</Trackpoint></Track></Lap>{creator_xml}</Activity></Activities>"
|
||||
f'<Author xsi:type="Application_t" xmlns:xsi="x"><Name>{author}</Name>'
|
||||
"</Author></TrainingCenterDatabase>"
|
||||
)
|
||||
|
||||
|
||||
def put_tcx(folder: Path, name: str, **kwargs: str) -> Path:
|
||||
"""Write a TCX named ``name`` into ``folder`` (created if needed)."""
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
path = folder / name
|
||||
path.write_text(tcx_text(**kwargs), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def put_marker(watch: Path, names: list[str], count: int | None = None) -> Path:
|
||||
"""Write the app's import marker into the drop folder."""
|
||||
watch.mkdir(parents=True, exist_ok=True)
|
||||
marker = watch / ".workout_app_import.json"
|
||||
body = {
|
||||
"completed_at": "2026-11-02T08:00:00Z",
|
||||
"count": len(names) if count is None else count,
|
||||
"tcx_names": names,
|
||||
}
|
||||
marker.write_text(json.dumps(body), encoding="utf-8")
|
||||
return marker
|
||||
|
||||
|
||||
def put_log(log_file: Path, entries: dict[str, list[dict[str, Any]]]) -> None:
|
||||
"""Write a day-keyed credit log."""
|
||||
log_file.write_text(json.dumps(entries), encoding="utf-8")
|
||||
|
||||
|
||||
def credit(
|
||||
timestamp: str, completed_at: object = None, kind: str = "runnerup_verified"
|
||||
) -> dict[str, Any]:
|
||||
"""One log entry as the credit chokepoint writes it."""
|
||||
data: dict[str, Any] = {"type": kind}
|
||||
if completed_at is not None:
|
||||
data["completed_at"] = completed_at
|
||||
return {"timestamp": timestamp, "workout_data": data}
|
||||
|
||||
|
||||
ANCHOR = datetime(2026, 11, 1, 10, 45, tzinfo=UTC)
|
||||
DUE = ANCHOR + timedelta(days=7)
|
||||
RUN = "RunnerUp_2026-08-12-17-42-11_Running.tcx"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakePhone:
|
||||
"""Records what the retirement asked of the phone."""
|
||||
|
||||
adb: bool = True
|
||||
device: Device = field(default_factory=lambda: Device("S", "attached"))
|
||||
present: bool = True
|
||||
ask_error: AdbError | None = None
|
||||
backup_error: Exception | None = None
|
||||
uninstall_error: AdbError | None = None
|
||||
calls: list[str] = field(default_factory=list)
|
||||
|
||||
def available(self) -> bool:
|
||||
"""Available."""
|
||||
return self.adb
|
||||
|
||||
def find_device(self) -> Device:
|
||||
"""Find device."""
|
||||
if self.ask_error:
|
||||
raise self.ask_error
|
||||
return self.device
|
||||
|
||||
def installed(self, dev: str, *, hidden: bool = True) -> bool:
|
||||
"""Installed."""
|
||||
self.calls.append(f"installed {dev}")
|
||||
return self.present
|
||||
|
||||
def backup(self, dev: str, final: Path) -> tuple[Path, int]:
|
||||
"""Backup."""
|
||||
self.calls.append("backup")
|
||||
if self.backup_error:
|
||||
raise self.backup_error
|
||||
final.mkdir(parents=True)
|
||||
return final, 3
|
||||
|
||||
def uninstall(self, dev: str) -> None:
|
||||
"""Uninstall."""
|
||||
self.calls.append("uninstall")
|
||||
if self.uninstall_error:
|
||||
raise self.uninstall_error
|
||||
|
||||
|
||||
def world(tmp_path: Path, *, anchored: bool = True, marker: bool = True) -> Paths:
|
||||
"""A drop folder with one RunnerUp run, one app run, and the credit log."""
|
||||
paths = Paths(
|
||||
tmp_path / "state" / "s.json", tmp_path / "drop", tmp_path / "log.json"
|
||||
)
|
||||
put_tcx(paths.processed, RUN)
|
||||
put_tcx(paths.processed, "app.tcx", author=APP)
|
||||
put_log(
|
||||
paths.log_file,
|
||||
{"d": [credit(ANCHOR.isoformat(), END_UNIX)]} if anchored else {},
|
||||
)
|
||||
if marker:
|
||||
put_marker(paths.watch_dir, [RUN])
|
||||
return paths
|
||||
@ -31,6 +31,7 @@ _TK_MODULES = (
|
||||
"screen_locker.status_view",
|
||||
"screen_locker._heat_skip",
|
||||
"screen_locker._surface_group",
|
||||
"screen_locker._ui_flows_sick_panel",
|
||||
# The scroll viewport the lock surfaces are built from lives in gatelock
|
||||
# and imports tkinter independently, so it needs the same mock -- otherwise
|
||||
# container.first is a *real* tk.Frame whose winfo_children() cannot be
|
||||
|
||||
@ -19,8 +19,9 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker import _sync_client
|
||||
from screen_locker._degraded_sources import clear_degraded_sources
|
||||
from screen_locker._earned import flat_earners
|
||||
from screen_locker._earned import watched_earners
|
||||
from screen_locker.tests._gatelock_fixtures import (
|
||||
FAKE_OUTPUTS,
|
||||
TWO_OUTPUTS,
|
||||
@ -28,6 +29,7 @@ from screen_locker.tests._gatelock_fixtures import (
|
||||
dual_output,
|
||||
)
|
||||
from screen_locker.tests._isolated_state import ISOLATED_STATE
|
||||
from screen_locker.tests._late_credit_fixtures import _untimed_credits_land_at_noon
|
||||
from screen_locker.tests._locker_factories import (
|
||||
_make_locker,
|
||||
create_locker,
|
||||
@ -36,7 +38,7 @@ from screen_locker.tests._locker_factories import (
|
||||
)
|
||||
from screen_locker.tests._opt_in_fixtures import (
|
||||
_mock_sys_exit,
|
||||
mock_sys_exit,
|
||||
mock_sys_exit_fixture,
|
||||
mock_tk,
|
||||
temp_log_file,
|
||||
)
|
||||
@ -47,20 +49,20 @@ from screen_locker.tests._tk_mocks import (
|
||||
_make_mock_tk,
|
||||
)
|
||||
|
||||
# Re-exported so pytest still resolves these by name: a fixture defined in a
|
||||
# sibling module is only visible to tests once conftest imports it. __all__
|
||||
# keeps ruff from pruning them as unused.
|
||||
# Re-exported: a sibling module's fixture is visible to tests only once
|
||||
# conftest imports it, and __all__ keeps ruff from pruning them as unused.
|
||||
__all__ = [
|
||||
"FAKE_OUTPUTS",
|
||||
"TWO_OUTPUTS",
|
||||
"_hermetic_gatelock",
|
||||
"_make_locker",
|
||||
"_mock_sys_exit",
|
||||
"_untimed_credits_land_at_noon",
|
||||
"create_locker",
|
||||
"create_locker_early_bird",
|
||||
"create_locker_relaxed_day",
|
||||
"dual_output",
|
||||
"mock_sys_exit",
|
||||
"mock_sys_exit_fixture",
|
||||
"mock_tk",
|
||||
"temp_log_file",
|
||||
]
|
||||
@ -152,7 +154,7 @@ def _block_real_network() -> Iterator[None]:
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_degraded_sources() -> Iterator[None]:
|
||||
def _clear_degraded_sources() -> None:
|
||||
"""Reset the process-scoped degraded-backend registry around every test.
|
||||
|
||||
``_degraded_sources._degraded`` is a module-level list, so a test that
|
||||
@ -161,7 +163,6 @@ def _clear_degraded_sources() -> Iterator[None]:
|
||||
the local venvs do not) exposes it -- it failed on push and nowhere else.
|
||||
"""
|
||||
clear_degraded_sources()
|
||||
return
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -182,8 +183,8 @@ def _isolate_state_files(tmp_path: Path) -> Iterator[None]:
|
||||
yield
|
||||
|
||||
|
||||
# Every registered flat earner's live-pass stamp, so a future one is seeded too.
|
||||
_SEEDED_STAMPS = ("last_reset_date", *(f"{e.name}_bonus_date" for e in flat_earners()))
|
||||
# Every registry's flat earners (a live focus project hides some today).
|
||||
_BONUS = [f"{e.name}_bonus_date" for e in watched_earners() if e.kind == "flat"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -199,7 +200,7 @@ def _isolate_shutdown_base(tmp_path: Path) -> Iterator[None]:
|
||||
"""
|
||||
target = tmp_path / "shutdown_base.json"
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
target.write_text(json.dumps(dict.fromkeys(_SEEDED_STAMPS, today)))
|
||||
target.write_text(json.dumps(dict.fromkeys(["last_reset_date", *_BONUS], today)))
|
||||
with (
|
||||
patch("screen_locker._constants.SHUTDOWN_BASE_FILE", target),
|
||||
patch("screen_locker._startup_checks.SHUTDOWN_BASE_FILE", target),
|
||||
@ -244,6 +245,5 @@ def _no_real_firebase_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""
|
||||
# Patch _sync_client's binding: crdt_sync's own CONFIG_FILE is bound to
|
||||
# the real home at import, which _isolate_home cannot redirect.
|
||||
from screen_locker import _sync_client
|
||||
|
||||
monkeypatch.setattr(_sync_client, "CONFIG_FILE", Path("/nonexistent/firebase.json"))
|
||||
|
||||
@ -8,16 +8,16 @@
|
||||
"threshold, change it here and watch both suites fail together.",
|
||||
"",
|
||||
"advertised_minutes is the ONLY number that may appear in a user-facing",
|
||||
"message. accept_minutes is deliberately invisible: a 35-minute session",
|
||||
"passes while every string still says 40. Do not advertise accept_minutes."
|
||||
"message. accept_minutes is deliberately invisible: a 25-minute session",
|
||||
"passes while every string still says 30. Do not advertise accept_minutes."
|
||||
],
|
||||
"advertised_minutes": 40,
|
||||
"advertised_minutes": 30,
|
||||
"leeway_minutes": 5,
|
||||
"accept_minutes": 35,
|
||||
"accept_minutes": 25,
|
||||
"cases": [
|
||||
{ "duration_minutes": 34, "accepted": false },
|
||||
{ "duration_minutes": 35, "accepted": true },
|
||||
{ "duration_minutes": 40, "accepted": true },
|
||||
{ "duration_minutes": 24, "accepted": false },
|
||||
{ "duration_minutes": 25, "accepted": true },
|
||||
{ "duration_minutes": 30, "accepted": true },
|
||||
{ "duration_minutes": 60, "accepted": true }
|
||||
]
|
||||
}
|
||||
|
||||
190
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-08-12-17-42-11_Running.tcx
vendored
Normal file
190
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-08-12-17-42-11_Running.tcx
vendored
Normal file
@ -0,0 +1,190 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:ns3="http://www.garmin.com/xmlschemas/ActivityExtension/v2" xsi:schemaLocation="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2 http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd">
|
||||
<Activities>
|
||||
<Activity Sport="Running">
|
||||
<Id>2026-08-12T15:42:11Z</Id>
|
||||
<Lap StartTime="2026-08-12T15:42:11Z">
|
||||
<TotalTimeSeconds>624</TotalTimeSeconds>
|
||||
<DistanceMeters>983.6306355595589</DistanceMeters>
|
||||
<Calories>0</Calories>
|
||||
<Intensity>Active</Intensity>
|
||||
<TriggerMethod>Manual</TriggerMethod>
|
||||
<Track>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:12Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858438076</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294584871</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>110.06611511572041</AltitudeMeters>
|
||||
<DistanceMeters>2.142966628074646</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>54</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:13Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858442867</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294574705</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>109.65041623678366</AltitudeMeters>
|
||||
<DistanceMeters>3.0187163949012756</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>53</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:37Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858578090</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294260282</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>102.71014718222068</AltitudeMeters>
|
||||
<DistanceMeters>31.116633534431458</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>65</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:48Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858744365</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294188828</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>101.26072111921606</AltitudeMeters>
|
||||
<DistanceMeters>50.64840912818909</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>61</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:42:56Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858792955</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.294100028</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>100.58585072961351</AltitudeMeters>
|
||||
<DistanceMeters>58.97427588701248</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>58</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:43:42Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.858859857</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.292925949</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>99.60869064520372</AltitudeMeters>
|
||||
<DistanceMeters>140.32738709449768</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>57</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
</Track>
|
||||
</Lap>
|
||||
|
||||
<Lap StartTime="2026-08-12T15:52:36Z">
|
||||
<TotalTimeSeconds>626</TotalTimeSeconds>
|
||||
<DistanceMeters>980.7304027080536</DistanceMeters>
|
||||
<Calories>0</Calories>
|
||||
<Intensity>Active</Intensity>
|
||||
<TriggerMethod>Manual</TriggerMethod>
|
||||
<Track>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:52:36Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.859968549</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.282023519</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>99.76934097304894</AltitudeMeters>
|
||||
<DistanceMeters>1003.5486820391379</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>59</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:52:37Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.859990492</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.282025531</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>100.06562741186055</AltitudeMeters>
|
||||
<DistanceMeters>1005.9941445044242</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>57</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:54:28Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.861645306</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.281974721</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>99.66943152941295</AltitudeMeters>
|
||||
<DistanceMeters>1190.9687945595942</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>55</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-08-12T15:54:46Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.861872213</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.282046494</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>99.35286804810477</AltitudeMeters>
|
||||
<DistanceMeters>1217.0878333947621</DistanceMeters>
|
||||
<Extensions>
|
||||
<ns3:TPX>
|
||||
<ns3:RunCadence>58</ns3:RunCadence>
|
||||
</ns3:TPX>
|
||||
</Extensions>
|
||||
</Trackpoint>
|
||||
</Track>
|
||||
</Lap>
|
||||
<Notes></Notes>
|
||||
<Creator xsi:type="Device_t">
|
||||
<Name>RunnerUp Pixel 6a with barometer</Name>
|
||||
<UnitId>0</UnitId>
|
||||
<ProductID>0</ProductID>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
<BuildMajor>0</BuildMajor>
|
||||
<BuildMinor>0</BuildMinor>
|
||||
</Version>
|
||||
</Creator>
|
||||
</Activity>
|
||||
</Activities>
|
||||
<Author xsi:type="Application_t">
|
||||
<Name>org.runnerup - 2.11.0.1 - 23000372</Name>
|
||||
<Build>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
</Version>
|
||||
</Build>
|
||||
<LangID>en</LangID>
|
||||
<PartNumber>000-00000-00</PartNumber>
|
||||
</Author>
|
||||
</TrainingCenterDatabase>
|
||||
1072
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-09-15-00_Walking.tcx
vendored
Normal file
1072
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-09-15-00_Walking.tcx
vendored
Normal file
File diff suppressed because it is too large
Load Diff
748
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-17-30-00_Running.tcx
vendored
Normal file
748
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-17-30-00_Running.tcx
vendored
Normal file
@ -0,0 +1,748 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2 http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd">
|
||||
<Activities>
|
||||
<Activity Sport="Running">
|
||||
<Id>2026-11-09T16:30:00Z</Id>
|
||||
<Lap StartTime="2026-11-09T16:30:00Z">
|
||||
<TotalTimeSeconds>1560</TotalTimeSeconds>
|
||||
<DistanceMeters>4212.014</DistanceMeters>
|
||||
<Calories>0</Calories>
|
||||
<Intensity>Active</Intensity>
|
||||
<TriggerMethod>Manual</TriggerMethod>
|
||||
<Track>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:30:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8584000</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.0</AltitudeMeters>
|
||||
<DistanceMeters>0.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:30:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8587720</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2949745</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.0</AltitudeMeters>
|
||||
<DistanceMeters>54.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:30:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8591285</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2954757</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.3</AltitudeMeters>
|
||||
<DistanceMeters>107.999</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:31:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8594688</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2960023</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.5</AltitudeMeters>
|
||||
<DistanceMeters>161.998</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:31:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8597926</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2965524</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.8</AltitudeMeters>
|
||||
<DistanceMeters>215.997</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:31:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8600999</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2971240</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.1</AltitudeMeters>
|
||||
<DistanceMeters>269.998</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:32:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8603909</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2977150</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.3</AltitudeMeters>
|
||||
<DistanceMeters>324.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:32:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8606663</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2983230</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.6</AltitudeMeters>
|
||||
<DistanceMeters>378.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:32:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8609269</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2989459</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.8</AltitudeMeters>
|
||||
<DistanceMeters>432.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:33:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8611740</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2995813</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.0</AltitudeMeters>
|
||||
<DistanceMeters>485.998</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:33:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8614092</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3002272</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.2</AltitudeMeters>
|
||||
<DistanceMeters>540.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:33:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8616342</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3008814</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.4</AltitudeMeters>
|
||||
<DistanceMeters>594.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:34:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8618509</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3015420</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.5</AltitudeMeters>
|
||||
<DistanceMeters>648.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:34:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8620615</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3022071</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.7</AltitudeMeters>
|
||||
<DistanceMeters>702.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:34:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8622683</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3028751</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.8</AltitudeMeters>
|
||||
<DistanceMeters>756.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:35:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8624735</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3035441</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.9</AltitudeMeters>
|
||||
<DistanceMeters>810.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:35:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8626796</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3042125</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.9</AltitudeMeters>
|
||||
<DistanceMeters>864.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:35:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8628890</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3048786</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>38.0</AltitudeMeters>
|
||||
<DistanceMeters>918.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:36:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8631038</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3055406</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>38.0</AltitudeMeters>
|
||||
<DistanceMeters>972.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:36:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8633263</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3061968</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>38.0</AltitudeMeters>
|
||||
<DistanceMeters>1026.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:36:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8635585</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3068452</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>38.0</AltitudeMeters>
|
||||
<DistanceMeters>1080.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:37:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8638022</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3074837</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.9</AltitudeMeters>
|
||||
<DistanceMeters>1134.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:37:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8640589</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3081103</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.8</AltitudeMeters>
|
||||
<DistanceMeters>1188.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:37:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8643301</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3087227</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.7</AltitudeMeters>
|
||||
<DistanceMeters>1242.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:38:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8646168</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3093186</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.6</AltitudeMeters>
|
||||
<DistanceMeters>1296.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:38:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8649196</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3098958</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.5</AltitudeMeters>
|
||||
<DistanceMeters>1350.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:38:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8652388</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3104521</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.3</AltitudeMeters>
|
||||
<DistanceMeters>1404.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:39:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8655746</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3109853</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.1</AltitudeMeters>
|
||||
<DistanceMeters>1457.999</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:39:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8659267</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3114938</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.9</AltitudeMeters>
|
||||
<DistanceMeters>1512.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:39:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8662946</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3119758</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.7</AltitudeMeters>
|
||||
<DistanceMeters>1566.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:40:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8666773</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3124302</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.5</AltitudeMeters>
|
||||
<DistanceMeters>1620.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:40:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8670740</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3128561</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.2</AltitudeMeters>
|
||||
<DistanceMeters>1674.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:40:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8674834</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3132533</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.0</AltitudeMeters>
|
||||
<DistanceMeters>1728.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:41:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8679042</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3136218</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.7</AltitudeMeters>
|
||||
<DistanceMeters>1782.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:41:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8683351</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3139623</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.4</AltitudeMeters>
|
||||
<DistanceMeters>1836.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:41:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8687747</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3142760</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.2</AltitudeMeters>
|
||||
<DistanceMeters>1890.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:42:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8692217</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3145646</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.9</AltitudeMeters>
|
||||
<DistanceMeters>1944.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:42:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8696748</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3148301</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.6</AltitudeMeters>
|
||||
<DistanceMeters>1998.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:42:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8701329</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3150752</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.3</AltitudeMeters>
|
||||
<DistanceMeters>2052.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:43:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8705949</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3153027</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.1</AltitudeMeters>
|
||||
<DistanceMeters>2106.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:43:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8710599</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3155157</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.8</AltitudeMeters>
|
||||
<DistanceMeters>2160.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:43:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8715270</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3157179</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.6</AltitudeMeters>
|
||||
<DistanceMeters>2214.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:44:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8719954</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3159128</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.3</AltitudeMeters>
|
||||
<DistanceMeters>2268.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:44:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8724644</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3161042</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.1</AltitudeMeters>
|
||||
<DistanceMeters>2322.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:44:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8729334</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3162960</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.9</AltitudeMeters>
|
||||
<DistanceMeters>2376.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:45:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8734016</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3164919</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.7</AltitudeMeters>
|
||||
<DistanceMeters>2430.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:45:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8738683</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3166958</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.6</AltitudeMeters>
|
||||
<DistanceMeters>2484.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:45:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8743329</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3169112</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.4</AltitudeMeters>
|
||||
<DistanceMeters>2538.012</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:46:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8747943</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3171416</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.3</AltitudeMeters>
|
||||
<DistanceMeters>2592.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:46:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8752516</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3173901</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.2</AltitudeMeters>
|
||||
<DistanceMeters>2646.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:46:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8757037</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3176595</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.1</AltitudeMeters>
|
||||
<DistanceMeters>2700.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:47:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8761495</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3179525</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.0</AltitudeMeters>
|
||||
<DistanceMeters>2754.011</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:47:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8765876</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3182709</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.0</AltitudeMeters>
|
||||
<DistanceMeters>2808.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:47:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8770168</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3186164</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.0</AltitudeMeters>
|
||||
<DistanceMeters>2862.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:48:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8774357</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3189900</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.0</AltitudeMeters>
|
||||
<DistanceMeters>2916.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:48:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8778429</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3193923</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.1</AltitudeMeters>
|
||||
<DistanceMeters>2970.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:48:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8782372</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3198234</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.1</AltitudeMeters>
|
||||
<DistanceMeters>3024.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:49:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8786174</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3202829</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.2</AltitudeMeters>
|
||||
<DistanceMeters>3078.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:49:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8789825</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3207698</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.3</AltitudeMeters>
|
||||
<DistanceMeters>3132.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:49:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8793318</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3212829</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.5</AltitudeMeters>
|
||||
<DistanceMeters>3186.012</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:50:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8796647</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3218205</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.6</AltitudeMeters>
|
||||
<DistanceMeters>3240.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:50:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8799810</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3223808</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>32.8</AltitudeMeters>
|
||||
<DistanceMeters>3294.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:50:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8802809</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3229617</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.0</AltitudeMeters>
|
||||
<DistanceMeters>3348.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:51:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8805647</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3235609</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.2</AltitudeMeters>
|
||||
<DistanceMeters>3402.011</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:51:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8808333</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3241761</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.4</AltitudeMeters>
|
||||
<DistanceMeters>3456.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:51:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8810877</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3248052</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.7</AltitudeMeters>
|
||||
<DistanceMeters>3510.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:52:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8813292</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3254458</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>33.9</AltitudeMeters>
|
||||
<DistanceMeters>3564.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:52:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8815595</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3260960</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.2</AltitudeMeters>
|
||||
<DistanceMeters>3618.011</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:52:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8817805</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3267536</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.4</AltitudeMeters>
|
||||
<DistanceMeters>3672.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:53:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8819942</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3274167</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>34.7</AltitudeMeters>
|
||||
<DistanceMeters>3726.010</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:53:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8822028</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3280836</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.0</AltitudeMeters>
|
||||
<DistanceMeters>3780.011</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:53:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8824086</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3287525</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.2</AltitudeMeters>
|
||||
<DistanceMeters>3834.012</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:54:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8826139</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3294217</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.5</AltitudeMeters>
|
||||
<DistanceMeters>3888.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:54:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8828212</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3300896</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>35.8</AltitudeMeters>
|
||||
<DistanceMeters>3942.013</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:54:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8830327</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3307543</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.0</AltitudeMeters>
|
||||
<DistanceMeters>3996.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:55:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8832508</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3314142</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.3</AltitudeMeters>
|
||||
<DistanceMeters>4050.012</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:55:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8834774</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3320674</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.5</AltitudeMeters>
|
||||
<DistanceMeters>4104.014</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:55:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8837145</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3327118</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>36.8</AltitudeMeters>
|
||||
<DistanceMeters>4158.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T16:56:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8839640</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.3333455</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>37.0</AltitudeMeters>
|
||||
<DistanceMeters>4212.014</DistanceMeters>
|
||||
</Trackpoint>
|
||||
</Track>
|
||||
</Lap>
|
||||
<Creator xsi:type="Device_t">
|
||||
<Name>kuhy workout_app</Name>
|
||||
<UnitId>0</UnitId>
|
||||
<ProductID>0</ProductID>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
</Version>
|
||||
</Creator>
|
||||
</Activity>
|
||||
</Activities>
|
||||
<Author xsi:type="Application_t">
|
||||
<Name>kuhy workout_app</Name>
|
||||
<Build>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
</Version>
|
||||
</Build>
|
||||
<LangID>en</LangID>
|
||||
<PartNumber>000-00000-00</PartNumber>
|
||||
</Author>
|
||||
</TrainingCenterDatabase>
|
||||
811
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-18-00-00_Running.tcx
vendored
Normal file
811
screen_locker/tests/fixtures/run_tcx/RunnerUp_2026-11-09-18-00-00_Running.tcx
vendored
Normal file
@ -0,0 +1,811 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2 http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd">
|
||||
<Activities>
|
||||
<Activity Sport="Running">
|
||||
<Id>2026-11-09T17:00:00Z</Id>
|
||||
<Lap StartTime="2026-11-09T17:00:00Z">
|
||||
<TotalTimeSeconds>1800</TotalTimeSeconds>
|
||||
<DistanceMeters>5400.009</DistanceMeters>
|
||||
<Calories>0</Calories>
|
||||
<Intensity>Active</Intensity>
|
||||
<TriggerMethod>Manual</TriggerMethod>
|
||||
<Track>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:00:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8584000</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>0.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:00:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8589396</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>60.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:00:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8594792</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>120.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:01:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8600188</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>180.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:01:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8605584</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>240.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:01:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8610980</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>300.000</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:02:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8616376</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>360.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:02:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8621772</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>420.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:02:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8627167</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>480.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:03:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8632563</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>540.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:03:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8637959</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>600.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:03:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8643355</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>660.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:04:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8648751</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>720.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:04:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8654147</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>780.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:04:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8659543</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>840.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:05:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8664939</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>900.001</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:05:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8670335</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>960.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:05:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8675731</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1020.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:06:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8681127</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1080.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:06:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8686523</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1140.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:06:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8691919</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1200.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:07:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8697315</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1260.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:07:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8702710</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1320.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:07:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8708106</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1380.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:08:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8713502</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1440.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:08:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8718898</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1500.002</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:08:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8724294</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1560.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:09:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8729690</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1620.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:09:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8735086</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1680.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:09:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8740482</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1740.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:10:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8745878</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>1800.003</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:12:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8778254</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2160.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:12:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8783649</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2220.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:12:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8789045</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2280.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:13:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8794441</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2340.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:13:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8799837</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2400.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:13:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8805233</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2460.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:14:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8810629</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2520.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:14:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8816025</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2580.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:14:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8821421</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2640.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:15:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8826817</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2700.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:15:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8832213</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2760.004</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:15:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8837609</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2820.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:16:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8843005</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2880.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:16:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8848401</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>2940.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:16:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8853797</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3000.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:17:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8859192</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3060.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:17:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8864588</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3120.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:17:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8869984</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3180.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:18:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8875380</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3240.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:18:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8880776</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3300.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:18:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8886172</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3360.005</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:19:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8891568</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3420.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:19:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8896964</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3480.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:19:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8902360</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3540.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:20:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8907756</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3600.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:20:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8913152</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3660.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:20:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8918548</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3720.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:21:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8923944</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3780.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:21:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8929340</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3840.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:21:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8934736</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3900.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:22:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8940131</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>3960.006</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:22:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8945527</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4020.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:22:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8950923</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4080.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:23:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8956319</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4140.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:23:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8961715</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4200.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:23:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8967111</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4260.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:24:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8972507</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4320.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:24:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8977903</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4380.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:24:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8983299</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4440.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:25:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8988695</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4500.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:25:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8994091</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4560.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:25:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.8999487</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4620.007</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:26:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9004883</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4680.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:26:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9010279</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4740.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:26:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9015674</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4800.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:27:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9021070</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4860.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:27:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9026466</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4920.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:27:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9031862</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>4980.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:28:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9037258</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5040.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:28:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9042654</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5100.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:28:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9048050</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5160.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:29:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9053446</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5220.008</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:29:20Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9058842</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5280.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:29:40Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9064238</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5340.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
<Trackpoint>
|
||||
<Time>2026-11-09T17:30:00Z</Time>
|
||||
<Position>
|
||||
<LatitudeDegrees>48.9069634</LatitudeDegrees>
|
||||
<LongitudeDegrees>2.2945000</LongitudeDegrees>
|
||||
</Position>
|
||||
<AltitudeMeters>30.0</AltitudeMeters>
|
||||
<DistanceMeters>5400.009</DistanceMeters>
|
||||
</Trackpoint>
|
||||
</Track>
|
||||
</Lap>
|
||||
<Creator xsi:type="Device_t">
|
||||
<Name>kuhy workout_app</Name>
|
||||
<UnitId>0</UnitId>
|
||||
<ProductID>0</ProductID>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
</Version>
|
||||
</Creator>
|
||||
</Activity>
|
||||
</Activities>
|
||||
<Author xsi:type="Application_t">
|
||||
<Name>kuhy workout_app</Name>
|
||||
<Build>
|
||||
<Version>
|
||||
<VersionMajor>1</VersionMajor>
|
||||
<VersionMinor>0</VersionMinor>
|
||||
</Version>
|
||||
</Build>
|
||||
<LangID>en</LangID>
|
||||
<PartNumber>000-00000-00</PartNumber>
|
||||
</Author>
|
||||
</TrainingCenterDatabase>
|
||||
1391
screen_locker/tests/fixtures/run_tcx/golden_tracks.json
vendored
Normal file
1391
screen_locker/tests/fixtures/run_tcx/golden_tracks.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user