Technical case study · Full-stack product · Solo

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

Schedule

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.

Teach

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.

Hold

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.

Tomo's home screen showing the due-card count, a prominent review button, and the calm warm-paper design
Tomo's home screen — one place, one tool, everything a learner needs

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.

Interactive

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.

packages/shared-types: one Zod contract spans the wire, so the client and the API cannot drift. Select a layer to trace its role.

Engineering decisions

  1. 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.

  2. 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.

  3. 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.

Review card face-down showing a kanji word centred on a warm-paper card, with a progress counter and the reveal prompt
Card face-down — the learner recalls before revealing
Review card revealed showing furigana reading, romaji, meaning, example sentence in Japanese and English, and the rating bar with Again, Hard, Good, and Easy buttons
Card revealed — reading, meaning, example, and the rating bar
Interactive

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.

Interactive

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.

An AI-generated card back in Tomo showing reading with furigana, meaning, an interest-tuned example sentence, and a mnemonic — no AI branding visible
A generated card — the AI output is the card itself, not a separate panel
Interactive

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.

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.

Interactive

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.

Interactive

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.

Tomo's session-complete screen showing the number of cards reviewed and a calm end-of-session message
Session done — the quiet finish, no streaks, no confetti

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.

Per-user rate limits
LimiterBudgetGuards 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

Interactive

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

AgainHardGoodEasy

Tomo's scale

Again Hard Good Easy

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.

Tomo's deck library showing premade JLPT decks alongside a learner's own collection, each card displaying name, card count, and due count
The deck library — one place for every deck, ready-made and your own

Reflection

  1. 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.

  2. 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.

  3. 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.

Say hello.

Open to entry-level and internship roles. Edmonton, Alberta, remote, hybrid, or on-site, all on the table.

Portfolio Vol. 01 · 2026. Set in Schibsted Grotesk and Spline Sans Mono. Built with Astro and GSAP.

Back to top