diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index 5a007f52d..7288138e9 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -503,13 +503,12 @@ export class CalendarService { if (windowSplit.has(r.id)) continue; const timeLines = buildReservationTimeLines(r); if (!timeLines) continue; - const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {}; let ev = `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`; ev += timeLines; ev += `SUMMARY:${esc(r.title)}\r\n`; - const desc = describeReservation(r, meta); + const desc = describeReservation(r); if (desc) ev += `DESCRIPTION:${esc(desc)}\r\n`; if (r.location) ev += `LOCATION:${esc(r.location)}\r\n`; ev += `END:VEVENT\r\n`; @@ -520,7 +519,8 @@ export class CalendarService { // event loop because a window booking's two hand-over events carry the same // lines: dropping the block must not drop the route or the confirmation with // it (#2068). - function describeReservation(r: any, meta: any): string { + function describeReservation(r: any): string { + const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {}; let desc = r.type ? `Type: ${r.type}` : ''; if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`; if (meta.airline) desc += `\nAirline: ${meta.airline}`; @@ -627,8 +627,7 @@ export class CalendarService { if (!split && r.type !== 'car') continue; const { start, end } = split ?? windowSidesOf(r); - const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {}; - const desc = split ? describeReservation(r, meta) : ''; + const desc = split ? describeReservation(r) : ''; const handover = (kind: 'pickup' | 'dropoff', side: WindowSide, summary: string) => { let ev = `BEGIN:VEVENT\r\nUID:${uid(r.id, kind)}\r\nDTSTAMP:${now}\r\n`; diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index 2a408c8b3..3421abfef 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -1192,6 +1192,55 @@ describe('car rentals', () => { expect(ics).toContain('DTEND;VALUE=DATE:20260705'); expect(ics).not.toContain('Pickup: Night Bus'); }); + + it('CAL-052: a split rental whose endpoints carry no zone falls back to their coordinates', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); + const rental = createReservation(testDb, trip.id, { title: 'Europcar', type: 'car' }); + testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(rental.id); + // An older import that geocoded both ends but stored no IANA zone. + insertEndpoint(rental.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, null, '09:00', '2026-07-07'); + insertEndpoint(rental.id, 'to', 1, 'Berlin Office', 52.5, 13.4, null, '10:30', '2026-07-14'); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000'); + expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20260714T103000'); + }); + + // reservation_end_time is frequently a bare clock next to the booking's own + // date, which puts both hand-overs on the same day — so it is one sitting. + it('CAL-053: a bare end clock resolves against the start date rather than splitting', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); + const parking = createReservation(testDb, trip.id, { title: 'Street Bay', type: 'parking' }); + testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=? WHERE id=?') + .run('2026-07-01T08:00', '18:30', parking.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('SUMMARY:Street Bay\r\n'); + expect(ics).not.toContain('Drop-off: Street Bay'); + expect(ics).not.toContain('Pickup: Street Bay'); + }); + + it('CAL-054: an unsplit rental with a day but no clock emits no hand-over of its own', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); + const day = createDay(testDb, trip.id, { date: '2026-07-03' }); + const rental = createReservation(testDb, trip.id, { title: 'Dayless', type: 'car' }); + // A day but no end day and no clock: one side resolves, and it has no time, + // so there is nothing to place — the all-day block carries it instead. + testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL, day_id=?, end_day_id=NULL WHERE id=?') + .run(day.id, rental.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('SUMMARY:Dayless\r\n'); + expect(ics).toContain('DTSTART;VALUE=DATE:20260703'); + expect(ics).not.toContain('Pickup: Dayless'); + expect(ics).not.toContain('Drop-off: Dayless'); + }); }); describe('folded quirk branches', () => {