Case study
FlexFingers
A one-minute typing warmup for developers. You type real code or prose, it finds the keys, letter pairs and fingers that slow you down, and it tells you how ready your hands are today compared with your own normal, not with anyone else's.
- Role
- Sole developer, design to deploy
- Period
- April 2026 – present
- Stack
- Next.js 15, Supabase Postgres, Deno edge functions, Vercel
- Status
- Live · alpha · no account needed
The problem
Most typing tests measure one number, words per minute, against a global leaderboard. That is the wrong question for a developer sitting down to work. Code is typed differently from prose, a good day and a bad day differ by more than any practice session changes, and a single speed tells you nothing about which keys cost you the time.
FlexFingers asks a narrower question: are your hands warmed up right now, for the kind of text you are about to type? Answering that needs every keystroke timed, a personal baseline per kind of text, and a score that treats a freak session as noise rather than news.
What I built
A Next.js App Router application in front of a Supabase Postgres database, with the expensive statistics moved out of the request into database triggers and Deno edge functions. Three parts carry the design.
- A headless typing engine. The session is a pure state machine with no React, no DOM, no network and no clock of its own: callers inject the time. It tracks the cursor, every character's status, errors, and timing per character and per letter pair. Because it is pure, the scoring logic is unit tested without a browser.
- A typing surface that never re-renders. The target text is
split into one element per character once. During a session a keystroke only
toggles a class and moves the caret through a ref; live WPM reaches the display
ten times a second through
useSyncExternalStore. A keystroke has no business invalidating a tree of hundreds of nodes when one class name changed. - Authorization in the database. Every table has row-level security, and the web app holds no elevated credential at all. A bug in a page or a server action cannot return another user's sessions, because Postgres filters them before they leave the database.
How it works
The design is three clocks kept apart. Keystrokes run in milliseconds and never touch the network. Finishing a warmup is one server action and one insert. The statistics, streaks and the readiness score happen afterwards, in the database and in edge functions, where nobody waits for them and where a failure cannot lose a session.
The readiness score is a weighted blend of how today's speed, accuracy and
consistency compare with your own baseline for that kind of text:
0.40 · speed + 0.35 · accuracy + 0.25 · consistency, each passed
through a logistic curve of its z-score. Being exactly at your baseline scores
50, and the curve flattens past about two standard deviations, so one
exceptional session cannot pin the score at 100. Baselines are a 10% trimmed
mean over the last 30 days, recomputed nightly, and a context with fewer than
five sessions gets no score rather than a confident-looking wrong one.
See it run
No video yet. The warmup itself is the demo and needs no account: flexfingers.com/warmup. Typing needs a physical keyboard, so on a phone the page says so instead.
The hard part
For months, not one anonymous warmup was saved, and nothing said so.
Saving a session was a single insert that asked for the new row back. Under
row-level security, asking for the row back is a read, and anonymous visitors
deliberately have no read policy on sessions. Postgres therefore rejected the
whole statement with 42501 new row violates row-level security
policy, a message that names the insert and says nothing about the read.
The save was fired without waiting, so the visitor never saw an error, and the
summary screen rendered from the result already in the browser. Every signal a
person would look at said it worked.
Fixing it meant generating the id before the insert and never reading the row back. That exposed the same bug one level down: the per-key and per-letter-pair statistics were guarded by a policy whose check read the sessions table as the anonymous caller, so it could never pass either. The obvious repair, a looser policy, would have been a security hole, because policies are combined with OR and a session id travels in share links. Anyone holding a link could have written invented keystrokes into a stranger's session. Instead, a database function accepts the anonymous client id as proof of ownership and writes both tables once per session.
What it cost to find: a unit test now fails if anyone reintroduces an insert that reads back, every fire-and-forget save now has an error path, and a scheduled end-to-end test completes a real anonymous warmup against the live site and checks that the row exists.
Numbers
Only numbers that can be checked, each next to what produced it.
What I would do differently
- Preview deployments share the production database. It kept the setup simple for one developer, but a migration tried on a preview is applied to real data. A separate preview project is the first thing a second contributor would need.
- The most important test does not run in CI. The end-to-end test that proves one user cannot read another's sessions skips itself without test-account credentials, so the database policies are checked by hand and by unit tests, not on every change.
- Readiness is proven on one account. The scoring pipeline has run end to end, but a score only appears once a context has five sessions in 30 days, which so far is one context on the demo account.