Skip to content

refactor: collapse six duplicated implementations across drivers and state - #69

Merged
morsela merged 2 commits into
mainfrom
claude/hopeful-brown-3o07ad
Sep 17, 2026
Merged

morsela merged 2 commits into
mainfrom
claude/hopeful-brown-3o07ad

Conversation

@morsela

@morsela morsela commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Six places where the same code existed two to seven times over, each kept in step by hand. No behaviour change; the 451 existing tests pass unchanged and 15 new ones cover the wrappers this adds.

These came out of a duplication audit of the whole app. The remaining findings from that audit are not in this PR — see the end.

What was duplicated, and why each one mattered

Notification teardown, four times in drivers.ts. classic, fitshow and ks1234 each carried a byte-identical detach block — remove the listener, stopNotifications() in a try/catch, null the pair — and FTMS a fourth spelling of it as a loop. Four places to fix a teardown that ever turns out to leak, on the path that decides whether a pad is left bound to a tab somebody has walked away from. Now subscribe/unsubscribe. The event name is a constant too: it appeared as a string literal twelve times, and a typo in one is a listener that registers and never fires.

The GATT fake reimplemented two helpers it could have imported. It had its own toHex, duplicating the hex the package already exports, and its own fullUuid, duplicating a canonicalUuid that was module-private. That second one is the sharp edge: a fake whose UUID canonicalisation is a separate implementation can agree with itself and disagree with detectDriver, which is the one thing a fake must not do. canonicalUuid is now exported and the fake imports both. The classic suite had a third hex formatter of its own; it shares the same one now while still deriving the frame CRC independently, since an expectation taken from the code under test checks nothing.

isObj declared three times, identically, in settings.ts, session.ts and backup.ts — the last of which already imports both sanitisers from the other two and then kept its own copy of the primitive underneath them. Moved to lib/validate.ts, with the array case pinned by a test because that case is the reason the guard is written as it is: typeof [] === 'object', so the obvious spelling waves an array through and every field read off it is then silently undefined.

Storage keys declared in three modules and listed again by hand in Recovery.tsx. That screen needs the whole set to dump and to clear, so it held a second copy as string literals. A fifth stored key added anywhere would have dropped silently out of both the rescue download and the clear button — the last two controls that still work once everything else has failed. lib/storage.ts holds the keys once and derives the full list from them.

The same try/catch around localStorage, seven times. Every one was needed — the accessors throw outright with site data blocked, and setItem throws on quota in private mode — and the answer was always the same. Now four wrappers.

[...sessions.value, ...(currentSession.value ? [currentSession.value] : [])], six times, twice in components. That put "what counts as a session" in the view layer, where changing it would have had to be chased out of the store. One computed now, which also memoises the concatenation: streak and bestStreak each walk dailySeries(400), and the streak chain asks for both on every render.

Two judgement calls worth a look

readJson distinguishes undefined from a stored null. Missing, unreachable and unparseable all come back undefined; a stored null comes back as null. That is not tidiness — restoreOpenSession returns quietly when there is nothing to recover, but hands a value the app actually wrote to sanitizeSession so it gets rejected and reported like any other bad record. Collapsing the two would have turned one of those into silence.

The blanket catches in loadSessions and restoreOpenSession are kept. I removed them at first and put them back: they are not about storage any more, they are about running at module scope. main.tsx calls both before render(), so a throw there never reaches the error boundary and leaves a blank page instead of the Recovery screen — the exact failure this app went to some trouble to make impossible. They now say so in a comment.

Verification

npm run check --workspaces, npm test, npm run lint and npm run build all clean. 451 existing tests pass unchanged; 15 added, covering the storage wrappers' failure behaviour under a localStorage that throws — which is what those seven try/catch blocks existed for and what nothing previously tested — plus the key registry and the isObj array case. The four protocol suites passed unedited on either side of the driver changes, which is the real safety net there since there is no treadmill in CI.

Not in this PR

