Case Study
KeyFlow
A typing test where every key has its own sound. Per-key mechanical audio via Web Audio API, four test modes, a statistical anti-cheat engine, and a fully offline PWA — built to make typing feel physical.
Offline PWA & Audio First
KeyFlow works 100% offline. Serwist pre-caches all static assets including word pools and audio sprites at install time. Even keyboard sounds work offline — the audio buffer is decoded into memory from the pre-cached file.
Core Feature Overview
🎹
Per-Key Audio Sprites
A single OGG audio file is loaded and decoded into a Web Audio API buffer. Individual sounds are sliced from specific time offsets — eliminating any per-keystroke latency.
Complete Project Structure
root
keythm-main/
app/
layout.tsx— Root layout with providers, font loading
page.tsx— Main test screen
manifest.ts— PWA manifest — standalone display mode
sw.ts— Serwist service worker config
globals.css— CSS custom properties for 10+ themes
components/
typing/
typing-test.tsx— Top-level typing test orchestrator
word-item.tsx— Per-word render with char-level coloring
test-controls/— Mode selector, time/word option UI
results/
results-screen.tsx— Post-test stats breakdown
stats-display.tsx— WPM, accuracy, raw, consistency cards
results-actions.tsx— Restart, share, leaderboard buttons
settings/
settings-panel.tsx— Drawer for font/theme/sound preferences
settings-provider.tsx— Context for global settings state
font-picker.tsx— Google Font selection component
theme-picker.tsx— Theme color palette selection
layout/
app-chrome.tsx— Navigation bar and layout shell
hooks/
use-typing-test.ts— 900-line core test state machine
use-media-query.ts— Responsive breakpoint hook
lib/
wpm-count.ts— Char-by-char WPM computation engine
validate-result.ts— Anti-cheat validation pipeline
test-storage.ts— Validated localStorage read/write helpers
words.ts— Word generator (easy/hard difficulty pools)
quotes.ts— Curated quote pool by length category
languages.ts— Multilingual word pool fetcher
personal-best.ts— Local PB tracking per mode/option
keyboard-layouts.ts— Key highlight map for keyboard display
audio-preloader.ts— Web Audio API OGG sprite preloader
db/
schema.ts— Drizzle schema (visits table)
index.ts— Turso SQLite database client
visits.ts— Visitor analytics queries
data/— Word lists, quote JSON files
next.config.ts— Serwist PWA plugin config
drizzle.config.ts— Drizzle Kit config for Turso
Test Lifecycle
1
Audio Sprite Preloading
On app load, `audio-preloader.ts` fetches the single OGG keyboard sound file and decodes it into a `Web Audio API` `AudioBuffer` stored in memory. Time offsets for each key type (regular press, space, backspace) are pre-mapped.
2
Word Pool Initialization
`buildWords()` in `use-typing-test.ts` checks the `wordPoolRef` cache. If the requested difficulty tier (easy/hard) is already loaded, it slices from the in-memory pool. Otherwise, `fetchLanguageWords(isHard)` fetches the word list — falling back to a local random generator if the fetch fails.
3
Test Start & Timer
On first keystroke, `setStarted(true)` and `startTime = Date.now()` are committed. A `setInterval` runs every 1000ms, decrementing `timeLeft` in time-mode and appending a `WpmSnapshot` to `wpmHistory` for the live graph.
4
Real-Time WPM Computation
WPM is computed inline on every re-render triggered by a keystroke — no `useEffect` needed. The formula is `Math.round(wpmNumerator / 5 / elapsedMinutes)` where `wpmNumerator` = correct word chars + correct spaces.
5
Anti-Cheat Validation at Finish
On test completion, `validateResult(stats)` runs 8 ordered checks. Results are only saved to personal-best storage if `valid: true` is returned. The UI shows a specific reason badge if the result is flagged.
6
Results Screen & PB Storage
The results screen renders WPM, raw WPM, accuracy, consistency, and a per-second WPM line chart from `wpmHistory`. If the result beats the stored personal best for that mode/option combination, it updates localStorage.
WPM Computation Engine
The WPM counter handles 5 distinct cases per character per word — correct, incorrect, extra (over-typed), missed (under-typed), and partial last-word during timed tests:
typescript
// lib/wpm-count.ts
export function countWpm({ targetWords, wordInputs, typed, wordIndex, mode, final }: CountParams): WpmCounts {
const inputWords = [...wordInputs.slice(0, wordIndex), typed];
let correctWordChars = 0, allCorrectChars = 0;
let incorrectChars = 0, extraChars = 0, missedChars = 0, correctSpaces = 0;
const isTimedTest = mode === 'time' || mode === 'zen';
const shouldCountPartialLastWord = !final || (final && isTimedTest);
for (let i = 0; i < inputWords.length; i++) {
const inputWord = inputWords[i];
const targetWord = targetWords[i];
if (targetWord === undefined) break;
if (inputWord === targetWord) {
// Entire word correct
correctWordChars += targetWord.length;
allCorrectChars += targetWord.length;
if (i < inputWords.length - 1) correctSpaces++;
} else if (inputWord.length >= targetWord.length) {
// Over-typed: count extras beyond target length
for (let c = 0; c < inputWord.length; c++) {
if (c < targetWord.length) {
inputWord[c] === targetWord[c] ? allCorrectChars++ : incorrectChars++;
} else {
extraChars++;
}
}
} else {
// Under-typed: count missed characters
const toAdd = { correct: 0, incorrect: 0, missed: 0 };
for (let c = 0; c < targetWord.length; c++) {
if (c < inputWord.length) {
inputWord[c] === targetWord[c] ? toAdd.correct++ : toAdd.incorrect++;
} else {
toAdd.missed++;
}
}
allCorrectChars += toAdd.correct;
incorrectChars += toAdd.incorrect;
if (i === inputWords.length - 1 && shouldCountPartialLastWord) {
if (toAdd.incorrect === 0) correctWordChars += toAdd.correct;
} else {
missedChars += toAdd.missed;
}
}
}
return { correctWordChars, correctSpaces, allCorrectChars, incorrectChars, extraChars, missedChars };
}
export function wpmNumeratorFromCounts(c: WpmCounts): number {
return c.correctWordChars + c.correctSpaces;
}
export function accuracyFromCounts(c: WpmCounts): number {
const denom = c.allCorrectChars + c.incorrectChars;
return denom <= 0 ? 100 : Math.round((c.allCorrectChars / denom) * 100);
}Statistical Anti-Cheat Engine
Eight ordered checks detect everything from AFK tab-away to bot injection:
typescript
// lib/validate-result.ts
const MAX_WPM = 300; // Human world record ceiling with headroom
const MAX_RAW_WPM = 350;
const MAX_CHARS_PER_SEC = 30; // 300 WPM ≈ 25 chars/sec
const MAX_BURST_WPM = 600; // Single-second spike → macro injection
const MIN_ELAPSED_SECS = 2;
const MAX_CONSECUTIVE_ZEROS = 3; // AFK detection threshold
const MIN_HISTORY_FOR_BOT_CHECKS = 4;
const MIN_WPM_FOR_BOT_CHECKS = 80;
export function validateResult(stats: ResultStats): ValidationResult {
const { wpm, raw, accuracy, correctChars, incorrectChars, extraChars, elapsedSeconds, wpmHistory } = stats;
const keystrokes = correctChars + incorrectChars + extraChars;
if (keystrokes === 0) return { valid: false, reason: 'no_keystrokes' };
if (!Number.isFinite(wpm) || !Number.isFinite(raw)) return { valid: false, reason: 'invalid_numbers' };
if (accuracy < 0 || accuracy > 100) return { valid: false, reason: 'invalid_accuracy' };
if (elapsedSeconds <= 0) return { valid: false, reason: 'zero_time' };
if (elapsedSeconds < MIN_ELAPSED_SECS) return { valid: false, reason: 'too_short' };
if (wpm > MAX_WPM) return { valid: false, reason: 'impossible_wpm' };
if (raw > MAX_RAW_WPM) return { valid: false, reason: 'impossible_raw' };
if (keystrokes / elapsedSeconds > MAX_CHARS_PER_SEC) return { valid: false, reason: 'impossible_cps' };
if (wpmHistory.length > 0) {
// Single-second burst spike — hallmark of macro/auto-typer injection
if (wpmHistory.some(s => s.wpm > MAX_BURST_WPM))
return { valid: false, reason: 'impossible_burst' };
// AFK: >3 consecutive 0-raw seconds in the middle of the test
const inner = wpmHistory.slice(1, -1);
let consecutive = 0;
for (const snap of inner) {
if (snap.raw === 0 && ++consecutive > MAX_CONSECUTIVE_ZEROS)
return { valid: false, reason: 'afk_detected' };
else if (snap.raw > 0) consecutive = 0;
}
// Bot pattern: all per-second WPMs within 1 WPM of each other
if (wpmHistory.length >= MIN_HISTORY_FOR_BOT_CHECKS && wpm >= MIN_WPM_FOR_BOT_CHECKS) {
const values = wpmHistory.map(s => s.wpm);
if (Math.max(...values) - Math.min(...values) <= 1)
return { valid: false, reason: 'flat_wpm_history' };
// σ/μ ≤ 0.01 at high WPM — physically impossible consistency
if (stats.consistency >= 99)
return { valid: false, reason: 'perfect_consistency' };
}
}
return { valid: true };
}Serwist PWA Service Worker
typescript
// app/sw.ts — Serwist-powered offline caching
import { defaultCache } from '@serwist/next/worker';
import { type PrecacheEntry, Serwist } from 'serwist';
declare const self: WorkerGlobalScope;
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST, // Auto-injected by next.config.ts plugin
skipWaiting: true,
clientsClaim: true,
navigationPreload: false,
runtimeCaching: defaultCache, // Cache-first for fonts, images, API calls
});
serwist.addEventListeners();localStorage Settings Persistence
typescript
// lib/test-storage.ts — Validated read helpers prevent bad state on restore
export type TestMode = 'time' | 'words' | 'quote' | 'zen';
export type TimeOption = 15 | 30 | 60 | 120;
export type WordOption = 10 | 25 | 50 | 100;
const VALID_TEST_MODES: readonly TestMode[] = ['time', 'words', 'quote', 'zen'];
export function readStoredTestMode(): TestMode | undefined {
if (typeof window === 'undefined') return;
const raw = localStorage.getItem('tc-test-mode');
if (!(raw && (VALID_TEST_MODES as readonly string[]).includes(raw))) return;
return raw as TestMode;
}
export function readStoredTimeOption(): TimeOption | undefined {
if (typeof window === 'undefined') return;
const raw = localStorage.getItem('tc-time-option');
const n = Number(raw);
if (!(Number.isFinite(n) && [15, 30, 60, 120].includes(n))) return;
return n as TimeOption;
}Tech Choices
KeyFlow uses **Drizzle ORM** with **Turso** (libSQL/SQLite at edge) to track visit counts — the only server-side data. Everything else (personal bests, preferences, test history) lives in localStorage, making the app fully functional offline with zero server dependency.