A small, playful reading-challenge PWA (in Danish) that motivates a 7–9-year-old to read. A big progress ring and a chosen mascot — a cat or a dog — get happier as the minutes add up, backed by a full reading log. Everything is stored on the device; there is no account and no server.
Live: https://laesemakker.dk/
Rebuilt from a Claude Design prototype (
Sommerlæsning.dc.html) into a real, deployable app. The original design + product spec lives indocs/spec/.
- Fremgang (Progress) — a circular progress ring with the live percentage, the evolving mascot (8 happiness stages: sad → party hat → bowtie → crown), an encouraging caption, and a deadline countdown chip. Handles three challenge states: none / ongoing / completed.
- Læselog (Reading log) — add / edit / delete readings (title, author, date, minutes), newest first, with a "continue a book" quick-pick row so the kid only types the minutes.
- Indstillinger (Settings) — pick the mascot, name it, set the goal (300 / 450 / 600 or custom) and a deadline. A parental lock (a small arithmetic gate) keeps the kid from changing things.
| Framework | Next.js 16 (App Router), output: 'export' (fully static) |
| UI | React 19 + TypeScript + Tailwind v4 (CSS-first @theme) |
| State | localStorage only — no backend, no API |
| PWA | web manifest + a hand-rolled service worker (offline-capable, installable) |
| Fonts | Baloo 2 (display) + Nunito (body) via next/font/google |
| Hosting | GitHub Pages (project site), deployed by GitHub Actions on push to main |
| Tests | Playwright e2e against the real static export, run in CI before deploy |
npm install
npm run dev # http://localhost:3000/npm run build # static export → ./out (postbuild injects the SW precache manifest)
npm run serve # serve ./out at http://localhost:4399/
npm run test:e2e # Playwright behavioural suite (builds nothing — run `build` first)
npm run lintapp/ layout (fonts, metadata), page (mounts the app), globals.css (@theme + keyframes),
manifest.ts, icon.svg / apple-icon.png
components/ AppShell, BottomNav, MascotFace (parametric renderer), ProgressScreen,
LogScreen + EntryForm + QuickPickRow, SettingsScreen + UnlockModal
lib/ store.tsx (reducer + derived values), storage.ts (localStorage + migration),
copy.ts (typed accessor), joy.ts (progress math), types.ts, config.ts (basePath)
copy/da.json ALL user-facing Danish text (i18n-ready — see below)
public/ sw.js, .nojekyll, icons/
scripts/ gen-icons.mjs, serve-out.mjs, inject-sw-assets.mjs (postbuild)
e2e/ Playwright specs
docs/spec/ the original design handoffs, prototype files, and screenshots
- All copy comes from
copy/da.jsonvialib/copy.ts— components never hardcode Danish. Adding a language is drop-in: createcopy/en.jsonwith the same shape, add it to thedictsmap, and switchLOCALE. (Date formatting usesDATE_LOCALEfrom the same module.) localStoragekeys are frozen (sommerlaesning.v1.*) — renaming any of them wipes a real kid's saved progress. A one-time migration upgrades pre-challenge data on load.- The mascot is a parametric SVG/div renderer (
MascotFace) ported near-verbatim from the prototype; cat + dog are surfaced in v1 (owl/horse/fox exist in the renderer for later). - GitHub Pages traps (already handled, do not regress): the site is served from the apex
custom domain laesemakker.dk at the root path — there is no
basePath/assetPrefix, andpublic/CNAMEmust keep containinglaesemakker.dk. Also keeppublic/.nojekyll,images: { unoptimized: true },trailingSlash: true, and the root-scoped service worker + manifeststart_url/scope.public/sw.jsandscripts/inject-sw-assets.mjshardcode an emptyBASE; if a base path is ever reintroduced, all three must change together.
Push to main → GitHub Actions builds the static export, runs the Playwright e2e suite, and
(only if green) publishes out/ to GitHub Pages. Hosting config is in
.github/workflows/deploy.yml.