Routine follow-up to the per-commit version bump: the clean vX.Y.Z tag moved,
so the template's pkgver follows. sha256sums stays 'SKIP' -- the tarball for a
tag contains this file, so the real hash can only be filled in by updpkgsums in
the AUR checkout after the tag exists.
Note this line is inherently a lagging indicator: publishing runs updpkgsums
against whatever tag is newest at that moment, so the AUR may be a release
ahead of what is written here. ~/utils/aur-publish/publish.sh resolves the tag
itself and does not read this value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
The AUR does not review uploads, but packages that violate the rules "may be
deleted without warning", so these were audited against the submission
guidelines rather than assumed compliant.
- pkgdesc now says what the app actually does, in terms a stranger can judge.
"Usefulness" is the one rule these packages are genuinely exposed to, and
the old wording led with the implementation detail.
- optdepends lists every browser the wrapper actually probes for. It searched
Thorium and Brave but only advertised Chrome and Chromium; all four names
were checked to resolve in the official repos or the AUR.
Verified compliant on the rules that can be checked mechanically: not in an
official repo, name free in the AUR, x86_64 declared, no replaces= (the AUR
reserves it for renames), Maintainer line present, license installed. The
conflicts=('todo') on todo-flutter is justified, not defensive: AUR 'todo'
2.4-1 installs its own /usr/bin/todo, confirmed by reading its PKGBUILD.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
This repo had only the pre-push hook wired into .git/hooks, never pre-commit,
so every pre-commit-stage hook here silently never ran locally -- including the
version bump added in the previous commit, which is why that commit did not
bump its own version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
The AUR package sources a clean vX.Y.Z tag, which only moves when the pubspec
version changes by hand -- so the packaged version drifts behind HEAD. Bumping
the patch per commit keeps the tag tracking HEAD. The Flutter app lives in
app/, so the hook is pointed at app/pubspec.yaml explicitly.
Patch, not minor: minor/major are the "how much broke" signal, and
auto-incrementing them on every commit throws that away.
Two guards keep the number honest: re-running the hook over the same staged
change is idempotent (the bump is computed from the last COMMITTED version, not
the working tree), and a rebase/merge/cherry-pick in progress is skipped so
replayed commits don't each inflate it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
The template previously carried the real hash of the current tag's tarball.
That tarball contains this very file, so the hash is stale the moment the next
tag is cut -- and a stale 64-hex string looks complete enough that someone
copying the template to the AUR checkout could skip updpkgsums and then debug a
checksum mismatch instead of the actual step they missed. 'SKIP' is
unmistakably a placeholder. The AUR checkouts under ~/aur keep the real hashes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
app/install_arch.sh generates a PKGBUILD into a mktemp dir with no source=() and
a package() that copies from hardcoded absolute paths under /home/kuhy. That
only ever works on one machine, so it cannot be published. This PKGBUILD builds
from the GitHub release tarball (v1.0.1) instead, and fixes what AUR review
would reject: license=('MIT') with the text actually installed to
/usr/share/licenses, real depends (glibc/bash/hicolor-icon-theme, found by
namcap), and the desktop entry plus icons installed into the package rather than
into ~/.local/share after the fact.
The app lives in app/ while LICENSE is at the repo root, so build()/package()
cd into the subdir and install the license from an absolute $srcdir path.
v1.0.0 was already taken by an older commit predating the LICENSE, hence v1.0.1.
The README documents the tagging hazard: this clone carries grafted upstream
Flutter SDK tags that must never be pushed with `git push --tags`.
install_arch.sh keeps its working-tree dev loop and is deliberately not wired to
this file. Verified: makepkg -Cf from a clean dir with only packaged Flutter on
PATH, namcap clean, pacman -U, and `diet-guard-app` serving on port 8732.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
Required for AUR packaging: a package declaring license=('MIT') must install
the license text to /usr/share/licenses/, and review rejects packages that
cannot. Matches the identical MIT text already used by the todo app.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
Auditing the whole fleet for the bugs found on the desktop turned up three
that were still live here.
1. `--virtual-time-budget=45000` was still in the provisioning args. The
previous commit added the comment explaining why it must not be there but
left the flag itself, so headless Chrome kept SIGKILLing itself (exit -9)
and could still truncate a sync. Actually removed now; a provisioning run
exits 0.
2. `backgroundSyncPush` gated on `settings.isConfigured` -- "has a GitHub
token" -- so the WorkManager background push was skipped entirely on a
device connected only to Firebase. Same defect as _autoSync's, one layer
down. Either backend now counts.
3. Three swallowed exceptions, all in the sync path: the in-isolate config
read, the background push itself, and `loadAccount()`. The last one is the
worst -- it hid *why* provisioning failed, which is indistinguishable from
"no account set" until it says so, and cost real debugging time this
session. All log at warning/error with the stack trace and stay non-fatal,
per the repo's no-silent-failures rule.
Verified after the change, not just compiled: provisioning exits 0 and the
desktop (c891b813) still holds 359 food_log records, identical to the PC
daemon's, 0 missing.
522 tests, analyzer and pre-commit clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
Two fixes found while verifying the desktop actually reaches Firebase rather
than trusting an exit code.
`--virtual-time-budget` makes headless Chrome SIGKILL itself when the budget
expires: the run reported exit -9 and could cut a sync short mid-flight. It
now gets a real wall-clock window and is closed cleanly, so the exit code
means what it says.
`_autoSync` caught `on Exception` and ignored it entirely — the exact
silent-failure shape screen-locker's CLAUDE.md bans ("an invisible log is a
silent failure with extra steps"). It now catches broadly and logs at error
level with the stack trace, so the next failure is diagnosable instead of
looking identical to "nothing to sync". `--enable-logging=stderr` on the
provisioning run surfaces that in the terminal that started it.
Verified end to end, which is what settled it: the desktop appears in the
RTDB as c891b813 with all four documents and a revs entry, and its food_log
holds 359 records — identical to the PC daemon's, 0 missing either way. An
earlier check read as "not pushing" because it ran before the push landed.
522 tests, analyzer and pre-commit clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
Two bugs, found because headless provisioning silently did nothing here while
it worked in todo.
`_autoSync` returned early unless `settings.isConfigured`, which means "has a
GitHub token". A desktop connected only to Firebase therefore never synced at
all -- not on this run, not ever -- and since the provisioning fallback is
reached through openFirebase(), it could never provision either. This is the
same defect workout_app's push() already documents ("Gating on the GitHub
token alone silently dropped every push from a device connected only to
Firebase -- and once the mirror is retired that would be every device").
Either backend now counts.
--provision-sync-account then does the setup with no window: the account
lives in origin-keyed localStorage so the page must run, but --headless=new
runs it without ever mapping a window. Startup auto-sync calls loadAccount(),
the wrapper fallback provisions, the process exits.
Verified after the fix, having watched it fail twice before it:
FlutterSecureStorage.firebase.account now holds a real AES payload in the
desktop profile's localStorage. The time budget is 45s rather than todo's
15s -- this log is an order of magnitude larger.
Usage: CRDT_SYNC_SERVE_ACCOUNT=1 diet-guard-app --provision-sync-account
522 tests, analyzer and pre-commit clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
Same change todo just took: the route read its own configuration from
Platform.environment and $HOME, so only the "disabled" branch was reachable
from a test. Both are now constructor parameters defaulting to the
environment -- production behaviour unchanged -- and the four outcomes are
covered: 404 disabled, 200 with the account, 404 when ~/.config/crdt-sync/ is
incomplete, 404 when firebase.json has no usable email.
522 tests, analyzer clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
Same change todo just took, for the same reason: app/'s desktop target is a
Flutter web build in a Chrome --app window, so FlutterSecureStorage resolves
to the web backend (AES-encrypted localStorage, keyed inside the browser
profile). Nothing outside the page can write that store — `secret-tool`
targets libsecret, which only a Linux embedder build would read, and a Dart
CLI cannot even import flutter_secure_storage, since it is a Flutter plugin
that fails to resolve on the VM. Both verified rather than assumed.
The wrapper is the one process that can read the filesystem and serve the
page, which is exactly why it already holds the GitHub token instead of the
browser (see github_proxy.dart). So the sync account now arrives the same
way: loadAccount() falls back to GET /sync-account when the store is empty
and persists the result, making the route a one-shot provisioning step
rather than a standing dependency.
It reads ~/.config/crdt-sync/{firebase.json,password} — the same pair
`diet_guard sync` already uses — so the machine keeps one source of truth.
Off unless CRDT_SYNC_SERVE_ACCOUNT is set, and 404 (not 403) when disabled so
a probe cannot tell the route exists. On Android there is no wrapper: the
request simply fails and loadAccount() returns null exactly as before.
clearAccount() writes an opt-out marker the fallback checks, so Disconnect
cannot be silently undone by the next launch re-adopting the account.
518 tests, analyzer clean, pre-commit clean. Pinned to crdt_sync_dart-v0.8.0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
run_number counts workflow RUNS, not code. Two failure modes, one of which
already bit: it resets to 1 if this file is renamed (the existing NOTE warned
about that), and it can fall BEHIND a build produced outside this workflow.
On 2026-08-09 diet-guard's phone held a manually sideloaded vc 8 while CI's
next build was vc 6, so `adb install -r` refused every CI build with
INSTALL_FAILED_VERSION_DOWNGRADE. The only non-destructive escape was
re-running the workflow until the counter passed 8 -- forcing a downgrade
needs an uninstall, which wipes app data.
`git rev-list --count HEAD` only ever increases, tracks the code rather than
the CI file, and is already far ahead of every installed build (diet-guard
62 vs 9, screen-locker 118 vs 5, wake-alarm 41 vs 5, todo 51).
Needs fetch-depth: 0 -- checkout@v4 is shallow by default and would count 1.
Verified the count is identical from the repo root and from app/, so the
job's `working-directory: app` default does not affect it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
diet_guard pushed as "pc" (Python), "phone" (Android) and "desktop" (web).
A role constant collides the moment a second machine takes the same role,
and after a reinstall the new install inherits the previous one's CRDT
identity -- the same reasoning home_inventory already records for choosing a
uuid. Both halves now mint a uuid once and persist it: the PC in
~/.local/share/diet_guard/.device_id, the app under SharedPreferences
'crdt.nodeId' (the key todo and home_inventory already use).
The old constant survives as the legacy id and is passed to sync_log /
syncLog, so devices/<legacy>/ is skipped as this device's own. Without that
every tick would re-download and re-merge this device's own pre-migration
log as though a peer had written it -- idempotent under LWW, but hundreds of
KB, 96 times a day. It drops to None once that path is reclaimed; a test
covers both states, including a negative control proving the skip is the
legacy id's doing.
All four documents move together (food_log, food_bank, food_bank_manual,
budget), and the hist:<date> fields riding on the budget record are
untouched -- merge_record unions field names, so they relay through
unchanged.
The resolver lives in its own module (_device.py / sync_device_id.dart)
because _sync_merge needs it for HLC stamping too and importing _sync there
would be a cycle. Dart resolves it asynchronously at startup, before
LogStorageService.init(); currentSyncDeviceId falls back to the compile-time
role constant until then, so a too-early Hlc stamp gets a valid id rather
than crashing.
conftest redirects the new id file in the same change that introduces it --
without that a test mints a uuid into the real state dir and this machine's
live sync identity is decided by whichever test ran first. Verified by
checking ~/.local/share/diet_guard/ before and after: untouched.
Verified live, not just against mocks: the sync timer ran mid-change and
migrated this machine to f8f46da6. All 358 food_log records and all 100
food_bank records are present under the new path (0 missing vs devices/pc/),
and the new log's HLCs carry only its own id -- confirming it did not
re-merge the old path as a peer.
Python: 687 tests, 100% branch coverage. App: 518 tests, analyzer clean.
Pins bumped to crdt-sync-v0.6.0 / crdt_sync_dart-v0.7.0 (pyproject,
requirements.txt, .pre-commit-config.yaml, pubspec), and the new library is
installed into /usr/bin/python's user site-packages so the production timer
does not ImportError on its next tick.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
sync_merge.dart declared its own `const _syncDeviceId = 'phone'`, which was
unconditional -- so the desktop web build wrote records under
`diet-guard-sync/devices/desktop/` while every HLC string inside them ended
in `-phone`. The path segment came from the conditional-export constant, the
HLC did not.
Import `syncDeviceId` from github_client_factory.dart instead, so both halves
come from the same source: 'phone' on Android, 'desktop' on web, matching the
Python side's 'pc' in _constants.py.
Preventive, not a repair: there is no devices/desktop/ in the live RTDB yet,
so no already-written record has the mismatch. HLC tie-break is
nodeId.compareTo, so desktop stamps now sort below phone's rather than tying;
entryHlc is deterministic from entry.time and the bodies are identical for a
given entry id, so this changes which identical copy wins, never what data
survives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GZH89WEqAjuJT8WZdqc7AP
A Duration is an absolute span, so stepping a local DateTime by
Duration(days: 1) moves 23 or 25 hours across a DST boundary and lands on
the wrong wall-clock day. Under Europe/Warsaw, spring-forward 2026-03-29:
lastCompleteDay(2026-03-30) -> 2026-03-28 23:00, the day before
yesterday, not yesterday
weeksAgo: 1 from 2026-04-01 -> 2026-03-24 23:00, a Tuesday, so the
wrong ISO week entirely
a 03-27..03-31 period -> emits four day keys, silently dropping
2026-03-31
The Python half computes all three correctly, because timedelta on a date
has no clock to drift against. So for about two weeks a year the phone and
the PC would report different averages for the same log -- exactly the kind
of device-local divergence the shared-source-of-truth rule exists to stop.
Passing out-of-range components to DateTime's constructor normalizes on the
calendar (day 0 is the previous month's last day), which is what timedelta
already does. The same fix goes to day_status_service's streak walker: it
had the identical pattern and predates this feature, so a streak that
spanned the clock change could break when it should have held.
None of the existing tests could see any of this -- every fixture date sits
in a DST-free span -- so the regression tests use dates that straddle
2026-03-29 and assert on the calendar day rather than the DateTime, which
keeps them meaningful whatever zone the runner is in. Verified they fail
against the old arithmetic before fixing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjJqpcEsaV5WRhPXyxCThd
The calendar already said which individual days went over budget, but not
whether the week as a whole did. A run of small overages reads as four
harmless yellow squares; averaged, it is the thing that actually moves the
needle.
Three rules are load-bearing, and are documented in CLAUDE.md because getting
any of them wrong silently changes what "under budget" means:
- The denominator is logged days, not elapsed days. An unlogged day is not a
zero-kcal day, and averaging gaps in as zeros would make a badly-tracked
week look like the best week of the year.
- The yardstick is the mean of the per-day budgets over those same days,
resolved through BudgetSchedule -- never today's budget, which would
retroactively reclassify past weeks on every budget edit.
- Today is excluded. A half-logged today drags the mean far enough to flip
the band, so every period ends at yesterday.
Band boundaries reuse the calendar's own OVER_BUDGET_YELLOW_CEILING by
import rather than by copy, so a period and its days can never disagree
about what "over" means; that constant loses its underscore to say so.
The MCP get_averages tool returns the average and the band but drops the
average budget, keeping the module's existing invariant that the budget
number never leaves this machine. The CLI, the gate and the phone all show
both, since it is hidden from the network, not from the user.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjJqpcEsaV5WRhPXyxCThd
Four things about the sync settings read wrong on the phone, all of them
left over from when GitHub was the only backend:
- "Connect GitHub" sat on the primary path as a peer of "Connect Firebase",
implying a choice. It is not one: GitHub is the cutover mirror. Its whole
configuration -- device-flow OAuth, owner, repo, client id, PAT fallback --
now lives under "Advanced (GitHub mirror)".
- "Test connection" builds a GitHubClient and calls canAccessRepo(). It has
never said anything about Firebase, so it is now "Test GitHub connection",
and its result messages name GitHub too.
- Once connected, the account still rendered as an editable email box beside
an empty password box, which reads as "you still have to enter this" -- a
connected device looked unconfigured. It now shows the account as text with
a Disconnect button, and the form appears only when disconnected.
Also pins crdt-sync v0.5.1 (from v0.3.0), the shared-config release the
Firebase cutover needs; verified importable under /usr/bin/python3, which is
what diet-guard-gate.service actually runs.
Adds coverage for both Firebase states, which had none: the disconnected form
and the connected read-only display.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017FH32BgGQbQLDstBhR26Xa
The largest consumer moves last on purpose: diet_guard was 71% of all sync
traffic, and both its surfaces (the PC timer and the phone app) share one
prefix, so they move together.
Firebase becomes the primary backend when it is set up, with GitHub kept as a
mirror so a device that has not moved yet still converges. An unconfigured
device keeps syncing exactly as before, which is also the rollback.
Revision tracking is new here. The PC hand-rolled its own pull-merge-push
instead of calling sync_log, so it had none: every tick re-downloaded every
peer's whole food log -- hundreds of KB, 96 times a day -- and re-pushed its
own regardless of change. It now reads one small revision map first, skips any
peer whose hash is unchanged, and suppresses a no-op push. Every sub-sync
(log, budget, food bank, manual bank) takes the client as a parameter, so one
swap covers all four.
The app could not have reached Firebase at all: nothing called saveAccount(),
so loadAccount() always returned null and openFirebase() always returned null.
Settings now has a Firebase section that stores the account in the OS keystore
and signs in immediately, so a typo surfaces on the screen where it can be
fixed rather than as a silently failed background sync hours later. The email
and password never touch the repo -- it is public.
Platform reaches now degrade instead of failing: an unavailable keystore or
application-support directory costs one tick of extra traffic, where before it
would have taken sync down entirely. Caught by `on Object` rather than
`on Exception`, because an uninitialised binding raises an Error.
Verified end to end against the real database, not just in tests: a live PC
sync merged 57 days / 339 entries and put 221 KB of food log plus 97 food-bank
records into Firebase; a second unchanged tick issued zero pushes; and the
Dart client signed in and read back all 339 meals and 97 foods, then completed
a push / publish-revision / suppress-no-op round trip under a throwaway prefix.
diet_guard: 618 tests at 100% branch coverage.
diet_guard_app: 477 tests. Two assertions moved from 4 pushes to 5 -- the
extra one is this device's revision, published after the log so a peer can
never cache "seen rev X" against a log it never received.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJ9sxEw3ymzXNjL7SHNpJc
Two failures, both pre-existing and both invisible as failures — each
reported only as "did not complete" after stalling the suite.
app/test/keyboard_operability_test.dart awaited writeLog() straight from
the test body. LogStorageService is backed by a FileDocumentStore there,
so that awaits real dart:io, and the fake-async zone never completes it:
the test hangs rather than fails. Seeding now happens inside runAsync
alongside the pumping, which is the only zone where that write can
resolve. The screen's own initState load needs the same real clock, and
pumpAndSettle() cannot be used to wait for it — inside runAsync it waits
on a frame scheduler this screen never reports as idle — so the helper
pumps a bounded number of times instead. 473 app tests now pass in 14s.
scripts/ci_mirror.sh ran bare pytest while python-tests.yml runs it under
xvfb-run. Without a display the gate's real-Tk tests collapse: the focus
ring has nothing to walk, so three TestRealFocusRing tests fail locally
and pass in CI. The mirror was reporting red on a green tree, which is
worse than not mirroring at all. It now uses the workflow's exact
xvfb-run invocation and fails loudly if xvfb-run is missing.
Neither change touches production code: the delete control and its
id-gate were already correct, and the Python suite already passed under a
display (609 passed, 100% coverage).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dzgcZxmpS3VtmLci2sbxj
diet-guard had no APK build at all; releases were not produced. Adds a
standalone release-apk.yml plus the release signing config the app was
missing.
Signing follows the pattern proven in home_inventory: gradle reads
android/key.properties when present and falls back to the debug key when
absent, so local `flutter run --release` is unaffected. CI writes that
file from repository secrets and shreds it afterwards, and apksigner
fails the build closed if a debug-signed APK ever appears.
Only the signingConfig line changes here: isMinifyEnabled, isShrinkResources
and proguardFiles are left exactly as they were.
This repo has no Flutter analyze/test job, so the release job is not
gated on tests — a broken commit still publishes, with the build failing
as the only backstop. Add `needs:` if a Flutter CI job is added later.
versionCode is github.run_number, which is per-workflow-file — this file
must never be renamed, or the counter resets and updates stop installing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dzgcZxmpS3VtmLci2sbxj
Your in-progress bump, committed as its own change so it is not buried in
anything of mine. All three declaration sites move together -- the manifest,
the lockfile-equivalent and pylint's hook env -- because a pin that lags the
others lints the code against a library it does not actually run with.
Verified in a clean venv that v0.3.0 still provides every symbol this repo
imports: GitHubSyncClient, GitHubSyncError, Log, merge_logs, Hlc and Record.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
mcp 2.0.0 removed mcp.server.fastmcp; MCPServer is the same object under a
new name, so @mcp.tool() and mcp.run() are untouched and every tool keeps its
name, signature and returned shape. Verified against a clean 2.0.0 venv: all
four tools list, and the suite passes at 100% coverage, 609 tests.
Two things the port buys, neither of which existed in 1.x:
* Tool annotations, which matter more here than in the sibling repos because
this server has a tool that writes. The three readers are marked read-only,
idempotent and closed-world; log_meal is marked not-read-only but
explicitly NOT destructive -- it appends and never rewrites or deletes --
not idempotent, since a second confirmed call logs a second meal, and
open-world, because it reaches Open Food Facts when macros are not given.
A client can now tell the difference without reading the docstring.
* Structured output: 2.x derives an output schema from the return types and
sends structured content alongside the text. No code change -- it falls out
of the type hints that were already there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
This job has been red since the keyboard tests landed: they drive real Tk --
widget heights and the focus ring come from the font engine, and no mock can
answer what they ask -- and a runner has no X display, so they failed with
"no display name and no $DISPLAY" rather than skipping. Thirteen tests,
including the two that assert the gate fits 1366x768, were reporting nothing
about the code.
xvfb-run gives them the display they need, which is what every local run of
these tests already used.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
CI has been red since 2026-07-30 for a reason unrelated to any commit here:
mcp 2.0.0 removed mcp.server.fastmcp, which _mcp.py imports at module scope,
and the manifests asked for `mcp>=1.9` with no upper bound. Verified
directly -- 2.0.0 raises ModuleNotFoundError on that import, 1.29.0 provides
it. It failed pylint here rather than only the test job, because the repo
root now reaches pylint's import resolution.
Pinned `<2` in all three places the dependency is declared until the server
is ported to the 2.x API.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
pylint infers sys.path from the files it is handed, so a file in scripts/
resolves its package imports only when a package file happens to be in the
same batch -- passing or failing by what else was staged. Sibling repos hit
exactly that in CI today. An init-hook puts the repo root on the path
unconditionally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
gatelock 0.4.0 (pinned here) stops the viewport following the app's own
focus_set(): on 2026-08-03 that moved the screen locker's lock screen to
mid-content and back with nobody touching the keyboard, and this gate uses
the same viewport. Scrolling now happens only on the user's own keypress,
wheel or click.
That makes fitting the screen the gate's own responsibility rather than
something a scrollbar covers for, and with a day of meals logged this one did
not: 770px of content in a 739px pane. Trimmed the vertical rhythm (the
title is one step down the scale, and the gaps around the description,
suggestions, macros and footer are one step tighter) for 718px, with room for
the dashboard to keep growing.
Its layout test now requires the gate to *fit* rather than merely be
reachable by scrolling, empty and populated. Dropped the 1024x600 tier: no
machine runs it, this gate cannot reach it without dropping content, and a
target nobody uses is a constraint that only ever costs.
The probe presses Tab before moving focus, because a bare focus_set() no
longer scrolls -- it would have been testing a path no user can take. The
Tk fakes learned pack_forget(), which the viewport calls to take the
scrollbar down whenever the content fits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF
The Log Meal tab did not fit this laptop, and had not for some time. Measured
under a real 1366x768 X server: it required 866px inside a 739px content pane
with every field EMPTY, and 938px with a day of meals logged.
Because the content frame was place()-centred it was sheared at BOTH edges at
once -- the "Diet Gate" title and the slot header (which meal you are being
asked to log) off the top, the calorie headline and the whole dashboard off the
bottom -- with no scrollbar anywhere in the package and nothing to indicate
content was missing. This is a hard lock: overrideredirect, a global grab, VT
switching disabled. Unreachable content is unrecoverable.
Two independent causes, both needed fixing:
- Every font size was handed to Tk as *points*. Tk encodes the unit in the
SIGN of the size, and _gatelock_typography carried a comment asserting the
opposite ("Tk has no separate px/pt distinction"). At ~100 DPI that made
every string ~30% oversized. Negative sizes now; the docstring records why.
- There was no scroll container. build_layout now uses gatelock's
ScrollableSurface, which top-anchors, scrolls by keyboard, and scrolls to
follow focus.
Font sizes alone were not enough: they take the layout to 702px, still over the
571px pane at 1024x600. Both fixes are load-bearing.
The grams/items selector was mouse-only. tk.OptionMenu's Menubutton defaults to
takefocus=0, so ::tk::FocusOK rejects it and it never entered the tab ring --
verified by walking the real ring, which ran Entry -> Text -> Listbox -> Button
-> TNotebook and skipped it entirely. Switching units rescales the whole entry,
so a pointerless user could not correct "250 grams" to "3 items" inside a lock
they cannot leave without logging a meal. It is now two Radiobuttons, which are
in the ring by default and take both Space and Return -- and which post nothing,
unlike a menu, whose grab-stealing is why screen-locker bans OptionMenu on lock
surfaces outright.
Also: the focus ring was invisible (Tk's default is 1px BLACK on #211D1B) and
_build_desc actively inverted it by setting highlightbackground, the UNfocused
ring, so the box outlined while idle and went black the moment it took focus.
Enter now activates buttons (X11 Tk binds only <space>). Text widgets no longer
trap Tab. notebook.enable_traversal() is called, so Ctrl+Tab and Ctrl+PageDown
work. wraplength drops 900 -> 640, the line-length cap, which also stops a
label forcing a 900px-wide layout onto a 1024px screen.
The Flutter app had two controls with no keyboard route at all. Its desktop
target is the web build in a Chrome --app window, so a touch gesture is not a
fallback there, it is the only interaction:
- Deleting a history entry was bound solely to onLongPress. Long-press has no
keyboard equivalent whatsoever. It now has a focusable IconButton; the
long-press stays as a redundant pointer shortcut.
- Calendar day cells were a bare GestureDetector, so navigating to
date-filtered history was pointer-only and invisible to a screen reader.
Now Semantics(button: true) over an InkWell.
Plus: the filter sheet built a TextEditingController inside build() with the
caret forced to end-of-text, so every keystroke discarded it and silently undid
mid-string Home/End/arrow editing. And nothing capped width, so on the 1366px
desktop window prose ran ~180 characters per line (twice the readable limit)
and a four-digit kcal field spanned ~1334px. AppWidth adds those caps.
Tests: the render check is a failing test, not a script -- ruff runs with
--unsafe-fixes, whose T201 fix DELETES print statements, which would silently
turn a diagnostic into a no-op. test_gate_layout_fits.py was verified red on
the pre-fix code ("content 866 in viewport 768 -> NO SCROLL VIEWPORT ->
UNREACHABLE CONTENT") and green after. The Flutter suite gains its first
keyboard-reachability assertions; it previously had ~109 tester.tap calls and
zero checks that anything was focusable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four asks, plus the fixes two review passes turned up.
Removals (all unused):
- "Repeat last meal" button, and the reward/temptation-bundling prompt it
was the sole trigger for. Replaced with a post-log progress card showing
kcal against budget, macros, and the adherence streak.
- The multi-item meal builder, on both the gate and the phone. The stored
`components` field stays: nothing writes it now, but historical composite
entries and the food bank still read it and it is part of the wire format.
- Meal photos entirely: 13 files, `imagePath`, the photo filter, the
wrapper's /blobs/ route, image_picker, and the CAMERA permission.
Reminder false alarms had three real causes, all fixed:
- Logging never reconciled notifications, so a reminder sat on screen up to
15 minutes after the meal was logged -- on the phone itself.
- The background check never pulled, so a PC-logged meal nagged forever
until the app was opened. Now mirrors _cli_gate._should_lock: cheap local
check, network only when a slot looks due, then re-check.
- Meals before 08:00 / after 22:00 were attributed to no slot at all. New
slot_for_log/slotForLog clamps them to the first/last slot, byte-identical
across Python and Dart. Deliberately separate from elapsed_slots, which
would otherwise make every slot fall due at 23:00.
Budget history, so lowering the budget stops rewriting the past:
- Forward-only (effective_from, kcal) entries; each day is judged against
the budget in force then. Verified live: setting 2000 left a 36-day
adherence streak and the whole calendar pixel-identical.
- Rides as hist:<date> fields on the existing budget record. crdt_sync
merges per-field over the union of field names and both devices push the
merged log, so a device that predates the feature relays them untouched --
no coordinated rollout, no second document.
- The seed guard tests emptiness, not file presence: _sync can write an
empty history after merging with a pre-feature peer, and a presence check
would then never seed again, silently reclassifying every past day.
One shared source of truth for every piece of data:
- Body weight now travels as its own `weight` CRDT field. Inside `value` it
was collateral damage of whole-map LWW -- any phone budget edit deleted it,
and the protein target with it.
- Both halves of the food bank sync. The curated half (foods added without
eating them) was the last device-local state. The derived half merges with
count as the clock, so LWW means max-count-wins; the log stays
authoritative for membership, because a CRDT union never shrinks and a
peer's stale copy would otherwise resurrect a deleted food permanently.
- Bank adapters honour tombstones, and Dart parses bank records defensively:
a cast failure there is an Error, not an Exception, so one malformed
remote record would have broken phone sync permanently.
_test_guard.py blocks any pathlib write to real user state while pytest is
in the process, whatever conftest is loaded. A throwaway test run outside
the package conftest destroyed a real GitHub token during this work; this
makes that class of accident impossible rather than merely discouraged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pre-push gate failed on `flutter analyze` with "The current Flutter SDK
version is 0.0.0-unknown", while the same command passed when run by hand.
`git push` exports GIT_DIR to its hooks, and the flutter tool shells out to
git to identify its own SDK. With GIT_DIR set it reads THIS repository
instead of /opt/flutter -- `flutter --version` inside the hook reports
Flutter 3.47.0-0.1.pre • channel main • https://github.com/kuhyx/diet-guard
Framework • revision 84bd5503 (our own HEAD)
so pub cannot resolve any SDK constraint and every dependency is "forbidden".
Worse, flutter writes that answer into its own version cache, so the next
caller -- in any repo -- inherits it until something repairs it.
Wrapping the two flutter calls in `env -u GIT_DIR ...` fixes it at the point
of use. Not `--no-verify`: the gate was right that something was wrong, it
just was not the code being pushed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0.2.0's recovery loop scheduled the next tick after running it, so any
exception out of a tick permanently ended all re-assertion of coverage, grab
and VT for the rest of the lock -- silently, since Tk logs and swallows
exceptions from `after` callbacks. 0.2.1 moves the reschedule into a
`finally`.
Also in 0.2.1: `holds_grab` no longer reads a grab held by one of our own
transient children (a posted menu) as "the grab was lost" and takes it back.
That one was a real, user-visible bug on screen-locker's gate windows; the
same class of widget on a diet_guard gate would have behaved the same way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate is laid out once per live output instead of once on the primary,
and claims the screen through an Arbiter at RANK_DIET_GUARD so the wake alarm
and the workout locker can both outrank it.
Three kinds of state needed three different answers. The display fields
already fanned out for free -- GateVars holds root-mastered StringVars bound
to labels. The typed-in fields did not: amount, per, meal name and the four
macro cells were bare tk.Entry widgets, so N copies would have been N
independent forms. They are variable-backed now, which is what makes logging
work from whichever screen the user is actually looking at. The description
box and the suggestion list cannot be shared at all -- tk.Text and tk.Listbox
have no variable -- so those genuinely diverge and reads answer from the copy
the user touched.
The History tab is per-surface too: its grid, budget field and edit button
are driven across every monitor, with the budget likewise variable-backed so
the value cannot differ between screens mid-edit.
Laying the gate out on the primary monitor only was considered and rejected:
not having to hunt for the screen the form landed on is the entire point of
per-output locking.
Tests: the suite hung rather than failed on first run -- diet_guard patches
its own `tk`, but LockWindow builds Toplevels through gatelock's, and real
tkinter over a MagicMock root recurses without bound. `_hermetic_gatelock`
closes that and redirects the runtime dir so no test can stand a production
locker down. Every other test runs single-output, where fan-out is
unobservable, so test_gatelock_fanout runs on two; each assertion was checked
by cropping the fan-out loops to the first surface and confirming it goes red.
_gatelock_ui and _gatelock_calendar were each split in two to stay under the
500-line limit, and GateVars' typed-in fields moved into a nested
GateEntryVars rather than raising pylint's attribute cap.
470 tests, 100% branch coverage, pre-commit clean, no lint suppressions.
Found via testing todo's app, which shares this theme.dart template and
has a SegmentedButton: neither ColorScheme.dark() constructor call
defined secondary/tertiary (or their container/on- variants), so any M3
widget reaching for secondaryContainer silently falls back to Flutter's
stock Material teal instead of erroring. Confirmed this app has the same
live bug — ChoiceChip (slot selector) uses secondaryContainer implicitly,
and FilterChip's "Any"/"With photo"/"Without photo" chips in the History
filter sheet were rendering in stock teal in production right now.
The shared palette has one accent, not a separate secondary hue, so these
mirror primary; secondaryContainer/tertiaryContainer use line-dark as a
distinguishable-but-subtle selected-state fill.
Verified: flutter analyze clean, 436/436 tests, re-verified identical in
an isolated worktree, and confirmed live: rebuilt debug + release APKs,
installed via adb install -r (no data wipe), screenshotted the real
device before/after (the History filter chips went from teal to warm
gold/tan), then rebuilt and redeployed the desktop package
(install_arch.sh -> pacman -U) and confirmed the running desktop window
renders identically.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Theme.of(context).extension<AppStatusColors>()! crashed in any context
that doesn't build its theme from buildAppTheme() -- which is most widget
tests, since they wrap in a bare MaterialApp(home: ...) rather than the
real app theme. That crash was latent since AppStatusColors was
introduced but only became reachable once main.dart actually started
using buildAppTheme() (previous commit), which is when 27-47 tests
started failing.
Fixed at both call sites (slot_selector_row.dart, day_status_calendar.dart)
by falling back to AppStatusColors.dark instead of force-unwrapping --
production code should never trust every context to fully replicate the
real theme. Also fixed day_status_calendar_test.dart, which asserted
against stale hardcoded Colors.* values from before the theme rewrite;
it now wraps in the real buildAppTheme() and asserts against the actual
tokens, so the assertions mean something again.
Verified: flutter analyze clean, 436/436 tests pass (up from 389/47).
main.dart now builds MaterialApp's theme from buildAppTheme() instead of
ColorScheme.fromSeed(Colors.teal) -- theme.dart existed but was never
actually used until this commit. photo_viewer_screen.dart,
settings_screen.dart, and day_status_calendar.dart's raw Colors.*
literals (Colors.black, Colors.red, Colors.white38, Colors.grey.shade800,
...) now read from Theme.of(context) instead.
Also rewrites CLAUDE.md's Visual Design & Theming section, which
previously contradicted the unified-design-system skill (noise texture,
multi-layered shadows, ColorScheme.fromSeed -- all things that skill
explicitly forbids).
This was the other prerequisite the on-fill/warm-palette fixes actually
needed: theme.dart existing but unwired meant the app was still running
Flutter's default seeded theme.
pubspec.yaml's adaptive_icon_background is a separate literal from
python_pkg/app_icons' own style.BACKGROUND -- the icon regen updated
every generated PNG but missed this one, so the actual on-device home
screen icon (Android's adaptive icon, not the legacy mipmap) stayed on
the old cool charcoal. Caught on the physical device, not by file checks.
testsAndMisc's generator now syncs this automatically going forward.
- theme.dart: warm unified-design-system palette, on-fill for
onPrimary/onError, and inputDecorationTheme/dividerTheme (previously
unset -> every TextField/Divider fell back to Material defaults).
- slot_selector_row.dart: the Snack ChoiceChip had no explicit
backgroundColor/side/labelStyle like the hour chips, so it fell through
to Flutter's own default -- a literal pure-white border.
- Regenerated launcher icon assets (warm charcoal + warm gold accent) via
python_pkg/app_icons.
- Bump gatelock pin to the released v0.1.1 carrying this same palette.
Verified: flutter analyze clean, diet_guard pytest 443/443 @ 100% coverage,
phone-installed APK pixel-checked (Snack chip border, on-fill contrast).
`ci_mirror.sh` -- the pre-push gate -- ran only the Python side: a clean-venv
pytest and `pre-commit --all-files`. Nothing ran the app's suite, and
`.pre-commit-config.yaml` has no Dart hooks at all, so a red `app/` could be
pushed with a fully green gate. It now runs `flutter analyze` and
`flutter test` too, and *fails* rather than skips when `app/` exists but
flutter is missing: a gate that quietly passes without its toolchain is the
same as no gate. Verified by breaking a Dart file on purpose and watching the
push gate refuse it.
app/README.md was still the stock "A new Flutter project" template. It now
says what the two targets are, why there is no `linux/`, what the wrapper
owns, why the port and Chrome profile are fixed, and which behaviours the
desktop genuinely gives up.
Drops MIGRATION_PROMPT.md, whose content now lives in CLAUDE.md and the
migration commits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The IndexedDB stores are the desktop device's primary copy of the food log and
the *only* copy of its photos -- photos never sync -- and their wrapper-mirror
fallback is what makes a wiped Chrome profile recoverable. All of that was
invisible to the suite: the files import `idb_browser.dart`, which cannot be
imported outside a web compile, so they were simply absent from the VM test
run rather than covered.
Splitting the store classes from the browser-factory `open…()` functions lets
them run against `idb_shim`'s in-memory backend on the plain VM, which is what
the flutter-desktop-to-chrome skill recommends the shim for. Twelve tests now
pin the round-trip, the mirror write, the recover-from-mirror path, and the
no-wrapper-running case (a plain browser tab, where the app must still work).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the move: `linux/` is gone, replaced by the web build served by the
wrapper in a Chrome `--app` window. The icons and the .desktop installer move
out of `linux/` first -- they are not Flutter scaffolding and are still
needed; the entry now points at the packaged launcher and matches the
`--class=diet_guard_app` the wrapper passes, so the taskbar shows the app's
icon rather than a browser's.
install_arch.sh carries over the three traps each of which produced an
install that looked fine until launched: `dart build cli` rather than
`dart compile exe` (which refuses any package whose dependency graph has
native build hooks), `options=('!strip')` (makepkg would otherwise discard
the AOT snapshot, leaving a binary that runs as the bare Dart VM printing a
usage message), and no browser in `depends` (this machine's policy uninstalls
`chromium` seconds later, failing dependency resolution).
Also adds the frame-cadence instrument the performance claim is measured
with, armed by `--dart-define=DIET_GUARD_FRAME_STATS=1` and read with
`String.fromEnvironment` -- `bool.fromEnvironment` only accepts the literal
`true`, so `=1` would evaluate to false and a whole run would silently
produce no data. It reports frame-to-frame cadence rather than build/raster
durations, since work that fits the budget says nothing about the frame rate
actually achieved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A browser cannot touch the filesystem, so the desktop app needs a second
half. This local process serves the built web assets, owns an on-disk copy of
every document and photo blob the app stores, and performs the GitHub I/O the
page cannot.
Both GitHub responsibilities are load-bearing, not convenience:
- the device-flow endpoints (github.com/login/device/*) send no CORS headers,
so a page cannot call them at all;
- proxying api.github.com server-side is what keeps the token out of the
browser -- the page is told `{"status":"ok"}`, never the token itself.
The wrapper falls back to the PC gate's existing
`~/.config/diet_guard/sync_token`, so this machine needs no second setup.
Details learned the hard way in the two apps migrated before this one, kept
here deliberately: loopback-only binding (the endpoints overwrite files in
$HOME with no auth), `application/wasm` for .wasm or CanvasKit renders
nothing at all, a fixed port and Chrome profile because IndexedDB is keyed by
origin, and treating a browser exit inside ~5s as a handoff to an existing
window rather than a reason to stop serving. Mirror names are rejected unless
they are plain filenames -- a `..` segment would turn a mirror write into an
arbitrary-file overwrite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flutter's Linux embedder manages ~20fps at 3840x2160 -- measured on a stock
`flutter create` counter app, so it is the toolkit rather than this code --
where the same Dart rendered by Chrome sustains ~144fps (evidence in
~/todo/docs/desktop-performance-findings.md). The desktop target therefore
becomes a Flutter *web* build; Android is untouched.
`dart:io` does not fail a web *compile* -- it resolves to a stub that throws
at runtime -- so every platform edge moves behind a conditional export rather
than being discovered later as a blank white window:
- storage: `document_store*.dart`, files on Android and IndexedDB in the
browser (mirrored to the desktop wrapper, which is what makes a wiped
Chrome profile recoverable). Document names stay the exact pre-web
filenames so an installed phone keeps reading its existing log.
- photos: `blob_store*.dart` plus `attached_image*.dart`, since `Image.file`
cannot resolve an IndexedDB key. The picker itself has web support; what
changed is the storage and display model. Camera *capture* is genuinely
lost on desktop -- and photos never crossed devices anyway, `imagePath`
being stripped before push.
- scheduling: `background_tasks*.dart`. A browser has no WorkManager
equivalent -- a closed tab runs nothing -- so the desktop runs an in-page
5-minute check while its window is open. That loses nothing on the PC,
where `diet-guard-gate.timer` already locks the screen on an unlogged
slot, which is strictly stronger than a notification.
- notifications: `notification_backend*.dart`, the browser Notifications API
in place of flutter_local_notifications.
- the GitHub token: `token_vault*.dart`. On web it is *not* held by the
browser at all -- flutter_secure_storage's web backend is WebCrypto-wrapped
localStorage with the wrap key beside the ciphertext, which is obfuscation,
not encryption at rest -- so the desktop wrapper owns it and proxies the
API (`github_client_factory*.dart`).
`Platform.isAndroid` itself throws on web, so every platform branch now tests
`kIsWeb` first; the battery-optimization row (permission_handler, no web
implementation) is hidden off Android.
The desktop pushes under device id `desktop`, not `phone`: sharing an id
would have made the two devices overwrite each other's pushed log on every
tick. The Python side enumerates device directories generically, so it needs
no change.
Tests move onto the same seams -- the file-backed stores keep the atomicity
and unreadable-file guarantees where they belong.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Applies Atomic Habits' two-minute rule: a FAB on the home screen repeats
the most recent logged meal in a single tap, mirroring _onLogMeal's
existing post-write sync sequence. An optional device-local reward
(label + URL, configured in Settings) surfaces after a repeat and opens
externally via url_launcher.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adopt the shared icon family (python_pkg/app_icons in testsAndMisc): a
charcoal #1B1D21 field carrying a teal shield-and-cutlery glyph, teal being
this app's own colorSchemeSeed. The fork is paired with a knife on purpose -
a lone three-tine fork reads as a trident at launcher size.
Android gains the adaptive-icon layers the project never had, including an
Android 13 monochrome layer.
Linux needs separate handling: Flutter's GTK runner never sets a window icon,
so my_application.cc now calls gtk_window_set_icon_name and
install_desktop_entry.sh populates the hicolor theme from the pre-rendered
PNGs in linux/icons (committed, so the installer needs no image tooling).
StartupWMClass is the APPLICATION_ID rather than the binary name - verified
with xprop against a running window, where WM_CLASS is the GApplication id.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both platforms gain a calendar tab/screen (green/yellow/red/not-logged
day coloring, logging/adherence streaks, year-to-date tally), and
tapping a day drills into that day's logged entries. This requires the
daily budget to become a normal, freely-editable value synced between
devices like the food log — replaces the previous chattr +i seal
(deliberately, per user decision) with plain last-edit-wins sync via a
new budget.json alongside food_log.json. The budget field defaults to
2200 kcal, starts read-only, and unlocks via an Edit/Save toggle on
both the PC gate's History tab and the phone app's Calendar screen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016dHac1iYQKTMZEtL7xDJvA
crdt_sync v0.3.0 adds canAccessRepo(), so the settings 'Test connection'
button uses the shared client and the local lib/services/github_client.dart
(+ its test) is deleted. flutter analyze clean, 299 tests pass. On-device
foreground check deferred: the active night curfew disables the app (a
phone-policy thing, not a code issue); build + install -r succeeded.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ
Immediate push on log only helps when the device is online. If a meal is
logged while offline, the in-process auto-sync fails silently and the meal
waited for the next app lifecycle event. Now each log also enqueues a one-off
WorkManager task constrained to NetworkType.connected, so the log uploads as
soon as connectivity returns, without the app being reopened.
- background_sync_service.dart: backgroundSyncPush() loads SyncSettings and
runs runSync, returning WorkManager's success/retry flag (true when nothing
to push or the push succeeded; false to retry after a transient failure).
Unit-testable half; the dispatcher branch and the one-off registration are
integration-only (real background isolate) and coverage-ignored, matching
the existing periodic check.
- log_meal_screen.dart: _enqueueSyncBackstop() registers the task (replace
policy coalesces a burst of logs; linear backoff retries) from both log
paths. No-op off mobile.
- background_check_service.dart: dispatcher routes the new task name.
- log_storage_service.dart: writeLog is now atomic (temp file + rename) — the
background push's runSync can write the log in a separate isolate while the
foreground app also writes, so a torn read must be impossible.
Verified on device (BL9000EEA0000102): airplane mode on -> log -> background;
airplane mode off -> the phone's remote food_log.json commit advanced within
~15s, app never reopened (also confirms the token loads in the background
isolate).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtSmzACzJSGyEatgK9exkd