Tomo
A patient practice partner for Japanese: quiet daily reviews, an FSRS scheduler that knows when to ask again, and an AI teacher that never shows its face. Here is how it is built, from the scheduler core to the offline sync.
Role: Solo full-stack + design
Shipped
a calm, Japanese-aware SRS that folds FSRS v5 scheduling and an AI teacher into one tool.
- Year
- 2026
- Team
- Solo, with Claude Code
- Scope
- FSRS scheduler, AI teacher, web app, design system
- Stack
-
- Frontend
-
- Next.js 15
- React
- TypeScript
- TanStack Query
- Zustand
- Backend
-
- Express 5
- Node
- Zod contracts
- Data
-
- Supabase / Postgres
- Upstash Redis
- RLS + RPCs
- Intelligence
-
- OpenAI
- ts-tomo (FSRS v5)
- Tooling
-
- Bun monorepo
- ESLint
Overview
Tomo (友, "friend") is a spaced-repetition app for serious Japanese learners: a Japanese-aware FSRS v5 scheduler paired with an AI that writes example sentences, mnemonics, and weak-spot diagnoses. One quiet tool, so a learner never has to leave it to study well.
My role
I designed and built Tomo end to end, working with Claude Code throughout: the FSRS scheduling layer, an Express and Supabase backend with row-level security and RPCs, a Next.js 15 frontend, the card-stack design system, and the keyboard and screen-reader accessibility the review loop runs on. This is the technical record of how it fits together.
The problem
Three tools, one daily practice, and the friction that kills it
Serious learners stitch their practice across three categories of tool, because no single one does the whole job. Anki brings a modern algorithm with none of the warmth. A chatbot brings intelligence but no memory of you. The friendlier apps split the difference and compromise somewhere. The real cost is not any one gap. It is the daily friction of leaving one tool for another, until the practice quietly dies. Tomo's engineering answer is to fold the scheduler, the AI, and the design into a single system, so context switching disappears.
The integration
One tool, three answers
Modern FSRS, Japanese-aware
An FSRS v5 scheduler for Japanese vocabulary, kanji, and grammar, so reviews land at the right moment without hand-tuning.
Desired-retention scheduling, full state and history persisted per card, with offline review buffered and replayed on reconnect.
The Problem It Answers
Anki had the algorithm but none of the warmth. Tomo keeps the modern scheduler and drops the hostility.
An AI that stays invisible
It writes example sentences tuned to your interests, mnemonics that stick, and plain-language weak-spot diagnoses. No sparkle icons, no chatbot.
Generation is gated by auth and cost-control rate limits, validated as structured output, and surfaced through card quality, never through "AI-powered" chrome.
The Problem It Answers
A chatbot could write a sentence but never schedule it. Tomo folds that intelligence into the cards themselves.
One calm room for all of it
Decks, creation, review, analytics, and weak-spot repair live under one considered system, so nothing pulls you out to a second tool.
A card-stack identity, warm-paper calm, and a keyboard-canonical review loop hold the whole practice in a single place.
The Problem It Answers
Warm apps compromised on depth or craft. Tomo refuses the trade and keeps all three under one roof.
Architecture
Three deployables, one type contract
Tomo is a Bun monorepo. A Next.js 15 App Router frontend, an Express 5 REST API, and a shared packages/shared-types workspace whose Zod schemas define the shape of the data that passes between them. The frontend never touches the database; it calls the API, which is the only process holding the Supabase service-role key. Postgres is the system of record, Upstash Redis is the cache and rate-limit substrate, and OpenAI is called server-side only.
The system
One API holds every key
Select any layer to read its role. The Express API is the only process holding the service-role key; the Next.js client talks to it over Zod-validated JSON and never reaches the database directly.
Engineering decisions
- 01
From three schedulers to one
I first split scheduling across comprehension, production, and listening. It multiplied state without improving recall.
FixI collapsed it to one FSRS scheduler per card at request_retention = 0.88; reverse-direction (production) recall became a render-time preference, not a scheduling dimension.
- 02
Shared decks, private progress
Premade decks (JLPT N5–N1) are shared source content, but a learner's reviews must never write back to them.
FixA copy-on-adopt RPC clones source cards into the learner's own deck, and a XOR constraint plus RLS keep source and progress in separate rows.
- 03
Open-ended AI is slow, costly, and risky
Unbounded model calls can stall a request, run up cost, or return malformed cards.
FixPer-user rate limits, a daily quota, idempotency keys, a Redis circuit breaker, and strict structured-output validation before anything reaches a card.
The scheduler
One FSRS v5 scheduler, tuned and trusted
Every card carries live FSRS state: stability, difficulty, the due date, reps, and lapses. On a review, Tomo reads that state, runs ts-fsrs in JavaScript to compute the next schedule, and persists the result plus an immutable log row in a single Postgres transaction. There is exactly one function that writes FSRS fields, so scheduling can never be corrupted from two directions at once.
The scheduler
Why a higher target means more reviews
FSRS schedules each card by a desired-retention target. Drag it: aim higher and Tomo reviews you sooner; relax it and intervals stretch out. The four ratings then branch the next date from here. The curve is the real ts-fsrs retrievability function.
At 88% retention, Tomo waits about 17 days before the next review.
If you rate it now
- Again~10 min
- Hard
- Good
- Easy
The AI pipeline
Intelligence that runs a gauntlet before it becomes a card
Tomo's AI has no chrome: no sparkle icon, no "AI mode" toggle, no "Generated by GPT" footer. A card simply feels smart; a sentence simply fits your interests. Underneath, every generation request passes through auth, two rate limiters, an idempotency claim, and a circuit breaker before it reaches OpenAI, and its structured output is validated by Zod before it can become a card. The one sanctioned affordance is a quiet, closed-by-default "how this was made" for the curious learner.
The AI pipeline
A generation request, end to end
Run it once to watch each gate fire in sequence, or select any stage to read what it guards against before the request ever reaches the model.
Card generation
Build the back of a card
Pick a word Tomo knows and an interest. It writes the reading, the meaning, an example tuned to you, and a mnemonic. (The real product also takes free text and generates from scratch, through the pipeline above.)
Word
Interest
Pick a word and an interest, then generate.
- Reading
- Meaning
- Example
- Mnemonic
How this was made
Data & access
Shared source content, private progress, enforced in the schema
The hard constraint is that JLPT decks are shared source content while a learner's reviews are private and must never write back. The schema enforces it: a card belongs to a user deck or a premade deck, never both (a XOR check), FSRS state can only be written when the card has an owner, and row-level security scopes every read and write to auth.uid(). The API speaks to Postgres as service_role and wraps multi-step writes in SECURITY DEFINER RPCs, so RLS is defense-in-depth behind the real authorization at the API boundary.
The data model
Trace how the core tables connect
Select any table to light up its relationships. Shared JLPT decks stay read-only; a learner's progress lives in their own copy, scoped to them.
- Who owns what: Each learner has their own decks, and every deck holds its own cards.
- Ready-made decks: Adding a ready-made JLPT deck copies its cards into your library, so studying never changes the shared original.
- Review history: Each time you rate a card, the app keeps a permanent record of that review.
- Weak spots: Cards you keep getting wrong are flagged as weak spots, which the AI later explains and helps you fix.
Offline-first sync
A review on a bad connection still counts
Every rating in the loop below is a write. Online, it posts straight through. Offline, it buffers in localStorage with a per-entry idempotency key, then drains as a single batch when the connection returns. A shared batch key, reused across retries, lets the server replay that batch without ever counting a review twice. After five failed attempts the queue stops auto-retrying and surfaces a Retry-now / Discard banner instead of silently churning. Run the loop: the ratings you make here are exactly the writes that get buffered.
The daily loop
Review a card, the way the app does
Reveal with space or tap, then rate. Keys 1 to 4 map to Again, Hard, Good, and Easy. The interval under each is what FSRS would schedule next. Keyboard-first, exactly like the real session.
3 cards waiting, let's begin.
1 / 3
Press space or tap the card to reveal.
Clear for now. Nicely done.
3 reviewed. Tomo will line up the next batch when they are due.
Cost & limits
Bounding a solo budget against an open internet
A single-developer product with a paid model behind it has to assume abuse and protect its own bill. Tomo layers sliding-window rate limits per user, a daily AI quota, an idempotency replay store, a cached due-list, and database timeouts, and it fails open when the limiter substrate itself goes down, trading a brief abuse window for staying available.
| Limiter | Budget | Guards against |
|---|---|---|
| AI generate | 20 / min | burst control per user |
| AI daily | 200 / 24h | cost ceiling per user |
| Single submit | 60 / min | review-log spam |
| Batch sync | 5 / 5 min | offline-buffer flush |
| Default floor | 240 / min | scripted-abuse backstop |
- Idempotency
- A per-user replay store (24h TTL) means an offline retry returns the original response instead of re-running the side effects.
- Due cache
- The due-card list is cached in Redis and invalidated fire-and-forget after a write, so a review never blocks on the cache.
- DB safety
- The service-role connection runs with statement_timeout 10s and lock_timeout 2s, so a pathological query cannot pin the database.
- Fail open
- If the rate-limit substrate itself is down, requests pass rather than 503, trading a brief abuse window for availability.
Accessibility engineering
Accessible is not a setting; it is the product
Accessibility in Tomo is never a gateable feature. WCAG AA is the floor, AAA the goal on long-form reading. The review loop above is the clearest case: it is fully operable from the keyboard, it announces Japanese correctly through semantic <ruby> and lang="ja", and its rating scale never relies on colour alone. Switch the colour-vision simulation below: the colour-only row collapses while Tomo's scale holds on position, label, and icon.
- 01
Keyboard is canonical
The review loop runs on the keyboard first: space or enter reveals, 1–4 rate Again / Hard / Good / Easy, escape pauses. The mouse is optional.
- 02
Japanese announces correctly
Japanese text carries lang="ja", and furigana renders as semantic <ruby> / <rt>, so a screen reader pronounces readings instead of skipping decorative glyphs.
- 03
Colour is never the only signal
The Again / Hard / Good / Easy scale fails red-green colour blindness if it leans on hue, so every rating also carries position, a text label, and a distinct icon.
- 04
Calm by default, motion optional
Reduced motion is honored end to end, contrast targets AA on chrome and AAA on long-form reading, and CJK font fallbacks keep Japanese legible everywhere.
Colour is never the only signal
The rating scale, through colour-blind eyes
Again, Hard, Good, and Easy commonly map to red, orange, yellow, green, which collapses under red-green colour blindness. Switch the simulation: the colour-only row turns ambiguous, while Tomo's scale holds on position, label, and icon.
Colour only
Tomo's scale
Normal vision. Both rows read clearly.
Outcome
One tool a learner never has to leave
Tomo ships the thing the genre keeps splitting apart: modern FSRS scheduling, an AI that teaches without performing, and a calm, accessible design, all under one roof and one type contract. A learner can go from a cold start to comfortable without stitching together Anki, a chatbot, and a separate app. That integration, held together by the architecture above, not any single feature, is the work I am proudest of.
Reflection
- 01
Integration was the whole point
The hardest, most valuable work was not any single feature; it was making FSRS, an AI teacher, and a considered design feel like one tool, so the daily-practice-killer (context switching) disappears.
- 02
Invisible AI is a discipline
Refusing the sparkle icon and the "AI mode" toggle meant the intelligence had to earn its place in the output. Sharper cards, not louder branding, became the bar.
- 03
Solo, full-stack, end to end
Owning the schema, the API, the frontend, and the design system in one product, built with Claude Code as my pair, is the clearest proof I can offer of building software that is considered in how it works and how it feels.