Introduces map-tiles.js module to centralize tile provider logic, supporting Geoapify (paid, high quality) with API key fallback to OSM public server. Updates LeafletMap, ResultMap, and CoverageMap to use the new abstraction, improves maintainability and consistency across the codebase.
12 KiB
Development Guidelines
Development Commands
npm run dev- Start development server with Turbopacknpm run dev:clean- Same, after clearing the.nextbuild cachenpm run build- Build the application for production (writes.next)npm run build:check- Same build for local verification, into.next-checknpm start- Start production servernpm run lint- Run ESLintnpm test- Run the test suite against the in-memory Redis and Postgres fakesnpm run test:watch- Re-run tests on changenpm run test:integration- Run the same suite against a local Redisnpm run redis:up/npm run redis:down- Start/stop that local Redisnpm 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
- All functions should use individual parameters instead of object destructuring
- Use
function(param1, param2)instead offunction({param1, param2}) - This applies to React components, utility functions, and API handlers
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
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 thePOSTGRES_URLalias) - Neon connection string holding the panorama index. Provision Neon from the Vercel Marketplace and pull the vars withvercel env pull, then seed withnpm run data:seed
Mapillary (required):
MAPILLARY_ACCESS_TOKEN- Mapillary API token for image fetching
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 insrc/lib/map-tiles.js
For local Redis without an Upstash account, see Running Upstash locally below.
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, and the leaderboard migration. 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 testusestests/fake-upstash-redis.js, an in-memory stand-in mocked in at the@upstash/redisboundary. No service, no Docker, well under a second. This is the default.npm run test:integrationruns 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, and one full round to the
reveal. 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:
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:
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:repartitionreruns 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:panosspends Mapillary tile requests against a 50,000/day cap.npm run data:districtsspends none -- it only re-partitions indexes that already exist, and refuses to rewritecounts.json a partial run.npm run data:seedspends none either. The full run stages intopanoramas_next, verifies, and renames into place, keeping the previous generation aspanoramas_old;-- --province=DNreseeds one province in place;-- --checkvalidates 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.
Migrating leaderboards
scripts/migrate-leaderboards.mjs backfills the two boards whose region code
changed (Da Lat into Lam Dong, Duc Hoa into Long An). It is dry-run by
default and copies rather than moves, so the source boards survive.
Deploy first, then migrate. The new code writes to the new keys immediately; a
migration run before the deploy would copy a board that is still being written
to under its old name. Run npm run leaderboard:migrate with no flags for a
dry run; add --apply --confirm-prefix=<key prefix> to write (the confirm
flag must echo the deployment's own KEY_PREFIX, or the script refuses), and
--restore=<backup.json> --confirm-prefix=<key prefix> to undo from the
backup file the apply run wrote. --dry-run is a guard, not a mode switch --
combining it with --apply throws instead of guessing which one you meant.
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:
min-h-dvh vn-gradient-bgon the page root. - Surfaces: shadcn
Cardwithbg-card border-border shadow-sm; nested panelsbg-muted/50. Neverbg-white/10glassmorphism or raw hex. - Text:
text-foregroundfor headings,text-muted-foregroundfor secondary,text-card-foregroundinside cards. Nevertext-whiteon a themed surface. - Accent: the
brandtoken family (text-brand,bg-brand-subtle,text-brand-subtle-foreground) -- seeRegionPicker.jsfor the idiom. - Borders/dividers:
border-border. - Components: use the vendored
src/components/ui/primitives; add missing ones with the shadcn CLI rather than hand-rolling.
Raw palette colors are allowed only where the color itself is the meaning and
must not follow the theme: score bands and podium colors
(RoundResultDialog.js, LeaderboardList.js, always with a dark: variant),
success/error badges (bg-green-600), and the bg-neutral-900 surround
behind panoramas and full-bleed maps, which stays dark in both themes on
purpose.
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