Calendar, streaks, and a freely-editable synced budget
+diet_guard — commit 957769f, 49 files, +4027/-443
+ + + +Background
+ +
+diet_guard is a "log-to-unlock" gate. On the PC, a systemd timer checks every
+30 minutes whether a meal slot (08:00, 12:00, 16:00, 20:00) has gone unlogged;
+if so, it throws up a fullscreen Tk window that blocks the desktop until the
+user types in what they ate. A companion Flutter app on the phone lets the
+user log meals there too. The two devices don't share a database — instead a
+Python library and a Dart port of it, both called crdt_sync,
+merge each device's log file using Hybrid Logical Clocks (HLCs): every record
+carries a clock derived from when it was written, and when two devices' logs
+are merged, the record with the later clock wins per field. GitHub is used
+purely as dumb file storage (one JSON file per device, under a private repo),
+polled every ~15 minutes.
+
+Before this change, the app also tracked a daily calorie budget — computed
+once from biometrics at setup time via the Mifflin-St Jeor formula — and then
+sealed it with chattr +i, an immutable-file flag. That
+was a deliberate anti-impulse-editing mechanism: you can't quietly raise your
+own budget mid-diet without a conscious sudo chattr -i step. The
+budget lived only on the PC and was never synced.
+
+This change does three things at once, and they're entangled on purpose: +
+-
+
- Adds a calendar view to both the PC gate and the phone +app — every day colored by how that day's eating compared to budget, plus +logging/adherence streak counters and a year-to-date tally. +
- To color days on the phone, the phone needs to know the budget — +which meant the budget could no longer be PC-only and sealed. So the seal is +removed and the budget becomes a normal, freely-editable value synced +last-writer-wins, exactly like the food log. +
- Tapping a colored day now opens the existing history list, pre-filtered +to just that day. +
Intuition
+ +Classifying a day
+
+Every day's status is one of four values, and the ordering matters:
+NOT_LOGGED is considered worse than RED —
+an unlogged day carries zero information about whether it went well or badly,
+which is worse than knowing it went badly. The other three are a symmetric
+band around the budget line, reusing the app's existing "80% is the warning
+threshold" constant so the yellow ceiling sits exactly as far above budget
+(120%) as the old warning line sat below it (80%):
+
| Status | Condition | Meaning |
|---|---|---|
| GREEN | kcal ≤ budget | at or under budget |
| YELLOW | budget < kcal ≤ budget × 1.20 | over, but within 20% |
| RED | kcal > budget × 1.20 | more than 20% over |
| NOT_LOGGED | no valid entries that day | unknown — worse than RED |
+A day that's absent from the log because it simply hasn't happened yet (it's
+after "today") is a fifth, separate case: it must never render as
+NOT_LOGGED's black cell, because that would visually punish the
+user for the future. The calendar widgets on both platforms special-case this
+by comparing the cell's date against a passed-in "today" before falling back
+to the not-logged classification.
+
Try it — this is exactly the rule both the Python and Dart code implement:
+ + + +Making the budget syncable
+
+The food log already had a clean sync story: every entry is immutable once
+logged, so its HLC can be derived deterministically from its own
+never-changing time field — the same entry always produces the
+same clock, so re-syncing is idempotent. The budget is different: it's a
+single value that gets edited repeatedly. Reusing the "derive the
+clock from an immutable field" trick doesn't work, because there's no
+immutable field to derive it from.
+
+The fix is to give the budget record its own edit timestamp — a
+t field (Python) / updatedAt field (Dart) that gets
+stamped fresh on every write — and derive the HLC from that. Now the
+merge rule is simply "whichever device's last edit has the later timestamp
+wins," which is exactly what a user expects from "I changed it on my phone,
+so that's the current value."
+
budget=1800
t=10:02
budget=2200
t=10:05
(later t wins)
The calendar, end to end
++Here's what the grid actually looks like once real data flows through the new +classification rule — six days logged, budget 2000, "today" is day 6: +
++Day 4 is black because nothing was logged, not because eating was bad — that +distinction is the entire point of ranking it worse than red. Day 7 is dashed +because it's tomorrow; the code never even asks whether it was "logged." +Tapping any resolved day (1–6) now opens the history list filtered to just +that date, so the calendar becomes a way to navigate into the log, not just a +read-only summary. +
+Code walkthrough
++The diff spans 49 files but breaks into six concerns. Grouped by concern +rather than by file, in the order a reader would want to build a mental model: +
+ +1. Removing the anti-impulse-editing seal
+diet_guard/_budget.py · install.sh · CLAUDE.md
+
+_budget.py lost its base64+HMAC signing, its
+seal_budget()/lock_command/unlock_command,
+and the BudgetLockedError exception — replaced by a plain
+write_budget() that stamps an edit time and writes JSON, no
+ritual required:
+
def write_budget(value: int, *, weight_kg: float | None = None) -> None:
+ record: dict[str, object] = {
+ "v": _FILE_VERSION,
+ "b": int(value),
+ "t": _now_local().isoformat(timespec="seconds"),
+ }
+ if weight_kg is not None:
+ record["w"] = round(float(weight_kg), 1)
+ write_raw_record(record)
+
+install.sh dropped its chattr +i step entirely
+(6 setup steps became 5), and CLAUDE.md's "Do NOT" list flipped from "don't
+remove the seal" to "don't reintroduce a seal as a fix" — documentation now
+actively warns against undoing this on purpose.
+
2. Day-status classification, twice, kept honest by mirrored tests
+diet_guard/_daystatus.py · app/lib/models/day_status.dart · app/lib/services/day_status_service.dart
+
+Python and Dart can't share code, so the same four-state classification
+(shown in the Intuition section above) is implemented independently in both
+languages, with matching module-doc "specs" at the top of each file pointing
+at its counterpart. Both expose the same shape of API — dayStatus,
+statusMap, loggingStreak, adherenceStreak,
+yearToDateTally — and both test suites encode the identical
+boundary matrix (exactly at budget, exactly at the yellow ceiling, one cent
+over it, an empty day, a day with only deleted entries) so a silent drift in
+either implementation fails its own tests rather than staying invisible.
+
+One subtlety both streak functions share: "today" being unlogged doesn't +break a streak by itself (you haven't had the chance to log it yet), but +"yesterday" being unlogged does. The streak counter starts at today, skips +back one day only if today itself is absent from the status map, +then counts backward as long as each day qualifies. +
+3. Budget sync — a second synced file, same merge machinery
+diet_guard/_sync_merge.py · _sync.py · app/lib/services/sync_merge.dart · sync_service.dart · app_settings_service.dart
+
+The budget becomes a sibling of the food log: a new
+devices/<device_id>/budget.json file per device, with one
+record (id "budget") merged via the exact same
+merge_logs/mergeLogs primitive the food log already
+uses. No new merge algorithm was needed — only new adapter functions
+(budget_to_log / log_to_budget in Python,
+budgetToLog / logToBudget in Dart) translating
+between the on-disk budget record and the generic Record/
+Log shape crdt_sync operates on.
+
+On the Dart side, AppSettingsService gained a second write path:
+saveDailyKcalGoal(int) (a real user edit — stamps "now") versus
+applySyncedBudget(int, {updatedAt}) (a merge result — persists
+the winning device's timestamp verbatim, not "now"). That distinction
+matters: if a sync merge re-stamped "now" on every tick, an unchanged value
+would look like a fresh edit and could out-race a genuinely newer edit on the
+next merge.
+
backgroundSyncPush()
+runs in a separate WorkManager background isolate with none of the foreground
+isolate's initialized singletons. Since budget sync now calls
+AppSettingsService.instance, that isolate needed an explicit
+await AppSettingsService.init(); added — a one-line fix caught
+before it could crash a background sync tick in production.
+4. The PC gate's History tab
+diet_guard/_gatelock_calendar.py (new, 466 lines) · _calendar_view.py (new) · _gatelock.py · _gatelock_ui.py
+
+The gate's fullscreen Tk window gained a second tab via ttk.Notebook
+— the existing meal-entry form becomes "Log Meal", and a new "History" tab
+holds the calendar, streaks, and budget field. The month-grid math
+(build_month_cells) was deliberately split into its own
+_calendar_view.py with zero Tk dependency, so the tricky
+boundary cases — variable month length, which weekday a month starts on,
+future-day handling — are unit-tested directly without a fake Tk harness in
+the loop.
+
+The budget field itself follows the same two-click state machine on both
+platforms: it starts readonly showing 2200 (a
+built-in default, not yet a real save) with a button labelled "Edit". First
+click flips the field to editable and relabels the button "Save"; second
+click validates (must parse as a positive integer) and either persists +
+reverts to read-only, or shows an error and stays in edit mode so nothing
+typed is silently discarded.
+
5. The phone's Calendar screen
+app/lib/screens/calendar_screen.dart (new) · widgets/day_status_calendar.dart (new) · widgets/streak_summary_row.dart (new) · log_meal_screen.dart
+
+The Flutter side mirrors the gate's layout and the same Edit/Save toggle,
+reachable from a new calendar icon in the main screen's app bar. The month
+grid widget (DayStatusCalendar) is a plain StatelessWidget
+— the parent owns which month is displayed and re-renders it, matching the
+existing WorkoutCalendar pattern from a sibling app in the same
+monorepo rather than inventing a new convention.
+
6. Tapping a day opens that day's history
+app/lib/widgets/day_status_calendar.dart · screens/history_screen.dart · screens/calendar_screen.dart
+
+Rather than building a new day-detail view, the calendar reuses the existing
+history list, which already supported filtering by an arbitrary date range.
+HistoryScreen gained one new constructor parameter,
+initialDateRange, applied once at load time:
+
const HistoryScreen({this.initialDateRange, super.key});
+...
+_filter = HistoryFilter(dateRange: widget.initialDateRange);
+
+The calendar widget gained an optional onDaySelected(DateTime)
+callback; each day cell wraps itself in a GestureDetector only
+when that callback is provided, and the screen wires it to push
+HistoryScreen with the range collapsed to a single day
+(start == end == that day) — which the existing filter logic
+already treats as "everything logged on that date."
+
Quiz
+Five questions on the reasoning behind this change, not line numbers. Click an option to reveal whether it's right.
+ + +