kanji-buddy

Web app & quiz

The screens, and the review loop underneath them — including two bugs that only showed up in use.

The screens

RouteWhat it does
/Dashboard: what's due, a week's forecast, level progress, the SRS matrix, and three subject lists.
/lessonsTeach a batch of five, then quiz it.
/reviewsEverything due, until the queue empties or you leave.
/levels/[level]One level, grouped by type.
/radicals · /kanji · /vocabularyOne type across a band of levels.
/subjects/[id]Everything about one item, with notes and synonyms.
/loginPaste a token. The only way in without the portal.

The visual language is copied from WaniKani deliberately — blue radicals, pink kanji, purple vocabulary — because those colours are a memory aid, used consistently in the tiles, the item spread and the highlighted words inside mnemonics.

Item state is shown, not just stored

locked dashed outline, faded lesson solid outline, tinted review filled burned grey

One CSS custom property (--tile-color) carries the subject type; the state classes vary fill and opacity. That is why a radical looks like a radical whether it is locked or burned.

The character-less radicals

19 radicals have no Unicode character and exist only as SVGs. They are drawn with a CSS mask and currentColor, not an <img>: WaniKani’s SVGs are strokes coloured by a CSS variable, and an SVG loaded through <img> is an isolated document that cannot see the page’s variables — so it always rendered black. As a mask the shape is filled with currentColor and picks up the tile’s colour in every state. Everything routes through one SubjectGlyph component, because the first version of this used characters ?? slug and printed the English word “pope” where a radical belonged.

The quiz loop

One component, QuizView, serves both lessons and reviews. It grades, advances a queue, and reports results — but persists nothing itself, because the two callers want opposite things.

components/quiz/QuizView.tsx
onItemComplete?  fires as each item finishes    → reviews use this
onSessionEnd?    fires once, when the queue empties → lessons use this
LessonsReviews
SavesThe whole batch, at the endEach item, as it finishes
Leaving earlyDiscards the batch — you start those five againKeeps everything finished; only the card in hand is lost
WhyA half-applied batch strands two items in reviews and three in lessons. All-or-nothing keeps 'I gave up' meaning that.A session can run to a hundred items. Losing an hour to a closed tab would be unforgivable.

Neither callback is awaited before the queue moves. That is the fix for the first bug below, and it is why a caller passing neither gets a quiz that persists nothing at all — which is what extra study will need if it is ever built.

Two bugs worth recording

The same radical, asked three times. The queue advanced only after await onComplete(...) — the network call that starts an assignment. So the card sat there for a round trip with its input cleared and, because an exactly-correct answer carries no message, no feedback at all. Indistinguishable from a dropped keystroke, so you answer again. The answers were registering the whole time.

Two changes: the queue now advances synchronously, before anything is persisted; and a correct answer gets a brief green flash, since previously it produced no acknowledgement whatsoever. The scheduling rule moved to lib/quiz.ts as a pure function — it had no test coverage at all, which is how this shipped.

The previous radical’s illustration. React reuses the same <img> between lesson items, and a browser keeps painting the old picture until the new one loads — so for a moment you saw the wrong drawing under the right mnemonic. In a memory app that teaches the wrong association. Fixed by keying the element on its URL so React builds a fresh one.

The same image was also invisible at first: it was marked loading="lazy", but an unloaded image has no intrinsic height, and a zero-height element never becomes visible enough for the lazy heuristic to fetch it. Chicken and egg.

Leaving a session

A worded End session button rather than an unlabelled icon, because what leaving costs differs: for reviews it warns only when the current item is part-answered and offers to finish it first; for lessons it says the batch will be discarded and names how many. The end screen then states the outcome plainly rather than staying silent about it.

Responsive

Below 860px the navigation collapses behind a hamburger into a full-width accordion. It has to: four dropdown triggers plus an avatar want about 550px, and a phone offers 390 — measured, the document scrolled to 544px and the avatar sat off-screen. The panels inside the sheet are the same components the desktop dropdowns render, so the two cannot drift.

The dashboard’s aggregate endpoint is described in The API; why it once took 31 seconds is in Build & deploy.