26 Commits
Author SHA1 Message Date
tiennm99 ceedf5dde9 feat(scripts): restore the leaderboards from a backup
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.
2026-10-02 13:12:52 +07:00
tiennm99 b8b4ffbbcb docs: record fresh session ids, the daily pick rules and the new components 2026-09-28 15:01:41 +07:00
tiennm99 11bfa59377 docs: codify the conventions the code follows, drop the unused tabs dependency
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.
2026-09-21 16:25:53 +07:00
tiennm99 064c1733b6 chore(ci): run the gates on every push and back the boards up weekly
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.
2026-09-20 23:31:28 +07:00
tiennm99 8450b75bce feat(daily): one panorama a day, the same for everyone, with a streak
/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.
2026-09-20 23:23:02 +07:00
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 17b1b46e0c docs: reflect Upstash REST migration, Mapillary dart-throw, cron removal 2026-05-10 03:03:15 +07:00
tiennm99 2b5359e3cb feat: update docs 2025-08-16 16:47:46 +07:00
tiennm99 1a27e2d596 feat: stash 2025-08-16 08:30:26 +07:00