// Datepicker values (Phase 12.2, D-18 to D-21). The server stores a `date` // as YYYY-MM-DD, a `time` as HH:MM:SS and a `datetime` as an RFC 3339 // instant; the picker edits @internationalized/date values. This module is // the only place that converts between the two, and it never builds a // global Date from a stored string: a date-only value has no time zone, so // the browser's Date would shift it (RESEARCH Pitfall 12). import { CalendarDate, CalendarDateTime, DateFormatter, Time, ZonedDateTime, getDayOfWeek, getLocalTimeZone, parseAbsolute, parseDate, parseTime, toCalendarDateTime, toTimeZone, toZoned, today, } from '@internationalized/date' /** A datepicker field's mode; the server defaults it to datetime. */ export type DateMode = 'date' | 'datetime' | 'time' /** The picker's value: a calendar date, a date and time, or a time of day. */ export type FieldDateValue = CalendarDate | CalendarDateTime | ZonedDateTime | Time /** The mode of a field's `mode` key: date, time, or datetime by default. */ export function dateMode(mode: string | null | undefined): DateMode { return mode === 'date' || mode === 'time' ? mode : 'datetime' } function pad(value: number, length = 2): string { return String(value).padStart(length, '0') } const ZONE_SUFFIX = /(Z|[+-]\d{2}:?\d{2})$/i /** * The picker value of a stored string, or null when it is empty or cannot * be read. A datetime is shown in the browser's time zone; with * ignoreTimezone its UTC wall clock is shown unchanged. */ export function parseFieldValue(mode: DateMode, raw: unknown, ignoreTimezone = false): FieldDateValue | null { if (typeof raw !== 'string' || raw.trim() === '') { return null } const text = raw.trim() try { if (mode === 'date') { return parseDate(text.slice(0, 10)) } if (mode === 'time') { const clock = /(\d{2}:\d{2}(:\d{2})?)/.exec(text.includes('T') ? text.slice(text.indexOf('T')) : text) return clock ? parseTime(clock[1]!) : null } // A datetime without a zone designator is read as UTC, like the server. const absolute = ZONE_SUFFIX.test(text) ? text.replace(' ', 'T') : `${text.replace(' ', 'T')}Z` if (ignoreTimezone) { return toCalendarDateTime(parseAbsolute(absolute, 'UTC')) } return parseAbsolute(absolute, getLocalTimeZone()) } catch { return null } } function dateText(value: { year: number; month: number; day: number }): string { return `${pad(value.year, 4)}-${pad(value.month)}-${pad(value.day)}` } function timeText(value: { hour: number; minute: number; second: number }): string { return `${pad(value.hour)}:${pad(value.minute)}:${pad(value.second)}` } /** * The stored string of a picker value: YYYY-MM-DD for a date, HH:MM:SS for * a time, an RFC 3339 UTC instant for a datetime, and with ignoreTimezone * the wall clock unchanged as YYYY-MM-DDTHH:MM:SSZ. An empty value is null. */ export function emitFieldValue( mode: DateMode, value: FieldDateValue | null | undefined, ignoreTimezone = false, ): string | null { if (!value) { return null } if (mode === 'time') { return 'hour' in value ? timeText(value) : null } if (!('year' in value)) { return null } if (mode === 'date') { return dateText(value) } if (ignoreTimezone) { const wall = value instanceof ZonedDateTime ? toCalendarDateTime(value) : value const clock = 'hour' in wall ? timeText(wall) : '00:00:00' return `${dateText(wall)}T${clock}Z` } const zoned = value instanceof ZonedDateTime ? value : toZoned(value instanceof CalendarDate ? toCalendarDateTime(value) : value, getLocalTimeZone()) const utc = toTimeZone(zoned, 'UTC') return `${dateText(utc)}T${timeText(utc)}Z` } /** The default display format of each mode (the list's datetime cell shapes). */ export const DEFAULT_DISPLAY: Record = { date: 'YYYY-MM-DD', datetime: 'YYYY-MM-DD HH:mm', time: 'HH:mm', } const TOKENS = /\[([^\]]*)\]|YYYY|YY|MMMM|MMM|MM|M|dddd|ddd|DD|D|HH|H|hh|h|mm|ss|A|a/g function calendarDay(value: FieldDateValue): CalendarDate | null { return 'year' in value ? new CalendarDate(value.year, value.month, value.day) : null } function namePart( locale: string, date: CalendarDate, options: Intl.DateTimeFormatOptions, part: Intl.DateTimeFormatPartTypes, ): string { // The formatter sees the calendar day at UTC midnight and formats it in // UTC, so no zone can move it to another day. const formatter = new DateFormatter(locale, { ...options, timeZone: 'UTC' }) return formatter.formatToParts(date.toDate('UTC')).find((item) => item.type === part)?.value ?? '' } /** * A picker value as text in the server's displayFormat (moment-style tokens * DD D MM M MMM MMMM ddd dddd YYYY YY HH H hh h mm ss A a; [text] is * literal), or in the mode's default format. Day and month names follow the * admin locale. Empty values give an empty string. */ export function formatDisplay( value: FieldDateValue | null | undefined, mode: DateMode, displayFormat: string | null | undefined, locale: string, ): string { if (!value) { return '' } const format = displayFormat || DEFAULT_DISPLAY[mode] const day = calendarDay(value) const clock = 'hour' in value ? value : null // A month name next to a day number takes the form a date uses (Polish // genitive, "2 października"); alone it is the standalone name. const withDay = /D/.test(format.replace(/\[[^\]]*\]/g, '')) return format.replace(TOKENS, (token: string, literal: string | undefined) => { if (literal !== undefined) { return literal } switch (token) { case 'YYYY': return day ? pad(day.year, 4) : '' case 'YY': return day ? pad(day.year % 100) : '' case 'MMMM': case 'MMM': { if (!day) { return '' } const month = token === 'MMMM' ? 'long' : 'short' return withDay ? namePart(locale, day, { day: 'numeric', month }, 'month') : namePart(locale, day, { month }, 'month') } case 'MM': return day ? pad(day.month) : '' case 'M': return day ? String(day.month) : '' case 'dddd': case 'ddd': return day ? namePart(locale, day, { weekday: token === 'dddd' ? 'long' : 'short' }, 'weekday') : '' case 'DD': return day ? pad(day.day) : '' case 'D': return day ? String(day.day) : '' case 'HH': return clock ? pad(clock.hour) : '' case 'H': return clock ? String(clock.hour) : '' case 'hh': return clock ? pad(clock.hour % 12 || 12) : '' case 'h': return clock ? String(clock.hour % 12 || 12) : '' case 'mm': return clock ? pad(clock.minute) : '' case 'ss': return clock ? pad(clock.second) : '' case 'A': return clock ? (clock.hour < 12 ? 'AM' : 'PM') : '' case 'a': return clock ? (clock.hour < 12 ? 'am' : 'pm') : '' default: return token } }) } /** A known Sunday: its weekday index in a locale gives that locale's first day. */ const SUNDAY = new CalendarDate(2026, 10, 4) /** * The calendar's first day of the week, 0 (Sunday) to 6: the field's * firstDay when set, else the locale's own (Monday for pl). */ export function weekStart(locale: string, firstDay?: number | null): 0 | 1 | 2 | 3 | 4 | 5 | 6 { if (typeof firstDay === 'number' && Number.isInteger(firstDay) && firstDay >= 0 && firstDay <= 6) { return firstDay as 0 | 1 | 2 | 3 | 4 | 5 | 6 } try { // getDayOfWeek counts from the locale's first day, so Sunday's index // tells how far that first day is from Sunday. return ((7 - getDayOfWeek(SUNDAY, locale)) % 7) as 0 | 1 | 2 | 3 | 4 | 5 | 6 } catch { return 1 } } /** The calendar bounds of a field, as plain calendar days. */ export interface DayBounds { min?: CalendarDate max?: CalendarDate } function boundDay(text: string | null | undefined): CalendarDate | undefined { if (!text) { return undefined } try { return parseDate(text.slice(0, 10)) } catch { return undefined } } /** * The selectable days of a field: minDate and maxDate when either is set, * else the yearRange around today ([n] years either side, or [from, to]). * The server checks minDate and maxDate again on save. */ export function dayBounds( field: { minDate?: string; maxDate?: string; yearRange?: number[] }, now: CalendarDate = today(getLocalTimeZone()), ): DayBounds { const min = boundDay(field.minDate) const max = boundDay(field.maxDate) if (min || max) { return { min, max } } const range = field.yearRange ?? [] if (range.length === 1 && typeof range[0] === 'number') { return { min: new CalendarDate(now.year - range[0], 1, 1), max: new CalendarDate(now.year + range[0], 12, 31) } } if (range.length === 2 && typeof range[0] === 'number' && typeof range[1] === 'number') { return { min: new CalendarDate(range[0], 1, 1), max: new CalendarDate(range[1], 12, 31) } } return {} } /** * A day bound in the picker's value type: the start of the first allowed * day and the last second of the last one, so a datetime on the bound day * itself stays valid. */ export function boundValue( day: CalendarDate | undefined, mode: DateMode, ignoreTimezone: boolean, end: boolean, ): CalendarDate | CalendarDateTime | ZonedDateTime | undefined { if (!day || mode === 'time') { return undefined } if (mode === 'date') { return day } const wall = new CalendarDateTime(day.year, day.month, day.day, end ? 23 : 0, end ? 59 : 0, end ? 59 : 0) if (ignoreTimezone) { return wall } // The server checks the saved instant's UTC calendar date. Build the // bound as a UTC instant so a local midnight just after the UTC day // change is refused, and a local evening still on that UTC day is allowed. return toTimeZone(toZoned(wall, 'UTC'), getLocalTimeZone()) } /** * The value a fresh picker starts from (segments show placeholders): today * at midnight in the picker's value type. */ export function startValue(mode: DateMode, ignoreTimezone: boolean): CalendarDate | CalendarDateTime | ZonedDateTime { const day = today(getLocalTimeZone()) return boundValue(day, mode === 'time' ? 'datetime' : mode, ignoreTimezone, false) ?? day }