mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 00:27:13 +01:00
Compare commits
No commits in common. "main" and "focus-project-v0.1.0" have entirely different histories.
main
...
focus-proj
36
.github/workflows/google-account-token-tests.yml
vendored
36
.github/workflows/google-account-token-tests.yml
vendored
@ -1,36 +0,0 @@
|
||||
name: google_account_token tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['google_account_token/**']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['google_account_token/**']
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: google_account_token
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: flutter test --coverage
|
||||
|
||||
- name: Enforce 100% line coverage
|
||||
run: scripts/check_coverage.sh coverage/lcov.info 100
|
||||
73
.github/workflows/holds-precommit.yml
vendored
73
.github/workflows/holds-precommit.yml
vendored
@ -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
|
||||
34
.github/workflows/holds-tests.yml
vendored
34
.github/workflows/holds-tests.yml
vendored
@ -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
|
||||
73
.github/workflows/leetcode-coach-precommit.yml
vendored
73
.github/workflows/leetcode-coach-precommit.yml
vendored
@ -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
|
||||
34
.github/workflows/leetcode-coach-tests.yml
vendored
34
.github/workflows/leetcode-coach-tests.yml
vendored
@ -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
|
||||
7
.github/workflows/md-naming.yml
vendored
7
.github/workflows/md-naming.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/music-theory-tests.yml
vendored
2
.github/workflows/music-theory-tests.yml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
5
.github/workflows/palette-drift.yml
vendored
5
.github/workflows/palette-drift.yml
vendored
@ -31,8 +31,3 @@ jobs:
|
||||
|
||||
- name: Categorical ramp still passes its constraints
|
||||
run: python3 unified-design-system/scripts/ramp_check.py
|
||||
|
||||
# Spacing, radius, type, shadow and motion: palette_check.py parses
|
||||
# hex only, so these scales were unguarded until structural_check.py.
|
||||
- name: Structural scales agree across all token sources
|
||||
run: python3 unified-design-system/scripts/structural_check.py
|
||||
|
||||
73
.github/workflows/sql-drills-precommit.yml
vendored
73
.github/workflows/sql-drills-precommit.yml
vendored
@ -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
|
||||
34
.github/workflows/sql-drills-tests.yml
vendored
34
.github/workflows/sql-drills-tests.yml
vendored
@ -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
|
||||
@ -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: >-
|
||||
|
||||
@ -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
|
||||
15
README.md
15
README.md
@ -42,21 +42,6 @@ dependencies:
|
||||
path: sync_settings_ui
|
||||
```
|
||||
|
||||
## google_account_token/
|
||||
|
||||
Flutter plugin (Android only; other platforms return null): one-tap Google ID
|
||||
token via `AccountManager`, replacing `google_sign_in` in the sync apps.
|
||||
|
||||
Consume via:
|
||||
```yaml
|
||||
dependencies:
|
||||
google_account_token:
|
||||
git:
|
||||
url: https://github.com/kuhyx/utils
|
||||
ref: google_account_token-v0.1.0
|
||||
path: google_account_token
|
||||
```
|
||||
|
||||
## crdt-sync/
|
||||
|
||||
Python CRDT sync library (package: `crdt_sync`, not published to PyPI). Same
|
||||
|
||||
41
TODO-crdt-e2e-encryption.md
Normal file
41
TODO-crdt-e2e-encryption.md
Normal 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
|
||||
153
TODO-pylint-fail-under-10.md
Normal file
153
TODO-pylint-fail-under-10.md
Normal file
@ -0,0 +1,153 @@
|
||||
# TODO — pylint `--fail-under=10` everywhere, and clear what that surfaces
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
|
||||
## what
|
||||
|
||||
Ten pylint hooks across six repos 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 every one to `10`, and fix what
|
||||
that surfaces. Five other sites already run at `10`, so this is adopting an
|
||||
existing standard, not inventing one.
|
||||
|
||||
## the ten sites, exact
|
||||
|
||||
Measured 2026-09-12. "Score now" is that package's current rating under its
|
||||
own config, tests included.
|
||||
|
||||
| Repo / package | Site | Score now |
|
||||
|---|---|---|
|
||||
| `control-panel` | `.pre-commit-config.yaml:159` | 8.53 |
|
||||
| `leetcode-guard` | `.pre-commit-config.yaml:112` | 8.71 |
|
||||
| `screen-locker` | `.pre-commit-config.yaml:121` | 9.85 |
|
||||
| `screen-locker` | `.pre-commit-config.yaml:140` | (2nd pylint hook) |
|
||||
| `steam-backlog-enforcer` | `.pre-commit-config.yaml:124` | 8.76 |
|
||||
| `testsAndMisc/meta` | `.pre-commit-config.yaml:194` | 8.77 |
|
||||
| `utils/crdt-sync` | `.pre-commit-config.yaml:111` | 9.59 |
|
||||
| `utils/freedays` | `.pre-commit-config.yaml:109` | 8.55 |
|
||||
| `utils/gatelock` | `.pre-commit-config.yaml:109` | 9.18 |
|
||||
| `utils/music_theory` | `.pre-commit-config.yaml:113` | 8.54 |
|
||||
|
||||
`screen-locker` has **two** pylint hooks; both need the bump or the second one
|
||||
silently keeps the old bar.
|
||||
|
||||
Already at `10` — do not touch, these are the reference:
|
||||
`build-your-x`, `diet-guard`, `testsAndMisc` (root config),
|
||||
`wake-alarm`, `WUT_Computer_Science/.../translator/.pylintrc`.
|
||||
|
||||
## why the scores look bad, and why that is misleading
|
||||
|
||||
Every one of these configs runs `enable = "all"` with `disable = []` — the
|
||||
strictest pylint setting there is, including checks that are off by default.
|
||||
The raw findings are dominated by **test-file idioms**, not source defects.
|
||||
Measured on the four `utils` packages:
|
||||
|
||||
```
|
||||
crdt-sync 54 protected-access, 50 redefined-outer-name, 18 duplicate-code
|
||||
freedays 100 missing-function-docstring, 14 implicit-booleaness
|
||||
gatelock 147 redefined-outer-name, 142 protected-access
|
||||
music_theory 112 missing-function-docstring, 14 missing-module-docstring
|
||||
```
|
||||
|
||||
`redefined-outer-name` at that volume is pytest fixtures. `protected-access`
|
||||
is tests reaching into internals on purpose. Neither is a defect.
|
||||
|
||||
**The repos already at 10 solved this and wrote down why.** From
|
||||
`wake-alarm/pyproject.toml`, verbatim:
|
||||
|
||||
> `ignore = [".venv", "__pycache__", "tests"]` — "tests" is a basename match:
|
||||
> test suites intentionally use patterns (protected-access, magic-value
|
||||
> comparisons) that don't apply to source code, matching testsAndMisc's
|
||||
> pylint scope (tests are linted by ruff, not pylint there either).
|
||||
|
||||
So the fleet standard is: **pylint grades source, ruff grades tests.** The ten
|
||||
sites above are the outliers that never adopted it. Their `ruff` config
|
||||
already carries the equivalent test exemptions
|
||||
(`"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]`),
|
||||
so tests stay linted — by the tool that has the right rules for them.
|
||||
|
||||
## what is actually left after adopting the fleet config
|
||||
|
||||
Measured: `pylint --ignore=.venv,__pycache__,tests` plus wake-alarm's
|
||||
three-item `disable` list, run over source files only.
|
||||
|
||||
| Package | Score | Remaining findings |
|
||||
|---|---|---|
|
||||
| `crdt-sync` | 9.93 | 5 wrong-import-position, 3 duplicate-code, 1 too-many-locals, 1 invalid-name |
|
||||
| `freedays` | 9.63 | 3 import-error, 1 broad-exception-caught |
|
||||
| `gatelock` | 9.88 | 6 missing-function-docstring, 4 wrong-import-position, 3 too-many-instance-attributes, 3 import-outside-toplevel, 2 broad-exception-caught, 1 each too-many-arguments / invalid-name / duplicate-code / disallowed-name / chained-comparison |
|
||||
| `music_theory` | 9.42 | 10 import-error |
|
||||
|
||||
That is ~47 real findings across four packages, not the ~600 the raw run
|
||||
suggests. **`import-error` is almost certainly config, not code**: every repo
|
||||
already at 10 carries an `init-hook` the 8.0 repos lack —
|
||||
|
||||
```toml
|
||||
init-hook = "import sys; sys.path.insert(0, '.')"
|
||||
```
|
||||
|
||||
Try that first on `music_theory` and `freedays` before editing any import.
|
||||
|
||||
## 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 --include=.pre-commit-config.yaml --include=.pylintrc \
|
||||
-E 'fail-under=(8|9)' ~/src ~/praca_magisterska 2>/dev/null \
|
||||
| grep -v cov-fail-under
|
||||
|
||||
# 2. every one of the nine packages passes its own pylint hook
|
||||
for d in ~/src/control-panel ~/src/leetcode-guard ~/src/screen-locker \
|
||||
~/src/steam-backlog-enforcer ~/src/testsAndMisc/meta \
|
||||
~/src/utils/crdt-sync ~/src/utils/freedays \
|
||||
~/src/utils/gatelock ~/src/utils/music_theory; 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
45
aur-publish/README.md
Normal 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
138
aur-publish/anubis_gate.py
Normal 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())
|
||||
107
aur-publish/lib/aur_publish_one.sh
Normal file
107
aur-publish/lib/aur_publish_one.sh
Normal 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
|
||||
}
|
||||
|
||||
107
aur-publish/lib/aur_registration.sh
Normal file
107
aur-publish/lib/aur_registration.sh
Normal 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
122
aur-publish/lib/aur_verify.sh
Normal file
122
aur-publish/lib/aur_verify.sh
Normal 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
148
aur-publish/publish.sh
Executable 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 "$@"
|
||||
@ -1,38 +0,0 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The todo MCP server's desktop session is seeded by ``seed_session``.
|
||||
|
||||
Split from :mod:`test_tool_seed_session_main` for the 250-line cap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from crdt_sync._config import credential_store_for
|
||||
from crdt_sync._credentials import FirebaseCredentials
|
||||
from tool import seed_session as ss
|
||||
|
||||
_APP = "todo-mcp"
|
||||
|
||||
|
||||
def test_todo_mcp_is_a_default_app() -> None:
|
||||
"""``~/src/todo/mcp`` reads ``~/.config/todo-mcp/firebase_auth.json`` once
|
||||
at start; ``--app todo-mcp`` is rejected by argparse unless it is listed."""
|
||||
assert _APP in ss.DEFAULT_APPS
|
||||
|
||||
|
||||
def test_the_seed_lands_where_the_todo_mcp_server_reads_it(tmp_path: Path) -> None:
|
||||
"""The Dart server's ``seedCredentials`` path, in the usual shape."""
|
||||
with patch.object(Path, "home", return_value=tmp_path):
|
||||
credential_store_for(_APP).save(
|
||||
FirebaseCredentials("fake-id", "fake-refresh", datetime.now(UTC))
|
||||
)
|
||||
written = tmp_path / ".config" / _APP / "firebase_auth.json"
|
||||
assert set(json.loads(written.read_text())) == {
|
||||
"id_token",
|
||||
"refresh_token",
|
||||
"expires_at",
|
||||
}
|
||||
@ -67,8 +67,4 @@ DEFAULT_APPS: Final = (
|
||||
# /kartka/source_health via credential_store_for("kartka-source-eval")
|
||||
# and falls back to the retired password only with no session here.
|
||||
"kartka-source-eval",
|
||||
# ~/src/todo/mcp: the Dart todo MCP server (systemd --user, :8735) is one
|
||||
# more sync node with its own session, read once from
|
||||
# ~/.config/todo-mcp/firebase_auth.json at start.
|
||||
"todo-mcp",
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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});
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)',
|
||||
);
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -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}');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
@ -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 |
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -64,11 +64,6 @@ MAVEN_REPOS: Final = (
|
||||
"https://repo1.maven.org/maven2/",
|
||||
"https://plugins.gradle.org/m2/",
|
||||
)
|
||||
# Asked only when none of the above knows an artifact AND the group is not
|
||||
# `com.github.*` (whose metadata is the 401 above): JitPack also hosts
|
||||
# artifacts under their own group, e.g. `cz.adaptech.tesseract4android`,
|
||||
# whose metadata answers 200 with the full release list.
|
||||
JITPACK_REPO: Final = "https://jitpack.io/"
|
||||
UTILS_TAG_REMOTE: Final = "https://github.com/kuhyx/utils"
|
||||
# JitPack coordinates: `com.github.<owner>:<repo>` or
|
||||
# `com.github.<owner>.<repo>:<module>`; the GitHub repo is what "HEAD" means.
|
||||
@ -91,8 +86,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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -16,9 +16,7 @@ artifacts published nowhere else (PhotoView, DirectionalViewPager). Since
|
||||
themselves still download, so a `com.github.<owner>:<repo>` name that no
|
||||
repository knows is answered from the GitHub repo's tags over `git
|
||||
ls-remote` (unmetered, no token). JitPack's version IS the tag name, `v`
|
||||
prefix included, so the tags are compared as they are. Artifacts JitPack
|
||||
hosts under their OWN group (`cz.adaptech.tesseract4android`) still get
|
||||
200 metadata there, so JitPack is the last resort for any other name.
|
||||
prefix included, so the tags are compared as they are.
|
||||
|
||||
`<latest>` and `<release>` in the metadata are not trusted: AndroidX writes
|
||||
`1.2.0-alpha03` into `<latest>` routinely. The full `<versions>` list goes
|
||||
@ -34,12 +32,7 @@ from __future__ import annotations
|
||||
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from dep_freshness._tables import (
|
||||
GITHUB_REMOTE,
|
||||
JITPACK_GROUP_PREFIX,
|
||||
JITPACK_REPO,
|
||||
MAVEN_REPOS,
|
||||
)
|
||||
from dep_freshness._tables import GITHUB_REMOTE, MAVEN_REPOS
|
||||
from dep_freshness.registries._git import ls_remote
|
||||
from dep_freshness.registries.gitcommit import source_repo
|
||||
from dep_freshness.registries.http import get_text
|
||||
@ -76,15 +69,6 @@ def github_tags(name: str) -> list[str]:
|
||||
return [line.rpartition("refs/tags/")[2] for line in lines if "refs/tags/" in line]
|
||||
|
||||
|
||||
def _jitpack_versions(name: str) -> list[str]:
|
||||
"""What only JitPack knows: tags for `com.github.*`, metadata otherwise."""
|
||||
if name.startswith(JITPACK_GROUP_PREFIX):
|
||||
return github_tags(name)
|
||||
url = metadata_url(JITPACK_REPO, name)
|
||||
body = get_text(url) if url else None
|
||||
return versions_in(body) if body is not None else []
|
||||
|
||||
|
||||
def latest(name: str) -> str | None:
|
||||
seen: list[str] = []
|
||||
for repo in MAVEN_REPOS:
|
||||
@ -95,5 +79,5 @@ def latest(name: str) -> str | None:
|
||||
if body is not None:
|
||||
seen.extend(versions_in(body))
|
||||
if not seen:
|
||||
seen.extend(_jitpack_versions(name))
|
||||
seen.extend(github_tags(name))
|
||||
return reference(seen) or newest_per_generation(seen)
|
||||
|
||||
@ -147,47 +147,6 @@ def test_a_tagless_repo_and_a_non_jitpack_name_are_none(repos, monkeypatch):
|
||||
assert calls == [("https://github.com/o/r", "--tags", "--refs")]
|
||||
|
||||
|
||||
JITPACK = maven.JITPACK_REPO
|
||||
TESSERACT = "cz.adaptech.tesseract4android:tesseract4android"
|
||||
|
||||
|
||||
def test_a_jitpack_only_artifact_is_answered_by_jitpack_metadata(repos):
|
||||
"""Tesseract4Android is published on JitPack under its own group, not
|
||||
`com.github.*`, so there is no source repo to read tags from; JitPack's
|
||||
metadata for it answers 200 and lists every release."""
|
||||
repos[maven.metadata_url(JITPACK, TESSERACT)] = metadata(
|
||||
"4.7.0", "4.9.0", "4.1.1a", latest="4.9.0"
|
||||
)
|
||||
assert maven.latest(TESSERACT) == "4.9.0"
|
||||
|
||||
|
||||
def test_jitpack_is_asked_last_and_only_when_nobody_else_knows_the_name(
|
||||
repos, monkeypatch
|
||||
):
|
||||
asked: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
maven, "get_text", lambda url, *a, **k: asked.append(url) or repos.get(url)
|
||||
)
|
||||
repos[maven.metadata_url(CENTRAL, "io.coil-kt:coil")] = metadata("2.7.0")
|
||||
assert maven.latest("io.coil-kt:coil") == "2.7.0"
|
||||
assert not any(url.startswith(JITPACK) for url in asked)
|
||||
|
||||
|
||||
def test_a_github_jitpack_name_never_asks_jitpack_metadata(repos, monkeypatch):
|
||||
"""That endpoint answers 401 for `com.github.*`; the tags are the answer."""
|
||||
asked: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
maven, "get_text", lambda url, *a, **k: asked.append(url) or None
|
||||
)
|
||||
tags(monkeypatch, {"o/r": ["1.0.0"]})
|
||||
assert maven.latest("com.github.o:r") == "1.0.0"
|
||||
assert not any(url.startswith(JITPACK) for url in asked)
|
||||
|
||||
|
||||
def test_a_name_jitpack_does_not_know_either_is_none(repos):
|
||||
assert maven.latest(TESSERACT) is None
|
||||
|
||||
|
||||
def test_an_unreachable_repo_is_offline(repos, monkeypatch):
|
||||
tags(monkeypatch, {})
|
||||
with pytest.raises(Offline):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
165
dopamine-ux/DOCS-dopamine-ux-index.md
Normal file
165
dopamine-ux/DOCS-dopamine-ux-index.md
Normal 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 | not started | — | — |
|
||||
| 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.**
|
||||
140
dopamine-ux/TODO-01-motion-tokens.md
Normal file
140
dopamine-ux/TODO-01-motion-tokens.md
Normal file
@ -0,0 +1,140 @@
|
||||
Add motion, haptic and sound tokens to the unified design system
|
||||
|
||||
## findings (3, ranked by value over effort)
|
||||
|
||||
1. **No motion token exists in any stack** — greenfield. Every app that wants an
|
||||
animation currently invents its own number, and one already has.
|
||||
2. **No haptic vocabulary exists** — the highest-value gap, because
|
||||
`HapticFeedback` needs no dependency, so three of four apps can adopt it
|
||||
the day this lands.
|
||||
3. **`tokens.md` is 260 lines against a 250 cap** — it cannot absorb a new
|
||||
section, so motion needs a sibling file.
|
||||
|
||||
## what
|
||||
|
||||
The shared design system has colour, spacing, radius, type and shadow tokens —
|
||||
and **no motion tokens at all**. Verified 2026-08-16: no `Duration`, `Curve`,
|
||||
`--duration-*`, `--ease-*`, or haptic constant exists in any stack copy. There is
|
||||
not even a prose position that motion is out of scope.
|
||||
|
||||
Consequence across kuhy's Flutter fleet: **exactly one animation exists in four
|
||||
apps** — a lone `AnimatedContainer(duration: Duration(milliseconds: 200))` with an
|
||||
inline magic number. Every other transition is a Flutter/browser default.
|
||||
|
||||
Create the motion token layer so the per-app prompts (03–09) have something
|
||||
canonical to consume instead of each inventing durations.
|
||||
|
||||
## where
|
||||
|
||||
Repo: `~/src/utils` (git repo, has CI + the file-length gate wired).
|
||||
|
||||
**Create** `~/src/utils/unified-design-system/motion.md` — the prose freeze.
|
||||
|
||||
**Do NOT append to `~/src/utils/unified-design-system/tokens.md`.** It is 260 lines
|
||||
and the cap is 250, so it already fails the gate; adding to it makes that worse.
|
||||
`screen-size.md` was split out of it for exactly this reason — follow that
|
||||
precedent and add a pointer line in `tokens.md` only if you can do so without
|
||||
growing it (i.e. by shortening another line).
|
||||
|
||||
Then mirror the tokens into the three code stacks:
|
||||
|
||||
| Stack | File | Form |
|
||||
|---|---|---|
|
||||
| Flutter/Dart | `~/src/utils/design_system/lib/src/tokens.dart` | `class AppDuration` / `class AppCurve` |
|
||||
| web/React | `~/src/utils/web_ui/src/tokens.css` | `--duration-*`, `--ease-*` custom props |
|
||||
| Python/Tkinter | `~/src/utils/gatelock/gatelock/_window.py` (`class LockConfig`) | int ms fields — **only if meaningful**; see `must not` |
|
||||
|
||||
There are **three** code stacks plus the prose freeze. Do not go hunting for a
|
||||
fourth — it does not exist.
|
||||
|
||||
## must
|
||||
|
||||
- Define a **small** scale. Recommended, but use judgement:
|
||||
`instant 0ms · fast 120ms · base 200ms · slow 320ms`, and easing
|
||||
`standard (0.2, 0, 0, 1)` · `decelerate (0, 0, 0, 1)` · `accelerate (0.3, 0, 1, 1)`.
|
||||
Justify each value in `motion.md` in one line — the file is a freeze, so the
|
||||
reasoning has to survive without you.
|
||||
- Include a **haptic** vocabulary mapped to Flutter's `HapticFeedback` API:
|
||||
which semantic event gets `selectionClick` vs `lightImpact` vs `mediumImpact`.
|
||||
This is the highest-value part — `HapticFeedback` needs no dependency, so
|
||||
three of four apps can adopt it immediately.
|
||||
- Include a **sound** vocabulary: named semantic cues (`confirm`, `complete`,
|
||||
`error`) with the rule that sound ships **on** (opt-out) per app, and that
|
||||
each app owns its own asset files.
|
||||
- State the reduced-motion contract in `motion.md`: web uses
|
||||
`@media (prefers-reduced-motion: reduce)`, Flutter uses
|
||||
`MediaQuery.of(context).disableAnimations`. Motion tokens collapse to `0ms`
|
||||
under it; **haptics and sound are unaffected** (they are not motion).
|
||||
- Keep every file at or under **250 lines** (`~/src/utils/file_length/config.py`,
|
||||
`MAX_LINES = 250`). `.md` and `.py` are capped; `.css` is not.
|
||||
- **Cut tags at the end** — this is the step that makes the work reach consumers:
|
||||
- `design_system-v0.2.0` (consumed by `~/src/todo`, currently on `v0.1.0`)
|
||||
- `web_ui-v0.3.2` (consumed by `~/src/dufs-cloud/web`, currently on `v0.3.1`)
|
||||
Print both tag names at the end of the session; prompts 06, 07 and 08 need them.
|
||||
|
||||
- must not: add motion values to the Tkinter `LockConfig` just for symmetry.
|
||||
Tkinter has no animation framework. If a duration has no meaning there, mark
|
||||
the row `n/a (tk)` in `motion.md` and leave the Python alone. An honest `n/a`
|
||||
beats a dead constant.
|
||||
- must not: express any motion token as a hex-looking value. The existing
|
||||
`palette_check.py` parses `#RRGGBB` and would either ignore it or trip on it.
|
||||
- must not: touch any colour token, or add a colour alongside the motion work.
|
||||
That *would* break `palette_check.py`'s completeness half.
|
||||
- must not: add `# noqa` or `type: ignore` anywhere.
|
||||
|
||||
- optional: a `prefers-reduced-motion` helper in
|
||||
`~/src/utils/design_system/lib/src/` and/or `web_ui/src/` so consumers get the
|
||||
collapse behaviour without reimplementing it. Nice, not required.
|
||||
|
||||
## done
|
||||
|
||||
All four hold:
|
||||
|
||||
1. `~/src/utils/unified-design-system/motion.md` exists, is ≤250 lines, and defines
|
||||
duration, easing, haptic and sound vocabularies with a one-line rationale each.
|
||||
2. `python3 ~/src/utils/file_length/check.py` (or
|
||||
`~/src/utils/scripts/check_file_length.sh --all`) reports **no new** violations.
|
||||
`tokens.md` (260), `nielsen-audit.md` (258) and `README.md` (257) are
|
||||
pre-existing failures — do not fix them here, but do not add a fourth.
|
||||
3. `python3 ~/src/utils/unified-design-system/scripts/palette_check.py` still exits 0.
|
||||
4. Both tags exist: `git -C ~/src/utils tag | grep -E 'design_system-v0.2.0|web_ui-v0.3.2'`
|
||||
prints two lines.
|
||||
|
||||
## verify
|
||||
|
||||
Desktop, any way you like — this prompt ships no UI. Run the two gate commands in
|
||||
`done` and paste their output. Then confirm the token names you chose are what
|
||||
prompts 03–09 will reference by printing the Dart class and the CSS custom-prop
|
||||
block.
|
||||
|
||||
## read first
|
||||
|
||||
- `~/src/utils/unified-design-system/tokens.md` — the existing freeze; match its
|
||||
table format and tone exactly. Note its length problem before editing.
|
||||
- `~/src/utils/unified-design-system/screen-size.md` — the precedent for a sibling
|
||||
file split out of `tokens.md` (32 lines; short and self-contained).
|
||||
- `~/src/utils/design_system/lib/src/tokens.dart` — `AppPalette`, `AppSpacing`,
|
||||
`AppRadius`, `AppTextSize`. Your `AppDuration`/`AppCurve` sit alongside these.
|
||||
- `~/src/utils/web_ui/src/tokens.css` — the full custom-prop list.
|
||||
- `~/src/utils/design_system/lib/src/feedback.dart` — `showToast`/`showError` and
|
||||
`_show`. `confirm.dart` next to it **already imports
|
||||
`package:flutter/services.dart`**, so `HapticFeedback` is one call away with no
|
||||
new import. This is the natural home for a shared haptic helper.
|
||||
- `~/src/utils/unified-design-system/scripts/palette_check.py` — read the four
|
||||
`parse_*` regexes to see why motion tokens are invisible to it. Prompt 02
|
||||
builds the checker that covers them; **do not extend this script here.**
|
||||
|
||||
## context you would otherwise rediscover
|
||||
|
||||
- **Green CI does not mean no drift for these tokens.** `palette_check.py` only
|
||||
matches `#RRGGBB`, so a `--duration-fast: 120ms` is invisible to every one of
|
||||
its parsers. `palette_map.py:159` claims structural values are "checked by the
|
||||
scale check" — **no such script exists**. Prompt 02 writes it.
|
||||
- `~/src/utils/gatelock/` is a package inside the `~/src/utils` monorepo, not a
|
||||
standalone repo. It is consumed as a pinned git dependency.
|
||||
- Only `~/src/todo` depends on `design_system`; diet-guard, workout_app and
|
||||
wake_alarm hand-transcribe the token values into local `lib/ui/theme.dart`
|
||||
copies. So tokens added here reach exactly one app automatically — the others
|
||||
are handled per-prompt.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
139
dopamine-ux/TODO-02-structural-check.md
Normal file
139
dopamine-ux/TODO-02-structural-check.md
Normal file
@ -0,0 +1,139 @@
|
||||
Add a structural-token drift checker to CI
|
||||
|
||||
## findings (2, ranked by value over effort)
|
||||
|
||||
1. **Spacing, radius and type have never been drift-checked** — `palette_check.py`
|
||||
parses hex only, so three whole token categories can diverge across stacks
|
||||
while CI stays green. Motion inherits the same hole.
|
||||
2. **`palette_map.py` points at a "scale check" that does not exist** — a
|
||||
comment describing a guarantee nobody wrote.
|
||||
|
||||
## what
|
||||
|
||||
`palette_check.py` guarantees the colour palette agrees across all stack copies.
|
||||
It does **not** check anything else — verified 2026-08-16 by reading its four
|
||||
parsers, every one of which requires a `#RRGGBB` value:
|
||||
|
||||
```python
|
||||
parse_css : r"(--[a-z0-9-]+)\s*:\s*(#[0-9A-Fa-f]{6})\s*;"
|
||||
parse_dart: r"static const Color (\w+) = Color\(0x[fF]{2}([0-9A-Fa-f]{6})\)"
|
||||
parse_tk : r'^\s{4}(\w+):\s*str\s*=\s*"(#[0-9A-Fa-f]{6})"'
|
||||
parse_md : r"^\|\s*`([a-z0-9-]+)`\s*\|\s*`(#[0-9A-Fa-f]{6})`"
|
||||
```
|
||||
|
||||
So **spacing, radius and type have been silently unprotected all along**, and the
|
||||
motion tokens added by prompt 01 inherit the same hole. A `--duration-fast: 120ms`
|
||||
is invisible to every parser: CI passes while the stacks drift apart.
|
||||
|
||||
Worse, `scripts/palette_map.py:159` claims structural values are "checked by the
|
||||
scale check" — **no such script exists anywhere in `~/src/utils`**. That comment
|
||||
describes a check that was never written. This prompt writes it.
|
||||
|
||||
## where
|
||||
|
||||
Repo: `~/src/utils`.
|
||||
|
||||
**Create** `~/src/utils/unified-design-system/scripts/structural_check.py`.
|
||||
|
||||
**Edit** `~/src/utils/.github/workflows/palette-drift.yml` — add a step invoking it.
|
||||
The workflow deliberately has no `paths:` filter; keep it that way.
|
||||
|
||||
**Fix** the stale comment at `~/src/utils/unified-design-system/scripts/palette_map.py`
|
||||
(~:159) to name the real script.
|
||||
|
||||
Sources to compare (three code stacks + the prose freeze):
|
||||
|
||||
| Role | Path |
|
||||
|---|---|
|
||||
| prose freeze | `~/src/utils/unified-design-system/tokens.md` (spacing/radius/type) |
|
||||
| motion freeze | `~/src/utils/unified-design-system/motion.md` (**created by prompt 01** — run it first) |
|
||||
| web/React | `~/src/utils/web_ui/src/tokens.css` |
|
||||
| Flutter/Dart | `~/src/utils/design_system/lib/src/tokens.dart` |
|
||||
| Python/Tkinter | `~/src/utils/gatelock/gatelock/_window.py` (`class LockConfig`) |
|
||||
|
||||
## must
|
||||
|
||||
- Cover **motion** (durations, easing) **and** the already-unprotected
|
||||
**spacing, radius and type** scales, in one checker.
|
||||
- Mirror `palette_check.py`'s two-halves design, because it is the part that
|
||||
actually works:
|
||||
1. **Agreement** — for each mapped token, resolve it in every non-`n/a` stack
|
||||
and fail if values disagree.
|
||||
2. **Completeness, bidirectional** — every structural value parsed out of any
|
||||
source must be mapped, or explicitly exempted. This is what catches
|
||||
"added it to one stack, forgot the others".
|
||||
- Support an explicit **`n/a` per stack**, and require a reason string. Tkinter
|
||||
has no animation framework; a duration legitimately does not exist there. An
|
||||
honest `n/a` with a reason is correct — a fabricated constant is not.
|
||||
- **Adjudicate with exit codes.** Exit non-zero on drift with a message naming
|
||||
the token and the disagreeing files. No warnings-only mode.
|
||||
- Keep every file ≤ **250 lines** (`~/src/utils/file_length/config.py`). Note
|
||||
`palette_check.py` is 231 and `palette_map.py` is 162 — that pair was split
|
||||
precisely to stay under the cap. **Split yours the same way from the start**
|
||||
(`structural_check.py` + `structural_map.py`) rather than discovering the
|
||||
problem at 251 lines.
|
||||
- Ruff-clean under the repo's config. No `# noqa`, no `type: ignore`.
|
||||
|
||||
- **Expect it to fail on first run.** Spacing/radius/type have never been
|
||||
compared; there may be real pre-existing drift. That is the point of writing
|
||||
it. Fix whatever it surfaces, in the stack copy that is wrong, and say in the
|
||||
session summary what drifted.
|
||||
- must not: weaken the checker, add an exemption, or narrow its scope to make it
|
||||
green. If a token genuinely differs per stack for a good reason, encode it as
|
||||
an `n/a`/variant **with the reason recorded**, not as a silent skip.
|
||||
- must not: modify `palette_check.py`. It works; it is simply colour-only. The
|
||||
two scripts run side by side.
|
||||
- must not: change any token *value* to resolve a disagreement without saying so
|
||||
explicitly — that is a design change, and `tokens.md` is a freeze. Surface it
|
||||
and let kuhy decide which value is canonical.
|
||||
|
||||
- optional: teach it the shadow tier too, if that falls out cheaply.
|
||||
|
||||
## done
|
||||
|
||||
1. `python3 ~/src/utils/unified-design-system/scripts/structural_check.py` exits 0,
|
||||
and exits non-zero when you deliberately break one stack copy (**test both
|
||||
directions** — a checker that never fails is not a checker).
|
||||
2. It is wired into `~/src/utils/.github/workflows/palette-drift.yml` and the
|
||||
workflow passes.
|
||||
3. `palette_map.py`'s "scale check" comment names the real script.
|
||||
4. `~/src/utils/scripts/check_file_length.sh --all` reports no new violations.
|
||||
5. The session summary states what drift, if any, the first run found.
|
||||
|
||||
## verify
|
||||
|
||||
Desktop. Run the script, paste the output. Then prove it fails: change one
|
||||
spacing or duration value in **one** stack copy, re-run, paste the non-zero exit
|
||||
and the message, and revert. Both halves of that demonstration are required —
|
||||
the passing run alone proves nothing.
|
||||
|
||||
## read first
|
||||
|
||||
- `~/src/utils/unified-design-system/scripts/palette_check.py` — the design to
|
||||
mirror. Read `NON_COLOUR_CSS`: it is the regex that currently *excludes*
|
||||
spacing/radius/type/shadow from the colour check, and therefore the precise
|
||||
list of what you are now covering.
|
||||
- `~/src/utils/unified-design-system/scripts/palette_map.py` — the `PALETTE` tuple of
|
||||
`Token(canonical, md, css, dart, tk, why)` rows. Your map wants the same shape,
|
||||
including the `why` field.
|
||||
- `~/src/utils/.github/workflows/palette-drift.yml` — how `palette_check.py` and
|
||||
`ramp_check.py` are invoked; add your step alongside.
|
||||
- `~/src/utils/unified-design-system/motion.md` — what prompt 01 created.
|
||||
- `~/src/utils/unified-design-system/tokens.md` — the spacing/radius/type tables.
|
||||
**Do not edit it**; it is 260 lines and already over the cap.
|
||||
|
||||
## context you would otherwise rediscover
|
||||
|
||||
- The four "stack copies" are really **three code stacks plus a prose freeze**.
|
||||
There is no fourth code stack; do not hunt for one.
|
||||
- `~/src/dufs-cloud/app/lib/ui/theme.dart` is a **fifth, uncompared copy** of the
|
||||
palette (the Flutter app has no `design_system` dependency and hand-restates
|
||||
every hex). It is out of scope here — prompt 07 wires it up properly. Do not
|
||||
add it as a source to this checker; wait until 07 has removed it.
|
||||
- Three Flutter apps (diet-guard, workout_app, wake_alarm) similarly
|
||||
hand-transcribe tokens into local `lib/ui/theme.dart` files. Those are
|
||||
deliberately outside the checker's scope for now — bringing them in is a much
|
||||
larger job than this prompt.
|
||||
- `~/src/utils/gatelock/` is a package inside this monorepo, not a separate repo.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
170
dopamine-ux/TODO-03-diet-guard.md
Normal file
170
dopamine-ux/TODO-03-diet-guard.md
Normal 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
|
||||
173
dopamine-ux/TODO-04-screen-locker.md
Normal file
173
dopamine-ux/TODO-04-screen-locker.md
Normal 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
|
||||
194
dopamine-ux/TODO-05-workout-app.md
Normal file
194
dopamine-ux/TODO-05-workout-app.md
Normal 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
|
||||
182
dopamine-ux/TODO-06-todo.md
Normal file
182
dopamine-ux/TODO-06-todo.md
Normal file
@ -0,0 +1,182 @@
|
||||
Adopt the motion tokens in todo, and make capture feel immediate
|
||||
|
||||
## findings (3, ranked by value over effort)
|
||||
|
||||
1. **Capture gives no confirmation** — a successful capture is indistinguishable
|
||||
from a no-op unless you go looking for the note.
|
||||
2. **Zero motion, zero haptics** — despite being the only app already wired to
|
||||
the shared package, so adoption here is cheapest.
|
||||
3. **The pinned `design_system` tag predates the motion tokens** — nothing
|
||||
compiles until the ref is bumped. Easy to forget, fatal if skipped.
|
||||
|
||||
## what
|
||||
|
||||
`todo` is the only app in kuhy's fleet already wired to the shared
|
||||
`design_system` package — it has **deliberately no local `theme.dart`** (the
|
||||
pubspec comment says so: nothing to drift from the frozen table). That makes it
|
||||
the cheapest place to adopt the motion tokens added by prompt 01, and the
|
||||
reference implementation the other apps get compared against.
|
||||
|
||||
Today it has **zero motion and zero haptics**. Verified 2026-08-16: no
|
||||
`Animated*`, no `AnimationController`, no `HapticFeedback`, no audio. Its only
|
||||
progress UI is the note-editor wizard bar. Capturing a note — the app's whole
|
||||
purpose, and the thing that has to feel frictionless enough to actually do —
|
||||
gives nothing back beyond a toast on error.
|
||||
|
||||
Per kuhy's rule for this project: **reward the capture, never the completion
|
||||
count.** A todo app that celebrates finishing things teaches you to log easy
|
||||
things; one that celebrates *capturing* keeps the backlog honest.
|
||||
|
||||
## where
|
||||
|
||||
Repo: `~/src/todo`.
|
||||
|
||||
**First step — bump the dependency.** `pubspec.yaml` pins `design_system` at git
|
||||
ref `design_system-v0.1.0`, which predates the motion tokens. Bump it to
|
||||
**`design_system-v0.2.0`** (cut by prompt 01) or the tag that prompt actually
|
||||
printed. Without this, `AppDuration` does not exist and nothing else here compiles.
|
||||
|
||||
Primary:
|
||||
- `lib/ui/capture_screen.dart`, `lib/ui/capture_screen_widget.dart`,
|
||||
`lib/ui/capture_screen_sync.dart` — the capture path. `showError` ~:76 and
|
||||
`showToast` ~:78 in `capture_screen_sync.dart` are the fleet's only uses of the
|
||||
shared feedback helpers.
|
||||
- `lib/ui/capture_app_bar.dart` — progress indicators ~:66, :84.
|
||||
- `lib/ui/note_editor.dart` — `showError` ~:115.
|
||||
- `lib/ui/note_editor_step_page.dart` ~:80 — `LinearProgressIndicator(value:
|
||||
(index+1)/total)`, the wizard step bar. The one honest progress surface.
|
||||
- `lib/ui/notes_list_screen.dart` — `_searchDebounce = 250ms` ~:45 (**not**
|
||||
animation; do not tokenise it).
|
||||
- `lib/ui/notes_list_navigation.dart`, `lib/ui/settings_screen.dart` (2×),
|
||||
`lib/ui/capture_screen.dart` — the `MaterialPageRoute` sites, all using the
|
||||
default platform transition.
|
||||
|
||||
Settings — **this app has the only working toggle in the fleet; it is the
|
||||
pattern the other prompts copy**:
|
||||
- `lib/ui/settings_screen.dart` ~:213-225 — `ValueListenableBuilder<AppSettings>`
|
||||
wrapping a `SwitchListTile`; setter ~:111-140 (logs an `AnalyticsEvent`, opens
|
||||
a Firebase client, calls `withAdvancedMode`).
|
||||
- `lib/data/app_settings.dart` — `AppSettings` immutable class,
|
||||
`SharedPreferences` keys ~:38-39, `load()` ~:42, `withAdvancedMode` ~:60,
|
||||
`reconcileWithRemote` ~:98, `adopt` ~:132 (last-writer-wins cross-device sync
|
||||
via `settings/advancedMode` ~:20). Held app-wide as a `ValueNotifier`.
|
||||
|
||||
Theme: no local file. `lib/main.dart` ~:4 imports `design_system`; ~:60-61 wires
|
||||
`theme: buildLightTheme(), darkTheme: buildDarkTheme()`. Source of truth is
|
||||
`~/src/utils/design_system/lib/src/theme.dart`.
|
||||
|
||||
## must
|
||||
|
||||
**Step 1 — motion + haptics (no new dependency).**
|
||||
|
||||
- Bump `design_system` to the new tag **first**, and confirm `AppDuration` /
|
||||
`AppCurve` resolve before writing any other code.
|
||||
- Add `HapticFeedback.selectionClick()` (or the token vocabulary's "confirm" cue)
|
||||
on **successful note capture**. `HapticFeedback` comes from
|
||||
`package:flutter/services.dart` — no pubspec change. Add
|
||||
`<uses-permission android:name="android.permission.VIBRATE"/>` to
|
||||
`android/app/src/main/AndroidManifest.xml`; it is absent today.
|
||||
- Fire the haptic **on the tap**, not after the Firebase sync returns. The whole
|
||||
point of the micro-animation idea is that immediate feedback removes the
|
||||
perceived latency; a cue gated on a network round-trip reintroduces it.
|
||||
- Give the capture a visible confirmation — currently a successful capture is
|
||||
indistinguishable from a no-op unless you go looking.
|
||||
- Animate the wizard `LinearProgressIndicator` between steps rather than jumping.
|
||||
- Apply the shared page-transition/motion tokens to the `MaterialPageRoute` sites
|
||||
so navigation matches the rest of the system.
|
||||
- Honour `MediaQuery.of(context).disableAnimations`: durations collapse to zero,
|
||||
haptics still fire.
|
||||
|
||||
**Step 2 — sound (separate, independently revertable).**
|
||||
|
||||
- Add an audio dependency and an `assets:` block to `pubspec.yaml` (neither
|
||||
exists) plus a short capture-confirm cue.
|
||||
- Sound ships **on**, with an opt-out `SwitchListTile` — copy the
|
||||
`advancedMode` pattern verbatim (widget ~:213-225, setter ~:111-140, field in
|
||||
`app_settings.dart`). It is the fleet's proven toggle; do not invent a new one.
|
||||
- Consider whether the sound preference should sync across devices like
|
||||
`advancedMode` does, or stay local. Local is the safer default — a per-device
|
||||
audio choice is usually what people mean.
|
||||
|
||||
**Both steps:**
|
||||
|
||||
- must not: reward **completing** a todo more than capturing one. No streak on
|
||||
completions, no counter that makes finishing feel better than recording. The
|
||||
backlog is only useful if capture stays frictionless and honest.
|
||||
- must not: add a variable/random reward to capture. There is no real pool to
|
||||
draw from here, so any variance would be manufactured — which fails the
|
||||
project's second test.
|
||||
- must not: fabricate progress. The wizard bar's `(index+1)/total` is true; keep
|
||||
it that way.
|
||||
- must not: hand-copy token values into a local `theme.dart`. This app
|
||||
deliberately has none — that is a feature, and the pubspec comment says so.
|
||||
- must not: add `# noqa`-equivalents or suppress analyzer warnings.
|
||||
|
||||
- optional: animate the notes-list reordering/filtering, if it falls out cheaply
|
||||
from the shared tokens.
|
||||
|
||||
## done
|
||||
|
||||
1. `pubspec.yaml` references the new `design_system` tag and `flutter pub get`
|
||||
resolves it.
|
||||
2. Capturing a note produces a haptic within ~50ms of the tap and a visible
|
||||
confirmation.
|
||||
3. Wizard step transitions and page navigation use shared motion tokens; no
|
||||
inline `Duration` remains for animation purposes (the 250ms search debounce
|
||||
and 5s auto-sync debounce stay as they are — they are not motion).
|
||||
4. `cd ~/src/todo && flutter analyze` is clean.
|
||||
5. `cd ~/src/todo && flutter test` passes.
|
||||
6. With OS "remove animations" enabled, the app works and durations are zero.
|
||||
7. Step 2: the sound toggle flips, persists across restart, and silences the cue.
|
||||
|
||||
## verify
|
||||
|
||||
**On the phone — todo is mobile-primary.**
|
||||
|
||||
```
|
||||
adb devices # confirm 23181JEGR08034
|
||||
cd ~/src/todo
|
||||
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 notes. `adb install -r` preserves them.
|
||||
|
||||
Then capture a real note on the device and report what the phone did. A haptic is
|
||||
a physical outcome: say what you felt, do not infer it from code. Also step
|
||||
through the note-editor wizard to confirm the progress bar animates.
|
||||
|
||||
If the app also has a desktop/web surface deployed under `/opt/`, note that it
|
||||
can go stale independently — but this prompt's done-condition is the phone.
|
||||
|
||||
## read first
|
||||
|
||||
- `lib/ui/settings_screen.dart` ~:213-225 and `lib/data/app_settings.dart` — the
|
||||
toggle pattern, in full, before writing the sound switch.
|
||||
- `lib/ui/capture_screen_sync.dart` ~:76-78 — how `showToast`/`showError` are
|
||||
used today; the shared helpers live in
|
||||
`~/src/utils/design_system/lib/src/feedback.dart`.
|
||||
- `~/src/utils/design_system/lib/src/feedback.dart` — `showToast`/`showError`, `_show`
|
||||
~:50. `confirm.dart` beside it **already imports `flutter/services.dart`**, so
|
||||
if prompt 01 added a shared haptic helper it lives near here.
|
||||
- `~/src/utils/unified-design-system/motion.md` — the vocabulary from prompt 01.
|
||||
**Prompt 01 must have run first, and its tag must be cut.**
|
||||
- `pubspec.yaml` — read the comment explaining why there is no local theme.
|
||||
|
||||
## context you would otherwise rediscover
|
||||
|
||||
- `showToast` and `showError` already carry fixed dwell times in the shared
|
||||
package (`_toastDuration = 3s`, `_errorDuration = 5s`,
|
||||
`feedback.dart` ~:14, :18). Those are **not** motion tokens; leave them.
|
||||
- This is the **only** app on `design_system`. diet-guard, workout_app and
|
||||
wake_alarm hand-transcribe tokens into local theme files, so a widget copied
|
||||
from this app into one of those will not compile unmodified.
|
||||
- The app has no streaks, no rings, no history charts, and no completion counts
|
||||
today. That is a deliberate starting point for this prompt, not an oversight to
|
||||
fix wholesale.
|
||||
- Settings sync cross-device last-writer-wins through Firebase
|
||||
(`settings/advancedMode`). If you add a synced preference, follow
|
||||
`reconcileWithRemote`/`adopt`; if local-only, skip that path entirely.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
141
dopamine-ux/TODO-07-dufs-theme.md
Normal file
141
dopamine-ux/TODO-07-dufs-theme.md
Normal file
@ -0,0 +1,141 @@
|
||||
Wire dufs-cloud's Flutter app to the shared design_system
|
||||
|
||||
## findings (2, ranked by value over effort)
|
||||
|
||||
1. **`app/lib/ui/theme.dart` is a fifth, uncompared palette copy** — hand-copied
|
||||
hexes that `palette_check.py` has never seen, so it can drift silently while
|
||||
CI stays green.
|
||||
2. **The web half of the same repo is wired correctly** — which makes the
|
||||
Flutter half's divergence an accident, not a decision.
|
||||
|
||||
## what
|
||||
|
||||
`~/src/dufs-cloud/app/lib/ui/theme.dart` is a **fifth, uncompared copy of the
|
||||
palette**. Verified 2026-08-16: the Flutter app has no `design_system`
|
||||
dependency at all — it hand-restates every hex (`Color(0xFF211D1B) // ink`,
|
||||
`Color(0xFFB8862E) // accent`, …) with the shared token names appearing only in
|
||||
comments. `palette_check.py` has never seen this file, so it can drift from the
|
||||
frozen table silently and CI will stay green.
|
||||
|
||||
The web half of the same repo is wired correctly (`@kuhyx/web-ui`), which makes
|
||||
the Flutter half's divergence an accident rather than a decision.
|
||||
|
||||
Fix the root cause: add the dependency, replace the hand-copied hexes with token
|
||||
references, and delete the duplicate. This must land **before** prompt 08 adds
|
||||
motion to this app, so that motion tokens arrive through the package instead of
|
||||
becoming a sixth hand-copy.
|
||||
|
||||
## where
|
||||
|
||||
Repo: `~/src/dufs-cloud`. Flutter app: `~/src/dufs-cloud/app` (package `dufs_client`).
|
||||
|
||||
- `app/pubspec.yaml` — current deps: `archive, cupertino_icons, flutter,
|
||||
flutter_secure_storage, http, image_picker, media_kit*, path, path_provider,
|
||||
share_plus, shared_preferences, webview_flutter, xml`. **No `design_system`.**
|
||||
- `app/lib/ui/theme.dart` — the hand-copy to eliminate.
|
||||
- Every file referencing a colour from it — find them with a grep for the theme's
|
||||
exported symbols rather than trusting any list here.
|
||||
|
||||
Source of truth:
|
||||
- `~/src/utils/design_system/lib/src/tokens.dart` — `AppPalette`, `AppSpacing`,
|
||||
`AppRadius`, `AppTextSize` (plus `AppDuration`/`AppCurve` if prompt 01 ran).
|
||||
- `~/src/utils/design_system/lib/src/theme.dart` — `buildLightTheme()`,
|
||||
`buildDarkTheme()`.
|
||||
|
||||
Reference implementation: `~/src/todo` is the only app already on the package. Its
|
||||
`pubspec.yaml` uses a **git ref** (`design_system-v0.1.0`) and it has
|
||||
deliberately **no local `theme.dart`**. Copy that shape.
|
||||
|
||||
## must
|
||||
|
||||
- Add `design_system` to `app/pubspec.yaml` using the **git ref** style matching
|
||||
`~/src/todo` — not a local `path:` dependency. One dependency convention across the
|
||||
fleet. Pin the tag prompt 01 cut (`design_system-v0.2.0` or whatever it
|
||||
printed); if prompt 01 has not run, pin `design_system-v0.1.0` and let prompt
|
||||
08 do the bump.
|
||||
- Replace every hand-copied hex in `app/lib/ui/theme.dart` with the corresponding
|
||||
token reference. **Verify each swap is value-identical before making it** — the
|
||||
survey found the values byte-identical today, but confirm rather than assume;
|
||||
a silent colour change in a live app is a real regression.
|
||||
- Delete the duplicated token declarations once nothing references them. If the
|
||||
app declares helper classes the shared package lacks, keep only those, and say
|
||||
in the session summary which ones survived and why.
|
||||
- Keep the app's visual output **identical**. This is a pure de-duplication: if
|
||||
anything looks different afterwards, a value was wrong and you have found a
|
||||
pre-existing drift — report it rather than papering over it.
|
||||
|
||||
- must not: change any colour, spacing, radius or type **value**. The shared
|
||||
table is a freeze. If a value genuinely disagrees, stop and surface it — that
|
||||
is a finding, and kuhy decides which side is canonical.
|
||||
- must not: add motion/animation here. That is prompt 08. This prompt is
|
||||
dependency plumbing only, so that a visual regression has exactly one possible
|
||||
cause.
|
||||
- must not: touch `~/src/dufs-cloud/web/` — its `@kuhyx/web-ui` wiring is already
|
||||
correct.
|
||||
- must not: leave a partial migration. Either the local theme file is gone (or
|
||||
reduced to app-specific extras) and everything resolves through the package, or
|
||||
the change is not done. A half-migrated theme is worse than the current state
|
||||
because it hides *which* copy is authoritative.
|
||||
|
||||
- optional: once migrated, add `app/lib/ui/theme.dart`'s path to the structural
|
||||
checker from prompt 02 if that is now meaningful. Probably it is not — after
|
||||
this prompt there is nothing left there to compare.
|
||||
|
||||
## done
|
||||
|
||||
1. `app/pubspec.yaml` declares `design_system` at a git ref; `flutter pub get`
|
||||
resolves.
|
||||
2. No hard-coded palette hex remains in `app/lib/`. A grep for `Color(0xFF` in
|
||||
`app/lib/ui/theme.dart` returns nothing (or only app-specific values that are
|
||||
not palette tokens — name them if so).
|
||||
3. `cd ~/src/dufs-cloud/app && flutter analyze` is clean.
|
||||
4. `cd ~/src/dufs-cloud/app && flutter test` passes.
|
||||
5. The app looks **the same** as before on device — verified by screenshot
|
||||
comparison, not by reasoning about the diff.
|
||||
6. `python3 ~/src/utils/unified-design-system/scripts/palette_check.py` still exits 0.
|
||||
|
||||
## verify
|
||||
|
||||
**On the phone**, and with a before/after comparison — a pure refactor is only
|
||||
verified by showing nothing changed.
|
||||
|
||||
```
|
||||
adb devices
|
||||
cd ~/src/dufs-cloud/app
|
||||
flutter build apk --release
|
||||
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
Take a screenshot of the browser screen and the settings screen **before**
|
||||
starting (from the currently installed build) and after, and compare them. Never
|
||||
`adb uninstall` or `pm clear` — the app holds a stored credential
|
||||
(`flutter_secure_storage`) and wiping it means re-authenticating.
|
||||
|
||||
## read first
|
||||
|
||||
- `~/src/dufs-cloud/app/lib/ui/theme.dart` — in full, before touching anything. The
|
||||
comments naming each token are your mapping table.
|
||||
- `~/src/todo/pubspec.yaml` — the git-ref dependency line to copy, and the comment
|
||||
explaining why no local theme exists.
|
||||
- `~/src/utils/design_system/lib/src/tokens.dart` — the canonical names.
|
||||
- `~/src/dufs-cloud/DESIGN_AUDIT_TODO.md` — a prior audit of this repo; check whether
|
||||
it already records theme findings so you do not contradict it.
|
||||
|
||||
## context you would otherwise rediscover
|
||||
|
||||
- The web half consumes `@kuhyx/web-ui` at tag **`web_ui-v0.3.1`**, declared in
|
||||
`~/src/dufs-cloud/web/package.json` as
|
||||
`"github:kuhyx/utils#web_ui-v0.3.1&path:/web_ui"`. It is correctly wired; the
|
||||
only app-local CSS value is `--overlay: rgba(33, 29, 27, 0.82)` in
|
||||
`web/src/index.css`. Prompt 08 bumps that tag — not this prompt.
|
||||
- `@kuhyx/web-ui` is **pnpm-only**; npm cannot install a monorepo subdirectory.
|
||||
Irrelevant here (this prompt is Flutter-only) but fatal if you wander into
|
||||
`web/`.
|
||||
- `palette_check.py` compares four sources and has never included this file, so
|
||||
"CI is green" told you nothing about it. That is the whole reason this prompt
|
||||
exists.
|
||||
- Three other Flutter apps (diet-guard, workout_app, wake_alarm) have the same
|
||||
hand-copy pattern. They are **out of scope** — migrating them is a separate,
|
||||
larger job, and doing one app properly is the point here.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
198
dopamine-ux/TODO-08-dufs-motion.md
Normal file
198
dopamine-ux/TODO-08-dufs-motion.md
Normal 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
|
||||
164
dopamine-ux/TODO-09-wake-alarm.md
Normal file
164
dopamine-ux/TODO-09-wake-alarm.md
Normal 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
|
||||
209
dopamine-ux/verify_prompts.py
Normal file
209
dopamine-ux/verify_prompts.py
Normal 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())
|
||||
@ -168,14 +168,17 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
||||
- `done_today(earner, ledger, key_file, *, now=None) -> bool | None`
|
||||
- `first_credit_at(earner, ledger, key_file, day) -> float | None` — unix time
|
||||
of the earliest verified credit for `day`, on its matcher's stamp
|
||||
(`_match.CREDIT_STAMPS`; else `created_at`); it can lie outside `day`.
|
||||
- `credit_units(earner, ledger, key_file, day) -> int | None` — the units the
|
||||
verified rows pay for `day`: 1 per row (the workout), or the row's own count
|
||||
(`detail.minutes`: tutor, legacy blocks 15, from 0.10.0 scaled to its weekday
|
||||
quota; focus). A repeated `entry_id` pays once; `resolve` applies `max_units`.
|
||||
(`_match.CREDIT_STAMPS`; Anki/Automation: `created_at`). It can lie outside
|
||||
`day` (Anki's rollover, a rest day declared the evening before).
|
||||
- `credit_units(earner, ledger, key_file, day) -> int | None` — the units
|
||||
the verified rows pay for `day`: 1 per row (the workout), or the row's own
|
||||
count (the tutor's `detail.minutes`, legacy block rows 15; from 0.10.0 the
|
||||
day's sum scaled to its weekday quota). A repeated `entry_id` pays once,
|
||||
its smallest count; `max_units` is applied by `resolve`.
|
||||
- `earners_for(day)`, `all_earners()`, `registries()` — the registry in force
|
||||
on a day, and every earner of every registry (what a ledger watcher must
|
||||
watch). They read `earned_time.EARNERS` through the package, so a patch holds.
|
||||
watch). They read `earned_time.EARNERS` through the package, so a consumer
|
||||
test that patches it still registers its stand-in.
|
||||
- `shutdown_minutes_for(earner, day)`, `extra_shutdown_minutes_for(earner, day)`,
|
||||
`shutdown_ceiling_for(day)` (ints), `on_ladder(day) -> bool` — what a status
|
||||
view lists as still left to earn. `Earner.shutdown_for(units, day=None)` is
|
||||
@ -184,22 +187,14 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
||||
|
||||
## The workout ledger
|
||||
|
||||
`WORKOUT` is `kind="counted"`, ledger `~/.local/share/workout_locker/ledger.json`
|
||||
(screen-locker; one signed credit row per unit, `detail.completed_at`/`source`).
|
||||
`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)
|
||||
|
||||
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):
|
||||
`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).
|
||||
`WORKOUT` is `kind="counted"` with a ledger at
|
||||
`~/.local/share/workout_locker/ledger.json`, written by screen-locker: one
|
||||
signed credit row per credited unit,
|
||||
`{"kind": "credit", "entry_id", "day", "detail": {"completed_at", "source"}}`.
|
||||
`workout_match` counts a `runnerup_tcx` row when `completed_at` falls in the
|
||||
window, and a `rest_day` row on the `day` it names — only if
|
||||
`detail.declared_at` is before that day's local midnight. A rest day declared
|
||||
on the day itself is logged and does not count.
|
||||
|
||||
## Adding a gate
|
||||
|
||||
@ -213,39 +208,43 @@ Unbacked timer time pays 0 from 2026-11-08 (focus_project 0.10.0).
|
||||
```python
|
||||
PIANO = Earner(
|
||||
name="piano", # also screen-locker's "piano_bonus_date" stamp
|
||||
label="piano",
|
||||
gaming_minutes=30,
|
||||
shutdown_minutes=30, # pre-ladder value
|
||||
penalty_from=date(2026, 11, 1), # gaming base drops by 30 from then on
|
||||
ledger=".local/share/piano_guard/ledger.json",
|
||||
match=piano_match, # which verified credit rows count today
|
||||
missing_ledger_is_no=True,
|
||||
)
|
||||
```
|
||||
|
||||
Then a rung in `TUTOR_LADDER` (`PIANO.name: Rung(20)`) and, for an event
|
||||
stamp, an entry in `_match.CREDIT_STAMPS`.
|
||||
Then give it a rung in `TUTOR_LADDER` (`PIANO.name: Rung(20)`), and, if its
|
||||
matcher decides on an event stamp, an entry in `_match.CREDIT_STAMPS`.
|
||||
|
||||
A new rung comes out of the floor from its penalty start (a pure bonus:
|
||||
the day its pin lands): ship it the day the gate can be satisfied.
|
||||
3. Bump, tag `earned-time-vX.Y.Z`, re-pin every consumer (no code needed).
|
||||
On the ladder a new earner's rung comes out of the floor from its penalty
|
||||
start (a pure bonus: the day its pin lands; the ceiling is fixed): re-split
|
||||
the rungs and ship it the day the gate can actually be satisfied.
|
||||
3. Bump the version, tag `earned-time-vX.Y.Z`, and bump the pin in both
|
||||
consumers. Neither needs code for a ledger-backed flat earner.
|
||||
|
||||
## Rules the consumers rely on
|
||||
|
||||
- **Minutes everywhere.** Shutdown is minutes after local midnight.
|
||||
- **Paths are passed in.** Only the switch file is resolved here
|
||||
(patch `earned_time._leisure.LEISURE_FROM_PATH`).
|
||||
- **Paths are passed in.** No reader resolves a real path itself, except the
|
||||
switch file: consumer tests patch `earned_time._leisure.LEISURE_FROM_PATH`.
|
||||
- **`None` is "could not check", never "no".** It earns nothing (fail closed),
|
||||
but consumers log it. A missing answer in `resolve()` is logged as well; an
|
||||
unknown earner name raises.
|
||||
- **The HMAC key is root 0644 on purpose.** User-run gates sign rows with it;
|
||||
it stops casual edits, not a determined forger with a shell.
|
||||
- **Stdlib only.** The root gaming daemon imports this headless; `focus_project`
|
||||
(stdlib too) is imported lazily and may be absent.
|
||||
- **The HMAC key is root-owned and 0644 on purpose.** User-run gates must sign
|
||||
rows, so the user reads it; root owns it so no user process can replace it.
|
||||
It stops casual edits and corruption, not a determined forger with a shell.
|
||||
- **Stdlib only.** The gaming daemon imports this as root, headless.
|
||||
|
||||
## Install
|
||||
|
||||
```console
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.20.0#subdirectory=earned_time"
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.18.0#subdirectory=earned_time"
|
||||
```
|
||||
|
||||
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy` and imports from
|
||||
the same user site-packages. Test: `scripts/run_subproject_tests.sh earned_time`.
|
||||
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports
|
||||
from the same user site-packages. Test: `scripts/run_subproject_tests.sh earned_time`.
|
||||
|
||||
@ -43,7 +43,6 @@ from earned_time._credits import (
|
||||
first_credit_at,
|
||||
)
|
||||
from earned_time._evidence import real_credit
|
||||
from earned_time._focus import FOCUS_LEDGER, FocusEarner
|
||||
from earned_time._ladder import (
|
||||
ANKI_WAIVED_FROM,
|
||||
LADDER,
|
||||
@ -106,7 +105,6 @@ __all__ = [
|
||||
"ANKI_WAIVED_FROM",
|
||||
"AUTOMATION",
|
||||
"EARNERS",
|
||||
"FOCUS_LEDGER",
|
||||
"GAMING_BASE_MINUTES",
|
||||
"GAMING_CEILING_MINUTES",
|
||||
"LADDER",
|
||||
@ -134,7 +132,6 @@ __all__ = [
|
||||
"WORKOUT",
|
||||
"Base",
|
||||
"Earner",
|
||||
"FocusEarner",
|
||||
"Level",
|
||||
"Maturity",
|
||||
"Resolution",
|
||||
|
||||
@ -20,7 +20,6 @@ from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from earned_time._focus import FOCUS_WATCH
|
||||
from earned_time._maturity import Maturity, maturity
|
||||
from earned_time._registry import all_earners, earners_for
|
||||
|
||||
@ -58,14 +57,9 @@ def _gates(day: date) -> list[tuple[str, Earner, Maturity]]:
|
||||
and after ``TUTOR_FROM`` until 0.9.0); those get their ledger's directory
|
||||
appended.
|
||||
"""
|
||||
registered = earners_for(day)
|
||||
earners = [
|
||||
e
|
||||
for e in (*all_earners(), *registered)
|
||||
if e.ledger is not None and e is not FOCUS_WATCH
|
||||
]
|
||||
earners = list(dict.fromkeys(earners))
|
||||
earners = [e for e in all_earners() if e.ledger is not None]
|
||||
names = Counter(e.name for e in earners)
|
||||
registered = earners_for(day)
|
||||
gates = []
|
||||
for item in earners:
|
||||
ledger = Path(str(item.ledger))
|
||||
|
||||
@ -1,121 +0,0 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The focus project (0.19.0): replaced gates stand down, one earner pays."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
import importlib
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
|
||||
from earned_time._ladder import shutdown_units_minutes
|
||||
from earned_time._match import focus_match
|
||||
from earned_time._policy import Earner
|
||||
|
||||
__all__ = [
|
||||
"FOCUS_LEDGER",
|
||||
"FOCUS_WATCH",
|
||||
"FocusEarner",
|
||||
"focus_earner",
|
||||
"with_focus",
|
||||
]
|
||||
|
||||
FOCUS_LEDGER: Final = ".local/share/focus_project/ledger.json"
|
||||
_SYNONYMS: Final = {"book": "reading"}
|
||||
_logger: Final = logging.getLogger(__name__)
|
||||
_warned: set[str] = set()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
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).
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
def shutdown_for(
|
||||
self,
|
||||
units: int,
|
||||
day: date | None = None, # noqa: ARG002
|
||||
) -> int:
|
||||
"""Shutdown minutes of ``units`` focused minutes, floored."""
|
||||
return self.capped(units) * self.shutdown_minutes // (self.max_units or 1)
|
||||
|
||||
|
||||
def _active(day: date) -> Any: # noqa: ANN401
|
||||
"""The active project on ``day``; ``None`` for none or on any failure."""
|
||||
try:
|
||||
return importlib.import_module("focus_project").active(day)
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
message = f"focus_project unusable ({type(exc).__name__}: {exc})"
|
||||
if message not in _warned:
|
||||
_warned.add(message)
|
||||
_logger.warning("%s; behaving as if no project is active", message)
|
||||
return None
|
||||
|
||||
|
||||
def daily_on(project: Any, day: date) -> int: # noqa: ANN401
|
||||
"""``day``'s target: ``project.daily_on(day)`` (a later edit never re-prices it)."""
|
||||
pick = getattr(project, "daily_on", None)
|
||||
return int(pick(day) if pick is not None else project.daily_minutes)
|
||||
|
||||
|
||||
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)
|
||||
return FocusEarner(
|
||||
name="focus",
|
||||
label="Focus",
|
||||
gaming_minutes=cut + sum(e.max_gaming_minutes for e in free),
|
||||
cut_gaming_minutes=cut,
|
||||
shutdown_minutes=sum(
|
||||
shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped
|
||||
),
|
||||
kind="counted",
|
||||
max_units=daily_on(project, day),
|
||||
penalty_from=project.start,
|
||||
confirmed_on=project.start - timedelta(days=1),
|
||||
ledger=FOCUS_LEDGER,
|
||||
match=focus_match,
|
||||
missing_ledger_is_no=True,
|
||||
)
|
||||
|
||||
|
||||
FOCUS_WATCH: Final = FocusEarner(
|
||||
name="focus",
|
||||
label="Focus",
|
||||
gaming_minutes=0,
|
||||
shutdown_minutes=0,
|
||||
kind="counted",
|
||||
max_units=1,
|
||||
ledger=FOCUS_LEDGER,
|
||||
match=focus_match,
|
||||
missing_ledger_is_no=True,
|
||||
)
|
||||
|
||||
|
||||
def with_focus(registry: tuple[Earner, ...], day: date) -> tuple[Earner, ...]:
|
||||
"""``registry`` reshaped by the active project; unchanged if none."""
|
||||
project = _active(day)
|
||||
if project is None:
|
||||
return registry
|
||||
gates = {_SYNONYMS.get(g, g) for g in project.replaces}
|
||||
dropped = tuple(e for e in registry if e.name in gates)
|
||||
if not dropped or daily_on(project, day) < 1:
|
||||
return registry
|
||||
kept = tuple(e for e in registry if e not in dropped)
|
||||
return (*kept, focus_earner(project, dropped, day))
|
||||
@ -156,15 +156,13 @@ def shutdown_minutes_for(item: Earner, day: date) -> int:
|
||||
without a rung, its own ``shutdown_minutes``.
|
||||
"""
|
||||
rung = _rung(item, day)
|
||||
return item.shutdown_for(1, day) if rung is None else rung.first
|
||||
return item.shutdown_minutes if rung is None else rung.first
|
||||
|
||||
|
||||
def extra_shutdown_minutes_for(item: Earner, day: date) -> int:
|
||||
"""What ``item``'s second unit earns on ``day`` (counted only)."""
|
||||
rung = _rung(item, day)
|
||||
if rung is not None:
|
||||
return rung.second
|
||||
return item.shutdown_for(2, day) - item.shutdown_for(1, day)
|
||||
return item.extra_shutdown_minutes if rung is None else rung.second
|
||||
|
||||
|
||||
def shutdown_units_minutes(item: Earner, units: int, day: date) -> int:
|
||||
|
||||
@ -167,30 +167,6 @@ def tutor_match(row: dict[str, object], window: Window) -> bool:
|
||||
return landed
|
||||
|
||||
|
||||
def focus_minutes(row: dict[str, object]) -> int | None:
|
||||
"""Minutes a focus row pays: ``detail.minutes``, a positive int, else ``None``."""
|
||||
detail = row.get("detail")
|
||||
raw = detail.get("minutes") if isinstance(detail, dict) else None
|
||||
if isinstance(raw, bool) or not isinstance(raw, int) or raw < 1:
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
def focus_match(row: dict[str, object], window: Window) -> bool:
|
||||
"""A focus credit counts on the day of ``detail.ended_at``, else its ``day``."""
|
||||
if focus_minutes(row) is None:
|
||||
_logger.warning("focus credit %r has bad minutes", row.get("entry_id"))
|
||||
return False
|
||||
detail = row.get("detail")
|
||||
landed = _within(
|
||||
detail.get("ended_at") if isinstance(detail, dict) else None, window
|
||||
)
|
||||
if landed is not None:
|
||||
return landed
|
||||
local = datetime.fromtimestamp(window[0], tz=UTC).astimezone()
|
||||
return row.get("day") == local.date().isoformat()
|
||||
|
||||
|
||||
# The ``detail`` field each matcher decides on: the unix second the work
|
||||
# happened, which :func:`earned_time._credits.credit_time` reports. A matcher
|
||||
# without one (Anki's day key) falls back to the row's ``created_at``.
|
||||
@ -200,7 +176,6 @@ CREDIT_STAMPS: Final[Mapping[CreditMatch, str]] = MappingProxyType(
|
||||
reading_match: "ended_at",
|
||||
workout_match: "completed_at",
|
||||
tutor_match: "ended_at",
|
||||
focus_match: "ended_at",
|
||||
}
|
||||
)
|
||||
# How many units one counting row pays, per matcher; a matcher without an
|
||||
@ -208,7 +183,7 @@ CREDIT_STAMPS: Final[Mapping[CreditMatch, str]] = MappingProxyType(
|
||||
# count (``detail.minutes``): book-guard's ``detail.minutes`` is a string
|
||||
# and means something else, so this is never read generically.
|
||||
ROW_UNITS: Final[Mapping[CreditMatch, Callable[[dict[str, object]], int | None]]] = (
|
||||
MappingProxyType({tutor_match: tutor_minutes, focus_match: focus_minutes})
|
||||
MappingProxyType({tutor_match: tutor_minutes})
|
||||
)
|
||||
# Which ledger row kinds each matcher is shown; a matcher without an entry
|
||||
# sees only ``credit`` rows. LeetCode also sees ``charge`` rows, so a day
|
||||
|
||||
@ -31,7 +31,6 @@ from datetime import UTC, date, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
from earned_time._evidence import credit_history
|
||||
from earned_time._focus import FocusEarner
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -101,12 +100,11 @@ def penalty_start(earner: Earner, first_credit: date | None) -> date | None:
|
||||
(never paid out, or could not check): :func:`confirmed_start` when kuhy
|
||||
set ``confirmed_on`` -- fail closed, deleting a ledger lifts nothing --
|
||||
else ``None``, not penalised. ``None`` for a pure bonus. Never earlier
|
||||
than ``penalty_from``. A focus earner is never delayed: its first credit
|
||||
comes on the day its penalty starts (0.19.0).
|
||||
than ``penalty_from``.
|
||||
"""
|
||||
start = confirmed_start(earner)
|
||||
if start is None or isinstance(earner, FocusEarner):
|
||||
return start
|
||||
if start is None:
|
||||
return None
|
||||
if first_credit is None:
|
||||
return start if earner.confirmed_on is not None else None
|
||||
return max(start, first_credit + _DAY)
|
||||
|
||||
@ -14,7 +14,6 @@ from functools import cache
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from earned_time._focus import FOCUS_WATCH, with_focus
|
||||
from earned_time._ladder import ANKI_WAIVED_FROM, TUTOR_FROM
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -39,14 +38,12 @@ def earners_for(day: date | None = None) -> tuple[Earner, ...]:
|
||||
"""The registry in force on ``day`` (default today).
|
||||
|
||||
Between ``ANKI_WAIVED_FROM`` and ``TUTOR_FROM`` the old registry without
|
||||
Anki and the anki-backed Automation: neither penalised nor paid. From
|
||||
``TUTOR_FROM`` an active focus project swaps its replaced earners for one
|
||||
``focus`` earner (:mod:`earned_time._focus`, 0.19.0).
|
||||
Anki and the anki-backed Automation: neither penalised nor paid.
|
||||
"""
|
||||
target = day or datetime.now(tz=UTC).astimezone().date()
|
||||
before, tutor = registries()
|
||||
if target >= TUTOR_FROM:
|
||||
return with_focus(tutor, target)
|
||||
return tutor
|
||||
if target >= ANKI_WAIVED_FROM:
|
||||
return _without_waived(before)
|
||||
return before
|
||||
@ -67,5 +64,4 @@ def all_earners() -> tuple[Earner, ...]:
|
||||
seen: dict[Earner, None] = {}
|
||||
for registry in registries():
|
||||
seen.update(dict.fromkeys(registry))
|
||||
seen[FOCUS_WATCH] = None
|
||||
return tuple(seen)
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Fakes for the focus tests: a fake ``focus_project`` and signed focus rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from earned_time import FocusEarner, earners_for
|
||||
from earned_time.tests._maturity_rows import at, signed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
START = date(2026, 11, 2)
|
||||
DAY = START + timedelta(days=1)
|
||||
|
||||
|
||||
def project(**changes: object) -> SimpleNamespace:
|
||||
"""A fake ``focus_project.Project`` (the contract's fields)."""
|
||||
fields: dict[str, object] = {
|
||||
"start": START,
|
||||
"end": START + timedelta(days=3),
|
||||
"replaces": ("leetcode", "reading", "tutor"),
|
||||
"daily_minutes": 120,
|
||||
}
|
||||
return SimpleNamespace(**{**fields, **changes})
|
||||
|
||||
|
||||
def activate(monkeypatch: pytest.MonkeyPatch, active: object) -> None:
|
||||
"""Make ``focus_project.active`` return ``active`` on project days only."""
|
||||
|
||||
def on(day: date) -> object:
|
||||
return active if START <= day <= START + timedelta(days=3) else None
|
||||
|
||||
monkeypatch.setitem(sys.modules, "focus_project", SimpleNamespace(active=on))
|
||||
|
||||
|
||||
def row(
|
||||
minutes: object, n: int, day: date = DAY, **detail: object
|
||||
) -> dict[str, object]:
|
||||
"""A signed focus credit row ending at noon on ``day``."""
|
||||
return signed(
|
||||
{
|
||||
"kind": "credit",
|
||||
"entry_id": f"hr-{day:%Y%m%d}-m{n}",
|
||||
"day": day.isoformat(),
|
||||
"detail": {"minutes": minutes, "ended_at": at(day), **detail},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def focus(monkeypatch: pytest.MonkeyPatch, **changes: object) -> FocusEarner:
|
||||
"""Today's focus earner under a fake project."""
|
||||
activate(monkeypatch, project(**changes))
|
||||
(found,) = (e for e in earners_for(DAY) if e.name == "focus")
|
||||
assert isinstance(found, FocusEarner)
|
||||
return found
|
||||
@ -3,8 +3,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
@ -23,14 +21,6 @@ def _no_real_switch_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_focus_project(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Never read the real projects.json: no project is active unless a test says."""
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "focus_project", SimpleNamespace(active=lambda _day: None)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key_file(tmp_path: Path) -> Path:
|
||||
"""The test HMAC key, written where a test can point ``maturity`` at it."""
|
||||
|
||||
@ -1,187 +0,0 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The focus project earner: registry swap, pay, rounding, penalty."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
import earned_time
|
||||
from earned_time import (
|
||||
TUTOR_EARNERS,
|
||||
all_earners,
|
||||
credit_units,
|
||||
earners_for,
|
||||
extra_shutdown_minutes_for,
|
||||
maturity,
|
||||
penalty_start,
|
||||
resolve,
|
||||
shutdown_minutes_for,
|
||||
)
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_the_replaced_gates_leave_and_focus_joins(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
activate(monkeypatch, project())
|
||||
assert [e.name for e in earners_for(DAY)] == ["workout", "focus"]
|
||||
assert earners_for(START - timedelta(days=1)) is TUTOR_EARNERS
|
||||
assert earners_for(START + timedelta(days=4)) is TUTOR_EARNERS
|
||||
|
||||
|
||||
def test_book_is_a_synonym_and_unreplaced_gates_stay(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
activate(monkeypatch, project(replaces=("book",)))
|
||||
assert [e.name for e in earners_for(DAY)] == [
|
||||
"workout",
|
||||
"leetcode",
|
||||
"tutor",
|
||||
"focus",
|
||||
]
|
||||
item = earners_for(DAY)[-1]
|
||||
assert (item.gaming_minutes, item.shutdown_minutes) == (60, 60)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changes", [{"replaces": ("anki",)}, {"replaces": ()}, {"daily_minutes": 0}]
|
||||
)
|
||||
def test_a_project_that_changes_nothing_leaves_the_registry(
|
||||
monkeypatch: pytest.MonkeyPatch, changes: dict[str, object]
|
||||
) -> None:
|
||||
activate(monkeypatch, project(**changes))
|
||||
assert earners_for(DAY) is TUTOR_EARNERS
|
||||
|
||||
|
||||
def test_a_full_day_pays_the_cut_plus_the_free_leetcode_hour(
|
||||
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
|
||||
|
||||
|
||||
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.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(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
item = focus(monkeypatch)
|
||||
many = ledger_file(tmp_path, [row(1, n) for n in range(1, 8)])
|
||||
one = tmp_path / "one.json"
|
||||
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
|
||||
|
||||
|
||||
def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
activate(monkeypatch, project())
|
||||
nothing = resolve({"workout": 0, "focus": 0}, DAY, leisure=False)
|
||||
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.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."""
|
||||
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
|
||||
|
||||
|
||||
def test_an_idle_project_day_also_cuts_the_free_leetcode_hour(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""kuhy 2026-11-02: a project day cuts all it can pay (180), not 120."""
|
||||
activate(monkeypatch, project())
|
||||
zero = {"workout": 0, "leetcode": 0, "reading": 0, "tutor": 0, "focus": 0}
|
||||
old = resolve(zero, DAY, TUTOR_EARNERS, leisure=False)
|
||||
new = resolve(zero, DAY, leisure=False)
|
||||
assert new.gaming_minutes == old.gaming_minutes - 60
|
||||
assert new.shutdown_minutes == old.shutdown_minutes
|
||||
|
||||
|
||||
def test_the_penalty_is_not_delayed_by_the_first_credit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
item = focus(monkeypatch)
|
||||
assert penalty_start(item, START) == penalty_start(item, None) == START
|
||||
answers = {"workout": 0, "focus": 0}
|
||||
plain = resolve(answers, START, leisure=False)
|
||||
paid = resolve(answers, START, first_credits={"focus": START}, leisure=False)
|
||||
assert paid.base == plain.base
|
||||
assert plain.base.gaming_minutes == 300 - 180
|
||||
|
||||
|
||||
def test_maturity_reads_the_focus_ledger(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
item = focus(monkeypatch)
|
||||
ledger = ledger_file(tmp_path, [row(30, 30, START), row(30, 60, START)])
|
||||
found = maturity(item, ledger, key_file, DAY)
|
||||
assert (found.first_credit, found.penalty_start) == (START, START)
|
||||
|
||||
|
||||
def test_the_first_and_second_minute_follow_the_floor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
item = focus(monkeypatch)
|
||||
assert shutdown_minutes_for(item, DAY) == 1
|
||||
assert extra_shutdown_minutes_for(item, DAY) == 2
|
||||
assert shutdown_minutes_for(earned_time.ANKI, date(2026, 10, 9)) == 30
|
||||
assert extra_shutdown_minutes_for(earned_time.ANKI, date(2026, 10, 9)) == 0
|
||||
|
||||
|
||||
def test_the_path_unit_watches_the_focus_ledger() -> None:
|
||||
assert earned_time.FOCUS_LEDGER in {e.ledger for e in all_earners()}
|
||||
|
||||
|
||||
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
|
||||
@ -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
|
||||
@ -1,82 +0,0 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Focus project: fail mode, row matching, per-day target."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
import logging
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from earned_time import TUTOR_EARNERS, day_window, earners_for
|
||||
from earned_time._match import focus_match, focus_minutes
|
||||
from earned_time.tests._focus_fakes import DAY, START, activate, project, row
|
||||
from earned_time.tests._maturity_rows import signed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [ImportError("gone"), ValueError("bad json")])
|
||||
def test_a_broken_focus_project_means_no_project_and_logs_once(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
failure: Exception,
|
||||
) -> None:
|
||||
def broken(_day: date) -> object:
|
||||
raise failure
|
||||
|
||||
monkeypatch.setitem(sys.modules, "focus_project", SimpleNamespace(active=broken))
|
||||
monkeypatch.setattr("earned_time._focus._warned", set())
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert earners_for(DAY) is TUTOR_EARNERS
|
||||
assert earners_for(DAY) is TUTOR_EARNERS
|
||||
assert sum("behaving as if no project" in m for m in caplog.messages) == 1
|
||||
|
||||
|
||||
def test_a_missing_package_is_no_project(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setitem(sys.modules, "focus_project", None)
|
||||
assert earners_for(DAY) is TUTOR_EARNERS
|
||||
|
||||
|
||||
def test_rows_count_by_ended_at_then_by_day(caplog: pytest.LogCaptureFixture) -> None:
|
||||
window = day_window(DAY)
|
||||
assert focus_match(row(5, 5), window)
|
||||
assert not focus_match(row(5, 5, START), window)
|
||||
nostamp = signed(
|
||||
{"kind": "credit", "day": DAY.isoformat(), "detail": {"minutes": 5}}
|
||||
)
|
||||
assert focus_match(nostamp, window)
|
||||
assert not focus_match({**nostamp, "day": START.isoformat()}, window)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert not focus_match(row(0, 1), window)
|
||||
assert "bad minutes" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -1, True, 1.5, "5", None])
|
||||
def test_minutes_must_be_a_positive_int(bad: object) -> None:
|
||||
assert focus_minutes({"detail": {"minutes": bad}}) is None
|
||||
assert focus_minutes({"detail": "x"}) is None
|
||||
assert focus_minutes({"detail": {"minutes": 4}}) == 4
|
||||
|
||||
|
||||
def edited(day: date) -> int:
|
||||
"""A target cut from 120 to 60 minutes after ``DAY``."""
|
||||
return 120 if day <= DAY else 60
|
||||
|
||||
|
||||
def test_a_target_edit_never_reprices_past_days(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
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.shutdown_for(60, DAY), after.shutdown_for(30, DAY)) == (90, 90)
|
||||
|
||||
|
||||
def test_a_zero_target_on_a_day_changes_nothing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
activate(monkeypatch, project(daily_on=lambda _day: 0))
|
||||
assert earners_for(DAY) is TUTOR_EARNERS
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "earned-time"
|
||||
version = "0.21.0"
|
||||
version = "0.18.0"
|
||||
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = []
|
||||
|
||||
@ -14,40 +14,4 @@ fp.record_activity("timer", "start")
|
||||
|
||||
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`
|
||||
(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.
|
||||
|
||||
@ -17,50 +17,25 @@ 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._model import 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",
|
||||
]
|
||||
|
||||
@ -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 _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():
|
||||
@ -154,11 +108,7 @@ def create_project( # noqa: PLR0913
|
||||
|
||||
|
||||
def end_project(project_id: str, day: date) -> Project:
|
||||
"""End early: ``day`` becomes the last active day (clamped to ``end``).
|
||||
|
||||
``day`` must be today or later: ending a past day would retroactively
|
||||
erase that day's shortfall (and the lock it causes).
|
||||
"""
|
||||
"""End early: ``day`` becomes the last active day (clamped to ``end``)."""
|
||||
with _store.locked():
|
||||
existing = _store.read_all()
|
||||
for i, project in enumerate(existing):
|
||||
@ -170,9 +120,6 @@ def end_project(project_id: str, day: date) -> Project:
|
||||
if day < project.start:
|
||||
msg = f"cannot end {project_id!r} before its start {project.start}"
|
||||
raise ValueError(msg)
|
||||
if day < _clock.today():
|
||||
msg = f"cannot end {project_id!r} in the past ({day})"
|
||||
raise ValueError(msg)
|
||||
ended = replace(project, ended_on=min(day, project.end))
|
||||
existing[i] = ended
|
||||
_store.write_all(existing)
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The one wall-clock seam: tests replace :func:`now` instead of sleeping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import time
|
||||
|
||||
|
||||
def now() -> float:
|
||||
"""Unix seconds right now."""
|
||||
return time.time()
|
||||
|
||||
|
||||
def today() -> date:
|
||||
"""The local calendar date right now."""
|
||||
return date.fromtimestamp(now()) # noqa: DTZ012 (local day by contract)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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,50 +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``."""
|
||||
|
||||
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] = {
|
||||
"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),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Project:
|
||||
"""One time-boxed focus project; at most one is active per day."""
|
||||
@ -79,39 +33,6 @@ 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)."""
|
||||
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
|
||||
|
||||
@property
|
||||
def last_day(self) -> date:
|
||||
@ -122,13 +43,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 +61,13 @@ 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 +80,6 @@ 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}"
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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)
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
@ -1,161 +0,0 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""update_project: edit a project with create's validation, never the past."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from focus_project import _store
|
||||
from focus_project._api import _validate
|
||||
from focus_project._model import Limits, Project, normalize_gate
|
||||
|
||||
_EDITABLE = frozenset(
|
||||
{
|
||||
"name",
|
||||
"start",
|
||||
"end",
|
||||
"replaces",
|
||||
"daily_minutes",
|
||||
"unlock_minutes",
|
||||
"subjects",
|
||||
"site_patterns",
|
||||
"work_url",
|
||||
}
|
||||
)
|
||||
_TUPLES = ("subjects", "site_patterns")
|
||||
|
||||
|
||||
def _apply(project: Project, changes: dict[str, Any], today: date) -> Update:
|
||||
"""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}"
|
||||
raise ValueError(msg)
|
||||
if project.last_day < today:
|
||||
msg = f"project {project.id!r} is over (ended {project.last_day})"
|
||||
raise ValueError(msg)
|
||||
fields = {k: v for k, v in changes.items() if k not in _TUPLES}
|
||||
if "replaces" in changes:
|
||||
fields["replaces"] = tuple(
|
||||
dict.fromkeys(map(normalize_gate, changes["replaces"]))
|
||||
)
|
||||
for key in _TUPLES:
|
||||
if key in changes:
|
||||
fields[key] = tuple(changes[key])
|
||||
new_start = changes.get("start", project.start)
|
||||
if new_start != project.start and today > min(project.start, new_start):
|
||||
msg = f"cannot move start of a started project ({project.start})"
|
||||
raise ValueError(msg)
|
||||
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)
|
||||
|
||||
|
||||
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) == (
|
||||
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)
|
||||
|
||||
|
||||
@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(
|
||||
project_id: str,
|
||||
*,
|
||||
today: date | None = None,
|
||||
**changes: Any, # noqa: ANN401
|
||||
) -> Update:
|
||||
"""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.
|
||||
"""
|
||||
unknown = sorted(set(changes) - _EDITABLE)
|
||||
if unknown:
|
||||
msg = f"cannot change {unknown}"
|
||||
raise ValueError(msg)
|
||||
today = today or date.today() # noqa: DTZ011
|
||||
with _store.locked():
|
||||
existing = _store.read_all()
|
||||
for i, project in enumerate(existing):
|
||||
if project.id != project_id:
|
||||
continue
|
||||
done = _apply(project, changes, today)
|
||||
edited = done.project
|
||||
_validate(edited)
|
||||
for other in existing:
|
||||
if other.id != project_id and (
|
||||
edited.start <= other.last_day and other.start <= edited.last_day
|
||||
):
|
||||
msg = f"project overlaps {other.id!r}"
|
||||
raise ValueError(msg)
|
||||
existing[i] = edited
|
||||
_store.write_all(existing)
|
||||
return done
|
||||
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
|
||||
@ -7,17 +7,15 @@ from datetime import date
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from focus_project import _api, _clock
|
||||
from focus_project import _api
|
||||
from focus_project._paths import activity_path, data_dir
|
||||
|
||||
SOURCES = frozenset({"timer", "tutor", "sql_drills", "leetcode", "browser"})
|
||||
KINDS = frozenset({"start", "checkin", "stop", "heartbeat"})
|
||||
DEVICES = frozenset({"pc", "phone"})
|
||||
# Clock skew tolerated on a stamped event; a bigger lead is rejected. Whatever
|
||||
# is accepted is still clipped at "now" by ``minutes_for``.
|
||||
MAX_SKEW_S = 120.0
|
||||
|
||||
|
||||
def record_activity( # noqa: PLR0913, PLR0917
|
||||
@ -28,12 +26,7 @@ def record_activity( # noqa: PLR0913, PLR0917
|
||||
dev: str = "pc",
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Append one event. ``project`` defaults to the project active at ``ts``.
|
||||
|
||||
Raises:
|
||||
ValueError: unknown source/kind/dev, or ``ts`` more than
|
||||
:data:`MAX_SKEW_S` ahead of the clock (nothing is written).
|
||||
"""
|
||||
"""Append one event. ``project`` defaults to the project active at ``ts``."""
|
||||
for value, allowed, label in (
|
||||
(source, SOURCES, "source"),
|
||||
(kind, KINDS, "kind"),
|
||||
@ -42,11 +35,7 @@ def record_activity( # noqa: PLR0913, PLR0917
|
||||
if value not in allowed:
|
||||
msg = f"unknown {label} {value!r}; expected one of {sorted(allowed)}"
|
||||
raise ValueError(msg)
|
||||
now = _clock.now()
|
||||
when = now if ts is None else ts
|
||||
if when > now + MAX_SKEW_S:
|
||||
msg = f"ts is {when - now:.0f} s ahead of the clock (max {MAX_SKEW_S:.0f} s)"
|
||||
raise ValueError(msg)
|
||||
when = time.time() if ts is None else ts
|
||||
if project is None:
|
||||
found = _api.active(date.fromtimestamp(when)) # noqa: DTZ012
|
||||
project = found.id if found else None
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user