The audit also turned up: fmtMph existing while ten call sites hand-roll toMph(x).toFixed(1); the resume confirmation dialog written out twice; a copy-to-clipboard-then-flash pattern four times; the unsupported-browser fallback block twice; shared chart scaffolding between Area and Column (already drifted — axis labels sit at H - 3 in one and H - 4 in the other); four hand-rolled poll-until-deadline loops in connection.ts; duplicated test fixtures (fakePad, the session() builder); ~25 lines of boilerplate across the four static pages; and the model/protocol table in five copies, which has already drifted three separate ways. Happy to take any of those next.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TXUkCfJBbZiFPoJLoaiKBo


Generated by Claude Code

Three of the four drivers carried a byte-identical `detach` block — remove the
listener, `stopNotifications()` inside a try/catch, null the pair — and FTMS a
fourth spelling of the same thing as a loop. That is four places to fix a
teardown that ever turns out to leak, on the one path that decides whether a pad
is left bound to a tab the user has walked away from. `subscribe`/`unsubscribe`
now say it once, and the event name is a constant rather than a string literal
repeated twelve times, where a typo is a listener that registers and never fires.

The GATT fake was the other copy. It had its own `toHex` and its own `fullUuid`,
duplicating the `hex` this package already exports and the `canonicalUuid` it did
not. A fake whose UUID canonicalisation is a second implementation can agree with
itself and disagree with `detectDriver`, which is the one thing a fake must never
do, so `canonicalUuid` is exported and the fake imports both. The classic suite
had rolled a third hex formatter of its own; it now shares the same one while
still deriving the frame CRC independently, because an expectation taken from the
code under test checks nothing.

No behaviour change: all four protocol suites pass unedited on either side of it,
which is the whole safety net here — there is no treadmill in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TXUkCfJBbZiFPoJLoaiKBo
Three duplications in the web app, all of the kind where the copies have to be
kept in step by hand.

`isObj` was declared identically in `settings.ts`, `session.ts` and `backup.ts` —
the last of which already imports both sanitisers from the other two and then
kept its own copy of the primitive underneath them. It moves to `lib/validate.ts`
with the array case pinned by a test, because that case is the reason the guard
is written the way it is: `typeof [] === 'object'`, so the obvious spelling waves
an array through and every field read off it is then silently `undefined`.

localStorage was reached in seven places behind seven copies of the same
try/catch, and the keys were declared in the three modules that own them and then
written out *again* as string literals in `Recovery.tsx`, which needs the whole
set to dump and to clear. That second list was maintained by hand, so a fifth
stored key added anywhere would have dropped out of both the rescue download and
the clear button — the last two controls that still work once everything else has
failed. `lib/storage.ts` holds the keys once, derives the full list from them,
and wraps the four accessors; `Recovery` reads the list rather than repeating it.

`readJson` answers a missing, unreachable or unparseable store with `undefined`,
deliberately distinct from a stored `null`: `restoreOpenSession` returns quietly
when there is nothing to recover, but hands a value the app actually wrote to
`sanitizeSession` so it is rejected *and reported* like any other bad record.

The blanket catches in `loadSessions` and `restoreOpenSession` are kept, and now
say what they are for. They are not about storage any more — they are about
running at module scope, which `main.tsx` reaches before `render()`, so a throw
there is a blank page with no error boundary and no Recovery screen behind it.

Finally, `[...sessions.value, ...(currentSession.value ? [currentSession.value] :
[])]` appeared six times, twice in components. That put "what counts as a
session" in the view layer, where a change to it would have had to be chased out
of the store. It is now one `computed`, which also memoises the concatenation —
`streak` and `bestStreak` each walk `dailySeries(400)` and the streak chain asks
for both on every render.

No behaviour change: the existing 451 tests pass unchanged, and 15 new ones pin
the storage wrappers' failure behaviour — which is what those seven try/catches
existed for and what nothing previously tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TXUkCfJBbZiFPoJLoaiKBo
@vercel

vercel Bot commented Sep 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
beltcontrol Ready Ready Preview Sep 17, 2026 1:31am UTC

Request Review

@morsela
morsela merged commit 634c3bf into main Sep 17, 2026
6 checks passed
@morsela
morsela deleted the claude/hopeful-brown-3o07ad branch September 17, 2026 02:40

This branch was successfully deployed

1 active deployment
Preview — 825d591e Deployed Sep 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants