From 9a9f5e6f28d2c11e217a2b48d625919deaec4d9f Mon Sep 17 00:00:00 2001 From: davd-gzl <60177543+davd-gzl@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:09:46 +0900 Subject: [PATCH] Publish: a date range keeps a trip's stops by their own dates The range filtered trips by the trip's date, its first stop's day, and ignored the per-stop dates: a trip whose later stops fell in the range was left out, and one starting inside it was published whole, with every step on the first day. Date each stop by its own day or the last dated stop before it, and keep the stops inside the range. --- apps/postcards/src/lib/publish/bundle.ts | 39 +++++++++++++++---- .../tests/unit/publishBundle.spec.ts | 39 +++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/apps/postcards/src/lib/publish/bundle.ts b/apps/postcards/src/lib/publish/bundle.ts index dee65a7..4446b43 100644 --- a/apps/postcards/src/lib/publish/bundle.ts +++ b/apps/postcards/src/lib/publish/bundle.ts @@ -72,6 +72,17 @@ function inRange(date: string | null, from?: string, to?: string): boolean { return true; } +/** A trip's ordered stops, each with the day you were there: its own per-stop date, + * else the last dated stop before it, else the trip's date. */ +function datedStops(t: Trip): { place: PlaceRef; date: string | null }[] { + let date = t.date; + return tripChain(t).map((place, i) => { + const own = t.stopDates?.[i]; + if (own) date = own; + return { place, date }; + }); +} + /** Build the ordered, self-contained journey for the reader. */ export function buildJourney(input: JourneyInput, sel: JourneySelection): PublishedJourney { const { visits, trips, stories, resolveCoords } = input; @@ -101,11 +112,17 @@ export function buildJourney(input: JourneyInput, sel: JourneySelection): Publis for (const s of stories) if (s.place && inRange(s.date, sel.dateFrom, sel.dateTo)) addPhotos(s.place, s.photos ?? []); // Ordered legs from the selected trips (date first, then a stable original order). + // A date range keeps a trip's stops that fall inside it, so a journey that runs + // into the range from before it (or out past it) publishes only the days picked. const wanted = sel.tripIds ? new Set(sel.tripIds) : null; const legs = trips .filter((t) => (wanted ? wanted.has(t.tripId) : true)) - .filter((t) => inRange(t.date, sel.dateFrom, sel.dateTo)) - .sort((a, b) => (a.date ?? "").localeCompare(b.date ?? "")); + .map((t) => ({ + t, + stops: datedStops(t).map((s) => ({ ...s, kept: inRange(s.date, sel.dateFrom, sel.dateTo) })), + })) + .filter(({ stops }) => stops.some((s) => s.kept)) + .sort((a, b) => (a.t.date ?? "").localeCompare(b.t.date ?? "")); const steps: JourneyStep[] = []; const makeStep = (place: PlaceRef, date: string | null, arriveBy: TravelMode | null): void => { @@ -124,12 +141,18 @@ export function buildJourney(input: JourneyInput, sel: JourneySelection): Publis }; if (legs.length > 0) { - for (const t of legs) { - // Every stop of a multi-stop trip, each arrived at by its own leg's mode. - const chain = tripChain(t); - const last = steps[steps.length - 1]; - if (!last || placeKey(last.place) !== placeKey(chain[0]!)) makeStep(chain[0]!, t.date, null); - for (let i = 1; i < chain.length; i++) makeStep(chain[i]!, t.date, t.legModes?.[i - 1] ?? t.mode); + for (const { t, stops } of legs) { + // Every kept stop of a multi-stop trip, each arrived at by its own leg's mode; + // the first stop after a cut starts the route afresh. + stops.forEach((s, i) => { + if (!s.kept) return; + if (i > 0 && stops[i - 1]!.kept) { + makeStep(s.place, s.date, t.legModes?.[i - 1] ?? t.mode); + } else { + const last = steps[steps.length - 1]; + if (!last || placeKey(last.place) !== placeKey(s.place)) makeStep(s.place, s.date, null); + } + }); } } else { // No trips selected — publish the stories in date order as the steps. diff --git a/apps/postcards/tests/unit/publishBundle.spec.ts b/apps/postcards/tests/unit/publishBundle.spec.ts index 841d5fe..ffb6631 100644 --- a/apps/postcards/tests/unit/publishBundle.spec.ts +++ b/apps/postcards/tests/unit/publishBundle.spec.ts @@ -154,6 +154,45 @@ describe("buildJourney (trips-driven)", () => { }); }); +// A multi-stop trip dates each stop (stopDates); a date range keeps the stops that +// fall inside it, so a trip counts by where you were on those days, not by its start. +describe("buildJourney (per-stop dates)", () => { + const journey: Trip = { + ...trip("j1", paris, cairo, "flight", "2026-05-02"), + stops: [paris, rome, cairo], + legModes: ["flight", "ferry"], + stopDates: ["2026-05-02", "2026-05-10", "2026-05-20"], + }; + const build = (sel: { dateFrom?: string; dateTo?: string }, t: Trip = journey) => + buildJourney({ visits: [], trips: [t], stories: [], resolveCoords }, { title: "Trip", ...sel }); + + it("dates each stop of the journey by its own day", () => { + const j = build({}); + expect(j.steps.map((s) => s.place.name)).toEqual(["Paris", "Rome", "Cairo"]); + expect(j.steps.map((s) => s.arriveBy)).toEqual([null, "flight", "ferry"]); + expect(j.steps.map((s) => s.date)).toEqual(["2026-05-02", "2026-05-10", "2026-05-20"]); + }); + + it("includes a trip whose later stops fall in the range, from the first stop inside it", () => { + const j = build({ dateFrom: "2026-05-08", dateTo: "2026-05-31" }); + expect(j.steps.map((s) => s.place.name)).toEqual(["Rome", "Cairo"]); + expect(j.steps.map((s) => s.arriveBy)).toEqual([null, "ferry"]); + expect(j.dateRange).toEqual({ start: "2026-05-10", end: "2026-05-20" }); + }); + + it("leaves out the stops of a trip that fall after the range", () => { + const j = build({ dateTo: "2026-05-12" }); + expect(j.steps.map((s) => s.place.name)).toEqual(["Paris", "Rome"]); + expect(j.dateRange).toEqual({ start: "2026-05-02", end: "2026-05-10" }); + }); + + it("dates an undated stop by the stop before it", () => { + const j = build({ dateTo: "2026-05-12" }, { ...journey, stopDates: ["2026-05-02", null, "2026-05-20"] }); + expect(j.steps.map((s) => s.place.name)).toEqual(["Paris", "Rome"]); + expect(j.steps.map((s) => s.date)).toEqual(["2026-05-02", "2026-05-02"]); + }); +}); + // The PublishScreen "By trip" (folder) scope resolves a trip NAME to the set of // tripIds sharing it, then feeds those to buildJourney. This verifies that path. describe("buildJourney (by trip name / folder selection)", () => {