Back to all projects

Interview Drill

Next.js · React · TypeScript · Tailwind CSS · Zustand · ts-fsrs · Claude API · Vitest

This project exists because the one it replaced broke in the worst way a portfolio demo can. The rebuild is organized around a single constraint: nothing the app needs to work should be something I do not control.

Live demoCode

Problem

This portfolio used to link a joke generator I wrote in 2021. In August 2026 I opened it and found it stuck on "Loading joke…" forever. Its only data source, api.icndb.com, had been abandoned, and the domain had since been re-registered. A request for a joke now redirected to an online gambling site. Anyone clicking through from my portfolio saw a hung page whose network tab pointed at a casino.

The code was not the problem. The code worked. The problem was that the app's entire reason for existing was one call to somebody else's server, so the app could not outlive it. That is the failure I wanted the replacement to be structurally incapable of repeating.

Constraints

No key, no third-party host, no required environment variable. The acceptance test is literal: delete every .env file, load the app, and a full session still has to be playable start to finish.

The answers ship under my name. A vague or subtly wrong answer is worse than no question at all: one bad answer discounts the whole bank for the reviewer who spots it.

Question ids are permanent. Scheduling state keys off them, so renaming an id orphans that question's entire review history.

It is a portfolio demo, so there is nothing to operate: no account, no database, and no ongoing cost or maintenance surface beyond the deployment itself.

Approach

Pick a concept where an API is an ingredient rather than the product. A quote generator or a journal-prompt generator is the same "fetch one random string" shape as the joke generator and would have inherited the same fragility. Spaced repetition is not: the value is in the scheduling and the history, and the content can ship with the build.

I did look for a question API first. OpenTDB needs no key and is CORS-open, but its computing category is retro trivia (a sample pull asked about a 1983 home computer and the Utah Teapot), and it rate-limited to a 429 on a second request within a second. QuizAPI.io requires a server-side key and leans DevOps. Both would have reintroduced exactly the dependency that killed the last project, so the bank is local and curated.

Use FSRS through the ts-fsrs library rather than hand-rolling SM-2. Writing the algorithm myself would have been the flashier move; choosing a well-maintained, zero-dependency MIT library and wrapping it cleanly is the better engineering call. The judgment worth showing is knowing which problem is already solved. What is hand-written and tested is everything around it: the persisted card shape, the session queue, the statistics.

Implementation

Next.js 16 on the App Router with React 19 and TypeScript in strict mode, styled with Tailwind v4 and shadcn/ui primitives, review state in Zustand.

The bank is imported statically in lib/bank.ts, so it is part of the build, and served by the app's own route handler at /api/questions with force-static rendering. There is no filesystem read at request time and no external host anywhere in the path. That module also validates the bank at build time: a duplicate id, an unknown topic, or an empty prompt fails the build rather than shipping.

lib/scheduler.ts is the only module in the app that knows how scheduling works. It speaks the persisted card shape, ISO strings in and ISO strings out, and never reads the clock; every function takes `now` as an argument. That one rule is what makes the scheduling logic deterministic and testable, and it is why no component anywhere does date math.

lib/queue.ts assembles a session: due work first, then new work in whatever room is left under the session cap. Burying reviews you already owe under fresh material is how a spaced-repetition queue spirals. Ordering is fully deterministic, down to a tie-break on id so equal due dates cannot reorder between renders.

Statistics are computed in local calendar days rather than rolling 24-hour windows, so a streak survives studying at 11pm and again at 8am, and anything overdue lands in today's column instead of a past one.

The signature control is the rating bar. Each of the four grades shows the interval it will actually schedule (Again 1m, Hard 6m, Good 10m, Easy 8d), computed by the real scheduler against the real card. The whole premise of the app is that your rating changes when the question comes back, so that consequence belongs on the button that causes it, not in a stats page afterward.

Accessibility is built into the same surface: Space reveals, 1 through 4 rate, focus moves to the answer when it appears so a screen reader lands on the new content, and each rating is announced politely. Grade is never carried by color alone. Every one also has a label, a keyboard digit, and a fixed position in the bar.

Challenges

Persisted state and server rendering disagree by default. The store reads localStorage, the server renders with none of it, and reading it during the first render throws a hydration mismatch. The store hydrates explicitly after mount instead, and every component that shows review data waits on a hydration flag read through useSyncExternalStore, so the server snapshot stays explicitly empty rather than being mirrored into state and hoped for.

