Rebased onto current main: the original commit only touched pnpm files and
versions main has since moved past, so it is replaced by a fresh npm update
within the declared ranges plus npm audit fix.
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.
checkout, setup-node and upload-artifact v4 still run on Node 20, which
GitHub deprecates and forces onto Node 24 with a warning on every run.
v7 of each runs on Node 24; nothing between v4 and v7 changes the
inputs these workflows use.
The 'some boards could not be updated' line rendered white on white and
only inside a details block that was absent when every write failed; it now
shows whenever the round is partial. Light-theme success, warning, danger
and muted tokens darkened to clear 4.5:1. A non-JSON server reply shows a
sentence, not the parser's message. A prefetched round older than twenty
minutes is dropped. Same-score count-up no longer flashes the old total,
the result map's resize timers are cleared, the first Escape in map search
only closes the list, the expanded minimap no longer claims aria-modal, a
panorama that fails to render reaches onError, and audio preferences read
through storage.js.
Every Upstash command and Neon query now has a deadline (5 s and 8 s): the
Redis client retried a hung connection past the browser's fifteen-second
wait, and the Neon client had no limit. Usernames are NFC-normalised so a
tone mark typed as a combining character is accepted and does not make two
board members of one name. The debug key is compared in constant time. A
panorama whose district the tree does not know fails the draw before a
session is written, not /api/guess after it is consumed. The country branch
of countPanos and the never-triggered per-city cap are gone.
A 403, a malformed answer, a missing thumbnail or a Redis error inside the
retry all counted as the panorama being deleted, so one such failure on the
cached pick handed players after it a different round under the same
number. UpstreamError now carries a 'gone' code for a Mapillary 400 or 404
and a missing thumbnail, and only that moves the day; everything else is
thrown. The Redis calls sit outside the retry.
Also: the draw budget caps the request in flight, the token travels in an
Authorization header rather than the URL, and the daily route's comment no
longer claims the daily credits a board.
PGlite start-up alone takes about seven seconds on an ARM host, so the
10s beforeAll default failed whole files at random. The weekly backup
fails instead of uploading an empty export, and both workflows run with
read-only permissions; CI cancels superseded runs.
Blocked storage kept a refused write nowhere, so the name prompt never
closed and every round scored under a new random name; storage.js now
holds it in memory for the visit. The guess marker is controlled by the
current guess and the map reframes on each round. Reloading /daily after
playing reads the stored record instead of fetching a round, and the
mounted flag survives StrictMode so result sounds play and no prefetch
starts after leaving.
GameClient's load paths share one helper and stop threading session ids.
ShareButton, PlayLink and ThemeSync replace duplicated share, play-link
and theme-apply code; the coverage page orphans a pano lookup on region
switch.
A timeout or 5xx from Mapillary no longer moves the day to another
panorama; only a missing image does, and the pick is written with SET NX
so instances that draw differently still serve one. /api/daily maps a dry
pool to 404 and an upstream failure to 502.
/api/new-game always mints a fresh session id instead of reusing the
client's, which let a new round be overwritten mid-guess; /api/guess
rejects ids the server could not have minted. The route reuses the history
it already read, runs round statistics alongside the board writes, and
drops an unreachable error branch. One cookie parser serves the player id
and the debug key; the leaderboard fan-out takes points, not a function,
and no longer rewraps errors.
Tests share one Mapillary stub, the leaderboard stub contract compares a
real scored row, and scanKeys and SET NX are covered.
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.
The game screen's app bar moves into GameHeader, purely presentational,
taking seventy lines of layout out of the round lifecycle. Vietnamese
place names render through PlaceName, one span with lang="vi", so a
screen reader switches voice and there is one place to change if names
ever get a second script. The expanded phone minimap, which already
took focus and owned Escape like a modal, is announced as one. The
search box loads with the map it sits on, taking the region tree out of
the game page's first load. The debug hub is a server component, and
the two header buttons use the default 44px size instead of patching a
height back onto the small one.
Every localStorage-backed preference (name, theme, sound, last region,
daily record, hint seen) now goes through lib/storage.js, which never
throws and notifies watchers in this tab and others, and components
read it with useStoredValue over useSyncExternalStore instead of
seeding state in an effect. That fixes the breakpoint pair of theme
toggles disagreeing after a click, takes the bare localStorage calls
out of the username module and the first-round hint (a blocked-storage
browser threw inside the home page's landing effect), and lets the
daily replay derive from the stored record rather than from six
setState calls in the mount effect.
Callback props reach the imperative Leaflet and PhotoSphere handlers
through useEffectEvent. The leaderboard modal fetches from the action
that changed the board, the search box resets on the prop's edge, the
count-up derives its idle values, and the username form mounts with
the dialog so it needs no re-seeding. The react-hooks rules that
flagged twenty-one warnings are now errors, and nothing violates them.
A dry pool and an upstream failure were told apart by matching error
message prefixes in eight places; they are now DryPoolError and
UpstreamError, and /api/new-game answers 404 with the coverage message
for the first and 502 for the second instead of 200 for both. The draw
stops retrying after eight seconds and the client gives up at fifteen.
/api/guess returns gameResult alone: the whole leaderboard and distance
objects, four legacy rank fields and a message duplicated it and had no
reader. The leaderboard module drops its pre-tree aliases and the
submitScore primitive with no production caller; tests award points
through submitRoundScore, the path production runs. /api/leaderboard
returns the rows alone. The unused debug POST on new-game, zScore,
isDay and indexedProvinces go with them.
Fixes two live bugs: GameClient dropped the partial flag, so a
half-credited round showed the success message; and the map centre was
state seeded to Ho Chi Minh, painting one frame there on every region.
The e2e stubs carry every key the routes emit and a test now asserts it.
Four scripts each carried a copy of the .env parser, and one copy lacked
the unquoting the others had, so vercel env pull output broke it. They
share scripts/lib/env.mjs now. The hand-edited region configuration
moves out of the boundary builder into scripts/lib/region-config.mjs,
where it no longer shares a name with the generated tree the sibling
scripts import. scripts/lib/assign-districts.mjs, which decides which
district a panorama is credited to, gets its first tests, against
synthetic polygons so they pin geometry rules rather than data. A
data:refresh command runs the whole pipeline, with its Mapillary cost
stated where it is invoked.
The daily challenge credited the main boards while its panorama is the
same all day and the answer is in the first guess response, which made
it a five-points-per-two-requests loop; it is now scored and counted but
credits nothing. The score fan-out settles per level and reports what
landed rather than calling a half-credited round unsaved. The daily
caches only its pick and resolves the image URL per request, so a dead
URL never breaks the day. The players HyperLogLog gets its TTL on its
own first write. DailyCard no longer computes the day number during
static render. Board rows use the Vietnamese name. Share buttons
announce their outcome and treat a cancelled sheet as nothing. The
backup artifact is encrypted before upload.
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.
Every province and district in the tree now carries its accented name,
derived by the boundary script from the OSM query it already held, and
regionName() is what the picker, the game header, the reveal, the
search results, the API's region.name and the share text show. The
ASCII name stays as the stable form for codes, logs and search
aliases, and the map search matches either spelling. The font loads
the Vietnamese subset so the glyphs render in Geist.
/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.
The debug routes returned panorama coordinates by id and by region to
anyone, which is the answer to a live round; in production they now
answer only a request carrying DEBUG_ACCESS_KEY, and the bbox tester
that proxied Mapillary's failing search is removed.
/api/guess checks the username against the one rule the name prompt
uses, rejects non-finite coordinates before consuming the session, and
names why a submit failed. Session ids that are not UUIDs are replaced
on new-game and rejected on skip.
Score boards are no longer trimmed to 200: trimming deleted the running
total of anyone below the cut, so once 200th place held more than one
round's points no new player could ever get on. Scores are added with
ZINCRBY so concurrent rounds under one name both count. The distance
record is best-effort after scoring, so a distance failure no longer
reports a scored round as unsaved.
Two Redis keys per day count rounds by picked level and score and
distinct players; npm run stats prints them.
Six phases from asset sourcing to verification, the free-audio source
research behind them, and the code review that followed: twelve
findings, eight of them real and fixed.
The by-ear checks stay open. Nothing here claims a sound was heard.
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.
Nine one-shot effects on the play flow and one ambient loop, both on by
default and mutable independently from every header.
lib/audio.js owns the single AudioContext, the decoded-buffer cache and
the two preferences. Those preferences live in module variables mirrored
into localStorage rather than read back from it: a private window throws
on both read and write, and re-reading before each sound turned a mute
that could not be persisted into a mute that never happened.
The gesture listeners stay registered for the life of the page. They
open the context on the first interaction, but they are also the only
thing that resumes one the browser suspended -- removing them after the
first gesture left the game silent for the rest of a session once a
phone had been locked.
MusicPlayer mounts in the root layout, not in a page, so walking from
the menu into a round does not tear down and restart the loop. It loops
a decoded buffer rather than an <audio> element, which returns to sample
zero and so has no seam.
The game header has no room below sm for a second pair of 44px cells
beside ThemeToggle's three, so sound collapses there to one
mute-everything switch. Both variants stay mounted and subscribe to the
preferences, or crossing the breakpoint would reveal a control showing
its mount-time state.
Assets are CC0 throughout, from Kenney and OpenGameArt, with per-file
provenance in public/audio/SOURCES.md. The loop was chosen on a
measurement: its first and last 250ms sit within 0.5dB, so the loop
point carries continuous energy.
The merge verdict and the production runtime sweep that backed it: four gates
green, both 404 exit links verified by clicking rather than by href alone, and
no theme flash on a hard load -- established at domcontentloaded, before
hydration could run.
Also the finding that reversed an earlier one: not-found.js CAN export
metadata, tested against the prerendered output.
The one place this branch's own per-page-title argument had not been applied:
a tab, a history entry and a bookmark for a dead link all read "VNGeoGuessr".
An earlier review recorded that not-found.js cannot export metadata and only
global-not-found.js can. That was wrong, and conflated the two 404s. Tested
rather than read: exporting metadata here puts the title in the prerendered
_not-found.html. global-not-found is only needed to title a 404 that replaces
the whole shell.
The region 404 genuinely cannot -- it is served from Next's error shell, which
carries no metadata at all.
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.
Adds coverage for this branch's behaviour: the homoglyph 404 through a real
percent-encoded request, href assertions on both not-found exits (a visible
link to nowhere is still a dead end), and the two halves of InlineScript's
contract -- no console error where React renders the script on the client, and
an executable type in the served markup everywhere else.
That second one is asserted on raw HTML, with no browser, because in a browser
the regression is invisible: ThemeToggle re-applies the theme on mount, so
<html> ends up correct whether or not the script ran -- just a flash later.
Mutating the helper to always emit text/plain previously failed nothing.
global-setup fixes a real flake. next dev compiles a route on first request,
and 8 workers demanded the same cold compile at once, so every test navigating
to a /game URL failed together at the 60s timeout -- reproducible by touching
any source file. One serial warm-up pays the compile once: 28 passed in 14s.
The fetch is bounded and its failure logged, because a skipped warm-up
otherwise looks exactly like the flake it prevents.
eslint ignores Playwright's artifact dirs, which are gitignored but were still
walked, crashing a lint run concurrent with a test run.
The region 404 logged "Encountered a script tag while rendering React
component" and pointed at the root layout's inline theme script.
A thrown notFound() is served from Next's error shell, so React renders the
root layout on the client there -- the only route where that happens. React
warns because a script created through the DOM never executes, and per
react-dom's isScriptDataBlock the warning is suppressed only for a
non-executable type. Ours had no type at all.
The warning was honest: the script really is dead on that route, which is why
game/[region]/not-found.js re-applies the theme in an effect.
InlineScript is the cure from Next's own "preventing flash before hydration"
guide -- text/javascript on the server, text/plain on the client. It must be a
Client Component: as a Server Component the ternary is evaluated once on the
server and baked into the RSC payload, and the warning persists. Verified both
ways, and that the production prerender still carries the executable script.
Three fixes to the not-found surfaces:
The explanation line used text-muted-foreground, which measures 4.05:1 average
and 3.09:1 at its worst over the background art through the vn-surface veil --
under the 4.5:1 AA needs at 14px. It is the only explanation on screen, and it
now also serves the app-wide 404. text-foreground clears 12:1 in both themes.
The comment beside it recorded a decision from the premise "near the AA floor",
which the measurement contradicts; it now states the measurement.
"Go to the start" named neither an action nor a destination for a visitor who
arrived from a mistyped external link and has never seen a start.
The region 404 applied the theme once on mount and never followed the OS
afterwards, so a system-theme visitor who changed appearance while sitting
there kept the old palette. It subscribes now, as ThemeToggle does.
watchSystemTheme's contract was documented as its caller's gating rather than
its own behaviour, which was false for this second caller.
The 85 prerendered region pages all shipped the root layout's single
"VNGeoGuessr", so the tab, the history entry and a bookmark said nothing about
the one thing the URL shape exists to express.
generateMetadata resolves through regionFromSlug, never getRegion: getRegion
throws on an unknown code and metadata resolves before the page renders, so an
unguarded version would turn the honest 404 at /game/notaregion into a 500. An
unresolved slug inherits the root metadata and the page still notFound()s.
The province is named alongside the district because district names repeat
across provinces. It reveals nothing the URL does not already carry.
toUpperCase() is full Unicode case mapping, so the accepted set was every
string whose uppercase equals a code, not just a re-casing. `hn-badinh` with a
dotless i (U+0131) and `hn-sontay` with a long s (U+017F) both resolved and
played a real round.
Each such spelling is a distinct URL: its own year-long ISR entry and its own
analytics row. Splitting one region across an unbounded set of URLs is exactly
what putting the region in the path was meant to prevent.
A round trip through regionSlug accepts a re-casing and nothing else.
/game/TPHCM still plays; there is still no canonical-casing redirect.
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.
The plan for the migration, plus the reports behind it: a research pass on
Vercel Web Analytics limits, the decision it forced, and five reviews of
the implementation (code, tests, runtime, docs, UX).
Two plan decisions were rejected during implementation and both are
recorded in place rather than edited out, since the reasoning is the
useful part: the next.config.mjs redirect (it forwards the source query
to the destination) and the canonical-casing redirect (a cached redirect
on a prerendered route corrupts the canonical URL on a case-insensitive
filesystem).
Also shelves the custom-events analytics plan. It cannot ship: this
project is on the Vercel Hobby plan, where custom events are not
included, so all twelve track() call sites would be no-ops. Its event
vocabulary and privacy allowlist stay valid if the project ever moves to
PostHog or upgrades.
/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.
Switching region fires three requests for it. Two carry the previous
viewport: the badge row resizes, the map's ResizeObserver calls
invalidateSize, and the moveend that follows reports the old bounds under
the new region. Only the third carries the boundary, and it is the oldest
of the three, so the stale-response guard threw it away -- leaving the
outline undrawn, the map parked on the region before it, and every
panorama out of view.
The boundary is now applied on the region it describes rather than on
request order.
Also adds a project .ckignore so src/app/debug/coverage/ reads as the
application source it is rather than a test-coverage report directory.
Dong Nai, Binh Duong, Thanh Hoa and Quang Nam join the tree, and Long An
gains Ben Luc and Can Giuoc, taking coverage from 5 provinces to 9 and the
panorama index from 425k locations to 493k.
A province outline is now simplified no more loosely than the leaves it is
the union of. It is not only drawn: the district assignment clips each
province's panoramas against it, and at the old tolerance the outline bulged
past its own districts, crediting every panorama in that band to a district
it does not sit in. That band held 571 panoramas across the tree, 2.53% of
Binh Duong's.
Unioning adjacent districts also leaves hairline sliver rings along shared
borders, some of only three points, which turf.simplify refuses to clean.
Those are dropped before the outline is simplified.
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.
Lists only the variables the app actually reads, with placeholder values, so a
fresh clone does not have to grep for them. .gitignore keeps .env* ignored and
whitelists this one file.
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.
The Submit and Skip bar collapsed to zero height on an iPad Pro 10.5 in
landscape: the map card ran 60px past where it should stop, which is exactly
the bar plus its gap. From lg up the bar sat four indefinite-height containers
deep -- a min-h-dvh column, two flex-1 min-h-0 boxes, an implicit auto grid row
-- and was the shrinkable sibling of a flex-1 map. Safari before 18 resolves
that chain by squeezing the auto-sized item to nothing, and that device caps at
iPadOS 17.
Give the map and controls column explicit grid tracks instead, so a stretched
row sizes the map and an auto row sizes the bar, with no flex free-space maths
in between. The bar and the header are also marked shrink-0, and the content
grid declares its single row rather than growing an implicit one.
The result dialog is centred with a translate, so a viewport unit that
over-reports the visible area hides Next Round below the fold rather than
cropping the bottom edge. Clamp it with svh, which is never larger than what
is on screen.
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.
The free plan asks for all three credits for non-osm-carto styles -- Geoapify,
OpenMapTiles for the tile schema, OpenStreetMap for the data -- and the page
carried only two. It matters more now that the credit is suppressed on the
collapsed minimap thumbnail.
The two-column grid held half the width empty until a point was picked, and
reflowing the map on selection moved the very dot the user had just clicked.
From lg the inspector now floats over the map's right edge; a phone has
neither the width to float into nor the height to split, so there it takes the
surface and its close button brings the map back.
The map keeps a 16rem floor below lg: the page chrome takes 225px, so a purely
flexed surface collapsed to 98px on a landscape phone. Escape closes the
inspector unless a select owns the keypress, a ResizeObserver re-measures the
map when it reappears, and the tile attribution moves to the corner the
inspector does not cover.
The how-to-play hint was a free-floating overlay centred on the whole game
box, which put it over the guess map search field on desktop and over the
Mapillary attribution -- required visible by their Terms of Use -- on phones.
It now rides PanoramaViewer's topBarSlot as a flex sibling of that credit, so
neither collision is representable.
Replace the ad-hoc z-index values and the two forced 9999 rules with one
ladder of tokens in globals.css. Leaflet and Photo Sphere Viewer ladders are
contained by isolate on the panes that host them rather than out-bid, which
lets the dialog scrim finally cover the action bar and the minimap.
Also: pad the header and content box with the left/right safe-area insets that
viewportFit cover requires, size the collapsed minimap against the viewport so
a landscape phone shrinks it instead of pushing it into the panorama chrome,
hide Leaflet's credit and zoom buttons on that thumbnail and lift the zoom
control clear of the wrapped credit when it expands, move the desktop
"Click to place your guess" badge off the tile credit, raise the hint dismiss
target to 44px, and move the sticky-footer column off body so Radix portals
are not flex items.
Render the credit and build stamp as an in-flow footer row in a
sticky-footer body column instead of a fixed overlay, so it can no
longer sit on top of the panorama, mini-map, or action bar. Page roots
now fill the column with flex-1, and the elements that used to reserve
space for the overlay drop their clearance offsets.
Open the username modal on first visit when localStorage holds no name,
instead of waiting for the first Play click. Dismissing the prompt with
no saved name falls back to a generated Player-xxxxxx, so every exit
path leaves a leaderboard name behind.
Centralize color token system in globals.css, update theme.js with semantic naming, replace icon library references with Lucide, rename vn-surface class, and update all component references.
Defer username prompt to first Play click, make username editable in header chip, generate random Player-xxxxxx name on skip or deep-link, update tests accordingly.
Geoapify free plan requires three linked credits for osm-bright styles
(Geoapify, OpenMapTiles, OSM contributors); the OSM fallback credit now
links to openstreetmap.org/copyright per OSM guidelines.
Adds licensing research reports (Mapillary ToU verification, tile-provider comparison) and a complete plan for the Geoapify tile-provider migration with two execution phases.
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.
Adds an overlay to acknowledge Mapillary imagery copyright and a dedicated /credits page displaying all data sources and providers with proper attribution.
Add improved hover/active feedback to debug button, safe-area offset for
better mobile positioning, descriptive title, and z-index adjustment for
proper stacking context.
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.
Expose NEXT_PUBLIC_COMMIT_SHA via next.config.mjs (resolves full commit
SHA from VERCEL_GIT_COMMIT_SHA or git), render copyable footer stamp in
DebugFooter component (bottom-center, underlined, a11y-tuned), and add
e2e test for footer visibility and clipboard interaction.
Document 260831-1906 plan phases, execution journal, and code review reports
covering scoring ladder, game state refactor, home region continuity, and
leaderboard UI fixes. Multiple review passes verified test coverage and
component contracts.
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.
Track last played region in localStorage and show \"Continue in [region]\"
button on home page. Add scoring table derived from SCORE_BANDS with caption
explaining region-relative ladder. New last-region.js utility module.
Separate initialLoading/roundLoading/submitting concerns to prevent premature
panorama unmount. Add roundKey viewer remount trigger and round-epoch watchdog
to prevent stale fetches from replacing the current round. Next-round prefetch
in result dialog. Replace alert() with inline error+retry. Last-region write
on successful load.
Implement region-scaled scoring: base ladder (0-5 points) adjusts thresholds
proportionally to the picked region's bbox diagonal. Leaderboards credited
separately using their own region's ladder, preventing country-round guesses
from earning inflated district-board points. Score and per-level points
returned in gameResult.bands and gameResult.levels.
- replace the pre-redesign gradient, white/10 glassmorphism and text-white
with vn-gradient-bg, bg-card surfaces and foreground/muted tokens, so the
page follows the light/dark theme like every other screen
- document the styling conventions and the deliberate raw-color exceptions
(score bands, podium, panorama surround) in docs/development.md
- 9 chromium specs: region picker, username modal, one full round to the
reveal and next-round reset
- every /api/* call, the panorama image, and OSM tiles stubbed via
page.route, so the lane runs offline with no services or env
- webServer starts or reuses next dev (never reuses in CI)
- 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)
Adds offline region-tree matching with diacritic folding and Vietnamese
aliases (quận/q7 for District N), plus Photon geocoder for streets and
places bounded to the played region. Selection pans/zooms only; guess
placement remains click-only on the map. LeafletMap now supports zoomPosition
and proper teardown. Includes client-safety tests to prevent server-data
imports in this module.
Move result display, map panels, and leaderboard into dedicated components,
reducing GameClient complexity. Removes unused tabs component and cleans
up unused parameters.
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 home page is a province accordion. The province row plays that province
and only the chevron expands it, so picking Ha Noi stays one click while its
thirty districts stay reachable. Districts with no coverage are listed and
disabled rather than hidden, with the reason shown -- absent coverage is
something the tree knows about, and a district that silently vanishes is more
confusing than one that says why.
The leaderboard modal browses by level and then by region. It used to fetch
every board when it opened, which was twelve requests for five cities and
would have been a hundred and thirty-four for sixty-seven regions; it now
fetches only the board on screen, caches within a session, and clears on open
so a player who just scored does not see a stale total. A failed fetch says so
instead of rendering as an empty board, which would have read as wiped
leaderboards during an outage.
A round shows one rank row per level it credited, and reveals where the
panorama actually was -- the interesting part when the player chose a province
or the whole country. A submission that did not record now says that plainly.
It previously rendered as a confident nine-hundred-and-ninety-nine-kilometre
miss, indistinguishable from a real one.
Removing the old fixed global/city rank state left its setters behind as free
identifiers, which would have thrown on every Next Round and Skip. Lint, the
build and all two hundred and sixty tests passed over it, because the preset
enables neither no-undef nor no-unused-vars and this project has no type
checker. no-undef is now an error, verified by reintroducing the fault.
The accordion and select come from Radix. Both were absent from the component
library, and hand-rolling an accessible accordion and a grouped listbox would
have shipped broken keyboard support.
The debug coverage page is unchanged: it cannot be read in this environment,
so it still lists cities and still calls the route by its old name. The city
lookups in lib/game.js remain live for that reason.
A round now carries two regions. The one the player picked is public and comes
back in every response; the one the panorama actually sits in is a secret, and
it is what the leaderboard fans out from. Revealing the second before the
guess would collapse a country-wide round to a single district, so it joins
the exact coordinates on the never-serialized list -- including in the session
lookup handler, which echoes fields back to whoever holds the session id, and
that is the player.
Session consumption is now a claim rather than a courtesy. Reading a session
and then deleting it is not a guard: ten concurrent submits all read it alive,
all delete it, and all score. DEL is atomic and returns how many keys it
removed, so exactly one caller sees a 1 -- the route scores only if it won
that. Consuming before the writes also closes the sequential case, where a
failure partway through the fan-out would otherwise leave the session alive
for half an hour and let a retry re-credit every level that already succeeded.
A guess lost to a mid-write failure is the accepted cost.
Region parsing lives in one place. Four routes accept a region, and four
slightly different ideas about casing and defaulting is how a typo becomes a
leaderboard key nobody reads. Unknown and uncovered codes are rejected with a
400 that names the region, rather than served as an empty board that looks
exactly like a region nobody has played yet.
Sessions created before this change still score, at province level, since they
carry no district. They expire within half an hour, so the fallback can go a
release from now.
The debug coverage route serves any region with an outline rather than only
the five provinces. Its directory keeps the city-coverage name for now: the
page that calls it cannot be read in this environment, and renaming the route
without updating its caller would break it.