diff --git a/docs/explanations/2026-07-13-explanation-calendar-streaks-budget-sync.html b/docs/explanations/2026-07-13-explanation-calendar-streaks-budget-sync.html new file mode 100644 index 0000000..99b60ba --- /dev/null +++ b/docs/explanations/2026-07-13-explanation-calendar-streaks-budget-sync.html @@ -0,0 +1,733 @@ +Calendar, streaks, and a freely-editable synced budget + + +
+

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: +

+
    +
  1. 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.
  2. +
  3. 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.
  4. +
  5. Tapping a colored day now opens the existing history list, pre-filtered +to just that day.
  6. +
+ +
+This was an explicit trade-off, not an oversight: the developer flagged that +"freely editable, synced" directly undoes the anti-impulse-editing guarantee +the seal existed for, and the user confirmed they wanted that anyway — the +budget-editing UI just adds one deliberate step (an Edit button) rather than +a hard system-level lock. +
+
+ +
+

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%): +

+ + + + + + + +
StatusConditionMeaning
GREENkcal ≤ budgetat or under budget
YELLOWbudget < kcal ≤ budget × 1.20over, but within 20%
REDkcal > budget × 1.20more than 20% over
NOT_LOGGEDno valid entries that dayunknown — 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." +

+ +
+
+
PC writes
budget=1800
t=10:02
+
+
+
Phone writes
budget=2200
t=10:05
+
→
+
merged: 2200
(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: +

+
+
+
1
+
2
+
3
+
4
+
5
+
6
+
7
+
+
+ green — at/under budget + yellow — up to 20% over + red — >20% over + black — not logged + dashed — future, not judged +
+
+

+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. +

+
+A real bug surfaced while wiring this up: 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.

+ +
+
Score: 0 / 5
+
+
+ + diff --git a/docs/explanations/index.html b/docs/explanations/index.html new file mode 100644 index 0000000..680dbd8 --- /dev/null +++ b/docs/explanations/index.html @@ -0,0 +1,43 @@ + + + + + +diet-guard — change explanations + + + +
+
diet-guard
+

Change explanations

+

1 explanation · interactive walkthroughs of notable changes, newest first.

+
+ + 2026-07-13 + Calendar, streaks, and a freely-editable synced budget + +
+ +
+ +