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