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. 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.
  3. Tapping a colored day now opens the existing history list, pre-filtered to just that day.
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