fix(booking-import): read a printed 12-hour clock instead of slicing it (#2094)

splitIso reached the time by taking characters 11..16, so "2026-06-11T01:11 PM"
became 01:11. The hour survived and the half of the day did not, which is why
AM and 24-hour times in the same document imported correctly. The arrival then
sorted before the departure and the router rolled it onto the next day, so a
three-hour hop was rendered as an overnight.

The client has done this properly since #1725; this is the server half, which
was never pulled across. normalizeLocalDateTime rewrites a value only when it
actually finds a meridiem, so a date-only value, a value carrying an offset and
anything the parser does not know come back byte for byte.

The mapper has only two exits for a date, toIsoString and splitIso, so this
covers all eight types and every emitted field at once. It also stops
sameConnection comparing a NaN timestamp, which silently merged unrelated legs
into one booking.
This commit is contained in:
Maurice
2026-08-28 13:23:23 +02:00
committed by Maurice
parent 15588d20c9
commit 016fdeec64
5 changed files with 202 additions and 3 deletions
@@ -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),
@@ -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);
});
});
@@ -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('');
});
});
+80
View File
@@ -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'}`;
}
+1
View File
@@ -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';