mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-14 12:10:56 +01:00
Add change explanation: calendar-streaks-budget-sync
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016dHac1iYQKTMZEtL7xDJvA
This commit is contained in:
parent
957769ff0e
commit
ce68dcd82c
@ -0,0 +1,733 @@
|
||||
<title>Calendar, streaks, and a freely-editable synced budget</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--fg: #1a1a2e;
|
||||
--muted: #5b5b6e;
|
||||
--card: #f4f5f9;
|
||||
--border: #dcdce6;
|
||||
--accent: #2f7d5f;
|
||||
--accent-bg: #e4f3ec;
|
||||
--red: #c0392b;
|
||||
--red-bg: #fbe6e3;
|
||||
--yellow: #a67c00;
|
||||
--yellow-bg: #fbf1d6;
|
||||
--code-bg: #f0f1f6;
|
||||
--link: #1d5fb0;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #14141c;
|
||||
--fg: #e8e8f0;
|
||||
--muted: #a3a3b8;
|
||||
--card: #1e1e2a;
|
||||
--border: #33333f;
|
||||
--accent: #56c398;
|
||||
--accent-bg: #163227;
|
||||
--red: #ff8b7d;
|
||||
--red-bg: #3a1e1c;
|
||||
--yellow: #e0c33c;
|
||||
--yellow-bg: #362d10;
|
||||
--code-bg: #1a1a24;
|
||||
--link: #7db4ff;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 2.5rem 1rem 6rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.65;
|
||||
}
|
||||
main {
|
||||
max-width: min(48rem, 92vw);
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 { font-size: 1.7rem; margin-bottom: 0.25rem; }
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-top: 3rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
h3 { font-size: 1.05rem; margin-top: 1.75rem; }
|
||||
p { margin: 0.9rem 0; }
|
||||
.subtitle { color: var(--muted); margin-top: 0; }
|
||||
code {
|
||||
background: var(--code-bg);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
pre {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.9rem 1rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.83rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
pre code { background: none; padding: 0; }
|
||||
nav.toc {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.2rem;
|
||||
margin: 1.5rem 0 2.5rem;
|
||||
}
|
||||
nav.toc strong { display: block; margin-bottom: 0.5rem; }
|
||||
nav.toc ul { margin: 0; padding-left: 1.2rem; }
|
||||
nav.toc a { color: var(--link); text-decoration: none; }
|
||||
nav.toc a:hover { text-decoration: underline; }
|
||||
.callout {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin: 1.2rem 0;
|
||||
}
|
||||
.diagram {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1.2rem;
|
||||
margin: 1.2rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.flow { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
|
||||
.flow .arrow { color: var(--muted); font-size: 1.3rem; }
|
||||
.box {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.8rem;
|
||||
background: var(--bg);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
min-width: 6rem;
|
||||
}
|
||||
.box.strong { border-color: var(--accent); background: var(--accent-bg); font-weight: 600; }
|
||||
.box.dim { color: var(--muted); }
|
||||
|
||||
/* calendar mini diagram */
|
||||
.cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(2.1rem, 1fr));
|
||||
gap: 0.3rem;
|
||||
max-width: 24rem;
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
.cal-cell {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #0b1f16;
|
||||
}
|
||||
.cal-cell.green { background: #4caf50; }
|
||||
.cal-cell.yellow { background: #e0a83c; }
|
||||
.cal-cell.red { background: #e5685a; }
|
||||
.cal-cell.black { background: #0b0b0d; color: #eee; border: 1.5px solid #cfcfcf; }
|
||||
.cal-cell.future { background: transparent; border: 1.5px dashed var(--border); color: var(--muted); font-weight: 400; }
|
||||
.cal-legend { display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.82rem; margin-top: 0.6rem; color: var(--muted); }
|
||||
.cal-legend .swatch { display: inline-block; width: 0.8rem; height: 0.8rem; border-radius: 50%; margin-right: 0.35rem; vertical-align: -1px; }
|
||||
|
||||
/* micro-world widget */
|
||||
.widget {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.3rem;
|
||||
margin: 1.3rem 0;
|
||||
}
|
||||
.widget-row { display: flex; gap: 1.2rem; flex-wrap: wrap; align-items: flex-end; margin-bottom: 1rem; }
|
||||
.field { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.85rem; }
|
||||
.field input[type="number"] {
|
||||
width: 8rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.field input[type="checkbox"] { transform: scale(1.2); margin-right: 0.4rem; }
|
||||
.result-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.result-badge .dot { width: 0.75rem; height: 0.75rem; border-radius: 50%; }
|
||||
.result-green { background: var(--accent-bg); color: var(--accent); }
|
||||
.result-yellow { background: var(--yellow-bg); color: var(--yellow); }
|
||||
.result-red { background: var(--red-bg); color: var(--red); }
|
||||
.result-black { background: var(--code-bg); color: var(--fg); }
|
||||
.result-neutral { background: var(--code-bg); color: var(--muted); }
|
||||
.math-line { font-family: ui-monospace, monospace; font-size: 0.85rem; margin-top: 0.7rem; color: var(--muted); }
|
||||
.math-line b { color: var(--fg); }
|
||||
|
||||
/* code walkthrough */
|
||||
.file-group {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.2rem;
|
||||
margin: 1.3rem 0;
|
||||
}
|
||||
.file-group h3 { margin-top: 0; }
|
||||
.filename { font-family: ui-monospace, monospace; font-size: 0.82rem; color: var(--muted); margin: 0.3rem 0 0.5rem; }
|
||||
|
||||
/* quiz */
|
||||
.quiz-q {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 1.1rem 1.2rem;
|
||||
margin: 1.3rem 0;
|
||||
background: var(--card);
|
||||
}
|
||||
.quiz-q p.question { font-weight: 600; margin-top: 0; }
|
||||
.quiz-options { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.quiz-option {
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.quiz-option:hover { border-color: var(--accent); }
|
||||
.quiz-option.correct { background: var(--accent-bg); border-color: var(--accent); }
|
||||
.quiz-option.incorrect { background: var(--red-bg); border-color: var(--red); }
|
||||
.quiz-option[disabled] { cursor: default; }
|
||||
.quiz-rationale {
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
display: none;
|
||||
}
|
||||
.quiz-rationale.shown { display: block; }
|
||||
#quiz-score {
|
||||
position: sticky;
|
||||
bottom: 1rem;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
border-radius: 999px;
|
||||
padding: 0.6rem 1.2rem;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
margin-top: 1.5rem;
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
table { border-collapse: collapse; width: 100%; margin: 0.9rem 0; font-size: 0.88rem; }
|
||||
th, td { border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left; }
|
||||
th { background: var(--card); }
|
||||
</style>
|
||||
|
||||
<main>
|
||||
<h1>Calendar, streaks, and a freely-editable synced budget</h1>
|
||||
<p class="subtitle">diet_guard — commit 957769f, 49 files, +4027/-443</p>
|
||||
|
||||
<nav class="toc" aria-label="Table of contents">
|
||||
<strong>Contents</strong>
|
||||
<ul>
|
||||
<li><a href="#background">Background</a></li>
|
||||
<li><a href="#intuition">Intuition</a></li>
|
||||
<li><a href="#code">Code walkthrough</a></li>
|
||||
<li><a href="#quiz">Quiz</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<section id="background">
|
||||
<h2>Background</h2>
|
||||
|
||||
<p>
|
||||
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 <code>crdt_sync</code>,
|
||||
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.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
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
|
||||
<em>sealed</em> it with <code>chattr +i</code>, 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 <code>sudo chattr -i</code> step. The
|
||||
budget lived only on the PC and was never synced.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This change does three things at once, and they're entangled on purpose:
|
||||
</p>
|
||||
<ol>
|
||||
<li>Adds a <strong>calendar view</strong> 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.</li>
|
||||
<li>To color days on the <em>phone</em>, 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.</li>
|
||||
<li>Tapping a colored day now opens the existing history list, pre-filtered
|
||||
to just that day.</li>
|
||||
</ol>
|
||||
|
||||
<div class="callout">
|
||||
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.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="intuition">
|
||||
<h2>Intuition</h2>
|
||||
|
||||
<h3>Classifying a day</h3>
|
||||
<p>
|
||||
Every day's status is one of four values, and the ordering matters:
|
||||
<code>NOT_LOGGED</code> is considered <em>worse</em> than <code>RED</code> —
|
||||
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%):
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr><th>Status</th><th>Condition</th><th>Meaning</th></tr>
|
||||
<tr><td><b>GREEN</b></td><td><code>kcal ≤ budget</code></td><td>at or under budget</td></tr>
|
||||
<tr><td><b>YELLOW</b></td><td><code>budget < kcal ≤ budget × 1.20</code></td><td>over, but within 20%</td></tr>
|
||||
<tr><td><b>RED</b></td><td><code>kcal > budget × 1.20</code></td><td>more than 20% over</td></tr>
|
||||
<tr><td><b>NOT_LOGGED</b></td><td>no valid entries that day</td><td>unknown — worse than RED</td></tr>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
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
|
||||
<code>NOT_LOGGED</code>'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.
|
||||
</p>
|
||||
|
||||
<p>Try it — this is exactly the rule both the Python and Dart code implement:</p>
|
||||
|
||||
<div class="widget" id="classifier-widget">
|
||||
<div class="widget-row">
|
||||
<div class="field">
|
||||
<label for="w-budget">Daily budget (kcal)</label>
|
||||
<input type="number" id="w-budget" value="2000" min="1" step="50">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="w-kcal">Eaten so far (kcal)</label>
|
||||
<input type="number" id="w-kcal" value="1800" min="0" step="50">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="w-logged"><input type="checkbox" id="w-logged" checked>Anything logged this day?</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="w-future"><input type="checkbox" id="w-future">This day is in the future</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="w-result"></div>
|
||||
<div class="math-line" id="w-math"></div>
|
||||
</div>
|
||||
|
||||
<h3>Making the budget syncable</h3>
|
||||
<p>
|
||||
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 <code>time</code> 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 <em>edited repeatedly</em>. Reusing the "derive the
|
||||
clock from an immutable field" trick doesn't work, because there's no
|
||||
immutable field to derive it from.
|
||||
</p>
|
||||
<p>
|
||||
The fix is to give the budget record its own edit timestamp — a
|
||||
<code>t</code> field (Python) / <code>updatedAt</code> field (Dart) that gets
|
||||
stamped fresh on every write — and derive the HLC from <em>that</em>. 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."
|
||||
</p>
|
||||
|
||||
<div class="diagram">
|
||||
<div class="flow">
|
||||
<div class="box">PC writes<br>budget=1800<br><span style="color:var(--muted)">t=10:02</span></div>
|
||||
<div class="arrow">+</div>
|
||||
<div class="box">Phone writes<br>budget=2200<br><span style="color:var(--muted)">t=10:05</span></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="box strong">merged: 2200<br><span style="font-weight:400">(later t wins)</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>The calendar, end to end</h3>
|
||||
<p>
|
||||
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:
|
||||
</p>
|
||||
<div class="diagram">
|
||||
<div class="cal-grid">
|
||||
<div class="cal-cell green">1</div>
|
||||
<div class="cal-cell yellow">2</div>
|
||||
<div class="cal-cell red">3</div>
|
||||
<div class="cal-cell black">4</div>
|
||||
<div class="cal-cell green">5</div>
|
||||
<div class="cal-cell green">6</div>
|
||||
<div class="cal-cell future">7</div>
|
||||
</div>
|
||||
<div class="cal-legend">
|
||||
<span><span class="swatch" style="background:#4caf50"></span>green — at/under budget</span>
|
||||
<span><span class="swatch" style="background:#e0a83c"></span>yellow — up to 20% over</span>
|
||||
<span><span class="swatch" style="background:#e5685a"></span>red — >20% over</span>
|
||||
<span><span class="swatch" style="background:#0b0b0d;border:1.5px solid #999"></span>black — not logged</span>
|
||||
<span><span class="swatch" style="border:1.5px dashed #999;background:transparent"></span>dashed — future, not judged</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="code">
|
||||
<h2>Code walkthrough</h2>
|
||||
<p>
|
||||
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:
|
||||
</p>
|
||||
|
||||
<div class="file-group">
|
||||
<h3>1. Removing the anti-impulse-editing seal</h3>
|
||||
<p class="filename">diet_guard/_budget.py · install.sh · CLAUDE.md</p>
|
||||
<p>
|
||||
<code>_budget.py</code> lost its base64+HMAC signing, its
|
||||
<code>seal_budget()</code>/<code>lock_command</code>/<code>unlock_command</code>,
|
||||
and the <code>BudgetLockedError</code> exception — replaced by a plain
|
||||
<code>write_budget()</code> that stamps an edit time and writes JSON, no
|
||||
ritual required:
|
||||
</p>
|
||||
<pre><code>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)</code></pre>
|
||||
<p>
|
||||
<code>install.sh</code> dropped its <code>chattr +i</code> 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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="file-group">
|
||||
<h3>2. Day-status classification, twice, kept honest by mirrored tests</h3>
|
||||
<p class="filename">diet_guard/_daystatus.py · app/lib/models/day_status.dart · app/lib/services/day_status_service.dart</p>
|
||||
<p>
|
||||
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 — <code>dayStatus</code>,
|
||||
<code>statusMap</code>, <code>loggingStreak</code>, <code>adherenceStreak</code>,
|
||||
<code>yearToDateTally</code> — 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.
|
||||
</p>
|
||||
<p>
|
||||
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 <em>only</em> if today itself is absent from the status map,
|
||||
then counts backward as long as each day qualifies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="file-group">
|
||||
<h3>3. Budget sync — a second synced file, same merge machinery</h3>
|
||||
<p class="filename">diet_guard/_sync_merge.py · _sync.py · app/lib/services/sync_merge.dart · sync_service.dart · app_settings_service.dart</p>
|
||||
<p>
|
||||
The budget becomes a sibling of the food log: a new
|
||||
<code>devices/<device_id>/budget.json</code> file per device, with one
|
||||
record (id <code>"budget"</code>) merged via the exact same
|
||||
<code>merge_logs</code>/<code>mergeLogs</code> primitive the food log already
|
||||
uses. No new merge algorithm was needed — only new adapter functions
|
||||
(<code>budget_to_log</code> / <code>log_to_budget</code> in Python,
|
||||
<code>budgetToLog</code> / <code>logToBudget</code> in Dart) translating
|
||||
between the on-disk budget record and the generic <code>Record</code>/
|
||||
<code>Log</code> shape <code>crdt_sync</code> operates on.
|
||||
</p>
|
||||
<p>
|
||||
On the Dart side, <code>AppSettingsService</code> gained a second write path:
|
||||
<code>saveDailyKcalGoal(int)</code> (a real user edit — stamps "now") versus
|
||||
<code>applySyncedBudget(int, {updatedAt})</code> (a merge result — persists
|
||||
the <em>winning</em> 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.
|
||||
</p>
|
||||
<div class="callout">
|
||||
A real bug surfaced while wiring this up: <code>backgroundSyncPush()</code>
|
||||
runs in a separate WorkManager background isolate with none of the foreground
|
||||
isolate's initialized singletons. Since budget sync now calls
|
||||
<code>AppSettingsService.instance</code>, that isolate needed an explicit
|
||||
<code>await AppSettingsService.init();</code> added — a one-line fix caught
|
||||
before it could crash a background sync tick in production.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-group">
|
||||
<h3>4. The PC gate's History tab</h3>
|
||||
<p class="filename">diet_guard/_gatelock_calendar.py (new, 466 lines) · _calendar_view.py (new) · _gatelock.py · _gatelock_ui.py</p>
|
||||
<p>
|
||||
The gate's fullscreen Tk window gained a second tab via <code>ttk.Notebook</code>
|
||||
— the existing meal-entry form becomes "Log Meal", and a new "History" tab
|
||||
holds the calendar, streaks, and budget field. The month-grid math
|
||||
(<code>build_month_cells</code>) was deliberately split into its own
|
||||
<code>_calendar_view.py</code> 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.
|
||||
</p>
|
||||
<p>
|
||||
The budget field itself follows the same two-click state machine on both
|
||||
platforms: it starts <code>readonly</code> showing <code>2200</code> (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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="file-group">
|
||||
<h3>5. The phone's Calendar screen</h3>
|
||||
<p class="filename">app/lib/screens/calendar_screen.dart (new) · widgets/day_status_calendar.dart (new) · widgets/streak_summary_row.dart (new) · log_meal_screen.dart</p>
|
||||
<p>
|
||||
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 (<code>DayStatusCalendar</code>) is a plain <code>StatelessWidget</code>
|
||||
— the parent owns which month is displayed and re-renders it, matching the
|
||||
existing <code>WorkoutCalendar</code> pattern from a sibling app in the same
|
||||
monorepo rather than inventing a new convention.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="file-group">
|
||||
<h3>6. Tapping a day opens that day's history</h3>
|
||||
<p class="filename">app/lib/widgets/day_status_calendar.dart · screens/history_screen.dart · screens/calendar_screen.dart</p>
|
||||
<p>
|
||||
Rather than building a new day-detail view, the calendar reuses the existing
|
||||
history list, which already supported filtering by an arbitrary date range.
|
||||
<code>HistoryScreen</code> gained one new constructor parameter,
|
||||
<code>initialDateRange</code>, applied once at load time:
|
||||
</p>
|
||||
<pre><code>const HistoryScreen({this.initialDateRange, super.key});
|
||||
...
|
||||
_filter = HistoryFilter(dateRange: widget.initialDateRange);</code></pre>
|
||||
<p>
|
||||
The calendar widget gained an optional <code>onDaySelected(DateTime)</code>
|
||||
callback; each day cell wraps itself in a <code>GestureDetector</code> only
|
||||
when that callback is provided, and the screen wires it to push
|
||||
<code>HistoryScreen</code> with the range collapsed to a single day
|
||||
(<code>start == end == that day</code>) — which the existing filter logic
|
||||
already treats as "everything logged on that date."
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="quiz">
|
||||
<h2>Quiz</h2>
|
||||
<p>Five questions on the reasoning behind this change, not line numbers. Click an option to reveal whether it's right.</p>
|
||||
|
||||
<div id="quiz-root"></div>
|
||||
<div id="quiz-score">Score: 0 / 5</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// ---- Day-status classifier micro-world ----
|
||||
var budgetEl = document.getElementById('w-budget');
|
||||
var kcalEl = document.getElementById('w-kcal');
|
||||
var loggedEl = document.getElementById('w-logged');
|
||||
var futureEl = document.getElementById('w-future');
|
||||
var resultEl = document.getElementById('w-result');
|
||||
var mathEl = document.getElementById('w-math');
|
||||
|
||||
function classify() {
|
||||
var budget = Math.max(1, Number(budgetEl.value) || 0);
|
||||
var kcal = Math.max(0, Number(kcalEl.value) || 0);
|
||||
var logged = loggedEl.checked;
|
||||
var future = futureEl.checked;
|
||||
var ceiling = budget * 1.20;
|
||||
|
||||
if (future) {
|
||||
resultEl.innerHTML = '<span class="result-badge result-neutral"><span class="dot" style="background:transparent;border:1.5px dashed currentColor"></span>FUTURE — not judged</span>';
|
||||
mathEl.innerHTML = 'A day after "today" is never classified, regardless of the log — it skips straight to a neutral render.';
|
||||
return;
|
||||
}
|
||||
if (!logged) {
|
||||
resultEl.innerHTML = '<span class="result-badge result-black"><span class="dot" style="background:#0b0b0d;border:1px solid #999"></span>NOT_LOGGED — worse than red</span>';
|
||||
mathEl.innerHTML = 'No valid entries this day → classified NOT_LOGGED, regardless of what <code>kcal</code> is set to.';
|
||||
return;
|
||||
}
|
||||
if (kcal <= budget) {
|
||||
resultEl.innerHTML = '<span class="result-badge result-green"><span class="dot" style="background:#2f7d5f"></span>GREEN</span>';
|
||||
mathEl.innerHTML = '<b>' + kcal + '</b> ≤ budget <b>' + budget + '</b>';
|
||||
} else if (kcal <= ceiling) {
|
||||
resultEl.innerHTML = '<span class="result-badge result-yellow"><span class="dot" style="background:#a67c00"></span>YELLOW</span>';
|
||||
mathEl.innerHTML = 'budget <b>' + budget + '</b> < <b>' + kcal + '</b> ≤ ceiling <b>' + ceiling.toFixed(0) + '</b> (budget × 1.20)';
|
||||
} else {
|
||||
resultEl.innerHTML = '<span class="result-badge result-red"><span class="dot" style="background:#c0392b"></span>RED</span>';
|
||||
mathEl.innerHTML = '<b>' + kcal + '</b> > ceiling <b>' + ceiling.toFixed(0) + '</b> (budget × 1.20)';
|
||||
}
|
||||
}
|
||||
[budgetEl, kcalEl, loggedEl, futureEl].forEach(function (el) {
|
||||
el.addEventListener('input', classify);
|
||||
el.addEventListener('change', classify);
|
||||
});
|
||||
classify();
|
||||
|
||||
// ---- Quiz ----
|
||||
var questions = [
|
||||
{
|
||||
q: "Why is a not-logged day classified as worse than a red (badly-over-budget) day?",
|
||||
options: [
|
||||
"Red days are rarer in practice, so ranking not-logged below them balances the calendar's color mix overall.",
|
||||
"The two color palettes only support four values total, so not-logged just reuses red's existing rank slot.",
|
||||
"Not-logged carries no information about how the day went, treated as worse than a known bad outcome.",
|
||||
"Not-logged days count twice toward the adherence streak, so ranking them below red keeps that math working."
|
||||
],
|
||||
correct: 2,
|
||||
rationale: "This was an explicit design decision from the person who requested the feature: not knowing is worse than knowing you went over, because at least a red day means you're aware of the outcome."
|
||||
},
|
||||
{
|
||||
q: "How does the calendar avoid rendering a future day (after \"today\") as a black not-logged cell?",
|
||||
options: [
|
||||
"Both widgets check the cell's date against today first, rendering neutrally before the log gets consulted.",
|
||||
"The log-loading code backfills every future date with a placeholder green entry before the grid gets built.",
|
||||
"Future dates are dropped from the grid entirely, so a visible month only ever runs up through today's date.",
|
||||
"A separate day-elapsed flag stored per cell retroactively unlocks the not-logged color once a day fully ends."
|
||||
],
|
||||
correct: 0,
|
||||
rationale: "day_status_calendar.dart's _statusFor and _gatelock_calendar.py's _cell_status both check `day > today` (or `date.isAfter(todayOnly)`) before consulting the status map at all."
|
||||
},
|
||||
{
|
||||
q: "Food-log entries derive their sync clock from an immutable time field. Why couldn't the budget reuse that trick?",
|
||||
options: [
|
||||
"Budget syncing runs on its own separate background timer, so it needed an unrelated clock source avoiding entry ticks.",
|
||||
"The budget record carries no id field, and clock derivation there requires an id to seed its internal hash.",
|
||||
"Budget values are numeric while entry times are strings, and clock derivation only ever accepts string fields.",
|
||||
"The budget gets edited repeatedly, so there's no fixed field to derive a clock from -- it needs its own edit stamp."
|
||||
],
|
||||
correct: 3,
|
||||
rationale: "An entry's time never changes after creation, so the same entry always yields the same clock (idempotent re-sync). The budget's value changes, so a fresh t/updatedAt field stamped on every edit is what lets last-writer-wins resolve correctly."
|
||||
},
|
||||
{
|
||||
q: "What actually happens when a user taps a colored day in the calendar?",
|
||||
options: [
|
||||
"The tapped day's entries get copied into a temporary list and shown in a modal dialog above the grid.",
|
||||
"The existing history screen opens with its date filter set to that single day, reusing logic already there.",
|
||||
"A brand-new day-detail screen renders a summary card, built specifically and only ever for this one feature.",
|
||||
"The app re-queries the sync server for that date, since the local log only ever holds the current month."
|
||||
],
|
||||
correct: 1,
|
||||
rationale: "HistoryScreen gained one new constructor parameter, initialDateRange, which seeds its existing HistoryFilter.dateRange -- no new screen or query path was built."
|
||||
},
|
||||
{
|
||||
q: "Why does AppSettingsService need two different budget-write methods instead of just one?",
|
||||
options: [
|
||||
"One method targets local storage while the other writes straight to the GitHub-backed repo, different destinations.",
|
||||
"Applying a merge must keep the winning edit's own timestamp, or an unchanged value would look freshly edited each tick.",
|
||||
"The split exists purely for testability -- production code in practice really only ever calls one of the two methods.",
|
||||
"The sync-facing method also checks the incoming value against a biometric minimum that the other one skips."
|
||||
],
|
||||
correct: 1,
|
||||
rationale: "If a merge re-stamped 'now' every time, syncing an unchanged value would make this device's copy look like the most recent edit, breaking idempotency and potentially out-racing a genuinely newer edit on the next merge."
|
||||
}
|
||||
];
|
||||
|
||||
var root = document.getElementById('quiz-root');
|
||||
var scoreEl = document.getElementById('quiz-score');
|
||||
var score = 0;
|
||||
var answered = 0;
|
||||
|
||||
questions.forEach(function (item, qIndex) {
|
||||
var qDiv = document.createElement('div');
|
||||
qDiv.className = 'quiz-q';
|
||||
|
||||
var p = document.createElement('p');
|
||||
p.className = 'question';
|
||||
p.textContent = (qIndex + 1) + '. ' + item.q;
|
||||
qDiv.appendChild(p);
|
||||
|
||||
var optsDiv = document.createElement('div');
|
||||
optsDiv.className = 'quiz-options';
|
||||
|
||||
var rationaleDiv = document.createElement('div');
|
||||
rationaleDiv.className = 'quiz-rationale';
|
||||
rationaleDiv.textContent = item.rationale;
|
||||
|
||||
item.options.forEach(function (optText, optIndex) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'quiz-option';
|
||||
btn.type = 'button';
|
||||
btn.textContent = optText;
|
||||
btn.addEventListener('click', function () {
|
||||
if (btn.disabled) return;
|
||||
var allBtns = optsDiv.querySelectorAll('.quiz-option');
|
||||
allBtns.forEach(function (b) { b.disabled = true; });
|
||||
if (optIndex === item.correct) {
|
||||
btn.classList.add('correct');
|
||||
score++;
|
||||
} else {
|
||||
btn.classList.add('incorrect');
|
||||
allBtns[item.correct].classList.add('correct');
|
||||
}
|
||||
rationaleDiv.classList.add('shown');
|
||||
answered++;
|
||||
scoreEl.textContent = 'Score: ' + score + ' / ' + questions.length + (answered === questions.length ? ' — done!' : '');
|
||||
});
|
||||
optsDiv.appendChild(btn);
|
||||
});
|
||||
|
||||
qDiv.appendChild(optsDiv);
|
||||
qDiv.appendChild(rationaleDiv);
|
||||
root.appendChild(qDiv);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
43
docs/explanations/index.html
Normal file
43
docs/explanations/index.html
Normal file
@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>diet-guard — change explanations</title>
|
||||
<style>
|
||||
:root{--bg:#fbfbfd;--fg:#1a1a1f;--muted:#5b5b6b;--card:#fff;--line:#e4e4ec;
|
||||
--accent:#6b4fd6;--accent-soft:#efeaff;--shadow:0 1px 3px rgba(0,0,0,.08),0 8px 24px rgba(0,0,0,.05);}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#131318;--fg:#e9e9f0;--muted:#a2a2b4;--card:#1c1c24;
|
||||
--line:#2c2c38;--accent:#a78bfa;--accent-soft:#241f38;--shadow:0 1px 3px rgba(0,0,0,.4),0 10px 30px rgba(0,0,0,.35);}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased}
|
||||
.wrap{width:min(48rem,92vw);margin:0 auto;padding:2.6rem 0 5rem}
|
||||
.kicker{color:var(--accent);font-weight:700;font-size:.8rem;letter-spacing:.08em;text-transform:uppercase}
|
||||
h1{font-size:1.7rem;margin:.35rem 0 .3rem}
|
||||
.lead{color:var(--muted);margin:0 0 1.6rem}
|
||||
.grid{display:flex;flex-direction:column;gap:.7rem}
|
||||
a.card{display:flex;flex-direction:column;gap:.2rem;text-decoration:none;color:var(--fg);
|
||||
background:var(--card);border:1px solid var(--line);border-radius:12px;padding:.9rem 1.1rem;box-shadow:var(--shadow)}
|
||||
a.card:hover{border-color:var(--accent);background:var(--accent-soft)}
|
||||
.date{font-size:.75rem;color:var(--muted);font-variant-numeric:tabular-nums;letter-spacing:.03em}
|
||||
.title{font-size:1.05rem;font-weight:600;line-height:1.35}
|
||||
.empty{color:var(--muted)}
|
||||
footer{margin-top:2.4rem;padding-top:1.1rem;border-top:1px solid var(--line);color:var(--muted);font-size:.83rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="kicker">diet-guard</div>
|
||||
<h1>Change explanations</h1>
|
||||
<p class="lead">1 explanation · interactive walkthroughs of notable changes, newest first.</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="2026-07-13-explanation-calendar-streaks-budget-sync.html">
|
||||
<span class="date">2026-07-13</span>
|
||||
<span class="title">Calendar, streaks, and a freely-editable synced budget</span>
|
||||
</a>
|
||||
</div>
|
||||
<footer>Generated by the <code>explain-diff</code> skill. Open any card to replay its walkthrough.</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user