diff --git a/server/src/nest/booking-import/kitinerary-mapper.ts b/server/src/nest/booking-import/kitinerary-mapper.ts index 3f5c21356..6001c934a 100644 --- a/server/src/nest/booking-import/kitinerary-mapper.ts +++ b/server/src/nest/booking-import/kitinerary-mapper.ts @@ -1,3 +1,4 @@ +import { normalizeLocalDateTime, splitLocalDateTime } from '@trek/shared'; import { findByIata } from '../airports/airports.data'; import type { KiReservation, KiFlight, KiTrainTrip, KiBusTrip, KiBoatTrip, @@ -9,17 +10,31 @@ import type { // Helpers // --------------------------------------------------------------------------- -/** Extract a plain ISO string from either a string or a KDE QDateTime object. */ +/** + * Extract a plain ISO string from either a string or a KDE QDateTime object. + * + * The value passes through normalizeLocalDateTime so a printed 12-hour clock + * ("2026-06-11T02:30 PM") becomes 24-hour before anything downstream reads it. + * These two functions are the mapper's only exits for a date, so this covers + * every type and every emitted field at once, and it keeps `sameConnection` + * from comparing a NaN timestamp (#2094). + */ function toIsoString(dt: KiDateTimeish): string | null { if (!dt) return null; - if (typeof dt === 'string') return dt || null; - if (typeof dt === 'object' && dt['@type'] === 'QDateTime') return dt['@value'] || null; + if (typeof dt === 'string') return dt ? normalizeLocalDateTime(dt) : null; + if (typeof dt === 'object' && dt['@type'] === 'QDateTime') { + return dt['@value'] ? normalizeLocalDateTime(dt['@value']) : null; + } return null; } function splitIso(dt: KiDateTimeish): { date: string | null; time: string | null } { const iso = toIsoString(dt); if (!iso) return { date: null, time: null }; + const split = splitLocalDateTime(iso); + // The slice is the fallback for anything the normalizer does not recognise, + // so a shape that works today cannot start returning null. + if (split.date) return split; return { date: iso.slice(0, 10) || null, time: iso.length > 10 ? iso.slice(11, 16) || null : null }; } @@ -261,6 +276,9 @@ function mapLodging(r: KiReservation, source: ParsedBookingItem['source']): Pars return { type: 'hotel', + // Only the lodging path can carry this: it is the shape the extractor falls + // back to when it could not read a type at all (#2076). + ...(r.trekTypeGuessed === true ? { type_guessed: true } : {}), title: l.name, confirmation_number: r.reservationNumber ?? null, location: formatAddress(l.address), diff --git a/server/tests/unit/services/kitineraryMapper.test.ts b/server/tests/unit/services/kitineraryMapper.test.ts index 35b422f2e..026489242 100644 --- a/server/tests/unit/services/kitineraryMapper.test.ts +++ b/server/tests/unit/services/kitineraryMapper.test.ts @@ -68,3 +68,42 @@ describe('kitinerary mapper — multi-leg flight grouping', () => { expect((items[0].metadata as any).legs).toBeUndefined(); }); }); + +describe('kitinerary mapper — printed 12-hour clocks (#2094)', () => { + it('reads a PM arrival as the afternoon instead of slicing the meridiem off', () => { + // Before the fix the endpoint carried '01:11', because splitIso sliced + // characters 11..16 out of '2026-06-11T01:11 PM'. The hour survived and the + // arrival landed twelve hours early. + const { items } = mapReservations([ + flight('PM1', FRA, BER, '2026-06-11T09:51 am', '2026-06-11T01:11 pm', 'LH 300'), + ] as any, 'meridiem.json'); + + expect(items).toHaveLength(1); + const [dep, arr] = items[0].endpoints!; + expect(dep.local_time).toBe('09:51'); + expect(arr.local_time).toBe('13:11'); + expect(items[0].reservation_time).toBe('2026-06-11T09:51:00'); + expect(items[0].reservation_end_time).toBe('2026-06-11T13:11:00'); + }); + + it('leaves a 24-hour document byte for byte where it was', () => { + const { items } = mapReservations([ + flight('H24', FRA, BER, '2026-06-11T10:00:00', '2026-06-11T12:00:00', 'LH 400'), + ] as any, 'plain.json'); + + expect(items[0].reservation_time).toBe('2026-06-11T10:00:00'); + expect(items[0].endpoints![1].local_time).toBe('12:00'); + }); + + it('keeps two legs apart that a NaN comparison used to merge', () => { + // sameConnection does `new Date(value).getTime()`, which is NaN for a + // printed meridiem, and both of its comparisons are false against NaN, so + // the guard fell through and merged unrelated legs into one booking. + const { items } = mapReservations([ + flight('SAME', FRA, BER, '2026-06-11T09:00 am', '2026-06-11T10:00 am', 'LH 500'), + flight('SAME', HND, FRA, '2026-06-18T09:00 am', '2026-06-18T05:00 pm', 'LH 600'), + ] as any, 'two-legs.json'); + + expect(items).toHaveLength(2); + }); +}); diff --git a/shared/src/datetime/datetime-normalize.spec.ts b/shared/src/datetime/datetime-normalize.spec.ts new file mode 100644 index 000000000..440734226 --- /dev/null +++ b/shared/src/datetime/datetime-normalize.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { parseMeridiemClock, splitLocalDateTime, normalizeLocalDateTime } from './datetime-normalize'; + +describe('parseMeridiemClock', () => { + it('resolves the half of the day', () => { + expect(parseMeridiemClock('01:11 pm')).toBe('13:11'); + expect(parseMeridiemClock('09:51 am')).toBe('09:51'); + expect(parseMeridiemClock('3 PM')).toBe('15:00'); + expect(parseMeridiemClock('02:30:00 p.m.')).toBe('14:30'); + }); + + it('keeps noon at 12 and midnight at 00', () => { + expect(parseMeridiemClock('12:05 pm')).toBe('12:05'); + expect(parseMeridiemClock('12:05 am')).toBe('00:05'); + }); + + it('returns null when there is no meridiem to resolve', () => { + expect(parseMeridiemClock('13:11')).toBeNull(); + expect(parseMeridiemClock('')).toBeNull(); + expect(parseMeridiemClock(null)).toBeNull(); + // 13 with a meridiem is not a 12-hour clock, so it is not ours to rewrite. + expect(parseMeridiemClock('13:11 pm')).toBeNull(); + }); +}); + +describe('splitLocalDateTime', () => { + it('reads a printed 12-hour clock instead of slicing it', () => { + // The defect this pins: 'T02:30 PM'.slice(11, 16) is '02:30'. The hour + // survives and the booking lands twelve hours early (#2094). + expect(splitLocalDateTime('2026-06-11T02:30 PM')).toEqual({ date: '2026-06-11', time: '14:30' }); + }); + + it('leaves a 24-hour value exactly as it is', () => { + expect(splitLocalDateTime('2026-06-11T13:11:00')).toEqual({ date: '2026-06-11', time: '13:11' }); + expect(splitLocalDateTime('2026-06-11')).toEqual({ date: '2026-06-11', time: null }); + }); + + it('has no date to give when the value does not start with one', () => { + expect(splitLocalDateTime('Aug 23 2025 10:00')).toEqual({ date: null, time: null }); + expect(splitLocalDateTime(undefined)).toEqual({ date: null, time: null }); + }); +}); + +describe('normalizeLocalDateTime', () => { + it('rewrites only the clock, and keeps the seconds it was given', () => { + expect(normalizeLocalDateTime('2026-06-11T02:30 PM')).toBe('2026-06-11T14:30:00'); + expect(normalizeLocalDateTime('2026-06-11T02:30:45 pm')).toBe('2026-06-11T14:30:45'); + }); + + it('touches nothing that has no meridiem on it', () => { + // Strictly additive: a date-only value stays date-only (the calendar + // branches on whether the string contains a 'T' and would turn an all-day + // event into a midnight point), an offset survives, and an unreadable + // shape keeps whatever it meant before. + expect(normalizeLocalDateTime('2026-06-11')).toBe('2026-06-11'); + expect(normalizeLocalDateTime('2026-06-11T13:11:00')).toBe('2026-06-11T13:11:00'); + expect(normalizeLocalDateTime('2026-06-11T13:11:00+02:00')).toBe('2026-06-11T13:11:00+02:00'); + expect(normalizeLocalDateTime('Aug 23 2025 10:00')).toBe('Aug 23 2025 10:00'); + expect(normalizeLocalDateTime('')).toBe(''); + }); +}); diff --git a/shared/src/datetime/datetime-normalize.ts b/shared/src/datetime/datetime-normalize.ts new file mode 100644 index 000000000..c21aaa1d8 --- /dev/null +++ b/shared/src/datetime/datetime-normalize.ts @@ -0,0 +1,80 @@ +/** + * Twelve-hour clock handling for imported booking documents (#2094). + * + * A confirmation mail prints "01:11 pm". Every downstream consumer in TREK + * expects a naive 24-hour local string, and the mapper used to reach it by + * slicing: `'2026-06-11T02:30 PM'.slice(11, 16)` yields '02:30'. The hour + * survives, the half of the day does not, and the booking silently lands + * twelve hours early. Because the arrival then sorts before the departure, the + * router also rolls it onto the next day, so a three-hour domestic flight is + * rendered as an overnight. + * + * The client has carried the same fix since #1725 (client/src/utils/formatters.ts); + * this is the server half, which was never pulled across. The regex here is + * deliberately looser than the client's: the server also sees 'HH:MM:SS AM' and + * 'a.m.' straight out of a PDF. + * + * Everything here is total: a value that cannot be read comes back unchanged + * rather than becoming null, so no format that works today can start failing. + */ + +const MERIDIEM_CLOCK = /^(\d{1,2})(?::(\d{2}))?(?::\d{2})?\s*([ap])\.?\s?m\.?$/i; + +/** + * '3:00 PM' / '02:30:00 pm' / '3 PM' → '15:00'. + * Returns null when there is no meridiem to resolve, so callers can tell + * "not my format" from "midnight". + */ +export function parseMeridiemClock(value: string | null | undefined): string | null { + const m = MERIDIEM_CLOCK.exec((value || '').trim()); + if (!m) return null; + let hour = Number.parseInt(m[1], 10); + const minute = m[2] ? Number.parseInt(m[2], 10) : 0; + if (!Number.isFinite(hour) || hour > 12) return null; + const isPm = m[3].toLowerCase() === 'p'; + // 12 AM is midnight and 12 PM is noon: the hour does not shift at 12. + if (hour === 12) hour = isPm ? 12 : 0; + else if (isPm) hour += 12; + return `${String(hour).padStart(2, '0')}:${String(Math.min(59, minute)).padStart(2, '0')}`; +} + +/** Split a naive date-time string into its date and its 24-hour clock. */ +export function splitLocalDateTime(value: string | null | undefined): { date: string | null; time: string | null } { + const raw = (value || '').trim(); + if (!raw) return { date: null, time: null }; + + const dateMatch = /^(\d{4}-\d{2}-\d{2})/.exec(raw); + if (!dateMatch) return { date: null, time: null }; + const date = dateMatch[1]; + + const rest = raw.slice(10).replace(/^[T\s]/, '').trim(); + if (!rest) return { date, time: null }; + + const meridiem = parseMeridiemClock(rest); + if (meridiem) return { date, time: meridiem }; + + const clock = /^(\d{2}:\d{2})/.exec(rest); + return { date, time: clock ? clock[1] : null }; +} + +/** + * Return the same naive date-time with a 24-hour clock. Anything the parser + * cannot read comes back byte for byte, which is what keeps this additive: + * a value that is already correct is never rewritten. + */ +export function normalizeLocalDateTime(value: string): string { + if (!value) return value; + const dateMatch = /^(\d{4}-\d{2}-\d{2})/.exec(value.trim()); + if (!dateMatch) return value; + + // Only a printed 12-hour clock is rewritten. Everything else comes back + // untouched, which is what makes this safe to drop into the middle of the + // mapper: a value carrying a UTC offset, a date-only value, or a shape this + // does not know keeps whatever it meant before. + const rest = value.trim().slice(10).replace(/^[T\s]/, '').trim(); + const clock = parseMeridiemClock(rest); + if (!clock) return value; + + const seconds = /^\d{1,2}:\d{2}:(\d{2})/.exec(rest); + return `${dateMatch[1]}T${clock}:${seconds ? seconds[1] : '00'}`; +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 8e1a2976a..775d7d744 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -28,6 +28,7 @@ export * from './todo/todo.schema'; export * from './budget/budget.schema'; export * from './reservation/reservation.schema'; export * from './reservation/ki-reservation.schema'; +export * from './datetime/datetime-normalize'; export * from './airtrail/airtrail.schema'; export * from './day/day.schema'; export * from './day/note-colors';