kanji-buddy
Code map
Where everything is, and the one rule that decides which directory a piece of code belongs in.
The rule
If it can be tested without a browser, it goes in lib/. Route handlers authenticate, parse, delegate and serialise. Components render. Everything with a decision in it sits between them as a plain function whose first argument is a database handle.
That is not tidiness for its own sake. The quiz queue lived inside the component for months and had no tests at all — which is exactly how a bug that re-asked the same radical three times got shipped. Pulling it into lib/quiz.ts as a pure function is what made it testable, and the test that reproduces that bug now exists.
lib/ in detail
| File | What it holds |
|---|---|
srs.ts | Stage arithmetic: what a correct or incorrect answer does to a stage, and when the item is next due. |
progression.ts | Unlocking. Passing a radical opens its kanji; passing 90% of a level's kanji opens the next level. Also bootstraps a new account. |
answer.ts | Grading. Damerau-Levenshtein typo tolerance for meanings, exact matching for readings, and the three-way correct/incorrect/retry verdict. |
quiz.ts | The review queue as a pure transition: what a graded answer does to the running order. |
dashboard.ts | The whole dashboard in two batched round trips. |
mnemonic.ts | Parser for the inline markup in mnemonics — 2,589 subjects carry <radical>, <kanji>, <reading> and friends. |
subject.ts | The shape of a subject and small readers over it. Shared by server and browser, so both halves agree. |
http.ts envelope.ts collection.ts pagination.ts conditional.ts filters.ts respond.ts query.ts serializers.ts | The WaniKani API surface: envelopes, id-cursor paging, ETag/304, shared filters. |
Why lib/web/ is separate
lib/web/ is the only part of lib/ that assumes a browser — fetch, localStorage. The split keeps server code from importing it by accident, and it is why the types in lib/subject.ts live outside both.
app/api/v1/ — the 22 route files
Eleven WaniKani resources, most with a collection route and a [id] route, plus four that are ours: dashboard, internal/sessions, internal/nudges and unsubscribe. The internal/ prefix is doing real work — it marks the endpoints guarded by a shared secret rather than a user token.
scripts/
Each exports a function taking a database handle and has a thin main() behind a run-as-main guard, so the tooling is tested like anything else. fetch-wanikani and import-subjects loaded the content; create-user and create-token are how accounts exist at all; nudge-preview and nudge-test exercise the email job without sending to anyone; import-mnemonic-images attaches the scraped illustrations.
What is not in the repository
data/ and public/radicals/ are git-ignored. They hold WaniKani’s copyrighted content — the 9,418 subjects with their mnemonics, and the SVGs for the 19 radicals that have no Unicode character. The radical SVGs are fetched at build time by fetch:radical-svgs, which runs as part of npm run build, so a fresh clone still produces a working app without ever committing them.
Next, if the framework is the unfamiliar part: Next.js by example.