Files
balance2/bingo/CLAUDE.md
T
2026-10-30 17:20:47 +01:00

13 KiB
Raw Blame History

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

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.