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