Skip to content

Show progress: ladder, statistics, export and import - #17

Merged
RCheesley merged 2 commits into
mainfrom
progress-views
Sep 21, 2026
Merged

RCheesley merged 2 commits into
mainfrom
progress-views

Conversation

@RCheesley

Copy link
Copy Markdown
Owner

The views around the drill, and the wiring that makes progress actually
persist. Stars were being computed, shown once, and thrown away; a reload
started the learner from nothing.

What is here

The ladder lists every lesson the layout produced, says which are unlocked
and how many stars each has earned, and puts a button on every unlocked rung so
an earlier lesson can be returned to. Unlocking is one-way, so the ladder stays
open behind a learner. Star counts are stated in words with the glyph row
aria-hidden beside them, and a locked rung says what unlocks it and names the
lesson rather than only looking dimmer.

The statistics group weak keys by finger and by row, which is the thing
this project exists to do. A list of missed characters is trivia; the same
numbers gathered under the finger that presses them and the row it reaches for
say "your left ring finger cannot find the upper row", and that is what a lesson
can be built from. The grouping is a pure module, src/stats/keystats.ts, with
no DOM, so it is unit-tested without a browser. Every level carries its own
numbers, its own wording and a severity band named in words; the tint and the
miss-rate bars are reinforcement only, and the bars are aria-hidden with the
same number printed beside them. Fingers that need work lead; the ones that are
behaving sit in a disclosure. describeKey supplies the wording for the part a
group heading does not already say — a sideways reach, or which arc of the thumb
cluster — so this view and the drill readout can never disagree about a
position. A key the current layout does not bind is reported separately rather
than dropped.

Export and import. Storage is probed rather than assumed, because
localStorage throws in a private window; when it is unavailable the page says
plainly that progress will not outlive the tab. Export writes a dated JSON file.
Import merges it back, taking the better of the file and this browser for every
field, so importing your own export can never cost you this morning's practice.
A partly unreadable import reports what it dropped, item by item, instead of
failing silently or refusing the whole file — readProgress's warnings are
printed as a list. Only a file that is not JSON at all is refused, and then the
message says so. Clearing saved progress takes two deliberate goes.

Stars are written to progress. createDrillView now takes a lessonId and
records best-of stars plus experience when a drill is scored, then calls
onScored so the caller can persist and refresh. Best-of, never last-of: a bad
run on a lesson already passed does not take a rung away.

The subpath caveat

The export is handed over as an object URL, not a relative href. The site
deploys to GitHub Pages from /touchwright/, and a relative href resolves
against the page's own path, so an export that worked at the root would have
pointed at the wrong place one directory down. tests/e2e/progress.spec.ts
pushes a deeper path with history.replaceState and exports from there.

Regression 1 is kept

Every load goes through ProgressStore, which thaws and validates, and the
thawed copy is handed to session.applyLoadedProgress, which merges field by
field. Nothing from a file or from storage becomes live state by reference.
Clearing empties the session's own Progress object in place rather than
replacing it, so the session keeps the one object it records keystrokes into.
tests/regression/prototype-failures.test.ts is untouched and still passes, and
there are functional tests that type a keystroke straight after an import and
after a clear.

Tests

  • tests/unit/keystats.test.ts — 19 tests over the grouping, thresholds,
    wording and boundary behaviour.
  • tests/functional/progress-view.test.ts — the views against the real markup.
  • tests/functional/progress-view-wiring.test.ts — the whole loop: load a
    layout, type a drill, have the stars written down, find them again after a
    second wireUp over the same storage driver.
  • tests/e2e/progress.spec.ts — the ladder, the statistics, downloads, a second
    browser context, axe in both themes, target sizes, focus rings and reduced
    motion.
  • tests/e2e/pending.spec.ts — keeps progress across a reload and exports
    progress, clears storage, and imports it back
    are filled in. The other four
    fixmes are left alone.

npm run verify and npx playwright test both pass: 443 vitest tests and 120
Playwright tests across both themes.

Deliberately not done

nextLessonName is still null on the drill view. Issue #3 has not landed, so
the result card would otherwise offer "Start <next lesson>" on a button
that could only restart the same sample text. The lesson is chosen on the ladder
instead. showDrill already has the lesson's id, name and key set in hand, so
wiring generated text in is one line, marked with a seam comment.

Closes #9

🤖 Generated with Claude Code

RCheesley and others added 2 commits September 21, 2026 00:43
The drill could score a lesson but had nowhere to put the result, so stars
were computed, shown once and thrown away, and a reload started the learner
from nothing. This closes that loop and builds the three views around the
drill.

The ladder lists every lesson the layout produced, says which are unlocked and
how many stars each has earned, and has a button on each unlocked rung so an
earlier lesson can be returned to. Unlocking is one-way, so the ladder stays
open behind a learner. A locked rung says what unlocks it and names the lesson,
rather than only looking dimmer: stars are stated in words with the glyph row
marked aria-hidden beside them.

The statistics group weak keys by finger and by row, which is the thing this
project exists to do. A list of missed characters is trivia; the same numbers
gathered under the finger that presses them and the row it reaches for say
"your left ring finger cannot find the upper row", and that is what a lesson
can be built from. The grouping is a pure module, src/stats/keystats.ts, with
no DOM, so the diagnosis is unit-tested without a browser; every level carries
its own numbers, its own wording and a severity band named in words, with the
tint and the little miss-rate bars as reinforcement only. Fingers that need
work lead; the ones that are behaving sit in a disclosure. A key whose
statistics the current layout does not bind is reported separately rather than
dropped, so the totals are never quietly short.

Progress is now actually persisted. Storage is probed rather than assumed,
because localStorage throws in a private window, and when it is unavailable
the page says plainly that progress will not outlive the tab. Export writes a
dated JSON file, and import merges it back, taking the better of the file and
this browser for every field so importing your own export can never cost you
this morning's practice. A partly unreadable import reports what it dropped,
item by item, instead of failing silently or refusing the whole file; only a
file that is not JSON at all is refused, and then the message says so.
Clearing saved progress takes two deliberate goes.

The export is handed over as an object URL, not a relative href. The site
deploys to GitHub Pages from /touchwright/, and a relative href resolves
against the page's own path, so an export that worked at the root would have
pointed at the wrong place one directory down. There is an end-to-end test
that pushes a deeper path and exports from there.

Regression 1 is kept, not undone: every load goes through ProgressStore, which
thaws and validates, and the thawed copy is handed to applyLoadedProgress,
which merges field by field. Nothing from a file or from storage becomes live
state by reference. Clearing empties the session's own Progress object in
place rather than replacing it.

Two end-to-end fixmes are filled in: "keeps progress across a reload" and
"exports progress, clears storage, and imports it back". The other four are
left alone.

nextLessonName is deliberately still null on the drill view. Issue #3 has not
landed, so the result card would otherwise offer "Start <next lesson>" on a
button that could only restart the same sample text. The lesson is chosen on
the ladder instead, and showDrill already has the lesson's id, name and key
set in hand, so wiring generated text in is one line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@RCheesley
RCheesley merged commit 075c8c6 into main Sep 21, 2026
3 checks passed
@RCheesley
RCheesley deleted the progress-views branch September 21, 2026 12:48
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.

Show progress: ladder, statistics, export and import

1 participant