/** * Date maths, formatting and parsing for the picker — built on the platform. * * `Date` plus `Intl` covers everything the calendar needs (month lengths, leap * years, locale-aware weekday order and names), so the kit ships no date * library and consumers inherit none. Azerbaijani is the one locale whose * *names* are carried here rather than asked for — see `AZ_MONTHS`. * * Everything here works in **local time**. A picker chooses a day as the user * sees it on a wall calendar; converting to UTC on the way in is what produces * the classic "off by one day" bug for anyone west of Greenwich. */ export type DateGranularity = 'date' | 'month' | 'year'; /** Midnight on the same day — the canonical form for a picked date. */ export const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()); export const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1); export const startOfYear = (date: Date) => new Date(date.getFullYear(), 0, 1); /** * `Date` normalises overflow, so day 0 of the next month is the last day of * this one, and month 13 rolls into next year. That is what makes every * `add*` below safe without a single explicit length or leap-year check. */ export const addDays = (date: Date, days: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + days, date.getHours(), date.getMinutes()); export const addMonths = (date: Date, months: number) => { const target = new Date( date.getFullYear(), date.getMonth() + months, 1, date.getHours(), date.getMinutes() ); /* Clamp the day: 31 January plus one month is 28 February, not 3 March. */ const lastDay = daysInMonth(target.getFullYear(), target.getMonth()); target.setDate(Math.min(date.getDate(), lastDay)); return target; }; export const addYears = (date: Date, years: number) => addMonths(date, years * 12); export const daysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate(); type MaybeDate = Date | null | undefined; export const isSameDay = (a: MaybeDate, b: MaybeDate) => !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); export const isSameMonth = (a: MaybeDate, b: MaybeDate) => !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth(); export const isSameYear = (a: MaybeDate, b: MaybeDate) => !!a && !!b && a.getFullYear() === b.getFullYear(); /** Inclusive at both ends, compared by day. */ export const isWithin = (date: Date, from: MaybeDate, to: MaybeDate) => { if (!from || !to) return false; const day = startOfDay(date).getTime(); const [start, end] = [startOfDay(from).getTime(), startOfDay(to).getTime()].sort((a, b) => a - b); return day >= start && day <= end; }; export const isValid = (date: Date | null | undefined): date is Date => date instanceof Date && !Number.isNaN(date.getTime()); /** * First day of the week for a locale, as a 0–6 index where 0 is Sunday. * * `Intl.Locale.prototype.getWeekInfo` is the standards-track answer but is not * everywhere yet, so an unsupported runtime falls back to Monday — the ISO * default, and right for far more of the world than Sunday. */ export const weekStartOf = (locale: string): number => { try { const info = ( new Intl.Locale(locale) as Intl.Locale & { getWeekInfo?: () => { firstDay: number }; weekInfo?: { firstDay: number }; } ); const firstDay = info.getWeekInfo?.().firstDay ?? info.weekInfo?.firstDay; /* The spec numbers days 1–7 from Monday; JS numbers them 0–6 from Sunday. */ if (firstDay) return firstDay % 7; } catch { /* Malformed locale tag — fall through to the ISO default. */ } return 1; }; /** The Sunday-or-Monday-anchored start of the week `date` falls in. */ export const startOfWeek = (date: Date, weekStart: number) => { const day = startOfDay(date); const shift = (day.getDay() - weekStart + 7) % 7; return addDays(day, -shift); }; /** * The six-week grid a month is drawn on. * * Always 42 cells, never a jagged one: a calendar that changes height as you * page through the year makes the next-month button move under the cursor. */ export const monthGrid = (month: Date, weekStart: number): Date[] => { const first = startOfWeek(startOfMonth(month), weekStart); return Array.from({ length: 42 }, (_, index) => addDays(first, index)); }; /* ── Azerbaijani names ────────────────────────────────────────────────────── The one locale the kit carries data for, because the platform does not. V8's bundled ICU has no `az`, so Chrome resolves `az-AZ` to the CLDR *root* locale and hands back `M01…M12` for months and English abbreviations for weekdays — silently, since `resolvedOptions().locale` still reports `az-AZ`. Node and Firefox ship the full set and are unaffected, which is exactly why the gap survives a test run. These are the CLDR `az` strings verbatim, so a runtime that does have the data sees no difference. */ const AZ_MONTHS = { long: ['yanvar', 'fevral', 'mart', 'aprel', 'may', 'iyun', 'iyul', 'avqust', 'sentyabr', 'oktyabr', 'noyabr', 'dekabr'], short: ['yan', 'fev', 'mar', 'apr', 'may', 'iyn', 'iyl', 'avq', 'sen', 'okt', 'noy', 'dek'], } as const; /** Sunday first, matching `Date.prototype.getDay`. */ const AZ_WEEKDAYS = ['B.', 'B.E.', 'Ç.A.', 'Ç.', 'C.A.', 'C.', 'Ş.'] as const; /** `az-Cyrl-*` is a different script and is left to `Intl`. */ const isAzerbaijani = (locale: string) => { const [language, script] = locale.toLowerCase().split('-'); return language === 'az' && script !== 'cyrl'; }; /** Short weekday names in display order, e.g. `['Mon', 'Tue', …]`. */ export const weekdayNames = (locale: string, weekStart: number) => { /* 2024-01-07 is a Sunday, so adding the index walks a whole week. */ const sunday = new Date(2024, 0, 7); const week = isAzerbaijani(locale) ? AZ_WEEKDAYS : (() => { const format = new Intl.DateTimeFormat(locale, { weekday: 'short' }); return Array.from({ length: 7 }, (_, day) => format.format(addDays(sunday, day))); })(); return Array.from({ length: 7 }, (_, index) => week[(index + weekStart) % 7]); }; export const monthNames = (locale: string, style: 'short' | 'long' = 'short') => { if (isAzerbaijani(locale)) return [...AZ_MONTHS[style]]; const format = new Intl.DateTimeFormat(locale, { month: style }); return Array.from({ length: 12 }, (_, month) => format.format(new Date(2024, month, 1))); }; /* ── Formatting ───────────────────────────────────────────────────────────── A small token formatter rather than `Intl.DateTimeFormat` alone: the input is round-tripped through `parse`, and only a fixed, unambiguous pattern can be parsed back reliably. `Intl` still supplies every localised *name*. */ const pad = (value: number, length = 2) => String(value).padStart(length, '0'); const TOKEN = /yyyy|yy|MMMM|MMM|MM|M|dd|d|HH|H|mm|m|ss|s/g; /** * Formats a date against a token pattern. * * Supported: `yyyy` `yy` `MMMM` `MMM` `MM` `M` `dd` `d` `HH` `H` `mm` `m` `ss` * `s`. Anything else passes through, so separators are yours to choose. */ export const formatDate = (date: Date, pattern: string, locale = 'en-US'): string => { if (!isValid(date)) return ''; return pattern.replace(TOKEN, (token) => { switch (token) { case 'yyyy': return String(date.getFullYear()); case 'yy': return pad(date.getFullYear() % 100); case 'MMMM': return isAzerbaijani(locale) ? AZ_MONTHS.long[date.getMonth()] : new Intl.DateTimeFormat(locale, { month: 'long' }).format(date); case 'MMM': return isAzerbaijani(locale) ? AZ_MONTHS.short[date.getMonth()] : new Intl.DateTimeFormat(locale, { month: 'short' }).format(date); case 'MM': return pad(date.getMonth() + 1); case 'M': return String(date.getMonth() + 1); case 'dd': return pad(date.getDate()); case 'd': return String(date.getDate()); case 'HH': return pad(date.getHours()); case 'H': return String(date.getHours()); case 'mm': return pad(date.getMinutes()); case 'm': return String(date.getMinutes()); case 'ss': return pad(date.getSeconds()); case 's': return String(date.getSeconds()); default: return token; } }); }; /** * Reads a date back out of typed text, against the same pattern. * * Deliberately forgiving about *width* — someone typing `2024-3-7` into a * `yyyy-MM-dd` field means the same thing as `2024-03-07` — but strict about * order, so an ambiguous `03/04/2024` is never silently guessed at. * * Returns `null` when the text does not describe a real date, which includes * calendar-impossible ones like 31 February. */ export const parseDate = (text: string, pattern: string, locale = 'en-US'): Date | null => { const trimmed = text.trim(); if (!trimmed) return null; const order: string[] = []; /* Build a regex from the pattern, capturing each token in order. */ const source = pattern .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') .replace(TOKEN, (token) => { order.push(token); switch (token) { case 'yyyy': return '(\\d{4})'; case 'yy': return '(\\d{2})'; case 'MMMM': case 'MMM': return '([\\p{L}.]+)'; default: return '(\\d{1,2})'; } }); const match = new RegExp(`^${source}$`, 'iu').exec(trimmed); if (!match) return null; const now = new Date(); let year = now.getFullYear(); let month = 0; let day = 1; let hours = 0; let minutes = 0; let seconds = 0; const namedMonths = { long: monthNames(locale, 'long').map((name) => name.toLowerCase()), short: monthNames(locale, 'short').map((name) => name.toLowerCase()), }; for (const [index, token] of order.entries()) { const raw = match[index + 1]; switch (token) { case 'yyyy': year = Number(raw); break; case 'yy': /* Two digits are this century; a picker rarely means 1924. */ year = 2000 + Number(raw); break; case 'MMMM': case 'MMM': { const needle = raw.toLowerCase().replace(/\.$/, ''); const at = namedMonths.long.indexOf(needle); const shortAt = namedMonths.short.map((name) => name.replace(/\.$/, '')).indexOf(needle); if (at === -1 && shortAt === -1) return null; month = at === -1 ? shortAt : at; break; } case 'MM': case 'M': month = Number(raw) - 1; break; case 'dd': case 'd': day = Number(raw); break; case 'HH': case 'H': hours = Number(raw); break; case 'mm': case 'm': minutes = Number(raw); break; case 'ss': case 's': seconds = Number(raw); break; default: break; } } if (month < 0 || month > 11 || hours > 23 || minutes > 59 || seconds > 59) return null; const parsed = new Date(year, month, day, hours, minutes, seconds); /* `Date` would happily turn 31 February into 2 March. Comparing the parts back rejects the dates that never existed. */ if (parsed.getMonth() !== month || parsed.getDate() !== day) return null; return parsed; }; /** The default input pattern for each granularity. */ export const defaultFormat: Record = { date: 'yyyy-MM-dd', month: 'yyyy-MM', year: 'yyyy', };