mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-14 02:16:41 +00:00
npm run leaderboard:import writes a decrypted backup back to Redis. It is a dry run until --apply, prints the destination prefix first, and accepts only score and distance board keys. By default each backed-up score is set and newer players are kept; --replace rewrites each board exactly. Members go in up to 1000 per ZADD through a new zAddMany helper. docs/leaderboard-backup.md covers what the backup holds, decrypting it and restoring it. The gitignore now covers the decrypted and encrypted file names the workflow uses, not only the dated export.
367 lines
19 KiB
Markdown
367 lines
19 KiB
Markdown
# Development Guidelines
|
|
|
|
## Development Commands
|
|
|
|
- `npm run dev` - Start development server with Turbopack
|
|
- `npm run dev:clean` - Same, after clearing the `.next` build cache
|
|
- `npm run build` - Build the application for production (writes `.next`)
|
|
- `npm run build:check` - Same build for local verification, into `.next-check`
|
|
- `npm start` - Start production server
|
|
- `npm run lint` - Run ESLint
|
|
- `npm test` - Run the test suite against the in-memory Redis and Postgres fakes
|
|
- `npm run test:watch` - Re-run tests on change
|
|
- `npm run test:integration` - Run the same suite against a local Redis
|
|
- `npm run redis:up` / `npm run redis:down` - Start/stop that local Redis
|
|
- `npm run test:e2e` - Playwright browser smoke tests, fully stubbed, no env
|
|
needed (first time: `npx playwright install chromium`)
|
|
|
|
## Important Development Guidelines
|
|
|
|
### JavaScript Only
|
|
- This project uses **JavaScript exclusively**
|
|
- Never create or suggest TypeScript files (.ts, .tsx)
|
|
- All components and utilities should be .js or .jsx files
|
|
|
|
### Function Parameters
|
|
- Library functions, scripts and route helpers use **individual parameters**
|
|
instead of object destructuring: `function(param1, param2)`, not
|
|
`function({param1, param2})`
|
|
- React components are the exception: they take one props object and
|
|
destructure it, which is what every component here does and what React
|
|
expects
|
|
|
|
### Conventions the code follows
|
|
Each of these is enforced by a test or a lint rule where one exists; the rest
|
|
are the shape the code has and new code should keep.
|
|
|
|
- **Server-only modules say so** in their header comment, and the import-graph
|
|
test in `tests/regions.test.js` keeps `src/lib/regions.js` from reaching
|
|
`pano-index.js`, `pano-db.js` or `daily.js`. Anything that touches exact
|
|
panorama coordinates is server-only.
|
|
- **The route decides what is best-effort; the library never swallows.** A
|
|
library function throws on failure. A route wraps the writes it can afford
|
|
to lose (recent-location history, distance records, statistics) in a small
|
|
`...OrIgnore` / `...OrNone` helper with a comment saying why, and lets the
|
|
load-bearing write (the session, the score) fail loudly.
|
|
- **Failure kinds are classes, not message prefixes**: `DryPoolError` and
|
|
`UpstreamError` in `src/lib/errors.js`. A route maps them to statuses (404
|
|
for a dry pool, 502 for an upstream failure); nothing string-matches an
|
|
error message. `UpstreamError.code` tells a fact about the image (`'gone'`)
|
|
from a fact about the service (`'timeout'`, `'network'`, `'http'`,
|
|
`'auth'`); only the former may change which panorama a daily serves.
|
|
- **Claim before write.** A session is consumed with an atomic `DEL` before any
|
|
board is written, and the fan-out after it settles per level rather than
|
|
all-or-nothing, because nothing after the claim can be retried.
|
|
- **One module per browser-storage concern**, all reading through
|
|
`src/lib/storage.js` (never throws, keeps a write the browser refuses in
|
|
memory for the visit, notifies watchers) and rendered through
|
|
`useStoredValue` in `src/lib/use-stored-value.js`. No component seeds state
|
|
from storage in an effect; the `react-hooks/set-state-in-effect` rule is an
|
|
error.
|
|
- **Callback props reach imperative handlers through `useEffectEvent`**, not
|
|
refs written during render; the `react-hooks/refs` rule is an error. The one
|
|
data ref written in render (`CoverageMap.js`) carries an inline disable and
|
|
its reason.
|
|
- **Logical Redis keys are unprefixed** and the adapter in `src/lib/upstash.js`
|
|
applies `KEY_PREFIX`. A new Redis command means a new adapter function and a
|
|
matching method on `tests/fake-upstash-redis.js`.
|
|
- **Three region levels, fixed**: country, province, district. Codes are
|
|
uppercase inside the app and lowercase in URLs (`regionSlug`); players see
|
|
`regionName()`, the accented form.
|
|
- **`URLSearchParams.get` is read with `||`, not `??`**: `?region=` yields
|
|
`''`, which must mean "absent".
|
|
- **API responses are `{ success, ... }` on success and
|
|
`{ success: false, error, reason? }` on failure**, with a real HTTP status.
|
|
`/api/guess` returns `gameResult` alone; the e2e stubs in
|
|
`tests/e2e/helpers.js` must carry every key a route emits, and
|
|
`tests/e2e-stub-contract.test.js` checks that they do.
|
|
|
|
### File Modification Policy
|
|
- **Only modify source code files**, documentation (/docs), and plans (/plans)
|
|
- Configuration changes (package.json, next.config.mjs, eslint.config.mjs, components.json, etc.) should be highlighted for manual processing
|
|
- Environment files and build settings require manual review
|
|
|
|
### Security Best Practices
|
|
- Never expose or commit secret keys and sensitive information
|
|
- Server-side session management prevents client-side coordinate access (Redis TTL 30 min)
|
|
- All geographic calculations must be performed server-side
|
|
- Session cleanup after guess submission for security
|
|
|
|
### Environment Variables
|
|
|
|
Copy `.env.example` to `.env` and fill it in; it lists every variable the app
|
|
reads, with placeholders only. The sections below explain each one.
|
|
|
|
**Redis (required)**:
|
|
- Either `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN` (vanilla Upstash)
|
|
- Or `KV_REST_API_URL` + `KV_REST_API_TOKEN` (Vercel Marketplace)
|
|
- Optional: `KEY_PREFIX` (default: `vngeoguessr:`) for multi-tenant DB sharing
|
|
|
|
**Postgres (required)**:
|
|
- `DATABASE_URL` (or the `POSTGRES_URL` alias) - Neon connection string holding
|
|
the panorama index. Provision Neon from the Vercel Marketplace and pull the
|
|
vars with `vercel env pull`, then seed with `npm run data:seed`
|
|
|
|
**Mapillary (required)**:
|
|
- `MAPILLARY_ACCESS_TOKEN` - Mapillary API token for image fetching
|
|
|
|
**Debug API (optional)**:
|
|
- `DEBUG_ACCESS_KEY` - In production the `/api/debug/*` routes return 404
|
|
unless the request carries this value as the `x-debug-key` header or the
|
|
`vng_debug` cookie. Unset, they are closed in production. Production is
|
|
`VERCEL_ENV=production`, or `NODE_ENV=production` where `VERCEL_ENV` is
|
|
absent; development, tests and preview deployments are always open
|
|
|
|
**Link previews (optional)**:
|
|
- `NEXT_PUBLIC_SITE_URL` - Absolute origin for Open Graph URLs. Defaults to
|
|
Vercel's production URL, then `http://localhost:3000`
|
|
|
|
**Map tiles (optional)**:
|
|
- `NEXT_PUBLIC_GEOAPIFY_KEY` - Geoapify API key. When set (at build time), all
|
|
Leaflet maps serve tiles from Geoapify, whose free tier permits commercial
|
|
use; unset, they fall back to the OSM public tile server (fine for dev/e2e,
|
|
not for a commercial deployment). The key is public by design — restrict it
|
|
to the production domain in the Geoapify dashboard. Provider choice lives in
|
|
`src/lib/map-tiles.js`
|
|
|
|
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, encrypts the JSON and keeps it as a workflow
|
|
artifact for 90 days. It needs the repository secrets `KV_REST_API_URL`,
|
|
`KV_REST_API_TOKEN` (and `KEY_PREFIX` if the deployment sets one) and
|
|
`BACKUP_PASSPHRASE`; until they are set the job fails harmlessly. The
|
|
artifact is encrypted because the repository is public, any signed-in GitHub
|
|
user can download an artifact, and the boards list every player name.
|
|
What the backup covers, how to decrypt it and how to restore it:
|
|
[Leaderboard Backup and Restore](leaderboard-backup.md).
|
|
|
|
### Testing & Completion
|
|
|
|
Tests live in `tests/` and cover the logic in `src/lib/` (scoring and distance,
|
|
the Upstash key adapter, game sessions, the region tree, the panorama indexes
|
|
and the leaderboards), the API routes, the district-assignment and env-loading
|
|
script helpers, and the e2e stubs' agreement with the routes. Run
|
|
`npm test` after changing anything under `src/lib/` or `src/data/`.
|
|
|
|
The suite runs against two backing stores, from one set of test files:
|
|
|
|
- `npm test` uses `tests/fake-upstash-redis.js`, an in-memory stand-in mocked in
|
|
at the `@upstash/redis` boundary, and PGlite for Postgres. No service, no
|
|
Docker; about 25 seconds for the whole suite, most of it PGlite start-up.
|
|
This is the default.
|
|
- `npm run test:integration` runs the same files against a real Redis. Two of
|
|
them skip: one asserts a response shape only an older SDK produces, and one
|
|
fast-forwards half an hour to watch a session expire.
|
|
|
|
Running both is what keeps the fake honest. A behaviour the fake gets wrong
|
|
shows up as a green unit run and a red integration run.
|
|
|
|
The panorama queries run against PGlite -- real Postgres in-process -- mocked in
|
|
at the `@neondatabase/serverless` boundary (`tests/mock-neon.js`), in both
|
|
lanes; only Neon's own endpoint speaks the production driver's protocol, so
|
|
there is no separate integration lane for it. Fixture rows come from
|
|
`tests/pano-fixtures.js`. The data-quality invariants that used to run in
|
|
`tests/pano-index.test.js` against the committed JSON now run inside
|
|
`scripts/seed-pano-db.mjs`, which refuses to upload artifacts that violate
|
|
them (`npm run data:seed -- --check` runs just that gate).
|
|
|
|
The UI has a Playwright smoke lane: `npm run test:e2e` runs `tests/e2e/*.spec.js`
|
|
in Chromium against a dev server it starts (or reuses) itself. Every `/api/*`
|
|
call and the panorama image are stubbed at the browser boundary
|
|
(`tests/e2e/helpers.js`), so it needs no Redis, Neon, Mapillary, or `.env`.
|
|
It covers the region picker, the username modal, one full round to the
|
|
reveal, and the `/game/{slug}` routing contract (legacy `?region=` redirects,
|
|
unknown-region 404s). Anything beyond those flows (real panoramas, real scoring round-trips)
|
|
is still manual: inform the user when work is complete. Do NOT start
|
|
development servers - user handles manual testing themselves.
|
|
|
|
### When the dev server needs restarting
|
|
|
|
Almost never. Measured against this project:
|
|
|
|
| Change | Behaviour |
|
|
|---|---|
|
|
| Components, pages | Fast Refresh, ~200ms |
|
|
| API routes | Hot reloaded, ~1.6s |
|
|
| Server libs under `src/lib/` | Hot reloaded, ~1.8s |
|
|
| Index data under `src/data/` | Hot reloaded, ~1.1s |
|
|
| `next.config.mjs` | Next restarts itself |
|
|
| `.env` | Reloaded in place |
|
|
|
|
Do not run `npm run build` while a dev server is running. Both own the same
|
|
output directory, so the build replaces manifests the dev server is still
|
|
reading, and it logs a burst of `ENOENT: no such file or directory` errors for
|
|
manifest files under that directory. Measured: 110 such errors from one
|
|
overlapping build, and none when the two do not overlap. They are noise from the
|
|
collision, not a fault in the code being edited.
|
|
|
|
Use `npm run build:check` instead. It builds into `.next-check` and leaves the
|
|
dev server alone. The plain `build` script is unchanged, because that is what the
|
|
deployment platform runs and it must keep writing the default directory.
|
|
|
|
If a change genuinely will not take effect, the build cache is usually
|
|
inconsistent rather than the code being wrong. That happens after a build is
|
|
killed part-way or a different Next version writes into `.next`, and it shows up
|
|
as the dev server serving 500s for everything. `npm run dev:clean` clears the
|
|
cache and starts fresh; a plain restart will not fix it.
|
|
|
|
### Running Upstash locally
|
|
|
|
Upstash itself is cloud-only, but `@upstash/redis` speaks HTTP REST, so any
|
|
server exposing that REST surface is indistinguishable to the app.
|
|
`docker-compose.yml` runs the proxy Upstash's own docs recommend, SRH
|
|
(`hiett/serverless-redis-http`), in front of a real Redis:
|
|
|
|
```bash
|
|
npm run redis:up # redis + SRH on http://localhost:8079
|
|
npm run redis:down # stop and discard the data
|
|
```
|
|
|
|
`npm run test:integration` points at it automatically. To use it for `npm run
|
|
dev` as well, set in `.env`:
|
|
|
|
```
|
|
UPSTASH_REDIS_REST_URL=http://localhost:8079
|
|
UPSTASH_REDIS_REST_TOKEN=vngeoguessr-local-token
|
|
```
|
|
|
|
SRH accepts commands only as a JSON array POSTed to `/`, which is what the SDK
|
|
sends; the path form Upstash also supports (`GET /set/key/value`) returns 404.
|
|
|
|
### Rebuilding the generated region data
|
|
|
|
`src/data/regions/` and `src/data/boundaries/` are generated and committed;
|
|
the panorama index is generated into `data-build/panos/` (gitignored) and
|
|
served from Postgres. Run the scripts in this order -- each reads what the
|
|
previous one wrote:
|
|
|
|
```bash
|
|
npm run data:boundaries # OSM/Nominatim -> boundaries + region tree
|
|
npm run data:panos # Mapillary z14 tiles -> per-province pano artifacts
|
|
npm run data:districts # clip + partition panos by district; writes counts.js
|
|
npm run data:seed # validate the artifacts and upload them to Neon
|
|
```
|
|
|
|
Each underlying script's header comment carries its flags and its cost; read it
|
|
before running. Two things worth knowing up front:
|
|
|
|
- `npm run data:repartition` reruns the boundaries step with `--regenerate`
|
|
(rebuilds the provinces, the barrel and the tree from what is already on
|
|
disk) and then the districts step, with no network calls. Use it after
|
|
hand-editing a boundary.
|
|
- `npm run data:panos` spends Mapillary tile requests against a 50,000/day cap.
|
|
`npm run data:districts` spends none -- it only re-partitions indexes that
|
|
already exist, and refuses to rewrite `counts.js` on a partial run.
|
|
- `npm run data:seed` spends none either. The full run stages into
|
|
`panoramas_next`, verifies, and renames into place, keeping the previous
|
|
generation as `panoramas_old`; `-- --province=DN` reseeds one province in
|
|
place; `-- --check` validates the artifacts without touching the database.
|
|
Deploy the app and the seed in either order: the schema is unchanged, and
|
|
running processes keep serving their cached counts until they recycle.
|
|
|
|
## Styling Conventions
|
|
|
|
One design system for every page, including debug pages. It is Tailwind 4 +
|
|
shadcn tokens, defined in `src/app/globals.css` (`:root` and `.dark` blocks);
|
|
the theme toggle works only on pages that stay inside it.
|
|
|
|
- **Page background**: `flex-1 vn-surface` on the page root. `layout.js`
|
|
wraps every page in a `min-h-dvh` flex column whose last row is the credit
|
|
footer, so a page fills the viewport minus that strip rather than claiming
|
|
the whole viewport itself.
|
|
- **Surfaces**: shadcn `Card` with `bg-card border-border shadow-sm`; nested
|
|
panels `bg-muted/50`. Never `bg-white/10` glassmorphism or raw hex.
|
|
- **Text**: `text-foreground` for headings, `text-muted-foreground` for
|
|
secondary, `text-card-foreground` inside cards. Never `text-white` on a
|
|
themed surface.
|
|
- **Accent**: the `brand` token family (`text-brand`, `bg-brand-subtle`,
|
|
`text-brand-subtle-foreground`) -- see `RegionPicker.js` for the idiom.
|
|
- **Result semantics**: the `success` / `warning` / `danger` token pairs
|
|
(each with a `-foreground`) for good/near/far judgements, and `rank-gold` /
|
|
`rank-silver` / `rank-bronze` for podium tints -- see `RoundResultDialog.js`
|
|
and `LeaderboardList.js`. Each token carries its own light and dark value in
|
|
`globals.css`, so call sites never write a `dark:` variant.
|
|
- **Borders/dividers**: `border-border`.
|
|
- **Components**: use the vendored `src/components/ui/` primitives; add
|
|
missing ones with the shadcn CLI rather than hand-rolling.
|
|
- **Icons**: Lucide only; no emoji glyphs in interactive chrome.
|
|
|
|
### Layering
|
|
|
|
One z-index ladder for the app, in `globals.css` `:root`, low to high:
|
|
`--z-backdrop` (the key art behind every page) · `--z-pane-chrome` (controls
|
|
over a map or panorama) · `--z-floating` (the phone minimap) · `--z-appbar`
|
|
(the game action bar) · `--z-fab` · `--z-overlay` (dialog scrim) · `--z-modal`
|
|
(dialog content) · `--z-popover` (a select opened inside a dialog). Use
|
|
`z-(--token)`; never an arbitrary `z-[…]`.
|
|
|
|
`--z-backdrop` is the only negative rung: `AppBackground` is a fixed child of
|
|
`<body>`, so it paints over the canvas and under every page. The ground it
|
|
shows through is `.vn-surface`, which is translucent for exactly that reason --
|
|
keep it that way, and keep panes that must stay legible (cards, the game
|
|
header, the panorama surround) opaque.
|
|
|
|
Third-party ladders are contained rather than out-bid: Leaflet (200-1000) and
|
|
Photo Sphere Viewer (50-9999) each live inside a pane carrying `isolate`
|
|
(`GuessMapPanel`, its inner map wrapper, and the panorama pane in
|
|
`GameClient.js`), so their values never reach the root stacking context and the
|
|
pane's own chrome can sit on `--z-pane-chrome`.
|
|
|
|
Two heights other elements measure against are tokens too: `--footer-h` (the
|
|
credit strip) and `--action-bar-h` (the game's submit bar). Offset against
|
|
those, not against the pixel value they happen to hold.
|
|
|
|
### Fixed-surface layout
|
|
|
|
The game screen does not scroll, so its vertical budget is the constraint --
|
|
about 271px of content on a 667x375 landscape phone. Chrome that floats over
|
|
the panorama goes in flow where it can (the how-to-play hint rides
|
|
`PanoramaViewer`'s `topBarSlot`, beside the Mapillary credit) and is sized
|
|
against the viewport where it cannot (the collapsed minimap is
|
|
`min(9rem,30vh)`). `viewport.viewportFit` is `cover`, so the game header and
|
|
content box pad themselves with `env(safe-area-inset-left/right)` and the
|
|
footer strip owns `env(safe-area-inset-bottom)`.
|
|
|
|
Third-party map chrome is sized for a full map, so each state gets what fits.
|
|
On the phone guess map, Leaflet's tile credit and zoom buttons are hidden while
|
|
the minimap is a collapsed thumbnail (behind a cover that swallows clicks, with
|
|
the credit wrapping to five lines) and return when it expands, where the zoom
|
|
control is lifted clear of the two-line credit. The permanent tile attribution
|
|
lives on `/credits`, which carries all three credits the Geoapify free plan
|
|
requires.
|
|
|
|
Inside these surfaces, size with flex or insets -- never a percentage height.
|
|
Below `layout.js`'s column every ancestor's height comes from flexing against a
|
|
`min-height`, which is indefinite, so `h-full` and `h-[45%]` silently collapse
|
|
to content height. Use `flex-1` for a share of the space and `absolute inset-0`
|
|
to fill a positioned box.
|
|
|
|
Raw palette colors are allowed only where the color must not follow the theme:
|
|
the `bg-neutral-900` surround behind panoramas and full-bleed maps (dark in
|
|
both themes on purpose), and the Leaflet marker hexes in `ResultMap.js`
|
|
(`MARKER_COLORS`), which render outside the CSS token cascade.
|
|
|
|
## shadcn/ui Configuration
|
|
|
|
- **Style**: "new-york"
|
|
- **Path aliases**: Configured for `@/components`, `@/lib`, etc.
|
|
- **Components**: Use JavaScript (.js) not TypeScript
|
|
- **CSS variables**: Enabled for theming
|
|
- **Components location**: `src/components/ui/`
|
|
|
|
## Code Style Standards
|
|
|
|
- Follow existing code patterns in the codebase
|
|
- Use established libraries and utilities already present
|
|
- Maintain consistent naming conventions
|
|
- Write descriptive comments where the reason for the code is not obvious from
|
|
the code -- this codebase leans on them heavily to record why a measured
|
|
approach was chosen over the obvious one
|
|
- Prefer editing existing files over creating new ones |