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.
This commit is contained in:
Konstantinos Thermos
2026-08-11 11:17:52 +03:00
committed by GitHub
parent 1edb531549
commit b4f9e96cc9
2 changed files with 121 additions and 19 deletions
+37 -19
View File
@@ -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
@@ -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' });