Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions apps/postcards/src/features/filter/applyFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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) {
Expand All @@ -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;
}
3 changes: 3 additions & 0 deletions apps/postcards/src/features/map/MapScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
102 changes: 77 additions & 25 deletions apps/postcards/src/features/stats/StatsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,37 +142,66 @@ 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) => (
<div className="cmeter">
<span className="cmeter-cap">
{t(labelKey)} <b>{formatPercent(pctVal)}</b>
{t(labelKey)} <b>{pctText(pctVal)}</b>
</span>
<Bar value={pctVal} label={t(ariaKey, { name: c.name })} color={color} />
</div>
);
// 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,
ariaKey: MessageKey,
visited: number,
total: number,
pctVal: number,
color?: string,
) => (
<div className="metric">
<div className="metric-label">
<span>{t(labelKey)}</span>
<span className="muted">{t(detailKey, { pct: formatPercent(pctVal), visited, total })}</span>
</div>
<Bar value={pctVal} label={t(ariaKey, { name: c.name })} color={color} />
</div>
);
color: string | undefined,
onClick?: () => void,
) => {
const body = (
<>
<div className="metric-label">
<span>{t(labelKey)}</span>
<span className="muted">{t(detailKey, { pct: pctText(pctVal), visited, total })}</span>
</div>
<Bar value={pctVal} label={t(ariaKey, { name: c.name })} color={color} />
</>
);
if (!onClick) return <div className="metric">{body}</div>;
return (
<button
type="button"
className="metric metric-btn"
onClick={onClick}
title={t("stats.country.exploreHint", { label: t(labelKey), name: c.name })}
>
{body}
</button>
);
};

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 (
Expand All @@ -188,13 +217,16 @@ function CountryRow({
<span className="country-caret" aria-hidden>
›
</span>
{/* 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) && (
<div className="country-meters">
{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)}
</div>
Expand All @@ -211,15 +243,19 @@ function CountryRow({
{t("stats.country.openPage")} <span aria-hidden>↗</span>
</button>

{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(
Expand All @@ -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(
Expand All @@ -240,6 +288,7 @@ function CountryRow({
c.regionsTotal,
c.regionPct,
REGION_COLOR,
() => useUi.getState().openCountry(c.iso2),
)}
{c.heritageTotal > 0 &&
metric(
Expand All @@ -250,6 +299,7 @@ function CountryRow({
c.heritageTotal,
c.heritagePct,
"var(--stat-want)",
drill("monuments", 0),
)}

{detail && (
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 7 additions & 2 deletions apps/postcards/src/features/visits/PlacesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -582,6 +584,7 @@ export function PlacesScreen() {
filters.hasPhoto,
filters.hasNote,
filters.continent,
filters.country,
],
);
const filterVisits = useCallback(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -698,6 +701,8 @@ export function PlacesScreen() {
filters.hasPhoto,
filters.hasNote,
filters.continent,
filters.country,
ref,
t,
],
);
Expand Down
18 changes: 16 additions & 2 deletions apps/postcards/src/features/visits/browseList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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 };
Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -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 };
Expand Down
1 change: 1 addition & 0 deletions apps/postcards/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/postcards/src/lib/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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é",
Expand Down
1 change: 1 addition & 0 deletions apps/postcards/src/lib/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "데이터셋 미로드",
Expand Down
7 changes: 7 additions & 0 deletions apps/postcards/src/lib/store/useFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -60,6 +64,7 @@ export const DEFAULT_FILTERS: FilterState = {
sort: "pop",
mode: "all",
category: "",
country: "",
favoritesOnly: false,
hasPhoto: false,
hasNote: false,
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/postcards/src/lib/store/useUi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type PlacesView =
| "favorites"
| "wishlist"
| "countries"
| "cities"
| "monuments"
| "moments"
| "passport";
Expand Down
Loading
Loading