2026-10-30 17:20:47 +01:00
# CLAUDE.md — Balance 2.0 Bingo
Context for anyone (human or Claude Code) picking up this project. Read this first.
## What this is
A single web page for a team wellbeing initiative, ** "Project: Balance — Season 2" ("Баланс 2.0")**,
run by Olga (project manager) for a remote team of ~20– 25 people at Adacta Fintech, some of them
contractors from other companies. The season runs **2 Nov – 21 Dec 2026** , 8 weeks. The link gets
pinned in a Microsoft Teams channel.
- Each person sees a **personal 4× 4 bingo card** of small wellbeing habits and taps the ones they did this week.
- Every tick feeds one shared ** "team battery"** that fills up over the whole season.
- The card resets every Monday 00:00 (season timezone). The battery never resets.
- **No accounts and no personal data.** This is a hard requirement — see "Anonymity" below.
The UI and all user-facing copy are **in Russian** and must stay Russian. Code comments and README
are also Russian (historical). The owner and her husband now work in English — conversation, commit
messages and new docs can be English.
The original brief (Russian) is in `docs/original-brief.md` . Some of it has been overridden; see below.
## Decisions that changed the brief
| Brief said | Now | Why |
|---|---|---|
| Only full **rows** give +5 | **Rows, columns and both diagonals** each give +5 (10 lines total) | Owner asked: "a diagonal is bingo too", then "vertical too" |
| Goal 300 steps | **Goal 500** | Owner's call after lines were added |
| Hosting: Cloudflare / Netlify / Firebase | **Self-hosted Docker Compose + nginx** , no external services | Owner wants independence from third parties |
| Fonts from Google Fonts | Fonts **self-hosted** in `public/fonts/` | No third-party requests (privacy, offline) |
The "+5 per row" wording also appears in the project's planning docs (outside this repo); the page's
rule text now reads "Собрал ряд, столбец или диагональ — +5 в батарейку".
## Architecture
```
Browser ──https──▶ nginx :443 (host) ──http──▶ 127.0.0.1:8080 container "bingo" ──▶ volume bingo-data (/data/bingo.sqlite)
```
- **Server:** `server/server.mjs` — plain `node:http` , Node.js ≥ 22.13, **built-in `node:sqlite`** ,
**zero npm dependencies** (keep it that way). Serves static files from `public/` (preloaded into
memory at startup) and a small JSON API.
- **DB:** one SQLite file, WAL mode. Schema in `schema.sql` , applied idempotently on every start.
- **Frontend:** `public/index.html` (page) and `public/admin.html` (stats) — each a single self-contained
file with inline CSS/JS, no build step, no framework. All asset/API paths are **relative**
(`api/...` , `fonts/...` ) so the app works on its own subdomain *or* in a subfolder behind nginx.
- **Container:** `Dockerfile` (base `node:22.22-alpine` ), `compose.yaml` (project name `balance-bingo` ).
Runs as user `node` , read-only rootfs, `cap_drop: ALL` , `no-new-privileges` , healthcheck on `/healthz` .
Port bound to `127.0.0.1` only.
- **Reverse proxy:** nginx on the host terminates TLS. Configs in `deploy/nginx/` .
### File map
```
public/index.html page: card, row highlight + confetti, battery, weekly pulse question, demo mode
2026-10-30 20:53:04 +01:00
public/admin.html organiser stats, CSV export; key login → session cookie, logout
2026-10-30 17:20:47 +01:00
public/fonts/*.woff2 Unbounded + Manrope, Cyrillic + Latin subsets
server/server.mjs HTTP server, API, season/week logic, security headers, daily backups
server/db.mjs openDb(), LINES_SQL (row/col/diag detection), LINE_BONUS = 5
server/cli.mjs `backup`, `reset --yes`
schema.sql tables marks, pulse, totals
tests/api.test.mjs end-to-end tests (spawns the real server on a temp DB)
deploy/nginx/bingo.conf nginx: own subdomain (recommended)
deploy/nginx/bingo-subpath.conf nginx: subfolder of an existing site
tools/build-demo.mjs builds dist/demo.html — serverless demo for previews
Dockerfile, compose.yaml, .env.example, .dockerignore
README.md deployment guide (Russian) incl. full nginx config
docs/original-brief.md original requirements (Russian)
```
## Commands
```bash
npm start # run locally on :8080, DB in ./data (needs Node >= 22.13)
npm test # 7 end-to-end API tests, no deps
npm run build:demo # dist/demo.html (forced demo mode, Google Fonts link for preview only)
cp .env.example .env # set ADMIN_KEY (>= 16 chars)
docker compose up -d --build
docker compose logs bingo
docker compose exec bingo node server/cli.mjs backup
docker compose exec bingo node server/cli.mjs reset --yes # wipe test data before the season
```
Local testing during the pre-season: set `SEASON_START` to a past Monday, otherwise the server
rejects marks (`season_closed` ). `http://localhost:8080/#demo` forces demo mode (no server calls).
## Configuration (`.env`, read by the server at start)
| Var | Default | Notes |
|---|---|---|
| `ADMIN_KEY` / `ADMIN_KEY_FILE` | empty | empty = admin disabled; < 16 chars = refuses to start |
2026-10-30 20:53:04 +01:00
| `ADMIN_SESSION_HOURS` | `8` | admin session lifetime from login, absolute (no sliding); 0 < h ≤ 720 |
| `COOKIE_SECURE` | on | `0` drops the `Secure` cookie flag — only for plain-http testing |
2026-10-30 17:20:47 +01:00
| `TIMEZONE` | `Europe/Moscow` | weekly reset is Monday 00:00 in this zone |
| `SEASON_START` | `2026-11-02` | before it: "practice mode", marks not accepted by server |
| `SEASON_END` | `2026-12-21` | inclusive; after it: season closed |
| `GOAL` | `500` | battery target; above it the UI shows "Овердрайв!" and keeps counting |
| `BACKUP_KEEP` | `14` | daily `VACUUM INTO` copies in `/data/backups` ; 0 = off |
| `HOST_BIND` / `HOST_PORT` | `127.0.0.1` / `8080` | compose port mapping only |
The page fetches goal/dates/timezone from `/api/total` , so no HTML edits are needed for config changes.
Invalid config makes the server exit at startup with a clear message.
**Open question for the owner:** 21 Dec 2026 is a Monday, so "week 8" is one day long.
`SEASON_END=2026-12-27` would give 8 full weeks. Not decided yet.
## Scoring rules (server is the source of truth)
- 1 ticked cell = 1 step. Each completed line = +5. Lines: rows 1– 4, columns 1– 4,
diagonals 01-06-11-16 and 04-07-10-13. Bonuses stack (a full card = 16 + 10× 5 = 66).
- Unticking in the same week removes the step and the bonus of every line it breaks.
- Only the **current week** can be changed (client sends `week` , server returns 409 if it's stale).
- Primary key `(device, week, cell)` prevents double-counting.
- After every mark the server **recomputes the total from scratch** inside one transaction
(`RECOUNT_SQL` = count(marks) + 5 × count(LINES_SQL)) and caches it in `totals` .
Polling `/api/total` reads that one row.
- The client mirrors the same rules (`LINES` in `index.html` ) for instant feedback and celebrations,
then reconciles with the server response. **If you change scoring, change both** `server/db.mjs`
and the `LINES` array in `public/index.html` , and update the tests and README.
## API
| Method & path | Body / query | Response |
|---|---|---|
| `GET /api/total` | — | `{total, goal, tz, start, end, week, weekNo, weeks, phase}` ; phase = `pre` / `active` / `post` |
| `GET /api/me?d=<id>` | — | `{week, cells:[...], pulse}` — this device's current week |
| `POST /api/mark` | `{d, week, cell 1– 16, on: bool}` | `{ok, week, total, cells}` ; 409 `week_changed` , 403 `season_closed` , 400 validation |
| `POST /api/pulse` | `{d, week, answer: full/mid/low}` | `{ok, answer}` ; one answer per device per week, can be changed |
2026-10-30 20:53:04 +01:00
| `POST /api/admin/login` | `{key}` | 200 + `Set-Cookie: bingo_admin=<token>` ; 401 `bad_key` (after 500 ms); 403 `admin_disabled` |
| `POST /api/admin/logout` | — | deletes the session, clears the cookie |
| `GET /api/admin/stats` | session cookie, or `Authorization: Bearer <ADMIN_KEY>` for scripts | weeks (cells, rows, cols, diags, steps, devices, pulse), per-cell counts, `session.expiresAt` ; 401 without auth |
2026-10-30 17:20:47 +01:00
| `GET /healthz` | — | `{ok:true}` ; blocked by nginx from outside |
Device id `d` = random UUID generated in the browser and kept in localStorage (`balance2-bingo-v1` ),
regex `^[A-Za-z0-9-]{16,64}$` . Request bodies are capped at 2 KB (413 above that).
## Anonymity — non-negotiable
- Store nothing beyond: random device id, week (Monday date), cell number, pulse answer, the total.
**No** names, emails, logins, IPs, timestamps, user agents, analytics or third-party requests.
- The server logs only startup and errors — never request lines or IPs.
- nginx: `access_log off` ; `X-Forwarded-For` deliberately **not** forwarded; error log at `crit` .
- Only the team total is public. The admin page shows aggregates only.
- Accepted trade-off: no anti-cheat (someone can use 10 browsers). Don't add fingerprinting or IP limits that store data.
2026-10-30 20:53:04 +01:00
## Admin authentication
- The key is never kept in the URL. `/admin#k=KEY` still works as a login link: the page immediately
`history.replaceState` s the fragment away, POSTs the key to `/api/admin/login` , then shows plain `/admin` .
The login form does the same without touching the URL.
- The server answers with `bingo_admin=<32 random bytes, base64url>; Max-Age; HttpOnly; SameSite=Strict; Secure` .
**No `Path` on purpose** : the browser defaults it to the request directory (`/api/admin` , or
`/<prefix>/api/admin` behind the subpath nginx config), so the cookie only goes to admin API calls.
- Sessions live in an in-memory `Map` keyed by sha256(token) → expiry (max 200, oldest evicted).
Absolute lifetime `ADMIN_SESSION_HOURS` (default 8 h), no sliding. A container restart logs everyone out — accepted.
- Wrong key → 401 after a 500 ms delay. The key is compared with `timingSafeEqual` over sha256 digests.
- `Authorization: Bearer <ADMIN_KEY>` is still accepted on `/api/admin/stats` for curl/scripts.
2026-10-30 17:20:47 +01:00
## Teams embedding gotcha
The page must be embeddable as a Teams "Website" tab. The server sends
`Content-Security-Policy: frame-ancestors 'self' https://teams.microsoft.com https://*.teams.microsoft.com https://*.office.com https://*.cloud.microsoft` .
**Never add `X-Frame-Options`** (app or nginx); it breaks the tab. The subpath nginx config uses
an `add_header` inside `location` specifically to stop inheriting a site-wide X-Frame-Options.
A test asserts the header is absent.
## Frontend notes
- Visual spec from the brief: background `#12233D` , cells `#1B3150` with border `#2A4468` , radius 20px,
text `#F2F5FA` / muted `#A9B8CC` . Row colours: Тело `#FF8A65` , Фокус `#7CC4FF` , Границы `#B9A6FF` ,
Люди и радость `#C6F432` . Accent `#C6F432` . Fonts: Unbounded (display), Manrope (text). Tokens are in `:root` .
- Deliberately single dark theme (brand look), not light/dark.
- Cells are real `<button>` s with `aria-pressed` ; announcements go to an `aria-live` region; respects `prefers-reduced-motion` .
- On phones (< 760px) each row becomes 2× 2, so diagonals/columns are shown via a light ring on the cells
(`.on-line` ) instead of visually.
- Completed row: whole row highlighted + confetti (canvas). Completed column/diagonal: ring on its cells + confetti bursts.
- Polls `/api/total` every 20 s (`POLL_MS` ), only while the tab is visible.
- Falls back to **demo mode** automatically if the API is unreachable (example battery, local-only ticks).
- Weekly pulse question "Твоя батарейка сейчас?" (⚡ Полная / 🔋 Средне / 🪫 На нуле) — anonymous counts per week,
used for a before/after comparison at the finale.
## Verification status
2026-10-30 20:53:04 +01:00
- `npm test` passes, 11 tests: scoring incl. rows/cols/diagonals, stale week, validation, 413, pulse,
admin totals, admin login/cookie flags/logout, session expiry, admin disabled, COOKIE_SECURE=0, headers.
- Admin login flow verified in a real browser over https through both nginx configs (30 Oct 2026):
key removed from URL/history, cookie HttpOnly + Secure + SameSite=Strict with path `/api/admin`
(or `/bingo/api/admin` ), survives reload, logout ends it, wrong key shows an error.
2026-10-30 17:20:47 +01:00
- Manually verified (17– 18 Oct 2026): desktop + 375px + 320px layouts, no horizontal overflow;
pre-season / overdrive / weekly reset states; container healthy, data persists across restart and
`down` /`up` , runs with network disconnected, non-root, read-only rootfs.
- nginx 1.24: both configs pass `nginx -t` ; full page + admin worked through both in a real browser;
rate limit returns 429; large body 413; zero access-log lines written.
- **Not yet verified:** the build on the real `node:22.22-alpine` image (the build sandbox couldn't reach
Docker Hub; a local image with the same Node 22.22.0 was used). First real `docker compose up --build`
is the check. If the `22.22-alpine` tag doesn't exist anymore, use `node:22-alpine` (needs ≥ 22.13 for `node:sqlite` ).
- Not tested inside an actual Teams tab yet.
## Deployment checklist (production)
1. `cp .env.example .env` ; set `ADMIN_KEY` (`openssl rand -base64 24` ); confirm dates/goal; decide `SEASON_END` .
2. `docker compose up -d --build` ; `docker compose ps` → healthy; `curl -s 127.0.0.1:8080/api/total` .
3. nginx: copy `deploy/nginx/bingo.conf` → `/etc/nginx/conf.d/` , set `server_name` + cert paths
(or `certbot --nginx -d <domain>` ), drop `listen [::]` lines if no IPv6; `nginx -t && systemctl reload nginx` .
4. Dry run: temporarily set `SEASON_START` to a past Monday, test on phone + desktop + Teams tab,
then restore `2026-11-02` , `docker compose up -d` , `docker compose exec bingo node server/cli.mjs reset --yes` .
2026-10-30 20:53:04 +01:00
5. Links: team → `https://<domain>/` ; organisers → `https://<domain>/admin` (log in with the key;
`/admin#k=<ADMIN_KEY>` also works as a one-click login link and is stripped from the URL).
2026-10-30 17:20:47 +01:00
6. Backups: `docker compose cp bingo:/data/backups ./backups` occasionally, or back up the `balance-bingo_bingo-data` volume.
## Conventions
- No dependencies, no build step, no frameworks — the whole thing should stay auditable in an afternoon.
- Keep `public/*.html` self-contained (inline CSS/JS), Russian copy, relative URLs.
- Russian UI copy style: warm, short, a little self-irony, no coaching/therapy language
("энергия, фокус, нагрузка", not "забота о себе"). Never single anyone out.
- After changes: `npm test` ; if UI changed, check 375px width and the Teams-embedding headers.
- Commit messages in English.
## Wider project context (not code, but useful)
The bingo is one part of a season of activities (weekly Monday posts, Tuesday 20-minute "quiet Tuesday"
calls, Friday chat check-ins). Weekly themes: 0 teaser (26 Oct) · 1 start · 2 where the day leaks ·
3 body at the laptop · 4 saying "no" · 5 my best fail · 6 productivity myths · 7 recharging outside work ·
8 finale (21 Dec) with battery results. Part of the team is sceptical of "self-care" — playfulness and
humour over earnestness. After the finale there's a pre-New-Year release crunch, so only light async activities.