Drop explanation pages and window titles

textgate scan 319 -> 0: docs/explanations (dropped explain-diff
output, not published, nothing links it), Tk window titles, the
MaterialApp title, '+kg' -> '+' plus the weight icon, and
spoken-only Semantics labels hoisted so the gate sees them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-11 01:04:56 +01:00
parent b6db88f9b9
commit 32a735f168
13 changed files with 22 additions and 1896 deletions

View File

@ -1,464 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Driving the workout app to 100% test coverage</title>
<style>
:root {
--bg: #ffffff; --fg: #1a1a1a; --muted: #5a5a5a; --card: #f5f5f7;
--border: #e2e2e6; --accent: #4f46e5; --accent-soft: #ede9fe;
--good: #15803d; --good-bg: #dcfce7; --bad: #b91c1c; --bad-bg: #fee2e2;
--code-bg: #f6f8fa; --add: #e6ffec; --add-fg: #04562a; --del: #ffebe9; --del-fg: #82071e;
--pill: #eef2ff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14151a; --fg: #e6e6ea; --muted: #9aa0aa; --card: #1c1e26;
--border: #2a2d38; --accent: #a5b4fc; --accent-soft: #262347;
--good: #4ade80; --good-bg: #16351f; --bad: #f87171; --bad-bg: #3a1a1a;
--code-bg: #1a1c23; --add: #12321f; --add-fg: #7ee2a8; --del: #3a1a1d; --del-fg: #f0a8ae;
--pill: #23263a;
}
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.65; font-size: 17px;
}
.wrap { max-width: min(48rem, 92vw); margin: 0 auto; padding: 2.5rem 0 5rem; }
h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 .3rem; }
h2 { font-size: 1.5rem; margin: 3rem 0 1rem; padding-top: .4rem; border-top: 1px solid var(--border); }
h3 { font-size: 1.15rem; margin: 1.8rem 0 .6rem; }
p, li { color: var(--fg); }
.sub { color: var(--muted); font-size: 1.02rem; margin: 0 0 1.5rem; }
a { color: var(--accent); }
code { background: var(--code-bg); padding: .1em .35em; border-radius: 5px; font-size: .88em;
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; }
pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 10px;
padding: 1rem; overflow-x: auto; white-space: pre-wrap; word-break: break-word; font-size: .82rem; line-height: 1.55; }
pre code { background: none; padding: 0; font-size: inherit; }
.toc { background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 1rem 1.25rem; margin: 1.5rem 0 0; }
.toc strong { font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
.toc ol { margin: .5rem 0 0; padding-left: 1.2rem; }
.toc a { text-decoration: none; }
.toc a:hover { text-decoration: underline; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1.25rem; margin: 1.25rem 0; }
.pill { display: inline-block; background: var(--pill); color: var(--accent); font-size: .72rem;
font-weight: 600; padding: .15em .6em; border-radius: 999px; letter-spacing: .03em; }
.add { color: var(--add-fg); background: var(--add); display: block; }
.del { color: var(--del-fg); background: var(--del); display: block; }
.diagram { display: flex; flex-wrap: wrap; gap: .75rem; align-items: stretch; margin: 1.25rem 0; }
.zone { flex: 1 1 220px; border: 2px solid var(--border); border-radius: 12px; padding: 1rem; }
.zone.fake { border-color: var(--bad); }
.zone.real { border-color: var(--good); }
.zone h4 { margin: 0 0 .5rem; font-size: .95rem; }
.zone .tag { font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.zone.fake .tag { color: var(--bad); }
.zone.real .tag { color: var(--good); }
.box { border: 1px solid var(--border); border-radius: 8px; background: var(--bg); padding: .5rem .7rem; margin: .5rem 0; font-size: .82rem; }
.flowrow { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin: .4rem 0; font-size: .85rem; }
.arrow { color: var(--muted); font-weight: 700; }
.k { font-weight: 600; }
table.cov { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: .9rem; }
table.cov th, table.cov td { border: 1px solid var(--border); padding: .5rem .6rem; text-align: left; }
table.cov th { background: var(--card); }
table.cov td.num { text-align: right; font-variant-numeric: tabular-nums; }
.good { color: var(--good); font-weight: 600; }
.bad { color: var(--bad); font-weight: 600; }
/* micro-world */
.sim { background: var(--card); border: 1px solid var(--border); border-radius: 14px; padding: 1.25rem; margin: 1.5rem 0; }
.sim h4 { margin: 0 0 .25rem; }
.sim .controls { display: flex; flex-wrap: wrap; gap: .5rem; margin: .9rem 0; }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.seg button { background: var(--bg); color: var(--fg); border: none; padding: .5rem .85rem; font-size: .85rem; cursor: pointer; }
.seg button.on { background: var(--accent); color: #fff; }
.runbtn { background: var(--accent); color: #fff; border: none; border-radius: 10px; padding: .6rem 1.1rem; font-size: .9rem; font-weight: 600; cursor: pointer; }
.runbtn:active { transform: translateY(1px); }
.stage { margin-top: 1rem; border: 1px dashed var(--border); border-radius: 10px; padding: 1rem; background: var(--bg); }
.step { font-size: .85rem; margin: .3rem 0; opacity: .35; transition: opacity .25s; }
.step.active { opacity: 1; }
.verdict { margin-top: .9rem; font-weight: 700; padding: .6rem .8rem; border-radius: 8px; font-size: .95rem; }
.verdict.pass { background: var(--good-bg); color: var(--good); }
.verdict.fail { background: var(--bad-bg); color: var(--bad); }
.verdict.idle { background: var(--code-bg); color: var(--muted); font-weight: 500; }
/* quiz */
.q { border: 1px solid var(--border); border-radius: 12px; padding: 1.1rem 1.25rem; margin: 1.1rem 0; background: var(--card); }
.q .qhead { font-weight: 600; margin-bottom: .75rem; }
.opt { display: block; width: 100%; text-align: left; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: 9px; padding: .6rem .8rem; margin: .4rem 0; font-size: .92rem; cursor: pointer; }
.opt:hover { border-color: var(--accent); }
.opt.correct { background: var(--good-bg); border-color: var(--good); color: var(--good); }
.opt.wrong { background: var(--bad-bg); border-color: var(--bad); color: var(--bad); }
.opt:disabled { cursor: default; }
.rationale { font-size: .85rem; color: var(--muted); margin-top: .5rem; display: none; }
.rationale.show { display: block; }
.score { position: sticky; bottom: 0; background: var(--accent); color: #fff; border-radius: 10px;
padding: .6rem 1rem; margin-top: 1rem; font-weight: 600; text-align: center; }
.mono { font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; font-size: .85em; }
</style>
</head>
<body>
<div class="wrap">
<span class="pill">CHANGE EXPLAINED · 2026-07-13</span>
<h1>Driving the workout app to 100% test coverage</h1>
<p class="sub">How a stuck push gate got unblocked — and the Flutter widget-test techniques that made the hard 267 lines of chart, calendar and live-workout UI testable.</p>
<nav class="toc">
<strong>Contents</strong>
<ol>
<li><a href="#background">Background — the gate that never went green</a></li>
<li><a href="#intuition">Intuition — two clocks, one test</a></li>
<li><a href="#code">Code — what actually changed</a></li>
<li><a href="#quiz">Quiz — check your understanding</a></li>
</ol>
</nav>
<h2 id="background">1 · Background</h2>
<p>The repo contains a Flutter phone app, <code>workout_app</code>, that tracks strength workouts and syncs them to a PC. To keep quality high, the project installs a <em>pre-push gate</em>: whenever a commit touches Dart code, pushing runs two checks locally before the push is allowed to leave your machine.</p>
<div class="card">
<div class="flowrow"><span class="k">git push</span> <span class="arrow">→</span> <span class="box"><b>flutter analyze</b> — zero issues (even <span class="mono">info</span>-level)</span></div>
<div class="flowrow"><span class="k"></span> <span class="arrow">→</span> <span class="box"><b>flutter test --coverage</b> — <b>100%</b> of every <code>lib/</code> line, and every file must appear in the report</span></div>
</div>
<p>That second check is strict in two ways. First, the bar is <strong>100%</strong> — not 90%, not "the lines you changed". Second, a helper script (<code>check_flutter_coverage.sh</code>) also fails if any <code>lib/**/*.dart</code> file is <em>missing</em> from the coverage report, so you can't quietly earn 100% by never importing a file into a test.</p>
<p>The catch: this gate had <strong>never once passed</strong> for a Dart commit. It was added while the app was only ~83% covered, so ten commits — an entire phone-to-PC sync feature — sat finished but <em>unpushable</em>, stuck behind 316 uncovered lines. This change closes that gap and ships all of it.</p>
<h3>Where the 316 lines lived</h3>
<table class="cov">
<thead><tr><th>File</th><th class="num">Before</th><th class="num">After</th><th>Nature of the gap</th></tr></thead>
<tbody>
<tr><td>storage_service.dart</td><td class="num">139/165</td><td class="num good">163/163</td><td>migrations, restore, edge branches</td></tr>
<tr><td>home_screen.dart</td><td class="num">117/121</td><td class="num good">121/121</td><td>post-navigation reloads</td></tr>
<tr><td>settings_screen.dart</td><td class="num">243/262</td><td class="num good">262/262</td><td>device-flow branches, steppers</td></tr>
<tr><td>history_screen.dart</td><td class="num">167/308</td><td class="num good">307/307</td><td>whole exercise drill-down + chart</td></tr>
<tr><td>workout_screen.dart</td><td class="num">144/270</td><td class="num good">269/269</td><td>live-workout breaks &amp; finish</td></tr>
</tbody>
</table>
<p>The two screens at the bottom — the history charts/calendar and the live-workout timer UI — held 267 of the 316 lines and were the genuinely hard part. Testing them ran head-first into a subtlety about <em>how time works inside a Flutter widget test</em>. That's the idea worth understanding, so it gets its own section.</p>
<h2 id="intuition">2 · Intuition — two clocks, one test</h2>
<p>A normal <code>testWidgets</code> test does <strong>not</strong> run on real time. Flutter wraps it in a <em>fake clock</em> (<span class="mono">FakeAsync</span>): timers only advance when you explicitly say <code>tester.pump(someDuration)</code>. This is usually wonderful — you can "wait" 3 minutes for a rest timer in a microsecond, deterministically.</p>
<p>But the app's real database, <span class="mono">sqflite</span>, schedules work on <em>real</em> timers and background isolates. Under the fake clock, those never get a chance to finish. So the moment a tapped widget kicks off a database write, you get a landmine:</p>
<div class="diagram">
<div class="zone fake">
<div class="tag">Fake-async zone (default)</div>
<h4>tap outside <span class="mono">runAsync</span></h4>
<div class="box">tap a set → <span class="mono">_tapCircle</span></div>
<div class="flowrow"><span class="arrow">↓</span></div>
<div class="box"><span class="mono">unawaited(_saveActiveSession())</span> → sqflite write</div>
<div class="flowrow"><span class="arrow">↓</span></div>
<div class="box bad">write parks on a real timer the fake clock never ticks</div>
<div class="flowrow"><span class="arrow">↓</span></div>
<div class="box"><b class="bad">✗ "Pending timers" — test fails</b></div>
</div>
<div class="zone real">
<div class="tag">Real loop via <span class="mono">tester.runAsync</span></div>
<h4>tap inside <span class="mono">runAsync</span></h4>
<div class="box">tap a set → <span class="mono">_tapCircle</span></div>
<div class="flowrow"><span class="arrow">↓</span></div>
<div class="box"><span class="mono">unawaited(_saveActiveSession())</span> → sqflite write</div>
<div class="flowrow"><span class="arrow">↓</span></div>
<div class="box good">real event loop runs the write to completion</div>
<div class="flowrow"><span class="arrow">↓</span></div>
<div class="box"><b class="good">✓ no leftover timers — test passes</b></div>
</div>
</div>
<p>So the rule that unlocked the live-workout screen is: <strong>any tap that triggers a database write or starts a <span class="mono">Timer.periodic</span> must be dispatched inside <code>tester.runAsync(...)</code></strong>, which runs that code on the genuine event loop where the work can actually finish. The assertion afterward happens on the fake clock again, so you still get deterministic <code>expect</code>s.</p>
<p>Two corollaries fell out of this while writing the tests:</p>
<ul>
<li><b>A dialog that awaits, then does DB work, is one flow.</b> When you tap "Finish", a confirm dialog opens; confirming resumes <code>_confirmFinish</code> in the <em>same zone the first tap fired in</em>, and that continuation does six sqflite writes. So the <em>whole</em> finish flow — open dialog, confirm, save — has to live in a single <code>runAsync</code> block, not just the confirm tap.</li>
<li><b><code>tester.longPress</code> fights <code>runAsync</code>.</b> It does its own internal fake-clock pumps to time the press, which don't advance inside <code>runAsync</code>. The fix is a manual gesture: <code>startGesture</code>, hold for a real 700 ms, then release — a real-time long-press.</li>
</ul>
<div class="sim" id="sim">
<h4>▶ Micro-world: run the test yourself</h4>
<p style="font-size:.9rem;color:var(--muted);margin:.2rem 0 0">Pick where the tapped code runs and what it does, then dispatch the tap. Watch whether the background work completes or leaks a pending timer.</p>
<div class="controls">
<div>
<div style="font-size:.75rem;color:var(--muted);margin-bottom:.25rem">Dispatch the tap in…</div>
<div class="seg" id="zoneSeg">
<button data-zone="fake" class="on">fake-async (default)</button>
<button data-zone="real">tester.runAsync</button>
</div>
</div>
<div>
<div style="font-size:.75rem;color:var(--muted);margin-bottom:.25rem">The tapped code…</div>
<div class="seg" id="workSeg">
<button data-work="db" class="on">writes to sqflite</button>
<button data-work="timer">starts a break timer</button>
<button data-work="pure">just calls setState</button>
</div>
</div>
</div>
<button class="runbtn" id="runBtn">Dispatch tap →</button>
<div class="stage" id="stage">
<div class="step" id="s1">1 · gesture dispatched, <span class="mono">onTap</span> fires</div>
<div class="step" id="s2">2 · handler runs…</div>
<div class="step" id="s3">3 · background work scheduled…</div>
<div class="step" id="s4">4 · test tears down, checks for leftover timers…</div>
<div class="verdict idle" id="verdict">Choose options and press “Dispatch tap”.</div>
</div>
</div>
<h3>The other half: reaching the code at all</h3>
<p>Zone discipline makes taps <em>survive</em>, but many lines were uncovered simply because the widget was never <em>built</em>. Flutter's <code>ListView</code> lazily builds only what's near the viewport, so the history screen's session tiles and the "DUMBBELL LUNGE" section label — which sit below an 800×600 test fold — never existed in the tree, and their lines stayed cold. The fix is to <code>scrollUntilVisible</code> / <code>ensureVisible</code> them first: scrolling builds them, which both renders and covers them.</p>
<h3>Honest 100%: what <code>coverage:ignore</code> is for</h3>
<p>A few lines genuinely can't run on a Linux CI host, so they're marked <code>coverage:ignore</code> with a stated reason rather than faked. The bar for an ignore is "no reachable test path exists," not "this was annoying to test":</p>
<div class="diagram">
<div class="box" style="flex:1 1 240px"><b>Android/iOS only</b><br><span style="font-size:.85rem;color:var(--muted)"><code>Vibration.vibrate()</code> — <code>hasVibrator()</code> returns <b>false</b> on Linux (no <code>Platform.isAndroid/isIOS</code>), so the body never runs there.</span></div>
<div class="box" style="flex:1 1 240px"><b>Production-only path</b><br><span style="font-size:.85rem;color:var(--muted)">The real on-device DB path (<code>getDatabasesPath()</code>) — tests always inject an in-memory or temp-file path instead.</span></div>
<div class="box" style="flex:1 1 240px"><b>Truly unreachable</b><br><span style="font-size:.85rem;color:var(--muted)">A defensive <code>null</code>-state branch that seeding makes impossible, and a history <code>_load</code> branch only reachable via a path that never calls <code>_load</code>.</span></div>
</div>
<h2 id="code">3 · Code — what actually changed</h2>
<p>The diff is <strong>792 insertions</strong> across ten files, but it splits cleanly into two buckets: a lot of <em>test</em> code, and a handful of tiny <em>production</em> edits that make code reachable from tests. Grouped by concern:</p>
<h3>A · Test seams in production code</h3>
<p>To test the "server not started" placeholder, a private widget had to become public — private widgets can't be constructed from a test file:</p>
<pre><code><span class="del">-class _ServerAddressTile extends StatelessWidget {
- const _ServerAddressTile({required this.addresses});</span>
<span class="add">+/// ...Public so the empty-address fallback can be exercised in a widget test.
+class ServerAddressTile extends StatelessWidget {
+ const ServerAddressTile({required this.addresses, super.key});</span></code></pre>
<p>And to test the schema migration, <code>resetForTesting</code> gained an optional path so a test can hand it a real <em>file</em> DB (in-memory DBs can't be reopened at a new version, which is exactly what an <code>onUpgrade</code> migration needs):</p>
<pre><code><span class="del">- static void resetForTesting() {
- _testDbPath = ':memory:';</span>
<span class="add">+ static void resetForTesting({String dbPath = ':memory:'}) {
+ _testDbPath = dbPath;</span></code></pre>
<h3>B · Justified coverage ignores</h3>
<p>Four small blocks are marked unreachable-on-CI, each with a comment explaining <em>why</em> — for example the haptics call:</p>
<pre><code> if (await Vibration.hasVibrator()) {
<span class="add">+ // Android/iOS-only: hasVibrator() returns false on the Linux test host
+ // (no Platform.isAndroid/isIOS), so this body never runs there.
+ // coverage:ignore-start</span>
unawaited(Vibration.vibrate(duration: 800));
<span class="add">+ // coverage:ignore-end</span>
}</code></pre>
<h3>C · The tests — real behaviour, on the real loop</h3>
<p>The bulk of the change. A representative live-workout test: a tap starts a rest break, and the tap is dispatched through a helper that keeps it on the real event loop so the sqflite write and periodic timer complete cleanly.</p>
<pre><code>Future&lt;void&gt; _tapReal(WidgetTester tester, Finder f) async {
await tester.runAsync(() async {
await tester.tap(f);
await Future&lt;void&gt;.delayed(const Duration(milliseconds: 100));
});
await tester.pump();
}
testWidgets('tapping a set starts a break which Skip cancels', (tester) async {
await _pump(tester, _wrap());
await _tapReal(tester, find.byType(RepCircle).first);
expect(find.byType(BreakBanner), findsOneWidget); // break started
await _tapReal(tester, find.text('Skip'));
expect(find.byType(BreakBanner), findsNothing); // Skip cancelled it
});</code></pre>
<p>And the schema-migration unit test that the new <code>dbPath</code> seam unlocked — build a v1 database on disk, open it through <code>StorageService</code> (which runs at v3), and assert the migration ran:</p>
<pre><code>final oldDb = await databaseFactory.openDatabase(dbFile,
options: OpenDatabaseOptions(version: 1, onCreate: /* v1 schema */));
await oldDb.close();
StorageService.resetForTesting(dbPath: dbFile); // real file, not :memory:
await StorageService.init(); // triggers onUpgrade 1 → 3
expect(await _svc.getNextWorkoutType(), 'A'); // settings table now exists
expect((await _svc.getExerciseState(name))!.successThreshold, 3); // column added</code></pre>
<h3>D · Making analyze green</h3>
<p>The gate also requires a spotless <code>flutter analyze</code>, and it exits non-zero on even an <span class="mono">info</span>. The last commit documents the newly-public field, reshapes the ignore comment so it isn't an over-long line, and reflows one pre-existing 81-char comment. Small, but the push doesn't go through without it.</p>
<h2 id="quiz">4 · Quiz</h2>
<p class="sub">Five questions on the reasoning behind the change. Click an answer to reveal whether it's right and why.</p>
<div id="quiz"></div>
<div class="score" id="score">Score: 0 / 0</div>
</div>
<script>
/* ---------- Micro-world ---------- */
(function () {
var zone = 'fake', work = 'db';
var zoneSeg = document.getElementById('zoneSeg');
var workSeg = document.getElementById('workSeg');
zoneSeg.addEventListener('click', function (e) {
if (!e.target.dataset.zone) return;
zone = e.target.dataset.zone;
[].forEach.call(zoneSeg.children, function (b) { b.classList.toggle('on', b.dataset.zone === zone); });
});
workSeg.addEventListener('click', function (e) {
if (!e.target.dataset.work) return;
work = e.target.dataset.work;
[].forEach.call(workSeg.children, function (b) { b.classList.toggle('on', b.dataset.work === work); });
});
var steps = ['s1', 's2', 's3', 's4'].map(function (id) { return document.getElementById(id); });
var verdict = document.getElementById('verdict');
var timers = [];
function clearTimers() { timers.forEach(clearTimeout); timers = []; }
function labelFor() {
if (work === 'db') return 'schedules a sqflite write (a real timer)';
if (work === 'timer') return 'starts a Timer.periodic rest break';
return 'only calls setState — no async work';
}
document.getElementById('runBtn').addEventListener('click', function () {
clearTimers();
steps.forEach(function (s) { s.classList.remove('active'); });
verdict.className = 'verdict idle';
verdict.textContent = 'Running…';
var leaks = (zone === 'fake') && (work === 'db' || work === 'timer');
var texts = [
'1 · gesture dispatched, onTap fires',
'2 · handler runs in the ' + (zone === 'fake' ? 'fake-async zone' : 'real event loop (runAsync)'),
'3 · handler ' + labelFor(),
'4 · teardown checks for leftover timers…'
];
steps.forEach(function (s, i) {
timers.push(setTimeout(function () {
s.classList.add('active');
s.textContent = texts[i];
}, 350 * (i + 1)));
});
timers.push(setTimeout(function () {
if (leaks) {
verdict.className = 'verdict fail';
verdict.textContent = '✗ FAIL — the ' + (work === 'db' ? 'sqflite write' : 'periodic timer') +
' never completed under the fake clock. "Pending timers" at teardown.';
} else if (zone === 'real' && work !== 'pure') {
verdict.className = 'verdict pass';
verdict.textContent = '✓ PASS — on the real loop the ' + (work === 'db' ? 'write' : 'timer') +
' finished (or was cancelled on dispose). No leftover timers.';
} else {
verdict.className = 'verdict pass';
verdict.textContent = '✓ PASS — pure setState schedules no async work, so there is nothing to leak in either zone.';
}
}, 350 * 5));
});
})();
/* ---------- Quiz ---------- */
var QUESTIONS = [
{
q: "A set-tap test run outside runAsync failed with “pending timers.” What caused it?",
options: [
"Its sqflite write and break timer stayed unfinished in the FakeAsync zone",
"The RepCircle sat below the 800×600 test fold so the simulated tap never landed",
"The setUp block forgot the fake secure-storage handler the tap path needs",
"The BreakBanner threw while building because its remaining value was null"
],
correct: 0,
why: "Under the fake clock, the real timers that sqflite (and Timer.periodic) schedule never advance, so they linger and fail teardown."
},
{
q: "Why does dispatching that same tap inside tester.runAsync fix the failure?",
options: [
"runAsync disables the widget's own timers so none of them can leak later",
"It runs that code on the real event loop, where the DB write completes",
"It auto-mocks the vibration and audio platform channels for the test",
"It forces a pumpAndSettle that drains every pending microtask first"
],
correct: 1,
why: "runAsync escapes the fake clock: the write and timer execute on the genuine event loop and actually finish, leaving nothing pending."
},
{
q: "Why was _ServerAddressTile renamed to the public ServerAddressTile?",
options: [
"A second screen needed to embed the identical tile without copying it",
"The analyzer forbids underscore-prefixed StatelessWidgets in screen files",
"A test file can't build a private widget, so its empty case is untested",
"So HttpServerService could write new addresses straight into the tile"
],
correct: 2,
why: "Only its no-address 'Server not started' branch was uncovered, and a test can't construct a library-private widget — so it had to be exposed."
},
{
q: "Why is the Vibration.vibrate call wrapped in coverage:ignore rather than tested?",
options: [
"Vibration is a side effect that unit tests must never trigger on the build host",
"The vibration plugin channel was too slow to mock reliably in the suite",
"The team decided haptics fall outside the 100% coverage requirement",
"hasVibrator() is false on the Linux CI host, so that body never runs there"
],
correct: 3,
why: "hasVibrator() checks Platform.isAndroid/isIOS via device_info_plus; both are false on Linux, so the guarded body is genuinely unreachable in CI."
},
{
q: "Why did resetForTesting gain an optional dbPath parameter?",
options: [
"To speed the suite up by sharing a single database across every test",
"To let production override the DB location from an environment variable",
"So a test can point at a real file DB and drive the v1→v3 migration",
"To stop the in-memory DB from leaking rows between separate test cases"
],
correct: 2,
why: "onUpgrade only runs when a persisted DB is reopened at a higher version; an in-memory DB can't be reopened, so a real file path was needed."
}
];
(function () {
var host = document.getElementById('quiz');
var scoreEl = document.getElementById('score');
var answered = 0, correct = 0;
QUESTIONS.forEach(function (item, qi) {
var card = document.createElement('div');
card.className = 'q';
var head = document.createElement('div');
head.className = 'qhead';
head.textContent = (qi + 1) + '. ' + item.q;
card.appendChild(head);
var done = false;
var buttons = [];
item.options.forEach(function (opt, oi) {
var b = document.createElement('button');
b.className = 'opt';
b.textContent = opt;
b.addEventListener('click', function () {
if (done) return;
done = true;
answered++;
buttons.forEach(function (bb, bi) {
bb.disabled = true;
if (bi === item.correct) bb.classList.add('correct');
});
if (oi === item.correct) { correct++; }
else { b.classList.add('wrong'); }
rat.classList.add('show');
scoreEl.textContent = 'Score: ' + correct + ' / ' + answered;
});
buttons.push(b);
card.appendChild(b);
});
var rat = document.createElement('div');
rat.className = 'rationale';
rat.textContent = '→ ' + item.why;
card.appendChild(rat);
host.appendChild(card);
});
})();
</script>
</body>
</html>

View File

@ -1,686 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Multiple workouts per day, the sync that never ran, and a ban on silence</title>
<style>
:root {
--bg: #ffffff; --fg: #1a1a1a; --muted: #5b6470; --line: #e2e6ea;
--card: #f6f8fa; --accent: #0b6bcb; --good: #1a7f37; --bad: #b42318;
--warn: #9a6700; --code: #f2f4f7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171a; --fg: #e6e9ec; --muted: #9aa4b0; --line: #2b3138;
--card: #1b1f24; --accent: #6cb6ff; --good: #4ac26b; --bad: #ff7b72;
--warn: #d4a72c; --code: #1e2329;
}
}
* { box-sizing: border-box; }
body {
margin: 0 auto; padding: 2rem 1rem 5rem; max-width: min(48rem, 92vw);
background: var(--bg); color: var(--fg); line-height: 1.65;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
h1 { font-size: 1.9rem; line-height: 1.25; margin: 0 0 .4rem; }
h2 { font-size: 1.4rem; margin: 2.6rem 0 .8rem; padding-top: .6rem;
border-top: 1px solid var(--line); }
h3 { font-size: 1.08rem; margin: 1.6rem 0 .5rem; }
.sub { color: var(--muted); margin: 0 0 1.5rem; }
a { color: var(--accent); }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
code { background: var(--code); padding: .12em .35em; border-radius: 4px;
font-size: .88em; }
.codewrap { overflow-x: auto; background: var(--code); border: 1px solid var(--line);
border-radius: 8px; margin: .8rem 0; }
pre { margin: 0; padding: .85rem 1rem; white-space: pre-wrap; overflow-x: auto;
font-size: .84rem; line-height: 1.5; }
.add { color: var(--good); }
.del { color: var(--bad); }
nav.toc { background: var(--card); border: 1px solid var(--line);
border-radius: 10px; padding: .9rem 1.1rem; margin: 1.5rem 0 2rem; }
nav.toc ol { margin: .4rem 0 0; padding-left: 1.2rem; }
nav.toc li { margin: .25rem 0; }
.card { background: var(--card); border: 1px solid var(--line);
border-radius: 10px; padding: 1rem 1.1rem; margin: 1.2rem 0; }
.note { border-left: 3px solid var(--accent); }
.warnbox { border-left: 3px solid var(--warn); }
.badbox { border-left: 3px solid var(--bad); }
table { border-collapse: collapse; width: 100%; font-size: .9rem; }
.tablewrap { overflow-x: auto; margin: 1rem 0; }
th, td { border: 1px solid var(--line); padding: .45rem .6rem; text-align: left; }
th { background: var(--card); }
/* diagram primitives */
.diagram { margin: 1.2rem 0; }
.row { display: flex; gap: .6rem; align-items: stretch; flex-wrap: wrap; }
.box { border: 1px solid var(--line); border-radius: 8px; padding: .55rem .7rem;
background: var(--card); font-size: .84rem; flex: 1 1 0; min-width: 8.5rem; }
.box .t { font-weight: 600; font-size: .78rem; color: var(--muted);
text-transform: uppercase; letter-spacing: .03em; margin-bottom: .3rem; }
.arrow { display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 1.3rem; padding: .2rem 0; }
.pill { display: inline-block; border-radius: 999px; padding: .08em .55em;
font-size: .76rem; border: 1px solid var(--line); }
.pill.v { color: var(--good); border-color: var(--good); }
.pill.m { color: var(--warn); border-color: var(--warn); }
.pill.x { color: var(--bad); border-color: var(--bad); }
/* micro-world */
.sim { border: 1px solid var(--line); border-radius: 10px; padding: 1rem;
background: var(--card); }
.sim .days { display: grid; grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
gap: .5rem; margin: .8rem 0; }
.day { border: 1px solid var(--line); border-radius: 8px; padding: .5rem;
background: var(--bg); }
.day h4 { margin: 0 0 .35rem; font-size: .8rem; color: var(--muted); }
.day .slot { font-size: .74rem; margin: .12rem 0; }
.btnrow { display: flex; gap: .3rem; margin-top: .35rem; flex-wrap: wrap; }
button { font: inherit; font-size: .78rem; padding: .2rem .5rem; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px;
background: var(--bg); color: var(--fg); }
button:hover { border-color: var(--accent); }
.verdict { margin-top: .8rem; padding: .7rem .9rem; border-radius: 8px;
background: var(--bg); border: 1px solid var(--line); font-size: .9rem; }
.big { font-size: 1.5rem; font-weight: 700; }
/* quiz */
.q { margin: 1.4rem 0; }
.q .stem { font-weight: 600; margin-bottom: .5rem; }
.opt { display: block; width: 100%; text-align: left; margin: .3rem 0;
padding: .5rem .7rem; border-radius: 8px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); cursor: pointer; font-size: .9rem; }
.opt:disabled { cursor: default; }
.opt.correct { border-color: var(--good); background: color-mix(in srgb, var(--good) 12%, var(--bg)); }
.opt.wrong { border-color: var(--bad); background: color-mix(in srgb, var(--bad) 12%, var(--bg)); }
.rat { font-size: .85rem; color: var(--muted); margin: .4rem 0 0; display: none; }
.rat.show { display: block; }
#score { font-weight: 600; }
</style>
</head>
<body>
<h1>Multiple workouts per day, the sync that never ran, and a ban on silence</h1>
<p class="sub">
<code>feat/check-phone-runnerup-and-tray</code> · 3 commits · 62 files ·
+2719 / −777 · 2026-07-17
</p>
<nav class="toc">
<strong>Contents</strong>
<ol>
<li><a href="#background">Background — what this thing is</a></li>
<li><a href="#intuition">Intuition — one slot per day</a></li>
<li><a href="#code">Code — the change, by concern</a></li>
<li><a href="#quiz">Quiz</a></li>
</ol>
</nav>
<h2 id="background">1. Background — what this thing is</h2>
<p>
<strong>screen-locker</strong> is a discipline tool. A systemd user service runs
<code>python -m screen_locker.screen_lock --production</code> at login and again
from a timer at 08:30/09:05. If you haven't earned your workout, it throws a
fullscreen Tk window over everything until you do. If you have, it calls
<code>sys.exit(0)</code> and you never see it. That's why "the locker isn't
running" is usually the <em>healthy</em> state, not a fault.
</p>
<p>
It decides using <code>screen_locker/log.json</code> — a gitignored,
HMAC-signed file. Each entry is signed with a key shared across screen-locker,
diet_guard and wake_alarm, so the log can't be quietly hand-edited to fake a
workout. Two things read it constantly: <code>has_weekly_minimum()</code>
(fire the lock or not?) and the reward path (how late may the PC stay on
tonight?).
</p>
<p>
A workout gets in three ways. <span class="pill v">phone_verified</span> — a
StrongLifts session pulled off the phone. <span class="pill v">runnerup_verified</span>
— a real run, parsed from the actual TCX file that RunnerUp writes to
<code>/sdcard/Documents/RunnerUp/</code> and pulled over ADB. And
<span class="pill m">manual_workout</span> — a self-reported evidence form
(squash, table tennis), rate-limited by a budget because nothing verifies it.
That distinction — machine-checked versus self-reported — is the hinge the
whole change turns on.
</p>
<p>
There's also a companion Flutter app on the phone, and a shared GitHub repo
(<code>kuhyx/syncs</code>) holding a small CRDT log per device, so the two
machines can agree on what happened.
</p>
<h3>The shape that caused everything</h3>
<p>
<code>log.json</code> was <strong>keyed by date, one entry per day</strong>.
The writer said so in as many words, and did exactly that:
</p>
<div class="codewrap"><pre><span class="muted"># _log_mixin.py — before</span>
"""...The log is day-keyed: one entry per date."""
logs[date] = entry <span class="del"># a second workout that day OVERWRITES the first</span></pre></div>
<p>
So the file physically could not hold two workouts on one day. Everything
downstream inherited that: <code>count_weekly_workouts</code> iterated the date
keys, so "4 workouts a week" and "4 <em>days</em> a week" were the same
sentence. Nobody had to choose between them, because the data model had
already chosen.
</p>
<h2 id="intuition">2. Intuition — one slot per day</h2>
<p>
The trigger was mundane. On Monday 13 July, kuhy played squash <em>and</em> went
for a 9.8&nbsp;km run. He logged the squash by hand. The run — a real, verified,
TCX-backed run — had nowhere to go. The day already had its slot.
</p>
<div class="diagram">
<div class="row">
<div class="box">
<div class="t">Before — one slot</div>
<div>2026-07-13 →</div>
<div style="margin-top:.3rem"><span class="pill m">squash (manual)</span></div>
<div style="margin-top:.4rem; color:var(--bad); font-size:.8rem">
the 9.8 km run would <strong>overwrite</strong> this
</div>
</div>
<div class="box">
<div class="t">After — a list</div>
<div>2026-07-13 → [</div>
<div style="margin-top:.3rem">
<span class="pill m">squash</span> <span class="pill v">run 9.8 km</span>
</div>
<div>]</div>
<div style="margin-top:.4rem; color:var(--good); font-size:.8rem">
both kept, both counted
</div>
</div>
</div>
</div>
<h3>The trap: a list alone is a cheat code</h3>
<p>
Letting a day hold many workouts sounds harmless until you notice what the
lock is <em>for</em>. If every entry counted, you could type four
<span class="pill m">manual</span> entries on a Monday morning, satisfy "4
workouts this week", and never be locked again until Sunday. The whole tool
would be an honour system with extra steps.
</p>
<p>
So the rule isn't "count workouts". It's <strong>count what can't be
faked</strong>:
</p>
<div class="card note">
<strong>The weekly rule.</strong> Each <span class="pill v">verified</span>
workout counts <em>individually</em> — several in one day all count, because a
TCX file and a StrongLifts session are machine-checked.
<span class="pill m">manual</span> entries count <strong>at most once per
day</strong>, however many you write. Self-reports can't inflate the total.
</div>
<p>
Play with it. Add workouts and watch the count and the lock:
</p>
<div class="sim diagram" id="sim">
<div style="font-size:.85rem; color:var(--muted)">
A week (Mon–Thu shown). Weekly minimum is <strong>4</strong>.
</div>
<div class="days" id="days"></div>
<div class="btnrow">
<button id="reset">Reset</button>
<button id="preset-real">Preset: kuhy's Jul 13</button>
<button id="preset-cheat">Preset: the 4-manual cheat</button>
</div>
<div class="verdict" id="verdict"></div>
</div>
<h3>The second trap: the same workout, twice</h3>
<p>
Once a day can hold many workouts, "is this one already here?" stops being
free. Every entry now carries a <code>workout_id</code> — deliberately the
<em>same string</em> the sync layer uses as its record id, so a workout logged
on the PC and the same workout arriving from the phone collapse to one row
instead of two.
</p>
<div class="diagram">
<div class="row">
<div class="box">
<div class="t">manual</div>
<code>manual:2026-07-13T14:00</code>
<div style="font-size:.78rem;color:var(--muted);margin-top:.3rem">date + start time</div>
</div>
<div class="box">
<div class="t">runnerup</div>
<code>runnerup_verified:2026-07-13</code>
<div style="font-size:.78rem;color:var(--muted);margin-top:.3rem">type + date</div>
</div>
<div class="box">
<div class="t">phone session</div>
<code>startTime.toIso8601String()</code>
<div style="font-size:.78rem;color:var(--muted);margin-top:.3rem">minted by the app</div>
</div>
</div>
</div>
<h3>The sync had never worked. Not once.</h3>
<p>
While fixing the above, a second thing surfaced. The PC was supposed to
publish its workouts so the phone could see them. It never had —
<code>screen-locker-sync/</code> in the repo contained a single
<code>.gitkeep</code>. Two independent reasons:
</p>
<ol>
<li>
<strong>No token.</strong> <code>push_pc_manuals()</code> opened with
<code>if token is None: return</code> — a bare return, no log line. The PAT
had been saved to the wrong app's config directory. The code knew and said
nothing.
</li>
<li>
<strong>A side-store.</strong> It pushed from <code>manual_sync_log.json</code>,
written only at log-time. So anything recorded before that feature existed —
like the squash — could never publish, no matter how many times it ran.
</li>
</ol>
<p>
The fix for (2) also fixes a category error: <em>derive the push from
<code>log.json</code> itself</em>. The log is the truth; the sync is a
projection of it. Then history publishes automatically, forever, with no
second place to fall out of date.
</p>
<h3>Why silence was the real bug</h3>
<p>
Notice the shape of the failure. Not a crash — a <code>return []</code>. During
the debugging session, <code>pull_all_manual_records()</code> returned
<code>0</code> and it was <strong>impossible to tell</strong> whether the repo
was empty or the call had failed, because the token guard was silent and real
errors logged at <code>INFO</code>. The answer only came from bypassing the
function and hitting the API by hand.
</p>
<div class="card badbox">
<strong>An invisible log is a silent failure with extra steps.</strong> That's
now a rule with teeth: <code>scripts/check_silent_failures.py</code>, a
pre-commit hook. Every <code>except</code> must re-raise or log at
<code>warning</code>+. It found <strong>27 violations across 16 modules</strong>
on its first run — and flagged its own source file too.
</div>
<p>
Worth knowing why a custom AST rule was needed at all: ruff already runs with
<code>select = ["ALL"]</code>, so <code>S110</code> (<code>except: pass</code>)
was <em>already</em> enforced — and the codebase had none. The lethal pattern
wasn't <code>pass</code>. It was <code>except: log.info(...); return []</code>,
which <em>looks</em> handled and no ruff rule catches.
</p>
<h2 id="code">3. Code — the change, by concern</h2>
<h3>3.1 One reader, two shapes</h3>
<p>
A new dependency-free module is the single place that reads the file. Old and
new shapes both load, so a half-migrated file — or a rolled-back writer — is
never a crash. Crucially, wrapping a legacy dict in a list leaves the
<em>signed bytes untouched</em>, so every existing HMAC still verifies with
zero re-signing.
</p>
<div class="codewrap"><pre><span class="add">+ # screen_locker/_log_io.py (new)</span>
+ def load_workout_log(log_file) -> dict[str, list[dict]]:
+ normalized = {}
+ for date, value in read_raw_log(log_file).items():
+ if isinstance(value, list):
+ normalized[date] = [e for e in value if isinstance(e, dict)]
+ elif isinstance(value, dict):
+ normalized[date] = [value] # legacy: wrap, don't rewrite
+ return normalized</pre></div>
<h3>3.2 One writer, which appends and dedups</h3>
<p>
The overwrite becomes an append behind a single chokepoint that returns a
result instead of <code>None</code> — so the caller can tell a genuinely new
workout from a duplicate, and scale the reward accordingly.
</p>
<div class="codewrap"><pre><span class="del">- logs[date] = entry # overwrite</span>
<span class="add">+ workout_id = _derive_workout_id(date, workout_data)
+ if workout_id is not None and any(
+ _entry_workout_id(date, e) == workout_id for e in entries
+ ):
+ return RecordResult(appended=False, prior_entries=prior)
+ entries.append(entry)
+ return RecordResult(appended=True, prior_entries=prior)</span></pre></div>
<div class="card warnbox">
<strong>This bit was written twice.</strong> The first version compared
<code>e.get("workout_id")</code> directly. Legacy entries predate that field,
so theirs is <code>None</code> — un-matchable. The moment the PC started
publishing history, the 08:30 timer ingested the squash back and appended a
<em>second</em> copy of it. It had been dismissed in the plan as a "known
minor, accepted" risk; it fired within the hour, on real data.
<code>_entry_workout_id()</code> now <em>derives</em> an existing entry's id
when it has none, so a legacy row matches its own synced twin.
</div>
<h3>3.3 The counting rule</h3>
<p>
The anti-gaming asymmetry from section 2, in the one function that both the
lock and the banked bonus consult:
</p>
<div class="codewrap"><pre>for date_str, entries in load_workout_log(log_file).items():
...
has_manual = False
for entry in entries:
wtype = entry.get("workout_data", {}).get("type", "")
if wtype in VERIFIED_WORKOUT_TYPES:
count += 1 <span class="add"># each verified workout counts</span>
elif wtype == MANUAL_WORKOUT_TYPE:
has_manual = True
if has_manual:
count += 1 <span class="add"># all of a day's manuals count as one</span></pre></div>
<h3>3.4 The reward, with two guards</h3>
<p>
<code>_apply_workout_credit</code> now scales to the day: the first counted
workout earns the base <strong>+2h</strong>; an <em>additional same-day
verified</em> one earns <strong>+1h</strong>; an additional manual earns
nothing. The old "+1h when <code>weekly_count &gt; 4</code>" gate was removed —
weekly volume is the banked bonus's job, and paying for it twice was the bug
hiding in plain sight. The credit fires only if the write actually appended
(<em>id guard</em>) and the entry is dated today (<em>date guard</em>) — so a
back-dated backfill raises the count but never pushes tonight's shutdown.
</p>
<p>
The tests caught a real defect here: the unlock screen still labelled the +1h
<code>#{weekly_count - 4}</code>, which under the new rule can render
<em>negative</em>.
</p>
<h3>3.5 The sync, derived from the log</h3>
<p>
Record id = the workout's own <code>workout_id</code>. HLC = derived from the
entry's own timestamp — which makes a re-push <em>byte-identical</em>, so the
repo never churns. That determinism is what lets the push run on every startup
without turning the sync repo into a commit firehose.
</p>
<div class="codewrap"><pre><span class="add">+ hlc = Hlc.new_tick(_PC_DEVICE_ID, wall_time_ms=_entry_wall_ms(entry, date))
+ log[record_id] = Record(id=record_id, fields={_PAYLOAD_FIELD: (payload, hlc)})</span></pre></div>
<p>
And nothing about it is silent any more:
</p>
<div class="codewrap"><pre><span class="add">+ @dataclass(frozen=True)
+ class PushResult:
+ pushed: bool
+ record_count: int
+ reason: str # always a sentence a human can act on</span></pre></div>
<h3>3.6 Two recoveries, one pattern</h3>
<p>
The phone kept a Keystore token and mirrored it to
<code>/sdcard</code> — but <code>load()</code> only consulted the mirror when
the Keystore was <strong>empty</strong>, never when its token was
present-but-<em>rejected</em>. A stale entry could shadow a good backup forever
while the UI insisted all was well. Now a 401 triggers one retry from the
mirror before anyone is asked to re-authorize.
</p>
<p>
The same pattern then closed a sharper hole. <code>active_session</code> — which
set you're on, and the reps done — lived only in app-private SQLite. A
<code>pm clear</code> wiped it <em>mid-workout</em>, and <code>backup.json</code>
didn't help because it mirrors only <em>completed</em> history. The state had to
be re-entered by hand. The persistence granularity was already right (it saves
on every set tap); only the <em>location</em> was wrong.
</p>
<div class="diagram">
<div class="row">
<div class="box">
<div class="t">app-private (wiped)</div>
SQLite <code>active_session</code><br>
Keystore <code>sync.token</code>
</div>
<div class="arrow">→</div>
<div class="box">
<div class="t">/sdcard (survives)</div>
<code>active_session.json</code><br>
<code>sync_token</code><br>
<code>backup.json</code>
</div>
</div>
<p style="font-size:.82rem;color:var(--muted);margin:.5rem 0 0">
On launch, an empty table re-seeds itself from the mirror. Set 4 of the
Romanian Deadlift, reps <code>[8,8,8,8]</code>, survives an uninstall.
</p>
</div>
<h3>3.7 The rule that outlives the fix</h3>
<div class="codewrap"><pre><span class="add">+ # scripts/check_silent_failures.py — pre-commit hook `no-silent-failures`
+ def _handler_is_loud(handler) -> bool:
+ """True if the handler re-raises or logs loudly about the failure."""
+ return any(
+ isinstance(node, ast.Raise) or _is_loud_log_call(node)
+ for node in ast.walk(handler)
+ )</span></pre></div>
<p>
There's deliberately no escape hatch, because <code>noqa</code> is banned
repo-wide. Two ironies for the record: the checker's own docstring contained
the literal string <code>#&nbsp;noqa</code> and tripped the noqa-blocker, and
<code>_log_io.py</code> — written for this very change — violated the new rule.
Both were invisible while untracked, since <code>git ls-files</code> hides
untracked files from the hooks, and only surfaced once committed.
</p>
<h2 id="quiz">4. Quiz</h2>
<p style="color:var(--muted);font-size:.9rem">
Five questions on the reasoning. Score: <span id="score">0 / 0</span>
</p>
<div id="quiz"></div>
<script>
/* ---------------- micro-world: the weekly counting rule ---------------- */
(function () {
const DAYS = ["Mon 13", "Tue 14", "Wed 15", "Thu 16"];
const MIN = 4;
let state = DAYS.map(() => ({ verified: 0, manual: 0 }));
const daysEl = document.getElementById("days");
const verdictEl = document.getElementById("verdict");
function count() {
// Mirrors count_weekly_workouts: each verified counts; manuals cap at 1/day.
return state.reduce((n, d) => n + d.verified + (d.manual > 0 ? 1 : 0), 0);
}
function render() {
daysEl.innerHTML = "";
state.forEach((d, i) => {
const el = document.createElement("div");
el.className = "day";
const contrib = d.verified + (d.manual > 0 ? 1 : 0);
el.innerHTML =
"<h4>" + DAYS[i] + "</h4>" +
'<div class="slot"><span class="pill v">verified</span> ' + d.verified + "</div>" +
'<div class="slot"><span class="pill m">manual</span> ' + d.manual +
(d.manual > 1 ? ' <span style="color:var(--warn)">(capped)</span>' : "") + "</div>" +
'<div class="slot" style="margin-top:.3rem;color:var(--muted)">counts: <strong>' +
contrib + "</strong></div>";
const row = document.createElement("div");
row.className = "btnrow";
const bv = document.createElement("button");
bv.textContent = "+ verified";
bv.onclick = () => { state[i].verified++; render(); };
const bm = document.createElement("button");
bm.textContent = "+ manual";
bm.onclick = () => { state[i].manual++; render(); };
row.append(bv, bm);
el.append(row);
daysEl.append(el);
});
const n = count();
const locked = n < MIN;
verdictEl.innerHTML =
'<div>weekly count: <span class="big">' + n + "</span> / " + MIN + "</div>" +
'<div style="margin-top:.4rem">lock this week: ' +
(locked
? '<span class="pill x">FIRES</span>'
: '<span class="pill v">skipped — minimum met</span>') +
"</div>" +
'<div style="margin-top:.4rem;font-size:.84rem;color:var(--muted)">' +
"Each verified workout adds 1. All of a day's manual entries add 1 between them." +
"</div>";
}
document.getElementById("reset").onclick = () => {
state = DAYS.map(() => ({ verified: 0, manual: 0 })); render();
};
document.getElementById("preset-real").onclick = () => {
// Jul 13: squash (manual) + the 9.8 km run (verified) => that day counts 2.
state = DAYS.map(() => ({ verified: 0, manual: 0 }));
state[0] = { verified: 1, manual: 1 };
render();
};
document.getElementById("preset-cheat").onclick = () => {
// Four self-reports on one morning: the whole point of the cap.
state = DAYS.map(() => ({ verified: 0, manual: 0 }));
state[0] = { verified: 0, manual: 4 };
render();
};
render();
})();
/* ---------------- quiz ---------------- */
(function () {
const QS = [
{
stem: "The log moved from {date: entry} to {date: [entry]}. Why does load_workout_log wrap legacy dict values on read instead of rewriting the file up front?",
opts: [
"A rewrite needs the HMAC key, which is not always available at read time.",
"Wrapping leaves each entry's signed bytes untouched, so old HMACs verify.",
"The file is gitignored, so a rewrite would be lost on the next checkout.",
"SQLite locks the file during reads, so an in-place rewrite may corrupt it."
],
correct: 1,
rat: "Wrapping never changes the bytes compute_entry_hmac signed, so zero re-signing is needed."
},
{
stem: "A single day holds 2 verified workouts and 3 manual ones. What does that day contribute to the weekly count?",
opts: [
"5 — every counted workout counts on its own, as multi/day implies.",
"2 — only machine-verified workouts count toward the weekly total.",
"3 — each verified counts; the manual entries count once in total.",
"1 — the day contributes exactly one, matching the old day-based rule."
],
correct: 2,
rat: "Verified workouts stack (2); all of a day's manual entries collapse to a single 1."
},
{
stem: "Why is each pushed record's HLC derived from the entry's own timestamp rather than the current clock?",
opts: [
"It stops the phone's clock from overriding the PC on same-day workouts.",
"GitHub rejects records whose clock is newer than the commit timestamp.",
"Re-pushing an unchanged log yields identical records, so no churn.",
"crdt_sync requires a wall_time_ms whenever a node id is also supplied."
],
correct: 2,
rat: "Determinism makes the push idempotent, so running it on every startup never spams the repo."
},
{
stem: "The new hook flags silent except handlers. Why did `if token is None: return []` still need fixing by hand?",
opts: [
"It is a guard clause, not an except handler, so the AST rule never sees it.",
"The rule ignores functions whose return type is a list rather than a result.",
"The checker skips files that were untracked when the hook last ran on them.",
"ruff's S110 already covers it, so the custom checker deliberately skips it."
],
correct: 0,
rat: "The checker walks ast.ExceptHandler nodes; a bare guard return is invisible to it."
},
{
stem: "After the PC began publishing history, the Jul 13 squash appeared twice. What went wrong?",
opts: [
"The push minted a fresh workout_id each run, so the dedup never matched.",
"The legacy entry stored no workout_id, so the id comparison found no match.",
"The 08:30 timer ran ingest twice and the second run raced the first write.",
"The manual-sync ingest skipped its record-id check for pre-migration rows."
],
correct: 1,
rat: "Its stored id was None, so it could not match its own synced copy — the id is now derived."
}
];
const quizEl = document.getElementById("quiz");
const scoreEl = document.getElementById("score");
let answered = 0, right = 0;
QS.forEach((q, qi) => {
const wrap = document.createElement("div");
wrap.className = "q";
const stem = document.createElement("div");
stem.className = "stem";
stem.textContent = (qi + 1) + ". " + q.stem;
wrap.append(stem);
const rat = document.createElement("p");
rat.className = "rat";
q.opts.forEach((text, oi) => {
const b = document.createElement("button");
b.className = "opt";
b.textContent = text;
b.onclick = () => {
if (wrap.dataset.done) return;
wrap.dataset.done = "1";
answered++;
if (oi === q.correct) right++;
wrap.querySelectorAll(".opt").forEach((o, i) => {
o.disabled = true;
if (i === q.correct) o.classList.add("correct");
else if (i === oi) o.classList.add("wrong");
});
rat.textContent = (oi === q.correct ? "Correct. " : "Not quite. ") + q.rat;
rat.classList.add("show");
scoreEl.textContent = right + " / " + answered;
};
wrap.append(b);
});
wrap.append(rat);
quizEl.append(wrap);
});
})();
</script>
</body>
</html>

View File

@ -1,686 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Check Phone learns to look backward, and forgives shaky GPS</title>
<style>
:root {
--bg: #ffffff; --fg: #1a1a1a; --muted: #5b6470; --line: #e2e6ea;
--card: #f6f8fa; --accent: #0b6bcb; --good: #1a7f37; --bad: #b42318;
--warn: #9a6700; --code: #f2f4f7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171a; --fg: #e6e9ec; --muted: #9aa4b0; --line: #2b3138;
--card: #1b1f24; --accent: #6cb6ff; --good: #4ac26b; --bad: #ff7b72;
--warn: #d4a72c; --code: #1e2329;
}
}
* { box-sizing: border-box; }
body {
margin: 0 auto; padding: 2rem 1rem 5rem; max-width: min(48rem, 92vw);
background: var(--bg); color: var(--fg); line-height: 1.65;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
h1 { font-size: 1.9rem; line-height: 1.25; margin: 0 0 .4rem; }
h2 { font-size: 1.4rem; margin: 2.6rem 0 .8rem; padding-top: .6rem;
border-top: 1px solid var(--line); }
h3 { font-size: 1.08rem; margin: 1.6rem 0 .5rem; }
.sub { color: var(--muted); margin: 0 0 1.5rem; }
a { color: var(--accent); }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
code { background: var(--code); padding: .12em .35em; border-radius: 4px;
font-size: .88em; }
.codewrap { overflow-x: auto; background: var(--code); border: 1px solid var(--line);
border-radius: 8px; margin: .8rem 0; }
pre { margin: 0; padding: .85rem 1rem; white-space: pre-wrap; overflow-x: auto;
font-size: .84rem; line-height: 1.5; }
.add { color: var(--good); }
.del { color: var(--bad); }
nav.toc { background: var(--card); border: 1px solid var(--line);
border-radius: 10px; padding: .9rem 1.1rem; margin: 1.5rem 0 2rem; }
nav.toc ol { margin: .4rem 0 0; padding-left: 1.2rem; }
nav.toc li { margin: .25rem 0; }
.card { background: var(--card); border: 1px solid var(--line);
border-radius: 10px; padding: 1rem 1.1rem; margin: 1.2rem 0; }
.note { border-left: 3px solid var(--accent); }
.warnbox { border-left: 3px solid var(--warn); }
.badbox { border-left: 3px solid var(--bad); }
table { border-collapse: collapse; width: 100%; font-size: .9rem; }
.tablewrap { overflow-x: auto; margin: 1rem 0; }
th, td { border: 1px solid var(--line); padding: .45rem .6rem; text-align: left; }
th { background: var(--card); }
/* diagram primitives */
.diagram { margin: 1.2rem 0; }
.row { display: flex; gap: .6rem; align-items: stretch; flex-wrap: wrap; }
.box { border: 1px solid var(--line); border-radius: 8px; padding: .55rem .7rem;
background: var(--card); font-size: .84rem; flex: 1 1 0; min-width: 8.5rem; }
.box .t { font-weight: 600; font-size: .78rem; color: var(--muted);
text-transform: uppercase; letter-spacing: .03em; margin-bottom: .3rem; }
.arrow { display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 1.3rem; padding: .2rem 0; }
.down { text-align: center; color: var(--muted); font-size: 1.2rem; margin: .1rem 0; }
.pill { display: inline-block; border-radius: 999px; padding: .08em .55em;
font-size: .76rem; border: 1px solid var(--line); }
.pill.v { color: var(--good); border-color: var(--good); }
.pill.m { color: var(--warn); border-color: var(--warn); }
.pill.x { color: var(--bad); border-color: var(--bad); }
/* micro-world */
.sim { border: 1px solid var(--line); border-radius: 10px; padding: 1rem;
background: var(--card); }
.sim label { display: block; font-size: .84rem; margin: .7rem 0 .25rem; }
.sim .grp { border: 1px solid var(--line); border-radius: 8px; padding: .6rem .7rem;
background: var(--bg); margin: .6rem 0; }
.sim select, .sim input[type=range] { width: 100%; }
.rangeval { font-size: .8rem; color: var(--muted); }
.btnrow { display: flex; gap: .3rem; margin-top: .5rem; flex-wrap: wrap; }
button { font: inherit; font-size: .82rem; padding: .3rem .6rem; cursor: pointer;
border: 1px solid var(--line); border-radius: 6px;
background: var(--bg); color: var(--fg); }
button:hover { border-color: var(--accent); }
button.primary { border-color: var(--accent); color: var(--accent); font-weight: 600; }
.trace { margin-top: .8rem; padding: .7rem .9rem; border-radius: 8px;
background: var(--bg); border: 1px solid var(--line); font-size: .85rem; }
.trace .step { padding: .35rem 0; border-bottom: 1px dashed var(--line); }
.trace .step:last-child { border-bottom: none; }
.trace .step .k { color: var(--muted); font-size: .78rem; }
.verdict-line { margin-top: .5rem; font-weight: 600; }
/* quiz */
.q { margin: 1.4rem 0; }
.q .stem { font-weight: 600; margin-bottom: .5rem; }
.opt { display: block; width: 100%; text-align: left; margin: .3rem 0;
padding: .5rem .7rem; border-radius: 8px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); cursor: pointer; font-size: .9rem; }
.opt:disabled { cursor: default; }
.opt.correct { border-color: var(--good); background: color-mix(in srgb, var(--good) 12%, var(--bg)); }
.opt.wrong { border-color: var(--bad); background: color-mix(in srgb, var(--bad) 12%, var(--bg)); }
.rat { font-size: .85rem; color: var(--muted); margin: .4rem 0 0; display: none; }
.rat.show { display: block; }
#score { font-weight: 600; }
</style>
</head>
<body>
<h1>Check Phone learns to look backward, and forgives shaky GPS</h1>
<p class="sub">
<code>c277325</code> · 12 files · +583 / −78 · 2026-07-18
</p>
<nav class="toc">
<strong>Contents</strong>
<ol>
<li><a href="#background">Background — what Check Phone actually checks</a></li>
<li><a href="#intuition">Intuition — a same-day blind spot, and a shaky GPS trace</a></li>
<li><a href="#code">Code — the change, by concern</a></li>
<li><a href="#quiz">Quiz</a></li>
</ol>
</nav>
<h2 id="background">1. Background — what Check Phone actually checks</h2>
<p>
<strong>screen-locker</strong> locks the screen until a weekly workout
quota is met. The status window has a voluntary "Check Phone" button
(<code>screen_locker/_status_view_verify.py</code>,
<code>PhoneCheckMixin</code>) that, on click, pulls real data off your
Android phone over ADB and — if it verifies — logs it and applies the
reward. It never trusts a typed claim; it always pulls the actual file
first. Two sources feed it:
</p>
<div class="diagram">
<div class="row">
<div class="box">
<div class="t">StrongLifts</div>
Pulls <code>workout_result.json</code> off the phone.
<code>_verify_phone_workout</code> in
<code>_phone_verification.py</code>.
</div>
<div class="box">
<div class="t">RunnerUp</div>
Pulls the real <code>.tcx</code> file from
<code>/sdcard/Documents/RunnerUp/</code>.
<code>_verify_runnerup_workout</code> in
<code>_runnerup_verification.py</code>.
</div>
</div>
</div>
<p>
Both of those functions were written to answer one specific question:
<em>did you work out today?</em> They match on today's date only —
StrongLifts by comparing the JSON's <code>date</code> field, RunnerUp by
grepping filenames for today's date string. That's the right question for
the locked screen, which decides whether to unlock <em>right now</em>. But
the voluntary "Check Phone" button reused those same today-only functions,
even though what it's really trying to answer is broader: <em>is this
week's quota met?</em>
</p>
<p>
The quota itself, from <code>_weekly_check.py</code>, follows an
anti-gaming rule established in the previous change to this repo: each
<span class="pill v">verified</span> workout (StrongLifts or RunnerUp)
counts individually, however many happen on one day, because they're
machine-checked and can't be faked. A <span class="pill m">manual</span>
self-report counts at most once per day. The weekly minimum is 4.
</p>
<p>
A week-aware scanner for RunnerUp already existed —
<code>_scan_and_fill_week_runnerup</code> walks every day from Monday to
today, and for each one still ADB-pulls and validates the real TCX file
before writing anything. It was wired into the automatic login check and
the <code>--status</code> CLI, but never into the "Check Phone" button.
Nothing equivalent existed for StrongLifts at all.
</p>
<h2 id="intuition">2. Intuition — a same-day blind spot, and a shaky GPS trace</h2>
<p>
Here's what actually happened. Wednesday's status window connected to the
phone and clicked Check Phone. The phone had a real RunnerUp run from
<strong>yesterday</strong> — a 4.9&nbsp;km run recorded the evening before,
after the button's earlier check that day had already failed. The result:
</p>
<div class="card badbox">
<code>Phone check (not_verified): StrongLifts: Workout JSON is from
2026-07-17, not today · RunnerUp: No RunnerUp activity found for today</code>
</div>
<p>
Both messages are literally true and both are useless. There <em>is</em> a
real, verifiable run — the button just never looked at any date but today.
</p>
<div class="diagram">
<div class="row">
<div class="box">
<div class="t">Before</div>
StrongLifts today? no →<br>
RunnerUp today? no →<br>
<span style="color:var(--bad)">give up</span>
</div>
<div class="box">
<div class="t">After</div>
StrongLifts today? no →<br>
RunnerUp today? no →<br>
<span style="color:var(--good)">scan Mon…today for anything unlogged</span>
</div>
</div>
</div>
<p>
The week-scan fallback reuses the existing RunnerUp scanner and a new,
symmetric StrongLifts one — both still pull and validate the real file via
ADB, they just stop requiring the date to be <em>today</em> and instead
accept anything from Monday through today that isn't logged yet.
</p>
<h3>The second problem the first one uncovered</h3>
<p>
Testing this against the real phone surfaced a second issue: that 4.9&nbsp;km
run should count, but <code>MIN_RUN_DISTANCE_KM</code> is 5.0. GPS traces
measure distance by summing straight-line hops between sampled points, so
tight turns, tree cover, and urban canyons make the recorded distance
consistently <em>shorter</em> than the run actually was. A real 5&nbsp;km run can
legitimately log as 4.9. So a 5% tolerance was added — applied to distance
only, since duration comes from the device clock and has no such noise:
</p>
<div class="diagram">
<div class="row">
<div class="box">
<div class="t">recorded</div>
<span style="font-size:1.3rem;font-weight:700">4.90&nbsp;km</span>
</div>
<div class="arrow">×1.05 →</div>
<div class="box">
<div class="t">tolerant distance</div>
<span style="font-size:1.3rem;font-weight:700">5.145&nbsp;km</span>
</div>
<div class="arrow">≥ 5.0 →</div>
<div class="box" style="border-color:var(--good)">
<div class="t">verified</div>
<span class="pill v">counts</span>
</div>
</div>
</div>
<p>
Play with the whole chain below — it mirrors
<code>_verify_phone_then_runnerup</code>'s real order: StrongLifts today,
then RunnerUp today, then the week-scan fallback, using the same 5%
distance rule at every RunnerUp step.
</p>
<div class="sim diagram" id="sim">
<div class="grp">
<label><input type="checkbox" id="sl-today"> StrongLifts JSON dated <strong>today</strong></label>
</div>
<div class="grp">
<label><input type="checkbox" id="ru-today"> RunnerUp TCX dated <strong>today</strong></label>
<label for="ru-today-km">distance (km) <span class="rangeval" id="ru-today-km-val">4.9</span></label>
<input type="range" id="ru-today-km" min="3" max="6" step="0.1" value="4.9">
</div>
<div class="grp">
<label><input type="checkbox" id="week-gap" checked> Unlogged workout earlier this week</label>
<label for="week-type">source</label>
<select id="week-type">
<option value="runnerup">RunnerUp run</option>
<option value="stronglifts">StrongLifts session</option>
</select>
<div id="week-km-wrap">
<label for="week-km">distance (km) <span class="rangeval" id="week-km-val">4.9</span></label>
<input type="range" id="week-km" min="3" max="6" step="0.1" value="4.9">
</div>
</div>
<div class="btnrow">
<button class="primary" id="run-check">Click "Check Phone"</button>
<button id="reset-sim">Reset</button>
</div>
<div class="trace" id="trace"></div>
</div>
<h2 id="code">3. Code — the change, by concern</h2>
<h3>3.1 Splitting the date-check out of StrongLifts validation</h3>
<p>
<code>_validate_json_data</code> used to do two jobs at once: check the
date, then check the exercises/duration/succeeded content. The week-scan
needs the second job without the first, so it was split:
</p>
<div class="codewrap"><pre><span class="add">+ def _validate_json_content(self, data: dict) -> tuple[str, str]:
+ """...no date check..."""
+ if not data.get("exercises"):
+ return "no_exercises", "No exercises found in the workout JSON"
+ ...
def _validate_json_data(self, data: dict) -> tuple[str, str]:
<span class="del">- today = time.strftime("%Y-%m-%d")</span>
<span class="del">- if data.get("date") != today: return "stale", ...</span>
<span class="del">- if not data.get("exercises"): ...</span>
<span class="add">+ today = time.strftime("%Y-%m-%d")
+ if data.get("date") != today:
+ return "stale", f"Workout JSON is from {data.get('date')}, not today"
+ return self._validate_json_content(data)</span></pre></div>
<p>
<code>_validate_json_data</code> — the live, today-only check used by the
locked screen's unlock chain — keeps behaving exactly as before. It's now
just a thin date gate in front of the shared content check.
</p>
<h3>3.2 A week-scan backfill for StrongLifts (new — nothing existed before)</h3>
<p>
RunnerUp's <code>_scan_and_fill_week_runnerup</code> can find multiple
unlogged days because RunnerUp writes one <code>.tcx</code> file per
activity. StrongLifts only has <em>one</em> <code>workout_result.json</code>
snapshot on the phone at a time, so its equivalent can only ever backfill
one day per call — that's not a limitation of the fix, it's what the data
actually allows:
</p>
<div class="codewrap"><pre><span class="add">+ def _try_fill_stronglifts_for_week(self, log_file: Path) -> int:
+ if not self._has_adb_device():
+ return 0
+ data = self._pull_workout_app_json()
+ date_str = data.get("date") if data else None
+ if not date_str:
+ return 0
+ today = datetime.now(tz=timezone.utc).astimezone().date()
+ week_start = today - timedelta(days=today.weekday())
+ entry_date = date.fromisoformat(date_str) # ValueError -> 0, logged
+ if not week_start <= entry_date <= today:
+ return 0
+ status, msg = self._validate_json_content(data) # no date gate
+ if status != "verified":
+ return 0
+ ...
+ if write_signed_entry(log_file, date_str, workout_data).appended:
+ return 1
+ return 0</span></pre></div>
<p>
<code>write_signed_entry</code> dedups by <code>workout_id</code> (here
<code>"phone_verified:{date}"</code>), so re-scanning an already-filled day
is a safe no-op — calling this twice in a row correctly returns
<code>1</code> then <code>0</code>.
</p>
<h3>3.3 Wiring both week-scans into the button</h3>
<p>
The old return type was a bare 4-tuple, which had no room for "nothing
verified today, but something got backfilled." A small frozen dataclass
replaces it, and a new helper runs both scanners and applies whatever
bonus is earned — reusing the exact prev/new-count math the login
auto-check and <code>--status</code> already use, specifically <em>not</em>
routing through <code>_apply_workout_credit</code>, which always files
under <em>today</em> by design and would mis-date a backfilled entry:
</p>
<div class="codewrap"><pre><span class="add">+ @dataclass(frozen=True)
+ class PhoneCheckOutcome:
+ credited_type: str | None
+ status: str
+ phone_message: str
+ runnerup_message: str | None
+ week_fill_message: str | None = None
+ def _backfill_week_and_apply_bonus(verifier) -> str | None:
+ prev_count = count_weekly_workouts(verifier.log_file)
+ filled = verifier._scan_and_fill_week_runnerup(verifier.log_file)
+ filled += verifier._try_fill_stronglifts_for_week(verifier.log_file)
+ if not filled:
+ return None
+ new_count = count_weekly_workouts(verifier.log_file)
+ bonus = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count))
+ if bonus > 0:
+ verifier._adjust_shutdown_time_by(bonus)
+ return f"Auto-filled {filled} workout(s) from earlier this week." + (
+ f" +{bonus}h shutdown time." if bonus > 0 else ""
+ )</span></pre></div>
<p>
<code>_verify_phone_then_runnerup</code> calls this only after both
today-checks fail, and <code>_on_phone_check_result</code> branches on it:
a verified-today result still goes through <code>_apply_workout_credit</code>
as before; a <code>week_fill_message</code> is shown as-is, because the
backfill step already wrote the entry and applied its own bonus — running
credit again there would double it.
</p>
<h3>3.4 The RunnerUp GPS tolerance</h3>
<div class="codewrap"><pre> distance_km = data["distance_m"] / 1000
<span class="add">+ if distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) < MIN_RUN_DISTANCE_KM:</span>
<span class="del">- if distance_km < MIN_RUN_DISTANCE_KM:</span>
msg = f"Run was {distance_km:.1f} km — need {MIN_RUN_DISTANCE_KM:.0f}+ km"
return "too_short", msg
<span class="add">+ if distance_km < MIN_RUN_DISTANCE_KM:
+ _logger.info("...within %.0f%% GPS tolerance — accepted.", ...)</span></pre></div>
<p>
<code>RUNNERUP_DISTANCE_TOLERANCE = 0.05</code> lives in
<code>_constants.py</code> next to the minimum it adjusts. The log line on
a tolerance-assisted pass exists so a below-threshold-but-accepted run is
still visible in the logs, never a silent pass — the entry itself still
stores the true, un-inflated distance.
</p>
<div class="note" style="border-left:4px solid #b58900;padding:0.75rem 1rem;margin:1.5rem 0;background:#fdf6e3">
<strong>Superseded 2026-08-14.</strong> The numbers on this page describe the
rule as it stood on 2026-07-18 and are kept for that history. The thresholds
and their <em>shape</em> have both since changed: duration and distance were
an <strong>AND</strong> here (30+ min <em>and</em> 5.0+ km); they are now an
<strong>OR</strong> — a run qualifies at
<code>MIN_RUN_DISTANCE_KM = 3.5</code> (inclusive, still with the 5% GPS
tolerance described above) <em>or</em> at
<code>MIN_RUN_DURATION_MINUTES = 40</code> (strict, no tolerance). The
30-minute floor every run had to clear is gone, so a fast 5&nbsp;km now
passes too. The interactive demo and quiz below still use the old 5.0&nbsp;km
AND-rule.
</div>
<h3>3.5 The CLI bug this surfaced (unrelated file, same root cause)</h3>
<p>
Verifying the fix against the real phone produced a week with two
workouts on one day for the first time. That's when
<code>_status.py</code>'s <code>--status</code> CLI printed
<em>"Need 2 more to reach the minimum (4)"</em> for a week that was
actually 4/4 — the GUI, which uses <code>count_weekly_workouts</code>
directly, showed the correct number.
</p>
<div class="codewrap"><pre><span class="del">- before_count = 0
- for i in range(7):
- ...
- if _print_day_line(d, ...):
- before_count += 1 # capped at 1 per day, regardless of
- # how many workouts that day held</span>
<span class="add">+ for i in range(7):
+ ...
+ _print_day_line(d, ...) # display only now
+ before_count = count_weekly_workouts(log_file) # the real anti-gaming count</span></pre></div>
<p>
<code>before_count</code> had been derived from <code>_print_day_line</code>'s
boolean return — one checkmark per day, regardless of how many workouts
that day held. It happened to agree with the real count for years, because
until this change no day ever legitimately held two <em>verified</em>
entries. The fix makes the CLI compute the same weekly total the GUI
already used, so the two views can no longer disagree.
</p>
<h2 id="quiz">4. Quiz</h2>
<p style="color:var(--muted);font-size:.9rem">
Five questions on the reasoning. Score: <span id="score">0 / 0</span>
</p>
<div id="quiz"></div>
<script>
/* ---------------- micro-world: the Check Phone chain ---------------- */
(function () {
const TOLERANCE = 0.05;
const MIN_KM = 5.0;
const slToday = document.getElementById("sl-today");
const ruToday = document.getElementById("ru-today");
const ruTodayKm = document.getElementById("ru-today-km");
const ruTodayKmVal = document.getElementById("ru-today-km-val");
const weekGap = document.getElementById("week-gap");
const weekType = document.getElementById("week-type");
const weekKmWrap = document.getElementById("week-km-wrap");
const weekKm = document.getElementById("week-km");
const weekKmVal = document.getElementById("week-km-val");
const traceEl = document.getElementById("trace");
function passesDistance(km) {
return km * (1 + TOLERANCE) >= MIN_KM;
}
function syncLabels() {
ruTodayKmVal.textContent = parseFloat(ruTodayKm.value).toFixed(1);
weekKmVal.textContent = parseFloat(weekKm.value).toFixed(1);
weekKmWrap.style.display = weekType.value === "runnerup" ? "block" : "none";
}
[ruTodayKm, weekKm, weekType].forEach((el) => el.addEventListener("input", syncLabels));
syncLabels();
function step(label, detail, ok) {
return '<div class="step"><span class="k">' + label + "</span> — " + detail +
(ok === true ? ' <span class="pill v">pass</span>' :
ok === false ? ' <span class="pill x">fail</span>' : "") + "</div>";
}
function run() {
let html = "";
// 1. StrongLifts today
if (slToday.checked) {
html += step("1. StrongLifts today", "JSON dated today, content valid", true);
finish(html, "phone_verified", "Credited today: StrongLifts verified.");
return;
}
html += step("1. StrongLifts today", "no JSON dated today", false);
// 2. RunnerUp today
if (ruToday.checked) {
const km = parseFloat(ruTodayKm.value);
const tol = (km * (1 + TOLERANCE)).toFixed(2);
const ok = passesDistance(km);
html += step(
"2. RunnerUp today",
km.toFixed(1) + " km × 1.05 = " + tol + " km " + (ok ? "≥" : "<") + " 5.0 km",
ok
);
if (ok) {
finish(html, "runnerup_verified", "Credited today: RunnerUp verified.");
return;
}
} else {
html += step("2. RunnerUp today", "no TCX dated today", false);
}
// 3. Week-scan fallback
if (weekGap.checked) {
if (weekType.value === "stronglifts") {
html += step("3. Week-scan (StrongLifts)", "unlogged day, content valid", true);
finish(html, "week_fill", "Auto-filled 1 workout from earlier this week.");
return;
}
const km = parseFloat(weekKm.value);
const tol = (km * (1 + TOLERANCE)).toFixed(2);
const ok = passesDistance(km);
html += step(
"3. Week-scan (RunnerUp)",
km.toFixed(1) + " km × 1.05 = " + tol + " km " + (ok ? "≥" : "<") + " 5.0 km",
ok
);
if (ok) {
finish(html, "week_fill", "Auto-filled 1 workout from earlier this week.");
return;
}
} else {
html += step("3. Week-scan", "nothing unlogged this week", false);
}
finish(html, "none",
"StrongLifts: Workout JSON is from earlier, not today · " +
"RunnerUp: No RunnerUp activity found for today");
}
function finish(html, kind, message) {
const cls = kind === "none" ? "x" : "v";
html += '<div class="verdict-line">Result: <span class="pill ' + cls + '">' +
message + "</span></div>";
traceEl.innerHTML = html;
}
document.getElementById("run-check").onclick = run;
document.getElementById("reset-sim").onclick = () => {
slToday.checked = false;
ruToday.checked = false;
ruTodayKm.value = 4.9;
weekGap.checked = true;
weekType.value = "runnerup";
weekKm.value = 4.9;
syncLabels();
traceEl.innerHTML = "";
};
})();
/* ---------------- quiz ---------------- */
(function () {
const QS = [
{
stem: "A 4.9 km run is logged today, and no StrongLifts JSON exists for today. What does Check Phone report?",
opts: [
"runnerup_verified — 4.9 km times 1.05 clears the 5.0 km line.",
"not_verified — a 4.9 km run stays under the 5.0 km minimum.",
"phone_verified — RunnerUp falls back to the StrongLifts checker.",
"week_fill — today's date always routes to the week-scan path."
],
correct: 0,
rat: "The 5% tolerance is applied before comparing to MIN_RUN_DISTANCE_KM, so today's own RunnerUp check already passes it — the week-scan fallback is never reached."
},
{
stem: "Why does _try_fill_stronglifts_for_week return at most 1, while _scan_and_fill_week_runnerup can return more?",
opts: [
"StrongLifts entries are capped at one credit per week by policy.",
"The phone holds one snapshot; RunnerUp writes one file per run.",
"Only RunnerUp results carry a date, so StrongLifts can't span days.",
"ADB restricts a single pull command to one remote file at a time."
],
correct: 1,
rat: "RunnerUp writes a dated .tcx per activity, so several unlogged days can each supply one; StrongLifts only ever has the single latest snapshot to pull."
},
{
stem: "Why does the week-scan backfill skip _apply_workout_credit and call _adjust_shutdown_time_by directly instead?",
opts: [
"_apply_workout_credit needs a live Tk window a worker thread lacks.",
"_apply_workout_credit always files the entry under today's own date.",
"_adjust_shutdown_time_by is the only method the bare verifier exposes.",
"Skipping it avoids clearing the sick-day debt on a backfilled entry."
],
correct: 1,
rat: "Its own docstring says so: it 'always files under today' as the intra-day reward path, which would mis-date a backfilled entry from an earlier day."
},
{
stem: "Before this change, why did _status.py's CLI summary agree with the GUI for years despite using a different formula?",
opts: [
"Both formulas give identical totals for any log file's contents.",
"The CLI recomputed its total from count_weekly_workouts either way.",
"No day had ever held two verified entries until this very change.",
"The CLI only ran weekly, before a second same-day workout could occur."
],
correct: 2,
rat: "A checkmark-per-day count and a per-workout count only diverge once some day holds more than one verified entry — which this very change made possible for the first time."
},
{
stem: "In the Check Phone chain, in what order are the three checks tried?",
opts: [
"RunnerUp today, then StrongLifts today, then the week-scan fallback.",
"The week-scan fallback first, then StrongLifts today, then RunnerUp today.",
"StrongLifts today, then RunnerUp today, then the week-scan fallback.",
"All three run together, and the first one to finish wins the credit."
],
correct: 2,
rat: "_verify_phone_then_runnerup checks StrongLifts first, falls back to RunnerUp, and only reaches the week-scan when neither today-check verifies."
}
];
const quizEl = document.getElementById("quiz");
const scoreEl = document.getElementById("score");
let answered = 0, right = 0;
QS.forEach((q, qi) => {
const wrap = document.createElement("div");
wrap.className = "q";
const stem = document.createElement("div");
stem.className = "stem";
stem.textContent = (qi + 1) + ". " + q.stem;
wrap.append(stem);
const rat = document.createElement("p");
rat.className = "rat";
q.opts.forEach((text, oi) => {
const b = document.createElement("button");
b.className = "opt";
b.textContent = text;
b.onclick = () => {
if (wrap.dataset.done) return;
wrap.dataset.done = "1";
answered++;
if (oi === q.correct) right++;
wrap.querySelectorAll(".opt").forEach((o, i) => {
o.disabled = true;
if (i === q.correct) o.classList.add("correct");
else if (i === oi) o.classList.add("wrong");
});
rat.textContent = (oi === q.correct ? "Correct. " : "Not quite. ") + q.rat;
rat.classList.add("show");
scoreEl.textContent = right + " / " + answered;
};
wrap.append(b);
});
wrap.append(rat);
quizEl.append(wrap);
});
})();
</script>
</body>
</html>

View File

@ -1,51 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>screen-locker — 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">screen-locker</div>
<h1>Change explanations</h1>
<p class="lead">3 explanations · interactive walkthroughs of notable changes, newest first.</p>
<div class="grid">
<a class="card" href="2026-07-18-explanation-check-phone-week-aware.html">
<span class="date">2026-07-18</span>
<span class="title">Check Phone learns to look backward, and forgives shaky GPS</span>
</a>
<a class="card" href="2026-07-17-explanation-multi-workout-day-and-sync.html">
<span class="date">2026-07-17</span>
<span class="title">Multiple workouts per day, the sync that never ran, and a ban on silence</span>
</a>
<a class="card" href="2026-07-13-explanation-flutter-coverage-to-100.html">
<span class="date">2026-07-13</span>
<span class="title">Driving the workout app to 100% test coverage</span>
</a>
</div>
<footer>Generated by the <code>explain-diff</code> skill. Open any card to replay its walkthrough.</footer>
</div>
</body>
</html>

View File

@ -129,7 +129,7 @@ class HeatSkipMixin:
# Use the root itself (not a Toplevel) so we can go fullscreen. # Use the root itself (not a Toplevel) so we can go fullscreen.
# This window is destroyed before the main GateRoot is created. # This window is destroyed before the main GateRoot is created.
root = tk.Tk() root = tk.Tk()
root.title("Extreme Heat") root.title("")
root.configure(bg=_COLORS.palette.bg) root.configure(bg=_COLORS.palette.bg)
root.attributes("-fullscreen", True) root.attributes("-fullscreen", True)
root.attributes("-topmost", True) root.attributes("-topmost", True)

View File

@ -168,7 +168,9 @@ class ScreenLocker(
self._instance_lock.release() self._instance_lock.release()
sys.exit(0) sys.exit(0)
self.root.on_callback_error = self.on_callback_error self.root.on_callback_error = self.on_callback_error
self.root.title("Workout Locker" + (" [DEMO MODE]" if demo_mode else "")) # textgate: the window is fullscreen, so its title is never painted;
# was "Workout Locker" (+ " [DEMO MODE]"). Nothing matches on it.
self.root.title("")
self.demo_mode = demo_mode self.demo_mode = demo_mode
self.lockout_time = 10 if demo_mode else 1800 self.lockout_time = 10 if demo_mode else 1800
self._lock: LockWindow | None = None self._lock: LockWindow | None = None

View File

@ -221,7 +221,7 @@ def main(argv: list[str] | None = None) -> None:
return return
root = tk.Tk() root = tk.Tk()
root.title("Workout Status") root.title("")
root.configure(bg=_STATUS_COLORS.palette.bg) root.configure(bg=_STATUS_COLORS.palette.bg)
root.minsize(560, 200) root.minsize(560, 200)

View File

@ -130,7 +130,7 @@ def main() -> int:
if workout_app_binary() is None: if workout_app_binary() is None:
return 1 return 1
root = tk.Tk() root = tk.Tk()
root.title("Workout handoff demo") root.title("")
# Local grab + VT switching enabled: every escape hatch stays open. # Local grab + VT switching enabled: every escape hatch stays open.
config = LockConfig(mode="hard", grab=GrabPolicy(kind="local", disable_vt=False)) config = LockConfig(mode="hard", grab=GrabPolicy(kind="local", disable_vt=False))
hooks = _DemoHooks() hooks = _DemoHooks()

View File

@ -192,8 +192,8 @@ class _WorkoutAppState extends State<WorkoutApp> with WidgetsBindingObserver {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
// Same proper noun as BreakNotificationText.channelName. // textgate: no title. Was 'Workout Tracker'; the recents card shows
title: 'Workout Tracker', // the launcher icon and the manifest label (android:label) already.
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildAppTheme(), theme: buildAppTheme(),
home: const HomeScreen(), home: const HomeScreen(),

View File

@ -76,10 +76,12 @@ class _ExerciseSettingsRow extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final until = state.isPausedAt(DateTime.now()) ? state.pausedUntil : null; final until = state.isPausedAt(DateTime.now()) ? state.pausedUntil : null;
// Spoken only: excludeSemantics hides the painted children from it.
final semanticsLabel = '${state.name} exercise settings';
return Semantics( return Semantics(
container: true, container: true,
button: true, button: true,
label: '${state.name} exercise settings', label: semanticsLabel,
excludeSemantics: true, excludeSemantics: true,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,

View File

@ -163,7 +163,8 @@ class _ExerciseSettingsSheetState extends State<ExerciseSettingsSheet> {
maintainState: true, maintainState: true,
child: _SettingRow( child: _SettingRow(
// Was "+kg at [N] reps, restart at [M]". // Was "+kg at [N] reps, restart at [M]".
label: '+kg', label: '+',
icon: Icons.fitness_center,
name: 'Top reps', name: 'Top reps',
value: _s.repsHigh, value: _s.repsHigh,
min: _s.repsLow + 1, min: _s.repsLow + 1,

View File

@ -14,10 +14,14 @@ class _SettingRow extends StatelessWidget {
required this.suffix, required this.suffix,
required this.trailing, required this.trailing,
required this.onChanged, required this.onChanged,
this.icon,
}); });
final String label; final String label;
/// Optional glyph painted right after [label] (e.g. `+` then a weight).
final IconData? icon;
/// Semantic name of the leading stepper. /// Semantic name of the leading stepper.
final String name; final String name;
final int value; final int value;
@ -36,6 +40,8 @@ class _SettingRow extends StatelessWidget {
return Row( return Row(
children: [ children: [
Text(label, style: style), Text(label, style: style),
if (icon != null)
Icon(icon, size: AppTextSize.label, color: style.color),
const SizedBox(width: 8), const SizedBox(width: 8),
_Stepper( _Stepper(
name: name, name: name,

View File

@ -28,10 +28,12 @@ class _ModeChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Spoken only: excludeSemantics hides the painted children from it.
final semanticsLabel = '${state.name} progression settings';
return Semantics( return Semantics(
container: true, container: true,
button: true, button: true,
label: '${state.name} progression settings', label: semanticsLabel,
excludeSemantics: true, excludeSemantics: true,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,