mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 18:53:55 +01:00
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:
parent
b6db88f9b9
commit
32a735f168
@ -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 & 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<void> _tapReal(WidgetTester tester, Finder f) async {
|
|
||||||
await tester.runAsync(() async {
|
|
||||||
await tester.tap(f);
|
|
||||||
await Future<void>.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>
|
|
||||||
@ -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 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 > 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># 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>
|
|
||||||
@ -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 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 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 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 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 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 km now
|
|
||||||
passes too. The interactive demo and quiz below still use the old 5.0 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>
|
|
||||||
@ -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>
|
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user