From 4c13a2aa26195fec33138ea5e0ee5272294ca669 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:33:43 +0000 Subject: [PATCH 1/3] Simplify: second batch of safe, behavior-preserving cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More from the app-wide analysis — each type-checked and test-covered, no behavior change: - Shared helpers replace duplicated logic: `placeFlag` (trip flag emoji, was copied in three travel components), `nullableSanitized`/`optionalLabel` (Zod transforms in models.ts, were repeated 4×/3× — the generated JSON-Schema artifact is byte-identical), a generic `ts` accessor in sync/engine, and `partitionTombs`/`snapFor` in sync/runSync. - offline/tiles: one `defaultFetch`, one `prefetchDisabled()` guard, and one `runPrefetchPool()` replacing two near-identical 2-worker loops. - Dead code / tighter surface: drop the unused `MOMENT_GROUP_ORDER`, the write- only `BrowseRow.lat/lon`, the `PackPlace` type, the never-passed `hint` prop on the stats NameList; collapse `fullCitiesOptedIn`/`fullCitiesEnabled` into one; drop unreferenced i18n barrel re-exports and the `export` on `MAX_PACK_PLACES`. - Smaller reads: PassportScreen counts instead of allocating; PublishScreen reuses `passNorm`; importJson renames a shadowing loop var; guideNames gets its explicit return type; photoBlobs' decode is one loop. Gate: tsc clean, 459 unit tests (incl. schema-artifact + import-security), e2e green (trip-reconstruction, trip-routemap, offline, a11y). --- .../src/features/backup/exportJson.ts | 10 ++- .../src/features/backup/importJson.ts | 4 +- .../src/features/guides/GuideButton.tsx | 2 +- .../src/features/passport/PassportScreen.tsx | 12 +-- .../src/features/publish/PublishScreen.tsx | 4 +- .../src/features/stats/StatsView.tsx | 5 +- .../src/features/travel/MyPlacesPicker.tsx | 9 +- .../src/features/travel/RouteMap.tsx | 7 +- .../src/features/travel/TripComposer.tsx | 5 +- .../postcards/src/features/travel/myPlaces.ts | 6 ++ .../src/features/visits/browseList.ts | 9 +- apps/postcards/src/lib/i18n/index.ts | 4 - apps/postcards/src/lib/image/photoBlobs.ts | 12 +-- apps/postcards/src/lib/offline/tiles.ts | 89 +++++++++---------- apps/postcards/src/lib/packs/schema.ts | 3 +- .../postcards/src/lib/reference/continents.ts | 4 - .../src/lib/reference/referenceData.ts | 9 +- apps/postcards/src/lib/schema/models.ts | 86 +++++++----------- apps/postcards/src/lib/sync/engine.ts | 10 +-- apps/postcards/src/lib/sync/runSync.ts | 20 +++-- 20 files changed, 131 insertions(+), 179 deletions(-) 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/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/StatsView.tsx b/apps/postcards/src/features/stats/StatsView.tsx index 8806848..a4bf534 100644 --- a/apps/postcards/src/features/stats/StatsView.tsx +++ b/apps/postcards/src/features/stats/StatsView.tsx @@ -31,20 +31,17 @@ 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 (

@@ -62,7 +59,7 @@ function NameList({ type="button" className="name-list-link" onClick={() => onPick(n)} - title={`${hintText} ${n}`} + title={`${t("common.open")} ${n}`} > {n} 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 - - ))} - {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(); @@ -117,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. @@ -298,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 && } ); @@ -324,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"); @@ -373,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], @@ -757,7 +660,7 @@ export function StatsView() { {countries.length === 0 &&

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

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

