206 lines
13 KiB
Markdown
206 lines
13 KiB
Markdown
# 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
|
|||
|
|
public/admin.html organiser stats, CSV export; key passed in URL fragment (#k=...)
|
|||
|
|
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 |
|
|||
|
|
| `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 |
|
|||
|
|
| `GET /api/admin/stats` | `Authorization: Bearer <ADMIN_KEY>` | weeks (cells, rows, cols, diags, steps, devices, pulse), per-cell counts |
|
|||
|
|
| `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.
|
|||
|
|
|
|||
|
|
## 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
|
|||
|
|
|
|||
|
|
- `npm test` passes (scoring incl. rows/cols/diagonals, stale week, validation, 413, pulse, admin auth/totals, headers).
|
|||
|
|
- 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`.
|
|||
|
|
5. Links: team → `https://<domain>/`; organisers → `https://<domain>/admin#k=<ADMIN_KEY>`.
|
|||
|
|
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.
|