feat(12.2-04): add the datepicker field and stored date and time list cells
- pin @internationalized/date 3.12.4 as a direct admin dependency (approved) - dateFormat.ts parses and emits date, datetime (local display, UTC emit, ignoreTimezone wall clock) and time values without the global Date - DatepickerField on Reka DatePicker and TimeField with locale segments, calendar popover, clear button, min/max and yearRange bounds - list cells of type date and time render the stored string - datepicker lang keys in en and pl; rebuilt boardwalk dist
This commit is contained in:
300
admin/src/app/dateFormat.ts
Normal file
300
admin/src/app/dateFormat.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
// 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<DateMode, string> = {
|
||||
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)
|
||||
return ignoreTimezone ? wall : toZoned(wall, 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
|
||||
}
|
||||
Reference in New Issue
Block a user