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