# 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 login → session cookie, logout 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 | | `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 | | `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=` | — | `{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 | | `POST /api/admin/login` | `{key}` | 200 + `Set-Cookie: bingo_admin=`; 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 ` for scripts | weeks (cells, rows, cols, diags, steps, devices, pulse), per-cell counts, `session.expiresAt`; 401 without auth | | `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. ## 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 `//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 ` is still accepted on `/api/admin/stats` for curl/scripts. ## 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 `