Commit Graph
100 Commits
Author SHA1 Message Date
tiennm99 e649259518 feat(game): explain a miss, share a round, and keep the phone header in reach
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.
2026-09-20 23:02:05 +07:00
tiennm99 3314da1358 fix(api): validate every input, close the debug routes in production, keep every score total
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.
2026-09-20 23:02:05 +07:00
tiennm99 c2d0d3f59e docs(plans): record the brainstorm review round and revised sequence 2026-09-20 22:37:29 +07:00
tiennm99 59be3024a7 docs(plans): record the improvement brainstorm reports and synthesis 2026-09-20 22:21:04 +07:00
tiennm99 5406b38a5c docs(plans): record the audio plan, research and review
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.
2026-09-09 11:14:58 +07:00
tiennm99 bf4b3ae465 docs: describe the audio layer
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.
2026-09-09 11:14:57 +07:00
tiennm99 4d10f0a879 feat(audio): play sound effects and background music
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.
2026-09-09 11:14:47 +07:00
tiennm99 89f7a81d2d docs(plans): record the pre-merge review evidence
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.
2026-09-06 20:22:54 +07:00
tiennm99 8987506dcb fix(404): give the app-wide not-found its own title
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.
2026-09-06 20:05:11 +07:00
tiennm99 76e72c0b00 docs: record the URL rules, the 404 surfaces and the e2e harness
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.
2026-09-06 19:51:26 +07:00
tiennm99 d5c97fab5c test(e2e): cover the URL contract, and compile game routes before the race
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.
2026-09-06 19:51:16 +07:00
tiennm99 f116fec36f fix(layout): stop the theme script erroring where React renders it client-side
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.
2026-09-06 19:51:05 +07:00
tiennm99 bc32de22b9 fix(404): raise the explanation's contrast, name the exit, follow the OS
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.
2026-09-06 19:50:56 +07:00
tiennm99 1a989b02ee feat(game): give every region page its own title and description
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.
2026-09-06 19:50:45 +07:00
tiennm99 45cb389d9d fix(regions): reject a slug that only uppercases into a region code
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.
2026-09-06 19:50:38 +07:00
tiennm99 6d7cb30e5e fix(404): give every not-found route a way out, in the right theme
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.
2026-09-06 16:50:15 +07:00
tiennm99 ae0cc3d798 docs(plans): record the region-path migration and the analytics constraint
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.
2026-09-06 16:38:32 +07:00
tiennm99 711ef863a6 feat(game): put the region in the URL path
/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.
2026-09-06 16:37:43 +07:00
tiennm99 90ebed728a fix(debug): draw the region a coverage switch asked for
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.
2026-09-04 16:47:39 +07:00
tiennm99 d1332cd689 feat(regions): open four more provinces for play
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.
2026-09-04 15:20:13 +07:00
tiennm99 6251ea5f4b feat(footer): link the build sha to its commit page
The sha now opens that commit on GitHub in a new tab, and a copy button beside it takes over copying the full value.
2026-09-04 11:59:48 +07:00
tiennm99 7862bcec9d feat(game): stop repeating a player's recent locations
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.
2026-09-03 15:18:01 +07:00
tiennm99 86eff7364d chore: add .env.example template
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.
2026-09-03 15:17:42 +07:00
tiennm99 eb4f272817 feat(ui): paint the key art behind every page
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.
2026-09-03 12:19:44 +07:00
tiennm99 b15d199565 feat(scoring): score every region on one distance ladder
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.
2026-09-03 11:46:17 +07:00
tiennm99 be448b3d97 fix(layout): size the game action bar with grid tracks
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.
2026-09-02 15:43:44 +07:00
tiennm99 23c55bdb75 chore(plans): add the game screen layout review reports
The overlap audit that drove the layout work, and the verification pass that
measured each fix live across six viewports.
2026-09-02 09:53:22 +07:00
tiennm99 1aa15f8969 docs: record the layering ladder and fixed-surface rules
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.
2026-09-02 09:53:22 +07:00
tiennm99 a3b3d9970e fix(credits): add the OpenMapTiles credit Geoapify requires
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.
2026-09-02 09:53:22 +07:00
tiennm99 26dd5f384e refactor(debug): float the coverage inspector over a full-width map
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.
2026-09-02 09:53:22 +07:00
tiennm99 23ee9d47ff fix(layout): put game chrome in flow and contain third-party z-index
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.
2026-09-02 09:53:06 +07:00
tiennm99 1efd2b3a8e fix(layout): keep the credit footer out of the game surface
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.
2026-09-02 01:58:01 +07:00
tiennm99 67b9195d63 feat(username): prompt for a name on landing
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.
2026-09-02 01:57:53 +07:00
tiennm99 3e6bff83da feat(ui): add maker credit beside the build stamp 2026-09-01 18:00:38 +07:00
tiennm99 7212b67feb chore: remove leaderboard migration scripts and close plan
Completed one-shot leaderboard backfill migration verified in production.
Removed migration scripts, test, unused export, and npm script. Updated
documentation and closed UI/UX flow polish plan with session journal.
2026-09-01 17:26:21 +07:00
tiennm99 0ce56737f3 chore(plans): add execution plan and review reports
Add UI/UX flow polish execution plan with phases and review reports from code audit and presentation audit.
2026-09-01 17:17:22 +07:00
tiennm99 3cc77e2869 docs: update styling tokens, username flow, and structure
Document semantic color token system, username flow changes, and project structure refinements.
2026-09-01 17:17:16 +07:00
tiennm99 eebe1b7d6f refactor(api): drop expired session cityCode fallbacks
Remove deprecated cityCode fallback logic from guess and new-game routes, simplify session handling, update tests.
2026-09-01 17:17:11 +07:00
tiennm99 bf67ae655d refactor(ui): semantic color tokens and Lucide icon unification
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.
2026-09-01 17:17:06 +07:00
tiennm99 0b8b60a2ab feat(ui): game onboarding and result dialog clarity
Add FirstRoundHint banner for onboarding, desktop map ghost label, session progress badge. Restructure result dialog with map above fold, labeled reveal, legend, and collapsible leaderboard section. Improve skip tooltip, region picker labels/tooltips, dialog accessibility, credits parity, and ThemeToggle sizing.
2026-09-01 17:17:00 +07:00
tiennm99 0876a9db2d feat(ui): username flow rework
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.
2026-09-01 17:16:53 +07:00
tiennm99 0f167192ef chore(journal): session journal for licensing compliance work
Documents tile attribution requirements and Mapillary licensing updates.
2026-09-01 16:16:45 +07:00
tiennm99 afee4b981f fix(maps): complete tile attribution requirements
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.
2026-09-01 16:16:37 +07:00
tiennm99 d7d6effbd0 chore(plans): document licensing research and tile-provider migration
Adds licensing research reports (Mapillary ToU verification, tile-provider comparison) and a complete plan for the Geoapify tile-provider migration with two execution phases.
2026-09-01 09:43:32 +07:00
tiennm99 a5c9ef5876 feat(maps): centralize tile provider with Geoapify support
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.
2026-09-01 09:43:19 +07:00
tiennm99 bbe5987a9b feat(credits): add Mapillary attribution overlay and credits page
Adds an overlay to acknowledge Mapillary imagery copyright and a dedicated /credits page displaying all data sources and providers with proper attribution.
2026-09-01 09:43:07 +07:00
tiennm99 649247b3a1 fix(home): tune wrench debug button hover and layout
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.
2026-09-01 00:11:50 +07:00
tiennm99 289986695a refactor(debug): reorganize debug pages with shared shell and nav
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.
2026-09-01 00:11:41 +07:00
tiennm99 2b57bf9526 feat(footer): add build-stamp footer with commit SHA
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.
2026-09-01 00:11:29 +07:00
tiennm99 fe3a89b3a8 chore(memory): update code-reviewer agent notes on scoring and quality gates
Capture scoring ladder implementation details, regional scoring board crediting
mechanics, and quality gate findings from UI/UX quick wins review cycle.
2026-08-31 22:09:04 +07:00
tiennm99 71e77eae00 chore(plans): add plan, journals, and review reports for UI/UX quick wins
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.
2026-08-31 22:08:41 +07:00
tiennm99 9516599948 fix(maps): use regional scoring ladder for leaderboard distance tint
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.
2026-08-31 22:08:26 +07:00
tiennm99 81765f4878 feat(home): add last-region persistence and continue button
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.
2026-08-31 22:08:10 +07:00
tiennm99 7123cdde76 fix(game): split loading states, add inline error retry, improve round handling
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.
2026-08-31 22:07:56 +07:00
tiennm99 434409443c feat(scoring): add region-relative scoring ladder and per-level board crediting
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.
2026-08-31 22:07:41 +07:00
tiennm99 38fd5af60e style(debug): move the debug page onto the app design tokens
- 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
2026-08-31 10:15:49 +07:00
tiennm99 9f1cd69029 chore: agent memory notes 2026-08-31 09:44:13 +07:00
tiennm99 1be57831a0 test(e2e): add Playwright smoke lane with stubbed APIs
- 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)
2026-08-31 09:44:13 +07:00
tiennm99 e308638dbe chore(deps): upgrade to Next 16 and refresh dependencies
- next 15.5->16.3.3, react 19.2.8, eslint-config-next 16, turf 7.4,
  upstash 1.38.3, photo-sphere-viewer 5.15, lucide-react 1.x
