feat(feed): car rental pickup and drop-off events in the ICS feed

Rentals now emit two additional timed markers, Pickup and Drop-off, the
same additive shape hotels already get for check-in/check-out (#1721,
last unported piece). The rental's existing single-block event is
untouched.

Sides are derived in priority order: the endpoint role (from = pickup,
to = drop-off, reusing the already-loaded endpoints map); if no
endpoint on the reservation carries either role, positional first/last
by sequence (only once there's more than one endpoint, so a lone
endpoint is never both sides); otherwise reservation_time /
reservation_end_time, since imports frequently carry only that window.
Per side, an event only fires once a usable date and clock resolve;
zone resolution prefers the endpoint's stored timezone, then its
coordinates, then the reservation's linked place.

Follows the hotel `marker` closure's pattern closely (same esc/uid/
dtLine/isDate/isTime/usedZones usage, same Pickup:/Drop-off: summary
convention, same per-kind UID shape) without touching the hotel path
itself.
This commit is contained in:
michael-bohr
2026-08-12 22:04:25 -04:00
committed by Maurice
parent 54a3c8d50c
commit 7d580efce9
2 changed files with 176 additions and 0 deletions
@@ -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.
@@ -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);