kanji-buddy

Code map

Where everything is, and the one rule that decides which directory a piece of code belongs in.

app/ routes — pages AND api, same tree page.tsx dashboard lessons/ reviews/ the two study flows levels/[level]/ browse one level radicals/ kanji/ vocabulary/ browse one type subjects/[id]/ subject detail login/ unsubscribe/ the two pages needing no account api/v1/ every route handler lib/ the logic. plain functions, no React srs.ts progression.ts scheduling and unlocking answer.ts quiz.ts grading and the review queue dashboard.ts the aggregate read auth.ts portal-session.ts unsubscribe.ts credentials nudge/ the email job web/ browser-side client and helpers components/ React only subject/ level/ lesson/ quiz/ dashboard/ browse/ nav/ db/ schema.ts + migrations/ + client.ts scripts/ one-off and scheduled tooling tests/ 19 files, run against pglite

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

FileWhat it holds
srs.tsStage arithmetic: what a correct or incorrect answer does to a stage, and when the item is next due.
progression.tsUnlocking. Passing a radical opens its kanji; passing 90% of a level's kanji opens the next level. Also bootstraps a new account.
answer.tsGrading. Damerau-Levenshtein typo tolerance for meanings, exact matching for readings, and the three-way correct/incorrect/retry verdict.
quiz.tsThe review queue as a pure transition: what a graded answer does to the running order.
dashboard.tsThe whole dashboard in two batched round trips.
mnemonic.tsParser for the inline markup in mnemonics — 2,589 subjects carry <radical>, <kanji>, <reading> and friends.
subject.tsThe 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.tsThe 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.