From 064c1733b6405ff9437bc73f63de85e385b50e4f Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 20 Sep 2026 23:31:28 +0700 Subject: [PATCH] chore(ci): run the gates on every push and back the boards up weekly One workflow runs lint, the tests and the production compile on pushes to main and dev and on pull requests; it needs no secrets. A second exports every leaderboard to JSON each Monday and keeps it as a 90-day artifact, since the free Redis plan has no scheduled backups. The rollout report records what shipped, the manual steps, and what was left out. --- .github/workflows/ci.yml | 23 ++++++++ .github/workflows/leaderboard-backup.yml | 32 +++++++++++ docs/development.md | 12 +++++ docs/project-structure.md | 2 + package.json | 1 + ...ntation-260920-2313-improvement-rollout.md | 53 +++++++++++++++++++ scripts/export-leaderboards.mjs | 52 ++++++++++++++++++ 7 files changed, 175 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/leaderboard-backup.yml create mode 100644 plans/reports/implementation-260920-2313-improvement-rollout.md create mode 100644 scripts/export-leaderboards.mjs 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}`);