mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
Add change explanation: surface sync outcomes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K5C5jZ2c4mTaGedK7C1dDv
This commit is contained in:
parent
7564a6569f
commit
8c4e695dae
@ -0,0 +1,480 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Surface sync outcomes so devices can't silently diverge</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #ffffff; --fg: #1e2430; --muted: #5c6675; --line: #dde2ea;
|
||||
--accent: #4a5ac8; --accent-soft: #eef0fb;
|
||||
--ok: #1a7f4b; --ok-soft: #e7f5ec;
|
||||
--bad: #b3261e; --bad-soft: #fbeceb;
|
||||
--code-bg: #f5f7fa; --card: #fafbfd;
|
||||
--add: #e7f5ec; --del: #fbeceb;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #14161c; --fg: #e6e9f0; --muted: #9aa3b2; --line: #2c313c;
|
||||
--accent: #93a0f5; --accent-soft: #232840;
|
||||
--ok: #5fd39a; --ok-soft: #16301f;
|
||||
--bad: #ff8f87; --bad-soft: #3a1d1b;
|
||||
--code-bg: #1c2027; --card: #191c23;
|
||||
--add: #16301f; --del: #3a1d1b;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0 auto; padding: 1.2rem 1rem 4rem;
|
||||
max-width: min(48rem, 92vw);
|
||||
background: var(--bg); color: var(--fg);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { font-size: 1.55rem; line-height: 1.3; margin: .4rem 0 .2rem; }
|
||||
h2 { font-size: 1.25rem; margin-top: 2.4rem; border-bottom: 2px solid var(--line); padding-bottom: .3rem; }
|
||||
h3 { font-size: 1.02rem; margin-top: 1.6rem; }
|
||||
p.meta { color: var(--muted); font-size: .85rem; margin-top: 0; }
|
||||
nav.toc {
|
||||
position: sticky; top: 0; z-index: 5;
|
||||
background: var(--bg); border-bottom: 1px solid var(--line);
|
||||
padding: .45rem 0; margin-bottom: 1rem;
|
||||
display: flex; gap: .9rem; flex-wrap: wrap; font-size: .9rem;
|
||||
}
|
||||
nav.toc a { color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||
nav.toc a:hover { text-decoration: underline; }
|
||||
a { color: var(--accent); }
|
||||
code { font-family: ui-monospace, "Cascadia Code", Menlo, monospace; font-size: .86em;
|
||||
background: var(--code-bg); padding: .1em .3em; border-radius: 4px; }
|
||||
.codeblock {
|
||||
background: var(--code-bg); border: 1px solid var(--line); border-radius: 8px;
|
||||
padding: .7rem .9rem; margin: .8rem 0; overflow-x: auto;
|
||||
}
|
||||
.codeblock pre { margin: 0; white-space: pre-wrap; font-size: .82rem;
|
||||
font-family: ui-monospace, "Cascadia Code", Menlo, monospace; }
|
||||
.codeblock .add { background: var(--add); display: block; }
|
||||
.codeblock .del { background: var(--del); display: block; }
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: .9rem 1rem; margin: 1rem 0;
|
||||
}
|
||||
.flow { display: flex; flex-wrap: wrap; align-items: stretch; gap: .4rem; margin: 1rem 0; }
|
||||
.flow .node {
|
||||
flex: 1 1 9rem; border: 1.5px solid var(--line); border-radius: 8px;
|
||||
padding: .5rem .6rem; font-size: .82rem; background: var(--card); min-width: 8.5rem;
|
||||
}
|
||||
.flow .node b { display: block; font-size: .84rem; }
|
||||
.flow .arrow { align-self: center; color: var(--muted); font-size: 1.1rem; }
|
||||
.node.badnode { border-color: var(--bad); background: var(--bad-soft); }
|
||||
.node.oknode { border-color: var(--ok); background: var(--ok-soft); }
|
||||
.pill { display: inline-block; border-radius: 999px; padding: .05rem .55rem;
|
||||
font-size: .74rem; font-weight: 700; }
|
||||
.pill.ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.pill.bad { background: var(--bad-soft); color: var(--bad); }
|
||||
.statusline { font-size: .84rem; border: 1px dashed var(--line); border-radius: 8px;
|
||||
padding: .45rem .7rem; margin: .4rem 0; background: var(--card); }
|
||||
.statusline.err { color: var(--bad); border-color: var(--bad); }
|
||||
table { border-collapse: collapse; width: 100%; font-size: .85rem; margin: .8rem 0; }
|
||||
th, td { border: 1px solid var(--line); padding: .4rem .6rem; text-align: left; }
|
||||
th { background: var(--card); }
|
||||
/* widgets */
|
||||
.widget { border: 1.5px solid var(--accent); border-radius: 12px; padding: 1rem; margin: 1.2rem 0;
|
||||
background: var(--accent-soft); }
|
||||
.widget h4 { margin: 0 0 .5rem; font-size: .95rem; }
|
||||
.widget button {
|
||||
font: inherit; font-size: .84rem; font-weight: 600; cursor: pointer;
|
||||
border: 1px solid var(--line); border-radius: 8px; padding: .35rem .7rem;
|
||||
background: var(--bg); color: var(--fg); margin: .15rem .2rem .15rem 0;
|
||||
}
|
||||
.widget button:hover { border-color: var(--accent); }
|
||||
.widget button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.simlog { background: var(--bg); border: 1px solid var(--line); border-radius: 8px;
|
||||
padding: .5rem .7rem; margin-top: .6rem; font-size: .8rem; min-height: 5.5rem;
|
||||
font-family: ui-monospace, Menlo, monospace; max-height: 12rem; overflow-y: auto; }
|
||||
.simstate { font-size: .82rem; margin-top: .5rem; }
|
||||
.simstate b { color: var(--accent); }
|
||||
select { font: inherit; font-size: .84rem; padding: .2rem .3rem; border-radius: 6px;
|
||||
border: 1px solid var(--line); background: var(--bg); color: var(--fg); }
|
||||
/* quiz */
|
||||
.q { border: 1px solid var(--line); border-radius: 10px; padding: .9rem 1rem; margin: 1rem 0; background: var(--card); }
|
||||
.q p.stem { font-weight: 600; margin: 0 0 .6rem; }
|
||||
.q .opt { display: block; width: 100%; text-align: left; font: inherit; font-size: .87rem;
|
||||
border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--fg);
|
||||
padding: .45rem .7rem; margin: .3rem 0; cursor: pointer; }
|
||||
.q .opt:hover { border-color: var(--accent); }
|
||||
.q .opt.right { border-color: var(--ok); background: var(--ok-soft); }
|
||||
.q .opt.wrong { border-color: var(--bad); background: var(--bad-soft); }
|
||||
.q .why { font-size: .82rem; color: var(--muted); margin-top: .5rem; display: none; }
|
||||
#score { font-weight: 700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Surface sync outcomes so devices can't silently diverge</h1>
|
||||
<p class="meta">Commit <code>7564a65</code> · 2026-07-16 · <code>lib/sync/sync_service.dart</code>, <code>lib/ui/capture_screen.dart</code> + tests</p>
|
||||
|
||||
<nav class="toc">
|
||||
<a href="#background">1 · Background</a>
|
||||
<a href="#intuition">2 · Intuition</a>
|
||||
<a href="#code">3 · Code</a>
|
||||
<a href="#quiz">4 · Quiz</a>
|
||||
</nav>
|
||||
|
||||
<h2 id="background">1 · Background</h2>
|
||||
|
||||
<p>The todo app is an offline-first notes app that runs on two devices — a Linux
|
||||
desktop and an Android phone — and keeps them in sync through a private GitHub
|
||||
repo used as dumb storage. There is no server: each device owns exactly one
|
||||
file, <code>todo-sync/notes/<nodeId>.json</code>, holding its full CRDT
|
||||
log of notes. A sync is a pull-merge-push cycle: list the directory, merge
|
||||
every <em>other</em> device's file into the local store, then upload your own
|
||||
merged log. Because merging is commutative and idempotent, any two devices that
|
||||
both complete a sync end up with the same notes.</p>
|
||||
|
||||
<p>That last sentence carries a hidden assumption: <em>that syncs actually
|
||||
complete, and that you'd know if they didn't.</em> Before this change, the
|
||||
capture screen triggered sync in three places:</p>
|
||||
|
||||
<ul>
|
||||
<li>a pull on launch,</li>
|
||||
<li>an automatic push when the app lifecycle hit <code>paused</code>,</li>
|
||||
<li>the manual Sync button — the <em>only</em> path that reported anything to the user.</li>
|
||||
</ul>
|
||||
|
||||
<p>Three separate silences hid failures:</p>
|
||||
|
||||
<ol>
|
||||
<li><code>_autoSync()</code> wrapped the whole cycle in <code>catch (_)</code>
|
||||
— every automatic failure (offline, bad token, GitHub error) vanished.</li>
|
||||
<li>Inside <code>SyncService.sync()</code>, a peer file that failed to fetch or
|
||||
parse was skipped with a bare <code>continue</code>. The sync then
|
||||
<em>succeeded</em> from the caller's point of view — it just quietly merged
|
||||
nothing from that device.</li>
|
||||
<li>On Linux desktop, Flutter rarely delivers the <code>paused</code> lifecycle
|
||||
event at all, so the desktop effectively only synced at launch or on a
|
||||
manual button press.</li>
|
||||
</ol>
|
||||
|
||||
<p>These silences had just produced a real incident: after the July 11 storage
|
||||
migration (which moved the sync location to a new, incompatible directory), the
|
||||
phone got the new build but the desktop kept running a bundle built two hours
|
||||
before the migration commit. For five days each device synced happily into a
|
||||
directory the other never read — desktop showed 39 notes, phone 30 — and no
|
||||
pixel anywhere said anything was wrong.</p>
|
||||
|
||||
<h2 id="intuition">2 · Intuition</h2>
|
||||
|
||||
<p>The fix is not smarter syncing — the CRDT merge was never the problem. The
|
||||
fix is <strong>making every sync outcome observable</strong>, and making sure
|
||||
the desktop actually gets chances to sync. One sentence: <em>a sync system that
|
||||
can fail silently will eventually diverge silently; a status line is the
|
||||
cheapest possible alarm.</em></p>
|
||||
|
||||
<p>Concretely, imagine the sync repo contains your own file plus two peers, one
|
||||
of them corrupt:</p>
|
||||
|
||||
<div class="flow">
|
||||
<div class="node oknode"><b>phone.json</b>valid log · 42 notes<br><span class="pill ok">merged</span></div>
|
||||
<div class="node badnode"><b>tablet.json</b><code>{not a log</code><br><span class="pill bad">undecodable</span></div>
|
||||
<div class="node"><b>desktop.json</b>(your own file)<br><span class="pill ok">pushed</span></div>
|
||||
</div>
|
||||
|
||||
<p>Before the change both outcomes below rendered identically as
|
||||
"<code>Synced: merged 1 device(s)</code>" — you could not tell a healthy fleet
|
||||
from one silently losing a device. Now the same run reports:</p>
|
||||
|
||||
<div class="statusline">Synced at 18:00:35 · merged 1 device(s), skipped 1 unreadable file(s)</div>
|
||||
|
||||
<p>and an automatic sync that fails outright no longer disappears into
|
||||
<code>catch (_)</code> — it lands in a small persistent status line under the
|
||||
editor, in the error color:</p>
|
||||
|
||||
<div class="statusline err">Sync failed at 07:12:03 · GitHubSyncError: GET todo-sync/notes failed: 401</div>
|
||||
|
||||
<p>The status is also written to <code>SharedPreferences</code>, so a failure
|
||||
that happened just before the app closed is still visible the next morning.
|
||||
Had this line existed on July 11, the desktop would have shown a stale
|
||||
"Synced at …" timestamp days old — the divergence would have been visible on
|
||||
day one instead of day five.</p>
|
||||
|
||||
<h3>The second half: the desktop never got a turn</h3>
|
||||
|
||||
<p>Reporting outcomes only helps if syncs actually run. On Android,
|
||||
<code>paused</code> fires reliably when you switch apps. On Linux desktop it
|
||||
rarely fires — but <code>inactive</code> and <code>hidden</code> (focus loss)
|
||||
do. The catch: focus flickers constantly on a desktop (alt-tab, clicking
|
||||
another window), and firing a GitHub round-trip per flicker would be abusive.
|
||||
So the new trigger is <strong>debounced</strong>: focus must stay lost for 5
|
||||
seconds before a sync fires, and every new lifecycle event resets the timer.
|
||||
<code>paused</code> still syncs immediately — Android may kill a paused app
|
||||
before any timer fires, so waiting would risk losing the push.</p>
|
||||
|
||||
<div class="widget" id="lifecycle-sim">
|
||||
<h4>Micro-world: the new lifecycle trigger</h4>
|
||||
<p style="font-size:.83rem; margin:.2rem 0 .5rem;">Fire lifecycle events and step time
|
||||
forward one second at a time. This mirrors the real
|
||||
<code>didChangeAppLifecycleState</code> logic: <code>paused</code> syncs now;
|
||||
<code>inactive</code>/<code>hidden</code> arm (or reset) a 5-second timer;
|
||||
<code>resumed</code> does nothing — a timer already armed still fires.</p>
|
||||
<div>
|
||||
<button class="primary" onclick="simEvent('paused')">paused</button>
|
||||
<button class="primary" onclick="simEvent('inactive')">inactive</button>
|
||||
<button class="primary" onclick="simEvent('hidden')">hidden</button>
|
||||
<button class="primary" onclick="simEvent('resumed')">resumed</button>
|
||||
<button onclick="simTick()">advance 1 s</button>
|
||||
<button onclick="simReset()">reset</button>
|
||||
</div>
|
||||
<div class="simstate">t = <b id="sim-t">0</b> s ·
|
||||
debounce timer: <b id="sim-timer">none</b> ·
|
||||
syncs fired: <b id="sim-count">0</b></div>
|
||||
<div class="simlog" id="sim-log">— try: inactive → inactive → advance ×5 (timer resets, then fires once)</div>
|
||||
</div>
|
||||
|
||||
<div class="widget" id="msg-sim">
|
||||
<h4>Micro-world: what the sync message reports</h4>
|
||||
<p style="font-size:.83rem; margin:.2rem 0 .5rem;">Set each peer file's condition and
|
||||
see the exact message the snackbar and status line build (the real
|
||||
<code>_describe()</code> logic over <code>SyncResult</code>).</p>
|
||||
<div style="font-size:.85rem;">
|
||||
<label>phone.json <select id="peer1" onchange="msgSim()">
|
||||
<option value="ok">valid log</option><option value="corrupt">corrupt JSON</option><option value="missing">listed but GET 404s</option>
|
||||
</select></label><br>
|
||||
<label>tablet.json <select id="peer2" onchange="msgSim()">
|
||||
<option value="ok">valid log</option><option value="corrupt" selected>corrupt JSON</option><option value="missing">listed but GET 404s</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<div class="statusline" id="msg-out" style="margin-top:.6rem;"></div>
|
||||
<div style="font-size:.78rem; color:var(--muted);" id="msg-note"></div>
|
||||
</div>
|
||||
|
||||
<h2 id="code">3 · Code</h2>
|
||||
|
||||
<h3>Concern 1 — skipped peers become data, not silence (<code>sync_service.dart</code>)</h3>
|
||||
|
||||
<p><code>SyncResult</code> gains a <code>skippedFiles</code> list, and the pull
|
||||
loop collects instead of discarding. Note the two failure shapes it now covers
|
||||
with one expression: a file that was listed but whose GET returned nothing
|
||||
(<code>text == null</code>), and one that fetched but didn't parse as a log:</p>
|
||||
|
||||
<div class="codeblock"><pre><span class="del">- final text = await github.getFileText('$notesDir/$name');
|
||||
- if (text == null) continue;
|
||||
- final remote = _decodeLog(text);
|
||||
- if (remote == null) continue; // Skip a corrupt/foreign file.</span><span class="add">+ final text = await github.getFileText('$notesDir/$name');
|
||||
+ final remote = text == null ? null : _decodeLog(text);
|
||||
+ if (remote == null) {
|
||||
+ // A vanished or undecodable peer file must not abort the sync, but
|
||||
+ // it must not vanish silently either — that's how a device drops
|
||||
+ // out of sync unnoticed. Report it in the result instead.
|
||||
+ skipped.add(name);
|
||||
+ continue;
|
||||
+ }</span></pre></div>
|
||||
|
||||
<p>The tolerance is unchanged — one bad file still never aborts the cycle, and
|
||||
the push still happens — but the caller now knows the merge was partial.</p>
|
||||
|
||||
<h3>Concern 2 — every outcome lands somewhere visible (<code>capture_screen.dart</code>)</h3>
|
||||
|
||||
<p>A tiny <code>SyncStatus</code> value (time, ok, detail) flows into a
|
||||
<code>ValueNotifier</code> — the same pattern the screen already used for the
|
||||
"Saved locally at…" indicator, so one status change repaints one line of text,
|
||||
not the whole screen. <code>_recordSyncStatus()</code> also persists the three
|
||||
fields to <code>SharedPreferences</code>, and <code>_restoreSyncStatus()</code>
|
||||
reloads them in <code>initState</code>.</p>
|
||||
|
||||
<div class="codeblock"><pre>Future<void> _autoSync() async {
|
||||
...
|
||||
try {
|
||||
<span class="del">- await _syncService.sync(widget.repository, client);
|
||||
- } catch (_) {
|
||||
- // Best-effort: ignore (offline, transient GitHub errors, etc.).</span><span class="add">+ final result = await _syncService.sync(widget.repository, client);
|
||||
+ await _recordSyncStatus(ok: true, detail: _describe(result));
|
||||
+ } catch (e) {
|
||||
+ // Offline or a transient GitHub error: still no snackbar (this path
|
||||
+ // must never interrupt capture), but the status line shows it.
|
||||
+ await _recordSyncStatus(ok: false, detail: '$e');</span>
|
||||
} finally { ... }
|
||||
}</pre></div>
|
||||
|
||||
<p>The design line to notice: auto-sync is still <em>quiet</em> (no snackbar —
|
||||
capture must never be interrupted), but it is no longer <em>silent</em>. The
|
||||
manual Sync button records the same status and shares the same message builder,
|
||||
so the snackbar and the status line can never drift apart:</p>
|
||||
|
||||
<div class="codeblock"><pre>static String _describe(SyncResult result) {
|
||||
final skipped = result.skippedFiles.length;
|
||||
return 'merged ${result.mergedDevices} device(s)'
|
||||
'${skipped == 0 ? '' : ', skipped $skipped unreadable file(s)'}';
|
||||
}</pre></div>
|
||||
|
||||
<h3>Concern 3 — a sync trigger the desktop actually receives</h3>
|
||||
|
||||
<div class="codeblock"><pre>if (state == AppLifecycleState.paused) {
|
||||
// Immediate: Android may kill the app before a debounce timer fires.
|
||||
_autoSyncDebounce?.cancel();
|
||||
_autoSync();
|
||||
} else if (state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.hidden) {
|
||||
// Focus loss — the only background signal Flutter Linux delivers
|
||||
// reliably (`paused` rarely arrives on desktop). Debounced so alt-tab
|
||||
// flicker doesn't fire a request per focus change.
|
||||
_autoSyncDebounce?.cancel();
|
||||
_autoSyncDebounce = Timer(CaptureScreen.autoSyncDebounce, _autoSync);
|
||||
}</pre></div>
|
||||
|
||||
<p>The timer is cancelled in <code>dispose()</code>, and the existing
|
||||
single-flight guard (<code>_autoSyncing</code>) still prevents overlapping
|
||||
cycles if a debounced sync lands while another is running. The 5-second
|
||||
constant lives on the public widget class so tests can pump exactly past it.</p>
|
||||
|
||||
<h3>Concern 4 — tests</h3>
|
||||
|
||||
<p>The suite grew from 173 to 174 tests, still at 100% line coverage. The old
|
||||
test "auto-sync failure is silent (no snackbar)" flipped meaning: it now
|
||||
asserts <em>no snackbar but a visible error status line</em>. New tests cover
|
||||
the skipped-file message, restoring a persisted failure on launch, and the
|
||||
debounce (two focus events reset the timer; the sync fires once, only after
|
||||
the full window elapses).</p>
|
||||
|
||||
<h2 id="quiz">4 · Quiz</h2>
|
||||
<p>Five questions, click an answer to check it. Score: <span id="score">0 / 0</span></p>
|
||||
|
||||
<div class="q" data-correct="0">
|
||||
<p class="stem">1. Why did the desktop/phone divergence stay invisible for five days?</p>
|
||||
<button class="opt">Auto-sync swallowed every error and unreadable peers were skipped with no UI signal at all</button>
|
||||
<button class="opt">The CRDT merge silently dropped notes whose HLC timestamps collided across the two devices</button>
|
||||
<button class="opt">GitHub rate-limiting made pushes succeed while pulls kept returning stale directory listings</button>
|
||||
<button class="opt">The "N saved" counter cached its value at launch and never refreshed after later merges ran</button>
|
||||
<p class="why"></p>
|
||||
</div>
|
||||
|
||||
<div class="q" data-correct="2">
|
||||
<p class="stem">2. A peer file appears in the directory listing but its GET returns 404. What does the new sync do?</p>
|
||||
<button class="opt">Aborts the whole cycle immediately so the user can retry once the repository is consistent</button>
|
||||
<button class="opt">Retries the fetch a few times with backoff before giving up on that peer for this cycle</button>
|
||||
<button class="opt">Adds it to <code>skippedFiles</code>, merges the remaining peers, and still pushes its own log</button>
|
||||
<button class="opt">Deletes the stale directory entry from the repo so the next listing comes back clean</button>
|
||||
<p class="why"></p>
|
||||
</div>
|
||||
|
||||
<div class="q" data-correct="1">
|
||||
<p class="stem">3. Why does <code>paused</code> sync immediately while <code>inactive</code>/<code>hidden</code> are debounced?</p>
|
||||
<button class="opt"><code>paused</code> fires only once per app session, so a debounce there would never accomplish anything</button>
|
||||
<button class="opt">Android may kill a paused app before a timer fires; focus flicker would otherwise spam GitHub</button>
|
||||
<button class="opt"><code>inactive</code> never occurs on Android, so it needs rate-limiting purely for the desktop's sake</button>
|
||||
<button class="opt">Debouncing <code>paused</code> would leave a pending timer that hangs the widget tests' fake clock</button>
|
||||
<p class="why"></p>
|
||||
</div>
|
||||
|
||||
<div class="q" data-correct="3">
|
||||
<p class="stem">4. Right after a fresh app launch, before any sync runs, where does the status line's content come from?</p>
|
||||
<button class="opt">The app replays the most recent sync commit from the GitHub repository's history</button>
|
||||
<button class="opt">Nowhere — the line always stays empty until the first sync of this session completes</button>
|
||||
<button class="opt">A hardcoded "never synced" placeholder that the first completed sync then overwrites</button>
|
||||
<button class="opt">SharedPreferences — the last run persisted its outcome; launch restores it</button>
|
||||
<p class="why"></p>
|
||||
</div>
|
||||
|
||||
<div class="q" data-correct="0">
|
||||
<p class="stem">5. Manual Sync reports "merged 1 device(s), skipped 1 unreadable file(s)". What should you conclude?</p>
|
||||
<button class="opt">A listed device's file couldn't be read, so the fleet may not be converged</button>
|
||||
<button class="opt">One peer merged and the other was merely offline this cycle, so nothing needs any attention</button>
|
||||
<button class="opt">The skipped entry was this device's own log file, which every sync skips over by design</button>
|
||||
<button class="opt">The corrupt peer was repaired automatically and will merge normally on the next sync run</button>
|
||||
<p class="why"></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ---------- lifecycle debounce simulator ----------
|
||||
const DEBOUNCE = 5;
|
||||
let simT = 0, simTimer = null, simSyncs = 0, simBusy = false;
|
||||
function simRender() {
|
||||
document.getElementById('sim-t').textContent = simT;
|
||||
document.getElementById('sim-timer').textContent =
|
||||
simTimer === null ? 'none' : simTimer + ' s left';
|
||||
document.getElementById('sim-count').textContent = simSyncs;
|
||||
}
|
||||
function simLog(msg, cls) {
|
||||
const log = document.getElementById('sim-log');
|
||||
const line = document.createElement('div');
|
||||
line.textContent = 't=' + simT + 's ' + msg;
|
||||
if (cls) line.style.color = 'var(--' + cls + ')';
|
||||
log.appendChild(line);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
function simSync(reason) {
|
||||
simSyncs++;
|
||||
simLog('SYNC fires (' + reason + ') → pull-merge-push', 'ok');
|
||||
}
|
||||
function simEvent(state) {
|
||||
if (state === 'paused') {
|
||||
if (simTimer !== null) { simTimer = null; simLog('pending timer cancelled'); }
|
||||
simLog('lifecycle: paused → immediate sync');
|
||||
simSync('paused');
|
||||
} else if (state === 'inactive' || state === 'hidden') {
|
||||
simLog('lifecycle: ' + state + ' → debounce timer (re)set to ' + DEBOUNCE + ' s');
|
||||
simTimer = DEBOUNCE;
|
||||
} else {
|
||||
simLog('lifecycle: resumed → no-op (an armed timer keeps running)');
|
||||
}
|
||||
simRender();
|
||||
}
|
||||
function simTick() {
|
||||
simT++;
|
||||
if (simTimer !== null) {
|
||||
simTimer--;
|
||||
if (simTimer <= 0) { simTimer = null; simSync('debounce elapsed'); }
|
||||
}
|
||||
simRender();
|
||||
}
|
||||
function simReset() {
|
||||
simT = 0; simTimer = null; simSyncs = 0;
|
||||
document.getElementById('sim-log').textContent = '';
|
||||
simRender();
|
||||
}
|
||||
simRender();
|
||||
|
||||
// ---------- sync message builder ----------
|
||||
function msgSim() {
|
||||
const states = [document.getElementById('peer1').value,
|
||||
document.getElementById('peer2').value];
|
||||
const merged = states.filter(s => s === 'ok').length;
|
||||
const skipped = states.filter(s => s !== 'ok').length;
|
||||
let msg = 'merged ' + merged + ' device(s)';
|
||||
if (skipped > 0) msg += ', skipped ' + skipped + ' unreadable file(s)';
|
||||
document.getElementById('msg-out').textContent = 'Synced at 18:00:35 · ' + msg;
|
||||
document.getElementById('msg-note').textContent = skipped > 0
|
||||
? 'Before this change the message would have claimed plain "merged ' + merged +
|
||||
' device(s)" — indistinguishable from a healthy fleet.'
|
||||
: 'All peers decoded — nothing skipped, nothing to warn about.';
|
||||
}
|
||||
msgSim();
|
||||
|
||||
// ---------- quiz ----------
|
||||
const RATIONALE = [
|
||||
'All three silences were UI-level: catch (_) in _autoSync, a bare continue for bad peer files, and no reliable desktop trigger. The merge itself was fine.',
|
||||
'Tolerance is unchanged — one bad file never aborts the cycle — but the skip is now reported in SyncResult.skippedFiles instead of vanishing.',
|
||||
'paused is the "about to be killed" signal on Android, so it pushes at once; inactive/hidden fire on every focus flicker, hence the 5 s debounce.',
|
||||
'_recordSyncStatus writes sync.lastTime/lastOk/lastDetail to SharedPreferences; _restoreSyncStatus reloads them in initState, so a pre-shutdown failure survives.',
|
||||
'A skipped file means a listed device was left out of the merge — exactly the condition that caused the 39-vs-30 split, now made visible.'
|
||||
];
|
||||
let answered = 0, correct = 0;
|
||||
document.querySelectorAll('.q').forEach((q, qi) => {
|
||||
const right = parseInt(q.dataset.correct, 10);
|
||||
const opts = q.querySelectorAll('.opt');
|
||||
opts.forEach((opt, oi) => {
|
||||
opt.addEventListener('click', () => {
|
||||
if (q.dataset.done) return;
|
||||
q.dataset.done = '1';
|
||||
answered++;
|
||||
if (oi === right) { opt.classList.add('right'); correct++; }
|
||||
else { opt.classList.add('wrong'); opts[right].classList.add('right'); }
|
||||
const why = q.querySelector('.why');
|
||||
why.textContent = RATIONALE[qi];
|
||||
why.style.display = 'block';
|
||||
document.getElementById('score').textContent = correct + ' / ' + answered;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -30,8 +30,12 @@
|
||||
<div class="wrap">
|
||||
<div class="kicker">todo</div>
|
||||
<h1>Change explanations</h1>
|
||||
<p class="lead">1 explanation · interactive walkthroughs of notable changes, newest first.</p>
|
||||
<p class="lead">2 explanations · interactive walkthroughs of notable changes, newest first.</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="2026-07-16-explanation-surface-sync-outcomes.html">
|
||||
<span class="date">2026-07-16</span>
|
||||
<span class="title">Surface sync outcomes so devices can't silently diverge</span>
|
||||
</a>
|
||||
<a class="card" href="2026-07-11-explanation-per-keystroke-backup.html">
|
||||
<span class="date">2026-07-11</span>
|
||||
<span class="title">Making capture smooth: cutting per-keystroke O(N) work</span>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user