From a7b590853835d1f2bd588e92b5e95a905722604b Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:33:27 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Places:=20fix=20list=20lag=20=E2=80=94=20de?= =?UTF-8?q?fer=20the=20search=20query=20off=20the=20keystroke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Places search drove browseList (gazetteer search + sort + visit overlay) and re-rendered up to 100 rows synchronously on every keystroke, so the list lagged far behind typing — while the map's search stayed smooth because it already uses useDeferredValue. Apply the same: the input stays instant (bound to raw filter), and the expensive browse recompute + row re-render run in an interruptible deferred pass. typecheck clean; places-explore e2e green. --- .../src/features/visits/PlacesScreen.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/postcards/src/features/visits/PlacesScreen.tsx b/apps/postcards/src/features/visits/PlacesScreen.tsx index 697a1be..32ac911 100644 --- a/apps/postcards/src/features/visits/PlacesScreen.tsx +++ b/apps/postcards/src/features/visits/PlacesScreen.tsx @@ -1,4 +1,13 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { + memo, + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, + type RefObject, +} from "react"; import { getReferenceData } from "../../lib/reference/referenceData"; import { useVisits } from "../../lib/store/useVisits"; import { useToast } from "../../lib/store/useToast"; @@ -442,7 +451,13 @@ export function PlacesScreen() { const [filterOpen, setFilterOpen] = useState(false); const [shown, setShown] = useState(100); const [groupBy, setGroupBy] = useState<"none" | "country" | "year">("none"); - const q = filter.trim().toLowerCase(); + // Defer the query that drives the heavy lists (browse over the gazetteer + the + // personal-list filter) off the keystroke: the input stays instant (bound to raw + // `filter`), while the expensive recompute + re-render of up to 100 rows runs in + // an interruptible follow-up pass — matching the map's search, so the Places list + // no longer lags behind typing. + const deferredFilter = useDeferredValue(filter); + const q = deferredFilter.trim().toLowerCase(); // The kind axis IS the app's shared map "mode" (FR-012): selecting a place kind // keeps the map and Places in lock-step. Countries is a Places-only kind (the @@ -630,9 +645,9 @@ export function PlacesScreen() { // the chosen kind, status-overlaid, via the tested pure engine. ──────────────── const browseRows = useMemo(() => { if (kind !== "cities" && kind !== "monuments" && kind !== "airports") return []; - return browseList(kind, status, currentFilters(filters), ref, visits, filter.trim()); + return browseList(kind, status, currentFilters(filters), ref, visits, deferredFilter.trim()); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kind, status, filters.continent, filters.minPop, filters.category, ref, visits, filter]); + }, [kind, status, filters.continent, filters.minPop, filters.category, ref, visits, deferredFilter]); // The years your visits span, newest first, for the date filter chips. const years = useMemo(() => { From f76c3bd31730e488e5e26eb78860854aef0333c4 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:23:52 +0000 Subject: [PATCH 2/3] =?UTF-8?q?Places:=20page=20the=20browse=20lazily=20?= =?UTF-8?q?=E2=80=94=20no=20cap,=20no=20lag=20(build=20only=20what's=20sho?= =?UTF-8?q?wn)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list lag was browseList building the WHOLE matching set (up to ~5,600 airport rows, ~9ms) on every visit toggle / filter change, even though only ~100 render. Refactor to page: the ordered base pools are built once (memoised), and a call materialises only up to a limit (= rows shown) plus a one-item hasMore probe. So a recompute builds ~100 row objects, not thousands (airports 9.28ms -> 0.08ms, 116x), and "Show more" pages uncapped through the full set — no 300 cap. browseList now returns { rows, hasMore } and takes a limit; PlacesScreen passes shown, renders the page, and loads more via hasMore. Personal list unchanged. Combined with the deferred search query, the Places list keeps up with typing and toggles like the map. typecheck clean; 444 unit (incl. new paging test); places-explore + a11y + smoke + filter-panel e2e green. --- .../src/features/visits/PlacesScreen.tsx | 29 ++++--- .../src/features/visits/browseList.ts | 81 ++++++++++++------- apps/postcards/tests/unit/browseList.spec.ts | 41 +++++++--- 3 files changed, 99 insertions(+), 52 deletions(-) diff --git a/apps/postcards/src/features/visits/PlacesScreen.tsx b/apps/postcards/src/features/visits/PlacesScreen.tsx index 32ac911..4d5fe04 100644 --- a/apps/postcards/src/features/visits/PlacesScreen.tsx +++ b/apps/postcards/src/features/visits/PlacesScreen.tsx @@ -27,6 +27,7 @@ import { PassportScreen } from "../passport/PassportScreen"; import { ExperiencesScreen } from "../experiences/ExperiencesScreen"; import { PhotoWall } from "./PhotoWall"; import { ListPager } from "../../ui/ListPager"; +import { MoreButton } from "../../ui/MoreButton"; import { browseList, type BrowseRow } from "./browseList"; import { useFilters, @@ -643,11 +644,16 @@ export function PlacesScreen() { // ── World browse (kind = Cities / Monuments / Airports): the whole gazetteer of // the chosen kind, status-overlaid, via the tested pure engine. ──────────────── - const browseRows = useMemo(() => { - if (kind !== "cities" && kind !== "monuments" && kind !== "airports") return []; - return browseList(kind, status, currentFilters(filters), ref, visits, deferredFilter.trim()); + // Paged: build only the `shown` rows we render, plus a `hasMore` probe — so a + // visit toggle or filter change never materialises thousands of rows (that was the + // list lag), and "load more" can page uncapped through the whole set. + const browse = useMemo(() => { + if (kind !== "cities" && kind !== "monuments" && kind !== "airports") + return { rows: [] as BrowseRow[], hasMore: false }; + return browseList(kind, status, currentFilters(filters), ref, visits, deferredFilter.trim(), shown); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [kind, status, filters.continent, filters.minPop, filters.category, ref, visits, deferredFilter]); + }, [kind, status, filters.continent, filters.minPop, filters.category, ref, visits, deferredFilter, shown]); + const browseRows = browse.rows; // The years your visits span, newest first, for the date filter chips. const years = useMemo(() => { @@ -1029,17 +1035,16 @@ export function PlacesScreen() { ) : ( <> - {browseRows.length > shown && ( - setShown((n) => n + 100)} - /> + {browse.hasMore && ( +
+ setShown((n) => n + 100)}> + {t("journal.showMore", { count: 100 })} + +
)} )} diff --git a/apps/postcards/src/features/visits/browseList.ts b/apps/postcards/src/features/visits/browseList.ts index 293f838..417c2d7 100644 --- a/apps/postcards/src/features/visits/browseList.ts +++ b/apps/postcards/src/features/visits/browseList.ts @@ -7,9 +7,13 @@ import type { FilterState } from "../../lib/store/useFilters"; // The unified browse engine (spec 018 US2): given a place KIND and a STATUS/scope, // list the reference places (the whole world) with each row's personal status // overlaid from the user's records. Pure & offline — reads only the passed-in -// reference data + visits, invents nothing (Constitution I). The huge city -// gazetteer is never returned whole: cities are population-ranked and capped -// (Constitution VII / FR-005). +// reference data + visits, invents nothing (Constitution I). +// +// Paged & UNCAPPED (perf): the ordered base pools are built ONCE (memoised), and a +// call materialises only up to `limit` rows for the current page — so a visit +// toggle or a filter change never rebuilds thousands of row objects (that was the +// list lag). `hasMore` drives an infinite "load more": raise the limit to reveal +// the next page. Only the visible rows are ever built. export type BrowseKind = "cities" | "monuments" | "airports"; /** The single status axis, mapped to a browse predicate. */ @@ -30,15 +34,20 @@ export interface BrowseRow { lon?: number; } -/** Bounded working set for the ~135k-city gazetteer — never render it all. */ -export const CITY_BROWSE_CAP = 300; -/** How many most-populous cities to keep as the browsable pool (before filtering). */ +/** A page of browse rows plus whether more remain (drives infinite load-more). */ +export interface BrowseResult { + rows: BrowseRow[]; + hasMore: boolean; +} + +/** How many most-populous cities to keep browsable (the notable-cities pool; smaller + * towns are reached via search). Airports & monuments are browsed in full. */ const CITY_POOL = 2000; -// The population-ranked city pool is expensive to build (sort over ~135k), so it's -// memoised at module scope, re-derived only when the gazetteer's size changes (it -// streams in once from the starter set to the full set). Keyed by source length — -// a cheap, deterministic generation proxy. +// The ordered base pools are expensive to build (a sort over the gazetteer), so +// they're memoised at module scope, re-derived only when a dataset's size changes +// (it streams in once). Keyed by source length — a cheap generation proxy. Building +// them "beforehand" (once) is what keeps paging cheap. let cityPool: { srcLen: number; cities: City[] } | null = null; function populousCities(ref: ReferenceData): City[] { const all = ref.allCities(); @@ -48,9 +57,19 @@ function populousCities(ref: ReferenceData): City[] { return sorted; } -/** Reset the memoised city pool (tests only). */ +let monPool: { srcLen: number; sites: HeritageSite[] } | null = null; +function sortedMonuments(ref: ReferenceData): HeritageSite[] { + const all = ref.allHeritage(); + if (monPool && monPool.srcLen === all.length) return monPool.sites; + const sorted = [...all].sort((a, b) => a.name.localeCompare(b.name)); + monPool = { srcLen: all.length, sites: sorted }; + return sorted; +} + +/** Reset the memoised pools (tests only). */ export function __resetBrowseCache(): void { cityPool = null; + monPool = null; } export function browseList( @@ -60,7 +79,8 @@ export function browseList( ref: ReferenceData, visits: Visit[], query: string, -): BrowseRow[] { + limit = Infinity, +): BrowseResult { const idx = visitIndex(visits); const q = query.trim(); @@ -82,9 +102,19 @@ export function browseList( const countryName = (iso2: string) => ref.countryByIso2(iso2)?.name ?? iso2; const rows: BrowseRow[] = []; + let hasMore = false; + // Materialise up to `limit` rows; the first passing item beyond it just flips + // `hasMore` (never built), so the scan stops early and no extra objects allocate. + const take = (row: BrowseRow): boolean => { + if (rows.length >= limit) { + hasMore = true; + return true; // stop + } + rows.push(row); + return false; + }; if (kind === "cities") { - // Scope-first: a search narrows first; otherwise the most-populous pool. const base: City[] = q ? ref.searchCities(q, 500) : populousCities(ref); for (const c of base) { if (!continentOk(c.countryIso2)) continue; @@ -92,19 +122,16 @@ export function browseList( const place: PlaceRef = { kind: "city", id: c.id, name: c.name, countryId: c.countryIso2 }; const o = overlay(place); if (!passStatus(o.status, o.favorite)) continue; - rows.push({ + if (take({ kind: "city", id: c.id, name: c.name, sub: countryName(c.countryIso2), countryIso2: c.countryIso2, place, status: o.status, favorite: o.favorite, lat: c.lat, lon: c.lon, - }); - if (rows.length >= CITY_BROWSE_CAP) break; // bounded — never all 135k + })) break; } - return rows; + return { rows, hasMore }; } if (kind === "monuments") { - let base: HeritageSite[] = q - ? ref.searchHeritage(q, 500) - : [...ref.allHeritage()].sort((a, b) => a.name.localeCompare(b.name)); + let base: HeritageSite[] = q ? ref.searchHeritage(q, 500) : sortedMonuments(ref); // Searchable BY COUNTRY (FR-007): a query that names a country surfaces that // country's sites, not only ones whose own name matches the query. if (q) { @@ -120,16 +147,16 @@ export function browseList( const place: PlaceRef = { kind: "heritage", id: h.id, name: h.name, countryId: h.countryIso2 }; const o = overlay(place); if (!passStatus(o.status, o.favorite)) continue; - rows.push({ + if (take({ kind: "heritage", id: h.id, name: h.name, sub: countryName(h.countryIso2), countryIso2: h.countryIso2, place, status: o.status, favorite: o.favorite, category: h.category, lat: h.lat, lon: h.lon, - }); + })) break; } - return rows; + return { rows, hasMore }; } - // airports + // airports — browsed in full (uncapped), paged by `limit` let base = q ? ref.searchAirports(q, 500) : ref.allAirports(); // Searchable BY COUNTRY (FR-007): a query naming a country surfaces its airports. if (q) { @@ -145,10 +172,10 @@ export function browseList( const place: PlaceRef = { kind: "airport", id: a.id, name, countryId: a.countryIso2 }; const o = overlay(place); if (!passStatus(o.status, o.favorite)) continue; - rows.push({ + if (take({ kind: "airport", id: a.id, name, sub: [a.city, countryName(a.countryIso2)].filter(Boolean).join(" · "), countryIso2: a.countryIso2, place, status: o.status, favorite: o.favorite, lat: a.lat, lon: a.lon, - }); + })) break; } - return rows; + return { rows, hasMore }; } diff --git a/apps/postcards/tests/unit/browseList.spec.ts b/apps/postcards/tests/unit/browseList.spec.ts index 2d6f6a3..23e5824 100644 --- a/apps/postcards/tests/unit/browseList.spec.ts +++ b/apps/postcards/tests/unit/browseList.spec.ts @@ -44,65 +44,80 @@ const visit = ( ({ visitId: `v-${id}`, place: { kind, id, name: id, countryId: "FR" }, status, favorite }) as Visit; const F = DEFAULT_FILTERS; +const bl = (...args: Parameters) => browseList(...args).rows; beforeEach(() => __resetBrowseCache()); describe("browseList — reference browse + personal status overlay (spec 018 US2)", () => { it("lists ALL reference cities, most-populous first, marked not-visited by default", () => { - const rows = browseList("cities", "all", F, ref, [], ""); + const rows = bl("cities", "all", F, ref, [], ""); expect(rows.map((r) => r.id)).toEqual(["tokyo", "paris", "lyon"]); // population-ranked expect(rows.every((r) => r.status === "none")).toBe(true); }); it("overlays personal status from visits", () => { - const rows = browseList("cities", "all", F, ref, [visit("city", "tokyo", "visited")], ""); + const rows = bl("cities", "all", F, ref, [visit("city", "tokyo", "visited")], ""); expect(rows.find((r) => r.id === "tokyo")?.status).toBe("visited"); expect(rows.find((r) => r.id === "paris")?.status).toBe("none"); }); it("status = notVisited excludes anything you've logged", () => { - const rows = browseList("cities", "notVisited", F, ref, [visit("city", "tokyo", "visited")], ""); + const rows = bl("cities", "notVisited", F, ref, [visit("city", "tokyo", "visited")], ""); expect(rows.map((r) => r.id)).not.toContain("tokyo"); expect(rows.map((r) => r.id)).toEqual(["paris", "lyon"]); }); it("status = visited / wishlist / favorites filter the overlay", () => { const visits = [visit("city", "tokyo", "visited", true), visit("city", "paris", "wishlist")]; - expect(browseList("cities", "visited", F, ref, visits, "").map((r) => r.id)).toEqual(["tokyo"]); - expect(browseList("cities", "wishlist", F, ref, visits, "").map((r) => r.id)).toEqual(["paris"]); - expect(browseList("cities", "favorites", F, ref, visits, "").map((r) => r.id)).toEqual(["tokyo"]); + expect(bl("cities", "visited", F, ref, visits, "").map((r) => r.id)).toEqual(["tokyo"]); + expect(bl("cities", "wishlist", F, ref, visits, "").map((r) => r.id)).toEqual(["paris"]); + expect(bl("cities", "favorites", F, ref, visits, "").map((r) => r.id)).toEqual(["tokyo"]); }); it("search narrows within the kind", () => { - expect(browseList("cities", "all", F, ref, [], "tok").map((r) => r.id)).toEqual(["tokyo"]); + expect(bl("cities", "all", F, ref, [], "tok").map((r) => r.id)).toEqual(["tokyo"]); }); it("monuments carry their category; the category filter narrows them", () => { - const all = browseList("monuments", "all", F, ref, [], ""); + const all = bl("monuments", "all", F, ref, [], ""); expect(all.find((r) => r.id === "h1")?.category).toBe("cultural"); - const cultural = browseList("monuments", "all", { ...F, category: "cultural" }, ref, [], ""); + const cultural = bl("monuments", "all", { ...F, category: "cultural" }, ref, [], ""); expect(cultural.map((r) => r.id)).toEqual(["h1"]); }); it("airports browse the world, name includes the IATA code", () => { - const rows = browseList("airports", "all", F, ref, [], ""); + const rows = bl("airports", "all", F, ref, [], ""); expect(rows.map((r) => r.id).sort()).toEqual(["CDG", "HND"]); expect(rows.find((r) => r.id === "CDG")?.name).toContain("(CDG)"); }); it("monuments & airports are searchable BY COUNTRY (FR-007)", () => { // "France" matches no monument NAME, but France's site (h1) surfaces by country. - const mon = browseList("monuments", "all", F, ref, [], "France"); + const mon = bl("monuments", "all", F, ref, [], "France"); expect(mon.map((r) => r.id)).toContain("h1"); expect(mon.map((r) => r.id)).not.toContain("h2"); // Japan's site excluded // "Japan" surfaces Japanese airports by country (HND), not French ones (CDG). - const air = browseList("airports", "all", F, ref, [], "Japan"); + const air = bl("airports", "all", F, ref, [], "Japan"); expect(air.map((r) => r.id)).toContain("HND"); expect(air.map((r) => r.id)).not.toContain("CDG"); }); it("continent filter narrows every kind", () => { - const rows = browseList("cities", "all", { ...F, continent: "Asia" }, ref, [], ""); + const rows = bl("cities", "all", { ...F, continent: "Asia" }, ref, [], ""); expect(rows.map((r) => r.id)).toEqual(["tokyo"]); }); + + it("pages with a limit and reports hasMore (uncapped load-more, perf)", () => { + // 3 cities in the pool; a limit of 2 materialises 2 rows and flags more remain. + const page1 = browseList("cities", "all", F, ref, [], "", 2); + expect(page1.rows.map((r) => r.id)).toEqual(["tokyo", "paris"]); // population-ranked page + expect(page1.hasMore).toBe(true); + // Raising the limit reveals the rest with nothing left over. + const page2 = browseList("cities", "all", F, ref, [], "", 5); + expect(page2.rows.map((r) => r.id)).toEqual(["tokyo", "paris", "lyon"]); + expect(page2.hasMore).toBe(false); + // Only the requested rows are built — never the whole pool. + expect(browseList("airports", "all", F, ref, [], "", 1).rows).toHaveLength(1); + expect(browseList("airports", "all", F, ref, [], "", 1).hasMore).toBe(true); + }); }); From 3d1a4903874e6640892d5a47ec3a5407fe4f033e Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:52:33 +0000 Subject: [PATCH 3/3] Stats: redesign the country card for readability + add a mega-city (1M+) tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-country card was hard to read: a doubled flag/name, a "cities %" sliver that was always ~0, and a wall of rounded chips for regions/monuments. - Country card: one flag + name in the summary; compact mega/big/regions meters at a glance; expanded body shows the same with counts plus an "Open full page" link (no repeated name) and plain, scannable name lists for what's left to explore — no chip wall. Tighter spacing when open. - Mega cities (1M+): a new tier alongside big cities (100k+). Precomputed per country at load (megaCityCount), surfaced as a KPI, a By-city-size segment, and a per-country meter. - Mobile top-bar search: short placeholder on narrow screens and reclaim the clear-button padding while empty, so it reads "Search…" instead of clipping to "Se". --- .../src/features/stats/StatsView.tsx | 308 ++++++++---------- .../src/features/stats/computeStats.ts | 16 +- .../src/features/visits/PlaceSearch.tsx | 19 +- apps/postcards/src/lib/i18n/en.ts | 5 + apps/postcards/src/lib/i18n/fr.ts | 5 + apps/postcards/src/lib/i18n/ko.ts | 5 + .../src/lib/reference/referenceData.ts | 16 +- apps/postcards/src/lib/reference/types.ts | 4 + apps/postcards/src/styles.css | 61 +++- apps/postcards/tests/unit/browseList.spec.ts | 4 +- 10 files changed, 256 insertions(+), 187 deletions(-) diff --git a/apps/postcards/src/features/stats/StatsView.tsx b/apps/postcards/src/features/stats/StatsView.tsx index 3979617..3d4c923 100644 --- a/apps/postcards/src/features/stats/StatsView.tsx +++ b/apps/postcards/src/features/stats/StatsView.tsx @@ -21,50 +21,60 @@ import { useFilters } from "../../lib/store/useFilters"; import { countryFlag, formatDate, formatInt, formatKm, formatPercent } from "../../lib/format/format"; import { CONTINENT_COLORS, CONTINENT_ORDER } from "../../lib/reference/continents"; import { ScopeToggle } from "../../ui/ScopeToggle"; -import { useT } from "../../lib/i18n"; +import { useT, type MessageKey } from "../../lib/i18n"; /** A row of tappable chips, capped so a huge country doesn't flood the card. */ -function ChipRow({ +/** A compact, readable list of place names as plain links — a label, a count, then + * names separated by "·", expandable past a small cap. Replaces the hard-to-read + * rounded-chip wall for regions/monuments (easier to scan, less visual weight). */ +function NameList({ label, - names, - done, + items, onPick, hint, - max = 16, + max = 12, }: { label: string; - names: string[]; - /** Style as already-seen (filled) vs still-to-do (outlined). */ - done?: boolean; - onPick?: (name: string) => void; - /** Tooltip verb; chips either open a page or fly the map. */ + items: string[]; + onPick: (name: string) => void; hint?: string; max?: number; }) { const t = useT(); const [expanded, setExpanded] = useState(false); - if (names.length === 0) return null; - const shown = expanded ? names : names.slice(0, max); + if (items.length === 0) return null; + const shown = expanded ? items : items.slice(0, max); const hintText = hint ?? t("common.open"); return ( -
- {label} - - {shown.map((n) => ( +
+ + {label} · {items.length} + + + {shown.map((n, i) => ( + + {i > 0 && ( + + {" · "} + + )} + + + ))} + {items.length > max && ( - ))} - {names.length > max && ( - )} @@ -103,11 +113,12 @@ function Bar({ value, label, color }: { value: number; label: string; color?: st } /** - * One country as a native
: the collapsed is a scannable - * roster row (flag + name + tag pills + one slim cities meter); expanding reveals - * the three metrics and the place chips. The chip name-lists are computed only - * once the row is opened (lazy, keyed off onToggle), and recompute when the full - * gazetteer lands (gazGen). + * One country as a native
: the collapsed is a scannable row — + * flag + name + a compact meter for the tiers that actually mean something (mega/ + * big cities and regions). Expanding reveals the same metrics with counts plus the + * places still to explore, as plain readable lists (no chip wall). The overall + * "cities %" (a sliver of every 15k+ town) is dropped — it read as noise. Detail + * lists are lazy (computed on open), refreshed when the full gazetteer lands. */ function CountryRow({ c, @@ -126,185 +137,132 @@ function CountryRow({ // eslint-disable-next-line react-hooks/exhaustive-deps [open, visits, ref, c.iso2, gazGen], ); - // Tapping a city or monument chip opens its detail page directly (you clicked - // the place, so show the place), never just a map fly-by. const openByName = (list: { id: string; name: string }[]) => (name: string) => { const hit = list.find((x) => x.name === name); if (hit) useUi.getState().openCity(hit.id); }; + + // Slim summary meter: a tiny label + percentage + bar. + const meter = (labelKey: MessageKey, ariaKey: MessageKey, pctVal: number, color?: string) => ( +
+ + {t(labelKey)} {formatPercent(pctVal)} + + +
+ ); + // Full metric row (expanded): label, "x/y · pct" detail, bar. + const metric = ( + labelKey: MessageKey, + detailKey: MessageKey, + ariaKey: MessageKey, + visited: number, + total: number, + pctVal: number, + color?: string, + ) => ( +
+
+ {t(labelKey)} + {t(detailKey, { pct: formatPercent(pctVal), visited, total })} +
+ +
+ ); + + const hasMega = c.megaCitiesTotal > 0; + const MEGA_COLOR = "var(--stat-fav)"; + const BIG_COLOR = "var(--stat-been)"; + const REGION_COLOR = "var(--accent)"; + return (
setOpen(e.currentTarget.open)}> - {/* Plain label so the whole row toggles the
— keeping an - interactive control out of (no nested-interactive). The - "open the country page" action lives as a real button on expand. */} + {/* Flag + name shown ONCE here (the expanded body no longer repeats them). */} {countryFlag(c.iso2)} {" "} {c.name} - - {t("stats.country.citiesTag", { count: formatInt(c.citiesVisited) })} - {t("stats.country.regionsTag", { count: formatInt(c.regionsVisited) })} - {c.heritageTotal > 0 && ( - {t("stats.country.sitesTag", { count: formatInt(c.heritageVisited) })} - )} - › - {/* Cities, big cities (100k+) and regions side by side, so every coverage % - reads at a glance — no need to expand the row to compare them. Hidden - once the row is open (the body shows the same, with counts) so nothing - is duplicated. */} - {(c.citiesTotal > 0 || c.bigCitiesTotal > 0 || c.regionsTotal > 0) && ( + {/* Only the meaningful tiers — mega (1M+), big (100k+), regions. Hidden once + open (the body shows the same, with counts) so nothing is duplicated. */} + {(hasMega || c.bigCitiesTotal > 0 || c.regionsTotal > 0) && (
- {c.citiesTotal > 0 && ( -
- - {t("stats.country.metricCities")} {formatPercent(c.cityPct)} - - -
- )} - {c.bigCitiesTotal > 0 && ( -
- - {t("stats.country.metricBigCities")} {formatPercent(c.bigCityPct)} - - -
- )} - {c.regionsTotal > 0 && ( -
- - {t("stats.country.metricRegions")} {formatPercent(c.regionPct)} - - -
- )} + {hasMega && meter("stats.country.metricMega", "stats.country.megaCityBarAria", c.megaCityPct, MEGA_COLOR)} + {c.bigCitiesTotal > 0 && + meter("stats.country.metricBigCities", "stats.country.bigCityBarAria", c.bigCityPct, BIG_COLOR)} + {c.regionsTotal > 0 && + meter("stats.country.metricRegions", "stats.country.regionBarAria", c.regionPct, REGION_COLOR)}
)}
+ {/* Open the full page — no repeated flag/name (that was "France" twice). */} -
-
- {t("stats.country.metricCities")} - - {c.citiesTotal > 0 - ? t("stats.country.metricCitiesDetail", { - pct: formatPercent(c.cityPct), - visited: c.citiesVisited, - total: c.citiesTotal, - }) - : t("stats.country.noCityData")} - -
- {c.citiesTotal > 0 && } -
- - {c.bigCitiesTotal > 0 && ( -
-
- {t("stats.country.metricBigCities")} - - {t("stats.country.metricBigCitiesDetail", { - pct: formatPercent(c.bigCityPct), - visited: c.bigCitiesVisited, - total: c.bigCitiesTotal, - })} - -
- -
- )} - -
-
- {t("stats.country.metricRegions")} - - {c.regionsTotal > 0 - ? t("stats.country.metricRegionsDetail", { - pct: formatPercent(c.regionPct), - visited: c.regionsVisited, - total: c.regionsTotal, - }) - : t("stats.country.datasetNotLoaded")} - -
- {c.regionsTotal > 0 && ( - + {hasMega && + metric( + "stats.country.metricMega", + "stats.country.metricMegaDetail", + "stats.country.megaCityBarAria", + c.megaCitiesVisited, + c.megaCitiesTotal, + c.megaCityPct, + MEGA_COLOR, + )} + {c.bigCitiesTotal > 0 && + metric( + "stats.country.metricBigCities", + "stats.country.metricBigCitiesDetail", + "stats.country.bigCityBarAria", + c.bigCitiesVisited, + c.bigCitiesTotal, + c.bigCityPct, + BIG_COLOR, + )} + {c.regionsTotal > 0 && + metric( + "stats.country.metricRegions", + "stats.country.metricRegionsDetail", + "stats.country.regionBarAria", + c.regionsVisited, + c.regionsTotal, + c.regionPct, + REGION_COLOR, + )} + {c.heritageTotal > 0 && + metric( + "stats.country.metricSites", + "stats.country.metricSitesDetail", + "stats.country.heritageBarAria", + c.heritageVisited, + c.heritageTotal, + c.heritagePct, + "var(--stat-want)", )} -
- - {c.heritageTotal > 0 && ( -
-
- {t("stats.country.metricSites")} - - {t("stats.country.metricSitesDetail", { - pct: formatPercent(c.heritagePct), - visited: c.heritageVisited, - total: c.heritageTotal, - })} - -
- -
- )} {detail && ( <> - {/* No per-city OR per-region "visited" chip wall: a country can have - dozens of each and the "done" chips add noise without insight (the % - bars above already summarize them; the names live in Places / the map). - Only "what's LEFT" stays — regions and monuments still to see are the - actionable, space-worthy lists. */} - - m.name)} - done - onPick={openByName(detail.monumentsVisited)} - /> - m.name)} + items={detail.monumentsRemaining.map((m) => m.name)} onPick={openByName(detail.monumentsRemaining)} /> diff --git a/apps/postcards/src/features/stats/computeStats.ts b/apps/postcards/src/features/stats/computeStats.ts index 2d01f58..3a74fe3 100644 --- a/apps/postcards/src/features/stats/computeStats.ts +++ b/apps/postcards/src/features/stats/computeStats.ts @@ -1,6 +1,6 @@ import type { Visit } from "../../lib/schema/models"; import type { ReferenceData } from "../../lib/reference/types"; -import { BIG_CITY_MIN_POPULATION } from "../../lib/reference/types"; +import { BIG_CITY_MIN_POPULATION, MEGA_CITY_MIN_POPULATION } from "../../lib/reference/types"; import { inScope, type CountryScope } from "../../lib/reference/scope"; /** Whether a visited country id counts under the chosen scope (unknown ids: only under "all"). */ @@ -37,6 +37,11 @@ export interface CountryCoverage { bigCitiesVisited: number; bigCitiesTotal: number; bigCityPct: number; // 0..1 + /** Cities with 1M+ people — the metropolises (see MEGA_CITY_MIN_POPULATION). + * Only meaningful when megaCitiesTotal > 0 (many countries have none). */ + megaCitiesVisited: number; + megaCitiesTotal: number; + megaCityPct: number; // 0..1 regionsVisited: number; regionsTotal: number; regionPct: number; // 0..1 @@ -146,6 +151,7 @@ export function computeCountryCoverage( const country = ref.countryByIso2(iso2); const visitedCityIds = new Set(); const visitedBigCityIds = new Set(); + const visitedMegaCityIds = new Set(); const visitedRegionIds = new Set(); for (const v of onlyVisited(visits)) { if (v.place.kind !== "city") continue; @@ -154,13 +160,16 @@ export function computeCountryCoverage( const city = ref.cityById(v.place.id); if (!city || city.countryIso2 !== iso2) continue; visitedCityIds.add(v.place.id); - if ((city.population ?? 0) >= BIG_CITY_MIN_POPULATION) visitedBigCityIds.add(v.place.id); + const pop = city.population ?? 0; + if (pop >= BIG_CITY_MIN_POPULATION) visitedBigCityIds.add(v.place.id); + if (pop >= MEGA_CITY_MIN_POPULATION) visitedMegaCityIds.add(v.place.id); if (city.subdivisionId) visitedRegionIds.add(city.subdivisionId); } // Denominator: how many 100k+ cities the country has. Precomputed in one pass // over the gazetteer (Country.bigCityCount), so this is an O(1) lookup — never a // per-country rescan. Like cityCount it's refreshed when the full gazetteer lands. const bigCitiesTotal = country?.bigCityCount ?? 0; + const megaCitiesTotal = country?.megaCityCount ?? 0; // Heritage sites (a "category"): visited sites in this country vs the total there. // Membership is by the country's own site list (heritageOf), so a transnational // site that spans several countries counts toward each — matching the denominator. @@ -183,6 +192,9 @@ export function computeCountryCoverage( bigCitiesVisited: visitedBigCityIds.size, bigCitiesTotal, bigCityPct: pct(visitedBigCityIds.size, bigCitiesTotal), + megaCitiesVisited: visitedMegaCityIds.size, + megaCitiesTotal, + megaCityPct: pct(visitedMegaCityIds.size, megaCitiesTotal), regionsVisited: visitedRegionIds.size, regionsTotal, regionPct: pct(visitedRegionIds.size, regionsTotal), diff --git a/apps/postcards/src/features/visits/PlaceSearch.tsx b/apps/postcards/src/features/visits/PlaceSearch.tsx index 4e0ffb4..6da3a7e 100644 --- a/apps/postcards/src/features/visits/PlaceSearch.tsx +++ b/apps/postcards/src/features/visits/PlaceSearch.tsx @@ -32,6 +32,21 @@ export function PlaceSearch({ const listRef = useRef(null); const focusNonce = useUi((s) => s.searchFocusNonce); + // On a phone the top-bar field is narrow (it shares the row with the brand and + // the action icons), so the full "Search a city or country…" placeholder gets + // clipped mid-word. Use a short placeholder there — it stays fully readable, + // and the leading 🔍 plus the accessible name still convey what it searches. + const [narrow, setNarrow] = useState( + () => typeof matchMedia !== "undefined" && matchMedia("(max-width: 899.98px)").matches, + ); + useEffect(() => { + if (typeof matchMedia === "undefined") return; + const mq = matchMedia("(max-width: 899.98px)"); + const onChange = (e: MediaQueryListEvent) => setNarrow(e.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); + // Focus when the "/" shortcut asks (nonce > 0 avoids grabbing focus on mount). useEffect(() => { if (focusNonce > 0) inputRef.current?.focus(); @@ -146,8 +161,8 @@ export function PlaceSearch({ 0} diff --git a/apps/postcards/src/lib/i18n/en.ts b/apps/postcards/src/lib/i18n/en.ts index a36898b..fded995 100644 --- a/apps/postcards/src/lib/i18n/en.ts +++ b/apps/postcards/src/lib/i18n/en.ts @@ -225,6 +225,10 @@ export const en = { "stats.country.metricBigCities": "Big cities", "stats.country.metricBigCitiesDetail": "{pct} · {visited}/{total} cities of 100k+", "stats.country.bigCityBarAria": "{name}: big cities (100k+) visited", + "stats.country.metricMega": "Mega cities", + "stats.country.metricMegaDetail": "{pct} · {visited}/{total} cities of 1M+", + "stats.country.megaCityBarAria": "{name}: mega cities (1M+) visited", + "stats.country.openPage": "Open full page", "stats.country.metricRegions": "Regions", "stats.country.metricRegionsDetail": "{pct} · {visited}/{total} regions", "stats.country.datasetNotLoaded": "dataset not loaded", @@ -787,6 +791,7 @@ export const en = { // ── Global place search (top bar) ──────────────────────────────────────── "search.placeholder": "Search a city or country…", + "search.placeholderShort": "Search…", "search.aria": "Search a city or country", "search.clear": "Clear search", "search.noMatches": "No matches for {q}", diff --git a/apps/postcards/src/lib/i18n/fr.ts b/apps/postcards/src/lib/i18n/fr.ts index 5f40bbb..9762d6d 100644 --- a/apps/postcards/src/lib/i18n/fr.ts +++ b/apps/postcards/src/lib/i18n/fr.ts @@ -218,6 +218,10 @@ export const fr: Messages = { "stats.country.metricBigCities": "Grandes villes", "stats.country.metricBigCitiesDetail": "{pct} · {visited}/{total} villes de 100k+", "stats.country.bigCityBarAria": "{name} : grandes villes (100k+) visitées", + "stats.country.metricMega": "Mégapoles", + "stats.country.metricMegaDetail": "{pct} · {visited}/{total} villes de 1M+", + "stats.country.megaCityBarAria": "{name} : mégapoles (1M+) visitées", + "stats.country.openPage": "Ouvrir la page", "stats.country.metricRegions": "Régions", "stats.country.metricRegionsDetail": "{pct} · {visited}/{total} régions", "stats.country.datasetNotLoaded": "jeu de données non chargé", @@ -782,6 +786,7 @@ export const fr: Messages = { // ── Global place search (top bar) ──────────────────────────────────────── "search.placeholder": "Rechercher une ville ou un pays…", + "search.placeholderShort": "Rechercher…", "search.aria": "Rechercher une ville ou un pays", "search.clear": "Effacer la recherche", "search.noMatches": "Aucun résultat pour {q}", diff --git a/apps/postcards/src/lib/i18n/ko.ts b/apps/postcards/src/lib/i18n/ko.ts index 291eb83..11370cc 100644 --- a/apps/postcards/src/lib/i18n/ko.ts +++ b/apps/postcards/src/lib/i18n/ko.ts @@ -219,6 +219,10 @@ export const ko: Messages = { "stats.country.metricBigCities": "대도시", "stats.country.metricBigCitiesDetail": "{pct} · 10만+ 도시 {visited}/{total}", "stats.country.bigCityBarAria": "{name}: 방문한 대도시(10만+)", + "stats.country.metricMega": "메가시티", + "stats.country.metricMegaDetail": "{pct} · {visited}/{total} 도시 (100만+)", + "stats.country.megaCityBarAria": "{name}: 방문한 메가시티(100만+)", + "stats.country.openPage": "전체 페이지 열기", "stats.country.metricRegions": "지역", "stats.country.metricRegionsDetail": "{pct} · 지역 {visited}/{total}", "stats.country.datasetNotLoaded": "데이터셋 미로드", @@ -781,6 +785,7 @@ export const ko: Messages = { // ── Global place search (top bar) ──────────────────────────────────────── "search.placeholder": "도시나 국가 검색…", + "search.placeholderShort": "검색…", "search.aria": "도시나 국가 검색", "search.clear": "검색 지우기", "search.noMatches": "{q}에 대한 결과 없음", diff --git a/apps/postcards/src/lib/reference/referenceData.ts b/apps/postcards/src/lib/reference/referenceData.ts index 2c7b011..41946ed 100644 --- a/apps/postcards/src/lib/reference/referenceData.ts +++ b/apps/postcards/src/lib/reference/referenceData.ts @@ -10,7 +10,7 @@ import type { ReferenceProvenance, Subdivision, } from "./types"; -import { BIG_CITY_MIN_POPULATION } from "./types"; +import { BIG_CITY_MIN_POPULATION, MEGA_CITY_MIN_POPULATION } from "./types"; import provenanceData from "./data/provenance.json"; import continentsData from "./data/continents.json"; import sovereigntyData from "./data/sovereignty.json"; @@ -63,10 +63,14 @@ function buildCountries(cities: City[], subdivisions: Subdivision[]): Country[] const names = countries.getNames("en"); const cityCounts = new Map(); const bigCityCounts = new Map(); + const megaCityCounts = new Map(); for (const c of cities) { cityCounts.set(c.countryIso2, (cityCounts.get(c.countryIso2) ?? 0) + 1); - if ((c.population ?? 0) >= BIG_CITY_MIN_POPULATION) + const pop = c.population ?? 0; + if (pop >= BIG_CITY_MIN_POPULATION) bigCityCounts.set(c.countryIso2, (bigCityCounts.get(c.countryIso2) ?? 0) + 1); + if (pop >= MEGA_CITY_MIN_POPULATION) + megaCityCounts.set(c.countryIso2, (megaCityCounts.get(c.countryIso2) ?? 0) + 1); } const subCounts = new Map(); for (const s of subdivisions) @@ -85,6 +89,7 @@ function buildCountries(cities: City[], subdivisions: Subdivision[]): Country[] continent: continents[iso2] ?? "", cityCount: cityCounts.get(iso2) ?? 0, bigCityCount: bigCityCounts.get(iso2) ?? 0, + megaCityCount: megaCityCounts.get(iso2) ?? 0, subdivisionCount: subCounts.get(iso2) ?? 0, sovereignty: sovereignty[iso2] ?? "territory", }); @@ -152,14 +157,19 @@ class ReferenceDataImpl implements ReferenceData { // (pack POIs are extra places, not part of the cities-known count). const counts = new Map(); const bigCounts = new Map(); + const megaCounts = new Map(); for (const c of this.baseCities) { counts.set(c.countryIso2, (counts.get(c.countryIso2) ?? 0) + 1); - if ((c.population ?? 0) >= BIG_CITY_MIN_POPULATION) + const pop = c.population ?? 0; + if (pop >= BIG_CITY_MIN_POPULATION) bigCounts.set(c.countryIso2, (bigCounts.get(c.countryIso2) ?? 0) + 1); + if (pop >= MEGA_CITY_MIN_POPULATION) + megaCounts.set(c.countryIso2, (megaCounts.get(c.countryIso2) ?? 0) + 1); } for (const country of this.countries) { country.cityCount = counts.get(country.iso2) ?? 0; country.bigCityCount = bigCounts.get(country.iso2) ?? 0; + country.megaCityCount = megaCounts.get(country.iso2) ?? 0; } this.remergeCities(); } diff --git a/apps/postcards/src/lib/reference/types.ts b/apps/postcards/src/lib/reference/types.ts index 1ae0784..fd16fcc 100644 --- a/apps/postcards/src/lib/reference/types.ts +++ b/apps/postcards/src/lib/reference/types.ts @@ -8,6 +8,9 @@ import type { CountryScope, Sovereignty } from "./scope"; * per-country big-city coverage; also the "large" size-band floor. Precomputed * once into Country.bigCityCount so coverage never rescans the gazetteer. */ export const BIG_CITY_MIN_POPULATION = 100_000; +/** Population floor for a "mega city" (a metropolis, 1M+). Denominator for the + * per-country mega-city coverage bar; precomputed like bigCityCount. */ +export const MEGA_CITY_MIN_POPULATION = 1_000_000; export interface Country { iso2: string; @@ -17,6 +20,7 @@ export interface Country { continent: string; // Europe|Asia|Africa|Americas|Oceania|Antarctic (from world-countries) cityCount: number; // # of gazetteer cities in this country (denominator for % of cities) bigCityCount: number; // # of gazetteer cities with 100k+ people (denominator for % of big cities) + megaCityCount: number; // # of gazetteer cities with 1M+ people (denominator for % of mega cities) subdivisionCount: number; // # of first-level subdivisions (denominator for % of regions) sovereignty: Sovereignty; // "un" member vs dependent "territory" (from world-countries) } diff --git a/apps/postcards/src/styles.css b/apps/postcards/src/styles.css index 4ff5857..2156c05 100644 --- a/apps/postcards/src/styles.css +++ b/apps/postcards/src/styles.css @@ -1798,6 +1798,53 @@ h1.brand { padding-top: 8px; border-top: 1px solid var(--border); } +/* Keep the expanded panel compact — tighter than the standalone continent + metrics so a country card doesn't grow into a wall when opened. */ +.country-body .metric { + margin-top: 7px; +} +.country-body .metric-label { + font-size: 12px; + margin-bottom: 4px; +} +/* "What's left to explore" as plain, scannable text — a label + count, then + names as accent links joined by thin separators. Far lighter than the old + rounded-chip wall, and it wraps cleanly on a narrow phone. */ +.name-list { + margin-top: 12px; + font-size: 12.5px; + line-height: 1.75; +} +.name-list-label { + display: block; + font-weight: 600; + color: var(--text); + margin-bottom: 1px; +} +.name-list-label .muted { + font-weight: 400; +} +.name-list-names { + color: var(--muted); +} +.name-list-sep { + color: var(--border); +} +.name-list-link { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--accent); + font-weight: 500; +} +.name-list-link:hover { + text-decoration: underline; +} +.name-list-more { + font-weight: 600; + margin-left: 4px; +} .metric { margin-top: 8px; } @@ -3904,8 +3951,13 @@ textarea.select { .topbar-search .search-input { height: 32px; font-size: 14px; - /* Room for the leading 🔍 and the trailing ✕. */ - padding: 0 32px 0 34px; + /* Leading 🔍 always; trailing space reserved for ✕ only WHEN it's shown + (i.e. once you've typed). Empty, the placeholder reclaims that room so it + reads fully instead of clipping to "Se…" on a narrow phone bar. */ + padding: 0 12px 0 34px; +} +.topbar-search .search-input.has-clear { + padding-right: 32px; } /* Dropdowns anchor under the bar, not in the page flow. */ .topbar-search .results { @@ -3930,9 +3982,12 @@ textarea.select { @media (max-width: 899.98px) { .topbar-search .search-input { height: 30px; - padding: 0 30px 0 32px; /* room for 🔍 + ✕ */ + padding: 0 10px 0 32px; /* leading 🔍; ✕ room added only when typing */ font-size: 16px; /* below 16px, iOS zooms the page on focus */ } + .topbar-search .search-input.has-clear { + padding-right: 30px; + } /* The input can be narrow between the brand and the icons — span the dropdowns across the viewport instead of the input's own width. */ .topbar-search .results, diff --git a/apps/postcards/tests/unit/browseList.spec.ts b/apps/postcards/tests/unit/browseList.spec.ts index 23e5824..6d808da 100644 --- a/apps/postcards/tests/unit/browseList.spec.ts +++ b/apps/postcards/tests/unit/browseList.spec.ts @@ -18,8 +18,8 @@ const airports: Airport[] = [ { id: "HND", name: "Haneda", city: "Tokyo", countryIso2: "JP", lat: 35.5, lon: 139.8 }, ]; const countries: Record = { - FR: { iso2: "FR", iso3: "FRA", numeric: "250", name: "France", continent: "Europe", cityCount: 2, bigCityCount: 2, subdivisionCount: 1, sovereignty: "un" }, - JP: { iso2: "JP", iso3: "JPN", numeric: "392", name: "Japan", continent: "Asia", cityCount: 1, bigCityCount: 1, subdivisionCount: 1, sovereignty: "un" }, + FR: { iso2: "FR", iso3: "FRA", numeric: "250", name: "France", continent: "Europe", cityCount: 2, bigCityCount: 2, megaCityCount: 1, subdivisionCount: 1, sovereignty: "un" }, + JP: { iso2: "JP", iso3: "JPN", numeric: "392", name: "Japan", continent: "Asia", cityCount: 1, bigCityCount: 1, megaCityCount: 1, subdivisionCount: 1, sovereignty: "un" }, }; const lc = (s: string) => s.toLowerCase();