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.