- eslint.config.mjs rewritten to native flat config (FlatCompat and
  @eslint/eslintrc dropped); eslint stays on 9, v10 breaks
  eslint-config-next; new react-hooks compiler rules downgraded to warn
  for deliberate hydration and imperative-ref patterns
- add @neondatabase/serverless, @electric-sql/pglite, @playwright/test;
  drop uuid; data:seed and test:e2e scripts; --turbopack flags removed
  (default in 16)
2026-08-31 09:44:13 +07:00
tiennm99 6569f095c9 feat(game): serve panorama index from Neon Postgres
- 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)
2026-08-31 09:44:03 +07:00
tiennm99 da7a715478 chore(plans): archive completed plans and stale reports
Journal entries preserve the outcomes; plan and report files remain
in git history.
2026-08-30 21:48:12 +07:00
tiennm99 75c77e9ab9 docs(plans): journal entry for guess map search 2026-08-30 21:42:44 +07:00
tiennm99 2768c0176a feat(game): add district and street search to the guess map
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.
2026-08-30 21:42:09 +07:00
tiennm99 5518676170 chore: session plan and agent memory
Record structure refactor plan and quality-gates analysis for future reference.
2026-08-30 20:52:41 +07:00
tiennm99 bed486c742 docs: refresh README and docs for the region tree and pipeline scripts
Update project overview to describe region tree refactor, document data
pipeline scripts, clarify project structure, and align with current practices.
2026-08-30 20:52:36 +07:00
tiennm99 997695659b build: add data pipeline scripts and migrate lint to eslint
Add build scripts for region data pipeline, migrate from eslint-config-next
to flat eslint config, and ensure node globals are available in all files.
2026-08-30 20:52:32 +07:00
tiennm99 3a965b7ede refactor(scripts): share data paths and barrel emitter across generators
Extract common path resolution and event-emitter barrel into shared modules
to reduce duplication and enable consistent data pipeline configuration.
2026-08-30 20:52:28 +07:00
tiennm99 fd64e9239b test(regions): guard every client module against server-only imports
Add comprehensive tests to ensure client-side code never imports from
pano-index or other server-only modules, maintaining security boundary.
2026-08-30 20:52:24 +07:00
tiennm99 10c828776b refactor(ui): extract round result, guess map, and leaderboard components
Move result display, map panels, and leaderboard into dedicated components,
reducing GameClient complexity. Removes unused tabs component and cleans
up unused parameters.
2026-08-30 20:52:20 +07:00
tiennm99 9133a3efc2 refactor(lib): split game.js into scoring and username modules
Extract username generation into a dedicated module, simplify game.js to
focus on round scoring logic, and update consumers accordingly.
2026-08-30 20:52:15 +07:00
tiennm99 3177e70e67 fix(api): stop sending the panorama id to the client
Prevents exposure of internal panorama IDs to the client, maintaining
security boundaries and avoiding client-side dependency on server state.
2026-08-30 20:52:11 +07:00
tiennm99 c9a35dca9a docs: describe the region tree, and finish the coverage page migration
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.
2026-08-30 19:46:13 +07:00
tiennm99 d72df56200 feat(ui): navigate the region tree instead of a flat city list
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.
2026-08-30 18:53:30 +07:00
tiennm99 00a8f8b627 feat(api): score a guess against the district it was actually in
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.
2026-08-30 18:06:55 +07:00
tiennm99 4770de51d4 feat(leaderboard): roll a guess up through the region tree
A guess is credited to the district its panorama sits in, then to that
district's province, then to Vietnam. Each level keeps its own board, so a
player can top District 7 without touching the national table.

