13 KiB
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— plainnode:http, Node.js ≥ 22.13, built-innode:sqlite, zero npm dependencies (keep it that way). Serves static files frompublic/(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) andpublic/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(basenode:22.22-alpine),compose.yaml(project namebalance-bingo). Runs as usernode, read-only rootfs,cap_drop: ALL,no-new-privileges, healthcheck on/healthz. Port bound to127.0.0.1only. - 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
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 intotals. Polling/api/totalreads that one row. - The client mirrors the same rules (
LINESinindex.html) for instant feedback and celebrations, then reconciles with the server response. If you change scoring, change bothserver/db.mjsand theLINESarray inpublic/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-Fordeliberately not forwarded; error log atcrit. - 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#1B3150with 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 witharia-pressed; announcements go to anaria-liveregion; respectsprefers-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/totalevery 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 testpasses (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-alpineimage (the build sandbox couldn't reach Docker Hub; a local image with the same Node 22.22.0 was used). First realdocker compose up --buildis the check. If the22.22-alpinetag doesn't exist anymore, usenode:22-alpine(needs ≥ 22.13 fornode:sqlite). - Not tested inside an actual Teams tab yet.
Deployment checklist (production)
cp .env.example .env; setADMIN_KEY(openssl rand -base64 24); confirm dates/goal; decideSEASON_END.docker compose up -d --build;docker compose ps→ healthy;curl -s 127.0.0.1:8080/api/total.- nginx: copy
deploy/nginx/bingo.conf→/etc/nginx/conf.d/, setserver_name+ cert paths (orcertbot --nginx -d <domain>), droplisten [::]lines if no IPv6;nginx -t && systemctl reload nginx. - Dry run: temporarily set
SEASON_STARTto a past Monday, test on phone + desktop + Teams tab, then restore2026-11-02,docker compose up -d,docker compose exec bingo node server/cli.mjs reset --yes. - Links: team →
https://<domain>/; organisers →https://<domain>/admin#k=<ADMIN_KEY>. - Backups:
docker compose cp bingo:/data/backups ./backupsoccasionally, or back up thebalance-bingo_bingo-datavolume.
Conventions
- No dependencies, no build step, no frameworks — the whole thing should stay auditable in an afternoon.
- Keep
public/*.htmlself-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.