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) => (
+
+ );
+
+ 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) && (
- )}
{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() {
) : (
<>