This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
A deck I open mid-interview: /interviewee is organised by job interview — pick one, and it shows the topics for that round; pick a topic, and it opens the bullet-point answers with the detail tucked behind a disclosure. The whole thing is driven by one data shape, so I can paste in a fresh batch of prep notes and have the pages just work. It’s an admin-only tool on the dashboard — the content is mine to rehearse, not to publish. This is the write-up on why it’s built the way it is.
The thing I wanted was not a page, it was a format. The deck is organised by job interview: an Interview owns a list of IntervieweeTopics, each an id, a title, a one-line summary, a list of questions with headline points and optional detail, and the ids of related topics in the same interview. The schemas live in src/lib/interviewee/types.ts and are the trust boundary: a test parses every interview against them, so a malformed paste fails a test rather than a page.
That means the workflow is: write notes in the markdown template, hand them over to be transformed into interviews.data.ts, and the deck, the interview pages, the topic pages, the related cards, and the keyboard shortcuts all come out the other side for free. The format is the feature; the pages are just a view of it. Related ids resolve within an interview, so the same topic slug can mean different things in two different rounds.
The prep notes are personal — my actual answers for a specific company’s round — so the whole feature is admin-only, gated exactly like the to-do list. The route is in the session-protected prefixes (a signed-out visitor lands on login with a returnTo), and every page then re-checks the admin allowlist and notFound()s anyone else — a 404 rather than a 403, so the page’s existence isn’t confirmed. Because the pages read the session, they’re force-dynamic and dropped from the public route list, so nothing about them reaches the sitemap. The write-up you are reading stays public; the deck itself does not.
Mid-interview I don’t want to be aiming a cursor. So the deck is driven from the keyboard two ways, matching two moods:
Esc drops back to the deck and the related cards keep their own numbers, so I can hop sideways without going back first.All of it is guarded against firing while I’m typing, and every card carries an aria-keyshortcuts so the shortcut is announced, not just wired.
Once I’ve rehearsed a topic I want it out of the way without losing it. Marking a topic answered drops it from the “To review” grid into an “Answered” one below — still a card, still openable, with a one-tap way to pull it back into rotation. The state is a rehearsal preference, not data, so it lives in localStorage via the same useSyncExternalStore pattern the rest of the site uses for device-local settings, rather than a datastore this doesn’t need.
Update — September 17, 2026
The closing block below used to list “search or a tag filter” as a someday. The first time an interview grew past a screen I wanted it immediately, and it turned out to be small.
Searching “performance” and only matching a topic called Performance would miss the point a paragraph deep in the refactor topic. So the searchable text for a topic is everything: the interview it sits in, the topic’s title and summary, and every question, point, and expandable detail — joined once and lowercased, so a match is a plain substring check and every whitespace-separated term has to hit (AND, so terms narrow).
[interview.title, interview.summary,
topic.title, topic.summary,
...topic.entries.flatMap(e => [e.question, ...e.points, ...(e.details ?? [])])
].join(" ").toLowerCase()The deck already grabs number and arrow keys for card nav, which would fight a search box. It didn’t, because that handler already stood down whenever focus was in a field — so digits and arrows type into the box, and Enter opens the top result. The search is a pure function I could unit-test on its own; the deck just renders the interviews when the box is empty and the hits when it isn’t.
The first cut sorted by document order, so a topic called Performance could sit below one that merely mentions the word in a bullet. The fix is to score by how shallow the match is — the shallowest tier whose text contains every term wins, and results sort by that rank.
rank 0: title rank 1: + summary + interview rank 2: + questions rank 3: + points & details // a match only this deep sinks
“Reviewed” used to be a boolean, which is a lie — I forget things. So the store keeps a timestamp per topic now, and a topic reviewed more than three days ago resurfaces on its interview page as “Due to revisit,” sorted to the top of the reviewed list. A small spaced-repetition nudge, no scheduler.
The obvious next step for the reviewed count was “sync it to my account,” but that means a table and an endpoint in the separate backend repo — more than the need justified. The lighter answer: export the map to a string, paste it on the other machine, merge. It moves between my devices without a server, and a real account sync can come later if I actually want it.