Visual Dutch vocabulary trainer with SRS, eight exercise modes, offline PWA support, and optional cross-device sync via private GitHub Gist.
Built with Vite + React 18 (plain JS) + Tailwind. State lives in Zustand, sync uses TanStack Query, animations are Framer Motion, the service worker is generated by Workbox via vite-plugin-pwa.
npm install
npm run dev # http://localhost:5173
npm run build # → dist/
npm run preview
npm test # vitestThe included workflow at .github/workflows/deploy.yml builds and publishes
on every push to main using the official actions/deploy-pages@v4 action.
- Push this repo to GitHub.
- In Settings → Pages, set Source = GitHub Actions.
- The workflow infers the base path from the repo name and deploys to
https://<user>.github.io/<repo>/.
To deploy somewhere else (custom domain, project root, subfolder), set the
VITE_BASE_PATH env var when building:
VITE_BASE_PATH=/ npm run build # root deploy
VITE_BASE_PATH=/dutch/ npm run build # subfolder deployThe default in vite.config.js is /dutch-visual-mastery/. The app uses
HashRouter, so deep links survive the GitHub Pages 404 problem with no extra
configuration.
Sync is opt-in and runs entirely from the browser — no backend.
- Create a fine-grained Personal Access Token with the
gistscope (only). https://github.com/settings/tokens - Open Settings → Sync via GitHub Gist in the app, paste the token, and tap Connect GitHub.
- The app creates a private gist named
dutch-visual-mastery-state.jsonon first connect and merges it on every sync.
Token storage warning: the PAT is stored in this device's localStorage.
Anyone with access to the browser profile can read or use it. Use a token
scoped only to gist, and tap Disconnect & wipe token when finished.
Auto-sync triggers: app start, end of every session, every 5 minutes while
the tab is focused, and on the online event. There's also a manual
Sync now button.
If you don't want to use Gist, the same Settings panel offers JSON Export and Import for manual transfer.
- 8 exercise modes — Image → Dutch, Dutch → English, English → Dutch, Article (de/het), Type the word, Listening, Sentence cloze, Reverse cloze. Disable individual modes in Settings; the picker weights toward your weakest active mode.
- Leitner-box SRS — 5 boxes with intervals 10 min / 1 d / 3 d / 7 d / 21 d. Three consecutive easy correct in box 5 → mastered.
- Revision Mode — sidebar toggle that restricts sessions to words you rated medium or hard.
- Streaks & achievements — daily streak with one grace day per rolling 7 days; toast on each unlock.
- PWA — installable, offline-capable, image cache, generated icons.
- Notifications — opt-in reminders at user-configured times.
- Stats — words-per-level stacked bar, accuracy by mode, 30-day GitHub-style heatmap.
- Keyboard —
1-4choose,Spaceaudio,E/M/Hrate,Enteradvance,Escexit a session.
src/
├── main.jsx, App.jsx
├── routes/ Dashboard, Quiz, WordList, Stats, Settings
├── components/ Sidebar, BottomNav, ProgressRing, Heatmap,
│ SpeakerButton, SyncStatus
│ └── quiz/ 8 mode components + shared QuizCard / ChoiceGrid /
│ ImageCard / useAnswerFlow
├── hooks/ useTTS, useNotifications, useGistSync, useKeyboardShortcuts
├── lib/ srs, quiz, merge, levenshtein, gist, storage, types (JSDoc)
├── store/ progress, settings, session (Zustand)
├── data/ words-level1.json (+ index.js merger)
└── styles/ index.css (Tailwind directives only)
tests/ srs / levenshtein / merge (Vitest, jsdom)
.github/workflows/ deploy.yml
public/icons/ icon.svg, icon-maskable.svg
| Feature | Tab open | Installed PWA | Background |
|---|---|---|---|
| TTS audio | ✅ | ✅ | n/a |
| Auto-sync (5 min) | ✅ | ✅ (when foregrounded) | ❌ |
| Reminders at fixed times | ✅ (in-app) | ✅ (in-app) | periodicSync support — Chrome on Android only at the time of writing |
| Image caching | ✅ | ✅ | n/a |
useTTS is built around the Web Speech API; on first install Dutch voices may
not be available immediately — the hook listens for voiceschanged and caches
the chosen voice. If neither nl-NL nor nl-BE is found, the speaker button
remains disabled rather than speaking with the wrong locale.
The repo ships with level 1 (top 100 most common words) only.
To add more levels, drop another file in src/data/:
// src/data/words-level2.json
[
{ "id": "l2-001", "en": "...", "nl": "...", "article": "de"|"het"|null,
"pos": "noun", "level": 2, "imageKeyword": "..." }
]…then import it in src/data/index.js:
import level2 from './words-level2.json';
const levels = { 1: level1, 2: level2, /* ... */ };The Word shape lives in src/lib/types.js (JSDoc, no compile step).
- All pure logic (
srs,quiz,merge,levenshtein) is insrc/lib/and unit-tested with Vitest undertests/. CI runs the suite before deploy. - All
localStoragereads/writes go throughsrc/lib/storage.jsand are wrapped intry/catchwith sensible defaults — corrupt data does not crash the app. - ESLint is configured (
.eslintrc.cjs);npm run lintshould produce no warnings on a clean tree. - Image loads have a graceful text-only fallback on 404.