Harsh Shah All projects

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

Visit site

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.

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.

keystroke clock · ms session clock · once stats clock · later key engine surface saveSession sessions row nightly refresh-baselines baselines compute-readiness readiness score
Figure 1: the three clocks. Nothing in the bottom row blocks the rows above it.

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.

191unit tests, 22 files, all passing (vitest, 16 Sep 2026)
13numbered, idempotent database migrations
5typing contexts, each with its own baseline
10 Hzlive stats update rate during a session

What I would do differently