Skip to content

Add sprint mode with an adjustable and disableable limit - #22

Merged
RCheesley merged 1 commit into
mainfrom
sprint-mode
Sep 23, 2026
Merged

RCheesley merged 1 commit into
mainfrom
sprint-mode

Conversation

@RCheesley

Copy link
Copy Markdown
Owner

A timed run across everything unlocked, for measuring rather than learning.

What a sprint is

src/drill/sprint.ts folds every lesson the learner has reached into one
synthetic lesson, so generateDrillText can be handed a sprint the same way it
is handed a rung. The stage is the reached lesson's own stage, so a sprint
drills prose once the ladder has reached prose and never asks for a key that
has not been taught. It carries no lesson id, so it earns experience and no
stars, and the result card names what it was — "sprint across every unlocked
key, 30 seconds" — rather than leaving it to be inferred from the star row.

The module is DOM-free, like the rest of src/drill/**. The repeating callback
is injected, so nothing in that directory reaches for setInterval; the
default schedule lives in the drill view, which is where the brief says a wall
clock belongs.

Regression 3, and how the timer is bound

An abandoned sprint left its interval running. It then acted on whichever drill
was current, and because an untimed drill carried a limit of zero, "elapsed is
at least the limit" was true on the first keystroke, so every subsequent drill
died instantly.

startSprintTimer captures the one Drill it is for and never looks a drill
up again. It also demands a currentDrill reader and compares it with that
captured object by identity on every tick, stopping itself the instant they
differ. The binding is a required argument rather than a convention: there is
no way to construct a timer that is not bound to a drill, so a teardown that is
missed costs one tick of nothing rather than every drill that follows.

All three it.todos in tests/regression/prototype-failures.test.ts are
filled in, including one that swaps in a second drill which is itself timed and
already over its own limit, and proves the timer refuses to touch it. The
functional suite goes further and fires a deliberately uncancelled tick at a
later lesson — a clearInterval that never happened — and the lesson still
runs all the way through.

WCAG 2.2.1, exemption declined

  • The duration control is built from SPRINT_DURATIONS_MS, so the options and
    the durations cannot drift apart.
  • The untimed option genuinely never expires: proved at a century of elapsed
    time in the unit and functional suites, and at an hour of faked wall clock in
    a real browser.
  • The limit is adjustable before the sprint starts, and the setting is stated
    in visible text under the control, not only as a selected option.
  • The countdown is a role="timer", whose implicit live setting is off, so the
    number is never announced per second. Time running out is announced instead
    at three polite milestones, and the count of announcements over a whole
    sprint is asserted so nobody can quietly turn it into a firehose.

docs/accessibility.md moves sprint out of "Known gaps" and into the checklist.

Defensive notes

parseSprintLimit refuses an empty control rather than letting Number('')
become zero and read as a deliberate choice of untimed — absent, zero and
malformed are three different things. A tick that throws stops its timer first
and rethrows with the original attached as cause, so a broken observer cannot
leave an interval running behind it. A sprint that cannot be built reports
itself without tearing the page down: the lesson on screen is still good.

Tests

npm run verify and npx playwright test both pass — 535 unit, functional and
regression tests, and 142 end-to-end across both themes, axe included. New
files: tests/unit/sprint.test.ts, tests/functional/sprint-view.test.ts,
tests/e2e/sprint.spec.ts, plus the runs a sprint to its limit, and offers an
untimed option
journey in tests/e2e/pending.spec.ts.

Closes #8

🤖 Generated with Claude Code

A sprint is a timed run across every key unlocked so far, folded into one
synthetic lesson by the new src/drill/sprint.ts, so the text generator is
handed a sprint the same way it is handed a rung. It carries no lesson id, so
it earns experience and no stars, and the result card names what it was
outright rather than leaving it to be inferred from "none, this was not a
lesson".

The reason this change exists is regression 3, the nastiest failure the
prototype shipped. An abandoned sprint left its interval running; the interval
then acted on whichever drill was current, and because an untimed drill carried
a limit of zero, "elapsed is at least the limit" was true on the very first
keystroke. Every drill after that died instantly: a learner would abandon a
sprint, go back to a lesson, and watch it end before they had typed anything.

Half of that was already fixed in limits.ts, where NO_LIMIT is a real state.
The other half is fixed structurally here. startSprintTimer captures the one
Drill it is for and never looks a drill up again, and it demands a currentDrill
reader which it compares with that captured object by identity on every tick,
stopping itself the instant they differ. The binding is a required argument
rather than a convention, so there is no way to build a timer that is not bound
to a drill, and a teardown that is missed costs one tick of nothing rather than
every drill that follows. The three regression todos are filled in, including
one that fires a deliberately uncancelled tick at a later drill and proves it
does nothing.

The repeating callback is injected, so nothing under src/drill reaches for
setInterval and every test drives the tick by hand. The default schedule lives
in the drill view, which is where the brief says a wall clock belongs.

WCAG 2.2.1 applies and the exemption is not claimed. The duration control is
built from SPRINT_DURATIONS_MS so the options cannot drift from the durations,
the current setting is stated in visible text rather than only shown as a
selected option, and the untimed option genuinely never expires -- proved at a
century of elapsed time in unit and functional tests and at an hour of faked
wall clock in a browser. The countdown is a role="timer", whose implicit live
setting is off, so the number is never announced per second; time running out
is announced instead at three polite milestones, and the count of those
announcements over a whole sprint is asserted.

parseSprintLimit refuses an empty control rather than letting Number('') become
zero and read as a deliberate choice of untimed. Absent, zero and malformed are
three different things.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@RCheesley
RCheesley merged commit 185fabf into main Sep 23, 2026
3 checks passed
@RCheesley
RCheesley deleted the sprint-mode branch September 23, 2026 08:55
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.

Add sprint mode with an adjustable and disableable limit

1 participant