Every existing point survives untouched. The country maps to the
leaderboard:vietnam and distance:vietnam keys that already exist rather than
to a new leaderboard:city:vn, so the national board keeps accumulating instead
of restarting. The ':city:' segment in the key namespace is now a misnomer --
it holds district and province codes alike -- but renaming it to ':region:'
would strand every key holding a player's history, so it stays, with a comment
recording why. Da Lat and Duc Hoa keep their bare codes for the same reason.
Ha Noi, Da Nang and Ho Chi Minh keep their totals on the province: those
points predate districts and cannot be attributed to one, so their boards stay
continuous while district boards start at zero.

Levels are written in parallel. They are independent keys and none reads
another's state, so serialising them would add two round trips of latency to
every guess for nothing.

Region codes and the leaderboard limit are validated inside the library rather
than only in the routes. The key builder lowercases whatever it is handed
straight into a Redis key, and the routes are not its only callers -- the
migration script and anything added later bypass them entirely.

The adapter gains scanKeys, which the migration needs to enumerate what it is
about to touch. It applies the key prefix to the pattern and strips it from
the results, because a caller that scanned 'leaderboard:*' directly would
match nothing at all: every physical key carries the prefix, and an empty
result is indistinguishable from an empty database. An empty KEY_PREFIX is no
longer accepted, since enumeration with no prefix would reach every other
project sharing the database.

