From ec7f8be8c4401624ebefd7df79ef1975b66fe136 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:17:39 +0000 Subject: [PATCH] Stats: tap a country tier to open Places filtered; restore the Cities tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two asks: bring back the "Cities" (all 15k+) coverage next to the other tiers, and make each tier open the matching Places list — scoped so the filter is the "good" one and survives moving between pages. - Country card now shows Cities · Big · Mega · Regions (Cities was missing). Near-zero shares read as "<1%" instead of a discouraging "0%". - Each expanded tier is a button: Cities / Big / Mega open Places on the cities browse scoped to that country + population tier; Sites opens the country's monuments; Regions (no Places list of their own) open the country's full page. - New shared `country` filter dimension: narrows every Places list to one country, shows as a removable "🇫🇷 France" chip, and — living in the shared store — persists as you navigate away and back. The map ignores it (you pan to a country there), like the other list-only dimensions. - The browse draws a country drill-down from that country's OWN city/site lists (not the global top-2000 pool), so nothing is missing. - World-level KPI tiles clear any country drill-down so they stay global. --- .../src/features/filter/applyFilters.ts | 13 ++- apps/postcards/src/features/map/MapScreen.tsx | 3 + .../src/features/stats/StatsView.tsx | 102 +++++++++++++----- .../src/features/visits/PlacesScreen.tsx | 9 +- .../src/features/visits/browseList.ts | 18 +++- apps/postcards/src/lib/i18n/en.ts | 1 + apps/postcards/src/lib/i18n/fr.ts | 1 + apps/postcards/src/lib/i18n/ko.ts | 1 + apps/postcards/src/lib/store/useFilters.ts | 7 ++ apps/postcards/src/lib/store/useUi.ts | 1 + apps/postcards/src/styles.css | 26 ++++- apps/postcards/src/ui/FilterSummary.tsx | 3 +- apps/postcards/tests/e2e/stats-drill.spec.ts | 44 ++++++++ apps/postcards/tests/unit/browseList.spec.ts | 13 +++ 14 files changed, 208 insertions(+), 34 deletions(-) create mode 100644 apps/postcards/tests/e2e/stats-drill.spec.ts 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 ( + + ); + }; 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);