diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts index 55a36a94e..886ade48d 100644 --- a/server/src/nest/calendar/calendar.service.ts +++ b/server/src/nest/calendar/calendar.service.ts @@ -483,6 +483,77 @@ export class CalendarService { } } + // Pickup and drop-off as their own timed events — the car-rental analogue + // of check-in/check-out above (last unported piece of #1721). Additive, + // same as the check-in/check-out markers are additive to the all-day stay + // range: the rental's existing single-block event (from the endpoint or + // reservation_time branch of buildReservationTimeLines, above) is untouched. + // "YYYY-MM-DD", full ISO, or a bare "HH:MM" → date/time parts. Endpoints + // already carry local_date/local_time separately; reservation_time and + // reservation_end_time (the last-resort fallback below) are combined + // strings, and reservation_end_time is frequently just a bare clock + // alongside the reservation's own date. + const dateOf = (s: string | null | undefined): string | null => { + if (!s) return null; + const d = s.includes('T') ? s.split('T')[0] : s; + return isDate(d) ? d : null; + }; + const timeOf = (s: string | null | undefined): string | null => { + if (!s) return null; + const t = s.includes('T') ? s.split('T')[1] : s; + return t && isTime(t) ? t : null; + }; + + for (const r of reservations) { + if (r.type !== 'car') continue; + + // Sides come from the endpoint role first: import can drop one endpoint + // (failed geocoding), and a surviving return endpoint must not masquerade + // as the pickup. Positional first/last by sequence is only a fallback + // when neither role is present at all, and only once there's more than + // one endpoint, so a lone endpoint is never treated as both sides. + const eps = endpointsMap.get(r.id); + const ordered = eps && eps.length > 0 ? [...eps].sort((a, b) => a.sequence - b.sequence) : []; + const roleFrom = ordered.find(e => e.role === 'from'); + const roleTo = ordered.find(e => e.role === 'to'); + const noRoles = !roleFrom && !roleTo; + const pickupEp = roleFrom ?? (noRoles && ordered.length > 1 ? ordered[0] : undefined); + const dropEp = roleTo ?? (noRoles && ordered.length > 1 ? ordered[ordered.length - 1] : undefined); + + const carMarker = (kind: 'pickup' | 'dropoff', summary: string, date: string, time: string, zone: string | null) => { + let ev = `BEGIN:VEVENT\r\nUID:${uid(r.id, kind)}\r\nDTSTAMP:${now}\r\n`; + ev += dtLine('DTSTART', time, zone, `${date}T00:00`); + ev += `SUMMARY:${esc(summary)}\r\n`; + if (r.location) ev += `LOCATION:${esc(r.location)}\r\n`; + ev += `END:VEVENT\r\n`; + events.push(ev); + }; + + // Per side: the chosen endpoint when it has a usable date and clock, + // else reservation_time (pickup) / reservation_end_time (drop-off) — + // imports frequently carry only that window. A side with no usable date + // and clock from either source emits nothing. + const pickupUsable = pickupEp && isDate(pickupEp.local_date) && isTime(pickupEp.local_time); + const pickupDate = pickupUsable ? pickupEp!.local_date : dateOf(r.reservation_time); + const pickupTime = pickupUsable ? pickupEp!.local_time : timeOf(r.reservation_time); + if (isDate(pickupDate) && isTime(pickupTime)) { + const zone = pickupUsable + ? (pickupEp!.timezone || resolveTimeZone(pickupEp!.lat, pickupEp!.lng)) + : resolveTimeZone(r.place_lat, r.place_lng); + carMarker('pickup', `Pickup: ${r.title}`, pickupDate!, pickupTime!, zone); + } + + const dropUsable = dropEp && isDate(dropEp.local_date) && isTime(dropEp.local_time); + const dropDate = dropUsable ? dropEp!.local_date : (dateOf(r.reservation_end_time) || dateOf(r.reservation_time)); + const dropTime = dropUsable ? dropEp!.local_time : timeOf(r.reservation_end_time); + if (isDate(dropDate) && isTime(dropTime)) { + const zone = dropUsable + ? (dropEp!.timezone || resolveTimeZone(dropEp!.lat, dropEp!.lng)) + : resolveTimeZone(r.place_lat, r.place_lng); + carMarker('dropoff', `Drop-off: ${r.title}`, dropDate!, dropTime!, zone); + } + } + // Every referenced zone gets a VTIMEZONE. They are emitted before the first // event so the TZID references resolve; keyed by TZID so a merged calendar // can define each one once. diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts index 488cda7b5..dde66a142 100644 --- a/server/tests/unit/nest/calendar.service.test.ts +++ b/server/tests/unit/nest/calendar.service.test.ts @@ -897,6 +897,111 @@ describe('accommodations', () => { }); }); +// ── Car rental pickup/drop-off in the feed (#1721) ────────────────────────── + +describe('car rentals', () => { + const insertEndpoint = ( + reservationId: number, + role: string, + sequence: number, + name: string, + lat: number, + lng: number, + timezone: string | null, + local_time: string | null, + local_date: string | null, + ) => { + testDb.prepare( + 'INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run(reservationId, role, sequence, name, null, lat, lng, timezone, local_time, local_date); + }; + + it('CAL-037: a rental with from/to endpoints produces a pickup and a drop-off event at the right local times and zones', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); + const reservation = createReservation(testDb, trip.id, { title: 'Hertz Rental', type: 'car' }); + testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL, location=? WHERE id=?') + .run('Hertz Downtown', reservation.id); + insertEndpoint(reservation.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00', '2026-07-07'); + insertEndpoint(reservation.id, 'to', 1, 'Berlin Office', 52.5, 13.4, 'Europe/Berlin', '10:30', '2026-07-14'); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('SUMMARY:Pickup: Hertz Rental'); + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000'); + expect(ics).toContain('SUMMARY:Drop-off: Hertz Rental'); + expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20260714T103000'); + expect(ics).toContain('LOCATION:Hertz Downtown'); + expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Paris'); + expect(ics).toContain('BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin'); + }); + + it('CAL-038: role-less endpoints fall back to first/last by sequence', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); + const reservation = createReservation(testDb, trip.id, { title: 'Avis Rental', type: 'car' }); + testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id); + // Neither endpoint carries a from/to role — an older import shape. + insertEndpoint(reservation.id, 'stop', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00', '2026-07-07'); + insertEndpoint(reservation.id, 'stop', 1, 'Berlin Office', 52.5, 13.4, 'Europe/Berlin', '10:30', '2026-07-14'); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('SUMMARY:Pickup: Avis Rental'); + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000'); + expect(ics).toContain('SUMMARY:Drop-off: Avis Rental'); + expect(ics).toContain('DTSTART;TZID=Europe/Berlin:20260714T103000'); + }); + + it('CAL-039: a rental with only reservation_time/reservation_end_time still produces both events', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); + const place = createPlace(testDb, trip.id, { name: 'Rental Desk', lat: 48.8566, lng: 2.3522 }); + const reservation = createReservation(testDb, trip.id, { title: 'Budget Rental', type: 'car' }); + testDb.prepare('UPDATE reservations SET reservation_time=?, reservation_end_time=?, place_id=? WHERE id=?') + .run('2026-07-07T09:00', '2026-07-14T10:30', place.id, reservation.id); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('SUMMARY:Pickup: Budget Rental'); + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260707T090000'); + expect(ics).toContain('SUMMARY:Drop-off: Budget Rental'); + expect(ics).toContain('DTSTART;TZID=Europe/Paris:20260714T103000'); + }); + + it('CAL-040: a rental with a single endpoint does not emit a bogus second event', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); + const reservation = createReservation(testDb, trip.id, { title: 'Sixt Rental', type: 'car' }); + testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id); + // Only the pickup was geocoded — the common shape for a partially-imported + // booking. The lone endpoint must not be reused as the drop-off too. + insertEndpoint(reservation.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', '09:00', '2026-07-07'); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).toContain('SUMMARY:Pickup: Sixt Rental'); + expect(ics).not.toContain('Drop-off'); + }); + + it('CAL-041: a rental with no usable clock emits no window events and its existing behaviour is unchanged', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Road Trip' }); + const reservation = createReservation(testDb, trip.id, { title: 'Untimed Rental', type: 'car' }); + testDb.prepare('UPDATE reservations SET reservation_time=NULL, reservation_end_time=NULL WHERE id=?').run(reservation.id); + // A date but no clock — the pre-existing "untimed transport" all-day + // fallback still applies and must be unaffected by the new marker events. + insertEndpoint(reservation.id, 'from', 0, 'Paris Office', 48.8566, 2.3522, 'Europe/Paris', null, '2026-07-07'); + + const { ics } = svc.exportICS(trip.id); + + expect(ics).not.toContain('Pickup'); + expect(ics).not.toContain('Drop-off'); + expect(ics).toContain('SUMMARY:Untimed Rental'); + expect(ics).toContain('DTSTART;VALUE=DATE:20260707'); + }); +}); + describe('folded quirk branches', () => { it('TRIP-SVC-048: exportICS renders untimed/notes all-day summaries, multi-leg routes, endpoint routes and train/location fields', () => { const { user } = createUser(testDb);