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/features/visits/PlacesScreen.tsx b/apps/postcards/src/features/visits/PlacesScreen.tsx index 697a1be..4d5fe04 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"; @@ -18,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, @@ -442,7 +452,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 @@ -628,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, filter.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, filter]); + }, [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(() => { @@ -1014,17 +1035,16 @@ export function PlacesScreen() { ) : ( <>
    - {browseRows.slice(0, shown).map((r) => ( + {browseRows.map((r) => ( ))}
- {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/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 2d6f6a3..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(); @@ -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); + }); });