mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user