Skip to content

Commit 1e0a213

Browse files
committed
docs(adr): write ADRs for existing architectural decisions
Add five ADRs in docs/adr/ for decisions already in production: - 0008: Transport layer — dual PeerJS + Gun.js with auto-fallback - 0009: Database — Dexie.js with versioned migrations - 0010: PWA / offline-first strategy - 0011: Tag-only question classification (companion to ADR-0007) - 0012: Vitest vmForks pool for ESM / fake-indexeddb compatibility Update README: add Documentation section with links to user guides and API docs, add docs.yml to CI/CD workflow table, add npm run docs to the scripts table, add API docs badge. Closes #82 Part of epic #75
1 parent 5ce7f03 commit 1e0a213

6 files changed

Lines changed: 357 additions & 7 deletions

README.md

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ Bar trivia PWA with WebRTC multiplayer, Reveal.js slides, and buzzer gameplay.
44
No backend — runs entirely in the browser.
55

66
**Live:** https://morbeo.github.io/viktorani/
7+
**API docs:** [![API Docs](https://img.shields.io/badge/docs-API-blue)](https://morbeo.github.io/viktorani/api/)
78

89
---
910

1011
## Contents
1112

1213
- [Tech stack](#tech-stack)
1314
- [Getting started](#getting-started)
15+
- [Documentation](#documentation)
1416
- [Project structure](#project-structure)
1517
- [Multiplayer](#multiplayer)
1618
- [Data storage](#data-storage)
@@ -51,6 +53,7 @@ npm run dev
5153
| ----------------------- | ---------------------------------------------- |
5254
| `npm run dev` | Start dev server at `localhost:5173` |
5355
| `npm run build` | Type-check + production build → `dist/` |
56+
| `npm run docs` | Generate API docs → `docs/api/` (gitignored) |
5457
| `npm run lint` | ESLint across all `*.ts` / `*.tsx` files |
5558
| `npm run typecheck` | Type-check app and test files (both tsconfigs) |
5659
| `npm run test` | Run unit tests once via Vitest |
@@ -65,6 +68,24 @@ npm run dev
6568

6669
---
6770

71+
## Documentation
72+
73+
| Document | Description |
74+
| -------- | ----------- |
75+
| [Host guide](docs/user-guide/host.md) | Setting up and running a trivia night |
76+
| [Player guide](docs/user-guide/player.md) | Joining a game and buzzing in |
77+
| [API docs](https://morbeo.github.io/viktorani/api/) | Generated TypeDoc — transport, DB, hooks |
78+
| [Architecture decisions](docs/adr/) | ADRs for all major technical decisions |
79+
80+
To generate API docs locally:
81+
82+
```bash
83+
npm run docs
84+
# Output: docs/api/ (gitignored — generated at build time)
85+
```
86+
87+
---
88+
6889
## Project structure
6990

7091
```
@@ -105,6 +126,10 @@ src/
105126
│ └── timer/ # Timer hook and component tests
106127
└── App.tsx # HashRouter + all routes
107128
129+
docs/
130+
├── adr/ # Architecture Decision Records
131+
└── user-guide/ # Host and player guides
132+
108133
public/
109134
├── favicon.svg # SVG favicon (source of truth)
110135
├── icon-192.png # PWA icon — generate from favicon.svg (see Favicon section)
@@ -153,12 +178,13 @@ restore or share your question bank.
153178

154179
### Workflows
155180

156-
| Workflow | Trigger | Purpose |
157-
| --------------- | ------------------------- | ---------------------------------------------------------------------- |
158-
| `ci.yml` | PRs to `master` | PR title lint + type-check, lint, test, build — both required to merge |
159-
| `deploy.yml` | push to `master` + manual | Type-check → lint → test → build → deploy to GitHub Pages |
160-
| `release.yml` | push of `v*` tags | Build tarball → generate release notes → publish GitHub Release |
161-
| `automerge.yml` | `CI` workflow completes | Auto-merge Dependabot patch/minor PRs when CI passes |
181+
| Workflow | Trigger | Purpose |
182+
| --------------- | ------------------------------ | ---------------------------------------------------------------------- |
183+
| `ci.yml` | PRs to `master` | PR title lint + type-check, lint, test, build — both required to merge |
184+
| `deploy.yml` | push to `master` + manual | Type-check → lint → test → build → deploy to GitHub Pages |
185+
| `docs.yml` | push to `master` (src/ changes) | Generate TypeDoc → publish to `gh-pages` under `/api/` |
186+
| `release.yml` | push of `v*` tags | Build tarball → generate release notes → publish GitHub Release |
187+
| `automerge.yml` | `CI` workflow completes | Auto-merge Dependabot patch/minor PRs when CI passes |
162188

163189
All workflows set `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true` to opt into the Node 24 runner ahead of the June 2026 forced migration.
164190

@@ -179,7 +205,7 @@ Force pushes and branch deletion are blocked. To apply or re-apply protection:
179205
bash scripts/protect-master.sh
180206
```
181207

182-
> **Note:** `scripts/` is gitignored. Add scripts explicitly with `git add --force scripts/`.
208+
> **Note:** `scripts/` is gitignored. Add scripts explicitly with `git add --force scripts/`
183209
184210
### Dependabot
185211

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# ADR-0008 — Transport layer: dual PeerJS + Gun.js with auto-fallback
2+
3+
**Date:** 2026-04-10
4+
**Status:** Accepted
5+
6+
## Context
7+
8+
Viktorani is a fully static PWA with no backend (ADR-0001). Real-time communication
9+
between the GameMaster and players is required for the buzzer, score updates, timer
10+
broadcasts, and question-navigation events. The transport layer must work in bar
11+
environments where network conditions are unpredictable.
12+
13+
Two broad options exist for browser-to-browser real-time communication without a
14+
custom server: WebRTC (with a signalling layer) and relay-based pub/sub.
15+
16+
**Constraints:**
17+
- Zero server maintenance — no custom signalling or relay server to run.
18+
- Must work behind restrictive NATs (common in pubs and venues).
19+
- Latency must be low enough for fair buzzer ordering (< 100 ms typical round-trip).
20+
- The passphrase displayed on the QR code must provide confidentiality.
21+
22+
## Decision
23+
24+
We implement two transport classes behind a common `ITransport` interface:
25+
26+
1. **`PeerJSTransport`** — WebRTC data channels via the PeerJS cloud signalling service.
27+
The host registers a deterministic PeerJS ID (`vkt-<roomId>`); players connect to it.
28+
All data flows peer-to-peer with DTLS encryption from the browser.
29+
30+
2. **`GunTransport`** — Decentralised graph database relay via public Gun.js peers.
31+
Events are symmetrically encrypted with SEA (`SEA.work(passphrase, roomId)`) before
32+
being written to the Gun graph. All peers subscribe to the same namespace and decrypt
33+
on receive.
34+
35+
`TransportManager` exposes three modes:
36+
- `'peer'` — PeerJS only.
37+
- `'gun'` — Gun.js only.
38+
- `'auto'` — Try PeerJS; if it times out after 8 seconds, fall back to Gun.js.
39+
40+
The default mode is `'auto'`.
41+
42+
## Alternatives considered
43+
44+
**PeerJS only:** Simpler implementation and lower latency. Rejected as the sole option
45+
because the PeerJS cloud service has had availability incidents, and WebRTC peer
46+
connections are blocked by some corporate/venue NATs.
47+
48+
**Gun.js only:** More reliable connectivity (relay-based, firewall-friendly). Rejected
49+
as the sole option because Gun.js relay peers are public infrastructure with variable
50+
latency, and the relay-hop model adds ~50–150 ms round-trip compared to PeerJS's
51+
direct data channel.
52+
53+
**Socket.IO / Pusher / Ably:** Managed WebSocket services. Rejected because they
54+
require an account, API keys, and introduce per-message costs or strict rate limits.
55+
They also contradict the zero-server-maintenance constraint.
56+
57+
**WebSockets with a self-hosted relay:** Maximum control and performance. Rejected
58+
because it requires running a server, which contradicts ADR-0001.
59+
60+
## Consequences
61+
62+
- Hosts can manually override the transport mode per-game if one option is known to
63+
work better at their venue.
64+
- The 8-second PeerJS timeout is a trade-off: short enough not to delay game start
65+
noticeably, long enough to distinguish a slow connection from a broken one.
66+
- Gun.js relay peers are third-party infrastructure. If both public relay servers are
67+
unavailable, Gun.js transport will fail. The passphrase-based SEA encryption means
68+
relay operators cannot read game events.
69+
- The `ITransport` interface makes it straightforward to add future transports
70+
(e.g. self-hosted WebSocket, BroadcastChannel for same-device testing) without
71+
changing consumers.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# ADR-0009 — Database: Dexie.js with versioned migrations
2+
3+
**Date:** 2026-04-10
4+
**Status:** Accepted
5+
6+
## Context
7+
8+
All application data — questions, games, rounds, players, scores, buzz events — must
9+
be stored locally in the browser. The project has no backend (ADR-0001), so
10+
server-side databases are out of scope. The storage layer must support:
11+
12+
- Structured, queryable data (not just key-value blobs).
13+
- Versioned schema migrations that run automatically on upgrade.
14+
- React integration without excessive boilerplate.
15+
- A test-friendly API (mockable in Vitest without a real browser).
16+
17+
IndexedDB is the only browser API that provides a full transactional database.
18+
The question is which abstraction to use on top of it.
19+
20+
## Decision
21+
22+
We use **Dexie.js** as the IndexedDB abstraction, with all schema definitions
23+
and migrations in a single `ViktoraniDB` class in `src/db/index.ts`.
24+
25+
Schema evolution uses Dexie's versioned `.version(n).stores({...}).upgrade(tx => ...)`
26+
pattern. Each version declares the full index set for every table (Dexie requires this)
27+
and an optional upgrade callback for data migrations. Versions are append-only —
28+
past versions are never modified.
29+
30+
All React components access the database via `dexie-react-hooks` (`useLiveQuery`)
31+
or via async helpers imported from `src/db/`. The database is exposed as a
32+
module-level singleton (`export const db = new ViktoraniDB()`).
33+
34+
For testing, `fake-indexeddb` polyfills the IndexedDB API in the Vitest environment
35+
(see ADR-0010).
36+
37+
## Alternatives considered
38+
39+
**Raw IndexedDB API:** Maximum control; no dependency. Rejected because the raw API
40+
is callback-based, verbose, and difficult to use with React's asynchronous rendering
41+
model. Schema migration code would be substantial to write and maintain.
42+
43+
**localForage:** A simple key-value store on top of IndexedDB. Rejected because it
44+
does not support indexes or queries — storing structured data like questions with tag
45+
filtering and sort-by-difficulty would require loading all records into memory.
46+
47+
**PouchDB:** A CouchDB-compatible database with sync capabilities. Rejected because
48+
sync is not needed (the transport layer handles real-time state), and PouchDB's
49+
document model and revision history add unnecessary overhead for this use case.
50+
51+
**SQLite via WASM (e.g. wa-sqlite, sql.js):** Full SQL in the browser. Rejected
52+
because the WASM bundle size (~1 MB) is disproportionate for a trivia app; browser
53+
support is still maturing; and Dexie covers all required query patterns.
54+
55+
## Consequences
56+
57+
- Schema changes require a new version number. Forgetting to increment the version
58+
causes Dexie to throw on open, which surfaces the mistake immediately in development.
59+
- All tables must be declared in every version's `.stores()` call, even if unchanged.
60+
This is verbose but ensures the index set is always explicit.
61+
- The `upgrade()` callback receives a Dexie transaction; long-running migrations
62+
(e.g. backfilling many rows) block the database open. This is acceptable for the
63+
current data volumes (hundreds of questions, not millions).
64+
- `fake-indexeddb` supports Dexie out of the box, making unit tests fast and
65+
deterministic without a browser.

docs/adr/0010-pwa-offline-first.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# ADR-0010 — PWA / offline-first strategy
2+
3+
**Date:** 2026-04-10
4+
**Status:** Accepted
5+
6+
## Context
7+
8+
Viktorani is designed for bar and pub environments where Wi-Fi is unreliable and
9+
mobile data is often congested. The host device must be able to run the GM view
10+
without any internet connection once the app is loaded. Players who have visited
11+
the site before should also be able to load the join page without connectivity.
12+
13+
Additionally, the app should be installable as a home-screen icon on the host's
14+
tablet or laptop, behaving like a native app (no browser chrome, standalone window).
15+
16+
## Decision
17+
18+
We build Viktorani as a **Progressive Web App (PWA)** using `vite-plugin-pwa`,
19+
which generates a service worker and a Web App Manifest automatically from Vite's
20+
build output.
21+
22+
**Service worker strategy:** `GenerateSW` with a `workbox` `StaleWhileRevalidate`
23+
strategy for all assets. On first visit, all build artifacts are cached. On subsequent
24+
visits, the cached version is served immediately while the new version is fetched in
25+
the background. On next reload, the updated version is active.
26+
27+
**Manifest:** Declares `display: standalone`, a 192×192 and 512×512 PNG icon set,
28+
and a `start_url` of `/viktorani/` (matching the GitHub Pages subpath).
29+
30+
**Offline capability scope:**
31+
- The GM can run a full game offline: questions, navigation, buzzer logic, and scoring
32+
all work without any network request.
33+
- The real-time transport (PeerJS / Gun.js) requires internet for initial connection.
34+
Once the game is in progress, PeerJS direct data channels survive brief connectivity
35+
drops; Gun.js buffers events and replays on reconnect.
36+
- Player devices need internet for the initial page load; after caching they can
37+
reload offline but will not receive transport events without connectivity.
38+
39+
**Peer conflict:** `vite-plugin-pwa` declares a peer dependency on a specific Vite
40+
major version. With Vite 8, this peer constraint is satisfied via an npm `overrides`
41+
block in `package.json` rather than downgrading Vite.
42+
43+
## Alternatives considered
44+
45+
**No service worker (plain static site):** Simpler build. Rejected because offline
46+
operation is a primary design requirement — bar Wi-Fi is notoriously unreliable.
47+
48+
**Manual service worker:** Full control over caching strategy. Rejected because
49+
Workbox (used by `vite-plugin-pwa`) handles cache versioning, update detection, and
50+
the `skipWaiting` / `clientsClaim` lifecycle correctly out of the box. Writing this
51+
manually would be substantial and error-prone.
52+
53+
**Capacitor / Electron (native wrapper):** True offline native app. Rejected because
54+
it would require a build and distribution pipeline (App Store, DMG/EXE) that
55+
contradicts the zero-cost, zero-maintenance deployment goal. A PWA achieves
56+
install-to-home-screen on iOS/Android/desktop without any store submission.
57+
58+
## Consequences
59+
60+
- The host must load the app at least once on a network-connected device before
61+
going offline. This is a reasonable requirement for a planned event.
62+
- Service worker updates are silent (StaleWhileRevalidate). If the host wants to
63+
pick up a new version immediately, they need to close all tabs and reopen, or
64+
use the browser's "Update" prompt if one appears.
65+
- The `display: standalone` manifest requires `HashRouter` instead of `BrowserRouter`
66+
because GitHub Pages cannot serve arbitrary deep paths (see ADR-0004).
67+
- PWA install prompts behave differently across browsers and OS versions. Chrome on
68+
Android and Edge on desktop show an install prompt; Safari on iOS requires the user
69+
to manually "Add to Home Screen" from the share sheet.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# ADR-0011 — Tag-only question classification
2+
3+
**Date:** 2026-04-10
4+
**Status:** Accepted
5+
6+
## Context
7+
8+
See ADR-0007, which documents the decision to remove the `Category` type and make
9+
tags the sole question classifier. This ADR records the consequences and implementation
10+
details that were deferred from ADR-0007.
11+
12+
The original system had:
13+
- **Categories** — a single required classifier per question (e.g. "Geography").
14+
- **Tags** — an optional multi-value set for additional labels.
15+
16+
Both were colour-coded and managed in separate Settings panels. The duplication
17+
created authoring friction (which label goes where?) and made filtering inconsistent.
18+
19+
## Decision
20+
21+
Remove the `Category` type entirely. Tags are the sole classifier. The filtering UI
22+
upgrades from a single-select category dropdown to a **tri-state per-tag** model:
23+
24+
- **None** (default) — tag is not used as a filter criterion.
25+
- **Include** — question must have this tag (conjunctive AND across multiple includes).
26+
- **Exclude** — question must not have this tag (conjunctive AND across multiple excludes).
27+
28+
Includes and excludes compose: "must have `History` AND must not have `Hard`" is a
29+
valid filter state.
30+
31+
The DB is migrated from v2 to v3 (see `src/db/index.ts`) with a Dexie upgrade step
32+
that strips `categoryId` from all existing `questions` records and drops the
33+
`categories` table. The snapshot format is bumped to v2 (categories field dropped);
34+
v1 imports remain supported with categories silently ignored.
35+
36+
## Alternatives considered
37+
38+
See ADR-0007 for the full alternatives analysis.
39+
40+
## Consequences
41+
42+
- `Question.categoryId` is removed from the TypeScript schema and all DB indexes.
43+
Existing data is migrated automatically on first open after the upgrade.
44+
- The `ManageCategories` settings panel is deleted. Tag management is consolidated
45+
into the expanded `ManageTags` panel.
46+
- Snapshot v2 does not include a `categories` field. Importing a v1 backup on a
47+
v3+ database silently discards the categories array and strips `categoryId` from
48+
imported questions.
49+
- Tri-state tag filtering is more expressive than the old single-select dropdown
50+
but requires users to understand include vs. exclude semantics. The UI communicates
51+
this via colour coding (green = include, red = exclude, grey = none).
52+
- Search index no longer includes `_categoryName`. Tag names are already indexed by
53+
Fuse.js, so coverage is equivalent.

0 commit comments

Comments
 (0)