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.
docs/development.md states the rules the code now enforces or keeps:
server-only modules and the import-graph test (which now also forbids
the daily pick and the region locator from client bundles), route
decides best-effort and the library never swallows, typed failure kinds
mapped to statuses, claim before write, one module per storage concern
through the guarded store, useEffectEvent for imperative callbacks,
unprefixed logical keys, three fixed region levels, and the response
contract. The parameter rule now says what it always meant: React
components destructure props. @radix-ui/react-tabs had no component
using it. Stale lines about tabs, pagination, a leaderboard migration
and a sub-second suite are corrected.
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.
/api/daily opens an ordinary session on a panorama picked
deterministically from the day and cached in Redis for two days, so
the Postgres draw and the Mapillary lookup happen once a day. Days
roll over at midnight Vietnam time. The round is scored by /api/guess
like any other and counted under its own level in the statistics.
There is no daily leaderboard: the only identity is a cookie and a
localStorage name, so a dated board would be won by whoever opened the
most private windows. The browser keeps the finished round and replays
it on a revisit, and tracks the streak of consecutive days. The home
page carries the challenge card and the result dialog shares the
outcome as a squares line.
Below 3 points the result dialog says whether the guess had the right
district or province, located server-side from the boundaries. The
reveal links the spot on OpenStreetMap, and a Share button builds a
squares line with the region's URL. Region pages and the root carry
Open Graph metadata for link previews.
An expired round is worded as expired rather than as a failed save. A
skipped round gets a fresh session id so the unawaited delete cannot
kill the next round. Below sm the theme switch collapses to one cycling
button and the tally hides, so the mute control stays on a 360px
screen. The panorama viewer loads on demand, taking three.js out of
the first load.
Records what actually fires rather than what the plan intended: the
click sound is limited to Play and Back, and the error tone covers the
viewer failing to construct, not panorama-error, which falls back to a
flat image and leaves the round playable.
Also notes that the loop is mounted app-wide, which is what lets it play
unbroken across the menu and a round -- and means it plays on Credits
and the debug pages too.
game-flow gains the re-casing-only rule and its percent-encoding exception
(/game/%74phcm plays, because the router decodes the segment before the page
sees it; reading the raw form would mean giving up static rendering on all 85
pages), plus per-region titles.
project-structure gains InlineScript and the e2e entries that had drifted:
routing.spec.js is most of the suite now, and global-setup.js is otherwise
deletion bait.
Two plan corrections: a success criterion was ticked against DN-HOANGSA, which
is not a region at all -- that URL 404s and never demonstrated the coverage
panel it claimed to. TPHCM-CUCHI is the fixture actually tested. And the build
gate named `npm run build`, which exits 0 without building when a dev server
holds .next; build:check is the gate that cannot silently pass.
Reports carry the full evidence trail, including two claims corrected after
measurement contradicted them.
There was no app-wide not-found route, so any unmatched path got Next's
stock page: unstyled text over the full-bleed background, no footer, and
no link back -- its own full-height wrapper pushes the footer off screen.
Both 404s now share NotFoundPanel, since they say the same three things
and only the wording differs: what is missing, why it probably happened,
and the one way out. One action each, deliberately -- a "try again" on a
deterministically invalid URL is a button guaranteed to reproduce the
same page.
The region 404 is a client component for one reason: a thrown notFound()
is served from Next's error shell, which carries none of the root
layout's pre-paint theme script, so a dark-theme visitor got a white page
permanently. Re-applying on mount costs a brief light flash on a rare
page and fixes the palette. The app-wide route needs none of this -- an
unmatched path prerenders inside the root layout, where the script runs.
Both are covered by e2e now, including the theme, since neither failure
mode is visible from a status code.
Also records the Windows ISR case-collision finding in the debugger's
agent memory: a local next start case-folds cache keys on NTFS, so
redirect and dynamicParams behaviour cannot be verified from a local
production build. The region route no longer redirects, so nothing trips
it today, but the verification guidance outlives this change.
/game?region=TPHCM becomes /game/tphcm, backed by an app/game/[region]
dynamic segment. The region is a property of the page, so it belongs in
the path: the URL is now shareable and honest, an unknown region is a
real 404 instead of silently rendering as "Vietnam", and the page no
longer needs useSearchParams or its Suspense boundary.
It also makes per-region traffic visible. Vercel's Pages dimension strips
query parameters on every plan, so ?region= was invisible; one row per
region falls out of correct routing.
Slugs are lowercase, and regionSlug/regionFromSlug in lib/regions.js own
the conversion. Three call sites build these URLs -- the picker,
generateStaticParams, and the legacy redirect -- and a casing mismatch
between any two would split one region across two rows, which is the
whole point of the move.
An unusual casing renders rather than redirecting to the canonical form.
A redirect on a prerendered route gets cached as that route's response:
on a case-insensitive filesystem the ISR cache folds /game/DNA onto
/game/dna, and a cached redirect that has lost its Location header then
answers the canonical URL with a 307 to nowhere for the life of the
process. Reproduced on Windows against next start; no redirect on the
route means no such mechanism anywhere.
Legacy ?region= and ?location= redirect from app/game/page.js rather
than next.config.mjs, because a config redirect forwards the source
query to the destination and would park ?region= on /game/tphcm
permanently. The legacy value is encodeURIComponent'd: it reaches a
Location header unvalidated, where a raw CRLF makes Node throw a 500
instead of the honest 404, and a raw ../ would be normalised onto
another path.
A region-less /game now plays the country rather than defaulting to
TPHCM, which was never a stated default -- just where the old query
chain happened to end.
API routes keep their query params. They are fetch calls, not
navigations, so a path segment buys nothing there.
Also repairs four specs that were already failing on main: three
asserted that the landing page shows no username prompt, which the
landing-prompt change contradicted, and one expected the build sha and
its copy button to be a single element, which splitting them
contradicted.
Draws now exclude the last 50 panoramas a browser has been shown, so
grinding one district no longer serves the same street corner twice.
An anonymous httpOnly vng_pid cookie carries the identity. The username
was not usable for this: it lives in localStorage, is renameable, and is
shared by anyone who types it, so a rename would wipe the history and a
name collision would merge two players'. The cookie holds nothing else
and is never joined to a username or a score.
The history is a preference, not a rule. Where excluding it would empty
a small region's pool, fetchRegionPanorama drops it and allows a repeat:
a repeat always beats telling a player that a region they can see has no
coverage. Only a redraw that finds something logs the downgrade, so a
region mid-reseed holding zero rows is not blamed on the filter.
Locations are recorded when the round is created rather than at guess
time, so a skipped round also counts as seen. A Redis failure on either
end costs a repeat, never a round.
Stored as a JSON array in one string key on the existing adapter rather
than a Redis LIST, which would need four new primitives and a new value
type in the in-memory fake to hold fifty short strings. The
read-modify-write is not atomic; the worst case is one dropped entry.
pano-history joins the client-safety FORBIDDEN list: its newest entry is
the live round's answer id, and a panorama id is one Mapillary lookup
from the coordinates.
public/bg.png on one fixed layer under the whole app, through next/image so
the 2.4MB PNG is served as a ~167KB WebP resized to the viewport rather than
as a CSS background nobody can optimise.
.vn-surface -- the ground every page sits on -- becomes translucent so the art
reads through it; opaque panes (cards, the game header, the panorama surround)
still cover it. New --z-backdrop rung is the ladder's only negative value.
Region size no longer stretches the score thresholds. A guess is graded on
absolute precision, so a point means the same thing on the district, province
and country board, and the headline score matches what every level is credited.
Drops the per-round bands payload and its client plumbing: with one ladder the
result dialog reads the constant directly.
Document the z-index tokens and the isolate-not-out-bid rule for third-party
ladders, the footer and action-bar height tokens, how the game screen's
vertical budget and safe areas constrain floating chrome, which Leaflet chrome
each phone map state shows, and why percentage heights collapse below the
sticky-footer column.
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.
Extract shared app bar (Home, DebugNav, ThemeToggle) to debug/layout.js,
consolidate tools as cards in debug/page.js, move bbox/Mapillary tester
to debug/bbox/page.js, apply shared shell to debug/coverage/page.js with
RegionSelect title row, and update project-structure docs to reflect new
debug section organization.
Leaderboard distance colors now computed against each board's own region's
scoring ladder, matching how boards are credited. 2km distance shows green
on country board (2 points) and red on district board (0 points). Remove
dead cdnjs config from ResultMap. Update project-structure.md.
- panoramas + pano_provinces tables replace 28MB bundled JSON; pano-index.js
now draws via cached COUNT + ORDER BY id OFFSET with rejection sampling,
composite (province,id)/(district,id) indexes support the skip
- scripts/seed-pano-db.mjs validates pipeline artifacts (the old real-data
vitest invariants, extracted to scripts/lib/pano-artifacts.mjs), stages
into panoramas_next, verifies, renames into place in one transaction,
keeps panoramas_old as backup; --province reseeds in place; --check
validates only
- pipeline writes gitignored data-build/panos/; pano barrel removed
- tests run against PGlite mocked in at the @neondatabase/serverless
boundary, mirroring the fake-upstash pattern; fixtures replace real data
- infrastructure errors rethrow instead of reading as missing coverage;
session ids via crypto.randomUUID (uuid package dropped)
Reconcile every doc with the shipped code. All six described a flat
five-city model, and features.md, tech-stack.md, game-flow.md and
project-structure.md still documented the dart-throw over /images?bbox=
that the prebuilt panorama indexes replaced.
project-overview.md gains a Coverage note that classifies absent coverage
into its three causes -- not yet added, no street imagery, missing from
the boundary -- because a note that only says "partial" teaches
maintainers to ignore real gaps. Cu Chi is named as the one instance of
the third, which is the only one that is a defect.
A context hook had been denying access to src/app/debug/coverage/page.js,
so this also lands the two items earlier phases recorded as
undeliverable: the api/debug/city-coverage -> region-coverage rename, and
the page's migration from a flat city list to RegionSelect. With its last
caller gone, game.js drops CITIES, cities, cityNames, cityCenters and
cityBboxes; getCityIndex, indexedCities and fetchCityPanorama take names
that match what they now take.
Selecting a district with no boundary returned a 400 and left the
previous region's panorama count and outline on screen beside the error,
so Ho Chi Minh's 184,938 read as Cu Chi's. The error path now clears
everything derived from the previous region, and a null boundary removes
the outline rather than skipping the redraw.
Two plan-time claims did not survive contact with the code and the docs
follow the code: VN scores like any other node rather than being a
zero-scoring exploration mode, and the fan-out credits two levels when a
panorama falls outside every district outline.
The Next.js starter left five SVGs in public/ that nothing references.
Eight shadcn primitives were vendored in but rendered nowhere: avatar,
form, popover, progress, separator, sheet, table and tooltip. Removing
them frees five Radix packages plus react-hook-form, its resolvers and
zod, none of which the app had wired up.
getAccumulatedScoreMessage was imported by GameClient and never called.
getRandomCityLocationFromBbox had no caller at all, so it goes with its
tests rather than leaving coverage pointed at unreachable code.
The docs claimed a form and validation stack the project never used, and
listed components that are no longer vendored.