A small local front-end for the Save Wisdom 1000 questions. A single Go binary (stdlib only) serves a JSON API and a single-page web UI that lets you see all 1000 questions, mark them done (with a completion date), filter and sort them, and pick random undone questions.
- No build toolchain, no bundlers, no external Go dependencies (stdlib only).
- No database: progress is a single JSON file, default
~/.savewisdom/state.json. - The question list is fetched once, at build time, checked in as JSON, and embedded into the binary — the server never needs the network.
- Go 1.26+ (anything that builds the code; the module declares
go 1.26) - A browser with JavaScript enabled (any modern one)
make build # produces ./savewisdom
./savewisdom # serves http://127.0.0.1:8080Or in one step:
make run # builds, then runs with defaultsYou can also pass flags directly:
./savewisdom -listen 0.0.0.0:8080 -state /path/to/state.jsonThen open http://127.0.0.1:8080/.
docker compose up -d # builds locally and starts the serviceThen open http://127.0.0.1:8080/.
- The container listens on
0.0.0.0:8080and stores state at/data/state.json, so the only thing the compose file sets is the host port (default8080; override withSAVE_WISDOM_HOST_PORT=9000 docker compose up -d). /datais a named volume by default. To keep data in a directory of your choice, mount it there indocker-compose.ymlinstead, e.g../data:/data.
| Flag | Env var | Default | Purpose |
|---|---|---|---|
-listen |
SAVE_WISDOM_LISTEN |
127.0.0.1:8080 |
Address to bind. The localhost default keeps the app private; binding 0.0.0.0 exposes your done/not-done status (no answers are stored) to the network. |
-state |
SAVE_WISDOM_STATE |
~/.savewisdom/state.json |
Path of the JSON state file. A leading ~ is expanded. |
Flags take precedence over the corresponding env vars.
Progress lives in a single JSON file (never committed; it's in .gitignore).
Missing file = no questions done. A corrupt file makes the server refuse to
start with a clear error rather than overwrite your data.
{
"version": 1,
"questions": {
"1": { "done": true, "completed_at": "2026-08-15T18:22:03Z" },
"105": { "done": false }
}
}- Keys are question numbers (stable across upstream list changes), not positions.
completed_atis RFC 3339 UTC, present only whendoneistrue(cleared when you un-do a question). The UI renders it in your local timezone.- Writes are atomic: the server writes
state.json.tmpin the same directory and renames it over the target, so a crash never leaves a torn file.
- Header —
done/totalbadge plus a gradient progress bar (green → gold), updated after every change. - Segmented tabs — Random (default) and Question List switch via pill tabs.
- Random tab — a random undone question with Mark done (auto-picks the next one) and Pick another. When all 1000 are done it says so, celebratorily.
- Question List tab — all 1000 questions with per-row status and completion date. Sort by question number (default) or completion date (done first, oldest → newest; not-done last). Filter by all / not done / done. Click (or press Enter/Space on a focused) row to toggle; changes are applied optimistically and rolled back with an error message if the server rejects them. Keyboard: rows are real buttons (Tab to focus, Enter to toggle); the tab menu supports Arrow keys. No CDN, no external fonts, no tracking.
Same-origin JSON; the UI is the only client you need, but the API is tiny and documented here anyway.
| Method | Path | Description |
|---|---|---|
GET |
/ |
The single-page app (HTML). |
GET |
/healthz |
Liveness probe, returns ok. |
GET |
/api/questions |
All 1000 questions, list order: { id, number, text, category, done, completed_at? }[] |
PATCH |
/api/questions/{id} |
Body {"done": true|false} — sets/clears the flag and completed_at, persists, returns the updated record. 400 bad body · 404 unknown id · 500 persistence failure. |
GET |
/api/random |
One uniformly random undone question (server-side crypto/rand). When none remain: {"all_done": true} with HTTP 200. |
GET |
/api/summary |
{"total": N, "done": M, "not_done": K} |
Errors are JSON: {"error": "..."}.
Example:
curl -s http://127.0.0.1:8080/api/summary
# {"total":1000,"done":42,"not_done":958}
curl -s -X PATCH http://127.0.0.1:8080/api/questions/7 \
-d '{"done":true}'
# {"id":7,"number":7,"text":"...","category":"...","done":true,"completed_at":"2026-08-15T22:49:04Z"}go test ./... # state store, JSON API (httptest), data invariants, web handler
go vet ./...
gofmt -l .The question list (site version v7.0, 2/8/2025 at time of writing) is open sourced by the project. If it changes upstream, re-run the generator:
go run ./tools/gen-questions # fetches the live page, validates, writes
# internal/data/questions.jsonUseful flags: -url <page> (default: the live page), -html <file> (parse a
saved copy offline), -out <path> (default: internal/data/questions.json),
-expect <n> (default 1000). The generator validates 1000 sequential,
non-duplicate, non-empty questions across 10 categories before writing, writes
atomically, and internal/data re-validates invariants at startup (fail fast).
├── main.go # flags, wiring, http.Server, graceful shutdown
├── internal/
│ ├── data/ # embedded question list + invariants
│ ├── state/ # done/not-done store: mutex, atomic save, crypto/rand
│ ├── api/ # JSON HTTP API
│ └── web/ # embedded SPA (index.html)
└── tools/gen-questions/ # one-time question fetcher/parser (build-time only)
See PLAN.md for the design rationale, data format, and the full phase plan.