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