diff --git a/apps/postcards/src/features/filter/applyFilters.ts b/apps/postcards/src/features/filter/applyFilters.ts
index ff6b1b1..2b3a712 100644
--- a/apps/postcards/src/features/filter/applyFilters.ts
+++ b/apps/postcards/src/features/filter/applyFilters.ts
@@ -2,6 +2,7 @@ import type { Visit } from "../../lib/schema/models";
import type { ReferenceData } from "../../lib/reference/types";
import type { FilterState, FilterStatus } from "../../lib/store/useFilters";
import { mapDateMatches, rangeExactYear } from "../travel/period";
+import { countryFlag } from "../../lib/format/format";
import type { TFunction, MessageKey } from "../../lib/i18n";
// Pure predicates shared by every screen that slices places (spec 016 D3). No I/O,
@@ -50,6 +51,9 @@ export function placeMatches(v: Visit, ref: ReferenceData, s: FilterState): bool
if (pop !== null && pop < s.minPop) return false;
}
if (s.continent && ref.continentOf(v.place.countryId) !== s.continent) return false;
+ // One-country drill-down (set from the Stats country card). ISO codes compare
+ // directly; a place with no country ("ZZ") never matches a real code.
+ if (s.country && v.place.countryId !== s.country) return false;
return true;
}
@@ -74,8 +78,9 @@ export interface ActiveFilterChip {
}
/** Localised, ordered summary of every non-default dimension (one chip each). The
- * caller wires each chip's ✕ to clearField(field). Pure: no store dependency. */
-export function activeChips(s: FilterState, t: TFunction): ActiveFilterChip[] {
+ * caller wires each chip's ✕ to clearField(field). Pure: no store dependency.
+ * `ref` (optional) names the country chip — without it, the ISO code shows. */
+export function activeChips(s: FilterState, t: TFunction, ref?: ReferenceData): ActiveFilterChip[] {
const chips: ActiveFilterChip[] = [];
// Only a partial selection is a "filter" (empty or all three = show everything).
if (s.status.length > 0 && s.status.length < 3) {
@@ -100,6 +105,10 @@ export function activeChips(s: FilterState, t: TFunction): ActiveFilterChip[] {
if (s.hasPhoto) chips.push({ field: "hasPhoto", label: t("filter.hasPhoto") });
if (s.hasNote) chips.push({ field: "hasNote", label: t("filter.hasNote") });
if (s.continent) chips.push({ field: "continent", label: s.continent });
+ if (s.country) {
+ const name = ref?.countryByIso2(s.country)?.name ?? s.country;
+ chips.push({ field: "country", label: `${countryFlag(s.country)} ${name}` });
+ }
if (s.sort !== "pop") chips.push({ field: "sort", label: t("filter.sort.az") });
return chips;
}
diff --git a/apps/postcards/src/features/map/MapScreen.tsx b/apps/postcards/src/features/map/MapScreen.tsx
index 65fd691..6f2404f 100644
--- a/apps/postcards/src/features/map/MapScreen.tsx
+++ b/apps/postcards/src/features/map/MapScreen.tsx
@@ -55,6 +55,9 @@ const MAP_HIDDEN_FIELDS: (keyof FilterState)[] = [
"hasPhoto",
"hasNote",
"continent",
+ // One-country drill-down is a lists action (set from the Stats card); on the map
+ // you just pan to the country, so it neither filters the markers nor shows a chip.
+ "country",
// Place-kind mode is its own prominent pill now, not a filter — its state is
// visible in the pill itself, so it must not double up as a summary chip or
// light the Filter badge.
diff --git a/apps/postcards/src/features/stats/StatsView.tsx b/apps/postcards/src/features/stats/StatsView.tsx
index 3d4c923..55337fb 100644
--- a/apps/postcards/src/features/stats/StatsView.tsx
+++ b/apps/postcards/src/features/stats/StatsView.tsx
@@ -142,16 +142,30 @@ function CountryRow({
if (hit) useUi.getState().openCity(hit.id);
};
+ // Tapping a metric drills into Places, scoped to THIS country + the tier — and the
+ // filter is the shared store, so it survives leaving and returning to the list.
+ const drill = (view: "cities" | "monuments", minPop: number) => () => {
+ useFilters.getState().set({ country: c.iso2, minPop, listOnly: false });
+ useUi.getState().openPlaces(view);
+ };
+ // Cities coverage is a sliver of a huge denominator, so it often rounds to 0 —
+ // floor a real, non-zero share to "<1%" so it never reads as "nothing seen".
+ const pctText = (p: number) => {
+ const s = formatPercent(p);
+ return p > 0 && s === formatPercent(0) ? "<1%" : s;
+ };
+
// Slim summary meter: a tiny label + percentage + bar.
const meter = (labelKey: MessageKey, ariaKey: MessageKey, pctVal: number, color?: string) => (
- {t(labelKey)} {formatPercent(pctVal)}
+ {t(labelKey)} {pctText(pctVal)}
);
- // Full metric row (expanded): label, "x/y · pct" detail, bar.
+ // Full metric row (expanded): label, "x/y · pct" detail, bar. With an onClick it
+ // renders as a button that opens the matching, country-scoped Places list.
const metric = (
labelKey: MessageKey,
detailKey: MessageKey,
@@ -159,20 +173,35 @@ function CountryRow({
visited: number,
total: number,
pctVal: number,
- color?: string,
- ) => (
-
-
- {t(labelKey)}
- {t(detailKey, { pct: formatPercent(pctVal), visited, total })}
-
-
-
- );
+ color: string | undefined,
+ onClick?: () => void,
+ ) => {
+ const body = (
+ <>
+
+ {t(labelKey)}
+ {t(detailKey, { pct: pctText(pctVal), visited, total })}
+
+
+ >
+ );
+ if (!onClick) return {body}
;
+ return (
+
+ {body}
+
+ );
+ };
const hasMega = c.megaCitiesTotal > 0;
const MEGA_COLOR = "var(--stat-fav)";
const BIG_COLOR = "var(--stat-been)";
+ const CITY_COLOR = "var(--stat-air)";
const REGION_COLOR = "var(--accent)";
return (
@@ -188,13 +217,16 @@ function CountryRow({
›
- {/* 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) && (
+ {/* Coverage tiers at a glance — cities (all), big (100k+), mega (1M+) and
+ regions. Hidden once open (the body shows the same, with counts and as
+ tappable drill-downs) so nothing is duplicated. */}
+ {(c.citiesTotal > 0 || c.regionsTotal > 0) && (
- {hasMega && meter("stats.country.metricMega", "stats.country.megaCityBarAria", c.megaCityPct, MEGA_COLOR)}
+ {c.citiesTotal > 0 &&
+ meter("stats.country.metricCities", "stats.country.cityBarAria", c.cityPct, CITY_COLOR)}
{c.bigCitiesTotal > 0 &&
meter("stats.country.metricBigCities", "stats.country.bigCityBarAria", c.bigCityPct, BIG_COLOR)}
+ {hasMega && meter("stats.country.metricMega", "stats.country.megaCityBarAria", c.megaCityPct, MEGA_COLOR)}
{c.regionsTotal > 0 &&
meter("stats.country.metricRegions", "stats.country.regionBarAria", c.regionPct, REGION_COLOR)}
@@ -211,15 +243,19 @@ function CountryRow({
{t("stats.country.openPage")} ↗
- {hasMega &&
+ {/* Each tier is tappable — it opens Places filtered to this country + tier,
+ so you can browse (and add) exactly what it counts. Regions have no
+ Places list of their own, so that one opens the country's full page. */}
+ {c.citiesTotal > 0 &&
metric(
- "stats.country.metricMega",
- "stats.country.metricMegaDetail",
- "stats.country.megaCityBarAria",
- c.megaCitiesVisited,
- c.megaCitiesTotal,
- c.megaCityPct,
- MEGA_COLOR,
+ "stats.country.metricCities",
+ "stats.country.metricCitiesDetail",
+ "stats.country.cityBarAria",
+ c.citiesVisited,
+ c.citiesTotal,
+ c.cityPct,
+ CITY_COLOR,
+ drill("cities", 0),
)}
{c.bigCitiesTotal > 0 &&
metric(
@@ -230,6 +266,18 @@ function CountryRow({
c.bigCitiesTotal,
c.bigCityPct,
BIG_COLOR,
+ drill("cities", 100_000),
+ )}
+ {hasMega &&
+ metric(
+ "stats.country.metricMega",
+ "stats.country.metricMegaDetail",
+ "stats.country.megaCityBarAria",
+ c.megaCitiesVisited,
+ c.megaCitiesTotal,
+ c.megaCityPct,
+ MEGA_COLOR,
+ drill("cities", 1_000_000),
)}
{c.regionsTotal > 0 &&
metric(
@@ -240,6 +288,7 @@ function CountryRow({
c.regionsTotal,
c.regionPct,
REGION_COLOR,
+ () => useUi.getState().openCountry(c.iso2),
)}
{c.heritageTotal > 0 &&
metric(
@@ -250,6 +299,7 @@ function CountryRow({
c.heritageTotal,
c.heritagePct,
"var(--stat-want)",
+ drill("monuments", 0),
)}
{detail && (
@@ -316,7 +366,9 @@ export function StatsView() {
// 100k = big cities, 1M = megacities) via the app's ONE shared filter, so the
// tile drills into exactly the cities it counts.
function openCitiesFiltered(minPop: number) {
- useFilters.getState().set({ minPop });
+ // A world-level tile: clear any country drill-down a card left set, so this
+ // shows the tier across every country, not just the last one you opened.
+ useFilters.getState().set({ minPop, country: "" });
useUi.getState().openPlaces("visited");
}
function flyToRegion(iso2: string) {
diff --git a/apps/postcards/src/features/visits/PlacesScreen.tsx b/apps/postcards/src/features/visits/PlacesScreen.tsx
index 4d5fe04..98e2ed7 100644
--- a/apps/postcards/src/features/visits/PlacesScreen.tsx
+++ b/apps/postcards/src/features/visits/PlacesScreen.tsx
@@ -104,6 +104,8 @@ function mapRequest(view: PlacesView): { kind?: Kind; status?: Status; collectio
return { kind: "all", status: "wishlist", collection: null };
case "countries":
return { kind: "countries", status: "all", collection: null };
+ case "cities":
+ return { kind: "cities", status: "all", collection: null };
case "monuments":
return { kind: "monuments", status: "all", collection: null };
case "moments":
@@ -582,6 +584,7 @@ export function PlacesScreen() {
filters.hasPhoto,
filters.hasNote,
filters.continent,
+ filters.country,
],
);
const filterVisits = useCallback(
@@ -652,7 +655,7 @@ export function PlacesScreen() {
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, shown]);
+ }, [kind, status, filters.continent, filters.minPop, filters.category, filters.country, ref, visits, deferredFilter, shown]);
const browseRows = browse.rows;
// The years your visits span, newest first, for the date filter chips.
@@ -687,7 +690,7 @@ export function PlacesScreen() {
// The active dimensions Places actually acts on (status + map mode are excluded —
// status is the axis, mode is map-only). Drives the Filter button's badge.
const placesFilterChips = useMemo(
- () => activeChips(currentFilters(filters), t).filter((c) => c.field !== "status" && c.field !== "mode"),
+ () => activeChips(currentFilters(filters), t, ref).filter((c) => c.field !== "status" && c.field !== "mode"),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
filters.date,
@@ -698,6 +701,8 @@ export function PlacesScreen() {
filters.hasPhoto,
filters.hasNote,
filters.continent,
+ filters.country,
+ ref,
t,
],
);
diff --git a/apps/postcards/src/features/visits/browseList.ts b/apps/postcards/src/features/visits/browseList.ts
index 417c2d7..62197a7 100644
--- a/apps/postcards/src/features/visits/browseList.ts
+++ b/apps/postcards/src/features/visits/browseList.ts
@@ -99,6 +99,7 @@ export function browseList(
}
};
const continentOk = (iso2: string) => !filter.continent || ref.continentOf(iso2) === filter.continent;
+ const countryOk = (iso2: string) => !filter.country || iso2 === filter.country;
const countryName = (iso2: string) => ref.countryByIso2(iso2)?.name ?? iso2;
const rows: BrowseRow[] = [];
@@ -115,8 +116,15 @@ export function browseList(
};
if (kind === "cities") {
- const base: City[] = q ? ref.searchCities(q, 500) : populousCities(ref);
+ // Country drill-down browses ALL of that country's cities (already population-
+ // desc), not just the global top-2000 pool, so nothing is missing from it.
+ const base: City[] = q
+ ? ref.searchCities(q, 500)
+ : filter.country
+ ? ref.citiesOf(filter.country)
+ : populousCities(ref);
for (const c of base) {
+ if (!countryOk(c.countryIso2)) continue;
if (!continentOk(c.countryIso2)) continue;
if (filter.minPop > 0 && (c.population ?? 0) < filter.minPop) continue;
const place: PlaceRef = { kind: "city", id: c.id, name: c.name, countryId: c.countryIso2 };
@@ -131,7 +139,11 @@ export function browseList(
}
if (kind === "monuments") {
- let base: HeritageSite[] = q ? ref.searchHeritage(q, 500) : sortedMonuments(ref);
+ let base: HeritageSite[] = q
+ ? ref.searchHeritage(q, 500)
+ : filter.country
+ ? ref.heritageOf(filter.country)
+ : 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) {
@@ -143,6 +155,7 @@ export function browseList(
}
for (const h of base) {
if (filter.category && h.category !== filter.category) continue;
+ if (!countryOk(h.countryIso2)) continue;
if (!continentOk(h.countryIso2)) continue;
const place: PlaceRef = { kind: "heritage", id: h.id, name: h.name, countryId: h.countryIso2 };
const o = overlay(place);
@@ -167,6 +180,7 @@ export function browseList(
}
}
for (const a of base) {
+ if (!countryOk(a.countryIso2)) continue;
if (!continentOk(a.countryIso2)) continue;
const name = `${a.name} (${a.id})`;
const place: PlaceRef = { kind: "airport", id: a.id, name, countryId: a.countryIso2 };
diff --git a/apps/postcards/src/lib/i18n/en.ts b/apps/postcards/src/lib/i18n/en.ts
index fded995..cda2856 100644
--- a/apps/postcards/src/lib/i18n/en.ts
+++ b/apps/postcards/src/lib/i18n/en.ts
@@ -229,6 +229,7 @@ export const en = {
"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.exploreHint": "Show {label} of {name} in Places",
"stats.country.metricRegions": "Regions",
"stats.country.metricRegionsDetail": "{pct} · {visited}/{total} regions",
"stats.country.datasetNotLoaded": "dataset not loaded",
diff --git a/apps/postcards/src/lib/i18n/fr.ts b/apps/postcards/src/lib/i18n/fr.ts
index 9762d6d..16f6e06 100644
--- a/apps/postcards/src/lib/i18n/fr.ts
+++ b/apps/postcards/src/lib/i18n/fr.ts
@@ -222,6 +222,7 @@ export const fr: Messages = {
"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.exploreHint": "Voir {label} de {name} dans Lieux",
"stats.country.metricRegions": "Régions",
"stats.country.metricRegionsDetail": "{pct} · {visited}/{total} régions",
"stats.country.datasetNotLoaded": "jeu de données non chargé",
diff --git a/apps/postcards/src/lib/i18n/ko.ts b/apps/postcards/src/lib/i18n/ko.ts
index 11370cc..33f3cfc 100644
--- a/apps/postcards/src/lib/i18n/ko.ts
+++ b/apps/postcards/src/lib/i18n/ko.ts
@@ -223,6 +223,7 @@ export const ko: Messages = {
"stats.country.metricMegaDetail": "{pct} · {visited}/{total} 도시 (100만+)",
"stats.country.megaCityBarAria": "{name}: 방문한 메가시티(100만+)",
"stats.country.openPage": "전체 페이지 열기",
+ "stats.country.exploreHint": "{name}의 {label}을(를) 장소에서 보기",
"stats.country.metricRegions": "지역",
"stats.country.metricRegionsDetail": "{pct} · 지역 {visited}/{total}",
"stats.country.datasetNotLoaded": "데이터셋 미로드",
diff --git a/apps/postcards/src/lib/store/useFilters.ts b/apps/postcards/src/lib/store/useFilters.ts
index b28ee4d..ad20568 100644
--- a/apps/postcards/src/lib/store/useFilters.ts
+++ b/apps/postcards/src/lib/store/useFilters.ts
@@ -38,6 +38,10 @@ export interface FilterState {
/** Monument category filter (kind = monuments): "" (all) | cultural | natural | mixed.
* Session-scoped, like date/folder; the map ignores it. */
category: string;
+ /** Narrow every list to ONE country (ISO 3166-1 alpha-2; "" = all). Set from the
+ * Stats country card ("show France's mega cities…"); session-scoped and, like
+ * category, a lists dimension the map ignores (you pan to a country there). */
+ country: string;
// Growth dimensions (US4) — off by default, hosted in the one panel.
favoritesOnly: boolean;
hasPhoto: boolean;
@@ -60,6 +64,7 @@ export const DEFAULT_FILTERS: FilterState = {
sort: "pop",
mode: "all",
category: "",
+ country: "",
favoritesOnly: false,
hasPhoto: false,
hasNote: false,
@@ -126,6 +131,7 @@ export function isDefault(s: FilterState): boolean {
s.sort === "pop" &&
s.mode === "all" &&
s.category === "" &&
+ s.country === "" &&
!s.favoritesOnly &&
!s.hasPhoto &&
!s.hasNote &&
@@ -174,6 +180,7 @@ export function currentFilters(s: FilterStore): FilterState {
sort: s.sort,
mode: s.mode,
category: s.category,
+ country: s.country,
favoritesOnly: s.favoritesOnly,
hasPhoto: s.hasPhoto,
hasNote: s.hasNote,
diff --git a/apps/postcards/src/lib/store/useUi.ts b/apps/postcards/src/lib/store/useUi.ts
index c1e0eac..f37634d 100644
--- a/apps/postcards/src/lib/store/useUi.ts
+++ b/apps/postcards/src/lib/store/useUi.ts
@@ -9,6 +9,7 @@ export type PlacesView =
| "favorites"
| "wishlist"
| "countries"
+ | "cities"
| "monuments"
| "moments"
| "passport";
diff --git a/apps/postcards/src/styles.css b/apps/postcards/src/styles.css
index 2156c05..8ea65d7 100644
--- a/apps/postcards/src/styles.css
+++ b/apps/postcards/src/styles.css
@@ -1763,7 +1763,8 @@ h1.brand {
.country-meters {
grid-area: meter;
display: flex;
- gap: 12px;
+ flex-wrap: wrap;
+ gap: 6px 12px;
margin-top: 3px;
}
/* The collapsed row's mini-meters are the at-a-glance summary; once open, the
@@ -1772,7 +1773,8 @@ h1.brand {
display: none;
}
.cmeter {
- flex: 1 1 0;
+ /* Two per row (so up to four tiers stay legible on a phone), growing to fill. */
+ flex: 1 1 calc(50% - 12px);
min-width: 0;
display: flex;
flex-direction: column;
@@ -1807,6 +1809,26 @@ h1.brand {
font-size: 12px;
margin-bottom: 4px;
}
+/* A tappable tier row: it opens Places scoped to this country + tier. Looks like
+ the metric it wraps, with a hover/focus wash and a bit of bleed so the target
+ spans the card's full width. */
+.metric-btn {
+ display: block;
+ width: calc(100% + 16px);
+ margin-left: -8px;
+ margin-right: -8px;
+ padding: 6px 8px;
+ background: none;
+ border: none;
+ border-radius: var(--radius-sm);
+ font: inherit;
+ color: inherit;
+ text-align: left;
+}
+.metric-btn:hover,
+.metric-btn:focus-visible {
+ background: var(--bg-sub);
+}
/* "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. */
diff --git a/apps/postcards/src/ui/FilterSummary.tsx b/apps/postcards/src/ui/FilterSummary.tsx
index c360dfd..84b2137 100644
--- a/apps/postcards/src/ui/FilterSummary.tsx
+++ b/apps/postcards/src/ui/FilterSummary.tsx
@@ -1,5 +1,6 @@
import { useFilters, currentFilters, type FilterState } from "../lib/store/useFilters";
import { activeChips } from "../features/filter/applyFilters";
+import { getReferenceData } from "../lib/reference/referenceData";
import { useT } from "../lib/i18n";
/**
@@ -15,7 +16,7 @@ export function FilterSummary({ exclude = [] }: { exclude?: (keyof FilterState)[
const t = useT();
const f = useFilters();
const state = currentFilters(f);
- const chips = activeChips(state, t).filter((c) => !exclude.includes(c.field));
+ const chips = activeChips(state, t, getReferenceData()).filter((c) => !exclude.includes(c.field));
if (chips.length === 0) return null;
return (
diff --git a/apps/postcards/tests/e2e/stats-drill.spec.ts b/apps/postcards/tests/e2e/stats-drill.spec.ts
new file mode 100644
index 0000000..4716f9c
--- /dev/null
+++ b/apps/postcards/tests/e2e/stats-drill.spec.ts
@@ -0,0 +1,44 @@
+import { test, expect } from "@playwright/test";
+import { gotoTab } from "./nav-helper";
+
+// A Stats country-card tier (cities / big / mega / sites) drills into Places,
+// scoped to THAT country + tier via the ONE shared filter — and because the
+// filter lives in the shared store, it survives leaving and returning to Places.
+test("a country tier opens Places filtered, and the filter persists across pages", async ({
+ page,
+}) => {
+ await page.goto("/");
+ for (const q of ["Paris", "Lyon", "Tokyo"]) {
+ await page.getByLabel("Search a city or country").fill(q);
+ await page.getByRole("button", { name: `Mark ${q} visited` }).first().click();
+ await page.keyboard.press("Escape");
+ }
+
+ await gotoTab(page, "Stats");
+ await page.locator(".country-summary", { hasText: "France" }).click();
+
+ // Tap "Big cities" in the France card → Places, cities kind, France + 100k+.
+ await page
+ .locator(".country-card", { hasText: "France" })
+ .locator("button.metric-btn", { hasText: "Big cities" })
+ .click();
+
+ // Both dimensions surface as removable chips, and the list is French cities.
+ await expect(page.locator(".filter-chip", { hasText: "France" })).toBeVisible();
+ await expect(page.locator(".filter-chip", { hasText: "100k+" })).toBeVisible();
+ await expect(page.getByText("Marseille", { exact: false }).first()).toBeVisible();
+ // Tokyo (Japan) must NOT appear — the country filter excludes it.
+ await expect(page.getByText("Tokyo", { exact: false })).toHaveCount(0);
+
+ // Leave to the Map and come back: the France filter is still applied.
+ await gotoTab(page, "Map");
+ await page.getByRole("button", { name: "Places", exact: true }).click();
+ await expect(page.locator(".filter-chip", { hasText: "France" })).toBeVisible();
+
+ // Clearing the country chip widens the list back out (Tokyo can return).
+ await page
+ .locator(".filter-chip", { hasText: "France" })
+ .getByRole("button")
+ .click();
+ await expect(page.locator(".filter-chip", { hasText: "France" })).toHaveCount(0);
+});
diff --git a/apps/postcards/tests/unit/browseList.spec.ts b/apps/postcards/tests/unit/browseList.spec.ts
index 6d808da..c8efda2 100644
--- a/apps/postcards/tests/unit/browseList.spec.ts
+++ b/apps/postcards/tests/unit/browseList.spec.ts
@@ -27,6 +27,9 @@ const ref = {
allCities: () => cities,
allHeritage: () => heritage,
allAirports: () => airports,
+ citiesOf: (iso2: string) =>
+ cities.filter((c) => c.countryIso2 === iso2).sort((a, b) => (b.population ?? 0) - (a.population ?? 0)),
+ heritageOf: (iso2: string) => heritage.filter((h) => h.countryIso2 === iso2),
searchCities: (q: string) => cities.filter((c) => lc(c.name).includes(lc(q))),
searchHeritage: (q: string) => heritage.filter((h) => lc(h.name).includes(lc(q))),
searchAirports: (q: string) => airports.filter((a) => lc(a.name).includes(lc(q)) || lc(a.id) === lc(q)),
@@ -107,6 +110,16 @@ describe("browseList — reference browse + personal status overlay (spec 018 US
expect(rows.map((r) => r.id)).toEqual(["tokyo"]);
});
+ it("country filter scopes every kind to one country (stats drill-down)", () => {
+ const fr = { ...F, country: "FR" };
+ // Cities drill from the country's own list (all of them), population-desc.
+ expect(bl("cities", "all", fr, ref, [], "").map((r) => r.id)).toEqual(["paris", "lyon"]);
+ expect(bl("monuments", "all", fr, ref, [], "").map((r) => r.id)).toEqual(["h1"]);
+ expect(bl("airports", "all", fr, ref, [], "").map((r) => r.id)).toEqual(["CDG"]);
+ // Country + population tier compose: only FR cities over the threshold.
+ expect(bl("cities", "all", { ...fr, minPop: 1_000_000 }, ref, [], "").map((r) => r.id)).toEqual(["paris"]);
+ });
+
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);