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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions apps/postcards/src/lib/publish/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 => {
Expand All @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions apps/postcards/tests/unit/publishBundle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down
Loading