The migration seeds Lam Dong and Long An from Da Lat and Duc Hoa, whose
history they inherit. It runs after the deploy, not before: migrating first
leaves a window where a Da Lat guess credits the town and the country but not
the province. It copies absolute scores so a second run converges rather than
doubling, and empties the destination first so a player trimmed out of the
source cannot survive in the copy with a stale score. An empty source is
refused before that delete -- otherwise the destination is wiped and nothing
written back, which reads as a clean no-op. Afterwards it confirms each
destination matches its source and that nothing else went backwards, accepting
that boards grow while the app serves traffic. Its backup can be restored
through the same script.

The migration logic lives in scripts/lib/ so its guards are reachable from a
test rather than only by running it against a live database.
2026-08-30 17:45:37 +07:00
tiennm99 b7f8879fe0 feat(regions): credit panoramas to the district they sit in
Every panorama now carries the district it falls in, so a guess can be
attributed to a leaf rather than only to a province. The assignment runs
against the indexes already on disk: districts are a property of a panorama,
not a separate dataset, and re-fetching per district would multiply a
~2,800-tile build against Mapillary's 50,000/day cap for bytes we already
hold.

The district is stored as an integer offset into a per-province districts
array rather than as a code string, which costs about five bytes an entry
instead of twelve. Across 424,617 entries that is the difference between the
2.85 MB the data actually grew and something closer to five.

Coverage is judged on distinct places, not on raw counts. The index is thinned
at 33m, so a count overstates how many different places a district offers by
roughly thirty times -- a district can hold hundreds of panoramas and still be
one street seen from many angles. Playability therefore needs both a panorama
floor, which exists because the Mapillary lookup retries three times with a
different candidate, and a floor on distinct ~1.1km cells. Sixty-four of the
sixty-seven regions clear it. The three that do not are one of each kind the
coverage note describes: Cu Chi has no boundary, while Cam Le and Hoa Vang
have no street imagery at all.

A point that falls in a sliver between two simplified outlines is placed in
the nearest district by distance to the outline itself. Ranking by bounding-box
centre instead was measured putting points up to six kilometres inside the
wrong district, because a compact district's centre can beat a sprawling
neighbour whose edge is metres away. The stranded tally is recorded per
province and the build refuses to write above two percent, since that is the
signal that the leaf simplification tolerance has opened gaps along shared
borders. It currently sits at 0.05 percent, worst case forty-six metres.

fetchCityPanorama now reports the district of the attempt that succeeded.
Each retry draws a fresh candidate, potentially from a different district, so
carrying the first one forward would credit the wrong place. An exhausted pool
returns a failure rather than escaping as a 500.

Panorama arrays handed out at runtime are frozen. They are process-global and
cached for the life of the server, and an in-place sort on the shared district
array had already once repointed every panorama at the wrong district.

Nothing is wired to scoring yet: the resolved district is computed and
returned but not stored on the session. That lands with the leaderboard
fan-out.
2026-08-30 17:09:41 +07:00
tiennm99 f41ba32f30 feat(regions): restructure cities into a country > province > district tree
Replaces the hand-maintained flat CITIES map with a generated 67-node tree:
one country, five provinces, 61 districts and towns. Names, centres and
extents now come from the boundary build rather than being duplicated in
src/lib/game.js, so there is one source instead of two that a test had to
police for drift.

Boundaries are built per district and unioned upward. A single-child province
byte-copies its child rather than re-simplifying it, so the two cannot
disagree along a border and strand panoramas in the gap. The country has no
polygon of its own: the real Vietnam outline is vastly larger than the covered
area and would mislead on a map, so its extent is the envelope of its
provinces.

