diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d1ce40 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +# The three gates a change has to pass, run on every push and pull request. +# Needs no secrets: every page that touches Redis, Postgres or Mapillary is +# rendered on demand, so compiling the app never opens a connection. +name: CI + +on: + push: + branches: [main, dev] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm run build:check diff --git a/.github/workflows/leaderboard-backup.yml b/.github/workflows/leaderboard-backup.yml new file mode 100644 index 0000000..b7c2e87 --- /dev/null +++ b/.github/workflows/leaderboard-backup.yml @@ -0,0 +1,32 @@ +# Weekly copy of every leaderboard, kept as a workflow artifact for 90 days. +# Upstash's free plan has no scheduled backups, and the boards are the only +# player data the game holds. Needs two repository secrets, the same pair the +# app reads: KV_REST_API_URL and KV_REST_API_TOKEN (plus KEY_PREFIX if the +# deployment sets one). +name: Leaderboard backup + +on: + schedule: + - cron: '17 3 * * 1' + workflow_dispatch: + +jobs: + export: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run leaderboard:export + env: + KV_REST_API_URL: ${{ secrets.KV_REST_API_URL }} + KV_REST_API_TOKEN: ${{ secrets.KV_REST_API_TOKEN }} + KEY_PREFIX: ${{ secrets.KEY_PREFIX }} + - uses: actions/upload-artifact@v4 + with: + name: leaderboard-backup + path: leaderboard-backup-*.json + retention-days: 90 diff --git a/docs/development.md b/docs/development.md index 52f908a..a357cd8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -76,6 +76,18 @@ reads, with placeholders only. The sections below explain each one. For local Redis without an Upstash account, see *Running Upstash locally* below. +### Continuous integration + +`.github/workflows/ci.yml` runs `npm run lint`, `npm test` and +`npm run build:check` on every push to `main` or `dev` and on every pull +request. It needs no secrets: compiling the app opens no connection. + +`.github/workflows/leaderboard-backup.yml` runs `npm run leaderboard:export` +every Monday and on demand, and keeps the JSON as a workflow artifact for 90 +days. It needs the repository secrets `KV_REST_API_URL` and +`KV_REST_API_TOKEN` (and `KEY_PREFIX` if the deployment sets one); until they +are set the job fails harmlessly. + ### Testing & Completion Tests live in `tests/` and cover the logic in `src/lib/` (scoring and distance, diff --git a/docs/project-structure.md b/docs/project-structure.md index d5a5e97..8362a5c 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -166,6 +166,8 @@ Each carries a header comment with its flags and its cost. - `seed-pano-db.mjs` - Validates the artifacts and uploads them to Neon - `build-check.mjs` - Production build into `.next-check` - `stats.mjs` - Print the daily play statistics from Redis (`npm run stats`) +- `export-leaderboards.mjs` - Dump every board to JSON for backup + (`npm run leaderboard:export`; run weekly by a GitHub Actions workflow) - `lib/assign-districts.mjs` - District assignment shared by the two pano scripts - `lib/pano-schema.mjs` - Panorama table DDL shared by the seed and the tests diff --git a/package.json b/package.json index d7da768..062da18 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "data:repartition": "npm run data:boundaries -- --regenerate && npm run data:districts", "data:seed": "node scripts/seed-pano-db.mjs", "stats": "node scripts/stats.mjs", + "leaderboard:export": "node scripts/export-leaderboards.mjs", "test:e2e": "playwright test" }, "dependencies": { diff --git a/plans/reports/implementation-260920-2313-improvement-rollout.md b/plans/reports/implementation-260920-2313-improvement-rollout.md new file mode 100644 index 0000000..4905a86 --- /dev/null +++ b/plans/reports/implementation-260920-2313-improvement-rollout.md @@ -0,0 +1,53 @@ +# Improvement rollout (2026-09-20) + +What shipped from the revised sequence in +[synthesis-260920-2201-improvement-brainstorm.md](synthesis-260920-2201-improvement-brainstorm.md), +all on branch `dev`, and what is left. Constraint honoured throughout: no +provider or stack change, nothing that costs money. + +## Shipped + +| Commit | Phase | What | +|---|---|---| +| `3314da1` | 0, 1 | Debug API closed in production behind `DEBUG_ACCESS_KEY`; bbox tester deleted. Shared username rule (Unicode letters, no `:`), finite coordinates before the session is consumed, UUID-only session ids, JSON-body guard, failure `reason`. Score boards untrimmed and ZINCRBY (ratchet fixed, concurrent rounds counted). Distance record best-effort after scoring. Daily stats counter + `npm run stats`. | +| `e649259` | 2 | Expired-round copy, region-hit line ("Right province, wrong district") located server-side, OpenStreetMap link on the reveal, Share button with squares text, Open Graph metadata, phone header collapse, fresh id after skip, PanoramaViewer loaded on demand (three.js out of first load). | +| `8450b75` | 3 | Daily challenge at `/daily`: deterministic pick per Vietnam day, cached 48h, no daily board, browser-side one-attempt and streak, home card, daily share text. | +| `6dbe2e0` | 4 | Vietnamese names: `nameVi` in the tree from the OSM queries, `regionName()` everywhere a name is shown, search matches both spellings, Geist `vietnamese` subset. | +| this | ops | CI workflow (lint, test, production compile) on push and PR. Weekly leaderboard export to a 90-day artifact. | + +Gates at the end: 348 tests pass, lint 0 errors / 21 warnings (all the +pre-existing localStorage-in-effect and callback-ref patterns), production +compile green with and without environment variables. + +Redis per completed round: about 23 commands (was 27). Distance boards remain +9 of them. + +## Manual steps for the maintainer + +- Set `DEBUG_ACCESS_KEY` in Vercel production if the debug pages should work + there; unset, the debug API is closed. +- Optional `NEXT_PUBLIC_SITE_URL` for absolute Open Graph URLs (Vercel's + production URL is the default). +- Repository secrets `KV_REST_API_URL`, `KV_REST_API_TOKEN` (and `KEY_PREFIX` + if set) for the backup workflow. +- One Vercel WAF rate-limit rule on `/api/*`. Free on Hobby, dashboard only. +- Merge `dev` to `main` to deploy. + +## Not done, and why + +- **Distance boards** still cost 9 commands a round. Every report questioned + them; removing them changes the leaderboard UI. Product call. +- **Open Graph image** per region. Metadata is text only. Optional polish. +- **Panorama movement, duels, curated landmarks, accounts, weekly boards**: + cut by the review round as wasted effort at this scale or as needing a + hosting model change. +- **e2e specs** were updated to the Vietnamese names but could not be run here + (no browser). Run `npm run test:e2e` locally before merging. + +## Watch after deploy + +- `npm run stats` after two weeks. Under ~20 players a day the next slice is + distribution, not mechanics. +- Whether the daily's cached Mapillary URL ever 403s inside its 48h window. +- Whether `Cá»§ Chi`-style accented names render in Geist on iOS (font subset + is loaded; glyph coverage not verified on a device). diff --git a/scripts/export-leaderboards.mjs b/scripts/export-leaderboards.mjs new file mode 100644 index 0000000..7dc91d9 --- /dev/null +++ b/scripts/export-leaderboards.mjs @@ -0,0 +1,52 @@ +// Write every leaderboard to one JSON file, for backup. +// +// Usage: node scripts/export-leaderboards.mjs [out.json] +// default output: leaderboard-backup-YYYYMMDD.json (gitignored) +// +// Needs the Upstash pair (UPSTASH_REDIS_REST_URL/_TOKEN or KV_REST_API_URL/ +// _TOKEN) in .env or the environment. Read-only. Every score and distance +// board is dumped whole, member and score, under its logical key, so the file +// can be replayed with ZADD against any prefix. + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { getUpstash, scanKeys, zRangeWithScores } from '../src/lib/upstash.js'; + +/** Read .env the way the other scripts do, without pulling in a dependency. */ +function loadEnvFile() { + if (!existsSync('.env')) return {}; + return Object.fromEntries( + readFileSync('.env', 'utf8') + .split(/\r?\n/) + .filter((line) => line && !line.startsWith('#') && line.includes('=')) + .map((line) => { + const at = line.indexOf('='); + return [line.slice(0, at).trim(), line.slice(at + 1).trim().replace(/^"(.*)"$/, '$1')]; + }) + ); +} + +for (const [key, value] of Object.entries(loadEnvFile())) { + process.env[key] ??= value; +} + +const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, ''); +const outPath = process.argv[2] ?? `leaderboard-backup-${stamp}.json`; + +const h = getUpstash(); +const keys = [...(await scanKeys(h, 'leaderboard:*')), ...(await scanKeys(h, 'distance:*'))].sort(); + +const boards = {}; +let members = 0; +for (const key of keys) { + // Ascending, whole set: the same order for every board, so two exports diff + // cleanly. + const entries = await zRangeWithScores(h, key, 0, -1, false); + boards[key] = entries.map(({ value, score }) => ({ member: value, score })); + members += entries.length; +} + +writeFileSync( + outPath, + JSON.stringify({ exportedAt: new Date().toISOString(), prefix: h.prefix, boards }, null, 1) + '\n' +); +console.log(`${keys.length} boards, ${members} members -> ${outPath}`);