Compare commits

..

No commits in common. "main" and "google_account_token-v0.1.0" have entirely different histories.

568 changed files with 4270 additions and 39795 deletions

View File

@ -1,73 +0,0 @@
name: holds pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as holds: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['holds/**', '.github/workflows/holds-precommit.yml']
pull_request:
branches: [main]
paths: ['holds/**', '.github/workflows/holds-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' holds/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-holds-${{ env.pythonLocation }}-${{
hashFiles('holds/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-holds-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-holds-${{ env.pythonLocation }}-${{
hashFiles('holds/.pre-commit-config.yaml') }}
restore-keys: mypy-holds-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config holds/.pre-commit-config.yaml
--all-files --show-diff-on-failure

View File

@ -1,34 +0,0 @@
name: holds tests
on:
push:
branches: [main]
# The workflow's own file counts as holds: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['holds/**', '.github/workflows/holds-tests.yml']
pull_request:
branches: [main]
paths: ['holds/**', '.github/workflows/holds-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: holds
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: holds/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -1,73 +0,0 @@
name: leetcode-coach pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as leetcode_coach: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-precommit.yml']
pull_request:
branches: [main]
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' leetcode_coach/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-leetcode-coach-${{ env.pythonLocation }}-${{
hashFiles('leetcode_coach/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-leetcode-coach-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-leetcode-coach-${{ env.pythonLocation }}-${{
hashFiles('leetcode_coach/.pre-commit-config.yaml') }}
restore-keys: mypy-leetcode-coach-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config leetcode_coach/.pre-commit-config.yaml
--all-files --show-diff-on-failure

View File

@ -1,34 +0,0 @@
name: leetcode-coach tests
on:
push:
branches: [main]
# The workflow's own file counts as leetcode_coach: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-tests.yml']
pull_request:
branches: [main]
paths: ['leetcode_coach/**', '.github/workflows/leetcode-coach-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: leetcode_coach
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: leetcode_coach/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -1,6 +1,6 @@
name: md-naming
# The markdown namespaces (README / CLAUDE* / DOCS*, no TODO*), enforced on push as well as at commit time.
# The four markdown namespaces, enforced on push as well as at commit time.
# The local pre-commit hook can be skipped (a fresh clone that never ran
# `pre-commit install` has no hooks at all), so the convention needs a second
# gate that no one can forget to install.
@ -31,3 +31,8 @@ jobs:
- name: Enforce the naming convention
run: bash .utils/scripts/check_md_naming.sh --all
# Warns only: no machine can prove a task is finished, so an old TODO
# gets a nudge, never a failure.
- name: Warn about stale TODOs
run: PYTHONPATH=.utils python3 .utils/md_naming/stale.py

View File

@ -28,4 +28,4 @@ jobs:
run: pip install -r requirements.txt
- name: Run tests with coverage
run: python -m pytest -v --cov=music_theory
run: python -m pytest -v

View File

@ -1,73 +0,0 @@
name: omniroute_offload pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as omniroute_offload: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['omniroute_offload/**', '.github/workflows/omniroute_offload-precommit.yml']
pull_request:
branches: [main]
paths: ['omniroute_offload/**', '.github/workflows/omniroute_offload-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' omniroute_offload/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-omniroute_offload-${{ env.pythonLocation }}-${{
hashFiles('omniroute_offload/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-omniroute_offload-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-omniroute_offload-${{ env.pythonLocation }}-${{
hashFiles('omniroute_offload/.pre-commit-config.yaml') }}
restore-keys: mypy-omniroute_offload-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config omniroute_offload/.pre-commit-config.yaml
--all-files --show-diff-on-failure

View File

@ -1,73 +0,0 @@
name: sql-drills pre-commit
on:
push:
branches: [main]
# The workflow's own file counts as sql_drills: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['sql_drills/**', '.github/workflows/sql-drills-precommit.yml']
pull_request:
branches: [main]
paths: ['sql_drills/**', '.github/workflows/sql-drills-precommit.yml']
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.x"
# Only pre-commit itself. Every linting hook runs in its own isolated
# env -- ruff, mypy, pylint, bandit and codespell each come from their
# own repo+rev, and the local hooks need nothing beyond bash and
# python3 -- so installing the full requirements.txt spent 9s a run on
# packages no pre-commit-stage hook imports. Verified by running all
# three configs green in a venv holding pre-commit and nothing else.
# The pin is read out of requirements.txt rather than repeated here, so
# it cannot drift from what the dependency-freshness gate governs.
- name: Install pre-commit
run: |
pip install "$(grep '^pre-commit==' sql_drills/requirements.txt)"
# Keyed on THIS package's config. pre-commit/action@v3.0.1 caches on
# hashFiles('.pre-commit-config.yaml') -- the repo ROOT's -- so all three
# package jobs shared one key, raced to overwrite it with hook sets built
# from four different configs, and editing a per-package config did not
# invalidate it. The cache was therefore silently wrong rather than
# missing, and the hooks reinstalled from scratch on every run (38-49s).
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: >-
pre-commit-sql-drills-${{ env.pythonLocation }}-${{
hashFiles('sql_drills/.pre-commit-config.yaml') }}
# Prefix fallback, or this fix would evaporate on the very
# event that motivated it: bumping a hook rev changes the
# exact key, and a total miss means reinstalling every hook
# again. pre-commit's store is keyed internally by hook
# repo+rev, so a superseded entry is ignored disk rather
# than a wrong hook, and cache still re-saves under the new
# exact key after a prefix-only hit.
restore-keys: pre-commit-sql-drills-${{ env.pythonLocation }}-
# mypy re-analyses the stdlib and every third-party stub from scratch
# without this, which measured 10s of the hook step in CI against 0.25s
# warm locally -- a 19MB cache dir does the whole difference. Keyed on
# the config, not the sources: mypy's own cache metadata records the
# interpreter, the flag set and a hash per source file, so it re-checks
# what changed and cannot pass stale code. Deliberately NOT keyed on
# github.sha -- that re-saves 19MB x3 every commit and would evict the
# 68MB hook caches under the repo's 10GB LRU budget. A fixed key keeps
# the expensive part, the dependency analysis, which is what costs 10s.
- uses: actions/cache@v4
with:
path: .mypy_cache
key: >-
mypy-sql-drills-${{ env.pythonLocation }}-${{
hashFiles('sql_drills/.pre-commit-config.yaml') }}
restore-keys: mypy-sql-drills-${{ env.pythonLocation }}-
- name: Run the hooks
run: >-
pre-commit run --config sql_drills/.pre-commit-config.yaml
--all-files --show-diff-on-failure

View File

@ -1,34 +0,0 @@
name: sql-drills tests
on:
push:
branches: [main]
# The workflow's own file counts as sql_drills: editing only this file would
# otherwise trigger nothing, so a fix to the job itself could never prove
# itself.
paths: ['sql_drills/**', '.github/workflows/sql-drills-tests.yml']
pull_request:
branches: [main]
paths: ['sql_drills/**', '.github/workflows/sql-drills-tests.yml']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: sql_drills
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
cache: pip
cache-dependency-path: sql_drills/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
- name: Run tests with coverage
run: python -m pytest -v

View File

@ -13,7 +13,7 @@
# linters, and carry this same hook so committing with one of those configs is
# gated too. This root config is what `pre-commit install` wires up, and it is
# the only one that covers the subprojects without a config of their own
# (guard-lib/, crdt_sync_dart/, file_length/, ...).
# (aur-publish/, guard-lib/, crdt_sync_dart/, file_length/, ...).
#
# The path is repo-relative on purpose. pre-commit always runs hooks from the
# git root, so this resolves the same locally and on a CI runner -- an absolute
@ -27,8 +27,8 @@ repos:
language: system
pass_filenames: true
# Three markdown namespaces (README / CLAUDE* / DOCS*); TODO*.md is banned
# (tasks live in the todo app).
# Four markdown namespaces (README / CLAUDE* / DOCS* / TODO*), plus the
# rule that a TODO carries "REMOVE ME AFTER FINISH" and nothing else may.
# Shared gate in ~/src/utils owns the namespaces and the exemption list, so
# one repo's idea of a valid name cannot drift from every other repo's.
# Since the 2026-09-11 reorganisation ~ holds eleven visible entries and
@ -66,14 +66,9 @@ repos:
# answer and passes with a loud banner; `--no-verify` is banned here, so
# a hook that hard-failed on a flaky connection would leave no way to
# commit at all. CI, always online, is the strict authority.
#
# Scoped to manifests this branch changed (vs merge-base with main), so
# `--all-files` asks "did THIS change leave a stale pin?" and not "is
# every project in the monorepo current?". CI `--all --strict` keeps
# the repo-wide answer. See scripts/dep_freshness_changed.sh.
- id: dependency-freshness
name: dependencies on newest stable
entry: bash scripts/dep_freshness_changed.sh
entry: bash scripts/check_dependency_freshness.sh
language: system
pass_filenames: true
files: >-

View File

@ -1,27 +0,0 @@
# Hooks other repos consume with `repo: https://github.com/kuhyx/utils`.
# All three are post-* stages, so each clone must install them (see
# todo_hooks/README.md). They never fail a git operation: the work has
# already happened, so server/gh trouble prints one warning and exits 0.
- id: todo-closer
name: close Todo trailer tasks
entry: scripts/todo_hooks.sh closer
language: script
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: script
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: script
stages: [post-rewrite]
always_run: true
pass_filenames: false

View File

@ -0,0 +1,41 @@
# Encrypt crdt_sync payloads end to end
## what
Firebase RTDB stores every crdt_sync record in plaintext, so Google can read
all app data. Encrypt record payloads on the device before push and decrypt
after pull, so RTDB stays a blind relay (the "ownership" meaning of
local-first: hologram.page/blog/local-first-means-three-different-things).
## open decisions (ask kuhy before coding)
- Key source: derived from the shared crdt-sync password (Argon2id) or a
random key stored in the OS keystore and copied to new devices.
- Migration: re-encrypt existing logs in place, or new prefix + one-time copy.
- Scope: payload only (keep HLC/ids clear for merge) or whole record.
- diet_guard's `export_keys` document (random data-export keys, synced as a
keyring) sits in plaintext in RTDB, so Firebase can read them: it must move
under E2E too, with its key source decided here first.
- tutor uses its own RTDB client (`~/src/tutor/app/lib/sync/rtdb_client.dart`):
in or out of scope.
## steps
1. Add an encrypt/decrypt seam in `crdt_sync_dart/lib/src/remote_store.dart`
(push/pull), AEAD (XChaCha20-Poly1305), version byte per record.
2. Property test: decrypt(encrypt(r)) == r; merge result identical with and
without encryption (see property-and-differential-tests skill).
3. Read path accepts plaintext and v1 records during migration.
4. Roll out app by app (todo first), bump pins, verify on the phone.
## done
`dart test` in `crdt_sync_dart` passes, and
`firebase database:get /<prefix> | grep -c '"title"'` returns 0 for every
migrated app.
## verify
Pixel 6a + desktop todo: edit on one, see it on the other; raw RTDB JSON shows
only ciphertext.
## read first
- `crdt_sync_dart/lib/src/sync.dart`, `log.dart`, `remote_store.dart`
- `crdt_sync_flutter/lib/src/bootstrap.dart`
REMOVE ME AFTER FINISH

View File

@ -0,0 +1,93 @@
# TODO — pylint `--fail-under=10` everywhere, and clear what that surfaces
REMOVE ME AFTER FINISH
## what
Two pylint hooks (`leetcode-guard`, `steam-backlog-enforcer`) still pass at
`--fail-under=8.0`, which permanently tolerates two points of lint debt and
lets a score *regress* from 9.9 to 8.1 without failing anything. Move both to
`10`, and fix what that surfaces. Every other site already runs at `10`, so
this is adopting an existing standard, not inventing one.
## what is left (updated 2026-11-02)
Done and verified (`pre-commit run pylint --all-files`, exit 0, each at
`--fail-under=10`): `control-panel`, `screen-locker` (both hooks),
`testsAndMisc/meta`, `utils/crdt-sync`, `utils/freedays`, `utils/gatelock`,
`utils/music_theory`.
Still at `--fail-under=8.0`:
| Repo | Site |
|---|---|
| `leetcode-guard` | `.pre-commit-config.yaml:126` |
| `steam-backlog-enforcer` | `.pre-commit-config.yaml:126` (ignore its `.claude/worktrees/` copies) |
Both live outside `utils`, so this file belongs to whichever session works
those repos. The method is the fleet standard: pylint grades source, ruff
grades tests. Copy the `[tool.pylint]` block (tests basename ignore,
`init-hook = "import sys; sys.path.insert(0, '.')"`) from `wake-alarm` /
`diet-guard`, bump to `10`, fix the residue in source. Read the `must`
rules below first.
## must
- Every site at `--fail-under=10`. No `9.5`, no per-repo exception.
- Adopt the reference config from `wake-alarm` / `diet-guard`: the `tests`
basename ignore, the `init-hook`, and only then the residue fixes.
- Fix findings in source. Prefer the fix to the disable, every time.
- must not: add a `# pylint: disable=` comment, or extend `disable = [...]`
beyond wake-alarm's three implicit-booleaness entries, **without asking
kuhy first — every time, per `memories/code-quality.md`.** All seven
"unavoidable" disables in a past konbini-67 session were reverted.
- must not: exclude a source file from pylint to raise the score, or drop
`enable = "all"`.
- must not: touch the five sites already at `10`.
- optional: `duplicate-code` findings may be genuine shared-code candidates
for `utils`. Note them, do not force a refactor to hit the number.
## done
Both commands, from `~`:
```bash
# 1. no site left below 10 (cov-fail-under is coverage, not pylint)
! grep -rn --exclude-dir=worktrees --include=.pre-commit-config.yaml --include=.pylintrc \
-E 'fail-under=(8|9)' ~/src ~/praca_magisterska 2>/dev/null \
| grep -v cov-fail-under
# 2. both remaining packages pass its own pylint hook
for d in ~/src/leetcode-guard ~/src/steam-backlog-enforcer; do
(cd "$d" && pre-commit run pylint --all-files) || echo "FAIL $d"
done
```
Command 1 prints nothing and exits 0; command 2 prints no `FAIL` line.
## verify
Desktop. Per repo, `pre-commit run --all-files` must be green — not just the
pylint hook, since a fix that satisfies pylint can break `ruff`, `mypy` or the
250-line cap. Then the test suite: these are behaviour-affecting edits
(`broad-exception-caught` and `import-outside-toplevel` fixes especially), and
every one of these packages holds a 100% coverage bar.
`utils` and `testsAndMisc` each have CI; push and confirm green rather than
trusting the local run. `gatelock`'s Tk suite is flaky on a live X session —
run it under `xvfb-run -a` and compare against a baseline worktree before
blaming a change for a failure.
## read first
- `~/src/wake-alarm/pyproject.toml` — `[tool.pylint]`, the reference config.
`diet-guard` is byte-similar; `build-your-x` also ignores `conftest.py`.
- `~/.claude/memories/code-quality.md` — the suppression rule that governs
every `disable` decision in this task.
- `~/src/utils/rules/` is not involved; this is not a dependency change.
Each repo is its own git repo: commit per repo, work on `main`, stage
narrowly. `utils` additionally runs the shared `md-naming` and file-length
gates, so delete this file in that repo's final commit.
REMOVE ME AFTER FINISH

45
aur-publish/README.md Normal file
View File

@ -0,0 +1,45 @@
# aur-publish
Hands-off publishing of kuhy's Flutter apps to the AUR.
```bash
~/src/utils/aur-publish/publish.sh # wait for registration, then publish
~/src/utils/aur-publish/publish.sh --dry-run # everything except the push
~/src/utils/aur-publish/publish.sh --skip-wait # account already works
~/src/utils/aur-publish/publish.sh --only habit-stack
```
## What it does
1. Polls `/register` until AUR signup reopens, opens it in a browser and
prints the SSH key to paste. **Signup is the only manual step** — it
requires email confirmation.
2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
3. Then, unattended, per package: resolve the newest `vX.Y.Z` tag → set
`pkgver` → `updpkgsums` → `makepkg -C` → `namcap` → AUR rule check →
`pacman -U` → launch check → `.SRCINFO` → commit → push.
Every package is built, installed and **launched** before it is pushed, so a
broken package cannot reach the AUR. A package failing any gate is skipped and
reported; the others still publish. Safe to re-run.
## anubis_gate.py
The AUR sits behind [Anubis](https://anubis.techaro.lol/), which answers HTML
requests with a proof-of-work challenge page carrying **HTTP 200**. A plain
`curl` therefore reports 200 for a page that is actually unreachable — which is
exactly how "is registration back?" gets answered wrong. This module solves the
challenge and reports the status of the *real* page behind it.
```bash
python3 anubis_gate.py /register # prints the true status code
```
## AUR rules enforced before pushing
The AUR does not review uploads, but per the submission guidelines "packages
that violate the rules may be deleted without warning". `check_aur_rules()`
blocks a push on the mechanically checkable ones: `# Maintainer:` line,
`x86_64` in `arch`, no `replaces=` (reserved for renames — use `conflicts`),
not already in an official repo, `pkgdesc`/`url`/`license` present, and
`.SRCINFO` matching the PKGBUILD.

138
aur-publish/anubis_gate.py Normal file
View File

@ -0,0 +1,138 @@
"""Fetch an AUR URL from behind the Anubis anti-bot gate.
The AUR sits behind Anubis (https://anubis.techaro.lol/), which answers every
HTML request with a proof-of-work challenge page carrying HTTP **200**. A bare
`curl` therefore reports 200 for a page that is actually unreachable, which is
exactly how "is /register back up?" gets answered wrong.
This module solves the challenge and reports the status of the *real* page
behind it, so the caller can distinguish:
* 200 -> genuinely available
* 503 -> reached the site; that endpoint is disabled server-side
* other-> whatever the app actually said
Exit codes mirror the HTTP status so shell callers can branch on them:
0 for 200, 1 for anything else, 2 for a local/transport failure.
"""
from __future__ import annotations
import argparse
import hashlib
import http.cookiejar
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE = "https://aur.archlinux.org"
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
CHALLENGE_RE = re.compile(
r'<script id="anubis_challenge" type="application/json">(.*?)</script>',
re.S,
)
PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
# A solve at difficulty 4 takes ~0.01s; this ceiling only guards a server-side
# difficulty spike, so the poller degrades instead of spinning forever.
MAX_NONCE = 50_000_000
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Keep redirects visible instead of silently following them."""
def redirect_request(self, *_args, **_kwargs): # noqa: D102 - see class doc
return None
def _opener() -> urllib.request.OpenerDirector:
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar), _NoRedirect
)
opener.addheaders = [
("User-Agent", UA),
("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
]
return opener
def _solve(random_data: str, difficulty: int) -> tuple[int, str, float]:
"""Find a nonce whose sha256 digest starts with `difficulty` zero hex chars."""
prefix = "0" * difficulty
started = time.time()
for nonce in range(MAX_NONCE):
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest()
if digest.startswith(prefix):
return nonce, digest, time.time() - started
raise RuntimeError(f"no nonce found below {MAX_NONCE} at difficulty {difficulty}")
def fetch(path: str, timeout: int = 30) -> tuple[int, str]:
"""Return (status, body) for `path`, clearing Anubis first if challenged."""
opener = _opener()
url = f"{BASE}{path}"
try:
with opener.open(url, timeout=timeout) as resp:
body = resp.read().decode("utf-8", "replace")
status = resp.status
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
match = CHALLENGE_RE.search(body)
if not match:
return status, body
challenge = json.loads(match.group(1))["challenge"]
nonce, digest, elapsed = _solve(
challenge["randomData"], int(challenge["difficulty"])
)
query = urllib.parse.urlencode(
{
"id": challenge["id"],
"response": digest,
"nonce": str(nonce),
"redir": path,
"elapsedTime": str(int(elapsed * 1000)),
}
)
try:
opener.open(f"{BASE}{PASS_PATH}?{query}", timeout=timeout)
except urllib.error.HTTPError:
# A redirect or non-2xx here is fine; what matters is the cookie the
# opener's jar just captured.
pass
try:
with opener.open(url, timeout=timeout) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path", help="AUR path, e.g. /register")
parser.add_argument(
"--print-body", action="store_true", help="write the page body to stdout"
)
args = parser.parse_args()
try:
status, body = fetch(args.path)
except (urllib.error.URLError, RuntimeError, TimeoutError) as exc:
print(f"transport error: {exc}", file=sys.stderr)
return 2
print(status)
if args.print_body:
sys.stdout.write(body)
return 0 if status == 200 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,107 @@
#!/bin/bash
# ============================================================================
# Building, verifying and pushing one package to the AUR.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_HOST, AUR_ROOT, DRY_RUN, PUBLISHED, FAILED) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
publish_one() {
local pkg="$1" repo="$2" wrapper="$3" binary="$4" expected="$5"
local dir="$AUR_ROOT/$pkg"
local version
log "$pkg"
[[ -d "$repo" ]] || { warn "$pkg: no repo at $repo"; return 1; }
[[ -f "$dir/PKGBUILD" ]] || { warn "$pkg: no PKGBUILD at $dir"; return 1; }
version="$(newest_tag "$repo")"
[[ -n "$version" ]] || { warn "$pkg: no vX.Y.Z tag on the remote"; return 1; }
info "newest release tag: v$version"
cd "$dir"
sed -i -E "s/^pkgver=.*/pkgver=$version/" PKGBUILD
# The tarball for a tag contains the PKGBUILD, so the checksum can only be
# computed after the tag exists — never committed ahead of time.
updpkgsums >/dev/null 2>&1 || { warn "$pkg: updpkgsums failed (tag missing?)"; return 1; }
info "building"
if ! env PATH=/usr/bin:/bin:/usr/local/bin makepkg -Cf --noconfirm >/dev/null 2>&1; then
warn "$pkg: makepkg failed"
return 1
fi
local built
built="$(find . -maxdepth 1 -name "$pkg-$version-*.pkg.tar.zst" | head -1)"
[[ -n "$built" ]] || { warn "$pkg: no package produced"; return 1; }
# namcap's "ELF files outside of a valid path ('opt/')" is a false positive
# for /opt-installed apps, so only genuinely new errors are worth failing on.
local findings
findings="$(namcap "$built" 2>&1 | grep -E ' E: ' | grep -v "outside of a valid path" || true)"
if [[ -n "$findings" ]]; then
warn "$pkg: namcap errors:"
printf ' %s\n' "$findings"
return 1
fi
info "installing and launching"
sudo pacman -U --noconfirm "$built" >/dev/null 2>&1 \
|| { warn "$pkg: pacman -U failed"; return 1; }
verify_installed "$binary" "$expected" "$wrapper" || { warn "$pkg: launch check failed"; return 1; }
# Generated before the rule check, which verifies it matches the PKGBUILD.
makepkg --printsrcinfo > .SRCINFO
check_aur_rules "$pkg" || return 1
# The AUR repo is a separate remote living alongside the build dir; adding
# it here (rather than cloning into a fresh path) keeps the verified
# PKGBUILD and the thing being pushed byte-identical.
if [[ ! -d .git ]]; then
git init -q -b master .
git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
fi
git remote get-url origin >/dev/null 2>&1 \
|| git remote add origin "ssh://aur@$AUR_HOST/$pkg.git"
# Fetch first: an existing AUR package must be built on, not overwritten.
if git fetch -q origin master 2>/dev/null; then
git reset -q --soft FETCH_HEAD 2>/dev/null || true
fi
cat > .gitignore <<'EOF'
# Build products; the AUR tracks only the recipe.
*.pkg.tar.zst
*.tar.gz
src/
pkg/
EOF
git add PKGBUILD .SRCINFO .gitignore
if git diff --cached --quiet; then
info "already up to date on the AUR; nothing to push"
PUBLISHED+=("$pkg v$version (unchanged)")
return 0
fi
git -c user.name='Krzysztof Rudnicki' \
-c user.email='krzysztofrudnicki0@gmail.com' \
commit -q -m "$pkg $version-1"
if ((DRY_RUN)); then
info "DRY RUN — not pushing"
PUBLISHED+=("$pkg v$version (dry run)")
return 0
fi
info "pushing to the AUR"
git push -q origin master 2>&1 | tail -3 || { warn "$pkg: push failed"; return 1; }
PUBLISHED+=("$pkg v$version")
return 0
}

View File

@ -0,0 +1,107 @@
#!/bin/bash
# ============================================================================
# Waiting for AUR registration and SSH access to come up.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_HOST, SSH_KEY, GATE, POLL_SECONDS) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
register_status() {
python3 "$GATE" /register 2>/dev/null | head -1
}
open_browser() {
local url="$1"
local browser
for browser in xdg-open thorium-browser chromium google-chrome-stable firefox librewolf; do
if command -v "$browser" >/dev/null 2>&1; then
"$browser" "$url" >/dev/null 2>&1 &
info "Opened $url in $browser"
return 0
fi
done
warn "No browser found; open this yourself: $url"
}
ensure_key() {
if [[ ! -f "$SSH_KEY" ]]; then
log "Generating an AUR SSH key (none at $SSH_KEY)"
ssh-keygen -t ed25519 -f "$SSH_KEY" -N '' -C 'kuhy@aur'
fi
if ! grep -q "Host $AUR_HOST" "$HOME/.ssh/config" 2>/dev/null; then
log "Adding $AUR_HOST to ~/.ssh/config"
mkdir -p "$HOME/.ssh"
cat >> "$HOME/.ssh/config" <<EOF
# AUR uses a dedicated key so it stays separate from the GitHub identity.
Host $AUR_HOST
User aur
IdentityFile $SSH_KEY
IdentitiesOnly yes
EOF
chmod 600 "$HOME/.ssh/config"
fi
}
ssh_works() {
# The AUR refuses interactive shells; "Interactive shell is disabled" on a
# successful key auth is the success signal, not an error.
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=15 "aur@$AUR_HOST" help 2>&1 |
grep -qiE 'interactive shell is disabled|Welcome to AUR'
}
wait_for_registration() {
local status
status="$(register_status)"
if [[ "$status" == "200" ]]; then
log "AUR registration is OPEN"
else
log "AUR registration is closed (HTTP $status). Polling every ${POLL_SECONDS}s."
info "Ctrl-C to stop; re-run any time, nothing is lost."
while :; do
sleep "$POLL_SECONDS"
status="$(register_status)"
if [[ "$status" == "200" ]]; then
log "Registration is BACK (HTTP 200)"
break
fi
printf ' %s still HTTP %s\n' "$(date +%H:%M)" "$status"
done
fi
open_browser "https://$AUR_HOST/register"
cat <<EOF
------------------------------------------------------------------
Sign up, and paste this into the "SSH Public Key" field:
$(sed 's/^/ /' "$SSH_KEY.pub")
You will need to confirm the address from your email before the
account works. This script waits for that automatically.
------------------------------------------------------------------
EOF
}
wait_for_ssh() {
log "Waiting for the SSH key to authenticate against the AUR"
local waited=0
until ssh_works; do
sleep 30
waited=$((waited + 30))
if ((waited % 300 == 0)); then
info "still waiting ($((waited / 60)) min) — key not accepted yet"
fi
done
log "SSH authentication OK"
}
# ---------------------------------------------------------------------------
# Phase 2: build, verify and publish one package
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,122 @@
#!/bin/bash
# ============================================================================
# Gates a package must pass before it is allowed near the AUR.
#
# Sourced by publish.sh, never executed: these functions read the readonly
# config (AUR_ROOT, DRY_RUN) that the entry script declares, and
# a `readonly` re-run inside a twice-sourced lib would abort under `set -e`.
# ============================================================================
set -euo pipefail
newest_tag() {
# Newest clean vX.Y.Z on the remote. Build tags (vX.Y.Z-build.N) and the
# grafted upstream Flutter SDK tags in diet-guard are excluded on purpose.
git -C "$1" ls-remote --tags origin 2>/dev/null |
awk '{print $2}' |
sed 's|refs/tags/||' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
sed 's/^v//' |
sort -V |
tail -1
}
verify_installed() {
local binary="$1" expected="$2" wrapper="$3" output
# An instance left over from a previous run would make the wrapper print
# "already running" and hand off, which passes trivially and turns the
# strongest gate in this script into the weakest. Clear it first so the
# check always exercises a real cold start.
#
# Resolved through /proc/<pid>/exe rather than any form of name or
# command-line matching. Every argv-based approach selects the wrong
# processes here:
# * `pkill -x` compares against the kernel's 15-character comm field, and
# two of these wrappers are longer (habit_stack_desktop is 19), so a
# name match would silently never fire.
# * `pkill -f`, and equally `ps -eo args` piped through a pattern, match
# any process whose command line merely *mentions* the path — this
# script, the shell running it, and the matching tool itself all
# qualify, so the run kills its own shell instead of the app. A
# $$/$PPID guard does not cover it, because the offending shell is
# often a more distant ancestor.
# /proc/<pid>/exe is a kernel-maintained symlink to the binary actually
# being executed, so it cannot collide with an incidental mention.
# The wrapper name is unique per app, so the /opt/<pkg>/ segment is left as
# a glob instead of threading the package name through another parameter.
#
# The link is read without `-f`: it is already fully resolved, and by the
# time this runs `pacman -U` has replaced the binary, so a leftover process
# points at an unlinked inode that the kernel reports with a " (deleted)"
# suffix. That suffix has to be stripped or the match silently misses the
# one process this exists to kill.
local pid exe killed=()
for pid in /proc/[0-9]*; do
pid="${pid#/proc/}"
exe="$(readlink "/proc/$pid/exe" 2>/dev/null)" || continue
exe="${exe% (deleted)}"
[[ "$exe" == /opt/*/bin/"$wrapper" ]] || continue
kill -TERM "$pid" 2>/dev/null && killed+=("$pid")
done
if ((${#killed[@]})); then
info "stopped a running instance so the launch check is meaningful"
# `kill` only reports that the signal was sent. Waiting for the process
# to actually go keeps a slow exit from failing the cold start below on
# a port that is still bound.
local waited=0
while ((waited < 100)) && kill -0 "${killed[@]}" 2>/dev/null; do
sleep 0.1
((waited++))
done
fi
output="$(timeout 15 "/usr/bin/$binary" 2>&1 | head -3 || true)"
if grep -qF "$expected" <<<"$output"; then
return 0
fi
warn "unexpected startup output: $output"
return 1
}
# The AUR does not review uploads, but "packages that violate the rules may be
# deleted without warning" (AUR submission guidelines). These are the rules that
# are mechanically checkable, enforced here so a violation blocks the push
# rather than surfacing later as a deletion request.
check_aur_rules() {
local pkg="$1" problems=()
grep -q '^# Maintainer:' PKGBUILD \
|| problems+=("missing '# Maintainer:' comment on line 1")
grep -qE "^arch=\(.*x86_64" PKGBUILD \
|| problems+=("x86_64 not in arch=() — packages without it are not allowed")
# `replaces` makes pacman rip out the named package on the next -Sy, which
# is only correct for a genuine rename. `conflicts` is the right tool.
grep -q '^replaces=' PKGBUILD \
&& problems+=("uses replaces=; AUR rules reserve it for renames — use conflicts=")
# A package already in an official repo must never be duplicated.
if pacman -Si "$pkg" >/dev/null 2>&1; then
problems+=("$pkg exists in an official repo — must not be submitted")
fi
grep -qE '^(pkgdesc|url|license)=' PKGBUILD \
|| problems+=("missing pkgdesc/url/license")
# A stale .SRCINFO is the single most common upload rejection.
if ! diff -q <(makepkg --printsrcinfo) .SRCINFO >/dev/null 2>&1; then
problems+=(".SRCINFO does not match PKGBUILD")
fi
if ((${#problems[@]})); then
warn "$pkg: AUR rule violations:"
printf ' - %s\n' "${problems[@]}"
return 1
fi
info "AUR rule checks passed"
return 0
}

148
aur-publish/publish.sh Executable file
View File

@ -0,0 +1,148 @@
#!/bin/bash
# ============================================================================
# Publish kuhy's three Flutter apps to the AUR, hands-off.
#
# Run this once AUR registration is back. It:
# 1. Waits for https://aur.archlinux.org/register to stop returning 503,
# then opens it in a browser and prints the SSH key to paste.
# (Signup is the ONLY manual step: it needs an email confirmation, and
# creating accounts is not something this script will do for you.)
# 2. Waits for `ssh aur@aur.archlinux.org` to authenticate.
# 3. From there, fully unattended, for each package:
# newest release tag -> pkgver -> updpkgsums -> makepkg -C -> namcap
# -> pacman -U -> launch check -> .SRCINFO -> clone AUR repo
# -> commit -> push.
#
# Every package is BUILT, INSTALLED and LAUNCHED before it is pushed, so a
# broken package cannot reach the AUR. A package that fails any gate is
# skipped and reported; the others still publish.
#
# Idempotent and resumable: re-running skips work that is already done, and
# an already-published package is updated rather than duplicated.
# ============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly GATE="$SCRIPT_DIR/anubis_gate.py"
readonly AUR_HOST="aur.archlinux.org"
readonly SSH_KEY="$HOME/.ssh/aur"
readonly AUR_ROOT="$HOME/sdk/aur"
# package:repo:wrapper-process:cli-name:expected-startup-string
# The wrapper process name is NOT derivable from the package name
# (diet-guard-app runs diet_guard_desktop), so it is listed explicitly.
readonly PACKAGES=(
"todo-flutter:$HOME/src/todo:todo_desktop:todo:serving on http://localhost:8730"
"habit-stack:$HOME/src/habit_stack:habit_stack_desktop:habit-stack:serving on http://localhost:8731"
"diet-guard-app:$HOME/src/diet-guard:diet_guard_desktop:diet-guard-app:serving on http://localhost:8732"
)
POLL_SECONDS=300
SKIP_WAIT=0
DRY_RUN=0
ONLY=""
FAILED=()
PUBLISHED=()
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Options:
--skip-wait Assume the account + SSH key already work; go straight
to building and publishing.
--dry-run Do everything except the final 'git push' to the AUR.
--only NAME Publish just one package (todo-flutter, habit-stack,
diet-guard-app).
--poll SECONDS How often to re-check /register (default: $POLL_SECONDS).
-h, --help Show this help.
EOF
exit 0
}
log() { printf '\n\033[1;34m==>\033[0m %s\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[1;33m ! %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31m==> ERROR: %s\033[0m\n' "$*" >&2; exit 1; }
require_tools() {
local missing=()
local tool
for tool in git makepkg updpkgsums namcap ssh python3 curl; do
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
done
if ((${#missing[@]})); then
info "Installing missing tools: ${missing[*]}"
# namcap/updpkgsums come from namcap + pacman-contrib; the rest are base.
sudo pacman -S --needed --noconfirm namcap pacman-contrib git openssh python \
|| die "could not install: ${missing[*]}"
fi
}
# ---------------------------------------------------------------------------
# Phase 1: wait for registration, then for the key to work
# ---------------------------------------------------------------------------
# The rest lives in lib/, sourced (not executed) so every function sees the
# readonly config above and the mutable FAILED/PUBLISHED arrays main() reads.
# shellcheck source=lib/aur_registration.sh
source "$SCRIPT_DIR/lib/aur_registration.sh"
# shellcheck source=lib/aur_verify.sh
source "$SCRIPT_DIR/lib/aur_verify.sh"
# shellcheck source=lib/aur_publish_one.sh
source "$SCRIPT_DIR/lib/aur_publish_one.sh"
main() {
require_tools
[[ -f "$GATE" ]] || die "missing $GATE"
mkdir -p "$AUR_ROOT"
ensure_key
if ((SKIP_WAIT)); then
ssh_works || die "SSH key is not accepted yet; drop --skip-wait to wait for it"
else
wait_for_registration
wait_for_ssh
fi
local entry pkg repo wrapper binary expected
for entry in "${PACKAGES[@]}"; do
IFS=: read -r pkg repo wrapper binary expected <<<"$entry"
[[ -z "$ONLY" || "$ONLY" == "$pkg" ]] || continue
if ! publish_one "$pkg" "$repo" "$wrapper" "$binary" "$expected"; then
FAILED+=("$pkg")
fi
done
log "Summary"
if ((${#PUBLISHED[@]})); then
printf ' published: %s\n' "${PUBLISHED[@]}"
fi
if ((${#FAILED[@]})); then
printf '\033[1;31m FAILED: %s\033[0m\n' "${FAILED[@]}"
info "Nothing broken was pushed; fix and re-run (safe to repeat)."
return 1
fi
if ((${#PUBLISHED[@]} == 0)); then
warn "nothing to do"
return 1
fi
printf '\n Check: https://aur.archlinux.org/packages/todo-flutter\n'
return 0
}
while [[ $# -gt 0 ]]; do
case $1 in
--skip-wait) SKIP_WAIT=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--only) ONLY="$2"; shift 2 ;;
--poll) POLL_SECONDS="$2"; shift 2 ;;
-h|--help) usage ;;
*) die "Unknown option: $1" ;;
esac
done
main "$@"

View File

@ -118,16 +118,6 @@ The public/private split is not cosmetic: every repo in this fleet is public.
APK — the security rules, not secrecy, protect the data. The account email and
password identify *a person* and are entered once per device.
### Sandbox namespace for test builds
`--dart-define=SYNC_NAMESPACE=sandbox/slot3` moves every RTDB read and write
under `/sandbox/slot3/...`, so a test build never touches real data. Sign-in
is unchanged (the real account). Empty, the default, keeps the exact current
paths. Only `^sandbox/[a-z0-9_-]+$` is accepted; anything else throws
`ConfigException` at startup (`firebaseClientFor` / `FirebaseRestClient`), never
falling back to production paths. The shipped rules (root-level uid pin) already
allow these paths.
### `password: ''` is legal
The Google one-tap path and the seeded-session path both store an empty

View File

@ -20,5 +20,4 @@ export 'src/remote_store.dart';
export 'src/secure_credential_store.dart';
export 'src/store.dart';
export 'src/sync.dart';
export 'src/sync_namespace.dart';
export 'src/sync_state.dart';

View File

@ -19,7 +19,6 @@ import 'firebase_auth_rest.dart';
import 'firebase_errors.dart';
import 'firebase_keys.dart';
import 'remote_store.dart';
import 'sync_namespace.dart';
const _httpUnauthorized = 401;
const _httpForbidden = 403;
@ -34,22 +33,13 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader {
required String databaseUrl,
required this.auth,
http.Client? httpClient,
String namespace = syncNamespace,
}) : _databaseUrl = databaseUrl.endsWith('/')
? databaseUrl.substring(0, databaseUrl.length - 1)
: databaseUrl,
_prefix = _prefixFor(checkSyncNamespace(namespace)),
_http = httpClient ?? http.Client();
final String _databaseUrl;
/// `<namespace>/` or empty: put in front of every RTDB path. Every request
/// goes through [_uri], so this is the single choke point.
final String _prefix;
static String _prefixFor(String namespace) =>
namespace.isEmpty ? '' : '$namespace/';
/// Mints the ID token every request carries. Public so a settings screen
/// can drive `signIn` / `signOut` through the same instance the client uses.
final FirebaseTokenProvider auth;
@ -57,12 +47,8 @@ class FirebaseRestClient implements RemoteStore, BulkMapReader {
Future<Uri> _uri(String path, {Map<String, String> query = const {}}) async {
final encoded = encodePath(path);
// A bare namespace (the root probe) must not leave a trailing slash.
final full = encoded.isEmpty && _prefix.isNotEmpty
? _prefix.substring(0, _prefix.length - 1)
: '$_prefix$encoded';
return Uri.parse(
'$_databaseUrl/$full.json',
'$_databaseUrl/$encoded.json',
).replace(queryParameters: {'auth': await auth.idToken(), ...query});
}

View File

@ -20,7 +20,6 @@ import 'firebase_credentials.dart';
import 'firebase_client.dart';
import 'mirror_store.dart';
import 'remote_store.dart';
import 'sync_namespace.dart';
/// The shared config is missing a field, or still holds a scaffold placeholder.
class ConfigException implements Exception {
@ -122,8 +121,6 @@ Future<FirebaseRestClient> firebaseClientFor({
String? expectedUid,
http.Client? httpClient,
}) async {
// Before any sign-in: a bad SYNC_NAMESPACE must stop the app at startup.
checkSyncNamespace(syncNamespace);
final auth = FirebaseTokenProvider(
apiKey: config.apiKey,
store: store,

View File

@ -7,11 +7,8 @@ import 'store.dart';
/// Writes atomically -- to a per-process temp file, then `rename`d over the
/// real path -- so a concurrent reader (e.g. a background sync isolate writing
/// while the foreground app also writes) never observes a half-written file.
/// The pid in the temp name keeps two processes from colliding; within one
/// instance, writes are serialized in issue order (last issued wins), so
/// overlapping calls (e.g. per-keystroke saves) never share the temp file
/// mid-flight. This generalizes diet_guard's hand-rolled `LogStorageService`
/// write scheme.
/// The pid in the temp name keeps two writers from colliding. This generalizes
/// diet_guard's hand-rolled `LogStorageService` write scheme.
///
/// Lives behind the `package:crdt_sync/crdt_sync_io.dart` entrypoint, not the
/// main barrel, because it imports `dart:io` and so is unavailable on web --
@ -23,10 +20,6 @@ class FileLogPersistence implements LogPersistence {
final File _file;
/// Completes when the most recently issued write has settled. Never
/// rejects, so one failed write cannot block the ones queued after it.
Future<void> _tail = Future<void>.value();
@override
Future<String?> read() async {
if (!_file.existsSync()) return null;
@ -38,14 +31,7 @@ class FileLogPersistence implements LogPersistence {
}
@override
Future<void> write(String text) {
final result = _tail.then((_) => _writeNow(text));
// The caller still sees its own error via [result]; the chain ignores it.
_tail = result.then((_) {}, onError: (Object _) {});
return result;
}
Future<void> _writeNow(String text) async {
Future<void> write(String text) async {
await _file.parent.create(recursive: true);
final tmp = File('${_file.path}.$pid.tmp');
await tmp.writeAsString(text);

View File

@ -1,26 +0,0 @@
/// Compile-time sandbox namespace for the Realtime Database.
///
/// Build a test app with `--dart-define=SYNC_NAMESPACE=sandbox/slot3` and every
/// RTDB path the library reads or writes moves under `/sandbox/slot3/`, so a
/// test build can never touch real data. Sign-in is unchanged (real account).
/// Empty, the default, leaves every path exactly as it was.
library;
import 'firebase_config.dart';
/// The `SYNC_NAMESPACE` dart-define; empty means production paths.
const String syncNamespace = String.fromEnvironment('SYNC_NAMESPACE');
final RegExp _allowed = RegExp(r'^sandbox/[a-z0-9_-]+$');
/// Returns [namespace] when it is empty or `sandbox/<slot>`, else throws.
///
/// Fails closed: a typo must stop the app at startup, never quietly fall back
/// to production paths. A trailing newline is rejected too.
String checkSyncNamespace(String namespace) {
if (namespace.isEmpty || _allowed.hasMatch(namespace)) return namespace;
throw ConfigException(
'SYNC_NAMESPACE "$namespace" is invalid: it must be empty or match '
r'^sandbox/[a-z0-9_-]+$ (e.g. sandbox/slot3)',
);
}

View File

@ -2,7 +2,7 @@ name: crdt_sync
description: >-
Shared CRDT merge scheme + GitHub-Contents-API sync transport for
cross-device apps. Pure Dart, no Flutter dependency.
version: 0.12.1
version: 0.11.3
publish_to: 'none'
environment:

View File

@ -128,32 +128,5 @@ void main() {
await store.write('{"hello":"world"}');
expect(await store.read(), '{"hello":"world"}');
});
test('50 overlapping writes all succeed and the last issued wins',
() async {
// Regression: every call shared one `<file>.<pid>.tmp`, so the first
// rename moved it away and a later rename threw PathNotFoundException.
final store = FileLogPersistence(File('${dir.path}/log.json'));
await Future.wait([
for (var i = 0; i < 50; i++) store.write('{"n":$i}'),
]);
expect(await store.read(), '{"n":49}');
expect(dir.listSync().map((e) => e.uri.pathSegments.last),
['log.json']);
});
test('a failed write rejects its caller but does not block later writes',
() async {
final target = File('${dir.path}/log.json');
// A directory at the target path makes the rename fail.
Directory(target.path).createSync();
final store = FileLogPersistence(target);
await expectLater(
store.write('{"n":0}'), throwsA(isA<FileSystemException>()));
Directory(target.path).deleteSync();
await store.write('{"n":1}');
expect(await store.read(), '{"n":1}');
});
});
}

View File

@ -1,170 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:test/test.dart';
const _databaseUrl = 'https://example-default-rtdb.firebasedatabase.app';
FirebaseTokenProvider _auth() => FirebaseTokenProvider(
apiKey: 'fake-api-key',
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'id-token',
refreshToken: 'refresh-token',
expiresAt: DateTime.utc(2099),
),
),
httpClient: http_testing.MockClient(
(_) async => throw StateError('auth must not be contacted'),
),
);
/// Client over an in-memory transport that records every request path.
(FirebaseRestClient, List<http.Request>) _client(String? namespace) {
final requests = <http.Request>[];
final mock = http_testing.MockClient((request) async {
requests.add(request);
return http.Response(jsonEncode(request.method == 'GET' ? null : 'x'), 200);
});
final client = namespace == null
? FirebaseRestClient(
databaseUrl: _databaseUrl,
auth: _auth(),
httpClient: mock,
)
: FirebaseRestClient(
databaseUrl: _databaseUrl,
auth: _auth(),
httpClient: mock,
namespace: namespace,
);
return (client, requests);
}
void main() {
group('compile-time default', () {
test('is empty when SYNC_NAMESPACE is not defined', () {
expect(syncNamespace, isEmpty);
});
test('empty namespace leaves paths byte-for-byte unchanged', () async {
final (client, requests) = _client(null);
await client.getFileText('devices/pc/log.json');
expect(requests.single.url.path, '/devices/pc/log~2Ejson.json');
});
});
group('prefixing', () {
test('every operation lands under the namespace', () async {
final (client, requests) = _client('sandbox/slot3');
await client.getFileText('devices/pc/log.json');
await client.putFileText('devices/pc/log.json', 't', message: 'm');
await client.listDirectory('devices');
await client.getStringMap('revs');
await client.patchValues('revs', {'a': 'b'});
await client.deleteFile('devices/pc/log.json');
await client.canAccessRemote();
expect(requests.map((r) => r.url.path), [
'/sandbox/slot3/devices/pc/log~2Ejson.json',
'/sandbox/slot3/devices/pc/log~2Ejson.json',
'/sandbox/slot3/devices.json',
'/sandbox/slot3/revs.json',
'/sandbox/slot3/revs.json',
'/sandbox/slot3/devices/pc/log~2Ejson.json',
'/sandbox/slot3.json',
]);
});
test('an explicitly empty namespace is the unprefixed path', () async {
final (client, requests) = _client('');
await client.getStringMap('revs');
expect(requests.single.url.path, '/revs.json');
});
});
group('validation (fail closed)', () {
const bad = [
'sandbox',
'sandbox/',
'sandbox/Slot3',
'sandbox/slot 3',
'sandbox/slot3/',
'sandbox/a/b',
'/sandbox/slot3',
'prod/slot3',
'sandbox/slot3\n',
'Sandbox/slot3',
'sandbox/slot.3',
'sandbox/..',
];
for (final value in bad) {
test('rejects ${jsonEncode(value)}', () {
expect(
() => checkSyncNamespace(value),
throwsA(isA<ConfigException>()),
);
expect(
() => FirebaseRestClient(
databaseUrl: _databaseUrl,
auth: _auth(),
namespace: value,
),
throwsA(isA<ConfigException>()),
);
});
}
test('the message names the define and the required shape', () {
expect(
() => checkSyncNamespace('typo'),
throwsA(
isA<ConfigException>().having(
(e) => e.message,
'message',
allOf(
contains('SYNC_NAMESPACE'),
contains(r'^sandbox/[a-z0-9_-]+$'),
),
),
),
);
});
test('accepts the allowed alphabet', () {
expect(() => checkSyncNamespace('sandbox/slot3'), returnsNormally);
expect(() => checkSyncNamespace('sandbox/a_b-c9'), returnsNormally);
expect(() => checkSyncNamespace(''), returnsNormally);
});
test(
'a bad compile-time define stops firebaseClientFor at startup',
() async {
// The define is compile-time, so only a fresh process can set it.
final result = await Process.run(Platform.resolvedExecutable, [
'-DSYNC_NAMESPACE=sandbox/Typo',
'run',
'tool/namespace_probe.dart',
]);
expect(result.exitCode, isNot(0));
expect(
result.stderr,
contains('SYNC_NAMESPACE "sandbox/Typo" is invalid'),
);
},
);
test('a good compile-time define reaches the request path', () async {
final result = await Process.run(Platform.resolvedExecutable, [
'-DSYNC_NAMESPACE=sandbox/slot3',
'run',
'tool/namespace_probe.dart',
]);
expect(result.stderr, isEmpty);
expect(result.stdout, '/sandbox/slot3/devices.json\n');
});
});
}

View File

@ -1,31 +0,0 @@
// Helper for test/sync_namespace_test.dart: the SYNC_NAMESPACE define is
// compile-time, so the test runs this in a fresh process with -D set. It signs
// in from an in-memory session (no network) and prints the first request path.
import 'package:crdt_sync/crdt_sync.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
Future<void> main() async {
final mock = http_testing.MockClient((request) async {
print(request.url.path);
return http.Response('null', 200);
});
final client = await firebaseClientFor(
config: const FirebaseConfig(
apiKey: 'k',
databaseUrl: 'https://example.firebasedatabase.app',
projectId: 'p',
uid: 'u',
email: 'e@x.y',
),
store: InMemoryCredentialStore(
FirebaseCredentials(
idToken: 'i',
refreshToken: 'r',
expiresAt: DateTime.utc(2099),
),
),
httpClient: mock,
);
await client.listDirectory('devices');
}

View File

@ -8,7 +8,7 @@ bootstrap, so a new app reaches "syncing" without writing any of it.
`crdt_sync` is pure Dart on purpose — it runs from command-line tools and from
`wake_alarm`'s headless systemd job, so it cannot depend on
`flutter_secure_storage` or the Android Google plugin. It takes closures instead.
`flutter_secure_storage` or `google_sign_in`. It takes closures instead.
Every Flutter app then wrote the same ~390 lines to supply those closures
(`firebase_backend.dart` + `google_sign_in_backend.dart` + `google_platform*`),
@ -110,11 +110,6 @@ const kServerClientId = '845446124781-...apps.googleusercontent.com';
/// to loopback.
const kDesktopClientId = '845446124781-...apps.googleusercontent.com';
/// That Desktop client's secret. Google rejects the token exchange without
/// it, but treats it as non-confidential for installed apps: inject it at
/// build time (`--dart-define`), never commit it. '' sends none.
const kDesktopClientSecret = String.fromEnvironment('DESKTOP_CLIENT_SECRET');
SyncSettingsScreen(
accountLoader: () => loadAccount(),
accountSaver: saveAccount,
@ -126,7 +121,6 @@ SyncSettingsScreen(
tokenFetcher: () => googleAnyIdToken(
serverClientId: kServerClientId,
desktopClientId: kDesktopClientId,
desktopClientSecret: kDesktopClientSecret,
),
),
googleAvailable: googleAnySignInSupported(
@ -138,13 +132,13 @@ SyncSettingsScreen(
### One tap on every platform
[`google_account_token`](../google_account_token) wraps Android's AccountManager
(no Google client library in the APK), so a real GTK desktop build cannot use
it. This package covers both halves and picks between them:
`google_sign_in` ships **android, ios and web** implementations only — there
is no `google_sign_in_linux` — so a real GTK desktop build cannot use the
plugin at all. This package covers both halves and picks between them:
| Platform | Flow | Entry point |
|---|---|---|
| Android | `google_account_token` plugin, OS account chooser | `googleIdToken` |
| Android / iOS | `google_sign_in` plugin, OS account picker | `googleIdToken` |
| Linux / Windows / macOS | OAuth **loopback** + PKCE, system browser | `googleDesktopIdToken` |
| Web | none (GIS renders its own button) | falls back to password |

View File

@ -84,9 +84,9 @@ Future<FirebaseRestClient?> openSync(
/// Signs in with Google alone, for a device with no account stored yet.
///
/// The one-tap path: [openSync] needs an account to know which email to use,
/// but a fresh install has none. [tokenFetcher] is the app's Google token
/// source -- a closure because the Android plugin does not exist elsewhere and
/// this package must keep working on Linux desktop.
/// but a fresh install has none. [tokenFetcher] is the app's `google_sign_in`
/// call -- a closure because that plugin is Android/iOS/web only and this
/// package must keep working on Linux desktop.
///
/// Returns null when the user dismisses the picker; throws
/// [FirebaseAuthError] when Google succeeds but resolves to the wrong uid,
@ -171,5 +171,8 @@ Future<FirebaseRestClient?> _clientFromStoredSession(
store: keystoreCredentialStore(storage: storage),
);
if (!await auth.hasSession()) return null;
return FirebaseRestClient(databaseUrl: app.project.databaseUrl, auth: auth);
return FirebaseRestClient(
databaseUrl: app.project.databaseUrl,
auth: auth,
);
}

View File

@ -1,8 +1,8 @@
/// Picks the right Google sign-in flow for the platform.
///
/// Two flows exist because one is not enough: the `google_account_token`
/// plugin wraps Android's AccountManager, which has no desktop counterpart, so
/// a real GTK desktop build reaches Google through the OAuth loopback flow
/// Two flows exist because one is not enough: `google_sign_in` ships android,
/// ios and web implementations only -- there is no `google_sign_in_linux` --
/// so a real GTK desktop build reaches Google through the OAuth loopback flow
/// instead. Both yield a Google ID token of the same shape, so
/// `signInWithGoogle` cannot tell them apart.
///
@ -48,7 +48,7 @@ bool desktopSignInSupported(String desktopClientId) =>
/// worth testing, and returning it lets the suite assert the routing on a
/// host that can run neither flow for real.
enum GoogleFlow {
/// The `google_account_token` plugin: Android.
/// The `google_sign_in` plugin: Android and iOS.
plugin,
/// OAuth loopback in the system browser: Linux, Windows, macOS.
@ -72,13 +72,9 @@ GoogleFlow googleFlowFor({
///
/// Null when neither is available or the user backs out -- the caller falls
/// back to the password path either way.
///
/// [desktopClientSecret] is the Desktop client's secret (see
/// `googleDesktopIdToken`); '' sends none.
Future<String?> googleAnyIdToken({
required String serverClientId,
required String desktopClientId,
String desktopClientSecret = '',
}) async {
switch (googleFlowFor(
serverClientId: serverClientId,
@ -91,10 +87,7 @@ Future<String?> googleAnyIdToken({
case GoogleFlow.plugin:
return await googleIdToken(serverClientId: serverClientId);
case GoogleFlow.desktop:
return await googleDesktopIdToken(
clientId: desktopClientId,
clientSecret: desktopClientSecret,
);
return await googleDesktopIdToken(clientId: desktopClientId);
// coverage:ignore-end
case GoogleFlow.none:
return null;

View File

@ -1,11 +1,11 @@
/// One-tap Google sign-in for desktop, via the OAuth loopback flow.
///
/// The Android plugin (`google_account_token`) wraps AccountManager, which
/// does not exist on Linux, so a real GTK build cannot use it at all. The
/// installed-app flow replaces it: bind a loopback socket, open the system
/// browser at Google's consent screen, catch the redirect, and exchange the
/// code for an id_token with PKCE. The token is the same shape the plugin
/// returns, so `signInWithGoogle` cannot tell the two apart.
/// `google_sign_in` ships android, ios and web implementations only -- there
/// is no `google_sign_in_linux` -- so a real GTK build cannot use the plugin
/// at all. The installed-app flow replaces it: bind a loopback socket, open
/// the system browser at Google's consent screen, catch the redirect, and
/// exchange the code for an id_token with PKCE. The token is the same shape
/// the plugin returns, so `signInWithGoogle` cannot tell the two apart.
///
/// Everything decision-shaped lives in `google_pkce.dart` and is covered.
/// This file is the I/O: a socket, a browser, one POST.
@ -66,12 +66,8 @@ Future<bool> _launchInBrowser(Uri url) =>
/// [clientId] must be a **Desktop**-type OAuth client. The Web client id used
/// for the Android flow will be rejected here, and a Desktop client is the
/// only type permitted to redirect to loopback.
///
/// [clientSecret] is that client's secret; Google's token endpoint rejects a
/// Desktop exchange without it. See `tokenExchangeBody`.
Future<String?> googleDesktopIdToken({
required String clientId,
String clientSecret = '',
ServerBinder binder = _bindLoopback,
BrowserLauncher launcher = _launchInBrowser,
http.Client? httpClient,
@ -87,7 +83,11 @@ Future<String?> googleDesktopIdToken({
final client = httpClient ?? http.Client();
try {
final opened = await launcher(
buildAuthUrl(clientId: clientId, redirectPort: port, pkce: pair),
buildAuthUrl(
clientId: clientId,
redirectPort: port,
pkce: pair,
),
);
// No browser means no redirect will ever arrive: fail now rather than
// holding the socket open for the full timeout.
@ -99,7 +99,6 @@ Future<String?> googleDesktopIdToken({
return await _exchange(
client: client,
clientId: clientId,
clientSecret: clientSecret,
code: code,
port: port,
verifier: pair.verifier,
@ -137,7 +136,6 @@ Future<String?> _awaitRedirect(
Future<String?> _exchange({
required http.Client client,
required String clientId,
required String clientSecret,
required String code,
required int port,
required String verifier,
@ -146,7 +144,6 @@ Future<String?> _exchange({
Uri.parse(kGoogleTokenEndpoint),
body: tokenExchangeBody(
clientId: clientId,
clientSecret: clientSecret,
code: code,
redirectPort: port,
verifier: verifier,

View File

@ -136,21 +136,13 @@ RedirectResult parseRedirect(Uri uri, {required String expectedState}) {
}
/// Form body for exchanging [code] at [kGoogleTokenEndpoint].
///
/// [clientSecret] is the Desktop client's secret. Google issues Desktop
/// clients one and rejects the exchange without it even with PKCE, but
/// documents it as non-confidential for installed apps: it ships in the
/// binary, injected at build time and never committed. Left out of the body
/// when empty, so a caller that has none behaves exactly as before.
Map<String, String> tokenExchangeBody({
required String clientId,
required String code,
required int redirectPort,
required String verifier,
String clientSecret = '',
}) => <String, String>{
'client_id': clientId,
if (clientSecret.isNotEmpty) 'client_secret': clientSecret,
'code': code,
'code_verifier': verifier,
'grant_type': 'authorization_code',

View File

@ -1,8 +1,10 @@
/// Platform gate for the programmatic Google sign-in flow.
///
/// Conditional export because the answer differs per platform: the io half
/// defers to the `google_account_token` plugin (Android only), the web half is
/// a constant false.
/// Conditional export because the answer differs per platform *and* because
/// asking the plugin directly is unsafe: `supportsAuthenticate()` throws
/// `UnimplementedError` where no implementation is registered -- an `Error`,
/// not an `Exception`, so it escapes an ordinary catch and would take down
/// the settings screen's `build()`.
library;
export 'google_platform_io.dart'

View File

@ -1,18 +1,15 @@
/// Whether the programmatic Google sign-in flow exists on this platform.
///
/// The io half. The flow is the `google_account_token` plugin, which wraps the
/// framework AccountManager and therefore exists on Android only. Every other
/// `dart:io` platform (Linux, Windows, macOS) reaches Google through the
/// loopback flow instead.
/// The io half. `google_sign_in` ships federated implementations for android,
/// ios and web only -- there is no `google_sign_in_linux` -- so a real GTK
/// desktop build (lyricanki has one) answers false here and reaches Google
/// through the loopback flow instead.
library;
import 'package:google_account_token/google_account_token.dart';
import 'dart:io';
/// True where the one-tap AccountManager flow exists: Android.
///
/// Asks the plugin rather than `Platform.isAndroid` so the two can never
/// disagree about where a token can be produced.
bool get googleSignInSupported => googleAccountTokenSupported;
/// True on the platforms shipping the programmatic plugin flow.
bool get googleSignInSupported => Platform.isAndroid || Platform.isIOS;
/// Whether the loopback sign-in flow can run: it needs a bindable socket and
/// a system browser, which every `dart:io` platform has.

View File

@ -1,8 +1,8 @@
/// One-tap Google sign-in: the `google_account_token` plugin call, owned once.
/// One-tap Google sign-in: the `google_sign_in` plugin call, owned once.
///
/// Why it lives here and not in `crdt_sync`: that package is pure Dart on
/// purpose, because the same library runs on Linux desktop and headless under
/// systemd, where the Android plugin does not exist. It only ever receives a
/// systemd, where `google_sign_in` does not exist. It only ever receives a
/// token string through a closure -- see `signInWithGoogle`, which takes a
/// `tokenFetcher`. This file is that closure.
///
@ -11,8 +11,10 @@
/// time is the mistake this package exists to prevent.
library;
import 'dart:developer';
import 'package:crdt_sync_flutter/src/google_platform.dart' as platform_gate;
import 'package:google_account_token/google_account_token.dart' as account;
import 'package:google_sign_in/google_sign_in.dart';
/// Whether this build can sign in with Google programmatically.
///
@ -31,21 +33,56 @@ bool googleSignInSupported(String serverClientId) =>
/// Returns a Google ID token for the signed-in account, or null.
///
/// Null rather than throwing when the user dismisses the chooser: cancelling
/// is an ordinary outcome, and the caller falls back to the password path.
/// The plugin logs (level 900) every failure, which matters because a
/// *configuration* failure (unregistered SHA-1, wrong client id) arrives
/// through the same path as "the user changed their mind".
/// Null rather than throwing when the user dismisses the picker: cancelling is
/// an ordinary outcome, and the caller falls back to the password path.
///
/// [serverClientId] must be the project's **Web** OAuth client id -- the token
/// is minted for that audience, and an Android client id yields one Firebase
/// rejects with `audience mismatch`.
/// [serverClientId] must be the project's **Web** OAuth client id -- Android
/// must request a token minted for the web client, and an Android client id
/// yields a token Firebase rejects with `audience mismatch`.
///
/// [signInFn] exists for tests; when supplied it replaces the plugin entirely.
/// [signInFn] exists for tests, which cannot reach the plugin's platform
/// channel; when supplied it replaces the plugin entirely.
Future<String?> googleIdToken({
required String serverClientId,
Future<String?> Function()? signInFn,
}) async {
if (signInFn != null) return await signInFn();
return await account.googleIdToken(serverClientId: serverClientId);
if (serverClientId.isEmpty) {
log(
'Google sign-in unavailable: no server client id was compiled in; '
'falling back to the password path',
level: 900,
);
return null;
}
// The plugin reaches the OS account picker through a platform channel,
// which `flutter test` has no binding for. Everything above this point is
// pure Dart and is covered.
// coverage:ignore-start
try {
await GoogleSignIn.instance.initialize(serverClientId: serverClientId);
if (!GoogleSignIn.instance.supportsAuthenticate()) {
log(
'Google sign-in is not available on this platform; falling back to '
'the password path',
level: 900,
);
return null;
}
final account = await GoogleSignIn.instance.authenticate();
return account.authentication.idToken;
} on GoogleSignInException catch (error, stackTrace) {
// Includes the user simply dismissing the picker, which is not an error
// worth surfacing -- but log it, because a *configuration* failure
// (unregistered SHA-1, wrong client id) arrives through the same path and
// is otherwise indistinguishable from "the user changed their mind".
log(
'Google sign-in did not complete',
level: 900,
error: error,
stackTrace: stackTrace,
);
return null;
}
// coverage:ignore-end
}

View File

@ -61,11 +61,11 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.12.0"
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
ref: "crdt_sync_dart-v0.11.3"
resolved-ref: a2e7334dfec5131170a084510d70361d53dc40bc
url: "https://github.com/kuhyx/utils"
source: git
version: "0.12.0"
version: "0.11.3"
crypto:
dependency: "direct main"
description:
@ -177,15 +177,54 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
google_account_token:
google_identity_services_web:
dependency: transitive
description:
name: google_identity_services_web
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
url: "https://pub.dev"
source: hosted
version: "0.3.3+1"
google_sign_in:
dependency: "direct main"
description:
path: google_account_token
ref: "google_account_token-v0.1.0"
resolved-ref: db70ba204d124c6eadf9b8808cd3d3b3e70363d1
url: "https://github.com/kuhyx/utils"
source: git
version: "0.1.0"
name: google_sign_in
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
google_sign_in_android:
dependency: transitive
description:
name: google_sign_in_android
sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e"
url: "https://pub.dev"
source: hosted
version: "7.2.16"
google_sign_in_ios:
dependency: transitive
description:
name: google_sign_in_ios
sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950
url: "https://pub.dev"
source: hosted
version: "6.3.0"
google_sign_in_platform_interface:
dependency: transitive
description:
name: google_sign_in_platform_interface
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
google_sign_in_web:
dependency: transitive
description:
name: google_sign_in_web
sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14
url: "https://pub.dev"
source: hosted
version: "1.1.3"
hooks:
dependency: transitive
description:

View File

@ -4,7 +4,7 @@ description: >-
persisted per-install device id, and a one-call bootstrap. Removes the
~150 lines of firebase_backend.dart every consuming app was copying.
publish_to: 'none'
version: 0.5.1
version: 0.3.5
environment:
sdk: ^3.12.2
@ -13,7 +13,7 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.12.1
ref: crdt_sync_dart-v0.11.3
path: crdt_sync_dart
crypto: 3.0.8
flutter:
@ -23,15 +23,10 @@ dependencies:
# consuming apps can test their own sync wiring without rebuilding it.
flutter_test:
sdk: flutter
# The one-tap Google ID token on Android: AccountManager chooser, no Google
# client library in the APK. Android only -- web has no token source and
# desktop reaches Google through the loopback flow in
# The one-tap Google plugin. Android/iOS/web only -- there is no Linux
# implementation -- so desktop reaches Google through the loopback flow in
# google_desktop_sign_in.dart instead. See google_platform.dart.
google_account_token:
git:
url: https://github.com/kuhyx/utils
ref: google_account_token-v0.1.0
path: google_account_token
google_sign_in: 7.2.0
http: 1.6.0
shared_preferences: 2.5.6
# Opens the system browser for the desktop loopback sign-in.

View File

@ -1,23 +1,11 @@
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_account_token/google_account_token.dart'
show kGoogleAccountTokenChannel;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// `flutter test` reports Android as the default target platform, so the
// desktop configuration this suite exercises is pinned explicitly: the
// plugin half is false and the loopback half is live. The Android routing
// has its own group at the bottom.
setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.linux);
tearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(kGoogleAccountTokenChannel, null);
});
// Under `flutter test` the io platform gate answers for the host, which is
// Linux -- so the plugin half is false and the loopback half is live. That
// is the desktop configuration, and it is the one this suite can exercise
// honestly; the Android half is asserted on the device instead.
group('desktopSignInSupported', () {
test('is true on this host once a desktop client id exists', () {
expect(desktopSignInSupported('desktop-cid'), isTrue);
@ -102,35 +90,4 @@ void main() {
);
});
});
group('on Android', () {
setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
test('the plugin flow wins and the desktop one is off', () {
expect(
googleFlowFor(serverClientId: 'web-cid', desktopClientId: 'd'),
GoogleFlow.plugin,
);
expect(desktopSignInSupported('d'), isFalse);
expect(
googleAnySignInSupported(
serverClientId: 'web-cid',
desktopClientId: '',
),
isTrue,
);
});
test('googleAnyIdToken reaches the AccountManager plugin', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
kGoogleAccountTokenChannel,
(_) async => 'native-token',
);
expect(
await googleAnyIdToken(serverClientId: 'web-cid', desktopClientId: ''),
'native-token',
);
});
});
}

View File

@ -23,9 +23,9 @@ BrowserLauncher _respondingLauncher(
final state = url.queryParameters['state']!;
unawaited(
http.get(
Uri.parse(
'http://127.0.0.1:$port/',
).replace(queryParameters: params(state)),
Uri.parse('http://127.0.0.1:$port/').replace(
queryParameters: params(state),
),
),
);
return true;
@ -54,22 +54,6 @@ void main() {
expect(sentBody['code_verifier'], 'v');
});
test('sends the configured client_secret in the exchange', () async {
late Map<String, String> sentBody;
final client = MockClient((request) async {
sentBody = Uri.splitQueryString(request.body);
return http.Response('{"id_token":"t"}', 200);
});
await googleDesktopIdToken(
clientId: 'desktop-cid',
clientSecret: 'shh',
binder: _bind,
launcher: _respondingLauncher((state) => {'code': 'c', 'state': state}),
httpClient: client,
);
expect(sentBody['client_secret'], 'shh');
});
test('returns null when the user denies consent', () async {
final token = await googleDesktopIdToken(
clientId: 'cid',
@ -115,7 +99,9 @@ void main() {
final token = await googleDesktopIdToken(
clientId: 'cid',
binder: _bind,
launcher: _respondingLauncher((state) => {'code': 'c', 'state': state}),
launcher: _respondingLauncher(
(state) => {'code': 'c', 'state': state},
),
httpClient: MockClient(
(_) async => http.Response('{"error":"invalid_grant"}', 400),
),

View File

@ -142,29 +142,6 @@ void main() {
expect(body['redirect_uri'], 'http://127.0.0.1:9');
});
test('tokenExchangeBody omits client_secret when none is configured', () {
final body = tokenExchangeBody(
clientId: 'cid',
code: 'c',
redirectPort: 9,
verifier: 'v',
);
expect(body.containsKey('client_secret'), isFalse);
});
test('tokenExchangeBody sends the Desktop client_secret when given', () {
// Google issues Desktop clients a secret and rejects the exchange
// without it, even with PKCE.
final body = tokenExchangeBody(
clientId: 'cid',
clientSecret: 'the-secret',
code: 'c',
redirectPort: 9,
verifier: 'v',
);
expect(body['client_secret'], 'the-secret');
});
group('idTokenFromResponse', () {
test('reads the id_token', () {
expect(idTokenFromResponse('{"id_token":"tok"}'), 'tok');

View File

@ -1,39 +1,18 @@
import 'package:crdt_sync_flutter/crdt_sync_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_account_token/google_account_token.dart'
show kGoogleAccountTokenChannel;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
tearDown(() {
debugDefaultTargetPlatformOverride = null;
messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, null);
});
group('googleSignInSupported', () {
test('is true on Android once a client id is present', () {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(googleSignInSupported('some-client-id'), isTrue);
});
test('is false on iOS: AccountManager is Android-only', () {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
expect(googleSignInSupported('some-client-id'), isFalse);
});
test('is false without a client id, whatever the platform', () {
// The guard that stops a build which forgot the constant from showing a
// button that can only ever report "cancelled".
expect(googleSignInSupported(''), isFalse);
});
test('is false on a desktop host even with a client id', () {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
test('follows the platform gate once a client id is present', () {
// Under `flutter test` the io half answers for the host, which is not
// Android -- so this is false here and true on a real handset. Asserting
// the platform's own answer keeps the test honest on every host.
expect(googleSignInSupported('some-client-id'), isFalse);
});
});
@ -61,30 +40,4 @@ void main() {
expect(await googleIdToken(serverClientId: ''), isNull);
});
});
group('googleIdToken through the AccountManager plugin', () {
setUp(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
test('asks the native side for a token for the web client', () async {
MethodCall? seen;
messenger.setMockMethodCallHandler(kGoogleAccountTokenChannel, (
call,
) async {
seen = call;
return 'native-token';
});
final token = await googleIdToken(serverClientId: 'web-cid');
expect(token, 'native-token');
expect(seen?.method, 'idToken');
expect(seen?.arguments, {'serverClientId': 'web-cid'});
});
test('a failing native side is null, not a throw', () async {
messenger.setMockMethodCallHandler(
kGoogleAccountTokenChannel,
(_) async => throw PlatformException(code: 'auth_failed'),
);
expect(await googleIdToken(serverClientId: 'web-cid'), isNull);
});
});
}

View File

@ -11,7 +11,7 @@ dependencies:
data_transfer_flutter:
git:
url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.2.1
ref: data_transfer_flutter-v0.2.0
path: data_transfer_flutter
```

View File

@ -4,13 +4,13 @@ description: >-
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
dialog on desktop, a download on web, plus a versioned export envelope.
publish_to: 'none'
version: 0.2.2
version: 0.2.0
environment:
sdk: ^3.12.2
dependencies:
file_picker: 13.3.3
file_picker: 13.3.1
file_selector: 1.1.0
flutter:
sdk: flutter

View File

@ -91,8 +91,6 @@ NPM_ACCEPT: Final = "application/vnd.npm.install-v1+json"
# --- Cache ------------------------------------------------------------------
CACHE_PATH_ENV: Final = "DEP_FRESHNESS_CACHE"
# Seconds; overrides every ecosystem TTL (a repo that wants a once-a-day gate).
TTL_ENV: Final = "DEP_FRESHNESS_TTL_SECONDS"
DEFAULT_CACHE_DIR: Final = "~/.cache/dep-freshness"
CACHE_FILE: Final = "registry.json"
TTL_SECONDS: Final = 6 * 3600

View File

@ -21,7 +21,6 @@ from dep_freshness._tables import (
DEFAULT_CACHE_DIR,
GITCOMMIT,
GITTAG,
TTL_ENV,
TTL_GITTAG_SECONDS,
TTL_SECONDS,
)
@ -34,14 +33,7 @@ def cache_dir() -> Path:
def ttl_for(ecosystem: str) -> int:
"""Git refs move rarely and cost a subprocess, so they cache longer.
`DEP_FRESHNESS_TTL_SECONDS` overrides both; garbage is ignored, because a
typo in an env var must not break a commit hook.
"""
override = os.environ.get(TTL_ENV, "")
if override.isdigit() and int(override) > 0:
return int(override)
"""Git refs move rarely and cost a subprocess, so they cache longer."""
return TTL_GITTAG_SECONDS if ecosystem in (GITTAG, GITCOMMIT) else TTL_SECONDS

View File

@ -12,7 +12,6 @@ from pathlib import Path
import pytest
from dep_freshness._tables import TTL_SECONDS
from dep_freshness.cache import Cache, Entry, ttl_for
from dep_freshness.models import Dep
from dep_freshness.registries import http
@ -140,16 +139,3 @@ def _offline(*_args):
def _explode(*_args):
raise RuntimeError("boom")
def test_ttl_env_overrides_the_registry_ttl(monkeypatch) -> None:
"""A repo can stretch the refresh window (once-a-day pre-push gates)."""
monkeypatch.setenv("DEP_FRESHNESS_TTL_SECONDS", "86400")
assert ttl_for("npm") == 86400
assert ttl_for("gittag") == 86400
def test_ttl_env_garbage_falls_back_to_the_default(monkeypatch) -> None:
"""A malformed value must never break a commit hook."""
monkeypatch.setenv("DEP_FRESHNESS_TTL_SECONDS", "soon")
assert ttl_for("npm") == TTL_SECONDS

View File

@ -8,10 +8,9 @@ import 'package:flutter/material.dart';
///
/// Rows per page come from the height the parent gives it; the overflow moves
/// to further pages in a horizontal [PageView], marked by dots once there is
/// more than one (a "n / N" counter past 12 pages). [itemBuilder] gets the
/// global item index and must fill the cell it is given: a square of up to
/// column width plus [labelHeight], the rows stretching to use up the
/// leftover height.
/// more than one. [itemBuilder] gets the global item index and must fill the
/// cell it is given: a square of up to column width plus [labelHeight], the
/// rows stretching to use up the leftover height.
class FitGrid extends StatefulWidget {
/// Creates the grid.
const FitGrid({
@ -55,10 +54,6 @@ class FitGrid extends StatefulWidget {
const double _dotSize = AppSpacing.sm;
const double _dotsHeight = AppSpacing.md + AppSpacing.xs;
// Above this many pages a row of dots outgrows a phone width, so the
// indicator becomes a "n / N" counter instead.
const int _maxDots = 12;
class _FitGridState extends State<FitGrid> {
final _controller = PageController();
var _page = 0;
@ -164,35 +159,22 @@ class _Dots extends StatelessWidget {
return ExcludeSemantics(
child: SizedBox(
height: _dotsHeight,
child: count > _maxDots
? Center(
child: Text(
'${current + 1} / $count',
key: const ValueKey<String>('fit-grid-page-counter'),
style: Theme.of(context).textTheme.labelSmall,
textScaler: TextScaler.noScaling,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
Container(
key: ValueKey<String>('fit-grid-dot-$i'),
width: _dotSize,
height: _dotSize,
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == current ? scheme.primary : scheme.outlineVariant,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
Container(
key: ValueKey<String>('fit-grid-dot-$i'),
width: _dotSize,
height: _dotSize,
margin: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == current
? scheme.primary
: scheme.outlineVariant,
),
),
],
),
],
),
),
);
}

View File

@ -5,7 +5,7 @@ description: >-
helpers that were each reimplemented in three or more repos. Owns the tokens
-- consumers import them rather than transcribing the token table by hand.
publish_to: 'none'
version: 0.3.1
version: 0.3.0
environment:
sdk: ^3.12.2

View File

@ -126,30 +126,6 @@ void main() {
expect(find.text('item $perPage').hitTestable(), findsNothing);
});
testWidgets('more than 12 pages: "n / N" counter instead of dots', (
tester,
) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
final count = perPage * 13;
await _pump(tester, count: count);
expect(_dot, findsNothing);
final counter = find.byKey(const ValueKey('fit-grid-page-counter'));
expect(tester.widget<Text>(counter).data, '1 / 13');
await tester.fling(find.byType(PageView), const Offset(-250, 0), 1000);
await tester.pumpAndSettle();
expect(tester.widget<Text>(counter).data, '2 / 13');
expect(tester.takeException(), isNull);
});
testWidgets('exactly 12 pages still shows dots', (tester) async {
await _pump(tester, count: 30);
final perPage = _onScreen(tester);
await _pump(tester, count: perPage * 12);
expect(_dot, findsNWidgets(12));
expect(tester.takeException(), isNull);
});
testWidgets('swiping left shows the next page and moves the dot', (
tester,
) async {

View File

@ -0,0 +1,165 @@
# Dopamine UX — session prompts
Ten pasteable prompts derived from *"The Dopamine Architecture: How UX Designers
Hold User Attention in 2026"* (london-post.co.uk), applied to kuhy's repos.
Paste **one file's contents** into a fresh Claude session. Each is
self-contained: it restates the findings it depends on, so no session needs the
context of another.
## The two tests — every change must pass both
The article's four mechanics (variable rewards, micro-animations, sound design,
near-miss) are in scope, aimed at **kuhy's own stated goals**. They are gated by
two questions, not by an allow/deny list:
1. **Is the displayed state true?** A progress ring at 93% is legitimate *iff*
you are actually at 93%. A streak is legitimate iff it counts real days.
Dopamine may come from framing, timing and salience — **never from a
fabricated number**.
2. **Is the variance real?** A random pick from a real pool (next workout, next
task) is legitimate. Injected random bonus points to manufacture uncertainty
are not.
### The load-bearing corollary
**Reward the recording, never the number recorded.** In diet-guard the
satisfying moment fires on *logging a meal*, not on hitting a calorie target —
otherwise the app rewards eating less rather than recording honestly, and
corrupts its own data. Same shape in workout_app (reward logging a workout, not
beating a PB) and todo (reward capture, not completion count).
## Running order
`01` gates everything. `07` gates `08`. Everything else is independent.
| # | File | Repo | Findings |
|---|---|---|---|
| 01 | `01-motion-tokens.md` | `~/src/utils` | 3 |
| 02 | `02-structural-check.md` | `~/src/utils` | 2 |
| 03 | `03-diet-guard.md` | `~/src/diet-guard` | 4 |
| 04 | `04-screen-locker.md` | `~/src/screen-locker` | 3 |
| 05 | `05-workout-app.md` | `~/src/screen-locker/stronglift_replacement/workout_app` | 4 |
| 06 | `06-todo.md` | `~/src/todo` | 3 |
| 07 | `07-dufs-theme.md` | `~/src/dufs-cloud` | 2 |
| 08 | `08-dufs-motion.md` | `~/src/dufs-cloud` | 4 |
| 09 | `09-wake-alarm.md` | `~/src/wake-alarm` | 2 |
**27 ranked findings total.**
### Dependency graph
```
01-motion-tokens ──┬─→ 03, 04, 05, 09 (transcribe tokens locally)
├─→ 06 (bump design_system-v0.2.0)
└─→ 07 ─→ 08 (bump web_ui-v0.3.2)
02-structural-check (independent; run after 01 so it has motion tokens to check)
```
## The top three findings fleet-wide
Ranked by value-over-effort from the survey data:
1. **screen-locker streak render** (prompt 04) — `snapshot.streak` is already
populated and no renderer reads it. Zero new state, zero conftest change,
zero new dependency.
2. **diet-guard `_onLogMeal` feedback** (prompt 03) — the flagship case.
Dependency-free via `HapticFeedback` from `flutter/services.dart`.
3. **workout_app finish-workout cue** (prompt 05) — `audioplayers`, `vibration`,
the `assets:` block and the VIBRATE permission are **already present**. Only
the call is missing.
## Distributed copies, and the completion protocol
Each prompt is **also copied into the repo it acts on**, at
`<repo>/prompts/dopamine-ux-*.md`, so you can open a repo and say
"do dopamine-ux-<name>" without going hunting. **This directory stays the source
of truth** — if a prompt needs correcting, fix it here and re-distribute.
| Prompt | Distributed to |
|---|---|
| 01 | `~/src/utils/prompts/dopamine-ux-01-motion-tokens.md` — deleted (done) |
| 02 | `~/src/utils/prompts/dopamine-ux-02-structural-check.md` |
| 03 | `~/src/diet-guard/prompts/dopamine-ux-diet-guard.md` |
| 04 | `~/src/screen-locker/prompts/dopamine-ux-04-screen-locker.md` — deleted (done) |
| 05 | `~/src/screen-locker/prompts/dopamine-ux-05-workout-app.md` — deleted (done) |
| 06 | `~/src/todo/prompts/dopamine-ux-todo.md` |
| 07 | `~/src/dufs-cloud/prompts/dopamine-ux-07-theme.md` |
| 08 | `~/src/dufs-cloud/prompts/dopamine-ux-08-motion.md` |
| 09 | `~/src/wake-alarm/prompts/dopamine-ux-wake-alarm.md` |
When a session finishes a prompt it **deletes its repo copy** (in the same
commit as the implementation) and **appends a row below**. A finished prompt left
in a repo is indistinguishable from a pending one, and the next session re-runs
it. Partial completion is never recorded as DONE — the file is edited to state
what remains instead.
### Completion log
| Prompt | Status | Impl commit | Note |
|---|---|---|---|
| 01-motion-tokens.md | DONE 2026-08-22 | `f741896` | `motion.md` frozen; `AppDuration`/`AppCurve` + `--duration-*`/`--ease-*` shipped. Tags: `design_system-v0.2.0`, `web_ui-v0.3.2`. |
| 02-structural-check.md | not started | — | — |
| 03-diet-guard.md | not started | — | — |
| 04-screen-locker.md | DONE 2026-11-01 | `89c65bd` | Status window `_section_streak` + week-transition reward text (kept in `extra_benefits_state.json`); the dead 09:00 early-bird claim is not shown. Per kuhy: no daily-streak state, no shutdown time, no freezes. Commit is on a worktree branch, unpushed. |
| 05-workout-app.md | DONE 2026-11-01 | `e938201` | Set/warmup `selectionClick`, finish `mediumImpact` + generated chime (music_theory, pinned tag) + dialog arrival, `AppDuration`/`AppCurve` in theme.dart, streak counts up, reduced motion honoured, one Settings "Sound" switch for chime and break-end (foreground service too). Worktree commits, unpushed; `62c1c2c` covers old export/import gaps. |
| 06-todo.md | PARTIAL 2026-11-02 (step 1 DONE; step 2 sound REJECTED by kuhy) | `kuhyx/todo-app 2ffae26` | haptic + checkmark on capture, appRoute motion tokens, animated wizard bar; no sound by decision |
| 07-dufs-theme.md | not started | — | — |
| 08-dufs-motion.md | not started | — | — |
| 09-wake-alarm.md | not started | — | — |
## Keeping these prompts honest
Every path and symbol cited across these files is machine-checked:
```
python3 ~/src/utils/dopamine-ux/verify_prompts.py
```
Exit 0 means every cited path resolves and every cited symbol is still greppable
in the file it is attributed to. **Re-run it after any refactor of the surveyed
repos** — a prompt pointing at a renamed function is worse than no prompt, and a
path check alone cannot catch that (which is why it greps symbols too).
Verified 2026-08-16: 146 paths, 30 symbols, all passing.
## Rules that apply to every prompt
1. **Tag cuts and version bumps are explicit steps.** `todo` consumes
`design_system` at git ref `design_system-v0.1.0`; `dufs-cloud/web` consumes
`@kuhyx/web-ui` at `web_ui-v0.3.1`. Adding tokens to those packages does
nothing for consumers until a new tag is cut. Prompt 01 ends by cutting
`design_system-v0.2.0` and `web_ui-v0.3.2`; prompts 06 and 08 begin by
bumping to those literal refs.
2. **Anchor on symbols, not line numbers.** Line numbers in these prompts are
"as of 2026-08-16" and will drift. The symbol name is the real anchor —
grep for it rather than trusting the line.
3. **Reduce-motion is mandatory and is NOT the sound toggle.** Web:
`@media (prefers-reduced-motion: reduce)`. Flutter:
`MediaQuery.of(context).disableAnimations`. The OS motion preference is
honoured automatically; the sound opt-out is a separate user-facing toggle.
4. **Motion and haptics first, sound second.** Haptics need no dependency;
sound needs a pubspec dep, an `assets:` block, and a settings toggle. Each
prompt stages them separately so a short session still lands the valuable
half, and sound stays independently revertable.
5. **Sound ships on (opt-out), never in screen-locker enforcement paths.**
6. **250-line cap** applies to code and prose alike (`~/src/utils/file_length`).
## Source
Article findings, verbatim from the piece:
- **Variable reward schedules** — unpredictable outcomes produce stronger
dopaminergic activity than guaranteed ones. Duolingo streaks, feed ordering,
pull-to-refresh.
- **Micro-animations** — "a button that bounces on press, a like counter that
ticks upward with a slight delay". Replaces latency with feedback, so waiting
reads as responsiveness rather than frustration.
- **Sound design** — "operates below the threshold of conscious attention";
the article's judgement is that it is more powerful than visual design and
gets less critical attention.
- **Near-miss effects** — a fitness ring at ninety-three per cent creates
discomfort that drives re-engagement, and works *even when the user is
explicitly told the mechanism*.
- Core claim: **dopamine is released in anticipation of a reward, not on
receipt. The uncertainty is the trigger.**

View File

@ -0,0 +1,170 @@
Make logging a meal feel satisfying in diet_guard_app
## findings (4, ranked by value over effort)
1. **`_onLogMeal()` gives zero feedback** — no haptic, no sound, no animation, no
confirmation. The flagship gap. Fix needs no new dependency.
2. **Streak text is inert** — "Adherence streak: N days" is a plain `Text` that
blinks to a new value. Real data, no salience.
3. **No VIBRATE permission** — blocks haptics until added to the manifest.
4. **No audio capability at all** — no package, no `assets:` block. Step 2 only.
## what
Logging a meal currently produces **no feedback whatsoever**. `_onLogMeal()` in
`lib/screens/log_meal_screen.dart` writes the meal, syncs it, clears the fields,
and calls `setState(() { _progress = buildTodayProgress(log); })`. The progress
card silently appears. No SnackBar, no haptic, no sound, no animation.
That is the whole problem. In kuhy's words: *"noting what you ate should be
SATISFYING and ADDICTING so you WANT TO ACTUALLY note down what you ate."* The
data quality of the entire app depends on the logging habit surviving the days
you don't feel like it, and right now the act of logging gives nothing back.
Make the moment of logging land: an immediate haptic, a real confirmation, and
the streak/progress reacting visibly rather than blinking into existence.
## where
Repo: `~/src/diet-guard`. Flutter app: `~/src/diet-guard/app` (package `diet_guard_app`).
Primary:
- `app/lib/screens/log_meal_screen.dart` — `_onLogMeal()` (line 122-168 as of
2026-08-16; **grep the symbol, the line will drift**). The `setState` that
silently swaps in the progress card is at ~:164-166. Card rendered at ~:240.
- `app/lib/widgets/today_progress_card.dart` — `TodayProgress` model (~:15),
`TodayProgressCard` (~:64). Consumed/budget kcal ~:100, P/C/F macro row ~:122,
**"Adherence streak: N days"** ~:129-130 — all plain `Text`, no ring, no bar.
- `app/lib/widgets/streak_summary_row.dart` — `StreakSummaryRow`: logging streak,
adherence streak, YTD tally. Also text-only.
- `app/lib/screens/log_meal_progress.dart` — `buildTodayProgress(DayLog)` (~:18),
`adherenceStreak(...)` (~:31).
Theme (local hand-copy, **no `design_system` dependency**):
- `app/lib/ui/theme.dart` — `buildAppTheme()` ~:12, `AppStatusColors`
ThemeExtension ~:89, redeclared `AppSpacing` ~:124, `AppRadius` ~:146,
`AppTextSize`, plus an `AppWidth` class the shared package does not have.
Settings (for the sound opt-out in step 2):
- `app/lib/screens/settings_screen.dart` — body is a `ListView` inside
`Center > ConstrainedBox(maxWidth: AppWidth.prose)` at ~:161-165.
**No `SwitchListTile` exists anywhere in this app** — you are adding the first.
- `app/lib/services/app_settings_service.dart` — the store. **Not
SharedPreferences**: a singleton over a `DocumentStore` writing JSON to
`app_settings.json` (~:26). `init()` ~:49, `_load()` ~:95-112, setters funnel
through `_persist`/`_writeToDisk` ~:147-165 (read the comment at ~:153-155 —
`_writeToDisk` is the single write path every setter must use).
`resetForTesting`/`initForTesting` at ~:66/:74.
## must
**Step 1 — motion + haptics (no new dependency; land this first).**
- On successful meal log, fire `HapticFeedback.mediumImpact()` (or the token
vocabulary's "complete" cue if prompt 01 defined one). `HapticFeedback` comes
from `package:flutter/services.dart` — **no pubspec change needed**. Add
`<uses-permission android:name="android.permission.VIBRATE"/>` to
`app/android/app/src/main/AndroidManifest.xml`; it is currently absent.
- Fire it **immediately on the tap**, before the write/sync completes. The
article's point about micro-animations is that they replace latency with
feedback; a haptic that waits for a network round-trip defeats it.
- Animate the progress card's arrival and the numbers' change rather than
snapping: the kcal figure and the streak count should visibly move to their new
value. Use the motion tokens from prompt 01.
- Show an explicit confirmation of what was recorded — the user should not have
to infer success from a card appearing.
- Honour `MediaQuery.of(context).disableAnimations`: collapse durations to zero.
Haptics still fire (they are not motion).
**Step 2 — sound (separate, independently revertable).**
- Add an audio dependency and an `assets:` block to `app/pubspec.yaml` (neither
exists today), plus a short confirmation cue asset.
- Sound ships **on**, with an opt-out `SwitchListTile` in `settings_screen.dart`,
backed by a new bool in `app_settings_service.dart` following the existing
field pattern: private field + static getter, parsed in `_load()`, written via
`_writeToDisk`.
- The toggle controls **UI sound only**. Never route notification-channel
behaviour through it.
**Both steps:**
- must not: **reward the calorie number.** The satisfying moment fires on
*logging*, not on being under budget. Rewarding a low number teaches the user
to eat less rather than record honestly, and corrupts the app's own data. A
meal that blows the budget gets the same confirmation as one that doesn't.
- must not: fabricate or round up any displayed value. The streak counts real
consecutive days; the progress bar reflects the true ratio. Framing, timing
and salience are fair game — the numbers are not.
- must not: add a celebratory cue to *deleting* or editing a meal. Only the
record-creating act gets it.
- must not: touch `~/src/diet-guard/diet_guard/` (the Python desktop side) — it has
its own streak logic and is out of scope here.
- optional: a progress ring instead of the plain kcal text. The article's
near-miss point applies honestly here — a ring at 80% of budget is truthful
and more motivating than a number. Only if it stays accurate.
## done
1. Logging a meal on the phone produces a haptic within ~50ms of the tap, an
explicit confirmation, and a visible (not instant) update of the streak/progress.
2. `cd ~/src/diet-guard/app && flutter analyze` is clean.
3. `cd ~/src/diet-guard/app && flutter test` passes.
4. With OS "remove animations" enabled, the screen still works and durations are
zero — verified by toggling it, not by reading the code.
5. Step 2 only: the settings toggle flips, persists across an app restart, and
silences the cue.
## verify
**On the phone — this app is mobile-primary; desktop is not sufficient.**
```
adb devices # confirm 23181JEGR08034 is attached
cd ~/src/diet-guard/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
real logged food data. `adb install -r` preserves it.
Then log a real meal on the device and report what you felt and saw. A haptic is
a physical outcome: state what the phone did, do not claim it worked from code.
If `com.kuhy.*` focus-mode kills the app on launch, the app needs a focus
whitelist entry before it can be tested.
## read first
- `app/lib/screens/log_meal_screen.dart` — `_onLogMeal()`. Read the whole method
before editing; the sync call and the field-clearing both matter for where the
haptic goes.
- `app/lib/services/app_settings_service.dart` — the `dailyKcalGoal` field
(~:34/:40) is the pattern to copy for a new bool. Note the `_writeToDisk`
comment.
- `~/src/diet-guard/diet_guard/_calendar_view.py` — `streaks_text()` ~:136 and
`ytd_text()` ~:154. The Dart `streak_summary_row.dart` docstring says it mirrors
this formatting; keep them consistent if you change wording.
- `~/src/utils/unified-design-system/motion.md` — the motion/haptic vocabulary from
prompt 01. **Prompt 01 must have run first.**
## context you would otherwise rediscover
- This app has **no `design_system` dependency** — `app/lib/ui/theme.dart` is a
hand-transcription of the shared tokens (values are byte-identical, names in
comments). Motion tokens from prompt 01 must be **transcribed into that local
theme file**, matching the existing convention. Do not add the dependency here;
that is a separate, larger refactor.
- Capability baseline, verified 2026-08-16: **no audio package, no `assets:`
block, no `vibration` package, no VIBRATE permission.** The only existing
audio-adjacent thing is the `diet_guard_due_slot` notification channel
(`app/lib/services/notification_backend_io.dart` ~:17-18, :52-55) at
`Importance.high` with no explicit sound/vibration override — leave it alone.
- The app already computes both a **logging streak** and an **adherence streak**
(`_daystatus.py:144`/`:153` on the Python side, mirrored in Dart). You are
making existing true data feel like something, not inventing a new metric.
- diet-guard state lives under XDG (`~/.local/share/diet_guard`), unlike
screen-locker's in-repo JSON. Tests isolate it via
`~/src/diet-guard/diet_guard/tests/conftest.py:97` `_isolate_state`.
REMOVE ME AFTER FINISH

View File

@ -0,0 +1,173 @@
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/workout_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
- **`workout_log.json` is the single source of truth**, written only through
`_log_mixin.py` `write_signed_entry()` (~:82), HMAC-signed via
`gatelock.log_integrity`, deduped on `workout_id`. The Flutter app converges on
it via `_manual_push.py`. There is no second history — a streak computed from
it is authoritative.
- All persistence is **JSON files, no sqlite**; paths declared in
`screen_locker/_constants.py`. The streak store is
`screen_locker/extra_benefits_state.json` (`consecutive_5plus_weeks`,
`last_processed_iso_week`, `extended_early_bird_iso_weeks`; a legacy
`weekly_shutdown_bonus_hours` map is ignored and dropped on the next write).
- The threshold lives in `_extra_benefits.py`: `_BONUS_THRESHOLD = 5` (workouts
per week, for the streak and the early-bird extension). There is no milestone
constant any more.
- **There is no config system** — no argparse, no env vars, no config file. Every
tunable is a module-level constant. An opt-out toggle has no existing home;
for a self-restriction tool, requiring a code edit to disable is arguably
correct, so prefer a constant in `_constants.py` over new state.
- `~/gatelock` is **not** a repo — it is `~/src/utils/gatelock/gatelock/`, consumed as
a pinned git dep, and it holds no workout state.
- Manual workouts were **not** removed: `_manual_workout.py` implements a
rate-limited, evidence-gated subsystem (budget 2 per 7 days, 10 per 30).
- Three UI surfaces exist: the Tk status window (`status_view.py`), a GTK tray
icon at `~/.config/i3/scripts/screen_locker_tray.py` (outside the repo, shells
out to the CLI, refreshes every 60s), and the lock screen itself. No i3blocks
integration exists despite `format_summary_line` being described as one.
REMOVE ME AFTER FINISH

View File

@ -0,0 +1,194 @@
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/workout_log.json` as the single source of truth. **Do not change
what counts as a workout** — enforcement depends on it, and it is out of scope.
- The repo enforces a 250-line file cap and bans `# noqa`; ruff runs
`select = ["ALL"]` on the Python side.
REMOVE ME AFTER FINISH

View File

@ -0,0 +1,198 @@
Give dufs-cloud real latency feedback on its slow operations
## findings (4, ranked by value over effort)
1. **No cancel on the unbounded index walk or the ZIP build** — the real
usability failure, and worth more than any animation.
2. **Slow operations report one static string** — the walk and the zip both know
real counts and discard them.
3. **No `prefers-reduced-motion` block anywhere in 806 lines of CSS** — a
straight accessibility gap.
4. **Long-press multi-select has no haptic** — the canonical place for one.
## what
dufs-cloud has genuinely slow operations and tells the user almost nothing while
they run. Verified 2026-08-16:
- The **recursive cloud index walk** lists every directory in the cloud,
unbounded. Web shows one static string `"Indexing cloud…"`; Flutter shows one
indeterminate `CircularProgressIndicator`. **No count, no current path, no
cancel** on either side.
- The **multi-file ZIP build** downloads every selected file's bytes into memory
and then zips them, with `setBusy('Preparing N item(s)…')` — a single static
string for the entire operation. Neither `buildSelectionZip` signature even
accepts a progress callback.
- Web's entire 806-line `index.css` contains **exactly one transition**
(`.tile-actions { transition: opacity 0.12s }` ~:539-544), zero `@keyframes`,
and **no `prefers-reduced-motion` block at all**.
- Flutter side has **zero animations and zero haptics** — notably
`browser_screen.dart` (~:95) enters multi-select on **long-press** with no
`HapticFeedback.selectionClick()`, the canonical place for one.
This is the article's micro-animation point in its most defensible form: replace
dead latency with honest, informative feedback. Nothing here needs a manufactured
reward — the operations are genuinely slow and the progress is genuinely knowable.
## where
Repo: `~/src/dufs-cloud`. Web: `web/`. Flutter: `app/` (package `dufs_client`).
**First step — bump both dependencies** to pick up prompt 01's motion tokens:
- `web/package.json`: `"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.3.1&path:/web_ui"`
→ bump to **`web_ui-v0.3.2`** (or whatever tag prompt 01 printed).
**pnpm only** — npm cannot install a monorepo subdirectory and will lock the repo.
- `app/pubspec.yaml`: bump `design_system` to the tag prompt 01 cut.
**Prompt 07 must have run first** — it is what adds this dependency at all.
Slow operations (the highest-value targets):
- Index walk — web `web/src/hooks/use-cloud-index.ts` (`walkInto` ~:36-43, root
walk ~:56-63, `loading` derived ~:113); Flutter
`app/lib/services/cloud_index.dart` (`buildCloudIndex` ~:25, `_walk` ~:31-41),
driven by `app/lib/screens/browser_screen.dart` `_ensureIndex()` ~:184-194.
- ZIP build — web `web/src/lib/download.ts` (`gatherFiles` ~:24,
`buildSelectionZip` ~:37-47, `saveBytes` ~:55; encoder `web/src/lib/zip.ts`
`zipStore` ~:32), triggered from `web/src/components/gallery.tsx` ~:307;
Flutter `app/lib/services/download_zip.dart` (`gatherFiles` ~:15,
`buildSelectionZip` ~:29-37).
- Upload — web `gallery.tsx` ~:186; Flutter
`app/lib/services/dufs_client.dart` `upload()` ~:73-81 (a plain `_http.put`
with the full body — **not** a `StreamedRequest`, so byte progress is not
currently observable), called from `browser_screen.dart` ~:339.
- Bulk mutations, all single static strings in `gallery.tsx`: move ~:211/:271,
delete ~:229/:290 (the bulk delete loops per item and reports only a final
failure count), rename ~:243, create dir ~:257.
- Directory listing — web `web/src/hooks/use-listing.ts` ~:31-72; Flutter
`browser_screen.dart` ~:120/:140/:149/:155.
Other surfaces:
- `web/src/index.css` (806 lines; `.busy` styled ~:87 — static text, no shimmer).
- `web/src/components/filter-bar.tsx` ~:75-83 — the search input fires `onFilter`
on **every keystroke with no debounce**.
- `web/src/components/gallery.tsx` ~:454 `{loading && <p className="muted">Loading…</p>}`,
~:355 the `busy` span; `folder-picker.tsx` ~:97.
- Flutter spinners: `browser_screen.dart` ~:717, ~:732, `RefreshIndicator` ~:748
(the one built-in motion affordance), plus `text_editor_screen.dart` ~:101/:80,
`audio_screen.dart` ~:104, `video_screen.dart` ~:169, `pdf_screen.dart` ~:184,
`app/lib/widgets/folder_picker.dart` ~:92.
- Settings: `app/lib/screens/settings_screen.dart` (93 lines — room for a toggle),
store `app/lib/services/settings.dart`.
## must
**Step 1 — motion + honest progress (the valuable half).**
- Make the index walk report **real progress**: directories walked, files found,
and ideally the current path. The walk already knows these numbers — it just
discards them. Thread a progress callback through `buildCloudIndex`/`walkInto`.
- Make the ZIP build report **per-file progress** (`3 of 47`). Both
`buildSelectionZip` signatures need a progress callback added; today neither
accepts one.
- Add a **cancel** to both. A long unbounded operation with no way out is the
actual usability failure here, and it is more valuable than any animation.
- Add `HapticFeedback.selectionClick()` on the Flutter **long-press to
multi-select** (`browser_screen.dart` ~:95). No dependency needed
(`flutter/services.dart`); add `<uses-permission
android:name="android.permission.VIBRATE"/>` to the manifest if absent.
- Add a `@media (prefers-reduced-motion: reduce)` block to `web/src/index.css`.
**There is none today** — this is a straight accessibility gap, not a nicety.
Flutter side: honour `MediaQuery.of(context).disableAnimations`.
- Use the shared motion tokens for any transition you add. Replace the inline
`0.12s` at `index.css` ~:539-544 with the token while you are there.
- Debounce the search input (`filter-bar.tsx` ~:75-83). Firing on every keystroke
over a large index is the cause of jank, and no animation fixes that.
**Step 2 — sound (separate, independently revertable).**
- A completion cue for long operations only (index built, ZIP ready, upload
finished) — **not** for navigation or selection. A file manager that chirps on
every click is intolerable.
- Sound ships **on**, with an opt-out toggle in
`app/lib/screens/settings_screen.dart` backed by `app/lib/services/settings.dart`,
and an equivalent on web.
- Consider suppressing the cue when the operation finishes in under ~2s — a
completion sound for something that felt instant is noise.
**Both steps:**
- must not: **fake progress.** No indeterminate bar dressed up as determinate, no
bar that advances on a timer, no "estimated" percentage that is really elapsed
time. If a total is unknown, show the count that *is* known ("47 files found")
rather than a fabricated fraction. This is the project's first test and the
easiest place in the fleet to violate it.
- must not: add a variable/random reward. There is no real pool here; any
variance would be manufactured.
- must not: change what the index actually contains, or filter/sort semantics
(`web/src/lib/filter-sort.ts`, `app/lib/util/filter_sort.dart` mirror each
other — a change to one desyncs the port).
- must not: use npm in `web/`. **pnpm only.**
- must not: let web and Flutter drift further apart. If you add a progress
callback on one side, add the equivalent on the other, or state explicitly in
the summary why not.
- optional: skeleton screens for the directory listing instead of "Loading…".
Genuinely useful for perceived latency, but lower value than cancel + real
counts. Do it only after step 1 lands.
## done
1. Starting a cloud index shows a live, **true** count and can be cancelled — on
both web and Flutter.
2. A multi-file ZIP shows per-file progress and can be cancelled.
3. `web/src/index.css` has a `prefers-reduced-motion` block; with the OS setting
on, transitions are suppressed.
4. Long-press multi-select produces a haptic on the phone.
5. `cd ~/src/dufs-cloud/web && pnpm install && pnpm build && pnpm test` passes
(use the repo's actual script names).
6. `cd ~/src/dufs-cloud/app && flutter analyze && flutter test` clean.
7. No fabricated progress value anywhere in the diff.
## verify
**Both surfaces — this repo has two and they drift.**
Web: run the dev server, open it in a real browser, and index a directory large
enough to take several seconds. Confirm the count moves, the cancel works, and
the numbers are real (cross-check the final count against the actual file count).
A jsdom test cannot verify this — it has no layout and no perceived latency.
Flutter, on the phone:
```
adb devices
cd ~/src/dufs-cloud/app
flutter build apk --release
adb install -r build/app/outputs/flutter-apk/app-release.apk
```
Never `adb uninstall`/`pm clear` — the app holds a stored credential.
Then trigger a real index build and a real multi-file ZIP on the device and
report what you saw. State the haptic as a physical observation.
## read first
- `web/src/hooks/use-cloud-index.ts` and `app/lib/services/cloud_index.dart` —
read both before changing either; they mirror each other deliberately.
- `web/src/lib/download.ts` and `app/lib/services/download_zip.dart` — same.
- `web/src/components/gallery.tsx` — the `setBusy` call sites; they are the
inventory of every operation currently reduced to one static string.
- `~/src/dufs-cloud/DESIGN_AUDIT_TODO.md` — prior findings for this repo.
- `~/src/utils/unified-design-system/motion.md` — vocabulary from prompt 01.
## context you would otherwise rediscover
- **Prompt 07 must have run first.** Until it does, `app/` has no
`design_system` dependency and hand-restates the palette in
`app/lib/ui/theme.dart`.
- Web tokens come from `@kuhyx/web-ui` imported in `web/src/main.tsx`;
`web/src/index.css` declares only `--overlay`. New tokens arrive **only** after
the tag bump — this is the step that is easy to forget and silently produces
"the variable doesn't exist".
- `upload()` uses a non-streaming `_http.put`, so real byte-level upload progress
needs a `StreamedRequest` refactor. That may be more than this prompt wants —
if you skip it, say so explicitly and report per-**file** progress instead of
per-byte rather than faking a byte percentage.
- Media playback (`web/src/hooks/use-dash-player.ts`, `web/src/lib/dash-buffer.ts`,
`app/lib/services/app_player.dart`) has its own buffering states. Out of scope
unless trivially adjacent.
REMOVE ME AFTER FINISH

View File

@ -0,0 +1,164 @@
Add motion and haptics to the wake-alarm phone app
## findings (2, ranked by value over effort)
1. **Setting an alarm gives no confirmation or haptic** — and since the real
alarm lives in another app entirely, confirming the handoff is the only
reassurance this app can offer.
2. **Zero motion, and the thinnest local theme in the fleet** — it lacks
`AppSpacing`/`AppRadius`/`AppTextSize`, so widgets copied from sibling apps
will not compile here.
## what
`~/src/wake-alarm/phone_app` is the smallest surface in this project: 12 Dart files,
a sync companion that sets a time, syncs it, and hands off to the system clock
app. It has **zero motion, zero haptics, zero audio, zero progress feedback** —
verified 2026-08-16. Its settings screen is a 56-line `StatelessWidget` with two
`ListTile`s and no state at all.
Bring it up to the fleet's baseline: a confirmation the alarm was actually set,
motion tokens on its transitions, and a haptic on the one action that matters.
**This app is deliberately last and deliberately small.** It also carries the
project's sharpest hazard — see `must not` about alarm audio.
## where
Repo: `~/src/wake-alarm`. Flutter app: `~/src/wake-alarm/phone_app` (**underscore**, not
`phone-app`). The repo root is a Python package; the Flutter app is only the
`phone_app/` subdirectory.
Primary:
- `phone_app/lib/screens/home_screen.dart` — `_setPhoneAlarm()` ~:86-96 builds an
`AndroidIntent(action: 'android.intent.action.SET_ALARM', arguments: {HOUR,
MINUTES, SKIP_UI: false, MESSAGE: 'Wake Alarm'})` via `android_intent_plus`.
The `CircularProgressIndicator` is at ~:189. The only `Duration` in the whole
app is `.timeout(const Duration(seconds: 3))` ~:95 — **an intent timeout, not
animation. Do not tokenise it.**
- `phone_app/lib/screens/settings_screen.dart` — 56 lines, `StatelessWidget`,
`ListView` ~:21-53, `MaterialPageRoute` ~:30, :49.
- `phone_app/lib/services/sync_settings.dart` — holds **only** the GitHub token
in the keystore (`FlutterSecureStorage`, `_secureTokenKey = 'github_token'`
~:31, `load()` ~:36, `save()` ~:61). There is no general app-preferences class.
- `phone_app/lib/ui/theme.dart` — local hand-copy, **no `design_system`
dependency**. `buildAppTheme()` ~:12, `AppStatusColors` ~:54. ⚠️ Unlike the
other apps it does **not** declare `AppSpacing`/`AppRadius`/`AppTextSize`, so a
widget copied from diet-guard or workout_app **will not compile here**. Wired at
`phone_app/lib/main.dart` ~:18.
## must
**Step 1 — motion + haptics (no new dependency).**
- Add `HapticFeedback.mediumImpact()` when the alarm is successfully set — the
single most important action in the app. Fire it on the tap, not after the
intent round-trips. `HapticFeedback` is in `package:flutter/services.dart`; add
`<uses-permission android:name="android.permission.VIBRATE"/>` to
`phone_app/android/app/src/main/AndroidManifest.xml` if absent.
- Give setting the alarm a **clear confirmation**: which time was set, and that
the handoff to the system clock succeeded. Today the user gets a `_status`
string and a spinner. Since the actual alarm lives in another app, confirming
the handoff is the only reassurance this app can offer — make it unambiguous.
- Transcribe the motion tokens from prompt 01 into `phone_app/lib/ui/theme.dart`,
matching the existing hand-copy convention. Add `AppSpacing`/`AppRadius`/
`AppTextSize` **only if** you need them; otherwise leave the file's shape alone.
- Apply motion tokens to the two `MaterialPageRoute` transitions.
- Honour `MediaQuery.of(context).disableAnimations`.
**Step 2 — sound (optional here; consider skipping).**
- A UI confirmation cue would need an audio dependency, an `assets:` block, a
settings toggle, **and** a preferences class that does not exist yet (see
below). For an app whose entire job is to hand off to another app, that is a
poor value ratio.
- **Recommendation: skip sound in this app** and say so explicitly in the session
summary. If you do add it, it needs the full opt-out toggle like everywhere
else — and the toggle work below.
- To add any toggle here you must do three things the other apps do not require:
(a) convert `SettingsScreen` from `StatelessWidget` to `StatefulWidget`,
(b) create a preferences class — there is no `AppSettings` equivalent; model it
on `~/src/todo/lib/data/app_settings.dart`, and note `shared_preferences: ^2.3.0`
is **already a dependency**, (c) add the `SwitchListTile` to the `ListView`.
**Both steps:**
- must not: **touch anything under `~/src/wake-alarm/wake_alarm/`.** That is the
desktop Python daemon that produces the real alarm noise — sine-tone WAVs via
`paplay` → `aplay` → `speaker-test` (`_audio.py`, `_play_on_all_sinks` ~:218,
pcspkr evdev fallback `_beep_pcspkr` ~:81), escalating 440Hz → 1000Hz → loud in
the loop at `_alarm.py` ~:393-407, with sink volume save/restore in `_sinks.py`.
**Never route a UI-sound opt-out through any of it.** A toggle that silences a
wake-up alarm is a bug that makes someone late for work.
- The two systems never share a process, package, or file — `phone_app` has no
audio engine at all — so a UI sound layer here **structurally cannot** touch
alarm playback. Keep it that way; do not build a bridge between them.
- must not: change the alarm-setting intent, its arguments, or the 3s timeout.
Feedback wraps that call; it does not modify it.
- must not: add a streak, a counter, or any reward for setting an alarm. Waking
up is not a metric this app owns, and rewarding "alarms set" rewards the wrong
act entirely.
- must not: copy a widget from another app without checking it only uses symbols
this app's thinner theme actually declares.
## done
1. Setting an alarm produces a haptic and an unambiguous confirmation naming the
time that was set.
2. Page transitions use the shared motion tokens; no inline animation `Duration`
remains (the 3s intent timeout stays).
3. `cd ~/src/wake-alarm/phone_app && flutter analyze` is clean.
4. `cd ~/src/wake-alarm/phone_app && flutter test` passes.
5. With OS "remove animations" enabled, the app works and durations are zero.
6. `git diff` shows **no changes under `~/src/wake-alarm/wake_alarm/`**.
7. The summary states whether sound was added or deliberately skipped.
## verify
**On the phone.**
```
adb devices # confirm 23181JEGR08034
cd ~/src/wake-alarm/phone_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` — the app stores
a GitHub token in the keystore and wiping it means re-authenticating.
Then set a real alarm on the device: confirm the haptic fired (state it as a
physical observation), the confirmation named the right time, and **the system
Clock app actually received the alarm**. The last one is the real test — this
app's job is the handoff, so verify the alarm exists in the Clock app, not just
that this screen said so.
Do **not** test by letting an alarm actually fire unless kuhy asks — that
involves the desktop daemon and real noise.
## read first
- `phone_app/lib/screens/home_screen.dart` — `_setPhoneAlarm()` in full,
including the timeout and the `_status` string handling.
- `phone_app/lib/ui/theme.dart` — note what it does **not** declare before
writing any widget.
- `~/src/todo/lib/data/app_settings.dart` — the preferences-class pattern, only if
you decide to do step 2.
- `~/src/utils/unified-design-system/motion.md` — vocabulary from prompt 01.
**Prompt 01 must have run first.**
## context you would otherwise rediscover
- **The Flutter app produces no audio whatsoever.** No audio package, no
`assets:` block; `assets/` contains only launcher icons. It fires an Android
`SET_ALARM` intent and the **system Clock app** rings. The desktop Python
daemon is an entirely separate program on a different machine.
- The repo root (`~/src/wake-alarm/wake_alarm/`, `wake_alarm.egg-info`) is the Python
package. Only `phone_app/` is Flutter.
- This app has **no `design_system` dependency** and the thinnest local theme in
the fleet. Adding the dependency is out of scope here (see prompt 07 for what
that migration looks like when done properly).
- No streaks, no charts, no progress, no completion counts exist — and none
should be added. This is a two-button utility.
REMOVE ME AFTER FINISH

View File

@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Verify every path and symbol cited in the dopamine-ux prompt files.
A copy-pasteable prompt that points at a file which does not exist is worse than
no prompt, so this gate adjudicates with an exit code rather than by eye.
Two checks:
1. Every cited on-disk path resolves.
2. Every cited symbol is greppable in the file it is attributed to. A path
check alone cannot catch a stale line number; a symbol grep can.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
PROMPT_DIR = Path.home() / "src/utils" / "dopamine-ux"
HOME = Path.home()
PATH_RE = re.compile(r"`(~?/[^`\s]+|[\w.\-]+/[\w./\-]+\.\w+)`")
# Not on-disk paths despite matching the shape above.
SKIP_SUBSTRINGS = (
"://",
"$",
"*",
"<",
">",
"…",
"devices/", # Firebase/crdt key path
"settings/advanced", # Firebase key path
"android.permission",
"android.intent",
"/sdcard/",
"/etc/",
"/dev/input/",
"/opt/",
"com.kuhy",
"app-release.apk",
"build/app/outputs", # build outputs
"flutter/services.dart",
"flutter/material.dart", # package imports
)
# Files a prompt CREATES. Citing them forward is correct, so absence is expected
# -- but pairing each with its creating prompt still catches a typo.
CREATED_BY_PROMPT = {
"~/src/utils/unified-design-system/motion.md": "01",
"~/src/utils/unified-design-system/scripts/structural_check.py": "02",
}
# Cited precisely to state that they do NOT exist.
ASSERTED_ABSENT = {
"~/gatelock",
# Distributed prompt copies are `git rm`d on completion (see the protocol in
# 00-INDEX.md), but 00-INDEX.md keeps citing them in its "Distributed copies"
# table. Asserting ABSENT rather than dropping the row keeps the check
# two-directional: a finished prompt reappearing in a repo is itself a
# failure, because the next session would re-run it. Move a row here as each
# prompt completes.
"~/src/utils/prompts/dopamine-ux-01-motion-tokens.md",
}
# Generic prose fragments rather than citations of one specific file.
PROSE_FRAGMENTS = {"lib/ui/theme.dart", "lib/main.dart", "pubspec.yaml"}
# Symbols each prompt claims exist, as (repo-relative file, symbol).
SYMBOL_CLAIMS = [
("~/src/diet-guard/app/lib/screens/log_meal_screen.dart", "_onLogMeal"),
("~/src/diet-guard/app/lib/widgets/today_progress_card.dart", "TodayProgressCard"),
("~/src/diet-guard/app/lib/widgets/streak_summary_row.dart", "StreakSummaryRow"),
("~/src/diet-guard/app/lib/screens/log_meal_progress.dart", "buildTodayProgress"),
("~/src/diet-guard/app/lib/services/app_settings_service.dart", "_writeToDisk"),
("~/src/screen-locker/screen_locker/_status_types.py", "StatusSnapshot"),
("~/src/screen-locker/screen_locker/_extra_benefits.py", "process_week_transition"),
("~/src/screen-locker/screen_locker/_extra_benefits.py", "current_streak"),
("~/src/screen-locker/screen_locker/_unlock_view.py", "unlock_screen"),
("~/src/screen-locker/screen_locker/_log_mixin.py", "write_signed_entry"),
("~/src/screen-locker/screen_locker/tests/conftest.py", "_ISOLATED_STATE"),
("~/src/screen-locker/screen_locker/_weekly_check.py", "WEEKLY_WORKOUT_MINIMUM"),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
"workout_screen_session.dart",
"_playBreakEndCue",
),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/screens/"
"workout_screen_finish.dart",
"_persistFinishedWorkout",
),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
"workout_summary_dialog.dart",
"WorkoutSummaryDialog",
),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/services/"
"storage_service.dart",
"_getSetting",
),
(
"~/src/screen-locker/stronglift_replacement/workout_app/lib/widgets/"
"exercise_tile_rows.dart",
"AnimatedContainer",
),
("~/src/todo/lib/data/app_settings.dart", "withAdvancedMode"),
("~/src/todo/lib/ui/settings_screen.dart", "SwitchListTile"),
("~/src/dufs-cloud/web/src/hooks/use-cloud-index.ts", "walkInto"),
("~/src/dufs-cloud/app/lib/services/cloud_index.dart", "buildCloudIndex"),
("~/src/dufs-cloud/web/src/lib/download.ts", "buildSelectionZip"),
("~/src/dufs-cloud/app/lib/services/download_zip.dart", "buildSelectionZip"),
("~/src/wake-alarm/phone_app/lib/screens/home_screen.dart", "_setPhoneAlarm"),
("~/src/wake-alarm/wake_alarm/_audio.py", "_play_on_all_sinks"),
("~/src/utils/unified-design-system/scripts/palette_check.py", "NON_COLOUR_CSS"),
("~/src/utils/unified-design-system/scripts/palette_map.py", "PALETTE"),
("~/src/utils/design_system/lib/src/feedback.dart", "showToast"),
("~/src/utils/design_system/lib/src/tokens.dart", "AppSpacing"),
("~/src/utils/gatelock/gatelock/_window.py", "LockConfig"),
]
def expand(raw: str) -> Path:
return HOME / raw[2:] if raw.startswith("~/") else Path(raw)
def candidate_paths(text: str) -> set[str]:
return {
raw
for raw in PATH_RE.findall(text)
if not any(s in raw for s in SKIP_SUBSTRINGS)
and raw not in PROSE_FRAGMENTS
and not re.search(r"\.\w+:\d+$", raw) # trailing :line suffix
}
def prompt_roots(text: str) -> list[Path]:
roots = []
for label in ("Repo", "App", "Flutter app"):
for m in re.finditer(rf"{label}: `(~/[^`]+)`", text):
roots.append(expand(m.group(1)))
return roots
def check_paths() -> list[str]:
failures = []
checked = 0
for prompt in sorted(PROMPT_DIR.glob("*.md")):
text = prompt.read_text()
roots = prompt_roots(text)
for raw in sorted(candidate_paths(text)):
if raw in ASSERTED_ABSENT:
if expand(raw).exists():
failures.append(
f"{prompt.name}: `{raw}` is asserted ABSENT but EXISTS"
)
continue
if raw in CREATED_BY_PROMPT:
owner = CREATED_BY_PROMPT[raw]
cites_owner = re.search(rf"[Pp]rompt {owner}", text) is not None
if not prompt.name.startswith(owner) and not cites_owner:
failures.append(
f"{prompt.name}: cites `{raw}` without noting prompt "
f"{owner} creates it"
)
continue
checked += 1
if raw.startswith(("~/", "/")):
if not expand(raw).exists():
failures.append(f"{prompt.name}: `{raw}` MISSING")
elif not any((r / raw).exists() for r in roots):
failures.append(
f"{prompt.name}: `{raw}` not found under any of "
f"{[str(r) for r in roots]}"
)
print(f"paths: checked {checked}")
return failures
def check_symbols() -> list[str]:
failures = []
for raw_file, symbol in SYMBOL_CLAIMS:
path = expand(raw_file)
if not path.exists():
failures.append(f"symbol source MISSING: {raw_file}")
continue
if symbol not in path.read_text(errors="replace"):
failures.append(f"symbol `{symbol}` NOT FOUND in {raw_file}")
print(f"symbols: checked {len(SYMBOL_CLAIMS)}")
return failures
def main() -> int:
n_prompts = len(list(PROMPT_DIR.glob("*.md")))
print(f"verifying {n_prompts} prompt files in {PROMPT_DIR}")
failures = check_paths() + check_symbols()
if failures:
print(f"\nFAIL -- {len(failures)} problem(s):")
for f in failures:
print(f" {f}")
return 1
print("\nPASS -- every cited path exists and every cited symbol is present")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -189,17 +189,16 @@ How far to trust a gate is computed from its ledger, never listed by name:
`workout_match` counts a `runnerup_tcx` row by `completed_at`, a `rest_day` row
on its `day` only if `detail.declared_at` predates that day (else logged, no).
## The focus project (0.19.0, cut 180 since 0.20.0, pays 240 since 0.21.0)
## The focus project (0.19.0, cut 180 since 0.20.0)
While `focus_project.active(day)` returns a project, `earners_for` drops the
gates it `replaces` (`book` = `reading`) and adds one counted `focus` earner
(`max_units` = the day's `daily_on`). Shutdown pays their rungs (180/120 min); gaming
their `max_gaming_minutes` + pure bonuses again (240/120 min = 2/min; full day = 8 h):
(`max_units` = the day's `daily_on`) paying what they paid (shutdown by their rungs,
gaming by `max_gaming_minutes`: 60+60+60 over 120 min = 1.5/min):
`floor(summed minutes * pay / daily_minutes)`, never per row. The penalty runs
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
Unbacked timer time pays 0 from 2026-11-08 (focus_project 0.10.0).
## Adding a gate

View File

@ -31,13 +31,9 @@ _warned: set[str] = set()
class FocusEarner(Earner):
"""Counted; ``gaming_minutes``/``shutdown_minutes`` pay a FULL day.
``cut_gaming_minutes``: the gaming base cut, all the replaced gates paid,
LeetCode's free hour too (0.20.0). The full-day gaming pay adds that free
hour once more, so a full day matches the old one (kuhy 2026-11-02, 0.21.0).
The gaming base cut is all of it, LeetCode's free hour too (kuhy 2026-11-02).
"""
cut_gaming_minutes: int = 0
def gaming_for(self, units: int) -> int:
"""Gaming minutes of ``units`` focused minutes, floored."""
return self.capped(units) * self.gaming_minutes // (self.max_units or 1)
@ -70,18 +66,11 @@ def daily_on(project: Any, day: date) -> int: # noqa: ANN401
def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner: # noqa: ANN401
"""The ``focus`` earner paying what ``dropped`` paid, over ``project``.
Gaming: the cut is every dropped gate's full pay; a full day pays that plus
the pure bonuses (no ``penalty_from``) again: 180 + 60 = 240 at 120 min.
"""
cut = sum(e.max_gaming_minutes for e in dropped)
free = tuple(e for e in dropped if e.penalty_from is None)
"""The ``focus`` earner paying what ``dropped`` paid, over ``project``."""
return FocusEarner(
name="focus",
label="Focus",
gaming_minutes=cut + sum(e.max_gaming_minutes for e in free),
cut_gaming_minutes=cut,
gaming_minutes=sum(e.max_gaming_minutes for e in dropped),
shutdown_minutes=sum(
shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped
),

View File

@ -25,7 +25,6 @@ from datetime import UTC, date, datetime
import logging
from typing import TYPE_CHECKING, Final
from earned_time._focus import FocusEarner
from earned_time._ladder import on_ladder, shutdown_ceiling_for
from earned_time._leisure import base_minutes, ceiling_minutes, is_leisure
from earned_time._maturity import confirmed_start, penalty_start
@ -46,16 +45,6 @@ def _today() -> date:
return datetime.now(tz=UTC).astimezone().date()
def _gaming_cut(item: Earner) -> int:
"""What a penalty in force takes off the gaming base: all ``item`` can pay.
A focus earner cuts what the gates it replaced paid; it pays more (0.21.0).
"""
if isinstance(item, FocusEarner):
return item.cut_gaming_minutes
return item.max_gaming_minutes
def _penalised(
item: Earner, day: date, first_credits: Mapping[str, date | None] | None
) -> bool:
@ -128,7 +117,7 @@ def _base(
penalised = [e for e in registry if _penalised(e, target, first_credits)]
return Base(
gaming_minutes=base_minutes(leisure=on)
- sum(_gaming_cut(e) for e in penalised),
- sum(e.max_gaming_minutes for e in penalised),
shutdown_minutes=_shutdown_floor(target, registry, penalised),
)

View File

@ -22,7 +22,7 @@ from earned_time import (
)
from earned_time._focus import focus_earner
from earned_time.tests._focus_fakes import DAY, START, activate, focus, project, row
from earned_time.tests._maturity_rows import ledger_file, signed
from earned_time.tests._maturity_rows import ledger_file
if TYPE_CHECKING:
from pathlib import Path
@ -61,24 +61,20 @@ def test_a_project_that_changes_nothing_leaves_the_registry(
assert earners_for(DAY) is TUTOR_EARNERS
def test_a_full_day_pays_the_cut_plus_the_free_leetcode_hour(
def test_a_full_day_pays_what_the_replaced_gates_paid(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""kuhy 2026-11-02 (0.21.0): 2 gaming minutes per focus minute, 240 a day."""
item = focus(monkeypatch)
assert item.max_units == 120
assert (item.gaming_for(120), item.shutdown_for(120, DAY)) == (240, 180)
assert (item.gaming_for(999), item.shutdown_for(999, DAY)) == (240, 180)
assert (item.max_gaming_minutes, item.gaming_for(-5)) == (240, 0)
assert item.cut_gaming_minutes == 180
assert (item.gaming_for(120), item.shutdown_for(120, DAY)) == (180, 180)
assert (item.gaming_for(999), item.shutdown_for(999, DAY)) == (180, 180)
assert (item.max_gaming_minutes, item.gaming_for(-5)) == (180, 0)
def test_pay_is_floored_on_the_summed_minutes(monkeypatch: pytest.MonkeyPatch) -> None:
item = focus(monkeypatch)
assert [item.gaming_for(n) for n in (1, 2, 3, 7)] == [2, 4, 6, 14]
assert [item.gaming_for(n) for n in (1, 2, 3, 7)] == [1, 3, 4, 10]
assert [item.shutdown_for(n, DAY) for n in (1, 2, 3, 7)] == [1, 3, 4, 10]
odd = focus(monkeypatch, daily_minutes=90)
assert [odd.gaming_for(n) for n in (1, 2, 90)] == [2, 5, 240]
def test_many_small_rows_pay_what_one_row_pays(
@ -90,21 +86,7 @@ def test_many_small_rows_pay_what_one_row_pays(
one.write_text(ledger_file(tmp_path, [row(7, 7)]).read_text(encoding="utf-8"))
units = credit_units(item, many, key_file, DAY)
assert units == credit_units(item, one, key_file, DAY) == 7
assert item.gaming_for(units or 0) == 14
def test_carry_rows_pay_like_own_rows(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
) -> None:
"""focus_project 0.6.0 banks overtime as ``<project>-<YYYYMMDD>-carry`` rows."""
item = focus(monkeypatch)
carry = row(30, 0, carry=True)
carry["entry_id"] = f"hr-{DAY:%Y%m%d}-carry"
top_up = row(5, 0, carry=True)
top_up["entry_id"] = f"hr-{DAY:%Y%m%d}-carry-m35"
rows = [signed(carry), signed(top_up), row(85, 85)]
ledger = ledger_file(tmp_path, rows)
assert credit_units(item, ledger, key_file, DAY) == 120
assert item.gaming_for(units or 0) == 10
def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch) -> None:
@ -113,25 +95,26 @@ def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch)
assert nothing.base.gaming_minutes == 300 - 180
assert nothing.base.shutdown_minutes == 18 * 60
half = resolve({"workout": 0, "focus": 60}, DAY, leisure=False)
assert half.gaming_minutes == 120 + 120
assert half.gaming_minutes == 120 + 90
assert half.shutdown_minutes == 18 * 60 + 90
full = resolve({"workout": 1, "focus": 120}, DAY, leisure=False)
assert full.shutdown_minutes == 23 * 60
assert full.gaming_minutes == 300 - 180 + 240 + 120 == 8 * 60
def test_a_full_project_day_pays_what_the_replaced_gates_paid(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A full project day equals the old full day, LeetCode's free hour too."""
"""Shutdown matches the old full day; gaming loses LeetCode's free hour."""
activate(monkeypatch, project())
done = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60, "focus": 120}
old = resolve(done, DAY, TUTOR_EARNERS, leisure=False)
new = resolve(done, DAY, leisure=False)
assert new.shutdown_minutes == old.shutdown_minutes
# kuhy 2026-11-02 (0.21.0): the day cuts 180 but pays 240, so a full day
# is the old full day (8 h); only an idle day is worse.
assert new.gaming_minutes == old.gaming_minutes == 8 * 60
# kuhy 2026-11-02: LeetCode's bonus hour was free on top of the base; the
# project day cuts it and pays it back, so the full day nets 60 less.
assert new.gaming_minutes == old.gaming_minutes - 60
unsolved = resolve({**done, "leetcode": 0}, DAY, TUTOR_EARNERS, leisure=False)
assert new.gaming_minutes == unsolved.gaming_minutes
def test_an_idle_project_day_also_cuts_the_free_leetcode_hour(
@ -183,5 +166,4 @@ def test_the_path_unit_watches_the_focus_ledger() -> None:
def test_focus_pays_only_replaced_gates_that_exist() -> None:
item = focus_earner(project(), (earned_time.LEETCODE,), DAY)
assert (item.gaming_minutes, item.shutdown_minutes) == (120, 60)
assert item.cut_gaming_minutes == 60
assert (item.gaming_minutes, item.shutdown_minutes) == (60, 60)

View File

@ -1,88 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Regression: a timer-only focus day after the anti-cheat cutover pays 0.
Runs the REAL ``focus_project`` (the sibling package of this monorepo):
timer rows -> harvester -> signed ledger -> ``credit_units``. earned_time
itself does not change; this pins that the gate reaches pay.
"""
from __future__ import annotations
from datetime import date, datetime
import importlib
import json
from pathlib import Path
import sys
from typing import TYPE_CHECKING
import pytest
from earned_time import credit_units, earners_for
if TYPE_CHECKING:
from types import ModuleType
DAY = date(2026, 11, 9) # after focus_project.BACKING_FROM
_SIBLING = Path(__file__).resolve().parents[3] / "focus_project"
def at(hour: int, minute: int) -> float:
return datetime(2026, 11, 9, hour, minute).astimezone().timestamp()
@pytest.fixture
def real_fp(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
) -> ModuleType:
"""The real focus_project on redirected state (HOME, data dir, key)."""
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "data"))
monkeypatch.setenv("FOCUS_PROJECT_KEY", str(key_file))
monkeypatch.setenv("FOCUS_PROJECT_LEETCODE_LEDGER", str(tmp_path / "lc.json"))
monkeypatch.delitem(sys.modules, "focus_project") # the conftest's fake
monkeypatch.syspath_prepend(str(_SIBLING))
fp = importlib.import_module("focus_project")
fp.create_project(
"hr",
name="HR",
start=DAY,
end=DAY,
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
)
return fp
def pay(fp: ModuleType, tmp_path: Path, key_file: Path, *rows: object) -> int | None:
log = tmp_path / "data" / "activity.jsonl"
log.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
harvest = importlib.import_module("focus_project.harvest")
assert harvest.harvest(now=at(23, 0)) == 0
(item,) = (e for e in earners_for(DAY) if e.name == "focus")
return credit_units(item, tmp_path / "data" / "ledger.json", key_file, DAY)
def row(source: str, kind: str, ts: float) -> dict[str, object]:
return {"ts": ts, "source": source, "kind": kind, "project": "hr"}
TIMER = (
row("timer", "start", at(9, 0)),
row("timer", "checkin", at(9, 5)),
row("timer", "stop", at(9, 10)),
)
def test_timer_only_day_pays_zero(
real_fp: ModuleType, tmp_path: Path, key_file: Path
) -> None:
assert date(2026, 11, 8) == real_fp.BACKING_FROM
assert pay(real_fp, tmp_path, key_file, *TIMER) == 0
def test_backed_timer_day_still_pays(
real_fp: ModuleType, tmp_path: Path, key_file: Path
) -> None:
beat = row("tutor", "heartbeat", at(9, 5))
assert pay(real_fp, tmp_path, key_file, *TIMER, beat) == 10

View File

@ -70,8 +70,8 @@ def test_a_target_edit_never_reprices_past_days(
activate(monkeypatch, project(daily_on=edited, daily_minutes=60))
before = earners_for(DAY)[-1]
after = earners_for(DAY + timedelta(days=1))[-1]
assert (before.max_units, before.gaming_for(60)) == (120, 120)
assert (after.max_units, after.gaming_for(60)) == (60, 240)
assert (before.max_units, before.gaming_for(60)) == (120, 90)
assert (after.max_units, after.gaming_for(60)) == (60, 180)
assert (before.shutdown_for(60, DAY), after.shutdown_for(30, DAY)) == (90, 90)

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "earned-time"
version = "0.21.0"
version = "0.20.0"
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
requires-python = ">=3.14"
dependencies = []

View File

@ -16,38 +16,7 @@ Data: `$FOCUS_PROJECT_DATA` or `~/.local/share/focus_project/` (`projects.json`,
`activity.jsonl`). `minutes.py` is owned by the minutes/harvester agent.
Time rules: `minutes_for` clips every interval at "now" (an event stamped ahead of
the clock earns only once the clock reaches it); `record_activity` rejects a `ts`
more than 120 s ahead (ValueError). Timer segments (`start` -> `checkin` ... -> `stop`)
lapse after a 600 s gap; a `tutor`/`heartbeat` is a beat too (0.8.0: a graded check-in card
extends the open segment, never opens one). `end_project(id, day)` rejects a past `day`
more than 120 s ahead (ValueError). `end_project(id, day)` rejects a past `day`
(ValueError); today or later is fine. The harvester pays every project day of the
last 31 days, so late-synced events for older days are credited (idempotent).
Overtime carries over: own minutes past a day's target bank (max 60) and pay on the
next project day; the bank never crosses the project's end. `minutes_for(day)` is the
capped pay figure, `status(day)["minutes"]` the real count (121/120) with `carried_in`,
`carry_out`, `own_minutes`, `counted_minutes`. Carry ledger rows: `<project>-<YYYYMMDD>-carry`
(`detail.carry`), never over `daily_minutes` together with own rows.
Edits only ratchet (0.7.0): `update_project`/`apply_update(id, today=...)` never lowers
today's obligation. A lower `daily_minutes`/`unlock_minutes`, or a gate added to `replaces`,
on a started project holds from TOMORROW (a `history` row ends today with the old values,
`replaces_on(day)`/`daily_on`/`unlock_on` read it); a mixed edit defers as a whole. Raises,
gates removed and edits to a project that has not started hold from today.
`apply_update(...).effective_from` says which. `end` cannot be before today, so an edit never
uncovers today. A NEW project starting today may replace gates and set any targets: it is a
new obligation, not a reduction (but see the floor).
Floor (0.7.1, coordinator decision for kuhy): `daily_minutes >= 30 x replaced gates` (min 30; `book` = `reading`),
enforced on create and update (`daily_floor(replaces)`), so a day cannot be made cheap.
Backed timer (0.10.0, days from `BACKING_FROM` = 2026-11-08): a timer span earns only if a
same-project `tutor`/`sql_drills`/`leetcode` beat (or accepted submission) is in it, 5 min
before it, or bridges it (beats <= 20 min apart). `unbacked_minutes_for(day)` /
`status()["unbacked_minutes"]` show the rest. A beat backs only the spans it touches (the tutor chain makes a graded card back the whole stretch). Known gap: any coach turn backs.
Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`).
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).
Interview prep (`kind="interview"`, 0.9): `create_project(..., kind="interview", test_at=, invite_url=, trips=, weekend_answers=)`;
`end` must be the test day. Budget: 30 min weekdays / 120 Sat-Sun (`daily_on`), exempt from `daily_floor`, unlock clamped.
Milestones are generated, never stored: `milestones(project, now)` -> `rules`, `trip_ask:<sat>`, `phone:<trip start>`, `mock`, `slot`
with states pending/done/missed/late_done/unknown. `owed_lock(now)` = earliest overdue uncompleted one (never on test day; wire it
after focus-guard's sick/free-day stand-down). `status(day)` gains `milestones` and `missed`.
Evidence is append-only (`evidence/<id>.jsonl` + copied files): `record_evidence(id, milestone, kind, value, file=, now=)`
(`file`, `tap`, `score`, `slot`, `slot_confirm`, tutor's `phone_check`). `add_trip` / `answer_weekend` only get stricter (ValueError).
`run_checks(id)` runs the `phone` source (reads the latest `phone_check` row) and latches changes; `register_source` swaps it.

View File

@ -17,50 +17,28 @@ from __future__ import annotations
from focus_project._api import (
active,
create_project,
daily_floor,
end_project,
list_projects,
stands_down,
)
from focus_project._errors import RegistryError
from focus_project._milestones import Milestone, milestones, owed_lock
from focus_project._model import Limits, Project
from focus_project._mutate import add_trip, answer_weekend, record_evidence
from focus_project._trips import Trip
from focus_project._update import Update, apply_update, update_project
from focus_project._update import update_project
from focus_project.activity import record_activity
from focus_project.checks import register_source, run_checks
from focus_project.evidence import Evidence
from focus_project.minutes import minutes_for, unbacked_minutes_for
from focus_project.sources.activity import BACKING_FROM
from focus_project.minutes import minutes_for
from focus_project.status import status
__all__ = [
"BACKING_FROM",
"Evidence",
"Limits",
"Milestone",
"Project",
"RegistryError",
"Trip",
"Update",
"active",
"add_trip",
"answer_weekend",
"apply_update",
"create_project",
"daily_floor",
"end_project",
"list_projects",
"milestones",
"minutes_for",
"owed_lock",
"record_activity",
"record_evidence",
"register_source",
"run_checks",
"stands_down",
"status",
"unbacked_minutes_for",
"update_project",
]

View File

@ -4,26 +4,17 @@
from __future__ import annotations
from dataclasses import replace
from datetime import date # noqa: TC003
import logging
import re
import time
from typing import TYPE_CHECKING
from focus_project import _clock, _interview, _store
from focus_project import _clock, _store
from focus_project._errors import RegistryError
from focus_project._kind import FOCUS
from focus_project._model import GATES, Project, normalize_gate
if TYPE_CHECKING:
from datetime import date, datetime
from focus_project._trips import Trip
_LOG = logging.getLogger(__name__)
_ID = re.compile(r"[a-z0-9][a-z0-9-]*")
MAX_WORK_URL = _interview.MAX_URL
# A project must ask real work: the focus earner pays per minute of ``daily``.
MIN_DAILY_PER_GATE = 30
def list_projects() -> list[Project]:
@ -53,21 +44,10 @@ def stands_down(gate: str, day: date) -> bool:
"focus_project registry unreadable, gate %s stays on: %s", gate, exc
)
return False
return project is not None and project.replaces_gate(gate, day)
def _validate_work_url(url: str) -> None:
"""Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``."""
_interview.check_http_url(url, "work_url")
def daily_floor(replaces: tuple[str, ...]) -> int:
"""The least ``daily_minutes`` a project replacing these gates may ask."""
return MIN_DAILY_PER_GATE * max(1, len(replaces))
return project is not None and project.replaces_gate(gate)
def _validate(project: Project) -> None:
_validate_work_url(project.work_url)
if not _ID.fullmatch(project.id):
msg = f"id {project.id!r} must be a lowercase slug"
raise ValueError(msg)
@ -80,13 +60,8 @@ def _validate(project: Project) -> None:
if not project.replaces or not set(project.replaces) <= GATES:
msg = f"replaces must be a non-empty subset of gates {sorted(GATES)}"
raise ValueError(msg)
_interview.validate(project)
floor = daily_floor(project.replaces)
if project.kind == FOCUS and project.daily_minutes < floor:
msg = (
f"daily_minutes must be at least {floor} "
f"({MIN_DAILY_PER_GATE} per replaced gate)"
)
if project.daily_minutes <= 0:
msg = "daily_minutes must be positive"
raise ValueError(msg)
if not 0 <= project.unlock_minutes <= project.daily_minutes:
msg = "unlock_minutes must be within 0..daily_minutes"
@ -104,20 +79,8 @@ def create_project( # noqa: PLR0913
unlock_minutes: int,
subjects: list[str] | None = None,
site_patterns: list[str] | None = None,
work_url: str = "",
kind: str = FOCUS,
test_at: datetime | None = None,
invite_url: str = "",
trips: list[Trip] | None = None,
weekend_answers: dict[date, bool] | None = None,
) -> Project:
"""Add a project; ValueError on bad input, a duplicate id or an overlap.
``kind="interview"`` takes ``test_at`` (naive local; its date must be
``end``), ``invite_url``, ``trips`` and ``weekend_answers``; an interview
is exempt from :func:`daily_floor` and runs the 30/120 budget.
"""
created_ms = time.time_ns() // 1_000_000
"""Add a project; ValueError on bad input, a duplicate id or an overlap."""
project = Project(
id=project_id,
name=name,
@ -128,16 +91,7 @@ def create_project( # noqa: PLR0913
unlock_minutes=unlock_minutes,
subjects=tuple(subjects or ()),
site_patterns=tuple(site_patterns or ()),
work_url=work_url,
created_ms=created_ms,
kind=kind,
test_at=test_at,
invite_url=invite_url,
trips=tuple(
t if t.entered_ms else replace(t, entered_ms=created_ms)
for t in trips or ()
),
weekend_answers=tuple(sorted((weekend_answers or {}).items())),
created_ms=time.time_ns() // 1_000_000,
)
_validate(project)
with _store.locked():

View File

@ -1,89 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Validation of the interview-only fields."""
from __future__ import annotations
from datetime import date, timedelta
from typing import TYPE_CHECKING
from urllib.parse import urlsplit
from focus_project._kind import FOCUS, KINDS, WEEKDAY_MINUTES
if TYPE_CHECKING:
from focus_project._model import Project
MAX_URL = 2000
SATURDAY = 5
def check_http_url(url: str, label: str) -> None:
"""Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``."""
if not url:
return
parts = urlsplit(url) if url.isprintable() and " " not in url else None
if (
parts is None
or len(url) > MAX_URL
or parts.scheme not in {"http", "https"}
or not parts.netloc
):
msg = f"{label} must be an http(s) URL without spaces (or empty)"
raise ValueError(msg)
def saturdays(project: Project) -> list[date]:
"""The Saturdays whose weekend lies inside D1..T-1."""
if project.test_at is None:
return []
last = project.test_at.date() - timedelta(days=1)
day = project.start + timedelta(days=(SATURDAY - project.start.weekday()) % 7)
found: list[date] = []
while day <= last:
found.append(day)
day += timedelta(days=7)
return found
def _has_interview_fields(project: Project) -> bool:
return bool(
project.test_at
or project.invite_url
or project.trips
or project.weekend_answers
)
def _validate_interview(project: Project) -> None:
if project.test_at is None or project.test_at.tzinfo is not None:
msg = "an interview needs test_at as a naive local datetime"
raise ValueError(msg)
test_day = project.test_at.date()
if project.end != test_day:
msg = f"end must be the test day {test_day}"
raise ValueError(msg)
if project.daily_minutes < WEEKDAY_MINUTES:
msg = f"daily_minutes must be at least {WEEKDAY_MINUTES}"
raise ValueError(msg)
for trip in project.trips:
if trip.end < trip.start or trip.start > test_day:
msg = f"trip {trip.start}..{trip.end} must start by the test day"
raise ValueError(msg)
weekends = saturdays(project)
for day, _ in project.weekend_answers:
if day not in weekends:
msg = f"{day} is not a weekend inside the project before the test day"
raise ValueError(msg)
def validate(project: Project) -> None:
"""ValueError unless the kind and its interview fields are coherent."""
if project.kind not in KINDS:
msg = f"kind must be one of {sorted(KINDS)}"
raise ValueError(msg)
check_http_url(project.invite_url, "invite_url")
if project.kind == FOCUS:
if _has_interview_fields(project):
msg = "test_at, invite_url, trips, weekend_answers need kind='interview'"
raise ValueError(msg)
return
_validate_interview(project)

View File

@ -1,23 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Project kinds and the interview budget (the one place the numbers live)."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import date
FOCUS = "focus"
INTERVIEW = "interview"
KINDS = frozenset({FOCUS, INTERVIEW})
# kuhy, 2026-11-10: near-zero weekday energy, weekends are for learning.
WEEKDAY_MINUTES = 30
WEEKEND_MINUTES = 120
_FIRST_WEEKEND_DAY = 5 # Saturday
def interview_minutes(day: date) -> int:
"""The daily target of an interview project on ``day`` (Sat/Sun = 120)."""
return WEEKEND_MINUTES if day.weekday() >= _FIRST_WEEKEND_DAY else WEEKDAY_MINUTES

View File

@ -1,235 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Interview milestones: generated from the project, judged from evidence.
Nothing here is stored or editable. ``due`` and the state are recomputed from
the project (kind, test_at, trips, weekend answers) and the append-only
evidence rows, so a milestone cannot be moved or ticked off by editing a row.
Failure modes (R8): an unanswered trip question reads as a trip (fail
closed); a check that could not run is ``unknown`` and counts as not done
(fail closed); an unreadable registry or evidence file arms no lock (fail
open, logged), matching focus-guard's "unreadable registry" rule.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
import logging
from typing import TYPE_CHECKING, Any, Literal
from focus_project import _api, _interview, evidence
from focus_project._errors import RegistryError
from focus_project._kind import INTERVIEW
from focus_project.sources import activity
if TYPE_CHECKING:
from collections.abc import Callable
from focus_project._model import Project
_LOG = logging.getLogger(__name__)
State = Literal["pending", "done", "missed", "late_done", "unknown"]
PHONE_LEAD = timedelta(hours=12)
MOCK_SESSION_S = 90 * 60
_RED = frozenset({"missed", "unknown"})
@dataclass(frozen=True)
class Milestone:
"""One generated obligation of an interview project."""
id: str
due: datetime
state: State
completed: bool
@property
def red(self) -> bool:
"""Past due and not done (also: the check could not run)."""
return self.state in _RED
def to_dict(self) -> dict[str, Any]:
"""The status() row: id, due ISO, state."""
return {"id": self.id, "due": self.due.isoformat(), "state": self.state}
def _at(day: date, hour: int, minute: int = 0) -> datetime:
return datetime.combine(day, time(hour, minute))
def _trip_due(trip_start: date, entered_ms: int) -> datetime:
"""Start - 12 h, or the moment it was entered if that left < 12 h."""
nominal = _at(trip_start, 0) - PHONE_LEAD
if entered_ms > 0:
entered = evidence.from_ms(entered_ms)
if nominal - entered < PHONE_LEAD:
return entered
return nominal
def _is_trip(
*,
answered: bool | None,
answer_ms: int | None,
ask_due: datetime,
phone_due: datetime,
now: datetime,
) -> bool:
"""Whether the weekend counts as a trip (fail closed).
Unanswered once the question is due = a trip. A no-trip answer given
only after the phone prep was already due cannot erase that overdue
milestone: the answer row's own timestamp decides, not a stored flag.
"""
if answered is None:
return now >= ask_due
if answered:
return True
return answer_ms is not None and answer_ms >= evidence.to_ms(phone_due)
def plan(
project: Project, test_day: date, now: datetime, rows: list[evidence.Evidence]
) -> dict[str, datetime]:
"""Milestone id -> due; ``project`` must be a validated interview."""
dues = {
"rules": _at(project.start, 23, 59),
"mock": _at(test_day - timedelta(days=2), 23, 59),
"slot": _at(test_day - timedelta(days=1), 12),
}
answers = dict(project.weekend_answers)
for sat in _interview.saturdays(project):
ask_due = _at(sat - timedelta(days=2), 23, 59)
phone_due = _at(sat, 0) - PHONE_LEAD
dues[f"trip_ask:{sat}"] = ask_due
covered = any(t.covers(sat) for t in project.trips)
row = evidence.first(rows, f"trip_ask:{sat}", "answer")
answer_ms = None if row is None else row.ms
if not covered and _is_trip(
answered=answers.get(sat),
answer_ms=answer_ms,
ask_due=ask_due,
phone_due=phone_due,
now=now,
):
dues[f"phone:{sat}"] = phone_due
for trip_row in project.trips:
dues[f"phone:{trip_row.start}"] = _trip_due(trip_row.start, trip_row.entered_ms)
return dues
def _when(row: evidence.Evidence | None) -> datetime | None:
return None if row is None else row.at
Rows = list[evidence.Evidence]
Completion = tuple[datetime | None, bool]
def _by_evidence(kind: str) -> Callable[[str, Rows, Project], Completion]:
"""Done at the first evidence row of ``kind`` (rules file, mock score)."""
def judge(mid: str, rows: Rows, _project: Project) -> Completion:
return _when(evidence.first(rows, mid, kind)), False
return judge
def _mock(mid: str, rows: Rows, project: Project) -> Completion:
"""Done at the first score row that follows a >= 90 min focus session.
The session (contiguous beats, :func:`activity.sessions`) must END at or
before the score row; a score with no such session completes nothing.
"""
ends = [hi for lo, hi in activity.sessions(project.id) if hi - lo >= MOCK_SESSION_S]
for row in rows:
if (row.milestone, row.kind) == (mid, "score") and any(
end <= row.ms / 1000 for end in ends
):
return row.at, False
return None, False
def _trip_ask(mid: str, rows: Rows, project: Project) -> Completion:
"""Done at the answer row; an answer with no row was given at creation."""
if date.fromisoformat(mid.partition(":")[2]) not in dict(project.weekend_answers):
return None, False
return _when(evidence.first(rows, mid, "answer")) or evidence.from_ms(0), False
def _slot(mid: str, rows: Rows, _project: Project) -> Completion:
"""Done once the latest slot entry has a confirm tap at or after it."""
slot = evidence.last(rows, mid, "slot")
if slot is None:
return None, False
confirm = evidence.first(rows, mid, "slot_confirm", since_ms=slot.ms)
return (None if confirm is None else max(slot.at, confirm.at)), False
def _phone(mid: str, rows: Rows, _project: Project) -> Completion:
"""Done when the offline tap and a passing auto check both exist.
A tap with no verdict at all (or a dark source) is ``unknown``.
"""
tap = evidence.first(rows, mid, "tap")
passed, latest, _ = evidence.check_summary(rows, mid)
if tap is not None and passed is not None:
return max(tap.at, evidence.from_ms(passed)), False
return None, tap is not None and latest is None
_JUDGES = {
"rules": _by_evidence("file"),
"mock": _mock,
"trip_ask": _trip_ask,
"slot": _slot,
"phone": _phone,
}
def _judge(
mid: str, due: datetime, rows: Rows, project: Project, now: datetime
) -> Milestone:
done_at, unknown = _JUDGES[mid.partition(":")[0]](mid, rows, project)
if done_at is not None:
state: State = "done" if done_at <= due else "late_done"
return Milestone(mid, due, state, completed=True)
if now < due:
return Milestone(mid, due, "pending", completed=False)
return Milestone(mid, due, "unknown" if unknown else "missed", completed=False)
def milestones(project: Project, now: datetime) -> list[Milestone]:
"""The project's milestones at ``now`` (naive local), earliest due first.
Empty for a focus project. May raise OSError reading the evidence file.
"""
if project.kind != INTERVIEW or project.test_at is None:
return []
rows = evidence.read(project.id)
dues = plan(project, project.test_at.date(), now, rows)
found = [_judge(mid, due, rows, project, now) for mid, due in dues.items()]
return sorted(found, key=lambda m: (m.due, m.id))
def owed_lock(now: datetime) -> Milestone | None:
"""The earliest overdue, uncompleted milestone that locks, or None.
Locks (missed or unknown) from the moment it is due, every day of the
project except the test day itself, where nothing locks. It does not
consult sickdays/freedays: focus-guard's ``_off(today)`` precondition
already stands the lock down on those days, so wire this after it.
Unreadable state arms nothing (fail open, logged).
"""
try:
project = _api.active(now.date())
if project is None or project.test_at is None:
return None
if now.date() >= project.test_at.date():
return None
owed = [m for m in milestones(project, now) if not m.completed and now >= m.due]
except (RegistryError, OSError) as exc:
_LOG.warning("interview state unreadable, no milestone lock: %s", exc)
return None
return owed[0] if owed else None

View File

@ -4,12 +4,10 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
from datetime import date
from typing import Any
from focus_project._errors import RegistryError
from focus_project._kind import FOCUS, INTERVIEW, interview_minutes
from focus_project._trips import Trip
GATES = frozenset({"leetcode", "reading", "tutor"})
_ALIASES = {"book": "reading"}
@ -20,17 +18,6 @@ def normalize_gate(gate: str) -> str:
return _ALIASES.get(gate, gate)
def _answers(raw: dict[str, Any] | None) -> tuple[tuple[date, bool], ...]:
"""Parse the saturday -> bool map; a non-bool answer is a bad row."""
pairs = []
for key, value in (raw or {}).items():
if not isinstance(value, bool):
msg = f"weekend answer for {key} is not true/false"
raise TypeError(msg)
pairs.append((date.fromisoformat(key), value))
return tuple(sorted(pairs))
@dataclass(frozen=True)
class Limits:
"""The minute targets that held up to and including ``until``."""
@ -38,29 +25,22 @@ class Limits:
until: date
daily_minutes: int
unlock_minutes: int
# None = a row from before 0.7.0: the project's current gates apply.
replaces: tuple[str, ...] | None = None
def to_dict(self) -> dict[str, Any]:
"""The history row in projects.json."""
row: dict[str, Any] = {
return {
"until": self.until.isoformat(),
"daily_minutes": self.daily_minutes,
"unlock_minutes": self.unlock_minutes,
}
if self.replaces is not None:
row["replaces"] = list(self.replaces)
return row
@classmethod
def from_dict(cls, row: Any) -> Limits: # noqa: ANN401
"""Parse a history row (errors surface as RegistryError upstream)."""
gates = row.get("replaces")
return cls(
until=date.fromisoformat(row["until"]),
daily_minutes=int(row["daily_minutes"]),
unlock_minutes=int(row["unlock_minutes"]),
replaces=None if gates is None else tuple(gates),
)
@ -79,39 +59,21 @@ class Project:
site_patterns: tuple[str, ...] = ()
ended_on: date | None = None
created_ms: int = 0
# Where the work happens (http/https); empty = the first site pattern.
work_url: str = ""
# Earlier targets, oldest first: an edit applies from its day, never back.
history: tuple[Limits, ...] = ()
# "focus" (every pre-interview row) or "interview"; the rest is interview-only.
kind: str = FOCUS
# Local naive datetime of the test; its date is the project's last day.
test_at: datetime | None = None
invite_url: str = ""
trips: tuple[Trip, ...] = ()
# Saturday -> True (trip that weekend) / False (no trip); sorted pairs.
weekend_answers: tuple[tuple[date, bool], ...] = ()
def _limits_on(self, day: date) -> Limits | None:
return next((h for h in self.history if day <= h.until), None)
def daily_on(self, day: date) -> int:
"""The daily target that held on ``day``."""
if self.kind == INTERVIEW:
return interview_minutes(day)
past = self._limits_on(day)
return self.daily_minutes if past is None else past.daily_minutes
def unlock_on(self, day: date) -> int:
"""The unlock target that held on ``day`` (clamped for an interview)."""
"""The unlock target that held on ``day``."""
past = self._limits_on(day)
held = self.unlock_minutes if past is None else past.unlock_minutes
return min(held, interview_minutes(day)) if self.kind == INTERVIEW else held
def replaces_on(self, day: date) -> tuple[str, ...]:
"""The gates this project switched off on ``day``."""
past = self._limits_on(day)
return self.replaces if past is None or past.replaces is None else past.replaces
return self.unlock_minutes if past is None else past.unlock_minutes
@property
def last_day(self) -> date:
@ -122,13 +84,13 @@ class Project:
"""Whether this project is active on ``day``."""
return self.start <= day <= self.last_day
def replaces_gate(self, gate: str, day: date) -> bool:
"""Whether this project switches ``gate`` off on ``day``."""
return normalize_gate(gate) in self.replaces_on(day)
def replaces_gate(self, gate: str) -> bool:
"""Whether this project switches ``gate`` off."""
return normalize_gate(gate) in self.replaces
def to_dict(self) -> dict[str, Any]:
"""The projects.json row."""
row: dict[str, Any] = {
return {
"id": self.id,
"name": self.name,
"start": self.start.isoformat(),
@ -140,25 +102,14 @@ class Project:
"site_patterns": list(self.site_patterns),
"ended_on": self.ended_on.isoformat() if self.ended_on else None,
"created_ms": self.created_ms,
"work_url": self.work_url,
"history": [h.to_dict() for h in self.history],
"kind": self.kind,
}
if self.kind == INTERVIEW:
row["test_at"] = self.test_at.isoformat() if self.test_at else None
row["invite_url"] = self.invite_url
row["trips"] = [t.to_dict() for t in self.trips]
row["weekend_answers"] = {
day.isoformat(): trip for day, trip in self.weekend_answers
}
return row
@classmethod
def from_dict(cls, row: Any) -> Project: # noqa: ANN401
"""Parse a registry row, or raise :class:`RegistryError`."""
try:
ended = row.get("ended_on")
test_at = row.get("test_at")
return cls(
id=str(row["id"]),
name=str(row["name"]),
@ -171,13 +122,7 @@ class Project:
site_patterns=tuple(row.get("site_patterns", ())),
ended_on=date.fromisoformat(ended) if ended else None,
created_ms=int(row.get("created_ms", 0)),
work_url=str(row.get("work_url") or ""),
history=tuple(Limits.from_dict(h) for h in row.get("history", ())),
kind=str(row.get("kind") or FOCUS),
test_at=datetime.fromisoformat(test_at) if test_at else None,
invite_url=str(row.get("invite_url") or ""),
trips=tuple(Trip.from_dict(t) for t in row.get("trips", ())),
weekend_answers=_answers(row.get("weekend_answers")),
)
except (KeyError, TypeError, ValueError, AttributeError) as exc:
msg = f"bad project row {row!r}: {exc}"

View File

@ -1,178 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Interview mutations: trips, weekend answers, evidence. Stricter only."""
from __future__ import annotations
from dataclasses import replace
from datetime import date, datetime
import math
from typing import TYPE_CHECKING
from focus_project import _interview, _milestones, _store, evidence
from focus_project._kind import INTERVIEW
from focus_project._trips import Trip, add
if TYPE_CHECKING:
from pathlib import Path
from focus_project._model import Project
# Which evidence kinds each milestone family accepts from a caller.
# ``check`` and ``answer`` are internal: written by run_checks/answer_weekend.
_ACCEPTS = {
"rules": {"file"},
"mock": {"score"},
"slot": {"slot", "slot_confirm"},
"phone": {"tap"},
}
# tutor's worker reports phone readiness under the bare id "phone".
_PHONE_REPORT = "phone"
def local_now(now: datetime | None) -> datetime:
"""``now`` or the clock's local time."""
return evidence.clock_now() if now is None else now
def find(existing: list[Project], project_id: str) -> tuple[int, Project]:
"""Index and interview project; KeyError unknown, ValueError focus kind."""
for i, project in enumerate(existing):
if project.id == project_id:
if project.kind != INTERVIEW:
msg = f"{project_id!r} is not an interview project (no milestones)"
raise ValueError(msg)
return i, project
raise KeyError(project_id)
def add_trip(
project_id: str, start: date, end: date, *, now: datetime | None = None
) -> Project:
"""Announce a trip; it may only grow (see :func:`focus_project._trips.add`)."""
stamp = local_now(now)
with _store.locked():
existing = _store.read_all()
i, project = find(existing, project_id)
trips = add(project.trips, Trip(start, end, evidence.to_ms(stamp)))
updated = replace(project, trips=trips)
_interview.validate(updated)
existing[i] = updated
_store.write_all(existing)
return updated
def answer_weekend(
project_id: str, saturday: date, *, trip: bool, now: datetime | None = None
) -> Project:
"""Answer the trip question of a weekend; True never flips back to False."""
stamp = local_now(now)
with _store.locked():
existing = _store.read_all()
i, project = find(existing, project_id)
if saturday not in _interview.saturdays(project):
msg = f"{saturday} is not a weekend of {project_id!r} before its test day"
raise ValueError(msg)
answers = dict(project.weekend_answers)
current = answers.get(saturday)
if current is trip:
return project
if current is True:
msg = "a trip answer may only get stricter: it stays True"
raise ValueError(msg)
if not trip and any(t.covers(saturday) for t in project.trips):
msg = f"an announced trip covers {saturday}: it cannot be a no-trip weekend"
raise ValueError(msg)
updated = replace(
project, weekend_answers=tuple(sorted({**answers, saturday: trip}.items()))
)
# Evidence first: a crash between the two leaves the question open
# (fail closed), never an answer with no time.
evidence.append(project_id, f"trip_ask:{saturday}", "answer", trip, stamp)
existing[i] = updated
_store.write_all(existing)
return updated
def _number(value: object) -> float:
if (
isinstance(value, bool)
or not isinstance(value, int | float)
or not math.isfinite(value)
or value < 0
):
msg = "a score must be a finite, non-negative number"
raise ValueError(msg)
return value
def _count(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
msg = f"{label} must be a non-negative integer"
raise ValueError(msg)
return value
def _clean(
project_id: str, kind: str, value: object, file: Path | None, stamp: datetime
) -> object:
"""The value to store for ``kind``; ValueError if it is not acceptable."""
if kind == "file":
if file is None or not file.is_file() or not file.stat().st_size:
msg = "a non-empty file is required"
raise ValueError(msg)
return evidence.store_file(project_id, file, stamp)
if kind == "score":
return _number(value)
if kind == "slot":
moment = datetime.fromisoformat(str(value)) if isinstance(value, str) else None
if moment is None or moment.tzinfo is not None:
msg = "slot must be a naive local ISO datetime"
raise ValueError(msg)
return moment.isoformat()
if kind == "phone_check":
if not isinstance(value, dict) or not isinstance(value.get("plan"), bool):
msg = "phone_check needs {plan: bool, due: int, seen_ms: int}"
raise ValueError(msg)
return {
"plan": value["plan"],
"due": _count(value.get("due"), "due"),
"seen_ms": _count(value.get("seen_ms"), "seen_ms"),
}
if kind == "slot_confirm" and value not in {True, None}:
msg = "slot_confirm is a tap: value True"
raise ValueError(msg)
return True # tap, slot_confirm
def record_evidence( # noqa: PLR0913
project_id: str,
milestone: str,
kind: str,
value: object = None,
*,
file: Path | None = None,
now: datetime | None = None,
) -> evidence.Evidence:
"""Append evidence for a milestone; ValueError if it does not fit.
Kinds: ``file`` (rules, copied from ``file``), ``tap`` (phone:*),
``score`` (mock), ``slot`` (ISO datetime) and ``slot_confirm`` (slot),
``phone_check`` ({plan, due, seen_ms}, milestone "phone", from tutor).
There is no way to delete a row.
"""
stamp = local_now(now)
_, project = find(_store.read_all(), project_id)
if milestone == _PHONE_REPORT:
accepted = {"phone_check"}
else:
known = {m.id for m in _milestones.milestones(project, stamp)}
accepted = _ACCEPTS.get(milestone.partition(":")[0], set())
if milestone not in known:
msg = f"{milestone!r} is not a milestone of {project_id!r}"
raise ValueError(msg)
if kind not in accepted:
msg = f"{milestone!r} takes evidence kinds {sorted(accepted)}, not {kind!r}"
raise ValueError(msg)
return evidence.append(
project_id, milestone, kind, _clean(project_id, kind, value, file, stamp), stamp
)

View File

@ -1,31 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""A project day's own worked minutes: the union of every source's intervals."""
from __future__ import annotations
from typing import TYPE_CHECKING
from focus_project import sources
if TYPE_CHECKING:
from datetime import date
from focus_project._model import Project
def union_seconds(spans: list[sources.Interval]) -> float:
"""Total length of the union of ``spans``."""
total = 0.0
end = float("-inf")
for lo, hi in sorted(spans):
if hi <= end:
continue
total += hi - max(lo, end)
end = hi
return total
def own_minutes_of(project: Project, day: date, moment: float) -> int:
"""Whole minutes worked on ``day``, uncapped, nothing after ``moment``."""
spans = sources.clip(sources.collect(day, project), (float("-inf"), moment))
return int(union_seconds(spans) // 60)

View File

@ -30,18 +30,3 @@ def activity_path() -> Path:
def lock_path() -> Path:
"""The registry's writer lock."""
return data_dir() / "projects.lock"
def evidence_dir() -> Path:
"""Interview-prep evidence: one jsonl per project plus copied files."""
return data_dir() / "evidence"
def evidence_path(project_id: str) -> Path:
"""The append-only evidence rows of one project."""
return evidence_dir() / f"{project_id}.jsonl"
def evidence_files_dir(project_id: str) -> Path:
"""Where evidence files (screenshots) of one project are copied."""
return evidence_dir() / project_id

View File

@ -1,61 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Trips: days away from the PC. They can only grow (ratchet)."""
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import date
from typing import Any
@dataclass(frozen=True)
class Trip:
"""An inclusive away range; ``entered_ms`` is when it was announced."""
start: date
end: date
entered_ms: int = 0
def covers(self, day: date) -> bool:
"""Whether ``day`` is inside the trip."""
return self.start <= day <= self.end
def to_dict(self) -> dict[str, Any]:
"""The projects.json row."""
return {
"start": self.start.isoformat(),
"end": self.end.isoformat(),
"entered_ms": self.entered_ms,
}
@classmethod
def from_dict(cls, row: dict[str, Any]) -> Trip:
"""Parse a trip row (errors surface as RegistryError upstream)."""
return cls(
start=date.fromisoformat(row["start"]),
end=date.fromisoformat(row["end"]),
entered_ms=int(row.get("entered_ms", 0)),
)
def add(trips: tuple[Trip, ...], new: Trip) -> tuple[Trip, ...]:
"""``trips`` plus ``new``; ValueError if that would loosen an old trip.
A new range touching an old trip must cover it: it replaces the old one
(keeping its entry time when the start did not move earlier). Anything
that starts later or ends earlier than what is on file is refused.
"""
kept: list[Trip] = []
for old in trips:
if old.start > new.end or new.start > old.end:
kept.append(old)
elif new.start <= old.start and new.end >= old.end:
if new.start == old.start:
new = replace(new, entered_ms=old.entered_ms)
else:
msg = (
f"a trip may only get stricter: pass a range that covers "
f"{old.start}..{old.end}"
)
raise ValueError(msg)
return tuple(sorted([*kept, new], key=lambda t: t.start))

View File

@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from dataclasses import replace
from datetime import date, timedelta
from typing import Any
@ -21,13 +21,12 @@ _EDITABLE = frozenset(
"unlock_minutes",
"subjects",
"site_patterns",
"work_url",
}
)
_TUPLES = ("subjects", "site_patterns")
def _apply(project: Project, changes: dict[str, Any], today: date) -> Update:
def _apply(project: Project, changes: dict[str, Any], today: date) -> Project:
"""The edited project; ValueError for what an edit may not touch."""
if project.ended_on is not None:
msg = f"project {project.id!r} ended on {project.ended_on}"
@ -50,81 +49,37 @@ def _apply(project: Project, changes: dict[str, Any], today: date) -> Update:
if "end" in changes and changes["end"] < today:
msg = f"end {changes['end']} is in the past"
raise ValueError(msg)
return _schedule(project, replace(project, **fields), today)
edited = replace(project, **fields)
return _keep_past_limits(project, edited, today)
def _held(project: Project, day: date) -> Limits:
"""What held on ``day``: both targets and the replaced gates."""
return Limits(
day,
project.daily_on(day),
project.unlock_on(day),
project.replaces_on(day),
)
def _reduces(held: Limits, new: Project) -> bool:
"""Whether ``new`` asks less than ``held``: lower target or a gate more."""
return (
new.daily_minutes < held.daily_minutes
or new.unlock_minutes < held.unlock_minutes
or not set(new.replaces) <= set(held.replaces or ())
)
def _schedule(old: Project, new: Project, today: date) -> Update:
"""Date the edit: a reduction of a started project waits for tomorrow.
A raise (or a project that has not started) applies from ``today``; the
days before keep what held. Anything that lowers a target or switches a
gate off defers the whole edit one day, so no edit can undo a lock or
cheapen the pay of a day already running.
"""
if (old.daily_minutes, old.unlock_minutes, old.replaces) == (
def _keep_past_limits(old: Project, new: Project, today: date) -> Project:
"""Freeze the old targets for the days before ``today`` if they changed."""
changed = (old.daily_minutes, old.unlock_minutes) != (
new.daily_minutes,
new.unlock_minutes,
new.replaces,
):
return Update(new, today)
deferred = today >= old.start and _reduces(_held(old, today), new)
first = today + timedelta(days=1) if deferred else today
last = first - timedelta(days=1)
history = [h for h in old.history if h.until < first]
held = _held(old, last)
same = (held.daily_minutes, held.unlock_minutes, held.replaces) == (
new.daily_minutes,
new.unlock_minutes,
new.replaces,
)
if last >= old.start and not same and not (history and history[-1].until == last):
history.append(held)
return Update(replace(new, history=tuple(history)), first)
cutoff = today - timedelta(days=1)
if not changed or cutoff < old.start:
return new
if old.history and old.history[-1].until == cutoff:
return new
row = Limits(cutoff, old.daily_minutes, old.unlock_minutes)
return replace(new, history=(*old.history, row))
@dataclass(frozen=True)
class Update:
"""An edited project and the first day its new targets and gates hold."""
project: Project
effective_from: date
def apply_update(
def update_project(
project_id: str,
*,
today: date | None = None,
**changes: Any, # noqa: ANN401
) -> Update:
) -> Project:
"""Edit ``project_id``; ValueError on bad input, overlap or a past edit.
Editable: name, start, end, replaces, daily_minutes, unlock_minutes,
subjects, site_patterns, work_url. A started project's start cannot move.
An edit never lowers today's obligation (0.7.0): a lower daily or unlock
target, or a gate added to ``replaces``, holds from tomorrow, the earlier
days (today included) keep theirs. Raises, gates removed and edits to a
project that has not started hold from ``today`` (default: now). The
result says which: ``effective_from``. A mixed edit defers as a whole.
KeyError for an unknown id.
subjects, site_patterns. A started project's start cannot move; changed
minute targets apply from ``today`` (default: now) on, earlier days keep
theirs. KeyError for an unknown id.
"""
unknown = sorted(set(changes) - _EDITABLE)
if unknown:
@ -136,8 +91,7 @@ def apply_update(
for i, project in enumerate(existing):
if project.id != project_id:
continue
done = _apply(project, changes, today)
edited = done.project
edited = _apply(project, changes, today)
_validate(edited)
for other in existing:
if other.id != project_id and (
@ -147,15 +101,5 @@ def apply_update(
raise ValueError(msg)
existing[i] = edited
_store.write_all(existing)
return done
return edited
raise KeyError(project_id)
def update_project(
project_id: str,
*,
today: date | None = None,
**changes: Any, # noqa: ANN401
) -> Project:
""":func:`apply_update` returning just the project."""
return apply_update(project_id, today=today, **changes).project

View File

@ -1,75 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Overtime carries over: minutes past a day's target bank for the next day.
Contract (kuhy, 2026-11-02): own minutes beyond ``daily_on(day)`` are banked
(at most :data:`BANK_CAP`) and pay on the NEXT project day. The bank never
crosses the project's last day (what is left then expires) and a carry never
pays on a day with no project. The balance is derived from the activity
record, never stored, so it cannot drift; a project day's counted minutes
are ``min(daily, own + carried_in)``. The bank is spent first, so what a day
takes from it is fixed when the day starts and only own minutes grow.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
import logging
from typing import Final
from focus_project import _api, _clock
from focus_project._errors import RegistryError
from focus_project._own import own_minutes_of
BANK_CAP: Final = 60
_LOG = logging.getLogger(__name__)
@dataclass(frozen=True)
class Balance:
"""One project day: worked minutes, bank taken in, bank left for tomorrow."""
own: int
carried_in: int
carry_out: int
def paid_bank(self, daily: int) -> int:
"""Minutes of the day paid from the bank (fixed at the day's start)."""
return min(self.carried_in, daily)
def paid_own(self, daily: int) -> int:
"""Minutes of the day paid from own work, after the bank."""
return min(self.own, daily - self.paid_bank(daily))
def counted(self, daily: int) -> int:
"""Minutes that count on the day: own plus carried, capped at ``daily``."""
return min(daily, self.own + self.carried_in)
_NONE: Final = Balance(0, 0, 0)
def balance(day: date, now: float | None = None) -> Balance:
"""The balance of ``day`` (zeros with no project or an unreadable registry)."""
moment = _clock.now() if now is None else now
try:
project = _api.active(day)
if project is None:
return _NONE
bank = 0
cursor = project.start
while True:
own = own_minutes_of(project, cursor, moment)
total = own + bank
left = min(BANK_CAP, max(0, total - project.daily_on(cursor)))
if cursor >= day:
return Balance(own, bank, left)
bank, cursor = left, cursor + timedelta(days=1)
except (RegistryError, OSError) as exc:
_LOG.warning("focus_project registry unreadable, nothing banked: %s", exc)
return _NONE
def own_minutes(day: date, now: float | None = None) -> int:
"""Minutes worked on ``day``, not capped at the target (0 with no project)."""
return balance(day, now).own

View File

@ -1,106 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Pluggable check sources for the auto-checked milestones, and latching.
A source is ``fn(project, now) -> True | False | None``; ``None`` means "could
not check" (logged) and counts as not done. Only ``phone`` is auto-checked
(the calendar slot check was dropped, 2026-11-10: ``slot`` is two taps).
:func:`run_checks` is the only place sources run: status()/milestones() stay
cheap and read the latched ``check`` rows it appends (only on a change).
"""
from __future__ import annotations
from collections.abc import Callable
from datetime import datetime, timedelta
import logging
from focus_project import _milestones, _mutate, _store, evidence
from focus_project._model import Project
_LOG = logging.getLogger(__name__)
CheckSource = Callable[[Project, datetime], bool | None]
DAY_MS = 24 * 3600 * 1000
# A phone check starts 24 h before the milestone is due.
WINDOW = timedelta(hours=24)
def _report_ok(value: object, report_ms: int) -> bool:
"""A plan exists, cards are due, the phone was seen <= 24 h before the report."""
if not isinstance(value, dict):
return False
try:
return bool(
value["plan"] is True
and int(value["due"]) > 0
and report_ms - int(value["seen_ms"]) <= DAY_MS
)
except KeyError, TypeError, ValueError:
return False
def phone_source(project: Project, now: datetime) -> bool | None:
"""Read tutor's latest ``phone_check`` report (<= 24 h old).
No report, a stale one or one stamped ahead of the clock = ``None``
(the worker is not reporting). A fresh one passes only if the plan
exists, cards are due and the phone was seen within 24 h of the report.
"""
report = evidence.last(evidence.read(project.id), "phone", "phone_check")
if report is None:
_LOG.warning("phone check: no tutor report for %s", project.id)
return None
if not 0 <= evidence.to_ms(now) - report.ms <= DAY_MS:
_LOG.warning("phone check: tutor report for %s is stale or ahead", project.id)
return None
return _report_ok(report.value, report.ms)
SOURCES: dict[str, CheckSource] = {"phone": phone_source}
def register_source(name: str, source: CheckSource) -> None:
"""Replace the callable behind an auto check (``phone``)."""
if name not in SOURCES:
msg = f"unknown check source {name!r}; known: {sorted(SOURCES)}"
raise ValueError(msg)
SOURCES[name] = source
def _run(project: Project, now: datetime) -> bool | None:
try:
return SOURCES["phone"](project, now)
except Exception as exc: # noqa: BLE001 (pluggable code: any failure = unknown)
_LOG.warning("phone check source failed: %s", exc)
return None
def run_checks(
project_id: str, *, now: datetime | None = None
) -> dict[str, bool | None]:
"""Run the auto checks of open phone milestones; latch what changed.
Returns milestone id -> verdict for the ones checked (inside their
24 h window, not yet done). KeyError for an unknown project; a focus
project has nothing to check.
"""
stamp = _mutate.local_now(now)
project = next((p for p in _store.read_all() if p.id == project_id), None)
if project is None:
raise KeyError(project_id)
open_ids = [
m.id
for m in _milestones.milestones(project, stamp)
if m.id.startswith("phone:")
and m.state not in {"done", "late_done"}
and stamp >= m.due - WINDOW
]
if not open_ids:
return {}
verdict = _run(project, stamp)
rows = evidence.read(project_id)
for mid in open_ids:
_, latest, seen = evidence.check_summary(rows, mid)
if verdict != latest and not (verdict is None and not seen):
evidence.append(project_id, mid, "check", verdict, stamp)
return dict.fromkeys(open_ids, verdict)

View File

@ -1,159 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The interview evidence store: append-only rows plus copied files.
One jsonl per project (``{ms, milestone, kind, value}``) under the
:mod:`focus_project._paths` seam. There is deliberately no delete or rewrite
function: a milestone once evidenced stays evidenced.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import itertools
import json
import logging
import os
import re
import shutil
from typing import TYPE_CHECKING
from focus_project import _clock, _paths
if TYPE_CHECKING:
from pathlib import Path
_LOG = logging.getLogger(__name__)
@dataclass(frozen=True)
class Evidence:
"""One evidence row."""
ms: int
milestone: str
kind: str
value: object
@property
def at(self) -> datetime:
"""When it was recorded, as a naive local datetime."""
return from_ms(self.ms)
def to_ms(moment: datetime) -> int:
"""Unix milliseconds of a naive local datetime."""
return int(moment.timestamp() * 1000)
def from_ms(ms: int) -> datetime:
"""Naive local datetime of unix milliseconds."""
return datetime.fromtimestamp(ms / 1000, tz=UTC).astimezone().replace(tzinfo=None)
def clock_now() -> datetime:
"""The clock seam (:mod:`focus_project._clock`) as a naive local datetime."""
return from_ms(int(_clock.now() * 1000))
def append(
project_id: str, milestone: str, kind: str, value: object, now: datetime
) -> Evidence:
"""Append one row (fsynced) and return it."""
row = Evidence(to_ms(now), milestone, kind, value)
path = _paths.evidence_path(project_id)
path.parent.mkdir(parents=True, exist_ok=True)
line = json.dumps(
{"ms": row.ms, "milestone": milestone, "kind": kind, "value": value}
)
with path.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
handle.flush()
os.fsync(handle.fileno())
return row
def store_file(project_id: str, source: Path, now: datetime) -> str:
"""Copy ``source`` beside the rows under a name that never collides."""
target = _paths.evidence_files_dir(project_id)
target.mkdir(parents=True, exist_ok=True)
safe = re.sub(r"[^A-Za-z0-9._-]", "_", source.name)[:80]
stamp = to_ms(now)
for attempt in itertools.count():
name = f"{stamp}-{safe}" if not attempt else f"{stamp}-{attempt}-{safe}"
try:
with (target / name).open("xb") as out, source.open("rb") as src:
shutil.copyfileobj(src, out)
out.flush()
os.fsync(out.fileno())
except FileExistsError:
continue
return name
raise AssertionError # pragma: no cover (itertools.count never ends)
def _parse(line: str) -> Evidence | None:
try:
row = json.loads(line)
if not isinstance(row, dict) or isinstance(row["ms"], bool):
return None
return Evidence(
int(row["ms"]), str(row["milestone"]), str(row["kind"]), row["value"]
)
except ValueError, KeyError, TypeError:
return None
def read(project_id: str) -> list[Evidence]:
"""Every row in file order; a missing file is empty, bad lines are skipped.
A torn last line (crash mid-append) must not wipe out the whole history,
so malformed lines are logged and ignored; an unreadable file raises.
"""
try:
path = _paths.evidence_path(project_id)
text = path.read_text(encoding="utf-8", errors="replace")
except FileNotFoundError:
return []
rows: list[Evidence] = []
for number, line in enumerate(text.splitlines(), start=1):
parsed = _parse(line)
if parsed is None:
_LOG.warning(
"evidence %s line %d: skipping malformed row", project_id, number
)
else:
rows.append(parsed)
return rows
def first(
rows: list[Evidence], milestone: str, kind: str, since_ms: int = 0
) -> Evidence | None:
"""The earliest matching row at or after ``since_ms``."""
return next(
(
r
for r in rows
if (r.milestone, r.kind) == (milestone, kind) and r.ms >= since_ms
),
None,
)
def last(rows: list[Evidence], milestone: str, kind: str) -> Evidence | None:
"""The latest matching row."""
return next(
(r for r in reversed(rows) if (r.milestone, r.kind) == (milestone, kind)),
None,
)
def check_summary(
rows: list[Evidence], milestone: str
) -> tuple[int | None, bool | None, bool]:
"""``(first passing ms, latest verdict, any check row)`` for a milestone."""
checks = [r for r in rows if (r.milestone, r.kind) == (milestone, "check")]
passed = next((r.ms for r in checks if r.value is True), None)
latest = checks[-1].value if checks else None
return passed, (latest if isinstance(latest, bool) else None), bool(checks)

View File

@ -7,9 +7,6 @@ one-minute ``.timer``). Harvests every project day of the last
after the fact) is paid once a run sees it; ``credit_to`` is idempotent, so
settled days cost one read and write nothing. Failure modes, by path:
Overtime past a day's target banks for the next day (``carry``): that day's
carry row pays it from the day's start, own-work rows pay the rest.
* a source that errors: logged, contributes 0, the run continues (fail closed);
* an unreadable registry, signing key or ledger: logged, exit status 1, so
the unit shows ``failed`` instead of silently crediting nothing.
@ -21,8 +18,9 @@ from datetime import date, timedelta
import logging
import sys
from focus_project import _api, _clock, carry, ledger
from focus_project import _api, _clock, ledger
from focus_project._errors import RegistryError
from focus_project.minutes import minutes_for
BACKFILL_DAYS = 31 # bounds the per-run cost; the relay accepts events <= 48 h old
_LOG = logging.getLogger("focus_project.harvest")
@ -45,18 +43,11 @@ def harvest_day(day: date, now: float) -> int:
project = _api.active(day)
if project is None:
return 0
daily = project.daily_on(day)
bank = carry.balance(day, now)
added = ledger.credit_to(project, day, bank.paid_bank(daily), now=now, carry=True)
added += ledger.credit_to(project, day, bank.paid_own(daily), now=now)
total = minutes_for(day, now=now)
added = ledger.credit_to(project, day, total, now=now)
if added:
_LOG.info(
"credited %d min to %s on %s (total %d, %d from the bank)",
added,
project.id,
day,
bank.counted(daily),
bank.paid_bank(daily),
"credited %d min to %s on %s (total %d)", added, project.id, day, total
)
return added

View File

@ -93,19 +93,10 @@ def _write_rows(path: Path, rows: list[Any]) -> None:
Path(handle.name).replace(path)
def paid_minutes(
rows: list[Any],
key: bytes,
project_id: str,
day: date,
*,
carry: bool | None = None,
) -> int:
def paid_minutes(rows: list[Any], key: bytes, project_id: str, day: date) -> int:
"""Minutes already paid for ``project_id`` on ``day`` by verified rows.
``carry`` picks one class (True: banked-overtime rows, False: own-work
rows); None sums both. A repeated ``entry_id`` pays once, at its smallest
count.
A repeated ``entry_id`` pays once, at its smallest count.
"""
by_id: dict[str, int] = {}
for row in rows:
@ -121,7 +112,6 @@ def paid_minutes(
or not isinstance(minutes, int)
or isinstance(minutes, bool)
or minutes < 1
or (carry is not None and bool(detail.get("carry")) != carry)
or not earned_time.verified(row, key)
):
continue
@ -131,31 +121,13 @@ def paid_minutes(
def build_row( # noqa: PLR0913
project_id: str,
day: date,
total: int,
minutes: int,
key: bytes,
*,
now: float,
carry: bool = False,
first_carry: bool = True,
project_id: str, day: date, total: int, minutes: int, key: bytes, *, now: float
) -> dict[str, Any]:
"""The signed row paying ``minutes`` that bring the day's total to ``total``.
A ``carry`` row pays banked overtime: ``<project>-<YYYYMMDD>-carry``, or
``-carry-m<total>`` when it tops up an earlier carry row (late data for
the day before grew the bank).
"""
"""The signed row paying ``minutes`` that bring the day's total to ``total``."""
ended = min(now, day_bounds(day)[1] - 1)
stem = f"{project_id}-{day:%Y%m%d}"
if not carry:
entry_id = f"{stem}-m{total}"
else:
entry_id = f"{stem}-carry" if first_carry else f"{stem}-carry-m{total}"
row: dict[str, Any] = {
"kind": CREDIT,
"entry_id": entry_id,
"entry_id": f"{project_id}-{day:%Y%m%d}-m{total}",
"day": day.isoformat(),
"created_at": float(now),
"detail": {
@ -163,7 +135,6 @@ def build_row( # noqa: PLR0913
"minutes": int(minutes),
"total": int(total),
"ended_at": float(ended),
**({"carry": True} if carry else {}),
},
}
row["hmac"] = str(earned_time.entry_signature(row, key))
@ -171,18 +142,10 @@ def build_row( # noqa: PLR0913
def credit_to(
project: Project,
day: date,
total: int,
*,
now: float | None = None,
carry: bool = False,
project: Project, day: date, total: int, *, now: float | None = None
) -> int:
"""Pay the day up to ``total`` cumulative minutes; return minutes added.
``carry`` pays the banked-overtime class and counts only its own rows;
the default pays (and counts) own work.
Idempotent: nothing is written when the ledger already pays ``total`` or
more. Raises LedgerError (unreadable ledger, unreadable key, unverifiable
write) so the harvester can fail loudly instead of crediting nothing quietly.
@ -196,18 +159,11 @@ def credit_to(
with path.with_suffix(".lock").open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
rows = read_rows(path)
paid = paid_minutes(rows, key, project.id, day, carry=carry)
paid = paid_minutes(rows, key, project.id, day)
if total <= paid:
return 0
row = build_row(
project.id,
day,
total,
total - paid,
key,
now=now or time.time(),
carry=carry,
first_carry=paid == 0,
project.id, day, total, total - paid, key, now=now or time.time()
)
if any(
isinstance(r, dict) and r.get("entry_id") == row["entry_id"] for r in rows
@ -216,7 +172,7 @@ def credit_to(
raise LedgerError(msg)
_write_rows(path, [*rows, row])
stored = read_rows(path)
if paid_minutes(stored, key, project.id, day, carry=carry) != total:
if paid_minutes(stored, key, project.id, day) != total:
msg = f"row {row['entry_id']} not found verified on read-back"
raise LedgerError(msg)
return total - paid

View File

@ -1,17 +1,12 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Credited focus minutes per day: the union of every source's intervals.
Contract: union of activity intervals on the local calendar day plus the
overtime carried in from the day before (:mod:`focus_project.carry`), capped
at the active project's ``daily_minutes``; overlaps never double count; whole
Contract: union of activity intervals on the local calendar day, capped at
the active project's ``daily_minutes``; overlaps never double count; whole
minutes (floor). Time not yet lived is never credited: every interval is
clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
earns only once the real clock reaches it. An unreadable registry reads as 0
(logged): fail closed.
Unbacked minutes (0.10.0): timer time the anti-cheat gate refused
(:mod:`focus_project.sources.activity`) and no other source covers, on the
local day up to "now"; carry and the cap play no part. Shown, never paid.
"""
from __future__ import annotations
@ -19,14 +14,24 @@ from __future__ import annotations
from datetime import date # noqa: TC003
import logging
from focus_project import _api, _clock, carry, sources
from focus_project import _api, _clock, sources
from focus_project._errors import RegistryError
from focus_project._own import union_seconds
from focus_project.sources import activity
_LOG = logging.getLogger(__name__)
def union_seconds(spans: list[sources.Interval]) -> float:
"""Total length of the union of ``spans``."""
total = 0.0
end = float("-inf")
for lo, hi in sorted(spans):
if hi <= end:
continue
total += hi - max(lo, end)
end = hi
return total
def minutes_for(day: date, now: float | None = None) -> int:
"""Minutes of focus work credited on ``day`` (0 with no project active).
@ -40,29 +45,6 @@ def minutes_for(day: date, now: float | None = None) -> int:
if project is None:
return 0
moment = _clock.now() if now is None else now
return carry.balance(day, moment).counted(project.daily_on(day))
def unbacked_minutes_for(day: date, now: float | None = None) -> int:
"""Whole minutes of timer time on ``day`` that the gate did not count.
0 with no project, before :data:`~focus_project.sources.activity.BACKING_FROM`,
or with an unreadable registry (logged).
"""
try:
project = _api.active(day)
if project is None:
return 0
lo, hi = sources.day_bounds(day)
bounds = (lo, min(hi, _clock.now() if now is None else now))
unbacked = sources.clip(activity.split_timer(day, project)[1], bounds)
counted = sources.clip(sources.collect(day, project), bounds)
except (RegistryError, OSError) as exc:
_LOG.warning("focus_project registry unreadable, 0 unbacked: %s", exc)
return 0
# |unbacked minus counted| = |unbacked union counted| - |counted|.
gap = union_seconds(unbacked + counted) - union_seconds(counted)
return int(gap // 60)
__all__ = ["minutes_for", "unbacked_minutes_for", "union_seconds"]
spans = sources.clip(sources.collect(day, project), (float("-inf"), moment))
seconds = union_seconds(spans)
return min(int(seconds // 60), project.daily_on(day))

View File

@ -1,112 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""``python3 -m focus_project.open [--no-site]``: go where the work is done.
Opens the tutor app's focus screen (``run.sh --route``, which starts the tutor
server if it is down and shows the app in its Chrome ``--app`` window) and,
unless ``--no-site``, the project's work URL in the default browser. The link
carries ``start=1`` only while a project is active: the app starts the timer
if it is not already running. The site opens last so it ends up in front.
One place for every caller (the i3blocks block, the daily-limits popup). No
active project, or an unreadable registry, still opens the focus screen.
"""
from __future__ import annotations
import argparse
import logging
import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import TYPE_CHECKING
from focus_project import _api, _clock
from focus_project._errors import RegistryError
if TYPE_CHECKING:
from collections.abc import Sequence
from focus_project._model import Project
_LOG = logging.getLogger(__name__)
TUTOR_RUN_ENV = "FOCUS_TUTOR_RUN"
_TUTOR_WAIT_SECONDS = 60 # run.sh may have to start the server first
_ROUTE = "/focus"
_ROUTE_START = "/focus?start=1"
def work_url(project: Project) -> str | None:
"""The project's work URL, else ``https://<first usable site pattern>``."""
if project.work_url:
return project.work_url
for pattern in project.site_patterns:
host = pattern.removeprefix("*.")
if host and "*" not in host:
return f"https://{host}"
return None
def _active() -> Project | None:
try:
return _api.active(_clock.today())
except (RegistryError, OSError) as exc:
_LOG.warning("focus registry unreadable, opening the tutor only: %s", exc)
return None
def _open_tutor(route: str) -> bool:
run = Path(os.environ.get(TUTOR_RUN_ENV) or "~/src/tutor/run.sh").expanduser()
try:
subprocess.run(
[str(run), "--route", route],
check=True,
stdin=subprocess.DEVNULL,
timeout=_TUTOR_WAIT_SECONDS,
start_new_session=True,
)
except (OSError, subprocess.SubprocessError) as exc:
_LOG.error("could not open the tutor via %s: %s", run, exc)
return False
return True
def _open_site(url: str) -> bool:
opener = shutil.which("xdg-open")
if opener is None:
_LOG.error("xdg-open not found, cannot open %s", url)
return False
try:
# Not a `with`: the browser must outlive this process.
subprocess.Popen(
[opener, url],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except OSError as exc:
_LOG.error("could not run xdg-open: %s", exc)
return False
return True
def main(argv: Sequence[str] | None = None) -> int:
"""Open the focus screen and the work URL; 1 only if nothing opened."""
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--no-site", action="store_true", help="only the tutor focus screen"
)
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="focus_project.open: %(message)s")
project = _active()
opened = _open_tutor(_ROUTE if project is None else _ROUTE_START)
url = None if project is None or args.no_site else work_url(project)
if url is not None:
opened = _open_site(url) or opened
return 0 if opened else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -8,41 +8,23 @@ starts a fresh one. Time after the last check-in is not credited until the
next event vouches for it. Every other source (tutor, sql_drills, ...) is a
heartbeat covering the five minutes before it. Only events recorded for
``project`` count.
0.8.0: a ``tutor`` ``heartbeat`` (a graded check-in card) is also a beat of
the open segment, like a ``checkin``: within :data:`MAX_GAP_S` of the last
beat it extends the segment; later it ends the segment at that last beat. It
never opens a segment. Other heartbeat sources never chain the timer.
0.10.0 anti-cheat (days from :data:`BACKING_FROM`): a timer span earns only
if real activity of the same project backs it, whole span or nothing. A
backing event is a ``tutor``/``sql_drills``/``leetcode`` heartbeat or an
accepted LeetCode submission (leetcode projects only); ``browser`` never
backs. A span ``(a, b)`` is backed if a backing event lies in
``[a - 300, b]``, or between two consecutive backing events at most
:data:`BRIDGE_S` apart. Check-ins alone earn 0. Earlier days keep the old
rule (every span counts), so paid days never change.
"""
from __future__ import annotations
from datetime import date
import importlib
from itertools import pairwise
import json
import logging
from typing import TYPE_CHECKING, Any, Final
from typing import TYPE_CHECKING, Any
from focus_project._paths import activity_path
from focus_project.sources import HEARTBEAT_WINDOW_S, Interval, heartbeat_interval
from focus_project.sources import Interval, heartbeat_interval
if TYPE_CHECKING:
from datetime import date
from focus_project._model import Project
MAX_GAP_S = 600.0
BACKING_FROM: Final = date(2026, 11, 8)
BACKING_SOURCES: Final = frozenset({"tutor", "sql_drills", "leetcode"})
BRIDGE_S: Final = 1200.0
_LOG = logging.getLogger(__name__)
@ -73,92 +55,26 @@ def read_events(project_id: str) -> list[dict[str, Any]]:
def timer_intervals(events: list[dict[str, Any]]) -> list[Interval]:
"""Credited spans of the ``timer`` events and tutor beats (time-ordered)."""
"""Credited spans of the ``timer`` events (already time-ordered)."""
spans: list[Interval] = []
last: float | None = None # the last beat of the open segment
last: float | None = None # the last event of the open segment
for event in events:
ts = float(event["ts"])
kind = event.get("kind")
beat = _is_beat(event)
if not beat and kind not in {"start", "checkin", "stop"}:
if kind not in {"start", "checkin", "stop"}:
continue
on_time = False
if last is not None and ts - last <= MAX_GAP_S:
spans.append((last, ts))
on_time = True
# A beat never opens a segment; a stop closes it; start/checkin open it.
keep = on_time if beat else kind != "stop"
last = ts if keep else None
last = None if kind == "stop" else ts
return spans
def _is_beat(event: dict[str, Any]) -> bool:
"""A tutor heartbeat: it chains the timer segment (0.8.0)."""
return event.get("source") == "tutor" and event.get("kind") == "heartbeat"
def sessions(project_id: str) -> list[Interval]:
"""Contiguous focus sessions of ``project_id``, time-ordered, as (start, end).
Built from the same timer/tutor-beat segments (and so the same
:data:`MAX_GAP_S` rule) that credit minutes; spans that touch are one
session, which ends at its last beat.
"""
events = read_events(project_id)
timer = [e for e in events if e.get("source") == "timer" or _is_beat(e)]
merged: list[Interval] = []
for lo, hi in timer_intervals(timer):
if merged and lo <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(hi, merged[-1][1]))
else:
merged.append((lo, hi))
return merged
def backing_times(events: list[dict[str, Any]], project: Project) -> list[float]:
"""Sorted times of the events that may back a timer span.
The LeetCode ledger is read in its own guard: if it fails, only the
submissions are lost, never the heartbeats (fail closed, narrow).
"""
times = [float(e["ts"]) for e in events if e.get("source") in BACKING_SOURCES]
try:
# Imported lazily: it pulls in earned_time, and the timer must keep
# working (heartbeat backing only) where that is not installed.
leetcode = importlib.import_module("focus_project.sources.leetcode")
times += leetcode.submission_times(project)
except Exception: # noqa: BLE001 (lose the submissions, keep the beats)
_LOG.exception("leetcode backing unavailable, heartbeats only")
return sorted(times)
def is_backed(span: Interval, times: list[float]) -> bool:
"""Whether a backing event lies in or just before ``span``, or bridges it."""
lo, hi = span
if any(lo - HEARTBEAT_WINDOW_S <= t <= hi for t in times):
return True
return any(a <= lo and hi <= b and b - a <= BRIDGE_S for a, b in pairwise(times))
def split_timer(
day: date, project: Project, events: list[dict[str, Any]] | None = None
) -> tuple[list[Interval], list[Interval]]:
"""``(backed, unbacked)`` timer spans of ``project``, judged for ``day``."""
found = read_events(project.id) if events is None else events
chain = [e for e in found if e.get("source") == "timer" or _is_beat(e)]
spans = timer_intervals(chain)
if day < BACKING_FROM:
return spans, []
times = backing_times(found, project)
backed = [s for s in spans if is_backed(s, times)]
return backed, [s for s in spans if s not in backed]
def intervals(day: date, project: Project) -> list[Interval]:
"""Backed timer segments plus heartbeats of ``project`` (the registry clips)."""
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
"""Timer segments plus heartbeats of ``project`` (the registry clips)."""
events = read_events(project.id)
timer = [e for e in events if e.get("source") == "timer"]
beats = [e for e in events if e.get("source") != "timer"]
return [
*split_timer(day, project, events)[0],
*timer_intervals(timer),
*(heartbeat_interval(float(e["ts"])) for e in beats),
]

View File

@ -55,8 +55,8 @@ def _submitted_at(row: object, key: bytes) -> float | None:
return None
def submission_times(project: Project) -> list[float]:
"""Times of verified accepted submissions; [] for a non-leetcode project."""
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
"""One heartbeat per accepted submission (the registry clips to the day)."""
if not any("leetcode" in pattern.lower() for pattern in project.site_patterns):
return []
key = ledger.read_key()
@ -64,9 +64,4 @@ def submission_times(project: Project) -> list[float]:
_LOG.warning("leetcode source: no signing key, contributes 0")
return []
stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file()))
return [ts for ts in stamps if ts is not None]
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
"""One heartbeat per accepted submission (the registry clips to the day)."""
return [heartbeat_interval(ts) for ts in submission_times(project)]
return [heartbeat_interval(ts) for ts in stamps if ts is not None]

View File

@ -3,79 +3,31 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from datetime import date # noqa: TC003
from typing import Any
from focus_project import _api, _milestones, carry, evidence, minutes
if TYPE_CHECKING:
from datetime import date
from focus_project._model import Project
_LOG = logging.getLogger(__name__)
def _milestone_block(project: Project) -> tuple[list[dict[str, Any]], bool]:
"""Milestone rows and whether any is red; unreadable evidence shows none.
Display path, so it fails open (logged): the lock path, ``owed_lock``,
has its own handling.
"""
now = evidence.clock_now()
try:
items = _milestones.milestones(project, now)
except OSError as exc:
_LOG.warning("interview evidence unreadable, no milestones shown: %s", exc)
return [], False
return [m.to_dict() for m in items], any(m.red for m in items)
from focus_project import _api, minutes
def status(day: date) -> dict[str, Any]:
"""Progress of the project active on ``day``; ``project`` is None if none.
``minutes`` is what was really worked plus what the day took from the
bank, so it may exceed ``daily_minutes`` (121/120); ``counted_minutes``
is the capped figure that pays. ``unbacked_minutes`` is timer time the
anti-cheat gate refused (shown as "not counted", never paid).
"""
"""Progress of the project active on ``day``; ``project`` is None if none."""
project = _api.active(day)
if project is None:
return {
"project": None,
"name": None,
"minutes": 0,
"own_minutes": 0,
"counted_minutes": 0,
"carried_in": 0,
"carry_out": 0,
"daily_minutes": None,
"unlock_minutes": None,
"ends": None,
"replaces": [],
"unbacked_minutes": 0,
"milestones": [],
"missed": False,
}
daily = project.daily_on(day)
bank = carry.balance(day)
rows, missed = _milestone_block(project)
return {
"project": project.id,
"name": project.name,
# Real minutes behind the day (own + banked in), never capped: the
# number to show. Pay is ``counted_minutes``; earned_time caps it.
"minutes": bank.own + bank.carried_in,
"own_minutes": bank.own,
"counted_minutes": bank.counted(daily),
"carried_in": bank.carried_in,
"carry_out": bank.carry_out,
"daily_minutes": daily,
"minutes": minutes.minutes_for(day),
"daily_minutes": project.daily_on(day),
"unlock_minutes": project.unlock_on(day),
"ends": project.last_day.isoformat(),
"replaces": list(project.replaces_on(day)),
"unbacked_minutes": minutes.unbacked_minutes_for(day),
# Interview projects: [{id, due ISO, state}] and whether any is red.
"milestones": rows,
"missed": missed,
"replaces": list(project.replaces),
}

View File

@ -11,7 +11,7 @@ from typing import TYPE_CHECKING
import pytest
from focus_project import _clock, _paths, checks
from focus_project import _clock
if TYPE_CHECKING:
from pathlib import Path
@ -40,16 +40,3 @@ def _far_future_clock(monkeypatch: pytest.MonkeyPatch) -> None:
def data(tmp_path: Path) -> Path:
"""The redirected data directory (may not exist yet)."""
return tmp_path / "data"
@pytest.fixture(autouse=True)
def _interview_isolation(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, _redirect_state: None
) -> None:
"""Evidence lives under the redirected data dir; sources are a private copy.
Fails closed (R3): if the path seam ever resolves outside ``tmp_path`` the
test errors before it can write a production evidence row.
"""
assert _paths.evidence_dir().is_relative_to(tmp_path)
monkeypatch.setattr(checks, "SOURCES", dict(checks.SOURCES))

View File

@ -1,65 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Shared fixtures-as-functions for the interview-prep tests."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
import json
from typing import TYPE_CHECKING
import focus_project as fp
from focus_project._paths import activity_path
if TYPE_CHECKING:
from pathlib import Path
START = date(2026, 11, 10) # Tuesday: D1
TEST_DAY = date(2026, 11, 27) # Friday: T
TEST_AT = datetime.combine(TEST_DAY, time(10, 0))
SAT1 = date(2026, 11, 14)
SAT2 = date(2026, 11, 21)
def at(day: int, hour: int = 0, minute: int = 0) -> datetime:
"""A November 2026 local datetime."""
return datetime.combine(date(2026, 11, day), time(hour, minute))
def make(pid: str = "iv", /, **kw: object) -> fp.Project:
"""Create an interview project D1 = Nov 10, T = Nov 27."""
args: dict[str, object] = {
"name": "Interview",
"start": START,
"end": TEST_DAY,
"replaces": ["leetcode", "reading", "tutor"],
"daily_minutes": 120,
"unlock_minutes": 30,
"kind": "interview",
"test_at": TEST_AT,
"invite_url": "https://app.codility.com/c/run/abc",
}
args.update(kw)
return fp.create_project(pid, **args) # type: ignore[arg-type]
def shot(tmp_path: Path, name: str = "rules.png") -> Path:
"""A non-empty screenshot stand-in."""
path = tmp_path / name
path.write_bytes(b"\x89PNG rules")
return path
def states(project: fp.Project, now: datetime) -> dict[str, str]:
"""Milestone id -> state."""
return {m.id: m.state for m in fp.milestones(project, now)}
def session(start: datetime, minutes: int, *, step: int = 5, pid: str = "iv") -> None:
"""A timer session start -> check-in every ``step`` min -> stop."""
activity_path().parent.mkdir(parents=True, exist_ok=True)
with activity_path().open("a", encoding="utf-8") as handle:
for offset in range(0, minutes + 1, step):
kind = "start" if offset == 0 else "checkin"
when = start + timedelta(minutes=offset)
row = {"ts": when.timestamp(), "source": "timer", "kind": kind}
handle.write(json.dumps({**row, "project": pid, "dev": "pc"}) + "\n")

View File

@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import date, datetime
from datetime import date
import importlib
import json
from pathlib import Path # noqa: TC003
@ -13,16 +13,12 @@ import time
import pytest
import focus_project as fp
from focus_project import _clock
from focus_project import _clock, minutes
from focus_project._paths import data_dir
D1 = date(2026, 11, 2)
def _at(hour: int, minute: int) -> float:
return datetime(2026, 11, 2, hour, minute).astimezone().timestamp()
def _project() -> None:
fp.create_project(
"hr",
@ -119,27 +115,19 @@ def test_record_activity_concurrent_appends(data: Path) -> None:
assert all(json.loads(x)["source"] == "tutor" for x in lines)
def test_status_active_reports_the_worked_minutes() -> None:
def test_status_active_uses_minutes_module(monkeypatch: pytest.MonkeyPatch) -> None:
_project()
assert fp.status(D1)["minutes"] == 0
for k in range(1, 10): # nine heartbeats, five minutes each
fp.record_activity("tutor", "heartbeat", ts=_at(8, 5 * k))
assert fp.status(D1)["minutes"] == 0 # the stub
monkeypatch.setattr(minutes, "minutes_for", lambda _day: 45)
st = fp.status(D1)
assert st == {
"project": "hr",
"name": "HackerRank prep",
"minutes": 45,
"own_minutes": 45,
"counted_minutes": 45,
"carried_in": 0,
"carry_out": 0,
"daily_minutes": 120,
"unlock_minutes": 30,
"ends": "2026-11-05",
"replaces": ["leetcode"],
"unbacked_minutes": 0,
"milestones": [],
"missed": False,
}
@ -153,17 +141,10 @@ def test_status_inactive() -> None:
"project",
"name",
"minutes",
"own_minutes",
"counted_minutes",
"carried_in",
"carry_out",
"daily_minutes",
"unlock_minutes",
"ends",
"replaces",
"unbacked_minutes",
"milestones",
"missed",
}

View File

@ -191,48 +191,3 @@ def test_failed_write_keeps_old_file(data: Path) -> None:
_make("two", start=D4 + timedelta(days=3), end=D4 + timedelta(days=4))
assert (data / "projects.json").read_text() == before
assert not list(data.glob("*.tmp"))
@pytest.mark.parametrize(
"bad",
[
"javascript:alert(1)",
"file:///etc/passwd",
"ftp://example.com",
"https://",
"https://exa mple.com",
"https://example.com/\n",
"https://example.com/" + "a" * 2100,
"www.hackerrank.com",
],
)
def test_work_url_must_be_a_plain_http_url(bad: str) -> None:
with pytest.raises(ValueError, match="work_url"):
fp.create_project(
"w",
name="W",
start=date(2026, 11, 2),
end=date(2026, 11, 5),
replaces=["leetcode"],
daily_minutes=60,
unlock_minutes=0,
work_url=bad,
)
def test_work_url_round_trips_and_old_rows_read_empty(data: Path) -> None:
made = fp.create_project(
"w",
name="W",
start=date(2026, 11, 2),
end=date(2026, 11, 5),
replaces=["leetcode"],
daily_minutes=60,
unlock_minutes=0,
work_url="https://www.hackerrank.com/dashboard",
)
assert fp.list_projects() == [made]
assert made.to_dict()["work_url"] == "https://www.hackerrank.com/dashboard"
row = made.to_dict()
del row["work_url"]
assert fp.Project.from_dict(row).work_url == ""

View File

@ -1,181 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""0.10.0: a timer span earns only if a same-project beat backs it."""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING
import focus_project as fp
from focus_project import _api, minutes
from focus_project.sources import activity
from focus_project.tests import test_minutes as tm
if TYPE_CHECKING:
import pytest
DAY = date(2026, 11, 8) # BACKING_FROM
def at(hour: int, minute: int = 0) -> float:
return tm.at(hour, minute, day=DAY)
def proj() -> None:
fp.create_project(
"hr",
name="HR prep",
start=DAY,
end=date(2026, 11, 12),
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
site_patterns=["leetcode.com"],
)
def rows(*items: tuple[str, str, float]) -> None:
tm.events(*items)
def timer_only() -> None:
rows(
("timer", "start", at(9)),
("timer", "checkin", at(9, 5)),
("timer", "stop", at(9, 10)),
)
def test_timer_only_stretch_pays_zero_and_shows_unbacked() -> None:
proj()
timer_only()
assert minutes.minutes_for(DAY) == 0
assert minutes.unbacked_minutes_for(DAY) == 10
assert fp.status(DAY)["unbacked_minutes"] == 10
def test_a_beat_in_the_span_backs_all_of_it() -> None:
proj()
timer_only()
rows(("sql_drills", "heartbeat", at(9, 5)))
assert minutes.unbacked_minutes_for(DAY) == 0
assert minutes.minutes_for(DAY) == 10
def test_a_beat_backs_only_the_spans_it_touches() -> None:
proj()
timer_only()
rows(("sql_drills", "heartbeat", at(9, 7)))
# 9:00-9:05 stays unbacked, but the beat itself pays 9:02-9:07.
assert minutes.unbacked_minutes_for(DAY) == 2
def test_a_beat_at_the_end_of_the_span_backs_it() -> None:
proj()
rows(
("timer", "start", at(9)),
("timer", "stop", at(9, 10)),
("sql_drills", "heartbeat", at(9, 10)),
)
assert minutes.unbacked_minutes_for(DAY) == 0
assert minutes.minutes_for(DAY) == 10
def test_a_beat_just_before_the_span_backs_it() -> None:
proj()
rows(
("sql_drills", "heartbeat", at(8, 56)), # within 300 s before 9:00
("timer", "start", at(9)),
("timer", "stop", at(9, 5)),
)
assert minutes.unbacked_minutes_for(DAY) == 0
def test_a_late_beat_does_not_back_it() -> None:
proj()
timer_only()
rows(("sql_drills", "heartbeat", at(9, 40)))
assert minutes.unbacked_minutes_for(DAY) == 10
def test_beats_up_to_twenty_minutes_apart_bridge_the_span() -> None:
proj()
rows(
("tutor", "heartbeat", at(8, 50)),
("timer", "start", at(9)),
("timer", "checkin", at(9, 5)),
("timer", "stop", at(9, 8)),
("tutor", "heartbeat", at(9, 10)),
)
assert minutes.unbacked_minutes_for(DAY) == 0
def test_beats_over_twenty_minutes_apart_do_not_bridge() -> None:
proj()
rows(
("tutor", "heartbeat", at(8, 30)),
("timer", "start", at(9)),
("timer", "stop", at(9, 8)),
("tutor", "heartbeat", at(9, 40)),
)
assert minutes.unbacked_minutes_for(DAY) == 8
def test_browser_never_backs() -> None:
proj()
timer_only()
rows(("browser", "heartbeat", at(9, 7)))
# The beat pays its own 9:02-9:07 but backs no timer span.
assert minutes.unbacked_minutes_for(DAY) == 5
assert minutes.minutes_for(DAY) == 5
def test_days_before_backing_from_keep_the_old_rule() -> None:
fp.create_project(
"hr",
name="Old",
start=date(2026, 11, 2),
end=date(2026, 11, 5),
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
site_patterns=["leetcode.com"],
)
old = date(2026, 11, 2)
rows(("timer", "start", tm.at(9)), ("timer", "stop", tm.at(9, 10)))
assert minutes.minutes_for(old) == 10
assert minutes.unbacked_minutes_for(old) == 0
def test_no_project_is_zero_unbacked() -> None:
assert minutes.unbacked_minutes_for(DAY) == 0
assert fp.status(DAY)["unbacked_minutes"] == 0
def test_unbacked_is_clipped_at_now() -> None:
proj()
timer_only()
assert minutes.unbacked_minutes_for(DAY, now=at(9, 4)) == 4
def test_split_timer_returns_backed_and_unbacked() -> None:
proj()
timer_only()
project = _api.active(DAY)
assert project is not None
backed, unbacked = activity.split_timer(DAY, project)
assert backed == []
assert unbacked == [(at(9), at(9, 5)), (at(9, 5), at(9, 10))]
def test_backing_from_is_exported() -> None:
assert date(2026, 11, 8) == fp.BACKING_FROM
assert fp.unbacked_minutes_for is minutes.unbacked_minutes_for
def test_registry_error_reads_zero(monkeypatch: pytest.MonkeyPatch) -> None:
def boom(_day: date) -> None:
raise OSError
monkeypatch.setattr("focus_project._api.active", boom)
assert minutes.unbacked_minutes_for(DAY) == 0

View File

@ -1,131 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""More 0.10.0 backing cases: sources, projects, the cutover midnight, chains.
The core rule lives in ``test_backing.py``; these pin the edges the spec
named (C5-C7) with leetcode-ledger and other-project fixtures.
"""
from __future__ import annotations
from datetime import date
import json
from typing import TYPE_CHECKING
import pytest
import focus_project as fp
from focus_project import minutes
from focus_project._paths import activity_path
from focus_project.tests.test_leetcode_source import ac_row, write
from focus_project.tests.test_minutes import at
if TYPE_CHECKING:
from pathlib import Path
DAY = date(2026, 11, 9) # after the cutover
PRE = date(2026, 11, 7) # before it
@pytest.fixture(autouse=True)
def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Nothing may fall back to the real home directory."""
monkeypatch.setenv("HOME", str(tmp_path / "home"))
def make(pid: str = "hr", patterns: tuple[str, ...] = ("leetcode.com",)) -> None:
fp.create_project(
pid,
name=pid,
start=date(2026, 11, 6),
end=date(2026, 11, 12),
replaces=["leetcode"],
daily_minutes=240,
unlock_minutes=30,
site_patterns=list(patterns),
)
def rows(*items: tuple[str, str, float] | tuple[str, str, float, str]) -> None:
"""Append ``(source, kind, ts[, project])`` events (project default hr)."""
activity_path().parent.mkdir(parents=True, exist_ok=True)
with activity_path().open("a", encoding="utf-8") as handle:
for item in items:
source, kind, ts = item[:3]
pid = item[3] if len(item) == 4 else "hr"
row = {"ts": ts, "source": source, "kind": kind, "project": pid}
handle.write(json.dumps(row) + "\n")
def timer(day: date, *marks: tuple[int, int]) -> None:
"""A segment: start at the first mark, check-ins, stop at the last."""
kinds = ["start", *["checkin"] * (len(marks) - 2), "stop"]
rows(
*(
("timer", k, at(h, m, day=day))
for k, (h, m) in zip(kinds, marks, strict=True)
)
)
def beat(source: str, h: int, m: int, day: date = DAY, pid: str = "hr") -> None:
rows((source, "heartbeat", at(h, m, day=day), pid))
@pytest.mark.parametrize("source", ["sql_drills", "leetcode"])
def test_sql_drills_and_leetcode_heartbeats_back_too(source: str) -> None:
make()
timer(DAY, (9, 0), (9, 5), (9, 10))
beat(source, 9, 5)
assert minutes.minutes_for(DAY) == 10
def test_browser_and_other_project_beats_do_not_back() -> None:
make() # "other" needs no registry entry: rows are filtered by id
timer(DAY, (9, 0), (9, 5), (9, 10))
beat("browser", 9, 4)
beat("tutor", 9, 4, pid="other")
assert minutes.minutes_for(DAY) == 5 # the browser beat's own window only
assert minutes.unbacked_minutes_for(DAY) == 6 # 9:04-9:10
def test_accepted_submission_backs_a_leetcode_project(tmp_path: Path) -> None:
make()
timer(DAY, (9, 0), (9, 5), (9, 10))
write(tmp_path, ac_row(at(9, 4, day=DAY)))
assert minutes.minutes_for(DAY) == 11 # 8:59-9:10
def test_submission_does_not_back_a_non_leetcode_project(tmp_path: Path) -> None:
make("hr", ("sqlzoo.net",))
timer(DAY, (9, 0), (9, 5), (9, 10))
write(tmp_path, ac_row(at(9, 4, day=DAY)))
assert minutes.minutes_for(DAY) == 0
def test_span_across_the_cutover_midnight_follows_the_day() -> None:
make()
rows(
("timer", "start", at(23, 58, day=date(2026, 11, 7))),
("timer", "checkin", at(0, 3, day=date(2026, 11, 8))),
("timer", "stop", at(0, 5, day=date(2026, 11, 8))),
)
assert minutes.minutes_for(date(2026, 11, 7)) == 2
assert minutes.minutes_for(date(2026, 11, 8)) == 0
assert minutes.unbacked_minutes_for(date(2026, 11, 8)) == 5
def test_tutor_chained_span_is_backed_but_earlier_reading_is_not() -> None:
"""A tutor beat chains the segment (0.8.0) and backs the span it ends.
start 9:00, checkin 9:08, tutor beat 9:16: the span 9:08-9:16 ends at a
backing beat and earns; 9:00-9:08 has no beat in or 5 min before it and
nothing bridges it, so it shows as unbacked.
"""
make()
rows(
("timer", "start", at(9, 0, day=DAY)),
("timer", "checkin", at(9, 8, day=DAY)),
)
beat("tutor", 9, 16)
assert minutes.minutes_for(DAY) == 8
assert minutes.unbacked_minutes_for(DAY) == 8

View File

@ -1,183 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Overtime carries over: own minutes, the bank, pay and the carry ledger rows."""
from __future__ import annotations
from datetime import date, timedelta
import json
from pathlib import Path # noqa: TC003
from typing import Any
import earned_time
import pytest
import focus_project as fp
from focus_project import carry, harvest, ledger, minutes
from focus_project.tests.test_minutes import D1, at, events, project
KEY = b"k" * 32
D2 = D1 + timedelta(days=1)
D3 = D2 + timedelta(days=1)
D4 = D3 + timedelta(days=1) # the last day of project()
D5 = D4 + timedelta(days=1) # after the end
@pytest.fixture(autouse=True)
def _key(tmp_path: Path) -> None:
(tmp_path / "hmac.key").write_bytes(KEY)
def work(day: date, mins: int) -> None:
"""``mins`` focused minutes on ``day``: tutor heartbeats every five minutes."""
events(
*(
("tutor", "heartbeat", at(8, 0, day=day) + 300 * k)
for k in range(1, mins // 5 + 1)
)
)
def rows() -> list[dict[str, Any]]:
out: list[dict[str, Any]] = json.loads(ledger.ledger_path().read_text())["entries"]
return out
def test_own_minutes_are_not_capped_but_pay_is() -> None:
project()
work(D1, 125)
assert carry.own_minutes(D1) == 125
assert minutes.minutes_for(D1) == 120
def test_overtime_banks_into_the_next_day() -> None:
project()
work(D1, 125)
assert carry.balance(D1) == carry.Balance(own=125, carried_in=0, carry_out=5)
assert carry.balance(D2) == carry.Balance(own=0, carried_in=5, carry_out=0)
assert minutes.minutes_for(D2) == 5
def test_carry_adds_to_own_minutes_on_the_next_day() -> None:
project()
work(D1, 125)
work(D2, 100)
assert minutes.minutes_for(D2) == 105
assert carry.balance(D2).carry_out == 0
def test_the_bank_is_capped_at_sixty() -> None:
project()
work(D1, 200)
assert carry.balance(D1).carry_out == carry.BANK_CAP == 60
def test_overtime_chains_over_days() -> None:
project()
work(D1, 130)
work(D2, 125)
assert carry.balance(D2) == carry.Balance(own=125, carried_in=10, carry_out=15)
assert minutes.minutes_for(D3) == 15
def test_the_bank_never_crosses_the_project_end() -> None:
project()
work(D4, 150)
assert carry.balance(D4).carry_out == 30
assert minutes.minutes_for(D5) == 0
assert carry.balance(D5) == carry.Balance(0, 0, 0)
def test_the_first_day_has_nothing_banked_in() -> None:
project()
assert carry.balance(D1 - timedelta(days=1)).carried_in == 0
assert carry.balance(D1).carried_in == 0
def test_a_past_target_is_used_for_a_past_day() -> None:
project()
work(D1, 125)
fp.update_project("hr", today=D2, daily_minutes=100, unlock_minutes=30)
assert carry.balance(D1).carry_out == 5 # D1 still judged at 120
assert carry.balance(D2).carried_in == 5
def test_status_reports_real_minutes_and_the_bank() -> None:
project()
work(D1, 125)
shown = fp.status(D1)
assert shown["minutes"] == 125
assert shown["own_minutes"] == 125
assert shown["counted_minutes"] == 120
assert (shown["carried_in"], shown["carry_out"]) == (0, 5)
nxt = fp.status(D2)
assert (nxt["minutes"], nxt["carried_in"]) == (5, 5)
def test_status_without_a_project_has_zero_bank() -> None:
shown = fp.status(D1)
assert (shown["own_minutes"], shown["carried_in"], shown["carry_out"]) == (0, 0, 0)
def test_unreadable_registry_banks_nothing(data: Path) -> None:
data.mkdir(parents=True)
(data / "projects.json").write_text("{")
assert carry.balance(D2) == carry.Balance(0, 0, 0)
assert carry.own_minutes(D2) == 0
def test_harvest_pays_own_rows_then_one_carry_row() -> None:
project()
work(D1, 125)
work(D2, 100)
harvest.harvest(now=at(23, 0, day=D2))
got = {r["entry_id"]: r for r in rows()}
assert got["hr-20261102-m120"]["detail"]["minutes"] == 120
assert got["hr-20261103-carry"]["detail"]["minutes"] == 5
assert got["hr-20261103-carry"]["detail"]["carry"] is True
assert got["hr-20261103-m100"]["detail"]["minutes"] == 100
assert all(earned_time.verified(r, KEY) for r in got.values())
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 105
def test_pay_never_exceeds_the_daily_target() -> None:
project()
work(D1, 125)
work(D2, 130)
harvest.harvest(now=at(23, 0, day=D2))
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 120
assert minutes.minutes_for(D2) == 120
def test_carry_is_paid_at_the_start_of_the_day_and_not_again() -> None:
project()
work(D1, 125)
harvest.harvest(now=at(0, 5, day=D2))
before = rows()
assert [r["entry_id"] for r in before if r["day"] == "2026-11-03"] == [
"hr-20261103-carry"
]
harvest.harvest(now=at(0, 6, day=D2))
assert rows() == before
def test_late_data_for_yesterday_pays_only_the_carry_delta() -> None:
project()
work(D1, 125)
harvest.harvest(now=at(9, 0, day=D2))
work(D1, 130) # a late phone sync: five more minutes
harvest.harvest(now=at(9, 5, day=D2))
paid = [r for r in rows() if r["detail"].get("carry")]
assert [r["entry_id"] for r in paid] == [
"hr-20261103-carry",
"hr-20261103-carry-m10",
]
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 10
def test_own_rows_leave_room_for_the_carry() -> None:
project()
work(D1, 125)
work(D2, 120)
harvest.harvest(now=at(23, 0, day=D2))
assert ledger.paid_minutes(rows(), KEY, "hr", D2) == 120
assert "hr-20261103-m115" in {r["entry_id"] for r in rows()}

View File

@ -1,193 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Pluggable check sources, the phone source, and latching."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
import focus_project as fp
from focus_project import _paths, checks, evidence
from focus_project.tests.helpers import SAT1, at, make
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import datetime
MID = "phone:2026-11-14"
def _ms(moment: datetime) -> int:
return int(moment.timestamp() * 1000)
def _report(
moment: datetime,
*,
plan: bool = True,
due: int = 5,
seen: datetime | None = None,
) -> None:
value = {"plan": plan, "due": due, "seen_ms": _ms(seen or moment)}
fp.record_evidence("iv", "phone", "phone_check", value, now=moment)
def _phone(now: datetime) -> bool | None:
return checks.phone_source(fp.list_projects()[0], now)
def test_phone_source_without_a_report_is_unknown(
caplog: pytest.LogCaptureFixture,
) -> None:
make()
with caplog.at_level("WARNING"):
assert _phone(at(13, 9)) is None
assert "phone" in caplog.text
def test_phone_source_passes_on_a_fresh_good_report() -> None:
make()
_report(at(13, 8), seen=at(12, 20))
assert _phone(at(13, 9)) is True
@pytest.mark.parametrize(
"bad",
[{"plan": False}, {"due": 0}, {"seen": at(11, 7)}],
)
def test_phone_source_fails_on_any_bad_part(bad: dict[str, object]) -> None:
make()
_report(at(13, 8), **bad) # type: ignore[arg-type]
assert _phone(at(13, 9)) is False
def test_phone_source_stale_or_future_report_is_unknown() -> None:
make()
_report(at(13, 8))
assert _phone(at(14, 8, 1)) is None # the worker stopped reporting
assert _phone(at(13, 7)) is None # stamped ahead of the clock
def test_phone_source_uses_the_latest_report() -> None:
make()
_report(at(13, 8), plan=False)
_report(at(13, 9))
assert _phone(at(13, 10)) is True
def test_phone_source_hand_edited_row_is_a_failure() -> None:
make()
_report(at(13, 8))
path = _paths.evidence_path("iv")
row = {"ms": _ms(at(13, 9)), "milestone": "phone", "kind": "phone_check"}
path.write_text(path.read_text() + json.dumps({**row, "value": "ok"}) + "\n")
assert _phone(at(13, 10)) is False
def _patch(
monkeypatch: pytest.MonkeyPatch, fn: Callable[[fp.Project, datetime], bool | None]
) -> None:
monkeypatch.setitem(checks.SOURCES, "phone", fn)
def _trip_project() -> None:
make()
fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 9))
def test_run_checks_latches_a_pass(monkeypatch: pytest.MonkeyPatch) -> None:
_trip_project()
_patch(monkeypatch, lambda p, n: True)
assert fp.run_checks("iv", now=at(13, 9)) == {MID: True}
assert fp.run_checks("iv", now=at(13, 10)) == {MID: True}
rows = [r for r in evidence.read("iv") if r.kind == "check"]
assert [(r.milestone, r.value) for r in rows] == [(MID, True)]
def test_run_checks_only_inside_the_window(monkeypatch: pytest.MonkeyPatch) -> None:
_trip_project()
_patch(monkeypatch, lambda p, n: True)
assert fp.run_checks("iv", now=at(12, 11, 59)) == {} # >24 h before due
assert fp.run_checks("iv", now=at(12, 12)) == {MID: True}
def test_run_checks_records_changes_not_every_tick(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_trip_project()
answers: list[bool | None] = [None, False, False, None, None]
_patch(monkeypatch, lambda p, n: answers.pop(0))
for minute in range(5):
fp.run_checks("iv", now=at(13, 9, minute))
rows = [r.value for r in evidence.read("iv") if r.kind == "check"]
assert rows == [False, None] # leading None writes nothing
def test_run_checks_source_crash_is_unknown_and_loud(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
_trip_project()
def boom(p: fp.Project, n: datetime) -> bool:
msg = "tutor down"
raise OSError(msg)
_patch(monkeypatch, boom)
with caplog.at_level("WARNING"):
assert fp.run_checks("iv", now=at(13, 9)) == {MID: None}
assert "tutor down" in caplog.text
def test_run_checks_skips_finished_milestones(monkeypatch: pytest.MonkeyPatch) -> None:
_trip_project()
fp.record_evidence("iv", MID, "tap", value=True, now=at(12, 9))
evidence.append("iv", MID, "check", True, at(12, 10))
_patch(monkeypatch, lambda p, n: pytest.fail("already done"))
assert fp.run_checks("iv", now=at(13, 9)) == {}
def test_run_checks_ignores_focus_projects() -> None:
fp.create_project(
"f",
name="F",
start=at(10).date(),
end=at(12).date(),
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
assert fp.run_checks("f", now=at(11, 9)) == {}
with pytest.raises(KeyError):
fp.run_checks("ghost", now=at(11, 9))
def test_register_source_swaps_the_callable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(checks, "SOURCES", dict(checks.SOURCES))
def fn(p: fp.Project, n: datetime) -> bool:
return True
fp.register_source("phone", fn)
assert checks.SOURCES["phone"] is fn
with pytest.raises(ValueError, match="source"):
fp.register_source("calendar", fn)
def test_end_to_end_phone_milestone_goes_done() -> None:
_trip_project()
fp.record_evidence("iv", MID, "tap", value=True, now=at(12, 9))
_report(at(13, 9))
assert fp.run_checks("iv", now=at(13, 9, 30)) == {MID: True}
state = {m.id: m.state for m in fp.milestones(fp.list_projects()[0], at(13, 10))}
assert state[MID] == "done"
def test_report_missing_a_field_fails() -> None:
make()
path = _paths.evidence_path("iv")
path.parent.mkdir(parents=True, exist_ok=True)
row = {"ms": _ms(at(13, 8)), "milestone": "phone", "kind": "phone_check"}
path.write_text(json.dumps({**row, "value": {"plan": True}}) + "\n")
assert _phone(at(13, 9)) is False

View File

@ -1,126 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The append-only evidence store and record_evidence validation."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
import focus_project as fp
from focus_project import _paths, evidence
from focus_project.tests.helpers import at, make, shot
if TYPE_CHECKING:
from pathlib import Path
def test_paths_are_isolated(tmp_path: Path) -> None:
assert _paths.evidence_dir().is_relative_to(tmp_path)
assert _paths.evidence_path("iv").name == "iv.jsonl"
assert _paths.evidence_files_dir("iv").name == "iv"
def test_file_is_copied_and_row_appended(tmp_path: Path) -> None:
make()
src = shot(tmp_path)
row = fp.record_evidence("iv", "rules", "file", file=src, now=at(10, 9))
assert row.kind == "file"
stored = _paths.evidence_files_dir("iv") / str(row.value)
assert stored.read_bytes() == src.read_bytes()
line = json.loads(_paths.evidence_path("iv").read_text().splitlines()[0])
assert set(line) == {"ms", "milestone", "kind", "value"}
assert line["milestone"] == "rules"
def test_rows_append_never_rewrite() -> None:
make()
fp.record_evidence("iv", "mock", "score", 71, now=at(24, 9))
fp.record_evidence("iv", "mock", "score", 80.5, now=at(24, 10))
assert [r.value for r in evidence.read("iv")] == [71, 80.5]
assert not hasattr(fp, "delete_evidence")
assert not hasattr(evidence, "delete")
def test_same_name_files_do_not_overwrite(tmp_path: Path) -> None:
make()
one = fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
two = fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
assert one.value != two.value
@pytest.mark.parametrize(
("milestone", "kind", "value"),
[
("rules", "tap", True), # wrong kind for the milestone
("rules", "check", True), # internal kind
("trip_ask:2026-11-14", "answer", True), # use answer_weekend
("nope", "tap", True), # not a milestone of the project
("mock", "score", "high"),
("mock", "score", True),
("mock", "score", float("nan")),
("mock", "score", -1),
("slot", "slot", "tomorrow"),
("slot", "slot", "2026-11-26T10:00:00+02:00"), # aware
("slot", "slot_confirm", False),
("phone", "phone_check", {"plan": True}),
("phone", "phone_check", {"plan": "yes", "due": 1, "seen_ms": 1}),
("phone", "phone_check", "ok"),
],
)
def test_bad_evidence_refused(milestone: str, kind: str, value: object) -> None:
make()
with pytest.raises(ValueError): # noqa: PT011
fp.record_evidence("iv", milestone, kind, value, now=at(12, 9))
def test_file_kind_needs_a_real_nonempty_file(tmp_path: Path) -> None:
make()
empty = tmp_path / "e.png"
empty.write_bytes(b"")
for bad in (None, tmp_path / "missing.png", empty, tmp_path):
with pytest.raises(ValueError, match="file"):
fp.record_evidence("iv", "rules", "file", file=bad, now=at(10, 9))
def test_unknown_project_is_keyerror() -> None:
with pytest.raises(KeyError):
fp.record_evidence("ghost", "mock", "score", 1, now=at(24, 9))
def test_focus_project_has_no_milestones() -> None:
fp.create_project(
"f",
name="F",
start=at(10).date(),
end=at(12).date(),
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
with pytest.raises(ValueError, match="milestone"):
fp.record_evidence("f", "mock", "score", 1, now=at(10, 9))
def test_torn_and_foreign_lines_are_skipped(caplog: pytest.LogCaptureFixture) -> None:
make()
fp.record_evidence("iv", "mock", "score", 70, now=at(24, 9))
path = _paths.evidence_path("iv")
path.write_text(path.read_text() + '{"ms": 1, "milest\n[1]\n{"ms":"x"}\n')
with caplog.at_level("WARNING"):
rows = evidence.read("iv")
assert [r.value for r in rows] == [70]
assert "skipping" in caplog.text
def test_missing_file_reads_empty() -> None:
assert evidence.read("nobody") == []
def test_garbage_bytes_are_skipped_not_fatal() -> None:
make()
fp.record_evidence("iv", "mock", "score", 70, now=at(24, 9))
path = _paths.evidence_path("iv")
path.write_bytes(path.read_bytes() + b"\xff\xfe\x80 not utf-8\n")
assert [r.value for r in evidence.read("iv")] == [70]

View File

@ -1,175 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Project.kind, interview fields, validation, budget."""
from __future__ import annotations
from datetime import date
import json
from typing import TYPE_CHECKING
import pytest
import focus_project as fp
from focus_project import _interview, _milestones
from focus_project._errors import RegistryError
from focus_project.tests.helpers import SAT1, START, TEST_AT, TEST_DAY, at, make
if TYPE_CHECKING:
from pathlib import Path
LIVE_ROW = {
"id": "hackerrank-prep",
"name": "HackerRank prep",
"start": "2026-11-02",
"end": "2026-11-05",
"replaces": ["leetcode", "reading", "tutor"],
"daily_minutes": 120,
"unlock_minutes": 30,
"subjects": ["a"],
"site_patterns": ["leetcode.com"],
"ended_on": None,
"created_ms": 1793603609345,
"work_url": "https://www.hackerrank.com/dashboard",
"history": [],
}
def test_live_projects_json_shape_loads(data: Path) -> None:
data.mkdir()
(data / "projects.json").write_text(json.dumps({"projects": [LIVE_ROW]}))
[p] = fp.list_projects()
assert p.kind == "focus"
assert p.test_at is None
assert p.trips == ()
assert p.weekend_answers == ()
assert p.daily_on(date(2026, 11, 3)) == 120
snap = fp.status(date(2026, 11, 3))
assert snap["milestones"] == []
assert snap["missed"] is False
def test_focus_row_stays_free_of_interview_keys(data: Path) -> None:
fp.create_project(
"f",
name="F",
start=START,
end=TEST_DAY,
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
row = json.loads((data / "projects.json").read_text())["projects"][0]
assert row["kind"] == "focus"
assert not {"test_at", "invite_url", "trips", "weekend_answers"} & set(row)
def test_interview_roundtrip_and_file_shape(data: Path) -> None:
p = make(
trips=[fp.Trip(date(2026, 11, 20), date(2026, 11, 22))],
weekend_answers={SAT1: False},
)
[back] = fp.list_projects()
assert back == p
assert back.kind == "interview"
assert back.test_at == TEST_AT
assert back.trips[0].entered_ms > 0
row = json.loads((data / "projects.json").read_text())["projects"][0]
assert row["test_at"] == "2026-11-27T10:00:00"
assert row["trips"][0]["start"] == "2026-11-20"
assert row["weekend_answers"] == {"2026-11-14": False}
assert row["invite_url"].startswith("https://")
@pytest.mark.parametrize(
"bad",
[
{"test_at": None},
{"test_at": at(26, 10)}, # end != test day
{"test_at": at(9, 10), "end": date(2026, 11, 9)},
{"invite_url": "ftp://x.example/t"},
{"invite_url": "javascript:alert(1)"},
{"invite_url": "https://a b.example"},
{"kind": "bogus"},
{"trips": [fp.Trip(date(2026, 11, 28), date(2026, 11, 29))]},
{"trips": [fp.Trip(date(2026, 11, 20), date(2026, 11, 19))]},
{"weekend_answers": {date(2026, 11, 15): True}}, # a Sunday
{"weekend_answers": {date(2026, 11, 28): True}}, # after T
{"daily_minutes": 29},
],
)
def test_interview_validation(bad: dict[str, object]) -> None:
with pytest.raises(ValueError): # noqa: PT011
make(**bad)
def test_focus_kind_rejects_interview_fields() -> None:
with pytest.raises(ValueError, match="interview"):
make(kind="focus")
def test_empty_invite_url_is_allowed() -> None:
assert make(invite_url="").invite_url == ""
def test_budget_30_weekdays_120_weekend() -> None:
p = make(unlock_minutes=60)
assert p.daily_on(date(2026, 11, 10)) == 30 # Tue
assert p.daily_on(date(2026, 11, 13)) == 30 # Fri
assert p.daily_on(SAT1) == 120
assert p.daily_on(date(2026, 11, 15)) == 120 # Sun
assert p.unlock_on(date(2026, 11, 10)) == 30 # clamped to the day's target
assert p.unlock_on(SAT1) == 60
def test_budget_flows_through_status() -> None:
make()
assert fp.status(date(2026, 11, 10))["daily_minutes"] == 30
assert fp.status(SAT1)["daily_minutes"] == 120
def test_interview_exempt_from_floor_but_focus_is_not() -> None:
make(daily_minutes=30, unlock_minutes=30) # 3 gates, floor 90
with pytest.raises(ValueError, match="at least"):
fp.create_project(
"f",
name="F",
start=date(2026, 12, 1),
end=date(2026, 12, 2),
replaces=["leetcode", "reading"],
daily_minutes=30,
unlock_minutes=10,
)
def test_non_interview_has_no_weekends() -> None:
focus = fp.create_project(
"f",
name="F",
start=START,
end=TEST_DAY,
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
assert _interview.saturdays(focus) == []
def test_trip_without_entry_time_uses_the_nominal_due() -> None:
assert _milestones._trip_due(date(2026, 11, 13), 0) == at(12, 12)
def test_non_boolean_weekend_answer_is_a_bad_row(data: Path) -> None:
make()
path = data / "projects.json"
rows = json.loads(path.read_text())
rows["projects"][0]["weekend_answers"] = {"2026-11-14": "false"}
path.write_text(json.dumps(rows))
with pytest.raises(RegistryError):
fp.list_projects()
def test_interview_unlock_edit_keeps_the_ratchet() -> None:
make(unlock_minutes=60)
fp.update_project("iv", today=SAT1, unlock_minutes=10)
assert fp.list_projects()[0].unlock_on(SAT1) == 60 # lower holds from tomorrow
assert fp.list_projects()[0].unlock_on(date(2026, 11, 15)) == 10

View File

@ -14,8 +14,6 @@ import pytest
import focus_project as fp
from focus_project import minutes, sources
from focus_project.sources import leetcode
from focus_project.tests.test_backing import DAY, proj, rows
from focus_project.tests.test_backing import at as day_at
from focus_project.tests.test_minutes import D1, at, project
KEY = b"k" * 32
@ -129,29 +127,3 @@ def test_read_only_replay_of_real_ledger(monkeypatch: pytest.MonkeyPatch) -> Non
assert got
assert all(b - a == sources.HEARTBEAT_WINDOW_S for a, b in got)
assert REAL.stat().st_mtime_ns == before
def test_accepted_submission_backs_a_timer_span(tmp_path: Path) -> None:
"""0.10.0: an accepted submission backs timer time on a backed day."""
proj()
rows(("timer", "start", day_at(9)), ("timer", "stop", day_at(9, 10)))
assert minutes.unbacked_minutes_for(DAY) == 10
write(tmp_path, ac_row(day_at(9, 10)))
assert minutes.unbacked_minutes_for(DAY) == 0
assert minutes.minutes_for(DAY) == 10
def test_unreadable_ledger_leaves_heartbeat_backing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def boom(_project: object) -> list[float]:
raise OSError
monkeypatch.setattr(leetcode, "submission_times", boom)
proj()
rows(
("timer", "start", day_at(9)),
("timer", "stop", day_at(9, 10)),
("tutor", "heartbeat", day_at(9, 10)),
)
assert minutes.unbacked_minutes_for(DAY) == 0

View File

@ -1,228 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Milestone generation, states and the lock that falls out of them."""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING
import focus_project as fp
from focus_project import evidence
from focus_project.tests.helpers import SAT1, SAT2, at, make, session, shot, states
if TYPE_CHECKING:
from pathlib import Path
import pytest
def _due(p: fp.Project, now_day: int = 10) -> dict[str, str]:
return {m.id: m.due.isoformat() for m in fp.milestones(p, at(now_day, 8))}
def test_table_of_dues() -> None:
assert _due(make()) == {
"rules": "2026-11-10T23:59:00",
"trip_ask:2026-11-14": "2026-11-12T23:59:00",
"trip_ask:2026-11-21": "2026-11-19T23:59:00",
"mock": "2026-11-25T23:59:00",
"slot": "2026-11-26T12:00:00",
}
def test_sorted_by_due_and_all_pending_at_start() -> None:
project = make()
items = fp.milestones(project, at(10, 8))
assert [m.due for m in items] == sorted(m.due for m in items)
assert {m.state for m in items} == {"pending"}
assert items[0].to_dict() == {
"id": "rules",
"due": "2026-11-10T23:59:00",
"state": "pending",
}
def test_focus_project_has_none() -> None:
p = fp.create_project(
"f",
name="F",
start=date(2026, 11, 10),
end=date(2026, 11, 12),
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
assert fp.milestones(p, at(11, 8)) == []
def test_unanswered_weekend_is_a_trip_once_ask_is_due() -> None:
p = make()
assert "phone:2026-11-14" not in states(p, at(12, 23, 58))
got = states(p, at(13, 8))
assert got["trip_ask:2026-11-14"] == "missed"
assert got["phone:2026-11-14"] == "pending" # due Fri 12:00
assert states(p, at(13, 12))["phone:2026-11-14"] == "missed"
assert _due(p, 13)["phone:2026-11-14"] == "2026-11-13T12:00:00"
def test_answers_decide_the_phone_milestone() -> None:
make()
no = fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
assert "phone:2026-11-14" not in states(no, at(13, 13))
assert states(no, at(13, 13))["trip_ask:2026-11-14"] == "done"
yes = fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 10))
assert states(yes, at(11, 11))["phone:2026-11-14"] == "pending"
def test_explicit_trip_covers_its_saturday() -> None:
make()
p = fp.add_trip("iv", date(2026, 11, 13), date(2026, 11, 15), now=at(10, 9))
got = states(p, at(13, 8))
assert "phone:2026-11-13" in got
assert "phone:2026-11-14" not in got
assert _due(p)["phone:2026-11-13"] == "2026-11-12T12:00:00"
def test_late_trip_is_due_the_moment_it_is_entered() -> None:
make()
p = fp.add_trip("iv", date(2026, 11, 13), date(2026, 11, 14), now=at(12, 8))
assert _due(p, 12)["phone:2026-11-13"] == "2026-11-12T08:00:00"
assert states(p, at(12, 8))["phone:2026-11-13"] == "missed"
far = fp.add_trip("iv", date(2026, 11, 20), date(2026, 11, 22), now=at(12, 9))
assert _due(far, 12)["phone:2026-11-20"] == "2026-11-19T12:00:00"
def _clean(tmp_path: Path) -> None:
"""Everything due before the slot is done on time; weekend 2 is no trip."""
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
fp.answer_weekend("iv", SAT2, trip=False, now=at(11, 9))
session(at(24, 7), 90)
fp.record_evidence("iv", "mock", "score", 70, now=at(24, 9))
def test_slot_is_due_t_minus_1_at_noon_and_locks_that_evening(tmp_path: Path) -> None:
make()
_clean(tmp_path)
assert fp.owed_lock(at(26, 11, 59)) is None
owed = fp.owed_lock(at(26, 12))
assert owed is not None
assert owed.id == "slot"
assert owed.state == "missed"
assert fp.owed_lock(at(26, 20)) is not None
def test_test_day_never_locks_but_shows_red(tmp_path: Path) -> None:
make()
assert fp.owed_lock(at(27, 9)) is None
assert fp.status(date(2026, 11, 27))["milestones"] is not None
assert {m.state for m in fp.milestones(fp.list_projects()[0], at(27, 9))} >= {
"missed"
}
def test_owed_lock_is_the_earliest_missed() -> None:
make()
owed = fp.owed_lock(at(13, 8))
assert owed is not None
assert owed.id == "rules"
def test_late_completion_unlocks(tmp_path: Path) -> None:
make()
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
owed = fp.owed_lock(at(11, 10))
assert owed is not None
assert owed.id == "rules"
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(11, 10))
p = fp.list_projects()[0]
assert states(p, at(11, 10))["rules"] == "late_done"
assert fp.owed_lock(at(11, 10, 1)) is None
def test_on_time_is_done_at_the_boundary(tmp_path: Path) -> None:
make()
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 23, 58))
p = fp.list_projects()[0]
assert states(p, at(11, 8))["rules"] == "done"
assert states(p, at(10, 23, 59))["rules"] == "done"
def test_slot_needs_entry_and_confirm_tap() -> None:
p = make()
fp.record_evidence("iv", "slot", "slot", "2026-11-26T20:00:00", now=at(25, 9))
assert states(p, at(25, 10))["slot"] == "pending"
assert states(p, at(26, 12))["slot"] == "missed" # entered, never confirmed
fp.record_evidence("iv", "slot", "slot_confirm", value=True, now=at(25, 11))
assert states(p, at(25, 12))["slot"] == "done"
fp.record_evidence("iv", "slot", "slot", "2026-11-26T21:00:00", now=at(25, 13))
assert states(p, at(25, 14))["slot"] == "pending" # moved: confirm again
fp.record_evidence("iv", "slot", "slot_confirm", value=True, now=at(26, 13))
assert states(p, at(26, 14))["slot"] == "late_done"
assert not [s for s in states(p, at(26, 14)).values() if s == "unknown"]
def _phone_setup() -> None:
make()
fp.answer_weekend("iv", SAT1, trip=True, now=at(11, 9))
def test_phone_milestone_states() -> None:
_phone_setup()
p = fp.list_projects()[0]
mid = "phone:2026-11-14"
assert states(p, at(13, 13))[mid] == "missed" # no tap, no check
fp.record_evidence("iv", mid, "tap", value=True, now=at(12, 9))
assert states(p, at(13, 13))[mid] == "unknown" # tap but nothing checked
evidence.append("iv", mid, "check", value=False, now=at(13, 11))
assert states(p, at(13, 13))[mid] == "missed" # checked and failing
evidence.append("iv", mid, "check", value=None, now=at(13, 11, 30))
assert states(p, at(13, 13))[mid] == "unknown" # source went dark
evidence.append("iv", mid, "check", value=True, now=at(13, 11, 40))
assert states(p, at(13, 13))[mid] == "done"
def test_phone_passed_after_due_is_late_done() -> None:
_phone_setup()
mid = "phone:2026-11-14"
fp.record_evidence("iv", mid, "tap", value=True, now=at(12, 9))
evidence.append("iv", mid, "check", value=True, now=at(13, 14))
assert states(fp.list_projects()[0], at(13, 15))[mid] == "late_done"
def test_unknown_locks_like_missed(tmp_path: Path) -> None:
_phone_setup()
mid = "phone:2026-11-14"
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
fp.answer_weekend("iv", SAT2, trip=False, now=at(11, 9))
fp.record_evidence("iv", mid, "tap", value=True, now=at(12, 9))
owed = fp.owed_lock(at(13, 13))
assert owed is not None
assert (owed.id, owed.state) == (mid, "unknown")
def test_unreadable_registry_fails_open_and_loud(
data: Path, caplog: pytest.LogCaptureFixture
) -> None:
data.mkdir()
(data / "projects.json").write_text("{nope")
with caplog.at_level("WARNING"):
assert fp.owed_lock(at(13, 8)) is None
assert "unreadable" in caplog.text
def test_no_project_no_lock() -> None:
assert fp.owed_lock(at(13, 8)) is None
def test_focus_project_never_locks_on_milestones() -> None:
fp.create_project(
"f",
name="F",
start=date(2026, 11, 10),
end=date(2026, 11, 20),
replaces=["leetcode"],
daily_minutes=30,
unlock_minutes=10,
)
assert fp.owed_lock(at(13, 8)) is None

View File

@ -1,61 +0,0 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The mock milestone: a score counts only after a >= 90 min focus session."""
from __future__ import annotations
import focus_project as fp
from focus_project.tests.helpers import at, make, session, states
def test_mock_needs_a_score_after_a_long_session() -> None:
p = make()
assert states(p, at(26, 8))["mock"] == "missed"
session(at(25, 20), 90)
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 8))
assert states(fp.list_projects()[0], at(26, 9))["mock"] == "late_done"
def test_score_without_a_session_does_not_complete_mock() -> None:
p = make()
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
assert states(p, at(26, 8))["mock"] == "missed"
assert fp.owed_lock(at(26, 8)) is not None
def test_short_session_does_not_complete_mock() -> None:
p = make()
session(at(24, 7), 89)
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
def test_session_broken_by_a_gap_does_not_complete_mock() -> None:
p = make()
session(at(24, 6), 60) # 06:00-07:00, then an 11 min hole (> 600 s)
session(at(24, 7, 11), 60)
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
def test_session_must_end_before_the_score() -> None:
p = make()
session(at(24, 8), 90) # ends 09:30
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
fp.record_evidence("iv", "mock", "score", 66, now=at(24, 9, 30))
assert states(p, at(24, 10))["mock"] == "done" # ends exactly at the score
def test_other_projects_sessions_do_not_count() -> None:
p = make()
session(at(24, 7), 90, pid="other")
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
def test_session_on_time_but_score_late_is_late_done() -> None:
p = make()
session(at(25, 20), 100)
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 0, 5))
assert states(p, at(26, 1))["mock"] == "late_done"

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