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 vacation.ts stopping and restarting the clock dailyLessons.ts the daily ceiling itemSpread.ts items by stage, then type audio.ts which recording to play 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/ empty/ the seven "nothing here" states spread/ vacation/ settings/ screen-specific pieces db/ schema.ts + migrations/ + client.ts scripts/ one-off and scheduled tooling tests/ 23 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. Also the shared SRS buckets and the trimmed subject columns the list endpoints select.
vacation.tsStopping and restarting the SRS clock. Ending a vacation shifts every timed assignment forward by the time away.
dailyLessons.tsThe daily lesson ceiling: how many the day still permits, counted as a calendar day in the account's timezone.
itemSpread.tsEvery started item grouped by SRS tier, then by exact stage, then by type — one query for the Item Spread screen.
audio.tsWhich recording of a word to play, given the five-way voice preference. Lifted out of the quiz so the only-versus-prefer split can be tested without a render.
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 34 route files

Eleven WaniKani resources, most with a collection route and a [id] route, plus the ones that are ours: dashboard, summary, review_stats, days_studied, heat_map, item_spread, lesson_allowance, vacation, study, advance, push_tokens, unsubscribe and three under internal/. That prefix is doing real work — it marks the endpoints guarded by a shared secret rather than a user token.

The app-specific ones exist for the same reason throughout: a screen needs an aggregate the resource endpoints cannot serve cheaply. Building the Item Spread from /assignments and /subjects would be a request per bucket plus a paged subject lookup, and the paging is where a mature account would quietly lose rows.

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/ is git-ignored. It holds WaniKani’s copyrighted content — the 9,418 subjects with their mnemonics — and must not be committed.

public/radicals/ is the exception, and is committed: 19 SVGs, 9.7 KB, for the radicals that have no Unicode character. It was git-ignored and refilled by fetch:radical-svgs during npm run build, which made every deploy depend on the database and WaniKani’s CDN both answering — and the script only warned when one did not, so a failed fetch produced a green build with radicals silently missing from it. Nineteen small files are cheaper to keep than to re-earn on every deploy. Same copyright footing as data/, which is why the repository is private.

Next, if the framework is the unfamiliar part: Next.js by example.