Mobile-first training check-in for a sports club (Swedish UI). Monorepo: Express API + React (Vite) SPA.
- Node.js
>=20.11 <21(seepackage.jsonengines)
npm install
cp .env.example .env # set CLUB_PIN, SESSION_SECRET, SPREADSHEET_ID, GOOGLE_SERVICE_ACCOUNT
npm run dev- Web UI: http://localhost:5173 (Vite proxies
/apito the API) - API only: http://localhost:3000/api/health
| Script | Description |
|---|---|
npm install |
Install all workspace dependencies |
npm run dev |
API (watch) + Vite dev server |
npm run build |
Production build of the web app (web/dist) |
npm start |
Run API serving web/dist — run npm run build first |
npm test |
API unit tests (node --test in api/) |
npm run build
npm start
# open http://localhost:3000
curl http://localhost:3000/api/healthCopy .env.example to .env. The API exits on startup if required variables are missing (CLUB_PIN, TRAINER_PIN, SESSION_SECRET, SPREADSHEET_ID, GOOGLE_SERVICE_ACCOUNT).
| Variable | Required when | Purpose |
|---|---|---|
PORT |
Optional | API port (default 3000) |
TZ |
Optional | Process timezone (API defaults to Europe/Stockholm) |
SESSION_SECRET |
PIN slice (#2) | Signs the club-unlock and trainer session cookies |
CLUB_PIN |
PIN slice (#2) | Club PIN (server only; length not fixed) |
TRAINER_PIN |
Always | Trainer-only password gating the /report page (use a value different from CLUB_PIN) |
COOKIE_SECURE |
Optional | Set true to force Secure cookies in development |
SPREADSHEET_ID |
Sheets slice (#4)+ | Google Spreadsheet ID |
GOOGLE_SERVICE_ACCOUNT |
Sheets slice (#4)+ | Path to service-account JSON file |
DEFAULT_GROUP_NAME |
Optional | Display name of the default group (default Standard) |
GDPR slice (#3) uses client-only localStorage.gdprAccepted and a static /privacy page — no extra env vars.
api/ Express API (/api/*; serves web/dist for local npm start only)
web/ React + Chakra UI (Vite)
deploy/ Docker, Caddy snippet, production env example
scripts/ deploy.sh (build + publish static + restart API)
docs/ PRD and implementation issue slices
CONTEXT.md Domain glossary and scaffold decisions
- Create a service account (no GCP project roles required for Sheets-only access).
- Create a JSON key for that account and save it at the repo root as
api-key.json(next to.env, gitignored). SetGOOGLE_SERVICE_ACCOUNT=../api-key.jsonin.env(see .env.example). - Enable the Google Sheets API for your project.
- Create an empty spreadsheet in Google Drive and copy its ID into
SPREADSHEET_ID(from the spreadsheet URL). - Share the spreadsheet with the service account email (from the JSON
client_email) as Editor.
On first member registration the API creates a members tab with headers (memberId, firstName, lastName, optOutRanking, createdAt). Do not rename those headers manually — fix or delete the tab and register again if the sheet was misconfigured.
New members start in the default group (name from DEFAULT_GROUP_NAME, default Standard), whose check-ins land in the regular checkins_YYYY tab. In Inställningar every group — including the default one — is a toggle: members can join extra groups and may also leave the default group. On Hem you see the groups you're in and pick which one to check into (a single group checks in directly; multiple groups show a picker). The Topplista has a group selector so you can view any group's leaderboard, defaulting to the default group.
Coaches define extra groups by adding rows to the groups tab (auto-created with headers groupId, name, createdAt on first use):
| groupId | name | createdAt |
|---|---|---|
1 |
Måndagsträning | (optional) |
2 |
Tävlingsgrupp | (optional) |
- Use incremental integer ids (
1,2,3, …). Tabs and labels show asGroup1,Group2, … independent of thename; thenameis what members see when checking in. - Each extra group's check-ins go to a per-year tab
groupN_YYYY(e.g. group1in 2026 →group1_2026), auto-created on first check-in with the same headers ascheckins_YYYY. - Membership is stored in an auto-created
member_groupstab (memberId,groupIds); do not edit it manually.
CI and local unit tests use an in-memory adapter (no live Google call).
/report is a separate, trainer-only page (outside the member app and its club PIN). It is gated by its own TRAINER_PIN password and its own session cookie, so a member who knows the club PIN cannot see who attended. After unlocking, the coach picks a date and a group to list everyone who checked in that day in that group.
- The report endpoint (
GET /api/trainer/report?date=YYYY-MM-DD&groupId=) reads the relevantgroupN_YYYY(orcheckins_YYYYfor the default group) tab and filters by date; names are resolved live from thememberstab. - Rotate access by changing
TRAINER_PIN(existing trainer sessions are invalidated, same asCLUB_PINrotation).
Production uses a dedicated subdomain, host Caddy (you append a snippet to your existing config), static files in /var/www/checkin, and the API in Docker on 127.0.0.1:3001. Local npm start remains valid for production-like testing on one port.
- DNS — Point
checkin.your-club-domainat the VPS. - Directories
sudo mkdir -p /var/www/checkin sudo chown "$USER:$USER" /var/www/checkin # or a dedicated deploy user
- Env — In the clone:
cp .env.example .envand fill in values. Place the Google service-account JSON asapi-key.jsonnext to.env(gitignored; Docker mounts it ascheckin-api). - Clone — e.g.
~/git/checkin(see Google Sheets on the VPS). - Caddy — Append
deploy/Caddyfile.snippetto your Caddyfile, replacecheckin.YOUR_CLUB_DOMAIN, then validate and reload Caddy. - Deploy — From the clone:
./scripts/deploy.sh(builds web, copies to/var/www/checkin, starts API). Override paths withCHECKIN_REPO/CHECKIN_WEB_ROOTif needed.
| Variable | Required | Notes |
|---|---|---|
NODE_ENV |
In Docker | Compose sets production on the container (Secure cookie). Optional in .env for npm start on the VPS |
SESSION_SECRET |
Yes | Long random string |
CLUB_PIN |
Yes | Club PIN (server only) |
TRAINER_PIN |
Yes | Trainer-only password for /report (different from CLUB_PIN) |
SPREADSHEET_ID |
Yes | Google Spreadsheet ID |
GOOGLE_SERVICE_ACCOUNT |
Yes | ../api-key.json in .env (file at repo root; container checkin-api uses /app/api-key.json) |
TZ |
Recommended | Europe/Stockholm (Club calendar timezone) |
PORT |
In container | Leave 3000 (host maps 3001 → 3000 in deploy/docker-compose.yml) |
Local dev and production Docker both use the same repo-root .env (see .env.example); do not commit it.
When closing deploy slice (#09)
curl -sS https://checkin.your-club-domain/api/health→{"status":"ok"}.- Open the site on a phone, enter Club PIN, open DevTools (or remote inspect) → Application → Cookies →
checkin_unlockhas Secure, HttpOnly, SameSite=Lax. - Complete consent + onboarding, Checka in → new row in the current year’s
checkins_YYYYtab in Sheets.
After leaderboard slice (#8)
- Open Topplista — list loads (or empty state) without API errors.
After deploy, members can install the app as a shortcut (online-only; no offline cache):
- iPhone (Safari): Share → Lägg till på hemskärmen.
- Android (Chrome): Menu → Lägg till på startskärmen / Installera app.
The build includes manifest.webmanifest and icon.svg so the shortcut shows the name Check-in and the teal icon.
| Path | Purpose |
|---|---|
deploy/Caddyfile.snippet |
Paste into existing Caddy config |
deploy/docker-compose.yml |
checkin-api container (127.0.0.1:3001) |
scripts/deploy.sh |
Build web, publish static to /var/www/checkin, restart API container |
Deploy slice tracked as GitHub issue #10 (closed).