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.
This commit is contained in:
Maurice
2026-08-28 13:38:12 +02:00
committed by Maurice
parent 130272cfb0
commit d091f4a815
+25 -17
View File
@@ -19,6 +19,7 @@
*/ */
const MERIDIEM_CLOCK = /^(\d{1,2})(?::(\d{2}))?(?::\d{2})?\s*([ap])\.?\s?m\.?$/i; 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'. * '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 { export function parseMeridiemClock(value: string | null | undefined): string | null {
const m = MERIDIEM_CLOCK.exec((value || '').trim()); const m = MERIDIEM_CLOCK.exec((value || '').trim());
if (!m) return null; const rawHour = m?.[1];
let hour = Number.parseInt(m[1], 10); const half = m?.[3];
const minute = m[2] ? Number.parseInt(m[2], 10) : 0; 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; 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. // 12 AM is midnight and 12 PM is noon: the hour does not shift at 12.
if (hour === 12) hour = isPm ? 12 : 0; if (hour === 12) hour = isPm ? 12 : 0;
else if (isPm) hour += 12; else if (isPm) hour += 12;
return `${String(hour).padStart(2, '0')}:${String(Math.min(59, minute)).padStart(2, '0')}`; 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. */ /** 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 } { export function splitLocalDateTime(value: string | null | undefined): { date: string | null; time: string | null } {
const raw = (value || '').trim(); 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); const rest = clockPart(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 }; if (!rest) return { date, time: null };
const meridiem = parseMeridiemClock(rest); const meridiem = parseMeridiemClock(rest);
if (meridiem) return { date, time: meridiem }; if (meridiem) return { date, time: meridiem };
const clock = /^(\d{2}:\d{2})/.exec(rest); return { date, time: /^(\d{2}:\d{2})/.exec(rest)?.[1] ?? null };
return { date, time: clock ? clock[1] : null };
} }
/** /**
@@ -64,17 +71,18 @@ export function splitLocalDateTime(value: string | null | undefined): { date: st
*/ */
export function normalizeLocalDateTime(value: string): string { export function normalizeLocalDateTime(value: string): string {
if (!value) return value; if (!value) return value;
const dateMatch = /^(\d{4}-\d{2}-\d{2})/.exec(value.trim()); const trimmed = value.trim();
if (!dateMatch) return value; const date = LEADING_DATE.exec(trimmed)?.[1];
if (!date) return value;
// Only a printed 12-hour clock is rewritten. Everything else comes back // 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 // 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 // mapper: a value carrying a UTC offset, a date-only value, or a shape this
// does not know keeps whatever it meant before. // 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); const clock = parseMeridiemClock(rest);
if (!clock) return value; if (!clock) return value;
const seconds = /^\d{1,2}:\d{2}:(\d{2})/.exec(rest); const seconds = /^\d{1,2}:\d{2}:(\d{2})/.exec(rest)?.[1] ?? '00';
return `${dateMatch[1]}T${clock}:${seconds ? seconds[1] : '00'}`; return `${date}T${clock}:${seconds}`;
} }