OpenStreetMap has applied the 2025 merger, so the pre-2025 units survive only
as boundary/historic relations whose rendered parent is the current province.
A qualified lookup misses them entirely and a bare one can match a same-named
unit elsewhere in the country, so each leaf tries both forms and the hit is
validated against the parent pre-2025 extent. 60 of 61 leaves resolve; Cu Chi
has no boundary relation left at all, which is why the previous outline
already recorded a missing part and no panorama in the index sits there.

The generated barrels now sanitise import identifiers and quote object keys.
Region codes contain hyphens, which are legal in a filename but not in a bare
identifier, and the barrel is rewritten after every region -- emitting them
raw would have broken the build from the first district onward.

Panorama indexes are re-clipped against the rebuilt outlines, dropping 74 of
424,691 entries that fell outside the retightened boundaries. Da Lat and Duc
Hoa keep their bare codes so their leaderboard history stays attached, and
their index files move to the province codes that now own them.

Scoring is untouched: a guess still credits one city and Vietnam. The rollup
through the tree lands in a later change.
2026-08-30 16:30:14 +07:00
tiennm99 3235312f7a build: keep verification builds out of the dev server's output directory
next dev and next build both own the output directory, so building while
a dev server is serving replaces manifests that server is still reading.
It logs a burst of ENOENT errors for those manifests, including paths
under pages/_app that this App Router project has no reason to touch,
which makes it look like the code being edited is at fault. Measured: 110
such errors from one overlapping build, none when they do not overlap.

build:check builds into a separate directory and leaves a running dev
server alone. The build script itself is untouched, because that is what
the deployment platform runs and it must keep writing the default
directory; distDir only moves when the new script sets the variable.

dev:clean covers the other trap: a build cache left inconsistent by a
killed build or a different Next version makes the dev server serve 500s
for everything, and restarting does not help while the cache is still
there.

Also records what actually needs a restart, which is nothing: routes,
libs and data all hot reload, Next restarts itself for its config, and it
reloads .env in place.
2026-08-30 13:35:51 +07:00
tiennm99 b0a090f625 feat(debug): add a panorama coverage map
Shows what the index actually holds: pick a city, see its outline and a
dot for every panorama, and click near one to open it in the viewer. It
answers the question the counts alone could not, which is whether
coverage spans a city or clusters on a few streets.

Points come back for the current viewport only. Ha Noi holds 225,985 of
them, which is far more than a map can draw and 13.8MB to send, so a
zoomed-out view gets an even sample and says so, and zooming in yields
every panorama in view.

Selection is resolved against the point list on a map click rather than
through per-marker handlers. Canvas hit-testing did not fire for these
markers, and requiring a pixel-perfect hit on a 2.5px dot would be
unpleasant even where it works; the nearest point within a small radius
wins instead.

The outline is sent only on the first request for a city. Returning it
with every viewport query gave the client a new object each time, which
made the map refit to the whole city, cancel whatever the user had zoomed
into, and trigger another fetch: it never settled.

Thumbnail URLs are signed and short-lived, so the index stores ids and
the picture is resolved when a point is opened. That lookup is the same
one the game uses, lifted out of fetchCityPanorama rather than copied.
2026-08-30 13:35:39 +07:00
tiennm99 adeebc46e2 refactor(data): store city boundaries as .json behind a generated barrel
Bundlers do not treat .geojson as a module, so importing a boundary
statically failed to resolve. The content is JSON either way, and the
extension was the only thing standing in the way of the coverage page
reading a city outline.

The barrel matches the one the panorama indexes already use: generated
from what is on disk, rewritten after each city, so a build that has only
produced some of them still compiles.
2026-08-30 13:35:27 +07:00
tiennm99 faa7cf987b fix(claude): stop defaulting edits to auto-accept
Removes permissions.defaultMode acceptEdits, so file changes are
confirmed rather than applied automatically. The setting is checked in
and applied to everyone working in the repository, which is the wrong
place to decide that.
2026-08-30 12:26:20 +07:00
tiennm99 a66b44f5b9 docs(plans): record the Mapillary fetch investigation
Keeps the measurements the change rests on: the density gradient showing
where the bbox endpoint fails, the by-ID latency that replaced it, and
the tile sizes and counts behind the index build. Also records what was
ruled out, since bbox size, limit and throttling all look plausible and
none of them are the cause.
2026-08-30 12:23:59 +07:00
tiennm99 64f8235935 fix(panorama): survive React's development double mount
The viewer sat on "Loading..." forever in development, with nothing
logged and no error thrown. React mounts an effect, tears it down and
mounts it again, and the viewer was built synchronously on mount, so the
first one began downloading the panorama and was destroyed mid-flight.
That cancelled the request the second viewer was waiting on, leaving its
loading promise pending: never resolved, never rejected. Production was
unaffected, which is why the API and the imagery both looked healthy.

