Files
TREK/shared/src/datetime/datetime-normalize.ts
T
Maurice d091f4a815 fix(shared): read the regex groups defensively in the datetime helper
shared compiles with noUncheckedIndexedAccess, so a capture group is
string | undefined there even when the pattern guarantees it. Pull the groups
through optional access with explicit fallbacks instead of indexing into the
match, which also drops the two throwaway locals.

No behaviour change: same inputs, same outputs, the spec is untouched.
2026-08-28 14:25:08 +02:00

89 lines
3.5 KiB
TypeScript

/**
* 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;
const LEADING_DATE = /^(\d{4}-\d{2}-\d{2})/;
/**
* '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());
const rawHour = m?.[1];
const half = m?.[3];
if (!rawHour || !half) return null;
let hour = Number.parseInt(rawHour, 10);
const minute = Number.parseInt(m?.[2] ?? '0', 10);
if (!Number.isFinite(hour) || hour > 12) return null;
const isPm = half.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')}`;
}
/** The part after a leading `YYYY-MM-DD`, with its `T` or space removed. */
function clockPart(value: string): string {
return value.slice(10).replace(/^[T\s]/, '').trim();
}
/** 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();
const date = LEADING_DATE.exec(raw)?.[1];
if (!date) return { date: null, time: null };
const rest = clockPart(raw);
if (!rest) return { date, time: null };
const meridiem = parseMeridiemClock(rest);
if (meridiem) return { date, time: meridiem };
return { date, time: /^(\d{2}:\d{2})/.exec(rest)?.[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 trimmed = value.trim();
const date = LEADING_DATE.exec(trimmed)?.[1];
if (!date) 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 = clockPart(trimmed);
const clock = parseMeridiemClock(rest);
if (!clock) return value;
const seconds = /^\d{1,2}:\d{2}:(\d{2})/.exec(rest)?.[1] ?? '00';
return `${date}T${clock}:${seconds}`;
}