mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
committed by
GitHub
parent
1edb531549
commit
b4f9e96cc9
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user