diet-guard/diet_guard/_constants.py
Krzysztof kuhy Rudnicki e23f7ac3b4
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Push under a persisted per-install uuid, not a fixed role id
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
2026-08-09 17:18:42 +02:00

107 lines
6.2 KiB
Python

"""Constants for the diet_guard calorie tracker and gate."""
from __future__ import annotations
from pathlib import Path
# --- Daily target -----------------------------------------------------------
# There is deliberately NO budget number here. It is computed once from
# biometrics at ``init`` time, written to BUDGET_FILE below, and freely
# editable and synced afterward from either device -- see _budget.py and
# _sync.py. Read via diet_guard._budget.daily_budget() for over/under
# decisions; freely shown in local CLI/GUI/app output.
#
# Fraction of the budget at which status flips from "on track" to "approaching
# limit". Also mirrored above 100% as the day-status yellow/red boundary for
# the calendar -- see _daystatus.py.
BUDGET_WARN_FRACTION: float = 0.80
# --- Storage ----------------------------------------------------------------
# The food log is personal and high-churn, so it lives in the XDG data dir and
# is deliberately NOT committed to git (unlike wake_state.json).
DATA_DIR: Path = Path.home() / ".local" / "share" / "diet_guard"
FOOD_LOG_FILE: Path = DATA_DIR / "food_log.json"
# Revision cache for cross-device sync. Beside the log it describes and
# cleared with it: skipping an unchanged peer is only sound because that
# peer's records are already merged into the local log, so state that outlived
# its log would skip peers whose data had been lost.
SYNC_STATE_FILE: Path = DATA_DIR / "sync_state.json"
# The user's personal "food bank": every food they have logged before, with its
# full macros, keyed by name. This is the ONLY corpus the gate's autocomplete
# searches -- Open Food Facts is used to *fill* a new food's macros, never to
# search. Local-only, git-ignored.
FOOD_BANK_FILE: Path = DATA_DIR / "food_bank.json"
# Hand-curated bank entries (see _foodbank_manual.py): foods added without
# ever logging them, so they are NOT derivable from the food log and must
# sync in their own right. Kept separate from FOOD_BANK_FILE because that
# one is rewritten wholesale on every log write.
MANUAL_BANK_FILE: Path = DATA_DIR / "food_bank_manual.json"
# The budget: a plain JSON dotfile alongside the log, freely editable on this
# device or the phone app and synced between them (see _sync.py).
# Git-ignored, never committed. "Hidden" here means never-online (it lives
# outside the repo) -- the number itself is shown freely in local CLI/GUI/app
# output.
BUDGET_FILE: Path = DATA_DIR / ".budget"
# The effective-from history of that budget (see _budget_history.py), kept in
# its own file so ``.budget``'s schema stays at v2 and every existing reader
# of it is untouched. Classifying a *past* day reads this; everything about
# *today* still reads BUDGET_FILE. Git-ignored, never committed.
BUDGET_HISTORY_FILE: Path = DATA_DIR / ".budget_history"
# --- Estimator (Open Food Facts) -------------------------------------------
# The default backend is Open Food Facts' "Search-a-licious" full-text search:
# free, no key, strongest for branded/packaged foods (including fast food).
# (The older cgi/search.pl endpoint is heavily rate-limited and returns an HTML
# "temporarily unavailable" page to API clients, and /api/v2/search ignores the
# query term, so neither is usable here.) Swappable for a local/remote LLM
# backend later without touching the log or CLI layers.
OFF_SEARCH_URL: str = "https://search.openfoodfacts.org/search"
OFF_TIMEOUT_SECONDS: float = 8.0
OFF_PAGE_SIZE: int = 5
# Open Food Facts asks API clients to identify themselves with a descriptive
# User-Agent string so abusive clients can be told apart from polite ones.
OFF_USER_AGENT: str = "diet_guard/1.0 (personal diet tracker)"
# Portion assumed when neither --grams nor an OFF serving size is available.
DEFAULT_PORTION_GRAMS: float = 100.0
# --- Gate (log-to-unlock) ---------------------------------------------------
# The gate is driven by FIXED MEAL SLOTS, not by a gap timer. Starting at the
# day-start hour, a slot opens every interval; once a slot's hour has passed,
# that slot must carry a logged meal or the screen locks until it does. This
# makes tracking fully automatic (you are prompted on a schedule rather than
# trusted to log voluntarily) and nudges regular eating. Coming home late
# naturally produces several unlogged elapsed slots at once -> one lock that
# backfills the whole day, which is the "requirement to access the PC" behavior.
GATE_DAY_START_HOUR: int = 8 # first slot (08:00); also the "beginning of day"
GATE_SLOT_INTERVAL_HOURS: int = 4 # slots at 08:00, 12:00, 16:00, 20:00
# Past this hour the gate never fires, so an unlogged late slot lapses quietly
# instead of locking you out overnight. (A new day resets all slots at 00:00.)
GATE_EATING_END_HOUR: int = 22 # exclusive (22:00)
# flock single-instance guard: stops a timer from stacking lock windows.
GATE_LOCK_FILE: Path = DATA_DIR / ".gate.lock"
# --- Sync (cross-device log merge) ------------------------------------------
# GitHub is used purely as dumb file storage via the REST Contents API (not a
# git clone) -- mirrors ~/todo's sync transport. Each device pushes its own
# full current log as one file under devices/<id>/food_log.json; merging
# happens client-side (see _sync_merge.py), never via git.
SYNC_REPO_OWNER: str = "kuhyx"
SYNC_REPO_NAME: str = "syncs"
# The id this machine pushed under before per-install uuids. The sync reader
# still treats it as this device's own, so the log pushed under it is not
# pulled back and re-merged as a peer's; drop to None once devices/pc/ has
# been reclaimed. See SYNC_DEVICE_ID_FILE.
SYNC_LEGACY_DEVICE_ID: str | None = "pc"
# This device's sync id, minted once and persisted. A fixed "pc" collides the
# moment a second machine takes the same role, and after a reinstall the new
# install would inherit the old one's CRDT identity; a uuid makes both
# impossible by construction. Lives beside the other state, not in ~/.config,
# because losing it means being seen as a brand-new device.
SYNC_DEVICE_ID_FILE: Path = DATA_DIR / ".device_id"
# A fine-grained GitHub PAT, scoped to just SYNC_REPO_NAME's contents. The
# user creates this once via github.com (see CLAUDE.md) and saves it here,
# mode 600. Never committed -- this path is outside the repo entirely.
SYNC_TOKEN_FILE: Path = Path.home() / ".config" / "diet_guard" / "sync_token"
SYNC_TIMEOUT_SECONDS: float = 10.0