From 1c34a74498be2b50b3828bbdb05552afc78c2dfe Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 1 Oct 2026 12:32:42 +0200 Subject: [PATCH] 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) --- server/src/nest/calendar/calendar.service.ts | 56 +++++++++++++++++++ .../tests/unit/nest/calendar.service.test.ts | 46 +++++++++++++++ wiki/Calendar-Feeds.md | 1 + 3 files changed, 103 insertions(+) diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index 61037343a..061e9ff25 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -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(); + 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; diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index 8a00d2b8a..9d347af83 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -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 diff --git a/wiki/Calendar-Feeds.md b/wiki/Calendar-Feeds.md index c2db0369c..9a29eb97d 100644 --- a/wiki/Calendar-Feeds.md +++ b/wiki/Calendar-Feeds.md @@ -63,6 +63,7 @@ The feed carries the same events as the `.ics` download: - **Timed stops**: one event per place in the day plan that has a time, titled with the place name, with its address as the location and its notes as the description. Times follow the place's own time zone. - **A summary per day**: an all-day event for every day with untimed places or notes, titled with the day's title (or *Day N*), listing those places and notes. - **Bookings** of every type, transport included. Flights and other rides take their start and end from the departure and arrival points, each in its own time zone. A booking with no date that can be placed is left out. +- **Connections**: a flight or train with several legs becomes one event per leg, titled *{title}: FRA → BER*, with each leg's own departure, arrival and segment reference, so the layover shows as the gap between them. This needs a departure time on every leg; otherwise the booking stays one event. - **Accommodations**: an all-day event covering every night of the stay, from the arrival day to the departure day, so the hotel sits above those days rather than showing up once on the day you check in. The dates come from the trip days the stay is attached to, so reordering days moves the event with them. - **Check-in and check-out**: separate timed events on the arrival and departure days whenever the stay has those times. If you entered a check-in window, its end becomes the event's end time. - **Car pickup and drop-off**: a booking of type *Car* also gets two timed events of its own, *Pickup: {title}* and *Drop-off: {title}*, next to the rental's own event, both with the booking's location. Each side prefers its own endpoint (the pickup point for the pickup, the return point for the drop-off) and takes that endpoint's local time and time zone. Otherwise it falls back to the booking's own times and to the time zone of the linked place. A side with no usable date and time from either source is left out, so a rental imported with only one located endpoint can end up with a single event.