diff --git a/apps/postcards/src/features/backup/exportJson.ts b/apps/postcards/src/features/backup/exportJson.ts index 3196b49..f3afaf9 100644 --- a/apps/postcards/src/features/backup/exportJson.ts +++ b/apps/postcards/src/features/backup/exportJson.ts @@ -11,6 +11,12 @@ import { } from "../../lib/schema/models"; import { getReferenceData } from "../../lib/reference/referenceData"; +/** Drop an empty `photos` array so a photo-less record stays lean in the file. */ +function dropEmptyPhotos(rec: T): T | Omit { + const { photos, ...rest } = rec; + return photos && photos.length ? { ...rest, photos } : rest; +} + /** Build the canonical portable file object from the current visits + trips + stories. * `tombstones` is written only for device sync; a plain backup passes none, so the * exported file stays free of an empty `tombstones` key. */ @@ -32,9 +38,9 @@ export function buildFile( schemaVersion: SCHEMA_VERSION, exportedAt: now.toISOString(), // Drop empty `photos` arrays so a photo-less export stays lean and readable. - visits: visits.map(({ photos, ...rest }) => (photos && photos.length ? { ...rest, photos } : rest)), + visits: visits.map(dropEmptyPhotos), trips, - stories: stories.map(({ photos, ...rest }) => (photos && photos.length ? { ...rest, photos } : rest)), + stories: stories.map(dropEmptyPhotos), ...(tombstones.length ? { tombstones } : {}), referenceSources, }; diff --git a/apps/postcards/src/features/backup/importJson.ts b/apps/postcards/src/features/backup/importJson.ts index cf4014d..db50466 100644 --- a/apps/postcards/src/features/backup/importJson.ts +++ b/apps/postcards/src/features/backup/importJson.ts @@ -82,8 +82,8 @@ export function importFile(text: string): ImportResult { // lists a place twice, keep the first record's identity but UNION the galleries // (photos are now the payload — dropping one silently would lose data). const byPlace = new Map(); - for (const raw of parsed.data.visits) { - const v = normalizeVisitPhotos(raw); + for (const rawVisit of parsed.data.visits) { + const v = normalizeVisitPhotos(rawVisit); const key = placeKey(v.place); const existing = byPlace.get(key); if (!existing) { diff --git a/apps/postcards/src/features/guides/GuideButton.tsx b/apps/postcards/src/features/guides/GuideButton.tsx index 889cc7b..a3f2ada 100644 --- a/apps/postcards/src/features/guides/GuideButton.tsx +++ b/apps/postcards/src/features/guides/GuideButton.tsx @@ -29,7 +29,7 @@ const isOffline = () => typeof navigator !== "undefined" && !navigator.onLine; /** Resolve the names a place's guides are built from (common country name — * the real Wikivoyage article title, e.g. "Russia", not "Russian Federation"). */ -function guideNames(place: PlaceRef) { +function guideNames(place: PlaceRef): GuideNames | null { const ref = getReferenceData(); const country = ref.countryByIso2(place.countryId); if (!country) return null; diff --git a/apps/postcards/src/features/map/visitedLayers.ts b/apps/postcards/src/features/map/visitedLayers.ts index e7c395c..6ae77dc 100644 --- a/apps/postcards/src/features/map/visitedLayers.ts +++ b/apps/postcards/src/features/map/visitedLayers.ts @@ -214,15 +214,16 @@ export function tripArcs(trips: Trip[], ref: ReferenceData): FeatureCollection[] = []; for (const t of trips) { const chain = t.stops && t.stops.length >= 2 ? t.stops : [t.from, t.to]; - features.push(...stopsArcs(chain, ref, t.mode).features); + features.push(...stopsArcs(chain, ref, t.mode, t.legModes).features); } return { type: "FeatureCollection", features }; } /** * Great-circle arcs for an ORDERED chain of stops (spec 019) — one arc per - * consecutive resolvable leg, tagged with the travel `mode`. Powers the live - * route drawn while reconstructing a journey (the composer's real map). A leg + * consecutive resolvable leg, each tagged with ITS transport so the map can colour + * a mixed-mode journey correctly (leg i uses `legModes[i]`, else the trip default + * `mode`). Powers the live route drawn while reconstructing a journey. A leg * touching a coordinate-less stop is skipped — nothing invented (FR-013); fewer * than two stops → an empty collection. Takes raw stops, NOT a Trip. */ @@ -230,6 +231,7 @@ export function stopsArcs( stops: PlaceRef[], ref: ReferenceData, mode: TravelMode, + legModes?: TravelMode[], ): FeatureCollection { const features: Feature[] = []; for (let i = 0; i < stops.length - 1; i++) { @@ -239,7 +241,7 @@ export function stopsArcs( features.push({ type: "Feature", geometry: { type: "LineString", coordinates: greatCircle(from, to) }, - properties: { mode }, + properties: { mode: legModes?.[i] ?? mode }, }); } return { type: "FeatureCollection", features }; diff --git a/apps/postcards/src/features/passport/PassportScreen.tsx b/apps/postcards/src/features/passport/PassportScreen.tsx index e5aa3fe..4a0d15f 100644 --- a/apps/postcards/src/features/passport/PassportScreen.tsx +++ b/apps/postcards/src/features/passport/PassportScreen.tsx @@ -70,9 +70,9 @@ export function PassportScreen({ embedded }: { embedded?: boolean } = {}) { }, [posterUrl]); const visitedIds = useMemo(() => visitedCountryIds(visits), [visits]); - const { collected, missing, continents } = useMemo(() => { + const { collectedCount, missing, continents } = useMemo(() => { const all = ref.countries.filter((c) => inScope(c.sovereignty, scope)); - const collected = all.filter((c) => visitedIds.has(c.iso2)); + const collectedCount = all.filter((c) => visitedIds.has(c.iso2)).length; const missing = all.filter((c) => !visitedIds.has(c.iso2)); // Collected flags grouped by continent, each with its own progress, so the // passport reads like pages of a real one. @@ -88,7 +88,7 @@ export function PassportScreen({ embedded }: { embedded?: boolean } = {}) { .filter(([, g]) => g.done.length > 0) .map(([name, g]) => ({ name, done: g.done, total: g.total })) .sort((a, b) => b.done.length - a.done.length || a.name.localeCompare(b.name)); - return { collected, missing, continents }; + return { collectedCount, missing, continents }; }, [ref, visitedIds, scope]); const [shownMissing, setShownMissing] = useState(60); @@ -152,15 +152,15 @@ export function PassportScreen({ embedded }: { embedded?: boolean } = {}) {

- {formatInt(collected.length)}{" "} - {t("passport.ofFlags", { total: formatInt(collected.length + missing.length) })} + {formatInt(collectedCount)}{" "} + {t("passport.ofFlags", { total: formatInt(collectedCount + missing.length) })}

- {collected.length === 0 ? ( + {collectedCount === 0 ? (

🛂 diff --git a/apps/postcards/src/features/publish/PublishScreen.tsx b/apps/postcards/src/features/publish/PublishScreen.tsx index 8eeaf8d..61f0c6f 100644 --- a/apps/postcards/src/features/publish/PublishScreen.tsx +++ b/apps/postcards/src/features/publish/PublishScreen.tsx @@ -213,11 +213,11 @@ export function PublishScreen({ onClose }: { onClose: () => void }) { /** Build the final self-contained HTML (encrypted when a passphrase is set). */ async function buildHtml(): Promise { - // Normalise ONCE and use the SAME value for the encrypt decision and the + // Use the SAME normalised value (passNorm) for the encrypt decision and the // encryption itself. Before, the decision used passphrase.trim() but the // encrypt used the raw value: a spaces-only box silently published PLAINTEXT, // and surrounding spaces produced a file that could never be unlocked. - const pass = passphrase.normalize("NFC").trim(); + const pass = passNorm; if (pass) { if (pass.length < MIN_PASSPHRASE_LENGTH) { throw new Error(`Use a passphrase of at least ${MIN_PASSPHRASE_LENGTH} characters.`); diff --git a/apps/postcards/src/features/stats/CountryCoverageMap.tsx b/apps/postcards/src/features/stats/CountryCoverageMap.tsx new file mode 100644 index 0000000..d470a04 --- /dev/null +++ b/apps/postcards/src/features/stats/CountryCoverageMap.tsx @@ -0,0 +1,174 @@ +import { useEffect, useMemo, useState } from "react"; +import type { FeatureCollection, Position } from "geojson"; +import { getReferenceData } from "../../lib/reference/referenceData"; +import { useVisits } from "../../lib/store/useVisits"; +import { useGazetteerGeneration } from "../../lib/reference/useGazetteer"; +import { getLand } from "../travel/landGeometry"; +import { useT } from "../../lib/i18n"; + +// A STATIC (non-interactive) per-country coverage map, shown under a country card +// in Stats in place of the long "regions/monuments to explore" text lists. It +// paints the country silhouette (bundled offline Natural Earth geometry), tints +// the regions you HAVEN'T been as soft "missing" blobs, and dots the cities you +// have — so coverage reads at a glance. Pure SVG, computed lazily when the card +// opens; the full, interactive lists still live on the country's own page. + +const W = 320; +const H = 190; +const PAD = 10; +const MAX_DOTS = 400; + +const mercY = (lat: number) => Math.log(Math.tan(Math.PI / 4 + (Math.max(-85, Math.min(85, lat)) * Math.PI) / 360)); + +export function CountryCoverageMap({ iso2, name }: { iso2: string; name: string }) { + const t = useT(); + const ref = useMemo(() => getReferenceData(), []); + const visits = useVisits((s) => s.visits); + const gazGen = useGazetteerGeneration(); // city set grows when the full gazetteer lands + const [land, setLand] = useState(null); + useEffect(() => { + let alive = true; + void getLand().then((fc) => { + if (alive) setLand(fc); + }); + return () => { + alive = false; + }; + }, []); + + // Visited city points + per-region centroids/spread, and which regions are unvisited. + const model = useMemo(() => { + const cities = ref.citiesOf(iso2); + const visitedCityIds = new Set( + visits + .filter((v) => v.status === "visited" && v.place.kind === "city" && v.place.countryId === iso2) + .map((v) => v.place.id), + ); + type Reg = { sx: number; sy: number; sxx: number; syy: number; n: number; visited: boolean }; + const regions = new Map(); + const visitedPoints: { lon: number; lat: number }[] = []; + for (const c of cities) { + const isVisited = visitedCityIds.has(c.id); + if (isVisited && visitedPoints.length < MAX_DOTS) visitedPoints.push({ lon: c.lon, lat: c.lat }); + const sub = c.subdivisionId; + if (!sub) continue; + let g = regions.get(sub); + if (!g) { + g = { sx: 0, sy: 0, sxx: 0, syy: 0, n: 0, visited: false }; + regions.set(sub, g); + } + g.n++; + g.sx += c.lon; + g.sy += c.lat; + g.sxx += c.lon * c.lon; + g.syy += c.lat * c.lat; + if (isVisited) g.visited = true; + } + const missing = [...regions.values()] + .filter((g) => !g.visited) + .map((g) => { + const lon = g.sx / g.n; + const lat = g.sy / g.n; + // Rough spread (deg) across the region's cities, to size the blob. + const spread = Math.sqrt(Math.max(0, g.sxx / g.n - lon * lon) + Math.max(0, g.syy / g.n - lat * lat)); + return { lon, lat, spread }; + }); + const regionsTotal = ref.countryByIso2(iso2)?.subdivisionCount ?? regions.size; + const regionsVisited = [...regions.values()].filter((g) => g.visited).length; + return { visitedPoints, missing, regionsTotal, regionsVisited }; + }, [iso2, visits, ref, gazGen]); + + // The country's polygon rings, matched from the bundled geometry by numeric code. + const rings = useMemo(() => { + if (!land) return []; + const numeric = ref.countryByIso2(iso2)?.numeric; + // The bundled TopoJSON carries the numeric country code as the feature `id`. + const feat = land.features.find( + (f) => String(f.id ?? f.properties?.numeric ?? "") === String(numeric), + ); + const geom = feat?.geometry; + const out: Position[][] = []; + if (geom?.type === "Polygon") out.push(...(geom.coordinates as Position[][])); + else if (geom?.type === "MultiPolygon") for (const p of geom.coordinates as Position[][][]) out.push(...p); + return out; + }, [land, iso2, ref]); + + const layout = useMemo(() => { + const xs: number[] = []; + const ys: number[] = []; + const push = (lon: number, lat: number) => { + xs.push((lon * Math.PI) / 180); + ys.push(mercY(lat)); + }; + // Frame to the MAINLAND — the ring with the most points — so a country with + // far-flung overseas territories (France, the US…) doesn't zoom out to the + // whole globe. Everything else still draws, clipped by the viewBox. + let mainRing: Position[] | null = null; + for (const r of rings) if (r.length > (mainRing?.length ?? 0)) mainRing = r; + if (mainRing) for (const p of mainRing) push(p[0]!, p[1]!); + else { + for (const p of model.visitedPoints) push(p.lon, p.lat); + for (const m of model.missing) push(m.lon, m.lat); + } + if (!xs.length) return null; + let minX = Math.min(...xs); + let maxX = Math.max(...xs); + let minY = Math.min(...ys); + let maxY = Math.max(...ys); + const spanX = maxX - minX || 0.1; + const spanY = maxY - minY || 0.1; + minX -= spanX * 0.08; + maxX += spanX * 0.08; + minY -= spanY * 0.12; + maxY += spanY * 0.12; + const scale = Math.min((W - 2 * PAD) / (maxX - minX), (H - 2 * PAD) / (maxY - minY)); + const midX = (minX + maxX) / 2; + const midY = (minY + maxY) / 2; + const sx = (lon: number) => W / 2 + ((lon * Math.PI) / 180 - midX) * scale; + const sy = (lat: number) => H / 2 - (mercY(lat) - midY) * scale; + const degToPx = (scale * Math.PI) / 180; // ~px per degree at this scale + + const landPath = rings + .map((r) => r.map((p, i) => (i ? "L" : "M") + sx(p[0]!).toFixed(1) + " " + sy(p[1]!).toFixed(1)).join("") + "Z") + .join(""); + const blobs = model.missing.map((m) => ({ + x: sx(m.lon), + y: sy(m.lat), + r: Math.max(6, Math.min(W / 4, (m.spread || 0.4) * degToPx)), + })); + const dots = model.visitedPoints.map((p) => ({ x: sx(p.lon), y: sy(p.lat) })); + return { landPath, blobs, dots }; + }, [rings, model]); + + if (!layout) return null; + + const aria = t("stats.country.mapAria", { + name, + visited: model.regionsVisited, + total: model.regionsTotal, + }); + + return ( +

+ + {layout.landPath && } + {/* Painted "still to explore" regions. */} + {layout.blobs.map((b, i) => ( + + ))} + {/* Cities you've been. */} + {layout.dots.map((d, i) => ( + + ))} + +
+ + {t("stats.country.mapVisited")} + + + {t("stats.country.mapMissing")} + +
+
+ ); +} diff --git a/apps/postcards/src/features/stats/StatsView.tsx b/apps/postcards/src/features/stats/StatsView.tsx index 8806848..87f100f 100644 --- a/apps/postcards/src/features/stats/StatsView.tsx +++ b/apps/postcards/src/features/stats/StatsView.tsx @@ -9,11 +9,11 @@ import { computeCityBands, computeContinentCoverage, computeRecords, - countryDetail, visitedCountriesList, type CountryCoverage, type CountrySort, } from "./computeStats"; +import { CountryCoverageMap } from "./CountryCoverageMap"; import { travelTotals } from "../travel/distance"; import { MODE_GLYPH } from "../travel/modes"; import { useUi, type PlacesView } from "../../lib/store/useUi"; @@ -23,65 +23,6 @@ import { CONTINENT_COLORS, CONTINENT_ORDER } from "../../lib/reference/continent import { ScopeToggle } from "../../ui/ScopeToggle"; import { useT, type MessageKey } from "../../lib/i18n"; -/** A row of tappable chips, capped so a huge country doesn't flood the card. */ -/** 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, - items, - onPick, - hint, - max = 12, -}: { - label: string; - items: string[]; - onPick: (name: string) => void; - hint?: string; - max?: number; -}) { - const t = useT(); - const [expanded, setExpanded] = useState(false); - if (items.length === 0) return null; - const shown = expanded ? items : items.slice(0, max); - const hintText = hint ?? t("common.open"); - return ( -
- - {label} · {items.length} - - - {shown.map((n, i) => ( - - {i > 0 && ( - - {" · "} - - )} - - - ))} - {items.length > max && ( - - )} - -
- ); -} - /** A record's city name — a button that flies the map to it. */ function RecordCity({ name, onPick }: { name: string; onPick: (name: string) => void }) { const t = useT(); @@ -120,27 +61,9 @@ function Bar({ value, label, color }: { value: number; label: string; color?: st * "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, - flyToRegion, -}: { - c: CountryCoverage; - flyToRegion: (name: string) => void; -}) { +function CountryRow({ c }: { c: CountryCoverage }) { const t = useT(); - const ref = useMemo(() => getReferenceData(), []); - const gazGen = useGazetteerGeneration(); // city lists grow when the full gazetteer lands - const visits = useVisits((s) => s.visits); const [open, setOpen] = useState(false); - const detail = useMemo( - () => (open ? countryDetail(visits, ref, c.iso2) : null), - // eslint-disable-next-line react-hooks/exhaustive-deps - [open, visits, ref, c.iso2, gazGen], - ); - const openByName = (list: { id: string; name: string }[]) => (name: string) => { - const hit = list.find((x) => x.name === name); - 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. @@ -301,21 +224,10 @@ function CountryRow({ )} - {detail && ( - <> - {/* What's LEFT to explore — plain, scannable name lists (not a chip wall). */} - - m.name)} - onPick={openByName(detail.monumentsRemaining)} - /> - - )} + {/* What's left to explore, at a glance: a static coverage map — cities + you've been as dots, the regions you haven't as soft "missing" blobs. + The full, tappable lists live on the country's own page (above). */} + {open && } ); @@ -327,7 +239,6 @@ export function StatsView() { const gazGen = useGazetteerGeneration(); // denominators change when the full gazetteer lands const visits = useVisits((s) => s.visits); const trips = useTrips((s) => s.trips); - const flyTo = useUi((s) => s.flyTo); const scope = useSettings((s) => s.countryScope); const [sortBy, setSortBy] = useState("cities"); @@ -376,17 +287,6 @@ export function StatsView() { useFilters.getState().set({ country: "" }); useUi.getState().openPlaces(view); } - function flyToRegion(iso2: string) { - return (name: string) => { - const sub = ref.subdivisionsOf(iso2).find((s) => s.name === name); - if (!sub) return; - const cities = ref.citiesOf(iso2).filter((c) => c.subdivisionId === sub.id); - if (!cities.length) return; - const lat = cities.reduce((s, c) => s + c.lat, 0) / cities.length; - const lon = cities.reduce((s, c) => s + c.lon, 0) / cities.length; - flyTo(lon, lat); - }; - } const continentCov = useMemo( () => computeContinentCoverage(visits, ref, scope), [visits, ref, scope, gazGen], @@ -760,7 +660,7 @@ export function StatsView() { {countries.length === 0 &&

{t("stats.byCountry.empty")}

} {countries.map((c) => ( - + ))}

diff --git a/apps/postcards/src/features/travel/MyPlacesPicker.tsx b/apps/postcards/src/features/travel/MyPlacesPicker.tsx index f5b3853..43581e0 100644 --- a/apps/postcards/src/features/travel/MyPlacesPicker.tsx +++ b/apps/postcards/src/features/travel/MyPlacesPicker.tsx @@ -1,11 +1,10 @@ import { useDeferredValue, useMemo, useState } from "react"; import { getReferenceData } from "../../lib/reference/referenceData"; import { searchPlaces } from "../visits/search"; -import { countryFlag } from "../../lib/format/format"; import { RouteMap } from "./RouteMap"; import { useT } from "../../lib/i18n"; import type { PlaceRef, TravelMode } from "../../lib/schema/models"; -import type { MyPlace } from "./myPlaces"; +import { placeFlag, type MyPlace } from "./myPlaces"; // Pick trip stops fast. Two ways: // • List — the places you've BEEN (visited + past trips) as instant taps, AND a @@ -15,8 +14,6 @@ import type { MyPlace } from "./myPlaces"; // pin to add it in sequence and watch the route draw (see RouteMap). // Flags everywhere for instant recognition (spec 019). -const flagFor = (p: PlaceRef) => (p.kind === "airport" ? "✈️" : countryFlag(p.countryId)); - export function MyPlacesPicker({ places, addedKeys, @@ -92,7 +89,7 @@ export function MyPlacesPicker({ onClick={() => onPick(r.place)} > - {flagFor(r.place)} + {placeFlag(r.place)} {r.place.name} {r.detail} @@ -114,7 +111,7 @@ export function MyPlacesPicker({ onClick={() => onPick(p.place)} > - {flagFor(p.place)} + {placeFlag(p.place)} {p.name} {addedKeys.has(p.key) && ( diff --git a/apps/postcards/src/features/travel/RouteMap.tsx b/apps/postcards/src/features/travel/RouteMap.tsx index a7e5e80..b03b145 100644 --- a/apps/postcards/src/features/travel/RouteMap.tsx +++ b/apps/postcards/src/features/travel/RouteMap.tsx @@ -3,12 +3,11 @@ import maplibregl, { type StyleSpecification } from "maplibre-gl"; import { getReferenceData } from "../../lib/reference/referenceData"; import { useSettings } from "../../lib/store/useSettings"; import { usePrefersReducedMotion } from "../../lib/hooks/usePrefersReducedMotion"; -import { countryFlag } from "../../lib/format/format"; import { stopsArcs } from "../map/visitedLayers"; import { fitBounds } from "../map/mapFit"; import { useT } from "../../lib/i18n"; import type { PlaceRef, TravelMode } from "../../lib/schema/models"; -import type { MyPlace } from "./myPlaces"; +import { placeFlag, type MyPlace } from "./myPlaces"; import { getLand } from "./landGeometry"; import { pickPointsFC } from "./pickPoints"; @@ -22,8 +21,6 @@ import { pickPointsFC } from "./pickPoints"; // places (real - - - +

+ + {i + 1} + + + {placeFlag(s)} + + + {s.name} + + + + + + +
+ {/* The transport for the leg from THIS stop to the next — change it + where a segment differs and a run of one mode reads as a sub-trip. */} + {i < stops.length - 1 && ( +
+ + +
+ )} ))} @@ -157,9 +205,9 @@ export function TripComposer({ tripId, onClose }: { tripId: string | null; onClo setStops((st) => addStop(st, place))} + onPick={(place) => applyChain(appendStop({ stops, legModes }, place, nextFill()))} stops={stops} - travelMode={mode} + travelMode={legModes[0] ?? "flight"} /> {/* Optional details — name now, date whenever. */} @@ -175,17 +223,6 @@ export function TripComposer({ tripId, onClose }: { tripId: string | null; onClo /> - -
{t("trip.compose.addDate")}
diff --git a/apps/postcards/src/features/travel/distance.ts b/apps/postcards/src/features/travel/distance.ts index 84e269e..97055a0 100644 --- a/apps/postcards/src/features/travel/distance.ts +++ b/apps/postcards/src/features/travel/distance.ts @@ -79,17 +79,33 @@ export interface TravelTotals { byMode: { mode: TravelMode; trips: number; km: number }[]; } -/** Aggregate totals across trips; distance sums only trips with two resolvable endpoints. */ +/** Aggregate totals across trips. Distance is summed PER LEG under that leg's own + * transport (spec 019 per-leg modes), so a mixed-mode journey splits correctly; + * a trip is counted once under each distinct mode it uses. */ export function travelTotals(trips: Trip[], ref: ReferenceData): TravelTotals { const per = new Map(); + const slot = (m: TravelMode) => { + const s = per.get(m) ?? { trips: 0, km: 0 }; + per.set(m, s); + return s; + }; let totalKm = 0; for (const t of trips) { - const km = tripDistanceKm(t, ref) ?? 0; - totalKm += km; - const slot = per.get(t.mode) ?? { trips: 0, km: 0 }; - slot.trips += 1; - slot.km += km; - per.set(t.mode, slot); + const chain = t.stops && t.stops.length >= 2 ? t.stops : [t.from, t.to]; + const modesUsed = new Set(); + for (let i = 0; i < chain.length - 1; i++) { + const mode = t.legModes?.[i] ?? t.mode; + modesUsed.add(mode); + const a = coordsOf(chain[i]!, ref); + const b = coordsOf(chain[i + 1]!, ref); + if (a && b) { + const km = haversineKm(a, b); + totalKm += km; + slot(mode).km += km; + } + } + // Count the trip once per distinct transport it used (single-mode → its mode). + for (const m of modesUsed) slot(m).trips += 1; } const byMode = MODE_ORDER.filter((m) => per.has(m)).map((mode) => ({ mode, ...per.get(mode)! })); return { trips: trips.length, totalKm, byMode }; diff --git a/apps/postcards/src/features/travel/myPlaces.ts b/apps/postcards/src/features/travel/myPlaces.ts index 53a86b7..617879d 100644 --- a/apps/postcards/src/features/travel/myPlaces.ts +++ b/apps/postcards/src/features/travel/myPlaces.ts @@ -1,6 +1,12 @@ import type { PlaceRef, Trip, Visit } from "../../lib/schema/models"; import type { ReferenceData } from "../../lib/reference/types"; import { placeKey } from "../../lib/schema/helpers"; +import { countryFlag } from "../../lib/format/format"; + +/** The emoji that stands in for a place in the trip UI — a plane for airports, + * else the country flag. One definition shared by every trip picker/row. */ +export const placeFlag = (p: PlaceRef): string => + p.kind === "airport" ? "✈️" : countryFlag(p.countryId); // The pool the trip composer picks stops from (spec 019, fast-reconstruction): ONLY // places you've already been — your visited records plus every place already used in diff --git a/apps/postcards/src/features/travel/tripStops.ts b/apps/postcards/src/features/travel/tripStops.ts index 63528af..732a8df 100644 --- a/apps/postcards/src/features/travel/tripStops.ts +++ b/apps/postcards/src/features/travel/tripStops.ts @@ -1,4 +1,4 @@ -import type { PlaceRef } from "../../lib/schema/models"; +import type { PlaceRef, TravelMode } from "../../lib/schema/models"; // Immutable ordered-stops helpers for the trip composer (spec 019). Pure, no I/O — // each returns a NEW array so React state updates stay predictable. A reconstructed @@ -36,3 +36,66 @@ export function endpoints(stops: PlaceRef[]): { from: PlaceRef; to: PlaceRef } | if (stops.length < 2) return null; return { from: stops[0]!, to: stops[stops.length - 1]! }; } + +// ── Per-leg transport (spec 019) ──────────────────────────────────────────────── +// A journey can mix transports: the mode of the leg from stop i to stop i+1 lives +// in `legModes[i]`, and a run of the same mode reads as a sub-trip. `legModes` is +// kept the right length (stops − 1) as stops are added/removed/reordered; a leg +// with no explicit entry falls back to the trip's default mode. + +export interface StopChain { + stops: PlaceRef[]; + legModes: TravelMode[]; +} + +/** Fit legModes to exactly `stopCount − 1` entries, keeping existing modes and + * padding any new legs with `fill`. */ +function fitLegs(legModes: TravelMode[], stopCount: number, fill: TravelMode): TravelMode[] { + const need = Math.max(0, stopCount - 1); + if (legModes.length === need) return legModes; + const out = legModes.slice(0, need); + while (out.length < need) out.push(fill); + return out; +} + +/** Append a stop, adding a new leg (mode `fill`) when it creates one. */ +export function appendStop(chain: StopChain, place: PlaceRef, fill: TravelMode): StopChain { + const stops = [...chain.stops, place]; + return { stops, legModes: fitLegs(chain.legModes, stops.length, fill) }; +} + +/** Remove the stop at `index`; the two legs it joined collapse into one (the + * incoming leg's mode is dropped), then legModes is refit. */ +export function removeStopAt(chain: StopChain, index: number, fill: TravelMode): StopChain { + if (index < 0 || index >= chain.stops.length) return chain; + const stops = chain.stops.filter((_, i) => i !== index); + const legModes = [...chain.legModes]; + const drop = index > 0 ? index - 1 : 0; + if (drop < legModes.length) legModes.splice(drop, 1); + return { stops, legModes: fitLegs(legModes, stops.length, fill) }; +} + +/** Move a stop; leg modes can't map cleanly across an arbitrary reorder, so the + * array is kept valid (right length, existing modes by position) — a leg the user + * cares about is one tap to re-set. */ +export function moveStopTo(chain: StopChain, from: number, to: number, fill: TravelMode): StopChain { + const stops = moveStop(chain.stops, from, to); + return { stops, legModes: fitLegs(chain.legModes, stops.length, fill) }; +} + +/** Set the transport mode of leg `legIndex` (stop legIndex → legIndex+1). */ +export function setLegMode(chain: StopChain, legIndex: number, mode: TravelMode): StopChain { + if (legIndex < 0 || legIndex >= chain.legModes.length) return chain; + const legModes = [...chain.legModes]; + legModes[legIndex] = mode; + return { ...chain, legModes }; +} + +/** The mode of leg `i`: its per-leg override if present, else the fallback default. */ +export function legModeAt( + legModes: TravelMode[] | undefined, + i: number, + fallback: TravelMode, +): TravelMode { + return legModes?.[i] ?? fallback; +} diff --git a/apps/postcards/src/features/visits/browseList.ts b/apps/postcards/src/features/visits/browseList.ts index 62197a7..2b7ba58 100644 --- a/apps/postcards/src/features/visits/browseList.ts +++ b/apps/postcards/src/features/visits/browseList.ts @@ -30,8 +30,6 @@ export interface BrowseRow { status: "visited" | "wishlist" | "none"; favorite: boolean; category?: string; - lat?: number; - lon?: number; } /** A page of browse rows plus whether more remain (drives infinite load-more). */ @@ -132,7 +130,7 @@ export function browseList( if (!passStatus(o.status, o.favorite)) continue; if (take({ kind: "city", id: c.id, name: c.name, sub: countryName(c.countryIso2), - countryIso2: c.countryIso2, place, status: o.status, favorite: o.favorite, lat: c.lat, lon: c.lon, + countryIso2: c.countryIso2, place, status: o.status, favorite: o.favorite, })) break; } return { rows, hasMore }; @@ -162,8 +160,7 @@ export function browseList( if (!passStatus(o.status, o.favorite)) continue; if (take({ kind: "heritage", id: h.id, name: h.name, sub: countryName(h.countryIso2), - countryIso2: h.countryIso2, place, status: o.status, favorite: o.favorite, - category: h.category, lat: h.lat, lon: h.lon, + countryIso2: h.countryIso2, place, status: o.status, favorite: o.favorite, category: h.category, })) break; } return { rows, hasMore }; @@ -188,7 +185,7 @@ export function browseList( if (!passStatus(o.status, o.favorite)) continue; if (take({ kind: "airport", id: a.id, name, sub: [a.city, countryName(a.countryIso2)].filter(Boolean).join(" · "), - countryIso2: a.countryIso2, place, status: o.status, favorite: o.favorite, lat: a.lat, lon: a.lon, + countryIso2: a.countryIso2, place, status: o.status, favorite: o.favorite, })) break; } return { rows, hasMore }; diff --git a/apps/postcards/src/lib/i18n/en.ts b/apps/postcards/src/lib/i18n/en.ts index 9bba984..a9c4e10 100644 --- a/apps/postcards/src/lib/i18n/en.ts +++ b/apps/postcards/src/lib/i18n/en.ts @@ -239,6 +239,9 @@ export const en = { "stats.country.chipRegionsToVisit": "Regions to visit", "stats.country.chipMonumentsSeen": "Monuments seen", "stats.country.chipMonumentsToSee": "Monuments to see", + "stats.country.mapAria": "{name}: {visited} of {total} regions visited — dots mark cities you've been, shaded areas are regions still to explore", + "stats.country.mapVisited": "Been", + "stats.country.mapMissing": "To explore", "stats.country.showOnMapHint": "Show on the map:", // ── Stat strip (compact counters) ──────────────────────────────────────── @@ -461,6 +464,7 @@ export const en = { "trip.compose.nameLabel": "Name (optional)", "trip.compose.namePlaceholder": "Name this trip", "trip.compose.modeLabel": "How", + "trip.compose.legModeAria": "Transport from {from} to {to}", "trip.compose.addDate": "Add a date (optional)", "trip.compose.whenLabel": "When (roughly)", "trip.compose.yearPlaceholder": "Year", diff --git a/apps/postcards/src/lib/i18n/fr.ts b/apps/postcards/src/lib/i18n/fr.ts index 0d1cf54..0dd65ef 100644 --- a/apps/postcards/src/lib/i18n/fr.ts +++ b/apps/postcards/src/lib/i18n/fr.ts @@ -232,6 +232,9 @@ export const fr: Messages = { "stats.country.chipRegionsToVisit": "Régions à visiter", "stats.country.chipMonumentsSeen": "Monuments vus", "stats.country.chipMonumentsToSee": "Monuments à voir", + "stats.country.mapAria": "{name} : {visited} régions visitées sur {total} — les points sont des villes visitées, les zones ombrées des régions encore à explorer", + "stats.country.mapVisited": "Visité", + "stats.country.mapMissing": "À explorer", "stats.country.showOnMapHint": "Montrer sur la carte :", // ── Stat strip ─────────────────────────────────────────────────────────── @@ -456,6 +459,7 @@ export const fr: Messages = { "trip.compose.nameLabel": "Nom (facultatif)", "trip.compose.namePlaceholder": "Nommez ce voyage", "trip.compose.modeLabel": "Comment", + "trip.compose.legModeAria": "Transport de {from} à {to}", "trip.compose.addDate": "Ajouter une date (facultatif)", "trip.compose.whenLabel": "Quand (environ)", "trip.compose.yearPlaceholder": "Année", diff --git a/apps/postcards/src/lib/i18n/index.ts b/apps/postcards/src/lib/i18n/index.ts index 08d7188..19176d9 100644 --- a/apps/postcards/src/lib/i18n/index.ts +++ b/apps/postcards/src/lib/i18n/index.ts @@ -12,10 +12,6 @@ import { useSettings } from "../store/useSettings"; import { translate, type Locale, type MessageKey, type TParams } from "./core"; export { - translate, - detectLocale, - applyLangAttr, - isLocale, LOCALES, LOCALE_LABELS, type Locale, diff --git a/apps/postcards/src/lib/i18n/ko.ts b/apps/postcards/src/lib/i18n/ko.ts index 2916cd7..508d6b9 100644 --- a/apps/postcards/src/lib/i18n/ko.ts +++ b/apps/postcards/src/lib/i18n/ko.ts @@ -233,6 +233,9 @@ export const ko: Messages = { "stats.country.chipRegionsToVisit": "방문할 지역", "stats.country.chipMonumentsSeen": "본 기념물", "stats.country.chipMonumentsToSee": "볼 기념물", + "stats.country.mapAria": "{name}: {total}개 지역 중 {visited}개 방문 — 점은 방문한 도시, 음영 영역은 아직 탐험할 지역입니다", + "stats.country.mapVisited": "방문함", + "stats.country.mapMissing": "탐험 예정", "stats.country.showOnMapHint": "지도에서 보기:", // ── Stat strip ─────────────────────────────────────────────────────────── @@ -455,6 +458,7 @@ export const ko: Messages = { "trip.compose.nameLabel": "이름 (선택)", "trip.compose.namePlaceholder": "이 여행 이름 지정", "trip.compose.modeLabel": "수단", + "trip.compose.legModeAria": "{from}에서 {to}까지 이동 수단", "trip.compose.addDate": "날짜 추가 (선택)", "trip.compose.whenLabel": "언제 (대략)", "trip.compose.yearPlaceholder": "연도", diff --git a/apps/postcards/src/lib/image/photoBlobs.ts b/apps/postcards/src/lib/image/photoBlobs.ts index f09eca4..59668c5 100644 --- a/apps/postcards/src/lib/image/photoBlobs.ts +++ b/apps/postcards/src/lib/image/photoBlobs.ts @@ -55,15 +55,9 @@ export function dataUrlToBlob(dataUrl: string): Blob { const base64 = /;base64$/i.test(meta); const mime = meta.replace(/;base64$/i, "") || "application/octet-stream"; const payload = dataUrl.slice(comma + 1); - if (base64) { - const bin = atob(payload); - const bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - return new Blob([bytes], { type: mime }); - } - const text = decodeURIComponent(payload); - const bytes = new Uint8Array(text.length); - for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i); + const str = base64 ? atob(payload) : decodeURIComponent(payload); + const bytes = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i); return new Blob([bytes], { type: mime }); } diff --git a/apps/postcards/src/lib/offline/tiles.ts b/apps/postcards/src/lib/offline/tiles.ts index e8a9e6c..9c1d2c9 100644 --- a/apps/postcards/src/lib/offline/tiles.ts +++ b/apps/postcards/src/lib/offline/tiles.ts @@ -92,6 +92,41 @@ function connectionTooLimited(): boolean { return !!c.saveData || c.effectiveType === "2g" || c.effectiveType === "slow-2g"; } +/** The default network fetch used when a caller doesn't inject its own. */ +const defaultFetch = (...a: Parameters) => fetch(...a); + +/** Passive tile prefetch is a nicety — never run it offline, nor on a data-saver + * / slow link where it would contend with the visible tiles. */ +function prefetchDisabled(): boolean { + return (typeof navigator !== "undefined" && !navigator.onLine) || connectionTooLimited(); +} + +/** Fetch a list of tile URLs at low priority with 2 workers; a failed tile is + * dropped from the seen-set so a later pass can retry it. An optional abort + * signal stops in-flight work (used by the viewport ring). */ +function runPrefetchPool(urls: string[], doFetch: typeof fetch, signal?: AbortSignal): void { + let i = 0; + async function worker() { + while (i < urls.length) { + if (signal?.aborted) return; + const url = urls[i++]!; + try { + await doFetch(url, { + mode: "cors", + referrerPolicy: "strict-origin-when-cross-origin", + priority: "low", + ...(signal ? { signal } : {}), + } as PrefetchInit); + } catch { + prefetched.delete(url); // offline blip / aborted — let a later pass retry it + } + } + } + // Low concurrency (2) so the pool never saturates the per-origin socket budget + // MapLibre needs for the tiles actually on screen. + for (let w = 0; w < 2; w++) void worker(); +} + // In-flight ring prefetch, aborted when the next pan starts so stale off-screen // requests never keep contending with the new viewport's visible tiles. let ringAbort: AbortController | null = null; @@ -125,12 +160,9 @@ export function prefetchAroundBounds( zoom: number, opts: { maxTiles?: number; template?: string; fetchFn?: typeof fetch } = {}, ): void { - if (typeof navigator !== "undefined" && !navigator.onLine) return; - // The passive ring is a pure nicety — never spend a data-saver / 2g user's - // budget on off-screen tiles, and never let it contend on a slow pipe. - if (connectionTooLimited()) return; + if (prefetchDisabled()) return; const maxTiles = opts.maxTiles ?? 40; - const doFetch = opts.fetchFn ?? ((...a: Parameters) => fetch(...a)); + const doFetch = opts.fetchFn ?? defaultFetch; // Target the DISPLAY level (round(zoom + 1) for 256px tiles), not the coarser // round(zoom) — otherwise we warm parent tiles and the crisp ones stay cold. const z = zoom + RASTER_ZOOM_OFFSET; @@ -146,26 +178,7 @@ export function prefetchAroundBounds( ringAbort?.abort(); ringAbort = new AbortController(); const signal = ringAbort.signal; - let i = 0; - async function worker() { - while (i < ring.length) { - if (signal.aborted) return; - const url = ring[i++]!; - try { - await doFetch(url, { - mode: "cors", - referrerPolicy: "strict-origin-when-cross-origin", - priority: "low", - signal, - } as PrefetchInit); - } catch { - prefetched.delete(url); // offline blip / aborted — let a later pause retry it - } - } - } - // Low concurrency (2) so the ring never saturates the per-origin socket budget - // MapLibre needs for the tiles actually on screen. - for (let w = 0; w < 2; w++) void worker(); + runPrefetchPool(ring, doFetch, signal); } /** @@ -180,8 +193,7 @@ export function prefetchAroundPoint( zoom: number, opts: { template?: string; fetchFn?: typeof fetch } = {}, ): void { - if (typeof navigator !== "undefined" && !navigator.onLine) return; - if (connectionTooLimited()) return; + if (prefetchDisabled()) return; // Match the display level (round(zoom + 1) for 256px tiles) so the block we // warm is the one the camera actually renders on arrival. const z = clamp(Math.round(zoom + RASTER_ZOOM_OFFSET), 1, MAX_ZOOM); @@ -189,7 +201,7 @@ export function prefetchAroundPoint( const cx = lon2x(lon, z); const cy = lat2y(lat, z); const template = opts.template ?? OSM_TILE_TEMPLATE; - const doFetch = opts.fetchFn ?? ((...a: Parameters) => fetch(...a)); + const doFetch = opts.fetchFn ?? defaultFetch; const urls: string[] = []; for (let dx = -2; dx <= 2; dx++) { for (let dy = -2; dy <= 2; dy++) { @@ -203,24 +215,7 @@ export function prefetchAroundPoint( if (urls.length === 0) return; if (prefetched.size > PREFETCH_SEEN_CAP) prefetched.clear(); for (const url of urls) prefetched.add(url); - let i = 0; - async function worker() { - while (i < urls.length) { - const url = urls[i++]!; - try { - await doFetch(url, { - mode: "cors", - referrerPolicy: "strict-origin-when-cross-origin", - priority: "low", - } as PrefetchInit); - } catch { - prefetched.delete(url); - } - } - } - // Two workers, low priority: warm the destination without starving the - // visible tiles MapLibre is streaming for the current view mid-fly. - for (let w = 0; w < 2; w++) void worker(); + runPrefetchPool(urls, doFetch); } export interface SaveProgress { @@ -250,7 +245,7 @@ export async function saveAreaOffline( const maxTiles = opts.maxTiles ?? 800; const urls = [...new Set(tilesForBounds(bounds, baseZoom, opts.levels ?? 3, maxTiles, opts.template))]; const capped = urls.length >= maxTiles; - const doFetch = opts.fetchFn ?? ((...a: Parameters) => fetch(...a)); + const doFetch = opts.fetchFn ?? defaultFetch; const total = urls.length; let done = 0; let failed = 0; diff --git a/apps/postcards/src/lib/packs/schema.ts b/apps/postcards/src/lib/packs/schema.ts index 677b31f..357b78c 100644 --- a/apps/postcards/src/lib/packs/schema.ts +++ b/apps/postcards/src/lib/packs/schema.ts @@ -11,7 +11,7 @@ import { z } from "zod"; import { sanitizeText } from "../schema/sanitize"; /** Bounds on a pack so a hostile/oversized file can't OOM the device. */ -export const MAX_PACK_PLACES = 50_000; +const MAX_PACK_PLACES = 50_000; const nonEmptySanitized = (max: number) => z @@ -44,7 +44,6 @@ export const PackPlaceSchema = z }) .strict(); -export type PackPlace = z.infer; /** A community data pack. `license` is REQUIRED — provenance is non-negotiable. */ export const DataPackSchema = z diff --git a/apps/postcards/src/lib/reference/continents.ts b/apps/postcards/src/lib/reference/continents.ts index 98be1b8..ecafb1d 100644 --- a/apps/postcards/src/lib/reference/continents.ts +++ b/apps/postcards/src/lib/reference/continents.ts @@ -30,7 +30,3 @@ export const CONTINENT_ORDER = [ // Bucket for borderless moments (worldwide scope, or an anchor whose country we // can't resolve). Always pinned LAST, after every real continent. export const ACROSS_THE_WORLD = "Across the world"; - -// Full ordered list used when grouping moments by home: continents first, the -// borderless bucket last. -export const MOMENT_GROUP_ORDER: readonly string[] = [...CONTINENT_ORDER, ACROSS_THE_WORLD]; diff --git a/apps/postcards/src/lib/reference/referenceData.ts b/apps/postcards/src/lib/reference/referenceData.ts index 41946ed..3793ce9 100644 --- a/apps/postcards/src/lib/reference/referenceData.ts +++ b/apps/postcards/src/lib/reference/referenceData.ts @@ -39,17 +39,14 @@ export const GAZETTEER_UPGRADED_EVENT = "postcards:gazetteer-upgraded"; // records the user's one-tap opt-in; once set, later launches re-load the full set // straight from the service-worker cache (offline-friendly). Default off. const FULL_CITIES_KEY = "postcards-full-cities"; -function fullCitiesOptedIn(): boolean { +/** Has the user opted into (downloaded) the full world city list? Default off. */ +export function fullCitiesEnabled(): boolean { try { return localStorage.getItem(FULL_CITIES_KEY) === "1"; } catch { return false; } } -/** Has the user opted into (downloaded) the full world city list? */ -export function fullCitiesEnabled(): boolean { - return fullCitiesOptedIn(); -} const SUBDIVISIONS_URL = `${import.meta.env.BASE_URL}reference/subdivisions.json`; const AIRPORTS_URL = `${import.meta.env.BASE_URL}reference/airports.json`; const HERITAGE_URL = `${import.meta.env.BASE_URL}reference/heritage.json`; @@ -383,7 +380,7 @@ export async function initReferenceData(): Promise { // The full 135k-city set is NOT auto-fetched — it's a one-tap download in // Settings (like a tile pack). Only re-load it here if the user already opted // in on a previous run; then it comes straight from the SW cache. - if (fullCitiesOptedIn()) void upgradeToFullGazetteer(ref as ReferenceDataImpl); + if (fullCitiesEnabled()) void upgradeToFullGazetteer(ref as ReferenceDataImpl); return ref; } catch { console.warn("Postcards: reference data failed to load; continuing without cities."); diff --git a/apps/postcards/src/lib/schema/helpers.ts b/apps/postcards/src/lib/schema/helpers.ts index 2dc1554..9f52078 100644 --- a/apps/postcards/src/lib/schema/helpers.ts +++ b/apps/postcards/src/lib/schema/helpers.ts @@ -49,7 +49,10 @@ export const FORMAT = "postcards" as const; // injected on parse; the date regex only accepts MORE). `from`/`to` mirror the // first/last stop, so an older build reading a v11 multi-stop trip still sees a valid // `from → to` leg. -export const SCHEMA_VERSION = 11; +// v12 adds Trip.legModes (per-leg transport). It's a new key on a `.strict()` +// object, so an older (≤v11) app would reject a file that carries it — hence the +// bump, which makes such files fail the version guard gracefully instead. +export const SCHEMA_VERSION = 12; /** Most photos one place's gallery may hold (bounds the inline portable file). */ export const MAX_PHOTOS_PER_VISIT = 48; diff --git a/apps/postcards/src/lib/schema/models.ts b/apps/postcards/src/lib/schema/models.ts index 184c5fc..75612e4 100644 --- a/apps/postcards/src/lib/schema/models.ts +++ b/apps/postcards/src/lib/schema/models.ts @@ -75,15 +75,33 @@ const photoDataUrl = z * rendered via , never executed, and never leaves the device except in an * explicit export. Downscaled on capture. */ +/** A nullable free-text field: bounded, sanitized to inert text, null-preserving + * (absent/null stays null; present text is sanitized to the same bound). */ +const nullableSanitized = (max: number) => + z + .string() + .max(max) + .nullable() + .optional() + .transform((v) => (v == null ? null : sanitizeText(v, max))); + +/** An optional label (folder / trip name): bounded + sanitized, with `.transform` + * BEFORE `.optional` so the KEY stays optional and older files round-trip + * byte-identically; a value that sanitizes away is dropped rather than stored empty. */ +const optionalLabel = (max = 80) => + z + .string() + .max(max) + .transform((v) => { + const s = sanitizeText(v, max); + return s.length ? s : undefined; + }) + .optional(); + export const PhotoSchema = z .object({ src: photoDataUrl, - caption: z - .string() - .max(300) - .nullable() - .optional() - .transform((v) => (v == null ? null : sanitizeText(v, 300))), + caption: nullableSanitized(300), }) .strict(); @@ -100,12 +118,7 @@ export const VisitSchema = z.object({ .nullable() .optional() .transform((v) => v ?? null), - note: z - .string() - .max(2000) - .nullable() - .optional() - .transform((v) => (v == null ? null : sanitizeText(v, 2000))), + note: nullableSanitized(2000), /** * Legacy single "postcard" photo (schema ≤ v2). Kept so v1/v2 files import * unchanged; on load it is migrated into `photos[0]` (see normalizeVisitPhotos). @@ -124,14 +137,7 @@ export const VisitSchema = z.object({ * files validating and round-tripping byte-identically. Sanitized to inert text * when present; a value that sanitizes away is dropped rather than stored empty. */ - folder: z - .string() - .max(80) - .transform((v) => { - const s = sanitizeText(v, 80); - return s.length ? s : undefined; - }) - .optional(), + folder: optionalLabel(), addedAt: z.string().datetime({ offset: true }), /** * When this record was last mutated (device sync, spec 013). Optional so files @@ -163,18 +169,7 @@ export const TripSchema = z * validating and round-tripping byte-identically. Sanitized to inert text when * present; a value that sanitizes away is dropped rather than stored empty. */ - name: z - .string() - .max(80) - // `.transform` BEFORE `.optional` keeps the KEY optional (name?: string) so - // existing trips without a name still typecheck and round-trip byte-identically; - // the transform runs only when a value is present, sanitizing it to inert text - // and dropping it entirely if it sanitizes away. - .transform((v) => { - const s = sanitizeText(v, 80); - return s.length ? s : undefined; - }) - .optional(), + name: optionalLabel(), from: PlaceRefSchema, to: PlaceRefSchema, /** @@ -187,6 +182,15 @@ export const TripSchema = z */ stops: z.array(PlaceRefSchema).min(2).max(200).optional(), mode: TravelModeSchema.optional().default("flight"), + /** + * Per-LEG transport (spec 019): the mode of the leg from stop i to stop i+1, so + * one journey can mix transports — fly Paris→Tokyo→Osaka, then take the train + * Osaka→Kyoto — and a run of the same mode reads as a sub-trip. When present its + * length is `stops.length - 1`; a leg with no entry falls back to `mode`. + * Additive & optional with no default, so the key is never injected on parse — + * a trip with a single `mode` (and every v1–v11 file) round-trips byte-identically. + */ + legModes: z.array(TravelModeSchema).max(200).optional(), // Approximate/"vague" date (spec 019): a full day `YYYY-MM-DD`, a month // `YYYY-MM`, or a year `YYYY` — all optional/nullable (an undated trip is fine). // The wider regex is a RELAXATION, so every previously-valid full-day value @@ -197,18 +201,8 @@ export const TripSchema = z .nullable() .optional() .transform((v) => v ?? null), - carrier: z - .string() - .max(120) - .nullable() - .optional() - .transform((v) => (v == null ? null : sanitizeText(v, 120))), - note: z - .string() - .max(2000) - .nullable() - .optional() - .transform((v) => (v == null ? null : sanitizeText(v, 2000))), + carrier: nullableSanitized(120), + note: nullableSanitized(2000), addedAt: z.string().datetime({ offset: true }), /** Last-mutated stamp for device sync (spec 013); see Visit.updatedAt. */ updatedAt: z.string().datetime({ offset: true }).optional(), @@ -252,18 +246,7 @@ export const StorySchema = z * inert text when present; a value that sanitizes away is dropped rather than * stored empty. */ - folder: z - .string() - .max(80) - // `.transform` BEFORE `.optional` keeps the KEY optional (folder?: string) so - // existing stories without a folder still typecheck and round-trip byte-identically; - // the transform runs only when a value is present, sanitizing it to inert text - // and dropping it entirely if it sanitizes away. - .transform((v) => { - const s = sanitizeText(v, 80); - return s.length ? s : undefined; - }) - .optional(), + folder: optionalLabel(), photos: z.array(PhotoSchema).max(MAX_PHOTOS_PER_STORY).optional(), addedAt: z.string().datetime({ offset: true }), /** Last-mutated stamp for device sync (spec 013); see Visit.updatedAt. */ diff --git a/apps/postcards/src/lib/schema/portable-file.schema.json b/apps/postcards/src/lib/schema/portable-file.schema.json index 96ded92..4598989 100644 --- a/apps/postcards/src/lib/schema/portable-file.schema.json +++ b/apps/postcards/src/lib/schema/portable-file.schema.json @@ -1,6 +1,6 @@ { "$id": "https://github.com/davd-gzl/Postcards/blob/main/apps/postcards/src/lib/schema/portable-file.schema.json", - "title": "Postcards portable data file (schemaVersion 11)", + "title": "Postcards portable data file (schemaVersion 12)", "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { @@ -361,6 +361,21 @@ "other" ] }, + "legModes": { + "maxItems": 200, + "type": "array", + "items": { + "type": "string", + "enum": [ + "flight", + "train", + "bus", + "ferry", + "car", + "other" + ] + } + }, "date": { "anyOf": [ { diff --git a/apps/postcards/src/lib/store/useTrips.ts b/apps/postcards/src/lib/store/useTrips.ts index 2ac89a4..3f0e47d 100644 --- a/apps/postcards/src/lib/store/useTrips.ts +++ b/apps/postcards/src/lib/store/useTrips.ts @@ -17,6 +17,8 @@ interface TripsState { /** Ordered stops for a multi-stop journey (spec 019); ≥2 or omitted. */ stops?: PlaceRef[]; mode?: TravelMode; + /** Per-leg transport (spec 019); omitted when every leg uses `mode`. */ + legModes?: TravelMode[]; date?: string | null; carrier?: string | null; note?: string | null; @@ -26,7 +28,7 @@ interface TripsState { updateTrip: ( tripId: string, changes: Partial< - Pick + Pick >, ) => Promise; removeTrip: (tripId: string) => Promise; @@ -46,6 +48,7 @@ export const useTrips = create((set, get) => ({ to, stops, mode = "flight", + legModes, date = null, carrier = null, note = null, @@ -60,6 +63,9 @@ export const useTrips = create((set, get) => ({ // stays lean and never gains the key (mirrors the schema's optional field). ...(stops && stops.length >= 2 ? { stops } : {}), mode, + // Only carry per-leg modes when a leg actually differs from `mode` (a + // uniform trip stays lean and byte-identical to a pre-legModes file). + ...(legModes && legModes.length ? { legModes } : {}), date, carrier, note, @@ -84,6 +90,9 @@ export const useTrips = create((set, get) => ({ if (nm) updated.name = nm; else delete updated.name; } + // Drop `legModes` when it's cleared (a uniform-mode edit), so the trip never + // keeps a stale per-leg array and stays lean. + if ("legModes" in changes && !changes.legModes?.length) delete updated.legModes; set({ trips: get().trips.map((t) => (t.tripId === tripId ? updated : t)) }); await db.putTrip(updated); }, diff --git a/apps/postcards/src/lib/sync/engine.ts b/apps/postcards/src/lib/sync/engine.ts index 06163d5..7aa7499 100644 --- a/apps/postcards/src/lib/sync/engine.ts +++ b/apps/postcards/src/lib/sync/engine.ts @@ -134,16 +134,14 @@ const DEFAULT_MESSAGE = "Sync Postcards via device sync"; const DEFAULT_MAX_RETRIES = 3; const DEFAULT_GC_HORIZON_DAYS = 90; -const visitTs = (v: Visit) => v.updatedAt ?? v.addedAt; -const tripTs = (t: Trip) => t.updatedAt ?? t.addedAt; -const storyTs = (s: Story) => s.updatedAt ?? s.addedAt; +const ts = (r: R): string => r.updatedAt ?? r.addedAt; /** Merge every collection newest-wins, honouring tombstones (reuses ./merge). */ function mergeAll(local: StoreSnapshots, remote: StoreSnapshots): StoreSnapshots { return { - visits: mergeById(local.visits, remote.visits, (v) => v.visitId, visitTs), - trips: mergeById(local.trips, remote.trips, (t) => t.tripId, tripTs), - stories: mergeById(local.stories, remote.stories, (s) => s.storyId, storyTs), + visits: mergeById(local.visits, remote.visits, (v) => v.visitId, ts), + trips: mergeById(local.trips, remote.trips, (t) => t.tripId, ts), + stories: mergeById(local.stories, remote.stories, (s) => s.storyId, ts), }; } diff --git a/apps/postcards/src/lib/sync/runSync.ts b/apps/postcards/src/lib/sync/runSync.ts index 9b344e2..52cdf78 100644 --- a/apps/postcards/src/lib/sync/runSync.ts +++ b/apps/postcards/src/lib/sync/runSync.ts @@ -30,6 +30,14 @@ import { SYNC_PATH, shouldGuardRemoval, type RemoteConfig } from "./syncConfig"; const kinds: TombstoneKind[] = ["visit", "trip", "story"]; +/** Project a tombstone list to the {id, deletedAt} pairs for ONE kind. */ +const partitionTombs = (list: SyncTombstone[], kind: TombstoneKind) => + list.filter((t) => t.kind === kind).map(({ id, deletedAt }) => ({ id, deletedAt })); + +/** The snapshot (records + tombstones) for one kind out of a merged set. */ +const snapFor = (merged: StoreSnapshots, kind: TombstoneKind) => + kind === "visit" ? merged.visits : kind === "trip" ? merged.trips : merged.stories; + /** The result of one run — a discriminated union so callers branch without relying * on thrown control-flow. `blocked` is the safety guard; `error` carries an i18n * code (see `sync.log.*`) so messages localise at render time. */ @@ -93,8 +101,7 @@ export async function runDeviceSync( ]); const localTombs = await getAllTombstones(); - const pickTombs = (kind: TombstoneKind) => - localTombs.filter((t) => t.kind === kind).map(({ id, deletedAt }) => ({ id, deletedAt })); + const pickTombs = (kind: TombstoneKind) => partitionTombs(localTombs, kind); const local: StoreSnapshots = { visits: { records: useVisits.getState().visits, tombstones: pickTombs("visit") }, @@ -107,8 +114,7 @@ export async function runDeviceSync( const parse = (text: string): StoreSnapshots => { const r = importFile(text); if (!r.ok) throw new Error(r.error); - const partition = (kind: TombstoneKind) => - r.tombstones.filter((t) => t.kind === kind).map(({ id, deletedAt }) => ({ id, deletedAt })); + const partition = (kind: TombstoneKind) => partitionTombs(r.tombstones, kind); return { visits: { records: r.visits.map(backfillUpdatedAt), tombstones: partition("visit") }, trips: { records: r.trips.map(backfillUpdatedAt), tombstones: partition("trip") }, @@ -119,8 +125,7 @@ export async function runDeviceSync( // The token is NOT part of the serialized file — only records + tombstones. const serialize = (merged: StoreSnapshots): string => { const tombs: SyncTombstone[] = kinds.flatMap((kind) => { - const snap = - kind === "visit" ? merged.visits : kind === "trip" ? merged.trips : merged.stories; + const snap = snapFor(merged, kind); return snap.tombstones.map((t) => ({ kind, id: t.id, deletedAt: t.deletedAt })); }); return serializeFile( @@ -134,8 +139,7 @@ export async function runDeviceSync( const persist = async (merged: StoreSnapshots): Promise => { const records: TombstoneRecord[] = kinds.flatMap((kind) => { - const snap = - kind === "visit" ? merged.visits : kind === "trip" ? merged.trips : merged.stories; + const snap = snapFor(merged, kind); return snap.tombstones.map((t) => ({ key: `${kind}:${t.id}`, kind, diff --git a/apps/postcards/src/styles.css b/apps/postcards/src/styles.css index 155ee1f..d4bfdf0 100644 --- a/apps/postcards/src/styles.css +++ b/apps/postcards/src/styles.css @@ -1801,6 +1801,59 @@ h1.brand { padding-top: 8px; border-top: 1px solid var(--border); } +/* Static per-country coverage map: the country silhouette, soft "still to explore" + region blobs, and a dot per city you've been. Non-interactive (a glance, not a + control) — the tappable lists live on the country's full page. */ +.country-cov-map { + margin: 12px 0 2px; +} +.country-cov-map svg { + display: block; + width: 100%; + height: auto; + background: var(--bg-sub); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +.ccov-land { + fill: var(--surface); + stroke: var(--border-strong); + stroke-width: 0.6; +} +.ccov-missing { + fill: var(--stat-want); + opacity: 0.18; +} +.ccov-visited { + fill: var(--stat-been); + stroke: #fff; + stroke-width: 0.6; +} +.country-cov-legend { + display: flex; + gap: 14px; + margin: 5px 2px 0; + font-size: 11.5px; + color: var(--muted); +} +.country-cov-legend span { + display: inline-flex; + align-items: center; + gap: 5px; +} +.ccov-key { + width: 10px; + height: 10px; + border-radius: 999px; + display: inline-block; +} +.ccov-key-visited { + background: var(--stat-been); +} +.ccov-key-missing { + background: var(--stat-want); + opacity: 0.4; +} /* Keep the expanded panel compact — tighter than the standalone continent metrics so a country card doesn't grow into a wall when opened. */ .country-body .metric { @@ -2669,6 +2722,11 @@ h1.brand { gap: 6px; } .trip-stop-row { + display: flex; + flex-direction: column; + gap: 0; +} +.trip-stop-main { display: flex; align-items: center; gap: 8px; @@ -2677,6 +2735,23 @@ h1.brand { border-radius: var(--radius-sm); background: var(--surface); } +/* The leg connector between two stops: a short line + a compact transport picker, + left-aligned under the stop's number so the route reads top-to-bottom and a run + of one transport looks like a sub-trip. */ +.trip-leg { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 0 2px 14px; + margin-left: 9px; + border-left: 2px dashed var(--border-strong); +} +.trip-leg-mode .select { + height: 30px; + padding: 0 24px 0 8px; + font-size: 12.5px; + min-height: 0; +} .trip-stop-index { min-width: 20px; height: 20px; diff --git a/apps/postcards/tests/e2e/stats-covmap.spec.ts b/apps/postcards/tests/e2e/stats-covmap.spec.ts new file mode 100644 index 0000000..1740fc2 --- /dev/null +++ b/apps/postcards/tests/e2e/stats-covmap.spec.ts @@ -0,0 +1,38 @@ +import { test, expect, type Page } from "@playwright/test"; +import { gotoTab } from "./nav-helper"; +import AxeBuilder from "@axe-core/playwright"; + +async function assertNoSeriousViolations(page: Page, screen: string) { + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + const serious = results.violations.filter((v) => ["serious", "critical"].includes(v.impact ?? "")); + expect(serious, `${screen}: ${serious.map((v) => v.id).join(", ")}`).toEqual([]); +} + +// Expanding a country card in Stats shows a STATIC coverage map (not the old text +// lists): the country silhouette with visited-city dots and "still to explore" +// region blobs. It's a role=img with a descriptive label, so it passes the a11y gate. +test("a country card shows a static coverage map that passes the a11y gate", async ({ + page, +}: { + page: Page; +}) => { + await page.goto("/"); + for (const c of ["Paris", "Lyon", "Marseille"]) { + await page.getByLabel("Search a city or country").fill(c); + await page.getByRole("button", { name: `Mark ${c} visited` }).first().click(); + await page.keyboard.press("Escape"); + } + await gotoTab(page, "Stats"); + await page.locator(".country-summary", { hasText: "France" }).click(); + + const map = page.locator(".country-card", { hasText: "France" }).locator(".country-cov-map svg"); + await expect(map).toBeVisible(); + await expect(map).toHaveAttribute("role", "img"); + // The silhouette + at least one visited dot rendered. + await expect(map.locator("path.ccov-land")).toHaveCount(1); + expect(await map.locator("circle.ccov-visited").count()).toBeGreaterThan(0); + + await assertNoSeriousViolations(page, "stats country coverage map"); +}); diff --git a/apps/postcards/tests/e2e/trip-legmodes.spec.ts b/apps/postcards/tests/e2e/trip-legmodes.spec.ts new file mode 100644 index 0000000..8ae0bf9 --- /dev/null +++ b/apps/postcards/tests/e2e/trip-legmodes.spec.ts @@ -0,0 +1,39 @@ +import { test, expect, type Page } from "@playwright/test"; +import { gotoTab } from "./nav-helper"; + +// Per-leg transport (spec 019): a journey can mix modes — fly one leg, take the +// train the next — and the per-leg choice survives save + reopen. +test("set a different transport per leg; it persists across save + reopen", async ({ + page, +}: { + page: Page; +}) => { + await page.goto("/"); + for (const c of ["Paris", "Tokyo", "Osaka"]) { + await page.getByLabel("Search a city or country").fill(c); + await page.getByRole("button", { name: `Mark ${c} visited` }).first().click(); + await page.keyboard.press("Escape"); + } + await gotoTab(page, "Trips"); + await page.getByRole("button", { name: "Reconstruct a journey" }).click(); + await page.getByRole("button", { name: "Add Paris to the trip" }).click(); + await page.getByRole("button", { name: "Add Tokyo to the trip" }).click(); + await page.getByRole("button", { name: "Add Osaka to the trip" }).click(); + + // Two legs → two per-leg pickers. Make the second leg (Tokyo → Osaka) a train. + const legs = page.locator(".trip-leg-mode select"); + await expect(legs).toHaveCount(2); + await legs.nth(0).selectOption("flight"); + await legs.nth(1).selectOption("train"); + + await page.getByRole("button", { name: "Save trip" }).click(); + await expect(page.getByRole("heading", { name: "Travel log" })).toBeVisible(); + + // Reopen → the per-leg choice is restored (flight, then train). + await page.getByRole("button", { name: /Edit trip/ }).first().click(); + await expect(page.getByRole("heading", { name: "Edit trip" })).toBeVisible(); + const legs2 = page.locator(".trip-leg-mode select"); + await expect(legs2).toHaveCount(2); + await expect(legs2.nth(0)).toHaveValue("flight"); + await expect(legs2.nth(1)).toHaveValue("train"); +}); diff --git a/apps/postcards/tests/unit/tripLegs.spec.ts b/apps/postcards/tests/unit/tripLegs.spec.ts new file mode 100644 index 0000000..264f3cc --- /dev/null +++ b/apps/postcards/tests/unit/tripLegs.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import { + appendStop, + removeStopAt, + moveStopTo, + setLegMode, + legModeAt, + type StopChain, +} from "../../src/features/travel/tripStops"; +import { stopsArcs } from "../../src/features/map/visitedLayers"; +import { travelTotals } from "../../src/features/travel/distance"; +import type { PlaceRef, Trip, TravelMode } from "../../src/lib/schema/models"; +import type { City, ReferenceData } from "../../src/lib/reference/types"; + +const cityRef = (id: string, name: string): PlaceRef => ({ kind: "city", id, name, countryId: "FR" }); +const P = cityRef("paris", "Paris"); +const T = cityRef("tokyo", "Tokyo"); +const O = cityRef("osaka", "Osaka"); +const empty: StopChain = { stops: [], legModes: [] }; + +describe("per-leg chain helpers keep legModes in sync with stops", () => { + it("appendStop adds a leg (filled) only once there are ≥2 stops", () => { + let c = appendStop(empty, P, "flight"); + expect(c.stops).toHaveLength(1); + expect(c.legModes).toHaveLength(0); // no leg yet + c = appendStop(c, T, "flight"); + expect(c.legModes).toEqual(["flight"]); + c = appendStop(c, O, "train"); // new legs continue the caller's fill + expect(c.stops).toHaveLength(3); + expect(c.legModes).toEqual(["flight", "train"]); + }); + + it("setLegMode overrides one leg; a run of one mode is a sub-trip", () => { + const c = setLegMode({ stops: [P, T, O], legModes: ["flight", "flight"] }, 1, "train"); + expect(c.legModes).toEqual(["flight", "train"]); + }); + + it("removeStopAt drops the right leg and refits length", () => { + const c = removeStopAt({ stops: [P, T, O], legModes: ["flight", "train"] }, 1, "flight"); + expect(c.stops).toEqual([P, O]); + expect(c.legModes).toHaveLength(1); // 2 stops → 1 leg + }); + + it("moveStopTo keeps legModes valid (right length)", () => { + const c = moveStopTo({ stops: [P, T, O], legModes: ["flight", "train"] }, 2, 0, "flight"); + expect(c.stops[0]).toBe(O); + expect(c.legModes).toHaveLength(2); + }); + + it("legModeAt falls back to the default when a leg has no override", () => { + expect(legModeAt(["train"], 0, "flight")).toBe("train"); + expect(legModeAt(["train"], 5, "flight")).toBe("flight"); + expect(legModeAt(undefined, 0, "flight")).toBe("flight"); + }); +}); + +// ── per-leg arcs + totals ──────────────────────────────────────────────────── +const cities: Record = { + paris: { id: "paris", name: "Paris", countryIso2: "FR", subdivisionId: null, lon: 2.35, lat: 48.85, population: 1 }, + tokyo: { id: "tokyo", name: "Tokyo", countryIso2: "JP", subdivisionId: null, lon: 139.69, lat: 35.68, population: 1 }, + osaka: { id: "osaka", name: "Osaka", countryIso2: "JP", subdivisionId: null, lon: 135.5, lat: 34.69, population: 1 }, +}; +const ref = { + cityById: (id: string) => cities[id], + airportById: () => undefined, + heritageById: () => undefined, +} as unknown as ReferenceData; + +describe("stopsArcs tags each leg with its own mode", () => { + it("uses legModes[i] when present, else the trip default", () => { + const fc = stopsArcs([P, T, O], ref, "flight", ["flight", "train"]); + expect(fc.features.map((f) => f.properties!.mode)).toEqual(["flight", "train"]); + // No per-leg array → every arc uses the default. + expect(stopsArcs([P, T, O], ref, "car").features.map((f) => f.properties!.mode)).toEqual(["car", "car"]); + }); +}); + +describe("travelTotals splits distance by per-leg transport", () => { + it("sums each leg's km under its own mode; counts the trip under each mode used", () => { + const trip = { + tripId: "t1", + from: P, + to: O, + stops: [P, T, O], + mode: "flight" as TravelMode, + legModes: ["flight", "train"] as TravelMode[], + date: null, + addedAt: new Date(0).toISOString(), + } as Trip; + const tot = travelTotals([trip], ref); + const byMode = Object.fromEntries(tot.byMode.map((m) => [m.mode, m])); + expect(byMode.flight!.km).toBeGreaterThan(0); // Paris→Tokyo + expect(byMode.train!.km).toBeGreaterThan(0); // Tokyo→Osaka + expect(byMode.train!.km).toBeLessThan(byMode.flight!.km); // the short leg + expect(byMode.flight!.trips).toBe(1); + expect(byMode.train!.trips).toBe(1); + // Total is the sum of both legs. + expect(tot.totalKm).toBeCloseTo(byMode.flight!.km + byMode.train!.km, 3); + }); +});