From b4f9e96cc9d6bf82313eff068e774c68766748f5 Mon Sep 17 00:00:00 2001 From: Konstantinos Thermos Date: Tue, 11 Aug 2026 11:17:52 +0300 Subject: [PATCH] fix(feeds): use per-endpoint zones for transport times in the ICS feed (#1874) A transport also carries a top-level reservation_time, and that branch derives its zone from the linked place, which a flight does not have, so both ends landed as floating times in the subscribed feed (#1453). Consult a timed departure endpoint first instead and keep the IANA zone per side. When the arrival side has no clock of its own, the DTEND still comes from reservation_end_time, so a rental imported with only a geocoded pickup keeps its multi-day return. --- server/src/nest/calendar/calendar.service.ts | 56 ++++++++----- .../tests/unit/nest/calendar.service.test.ts | 84 +++++++++++++++++++ 2 files changed, 121 insertions(+), 19 deletions(-) diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index b324f1da6..c2dc6c21d 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -295,9 +295,40 @@ export class CalendarService { const isTime = (s: string | null | undefined) => !!s && /^\d{2}:\d{2}/.test(s); // Build the DTSTART/DTEND lines for a reservation, or null when it has no - // calendar-placeable time. Hotels/restaurants use reservation_time; flights - // fall back to their first/last endpoint. + // calendar-placeable time. Transports keep a per-side wall clock + IANA zone + // on their endpoints, so consult a timed departure endpoint FIRST: transports + // also carry a top-level reservation_time (TransportModal stamps it), whose + // only zone is the linked place — flights have none — so that branch floats + // both ends in the subscribed feed (#1453). Hotels/restaurants have no + // endpoints and fall through to reservation_time. const buildReservationTimeLines = (r: any): string | null => { + const eps = endpointsMap.get(r.id); + const ordered = eps && eps.length > 0 ? [...eps].sort((a, b) => a.sequence - b.sequence) : null; + const first = ordered?.[0]; + + if (first && isDate(first.local_date) && isTime(first.local_time)) { + // Transport: departure endpoint zone drives DTSTART, arrival drives DTEND. + // Prefer the stored IANA zone; fall back to the endpoint's coordinates. + const last = ordered![ordered!.length - 1]; + const startZone = first.timezone || resolveTimeZone(first.lat, first.lng); + const startWallClock = `${first.local_date}T${first.local_time}`; + let out = dtLine('DTSTART', startWallClock, startZone); + if (last !== first && isDate(last.local_date) && isTime(last.local_time)) { + const endZone = last.timezone || resolveTimeZone(last.lat, last.lng); + out += dtLine('DTEND', `${last.local_date}T${last.local_time}`, endZone); + } else if (r.reservation_end_time) { + // No second timed endpoint, so the arrival side still lives in + // reservation_end_time — a rental car imported with only a geocoded + // pickup is the common shape. Taking the endpoint branch without this + // would drop the DTEND the reservation_time branch used to emit and + // shrink a multi-day booking to a point. The departure zone is the + // best one on hand for that end too. + const endDt = fmtDateTime(r.reservation_end_time, startWallClock); + if (endDt.length >= 15) out += dtLine('DTEND', r.reservation_end_time, startZone, startWallClock); + } + return out; + } + if (r.reservation_time) { const datePart = r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time; if (!isDate(datePart)) return null; // time-only (relative "Day N" trips) @@ -314,24 +345,11 @@ export class CalendarService { return `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`; } - const eps = endpointsMap.get(r.id); - if (!eps || eps.length === 0) return null; - const ordered = [...eps].sort((a, b) => a.sequence - b.sequence); - const first = ordered[0]; - const last = ordered[ordered.length - 1]; - if (!isDate(first.local_date)) return null; - if (isTime(first.local_time)) { - // Transport: departure endpoint zone drives DTSTART, arrival drives DTEND. - // Prefer the stored IANA zone; fall back to the endpoint's coordinates. - const startZone = first.timezone || resolveTimeZone(first.lat, first.lng); - let out = dtLine('DTSTART', `${first.local_date}T${first.local_time}`, startZone); - if (last !== first && isDate(last.local_date) && isTime(last.local_time)) { - const endZone = last.timezone || resolveTimeZone(last.lat, last.lng); - out += dtLine('DTEND', `${last.local_date}T${last.local_time}`, endZone); - } - return out; + // Untimed transport (endpoint has a date but no clock, no reservation_time). + if (first && isDate(first.local_date)) { + return `DTSTART;VALUE=DATE:${fmtDate(first.local_date)}\r\n`; } - return `DTSTART;VALUE=DATE:${fmtDate(first.local_date)}\r\n`; + return null; }; // Reservations as events diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index 3cab59280..c2d149323 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -277,6 +277,90 @@ describe('exportICS', () => { expect(ics).toContain('Route: CDG → JFK'); }); + it('TRIP-SVC-024a: a flight that also carries reservation_time still uses per-side endpoint zones', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); + const reservation = createReservation(testDb, trip.id, { + title: 'CDG → JFK', + type: 'flight', + }); + // TransportModal stamps reservation_time/_end_time (departure/arrival) alongside + // the endpoints. That branch's only zone is the linked place — a flight has none — + // so it floated both ends in the subscribed feed; endpoints must win (#1453). + testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') + .run('2025-06-02T09:00', '2025-06-02T12:00', reservation.id); + const insertEp = testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + insertEp.run(reservation.id, 'from', 0, 'Paris CDG', 'CDG', 49.0, 2.5, 'Europe/Paris', '09:00', '2025-06-02'); + insertEp.run(reservation.id, 'to', 1, 'New York JFK', 'JFK', 40.6, -73.8, 'America/New_York', '12:00', '2025-06-02'); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20250602T090000'); + expect(ics).toContain('DTEND;TZID=America/New_York:20250602T120000'); + // The bug was a floating DTSTART/DTEND (no TZID) from the reservation_time branch. + expect(ics).not.toContain('DTSTART:20250602T090000'); + expect(ics).not.toContain('DTEND:20250602T120000'); + }); + + it('TRIP-SVC-024c: a one-sided transport keeps the multi-day DTEND from reservation_end_time', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Rental Trip' }); + const reservation = createReservation(testDb, trip.id, { title: 'Rental car', type: 'car_rental' }); + // An imported rental geocodes the pickup only, so there is no second endpoint + // to carry the return. Before the endpoint branch took precedence, the return + // came from reservation_end_time; it still has to. + testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') + .run('2025-06-02T10:00', '2025-06-09T10:00', reservation.id); + testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run(reservation.id, 'from', 0, 'Berlin', null, 52.5, 13.4, 'Europe/Berlin', '10:00', '2025-06-02'); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20250602T100000'); + expect(ics).toContain('DTEND;TZID=Europe/Berlin:20250609T100000'); + }); + + it('TRIP-SVC-024d: an untimed arrival endpoint still takes its DTEND from reservation_end_time', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Train Trip' }); + const reservation = createReservation(testDb, trip.id, { title: 'ICE 1234', type: 'train' }); + testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') + .run('2025-06-02T08:00', '2025-06-02T14:30', reservation.id); + const insertEp = testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + insertEp.run(reservation.id, 'from', 0, 'Berlin Hbf', null, 52.5, 13.4, 'Europe/Berlin', '08:00', '2025-06-02'); + // The destination was added without a clock — the arrival time only exists + // on the reservation itself. + insertEp.run(reservation.id, 'to', 1, 'Wien Hbf', null, 48.2, 16.4, 'Europe/Vienna', null, '2025-06-02'); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20250602T080000'); + expect(ics).toContain('DTEND;TZID=Europe/Berlin:20250602T143000'); + }); + + it('TRIP-SVC-024e: a lone timed endpoint without reservation_end_time stays a DTSTART', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Ferry Trip' }); + const reservation = createReservation(testDb, trip.id, { title: 'Ferry', type: 'ferry' }); + testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=NULL WHERE id=?') + .run('2025-06-02T07:00', reservation.id); + testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run(reservation.id, 'from', 0, 'Dover', null, 51.1, 1.3, 'Europe/London', '07:00', '2025-06-02'); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;TZID=Europe/London:20250602T070000'); + // Nothing to end it with, so no DTEND may be invented (the trip's own all-day + // DTEND is VALUE=DATE, hence the narrower match). + expect(ics).not.toContain('DTEND;TZID='); + }); + it('TRIP-SVC-024b: an invalid endpoint timezone degrades to floating time instead of crashing the export', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id, { title: 'Bad TZ Trip' });