feat(calendar): one feed event per leg of a connecting flight or train

A flight or train with several legs comes out of the calendar feed as one event per leg, titled with its route and carrying the leg's own departure, arrival and segment reference, so the layover shows as the gap between them. A booking with a leg missing its departure time stays one event, as before. (#2389)
This commit is contained in:
Maurice
2026-10-01 12:32:42 +02:00
parent f4626c9f3e
commit 1c34a74498
3 changed files with 103 additions and 0 deletions
@@ -534,10 +534,66 @@ export class CalendarService {
if (start && end && start.date !== end.date) windowSplit.set(r.id, { start, end });
}
// A flight or train with connections, as one event per leg (#2389), so each
// segment shows its own departure and arrival and the layover between them is
// visible in the calendar. Only when every leg has a dated departure clock:
// anything less keeps the single event, which is then the only carrier of the
// times there are. Each leg takes its zones from the stop endpoints when the
// booking has one per airport or station, and floats otherwise.
const dayDate = new Map<number, string>();
for (const d of days) if (isDate(d.date)) dayDate.set(Number(d.id), d.date);
interface LegTimes { from: string | null; to: string | null; label: string | null; confirmation: string | null; dep: { date: string; time: string; zone: string | null }; arr: { date: string; time: string; zone: string | null } | null }
const legsOf = (r: any): LegTimes[] | null => {
if (r.type !== 'flight' && r.type !== 'train') return null;
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
const legs = Array.isArray(meta.legs) ? meta.legs : [];
if (legs.length < 2) return null;
const eps = endpointsMap.get(r.id);
const ordered = eps && eps.length === legs.length + 1 ? [...eps].sort((a, b) => a.sequence - b.sequence) : null;
const zoneAt = (i: number): string | null => {
const ep = ordered?.[i];
return ep ? ep.timezone || resolveTimeZone(ep.lat, ep.lng) : null;
};
const out: LegTimes[] = [];
for (let i = 0; i < legs.length; i++) {
const leg = legs[i] ?? {};
const depDate = dayDate.get(Number(leg.dep_day_id));
if (!depDate || !isTime(leg.dep_time)) return null;
const arrDate = dayDate.get(Number(leg.arr_day_id ?? leg.dep_day_id));
const label = [leg.airline, leg.flight_number ?? leg.train_number].filter(Boolean).join(' ') || null;
out.push({
from: leg.from ?? ordered?.[i]?.code ?? ordered?.[i]?.name ?? null,
to: leg.to ?? ordered?.[i + 1]?.code ?? ordered?.[i + 1]?.name ?? null,
label,
confirmation: leg.confirmation_number ?? null,
dep: { date: depDate, time: leg.dep_time, zone: zoneAt(i) },
arr: arrDate && isTime(leg.arr_time) ? { date: arrDate, time: leg.arr_time, zone: zoneAt(i + 1) } : null,
});
}
return out;
};
// Reservations as events
for (const r of reservations) {
// The two hand-over events below stand in for this booking entirely.
if (windowSplit.has(r.id)) continue;
const legs = legsOf(r);
if (legs) {
const desc = describeReservation(r);
legs.forEach((leg, i) => {
const route = [leg.from, leg.to].filter(Boolean).join(' → ');
let ev = `BEGIN:VEVENT\r\nUID:${uid(r.id, `res-leg${i + 1}`)}\r\nDTSTAMP:${now}\r\n`;
ev += dtLine('DTSTART', leg.dep.time, leg.dep.zone, `${leg.dep.date}T00:00`);
if (leg.arr) ev += dtLine('DTEND', leg.arr.time, leg.arr.zone ?? leg.dep.zone, `${leg.arr.date}T00:00`);
ev += `SUMMARY:${esc([r.title, route].filter(Boolean).join(': '))}\r\n`;
const legLines = [leg.label, leg.confirmation ? `Confirmation: ${leg.confirmation}` : null, `Leg ${i + 1} of ${legs.length}`].filter(Boolean).join('\n');
ev += `DESCRIPTION:${esc([legLines, desc].filter(Boolean).join('\n'))}\r\n`;
if (leg.from) ev += `LOCATION:${esc(leg.from)}\r\n`;
ev += `END:VEVENT\r\n`;
events.push(ev);
});
continue;
}
const timeLines = buildReservationTimeLines(r);
if (!timeLines) continue;
@@ -650,6 +650,52 @@ describe('exportICS', () => {
expect(ics).toContain('DESCRIPTION:Type: flight\\nConfirmation: BOOK1\\nRoute: FRA → BER → HND\r\n');
});
it('CAL-045: a connecting flight with times on every leg becomes one event per leg (#2389)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Layover' });
const d1 = createDay(testDb, trip.id, { date: '2025-06-02' });
const d2 = createDay(testDb, trip.id, { date: '2025-06-03' });
const flight = createReservation(testDb, trip.id, { title: 'FRA to HND', type: 'flight' });
testDb.prepare('UPDATE reservations SET reservation_time=?, confirmation_number=?, metadata=? WHERE id=?').run(
'2025-06-02T09:00', 'BOOK1',
JSON.stringify({ legs: [
{ from: 'FRA', to: 'BER', airline: 'LH', flight_number: '1', dep_day_id: d1.id, dep_time: '09:00', arr_day_id: d1.id, arr_time: '10:10' },
{ from: 'BER', to: 'HND', airline: 'LH', flight_number: '2', confirmation_number: 'SEG2', dep_day_id: d1.id, dep_time: '12:30', arr_day_id: d2.id, arr_time: '07:45' },
] }),
flight.id,
);
const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
expect(ics).toContain(`UID:trek-res-leg1-${flight.id}@trek`);
expect(ics).toContain(`UID:trek-res-leg2-${flight.id}@trek`);
expect(ics).not.toContain(`UID:trek-res-${flight.id}@trek`);
expect(ics).toContain('DTSTART:20250602T090000\r\nDTEND:20250602T101000\r\nSUMMARY:FRA to HND: FRA → BER');
// The second leg lands the next morning, and its own reference rides along.
expect(ics).toContain('DTSTART:20250602T123000\r\nDTEND:20250603T074500\r\nSUMMARY:FRA to HND: BER → HND');
expect(ics).toContain('DESCRIPTION:LH 2\\nConfirmation: SEG2\\nLeg 2 of 2\\nType: flight');
expect(ics).toContain('LOCATION:BER\r\n');
});
it('CAL-046: a leg without a departure clock keeps the single event', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Layover' });
const d1 = createDay(testDb, trip.id, { date: '2025-06-02' });
const flight = createReservation(testDb, trip.id, { title: 'FRA to HND', type: 'flight' });
testDb.prepare('UPDATE reservations SET reservation_time=?, metadata=? WHERE id=?').run(
'2025-06-02T09:00',
JSON.stringify({ legs: [
{ from: 'FRA', to: 'BER', dep_day_id: d1.id, dep_time: '09:00' },
{ from: 'BER', to: 'HND', dep_day_id: d1.id },
] }),
flight.id,
);
const ics = svc.exportICS(trip.id).ics;
expect(ics).toContain(`UID:trek-res-${flight.id}@trek`);
expect(ics).not.toContain('res-leg');
});
it('CAL-020: an empty trip title falls back for SUMMARY, X-WR-CALNAME and the filename', () => {
const { user } = createUser(testDb);
// The title is only NOT NULL, not non-empty; an empty one used to produce