Construction now waits a tick and is cancelled on cleanup, so the
throwaway mount goes away before it asks the network for anything. Each
mount also gets its own child element rather than rewriting the
container and looking the node back up by a fixed id, which let two
viewers fight over one element, and cleanup destroys the viewer that its
own run created so a late ready event cannot clear its successor's
loading state.

defaultZoomLvl was -60, outside the accepted 0-100. Behaviour is
unchanged because it was being clamped to 0 anyway. Also drops the
console logging that ran on every render.
2026-08-30 12:23:58 +07:00
tiennm99 ab2c271c87 perf(mapillary): play from a prebuilt index instead of searching bboxes
Rounds took a measured 4.9s at the median and failed most of the time. A
40-window sample returned 6 hits, 1 empty window and 33 errors.

The cause was not our retry tuning. /images?bbox= counts the images
inside the box before applying the limit, so it returns HTTP 500 in
exactly the places worth playing: District 1 and central Ha Noi failed on
every attempt, and limit=1 on a 0.0004 degree window failed the same as
limit=50. Backing off does not clear it, and neither does shrinking the
window, so the whole dart-throw goes: the racing, the empty and error
budgets, the widening backoff, and the per-city delta they needed.

Coverage now comes from the vector tile API, which answers everywhere
the Graph API refuses, and is walked once offline into a per-city list of
panorama ids. A round is a single lookup by id, measured at 482ms median
across all five cities with no failures. Locations are deduplicated to
one per 33m so the file holds distinct places rather than repeat passes
down the same street.

Cities gain real outlines in place of rectangles. Vietnam merged its
provinces in mid-2025, so today's Ho Chi Minh City covers 36,566 km2 and
reaches Vung Tau; these are the pre-merger extents, rebuilt by unioning
the district-level units OpenStreetMap still serves as historic
boundaries. Da Nang is enabled now that every location is known to have
imagery. Cu Chi is absent from Ho Chi Minh: its old relation is gone from
OSM and only sub-communes remain, so the file records the gap.

The build script refuses to write when a tile is missing after retries.
tiles.mapillary.com allows 50,000 requests a day and a full rebuild costs
about 2,800, so a quota failure is plausible, and a partial index would
be indistinguishable from genuinely absent coverage.
2026-08-30 12:23:44 +07:00
tiennm99 a3b1e2b22b docs(plans): record the GeoGuessr research and the UI audit
The research pass corrected two assumptions before they became work.
GeoGuessr does not dark-theme its own guess map; it frames a light one,
which turned a tile-provider decision into container styling. And the
score count-up needs no animation library, which removed the only
candidate dependency.

The audit traced "the buttons are not pretty" to the unmodified shadcn
scaffold and the ten call sites patching around it, and both files
record what could not be verified rather than filling the gaps: exact
GeoGuessr button values are behind a paid client, and the audit flagged
its own contrast figures as estimates. Two of those estimates were wrong
when measured, so the ranges stay in the record.
2026-08-30 10:28:03 +07:00
tiennm99 478e62a085 feat(ui): add a theme switcher and rebuild the button system
Two threads that could not be separated: they share globals.css, page.js
and GameClient.js, and their edits interleave inside those files.

THEME SWITCHING

Light, dark and system, chosen from a control in both headers and
remembered in localStorage. An inline script in the document head
resolves the choice before first paint, so a dark-theme visitor never
sees a white flash.

The dark palette moves out of a prefers-color-scheme media query and
onto a class, because a media query cannot be overridden by an explicit
choice. That also repairs every dark: utility in the app: the variant
keys off a .dark class that nothing had ever set, so rules like
dark:text-green-400 had never once applied.

The palette is declared as :root.dark rather than .dark. A bare class
ties with :root on specificity and wins only by sitting later in the
file, which is fragile: a brand :root block further down had been
silently reverting --surface and the brand tokens to their light values,
leaving the page background white while the cards went dark.

BUTTONS AND ELEVATION

The button primitive was still the shadcn scaffold: a grayscale default
variant and a size scale starting at 36px, below the touch target this
app needs. So every real call site hand-patched the same brand fill and
min-height, ten of them, and drifted apart on padding, weight and
shadow. That drift is what read as unpolished.

The default variant is now the brand fill, sizes start at 44px, and
there is a pressed state, a hover lift, and a loading prop. The
overrides are gone. A shared elevation scale sits behind the Tailwind
shadow utilities, so cards and dialogs agree with buttons about how
raised things are; the dark values carry more opacity and a faint inset
highlight, since shadows read as nothing on a dark ground.

