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
@@ -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'}`;
}