/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.