A live clock made the interface twitch. The rating buttons preview real intervals, so reading `now` on every render meant the numbers shifted while you were looking at them. The clock is now frozen per card and only re-read when the card changes; a session is short enough that the drift does not matter.

Prompts needed markdown, not just answers. Questions carry inline code like `setTimeout(fn, 0)`, which first shipped as literal backticks on screen. Rendering the prompt as markdown put a paragraph element inside a heading, which is invalid HTML, so the inline renderer unwraps it.

Removing what looked like a build-time-only dependency broke the build. Uninstalling the shadcn package produced a blank page and an unresolvable import: the current CLI also ships a stylesheet that the app's global CSS imports, which makes it a genuine runtime dependency rather than just a scaffolding tool.

Verifying sixty answers turned out to be a different kind of work than writing the code, and the slower half of the project.

Solution

Answers were checked in three passes of honestly different strength. Every behavioral JavaScript claim was executed in Node and compared against what the answer asserts: 55 assertions across event-loop ordering, coercion, promise combinator semantics, temporal dead zone behavior, prototype lookup, and module semantics. Factual claims were checked against primary sources: web.dev for Core Web Vitals, the W3C for WCAG contrast ratios, MDN for CORS and redirect semantics, react.dev for React 19 signatures. Four answers were corrected as a result, including a contrast threshold that was off by a fraction of a pixel and a cookie default that was attributed to browsers generally when it belongs specifically to Chromium.

The third pass is the one worth naming: the claims that are not verifiable. "Always use strict equality," "prefer an HttpOnly cookie over localStorage." These are defensible positions, not facts, and they are written as recommendations rather than stated as rules.

The edges that could dead-end a user are handled explicitly rather than left to fail quietly. A saved session referencing a question the bank no longer has says so and confirms the review history is intact, instead of rendering an empty card. Deselecting every topic in the filter means all topics rather than none, so there is no way to filter yourself into an empty deck by accident.

Outcome

The portfolio no longer links a hung page pointing at a squatted domain, and the thing that replaced it cannot fail that way: 60 questions across JavaScript, React, CSS, HTML and accessibility, and the web platform, all shipping with the build. Delete every environment file and the app still runs a full session end to end.

Scheduler, queue and statistics carry 62 unit tests between them, covering every rating, lapse behavior, due-date boundaries, session caps, streak gaps, and the JSON round trip that browser storage requires. They are the most reviewable code in the project, which is deliberate.

There is an AI question-generation route in the repository, built and tested, and constructed so that it can never become the only path to a question. With no key configured it reports itself unavailable, the panel that would offer it does not render at all, and every question comes from the bundled bank. On or off, a full session never needs a key or a third-party host. That is the one property the project exists to have, and the failure it refuses to be able to repeat.

It also declines to run in production with a key but no rate limiter, on the reasoning that a spend guard which fails open is worse than none at all: it reads as protection while billing without a ceiling. Two limits sit in front of any model call, one per visitor and one global daily cap, because a per-visitor limit on its own bounds nothing when the caller can change address. The generation modules and the route bring the suite to 148 tests, with the model call mocked at the module boundary so refusals, rate-limit rejections and the fail-closed path can all be exercised deterministically.

What is not done is written down rather than implied. The bank is worth growing, and the constraint on that is not code. It is that every answer has to be good enough to trust in a real interview.

What I Learned

An architectural risk does not show up in a code review. Nothing about the old project's code was wrong, and no amount of reading it would have predicted that its API host would be sold to a casino. The question a review cannot ask is what this software depends on to work at all, and whose lifespan that is.

Purity is a testing strategy, not a stylistic preference. The single rule that scheduling code never reads the clock is what let the intervals, the boundaries, and the streak arithmetic be tested exhaustively without mocking time. I also turned off the library's interval randomization for the same reason: reproducible numbers matter more here than the clumping it prevents.

The content was the hard part, and treating it that way was the right call. Anyone can render a question bank; the work was making sixty answers correct enough to say out loud in an interview, and being explicit about which claims I executed, which I checked against a primary source, and which are judgment calls I am not going to pretend are facts.