diff --git a/server/src/services/airtrail/airtrailMapper.ts b/server/src/services/airtrail/airtrailMapper.ts index dd300b06..4c398628 100644 --- a/server/src/services/airtrail/airtrailMapper.ts +++ b/server/src/services/airtrail/airtrailMapper.ts @@ -64,8 +64,8 @@ export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight { toCode: airportCode(raw.to), toName: raw.to?.name ?? null, date: raw.date ?? null, - departure: raw.departureScheduled ?? null, - arrival: raw.arrivalScheduled ?? null, + departure: raw.departureScheduled ?? raw.departure ?? null, + arrival: raw.arrivalScheduled ?? raw.arrival ?? null, airline: entityName(raw.airline), flightNumber: raw.flightNumber ?? null, aircraft: entityCode(raw.aircraft), @@ -103,11 +103,14 @@ function hasCoords(a: AirtrailAirport | null): a is AirtrailAirport & { lat: num /** Raw AirTrail flight → the data createReservation() expects (type:'flight'). */ export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservation { - // Read the SCHEDULED times only — TREK plans against the scheduled (booked) time, - // not the actual/estimated `departure`/`arrival`. When a flight has no scheduled - // time, the clock is left blank (date preserved) rather than fabricated. - const dep = localParts(raw.departureScheduled, raw.from?.tz ?? null); - const arr = localParts(raw.arrivalScheduled, raw.to?.tz ?? null); + // Prefer the scheduled (booked) time TREK plans against, but fall back to the + // primary departure/arrival instant when AirTrail has no scheduled time. Manually + // entered flights only set `departure`/`arrival` (the `*Scheduled` columns stay + // null), so reading scheduled alone dropped the clock — and the whole arrival — + // for the common case (#1336). Only when neither exists is the clock left blank + // (date preserved) rather than fabricated. + const dep = localParts(raw.departureScheduled ?? raw.departure, raw.from?.tz ?? null); + const arr = localParts(raw.arrivalScheduled ?? raw.arrival, raw.to?.tz ?? null); const fromCode = airportCode(raw.from); const toCode = airportCode(raw.to); @@ -194,8 +197,11 @@ export function canonicalHash(raw: AirtrailFlightRaw): string { to: airportCode(raw.to), date: raw.date ?? null, datePrecision: raw.datePrecision ?? 'day', - departureScheduled: raw.departureScheduled ?? null, - arrivalScheduled: raw.arrivalScheduled ?? null, + // Hash the same instant the import uses (scheduled, else primary) so a change to + // whichever time TREK actually shows triggers a re-sync — and existing flights + // imported without a scheduled time re-sync once to pick up their clock (#1336). + departureScheduled: raw.departureScheduled ?? raw.departure ?? null, + arrivalScheduled: raw.arrivalScheduled ?? raw.arrival ?? null, airline: entityCode(raw.airline), flightNumber: raw.flightNumber ?? null, aircraft: entityCode(raw.aircraft), diff --git a/server/tests/unit/services/airtrailMapper.test.ts b/server/tests/unit/services/airtrailMapper.test.ts index 10d9b5c5..bf83b633 100644 --- a/server/tests/unit/services/airtrailMapper.test.ts +++ b/server/tests/unit/services/airtrailMapper.test.ts @@ -51,11 +51,17 @@ describe('airtrailMapper.normalizeFlight', () => { flightNumber: 'BA178', seatClass: 'economy', }); - // The picker preview surfaces the scheduled times, not the actual ones. + // The picker preview prefers the scheduled times over the actual ones. expect(n.departure).toBe('2021-09-01T23:00:00.000+00:00'); expect(n.arrival).toBe('2021-09-02T07:00:00.000+00:00'); }); + it('#1336 surfaces the primary departure/arrival when there is no scheduled time', () => { + const n = normalizeFlight(flight({ departureScheduled: null, arrivalScheduled: null })); + expect(n.departure).toBe('2021-09-01T23:42:00.000+00:00'); + expect(n.arrival).toBe('2021-09-02T07:42:00.000+00:00'); + }); + it('falls back to ICAO when IATA is missing and tolerates null airports', () => { const n = normalizeFlight(flight({ from: airport({ iata: null }), to: null })); expect(n.fromCode).toBe('KJFK'); @@ -74,8 +80,8 @@ describe('airtrailMapper.mapFlightToReservation', () => { expect(m.reservation_end_time).toBe('2021-09-02T08:00'); }); - it('leaves the clock blank (date only) when the flight has no scheduled time', () => { - const m = mapFlightToReservation(flight({ departureScheduled: null, arrivalScheduled: null })); + it('leaves the clock blank (date only) when the flight has no time at all', () => { + const m = mapFlightToReservation(flight({ departure: null, arrival: null, departureScheduled: null, arrivalScheduled: null })); // Date is preserved from the AirTrail canonical date; no fabricated 00:00. expect(m.reservation_time).toBe('2021-09-01'); expect(m.reservation_end_time).toBeNull(); @@ -83,6 +89,17 @@ describe('airtrailMapper.mapFlightToReservation', () => { expect(m.endpoints.find(e => e.role === 'to')?.local_time).toBeNull(); }); + it('#1336 falls back to the primary departure/arrival when AirTrail has no scheduled times', () => { + // Manually-entered AirTrail flights set only departure/arrival; *Scheduled stays null. + const m = mapFlightToReservation(flight({ departureScheduled: null, arrivalScheduled: null })); + // departure 23:42 UTC at JFK (EDT) = 19:42 local; arrival 07:42 UTC at LHR (BST) = 08:42. + expect(m.reservation_time).toBe('2021-09-01T19:42'); + expect(m.reservation_end_time).toBe('2021-09-02T08:42'); + expect(m.endpoints.find(e => e.role === 'from')?.local_time).toBe('19:42'); + expect(m.endpoints.find(e => e.role === 'to')?.local_time).toBe('08:42'); + expect(m.endpoints.find(e => e.role === 'to')?.local_date).toBe('2021-09-02'); + }); + it('builds two endpoints with codes, coords and timezones', () => { const m = mapFlightToReservation(flight()); expect(m.endpoints).toHaveLength(2); @@ -142,8 +159,8 @@ describe('airtrailMapper.mapFlightToReservation', () => { expect(m.endpoints.find(e => e.role === 'to')).toBeDefined(); }); - it('leaves the end time null for a partial flight with no scheduled arrival', () => { - const m = mapFlightToReservation(flight({ arrivalScheduled: null })); + it('leaves the end time null for a partial flight with no arrival time at all', () => { + const m = mapFlightToReservation(flight({ arrival: null, arrivalScheduled: null })); expect(m.reservation_end_time).toBeNull(); expect(m.reservation_time).toBe('2021-09-01T19:00'); }); @@ -164,12 +181,20 @@ describe('airtrailMapper.canonicalHash', () => { expect(canonicalHash(flight())).not.toBe( canonicalHash(flight({ departureScheduled: '2021-09-01T22:00:00.000+00:00' })), ); - // ...but a change to the actual time alone must not (TREK never shows it). + // ...but a change to the actual time alone must not (TREK shows the scheduled one). expect(canonicalHash(flight())).toBe( canonicalHash(flight({ departure: '2021-09-01T20:00:00.000+00:00', arrival: '2021-09-02T05:00:00.000+00:00' })), ); }); + it('#1336 tracks the primary departure when there is no scheduled time', () => { + // With no scheduled time, departure IS what TREK imports, so a change must re-sync. + const manual = flight({ departureScheduled: null, arrivalScheduled: null }); + expect(canonicalHash(manual)).not.toBe( + canonicalHash(flight({ departureScheduled: null, arrivalScheduled: null, departure: '2021-09-01T20:00:00.000+00:00' })), + ); + }); + it('is independent of seat ordering', () => { const a = flight({ seats: [