# 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 ``, 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