diff --git a/apps/postcards/src/lib/i18n/en.ts b/apps/postcards/src/lib/i18n/en.ts index 9bba984..fd37dae 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) ──────────────────────────────────────── diff --git a/apps/postcards/src/lib/i18n/fr.ts b/apps/postcards/src/lib/i18n/fr.ts index 0d1cf54..a713d79 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 ─────────────────────────────────────────────────────────── diff --git a/apps/postcards/src/lib/i18n/ko.ts b/apps/postcards/src/lib/i18n/ko.ts index 2916cd7..1e08ece 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 ─────────────────────────────────────────────────────────── diff --git a/apps/postcards/src/styles.css b/apps/postcards/src/styles.css index 155ee1f..a4f96c8 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 { 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"); +}); From 3f0ac4dbc851ed53c3b41a20b03d4af2edfee0f3 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:01:07 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Travel:=20per-leg=20transport=20=E2=80=94?= =?UTF-8?q?=20mix=20modes=20in=20one=20journey=20(sub-trips)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconstruct a journey that changes transport partway: fly Paris→Tokyo, then take the train Tokyo→Osaka. Each leg (stop i → i+1) has its own mode, and a run of the same mode reads as a sub-trip. - Composer: a compact transport picker sits on the connector between each pair of stops (replacing the single trip-level "How" select). New legs continue the previous leg's mode, so tapping stops in a row keeps one transport until you change it. Live arcs on the real map colour each leg by its own mode. - Data model: Trip.legModes (schema v12) — an optional per-leg array, saved ONLY when a leg actually differs from the trip's `mode`, so a uniform single-mode trip stays byte-identical to a pre-v12 file. A leg with no entry falls back to `mode`. Older apps reject a v12 file gracefully via the version guard. - travelTotals now splits distance PER LEG under that leg's transport, and counts a trip once per distinct mode it uses — so the stats travel breakdown is right for mixed journeys (single-mode trips are unchanged). - Pure chain helpers (appendStop/removeStopAt/moveStopTo/setLegMode/legModeAt) keep legModes in sync with the stops. Gate: tsc clean, 466 unit tests (new tripLegs spec: chain sync, per-leg arcs, per-leg totals; schema artifact regenerated), e2e green — a new trip-legmodes spec (set → save → reopen persists), trip-reconstruction, tripedit, trips, trip-routemap, and the composer a11y (axe WCAG 2.1 AA). --- .../src/features/map/visitedLayers.ts | 10 +- .../src/features/travel/TripComposer.tsx | 150 +++++++++++------- .../postcards/src/features/travel/distance.ts | 30 +++- .../src/features/travel/tripStops.ts | 65 +++++++- 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/schema/helpers.ts | 5 +- apps/postcards/src/lib/schema/models.ts | 9 ++ .../src/lib/schema/portable-file.schema.json | 17 +- apps/postcards/src/lib/store/useTrips.ts | 11 +- apps/postcards/src/styles.css | 22 +++ .../postcards/tests/e2e/trip-legmodes.spec.ts | 39 +++++ apps/postcards/tests/unit/tripLegs.spec.ts | 100 ++++++++++++ 14 files changed, 390 insertions(+), 71 deletions(-) create mode 100644 apps/postcards/tests/e2e/trip-legmodes.spec.ts create mode 100644 apps/postcards/tests/unit/tripLegs.spec.ts 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/travel/TripComposer.tsx b/apps/postcards/src/features/travel/TripComposer.tsx index 9cbdd5d..0ec42da 100644 --- a/apps/postcards/src/features/travel/TripComposer.tsx +++ b/apps/postcards/src/features/travel/TripComposer.tsx @@ -7,9 +7,9 @@ import { useT, useLocale } from "../../lib/i18n"; import type { PlaceRef, TravelMode } from "../../lib/schema/models"; import { MyPlacesPicker } from "./MyPlacesPicker"; import { myPlaces, placeFlag } from "./myPlaces"; -import { addStop, moveStop, removeStop } from "./tripStops"; +import { appendStop, moveStopTo, removeStopAt, setLegMode, type StopChain } from "./tripStops"; import { tripPathKm } from "./distance"; -import { MODE_ORDER } from "./modes"; +import { MODE_ORDER, MODE_GLYPH } from "./modes"; import { parseTripDate } from "./tripDate"; const MONTHS = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"]; @@ -36,13 +36,30 @@ export function TripComposer({ tripId, onClose }: { tripId: string | null; onClo // The pool of places you've been — visited records + places already used in trips. const pool = useMemo(() => myPlaces(visits, trips, ref), [visits, trips, ref]); - const [stops, setStops] = useState(() => { + const initialStops = (): PlaceRef[] => { if (existing?.stops && existing.stops.length >= 2) return existing.stops; if (existing) return [existing.from, existing.to]; return []; + }; + const [stops, setStops] = useState(initialStops); + // Per-leg transport (spec 019): one mode per leg, seeded from the trip's saved + // modes (or its single `mode`), so a journey can mix transports and a run of one + // mode reads as a sub-trip. Kept the right length as stops change. + const [legModes, setLegModes] = useState(() => { + const s0 = initialStops(); + const need = Math.max(0, s0.length - 1); + const base = (existing?.legModes ?? []).slice(0, need); + while (base.length < need) base.push(existing?.mode ?? "flight"); + return base; }); + const applyChain = (c: StopChain) => { + setStops(c.stops); + setLegModes(c.legModes); + }; + // New legs continue the last leg's transport (so tapping stops in a row keeps + // one mode until you change it — that's what makes a sub-trip). + const nextFill = (): TravelMode => legModes[legModes.length - 1] ?? existing?.mode ?? "flight"; const [name, setName] = useState(existing?.name ?? ""); - const [mode, setMode] = useState(existing?.mode ?? "flight"); const seededDate = parseTripDate(existing?.date ?? null); const [year, setYear] = useState(seededDate ? String(seededDate.year) : ""); const [month, setMonth] = useState( @@ -67,10 +84,15 @@ export function TripComposer({ tripId, onClose }: { tripId: string | null; onClo const from = stops[0]!; const to = stops[stops.length - 1]!; const date = composeDate(); + // The primary mode is the first leg's; per-leg modes are saved only when a leg + // actually differs (a uniform trip stays a plain single-mode trip). + const mode = legModes[0] ?? existing?.mode ?? "flight"; + const mixed = legModes.some((m) => m !== mode); + const legModesToSave = mixed ? legModes : undefined; if (tripId && existing) { - await updateTrip(tripId, { from, to, stops, mode, date, name: name.trim() }); + await updateTrip(tripId, { from, to, stops, mode, legModes: legModesToSave, date, name: name.trim() }); } else { - await addTrip({ from, to, stops, mode, date, name: name.trim() || null }); + await addTrip({ from, to, stops, mode, legModes: legModesToSave, date, name: name.trim() || null }); } onClose(); } @@ -107,43 +129,70 @@ export function TripComposer({ tripId, onClose }: { tripId: string | null; onClo

    {stops.map((s, i) => (
  1. - - {i + 1} - - - {placeFlag(s)} - - - {s.name} - - - - - - +
    + + {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 && ( +
    + + +
    + )}
  2. ))}
@@ -156,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. */} @@ -174,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/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/lib/i18n/en.ts b/apps/postcards/src/lib/i18n/en.ts index fd37dae..a9c4e10 100644 --- a/apps/postcards/src/lib/i18n/en.ts +++ b/apps/postcards/src/lib/i18n/en.ts @@ -464,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 a713d79..0dd65ef 100644 --- a/apps/postcards/src/lib/i18n/fr.ts +++ b/apps/postcards/src/lib/i18n/fr.ts @@ -459,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/ko.ts b/apps/postcards/src/lib/i18n/ko.ts index 1e08ece..508d6b9 100644 --- a/apps/postcards/src/lib/i18n/ko.ts +++ b/apps/postcards/src/lib/i18n/ko.ts @@ -458,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/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 9073ed3..75612e4 100644 --- a/apps/postcards/src/lib/schema/models.ts +++ b/apps/postcards/src/lib/schema/models.ts @@ -182,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 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/styles.css b/apps/postcards/src/styles.css index a4f96c8..d4bfdf0 100644 --- a/apps/postcards/src/styles.css +++ b/apps/postcards/src/styles.css @@ -2722,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; @@ -2730,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/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); + }); +});