The focus ring was measured at 2.59:1 against the page, under the 3:1
floor for a non-text indicator, and no alpha value clears it at any
lightness. It is now opaque at oklch(0.55), measuring 4.54:1.

HIERARCHY

Submit outranks Skip by size and fill instead of colour alone. Next
Round outweighs Menu. The donate button gives up the only brand fill on
the landing page so choosing a city can have it. City rows become cards
with real elevation and an explicit Play affordance, having previously
been near-invisible against the page. The guess map is framed in a
padded card rather than restyled, so a bright map over dark chrome reads
as deliberate. The score counts up on reveal.
2026-08-30 10:27:54 +07:00
tiennm99 b69aab5205 chore: drop unused assets, primitives and dead functions
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.
2026-08-30 09:26:11 +07:00
tiennm99 c2c1ced387 test: cover the lib layer against a fake and a real Redis
Scoring, the Upstash key adapter, sessions and the leaderboards had no
coverage, and they carry the anti-cheat model: server-held coordinates,
session TTLs, rank arithmetic.

One suite runs against two backing stores. By default @upstash/redis is
mocked with an in-memory fake, so `npm test` needs no service and
finishes well under a second. With TEST_REDIS=real the mock steps aside
and the same files run against a real Redis behind SRH, the proxy
Upstash recommends for local work, since the SDK speaks HTTP REST rather
than RESP and a plain Redis container cannot serve it alone.

Running both is what keeps the fake honest: a semantic it gets wrong
shows up as a green unit run and a red integration run. Two tests skip
against real Redis, one asserting a response shape only an older SDK
produces and one fast-forwarding half an hour to watch a session expire.

The suite was checked by mutation rather than by passing alone. Breaking
a score band, the key prefix and the session TTL each failed a test;
reversing the global trim window did not, because only the city
leaderboard was asserted, so that test now covers both scopes.

The compose stack doubles as a local Redis for `npm run dev`, which had
no credentials to reach one before.

package.json also loses the eight unused runtime dependencies removed in
the cleanup that follows; they cannot be split from the same file.
2026-08-30 09:26:02 +07:00
tiennm99 f214f4bd49 docs(plans): record the tech stack and mobile playability research
Reviews the stack against 2026 alternatives and finds no swap worth
making: Street View is priced out, MapLibre would fight the panorama
viewer for the GPU, Pannellum trades away the gestures the game runs on.

The findings that matter are mobile playability, panorama weight, and
the Turf monolith reaching the browser through a client-imported module.
Also records an unverified Mapillary bbox constraint that four of five
cities would sit exactly on.
2026-08-30 08:37:19 +07:00
tiennm99 0c977f7017 feat(game): rebuild the mobile game screen around a full-bleed panorama
The phone layout stacked a 45dvh panorama above a 400px map and the
action row, so the page ran well past one viewport. Players scrolled
between looking and guessing, page scroll fought every drag, and Submit
often sat below the fold.

The screen is now a fixed h-dvh surface. The panorama fills it, and the
guess map floats over it as a corner minimap that expands to near
fullscreen on tap. A cover intercepts touches while collapsed so a stray
finger cannot drop a pin too small to see, and Leaflet is told to
remeasure after each transition because it caches container size.

touchmoveTwoFingers is off: one finger has to rotate the panorama, since
looking around is the core verb and the screen no longer scrolls. The
viewer navbar hides below lg, where the action bar would bury it and
gestures already cover what it offered.

viewport-fit=cover lets the layout own the display, so the header pads
past the notch and the action bar past the home indicator. The result
dialog caps to the viewport, scrolls its body and pins its actions.

Desktop keeps the two-column split unchanged.

The header donate button also moves to the beer emoji here, because it
shares an import line with the icons this layout adds.
2026-08-30 08:37:11 +07:00
tiennm99 ac765857bc feat(donate): swap in the new QR image and rebrand the call to action
The donation QR is now zlp.jpg, a 768x1024 portrait rather than the old
square, so the intrinsic dimensions and the container width move with it
to keep the aspect ratio right and the dialog inside the viewport.

Coffee becomes beer across the donate surfaces. The emoji replaces the
lucide icon and is hidden from assistive tech, which reads the button
text instead.
2026-08-30 08:37:00 +07:00
tiennm99 6ab546af89 perf(mapillary): serve the 2048px panorama derivative
thumb_original_url returns the full-resolution equirectangular, commonly
4-8 MP and several megabytes. That download dominates round latency on
mobile data and strains WebGL texture memory on phones.

