const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; // Parse a FHIR date/datetime string. Date-only values (FHIR `date`, e.g. birthDate "1967-11-20") // are parsed as LOCAL midnight so they render on the same calendar day in any timezone - // `new Date("YYYY-MM-DD")` parses as UTC and shifts a day west of UTC. Datetime strings (with a // time and/or zone) keep their explicit instant. const parseFhirDate = (dateString: string): Date => { const match = DATE_ONLY_PATTERN.exec(dateString); if (match) { return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); } return new Date(dateString); }; export const formatDate = (dateString: string | undefined): string => { if (!dateString) return ''; try { const date = parseFhirDate(dateString); return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric' }).format(date); } catch (error) { console.error('Error formatting date:', error); return dateString; } }; /** * Month and day, no year - "Jul 14". * * For lists where every entry is recent and the year is the same on all of them, so printing it four * times a row is noise the reader has to skip. Shares `parseFhirDate` with `formatDate` deliberately: * the date-only-is-local-midnight rule above is easy to get wrong twice, and a second copy is how one * surface ends up a day out from another. */ export const formatShortDate = (dateString: string | undefined): string => { if (!dateString) return ''; try { return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', }).format(parseFhirDate(dateString)); } catch (error) { console.error('Error formatting date:', error); return dateString; } }; /** The month written out - "March 4, 1978". A value that is not a date is shown as stored. */ export const formatLongDate = (dateString: string | undefined): string => { if (!dateString) return ''; const date = parseFhirDate(dateString); return Number.isNaN(date.getTime()) ? dateString : new Intl.DateTimeFormat('en-US', { month: 'long', day: 'numeric', year: 'numeric' }).format(date); }; /** Month and year - "March 2026". An invalid date gives '' rather than "Invalid Date". */ export const formatMonthYear = (dateString: string | undefined): string => { if (!dateString) return ''; const date = parseFhirDate(dateString); return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(date); }; /** The time of day with its zone - "9:00 AM CDT". A value that is not a date is shown as stored. */ export const formatTimeOfDay = (dateString: string | undefined): string => { if (!dateString) return ''; const date = parseFhirDate(dateString); return Number.isNaN(date.getTime()) ? dateString : new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }).format(date); }; export const formatDateTime =(dateString: string | undefined): string => { if (!dateString) return ''; try { const date = parseFhirDate(dateString); return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }).format(date); } catch (error) { console.error('Error formatting date:', error); return dateString; } }; export const calculateAge = (birthDate: string): number | null => { if (!birthDate) return null; try { const birth = parseFhirDate(birthDate); const today = new Date(); let age = today.getFullYear() - birth.getFullYear(); const monthDiff = today.getMonth() - birth.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) { age--; } return age; } catch (error) { console.error('Error calculating age:', error); return null; } }; /** * How an age is written when it stands on its own. * * Spencer, reviewing the receptionist decks 2026-08-25: *"the same fact is spelled two ways, one * click apart"* - the patients list said `47 years old` and the quick view one click into it said * `42 yrs`. * * THE DEFECT WAS NOT THE WORDING, IT WAS THAT NOTHING DECIDED IT. Three surfaces each built the * string themselves, so there was no place where a reader could see that a choice had been made, and * no way for the next surface to inherit it. A shared function is the fix; which wording it holds is * almost incidental. * * `years old` over `yrs` because it is what two of the three already said, and because a subline * reading `47 yrs - Male - (319) 555-0142` is already dense with abbreviation - the six characters buy * nothing a receptionist scanning a list needs. * * Not for the parenthetical after a birth date, which is a different context and is ALSO spelled two * ways today (`13 Aug 1979 (47)` on the profile card, `(47 years)` on the patient info card). That pair * wants settling too; it is not this one. */ export const patientAgeLabel = (age: number | null | undefined): string | null => age == null ? null : `${age} years old`; export const toLocalDateTimeInput = (date: Date): string => { const pad = (n: number) => String(n).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; }; const WRITTEN_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-](\d{2}):(\d{2}))?$/; /** A written date and time read as written, or undefined unless it is a real one: `new Date` moves 30 February to 2 March. */ const writtenTime = (value: string) => { const parts = WRITTEN_DATE_TIME.exec(value); if (!parts) return undefined; const [year, month, day, hour, minute, second] = parts.slice(1, 7).map((part) => Number(part ?? 0)); const millisecond = Number((parts[7] ?? '').slice(0, 3).padEnd(3, '0')); const [offsetHour, offsetMinute] = [parts[9], parts[10]].map((part) => Number(part ?? 0)); const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate(); const real = month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth && hour <= 23 && minute <= 59 && second <= 59 && offsetHour <= 14 && offsetMinute <= 59; return real ? { year, month, day, hour, minute, second, millisecond, zoned: parts[8] !== undefined } : undefined; }; /** The moments a local time names here: none in the hour the clocks skip, two in the hour they repeat. */ export const instantsAtWallTime = (wall: string): Date[] => { const at = writtenTime(wall); if (!at || at.zoned) return []; const guess = new Date(at.year, at.month - 1, at.day, at.hour, at.minute, at.second, at.millisecond).getTime(); const guessedOffset = new Date(guess).getTimezoneOffset(); // Nearby offsets include clock changes shorter than an hour. const offsets = new Set([-86_400_000, 0, 86_400_000].map((shift) => new Date(guess + shift).getTimezoneOffset())); return [...offsets] .map((offset) => new Date(guess + (offset - guessedOffset) * 60_000)) .filter((moment) => moment.getFullYear() === at.year && moment.getMonth() === at.month - 1 && moment.getDate() === at.day && moment.getHours() === at.hour && moment.getMinutes() === at.minute) .sort((a, b) => a.getTime() - b.getTime()); }; /** A time the field can file: a real zoned instant, or a local time that names exactly one moment here. */ export const isFileableTime = (value: unknown): value is string => { if (typeof value !== 'string') return false; const at = writtenTime(value); if (!at) return false; return at.zoned || instantsAtWallTime(value).length === 1; }; /** What the box shows for a held time: a zoned instant at its local time here, seconds and fraction kept. */ export const wallTimeOf = (value: unknown): string => { if (typeof value !== 'string') return ''; const at = writtenTime(value); if (!at) return ''; if (!at.zoned) return value; const moment = new Date(value); const seconds = moment.getSeconds(); const fraction = moment.getMilliseconds(); return toLocalDateTimeInput(moment) + (seconds || fraction ? `:${String(seconds).padStart(2, '0')}` : '') + (fraction ? `.${String(fraction).padStart(3, '0')}` : ''); }; /** * A time the note gave, as the field holds it: a zoned instant exactly as written, or a local time as written. * A day with no time, an impossible date, or words is '' - not a time, and not now. */ export const noteTimeValue = (value: unknown): string => { if (typeof value !== 'string') return ''; const written = value.trim(); return writtenTime(written) ? written : ''; }; const WRITTEN_DAY = /^(\d{4})-(\d{2})-(\d{2})$/; /** A written day, read as written, when it is a real one: 30 February is not. */ export const isRealDay = (value: unknown): value is string => { if (typeof value !== 'string') return false; const parts = WRITTEN_DAY.exec(value); if (!parts) return false; const [year, month, day] = parts.slice(1, 4).map(Number); return month >= 1 && month <= 12 && day >= 1 && day <= new Date(Date.UTC(year, month, 0)).getUTCDate(); }; /** A record's own date the field can file: a real day as given, or a date and time that names one moment. */ export const isFileableDate = (value: unknown): value is string => isRealDay(value) || isFileableTime(value); /** * A record's date the note gave, as the field holds it: a real day, a zoned instant or a local time, each as written. * An impossible date or words are '' - owed, never moved to another day and never midnight or now. */ export const noteDateValue = (value: unknown): string => { if (typeof value !== 'string') return ''; const written = value.trim(); return isRealDay(written) || writtenTime(written) ? written : ''; }; /** A moment's offset from UTC here, as `UTC-05:00`. */ export const utcOffsetLabel = (moment: Date): string => { const minutes = -moment.getTimezoneOffset(); const pad = (n: number) => String(n).padStart(2, '0'); return `UTC${minutes < 0 ? '-' : '+'}${pad(Math.floor(Math.abs(minutes) / 60))}:${pad(Math.abs(minutes) % 60)}`; }; /** * The day, then the month written out, then the year - "5 September 2026". Hardcodes en-GB's * ordering: 'en-US' renders month first for the same options, and the practice's setup, BAA and * account surfaces settled on the deck's day-first reading instead. */ export const formatDayMonthYear = (dateString: string | undefined): string => { if (!dateString) return ''; const date = parseFhirDate(dateString); return Number.isNaN(date.getTime()) ? dateString : new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }).format(date); }; export const formatDateRange = (startDate?: string, endDate?: string): string => { if (!startDate && !endDate) return ''; const formattedStart = startDate ? formatDate(startDate) : ''; const formattedEnd = endDate ? formatDate(endDate) : ''; if (formattedStart && formattedEnd) { return `${formattedStart} - ${formattedEnd}`; } if (formattedStart) { return `From ${formattedStart}`; } return `Until ${formattedEnd}`; };