Add sprint mode with an adjustable and disableable limit - #22
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A timed run across everything unlocked, for measuring rather than learning.
What a sprint is
src/drill/sprint.tsfolds every lesson the learner has reached into onesynthetic lesson, so
generateDrillTextcan be handed a sprint the same way itis 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 callbackis injected, so nothing in that directory reaches for
setInterval; thedefault 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.
startSprintTimercaptures the oneDrillit is for and never looks a drillup again. It also demands a
currentDrillreader and compares it with thatcaptured 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 intests/regression/prototype-failures.test.tsarefilled 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
clearIntervalthat never happened — and the lesson stillruns all the way through.
WCAG 2.2.1, exemption declined
SPRINT_DURATIONS_MS, so the options andthe durations cannot drift apart.
time in the unit and functional suites, and at an hour of faked wall clock in
a real browser.
in visible text under the control, not only as a selected option.
role="timer", whose implicit live setting is off, so thenumber 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.mdmoves sprint out of "Known gaps" and into the checklist.Defensive notes
parseSprintLimitrefuses an empty control rather than lettingNumber('')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 cannotleave 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 verifyandnpx playwright testboth pass — 535 unit, functional andregression 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 anuntimed option journey in
tests/e2e/pending.spec.ts.Closes #8
🤖 Generated with Claude Code