Request thumb_2048_url alongside it and prefer it, falling back when
Mapillary has no 2048 derivative for an image.
2026-08-30 08:36:52 +07:00
tiennm99 577cb8f599 feat(ui): semantic colour tokens, OS dark mode, and accessibility pass
Every surface hardcoded its own grey and red, so the dark palette already
defined in globals.css could never take effect and muted text failed
contrast. Route colour through tokens instead: --brand and --surface join
the shadcn set, the Vietnamese flag red stays the brand, and a
prefers-color-scheme block supplies the dark values.

The dark: variant now matches both the media query and a .dark ancestor.
It previously matched only the class, so dark: utilities compiled to rules
that could never apply under OS-driven theming.

Contrast, measured against WCAG AA rather than eyeballed:
text-gray-400 on the page background was 2.41:1, the blue and purple rank
labels 2.34:1 and 2.46:1, white on amber-500 2.15:1. All twelve token
pairs now sit between 4.7:1 and 19.8:1 in both themes.

Also: emoji and text glyphs used as icons give way to lucide SVGs with
accessible names, score and rank stop relying on colour alone, loading and
result regions announce via role=status, interactive targets reach 44px,
100vh becomes dvh so mobile browser chrome stops clipping the game, the
leaderboard tab count follows the city list, and numeric columns use
tabular figures.
2026-08-29 15:16:19 +07:00
tiennm99 9645a355c6 fix(new-game): repair session lookup and log Mapillary failures
The POST handler read from gameSessions, an in-memory Map removed in
the Redis migration, so every call threw a ReferenceError and returned
an opaque 500. Read through getGameSession instead, and answer 400 on a
missing sessionId, 404 when the session is gone, 500 on a store error.
Parsing the body no longer throws out of the handler on malformed JSON.

Log the underlying Mapillary error before returning the generic
coverage message, so an API outage is not silently reported as a city
without street view.
2026-08-29 14:44:02 +07:00
tiennm99 31dd477080 fix(mapillary): budget API errors separately from empty windows
Racing 8 windows per round made the retry budget correlated: a single
transient 5xx burst failed all 8 at once and burned 40% of it, so a few
seconds of Mapillary trouble exhausted the loop and surfaced to players
as "this city may not have sufficient coverage". Sequential attempts
had spread the same budget over ~20s and rode such blips out.

Count only genuine misses against the 20-window budget. Rounds that
lose windows to 5xx or network errors get their own smaller allowance
with a widening backoff, so a burst is not answered with another burst.

Restore the per-attempt error logging dropped in the previous commit:
a round losing windows to errors now logs the status and count instead
of the same benign "no panos yet" line an empty round produces.
2026-08-29 14:44:02 +07:00
tiennm99 1c62e3e1dd perf(mapillary): race dart-throw windows concurrently
Each Mapillary window query costs ~1-5s and most windows hold no
panoramas, so throwing them one at a time made /api/new-game wait for
the sum of every miss. Measured in production: median 17.8s, max 90.5s
over 18 samples.

Throw 8 windows per round instead, race them with Promise.any and abort
the losers once one yields panoramas. Wall time becomes one round-trip
in the common case. Same 20-window budget, same per-city delta, same
401 short-circuit.

After the change, over 10 samples: median 5.6s, max 20.3s.
2026-08-29 14:27:42 +07:00
tiennm99 493f29621e build: move from pnpm to npm
Replace pnpm-lock.yaml with package-lock.json. The security overrides move to
package.json#overrides with their ranges intact, including the bounded
'>=x <y' pins whose upper bounds keep the next major out. allowBuilds becomes
package.json#allowScripts for the packages actually present in the tree.
2026-08-17 13:28:24 +07:00
tiennm99 e3fb09c4d9 chore: remove dependabot version-update config 2026-07-25 14:15:31 +07:00
tiennm99 f614ce9057 chore(deps): bump dependencies and remediate js-yaml/postcss advisories
Bump the minor-and-patch dependency group (react 19.2.7, radix-ui,
react-hook-form 7.81.0, tailwindcss 4.3.3, and others), pin eslint to
the Next-compatible 9.x range since eslint 10 breaks next lint, and add
pnpm overrides forcing js-yaml >=4.2.0 and postcss >=8.5.10 to clear
open Dependabot alerts.
2026-07-17 18:05:42 +07:00
tiennm99 45b2f5a0a6 chore: drop inapplicable dependabot ecosystems (#27) 2026-05-23 11:09:04 +07:00