diff --git a/shared/src/datetime/datetime-normalize.ts b/shared/src/datetime/datetime-normalize.ts index c21aaa1d8..a292cd90a 100644 --- a/shared/src/datetime/datetime-normalize.ts +++ b/shared/src/datetime/datetime-normalize.ts @@ -19,6 +19,7 @@ */ 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'. @@ -27,34 +28,40 @@ const MERIDIEM_CLOCK = /^(\d{1,2})(?::(\d{2}))?(?::\d{2})?\s*([ap])\.?\s?m\.?$/i */ 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; + 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 = m[3].toLowerCase() === 'p'; + + 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(); - if (!raw) return { date: null, time: null }; + const date = LEADING_DATE.exec(raw)?.[1]; + if (!date) 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(); + const rest = clockPart(raw); 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 { date, time: /^(\d{2}:\d{2})/.exec(rest)?.[1] ?? null }; } /** @@ -64,17 +71,18 @@ export function splitLocalDateTime(value: string | null | undefined): { date: st */ 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; + 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 = value.trim().slice(10).replace(/^[T\s]/, '').trim(); + const rest = clockPart(trimmed); 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'}`; + const seconds = /^\d{1,2}:\d{2}:(\d{2})/.exec(rest)?.[1] ?? '00'; + return `${date}T${clock}:${seconds}`; }