/**
* A calendar date, and deliberately not an instant.
*
* ### The bug this file exists to make impossible
*
* A due date is not a moment. "This is due on the 19th" is true in
* Denver and true in Tokyo, and it does not become the 18th because
* a laptop crossed a meridian. The classic date-picker defect is
* that the value is a `Date` — which is an instant, a count of
* milliseconds — and every read of it goes through the *browser's*
* timezone. Pick the 19th at 23:30 in Denver, store
* `new Date("2026-08-19").toISOString()`, and the server, the next
* device, and the same device after a flight all disagree about
* which day you meant. It is a one-day error, it only appears near
* midnight or after travel, and it is invisible in every test
* written at noon.
*
* So the value here is a **plain date**: a year, a month, and a day,
* with no time, no offset, and no zone. It is stored as an ISO
* `YYYY-MM-DD` string — which is exactly ``'s own
* `value` format, so a consumer can move between this component and
* the native control without a migration.
*
* ### There is no `Date` arithmetic in here at all
*
* Not "carefully-written `Date` arithmetic" — none. Every operation
* below is integer maths on a **day number**: the count of days
* since 1970-01-01, computed with Howard Hinnant's `days_from_civil`
* / `civil_from_days` (public domain, the algorithm C++20's
* `` and Rust's `chrono` both use). Adding a day is `+ 1`.
* That is a proleptic Gregorian calendar with no DST, no leap
* seconds, and no zone, which is what a calendar date actually is.
*
* A `Date` appears in exactly two places, both of them named and
* both of them fenced:
*
* - `getLocalPlainDate`, which is the **one** conversion from an
* instant to a calendar date, and which requires you to say
* which zone's calendar you mean.
* - `formatPlainDate` and its callers, which hand `Intl` a date
* built at UTC midnight and format it with `timeZone: "UTC"`, so
* the formatter cannot shift the day either.
*
* `plainDate.test.ts` re-runs the whole suite under four `TZ`
* values, including UTC+14 and UTC-11, and asserts every answer is
* byte-identical.
*
* ### Why not `Temporal`
*
* `Temporal.PlainDate` is this type, done properly, and it is
* available in the Node this repo builds on. It is not available in
* every browser a published package has to run in, and the polyfill
* is ~50 KB gz — an order of magnitude more than this entire
* component. The functions below are the ~80 lines of it a date
* picker needs. When `Temporal` is baseline, this file becomes a
* thin adapter and the public API — ISO strings — does not change,
* which is the reason the API is ISO strings.
*/
/** Year, month (1-12), day (1-31). No time, no zone. */
export type PlainDate = {
day: number
month: number
year: number
}
/**
* A start and an end, either of which may be absent — because a
* half-picked range is a real state a user spends time in, not an
* error. `start` with a null `end` is "I have clicked once".
*/
export type DateRange = {
end: null | string
start: null | string
}
const MONTH_LENGTHS = [
31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
]
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
export const getIsLeapYear = (year: number): boolean =>
(year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
export const getDaysInMonth = (
year: number,
month: number,
): number =>
month === 2 && getIsLeapYear(year)
? 29
: (MONTH_LENGTHS[month - 1] ?? 30)
export const getIsValidPlainDate = ({
day,
month,
year,
}: PlainDate): boolean =>
Number.isInteger(year) &&
Number.isInteger(month) &&
Number.isInteger(day) &&
year >= 1 &&
year <= 9999 &&
month >= 1 &&
month <= 12 &&
day >= 1 &&
day <= getDaysInMonth(year, month)
/**
* Days since 1970-01-01, by Hinnant's `days_from_civil`.
*
* Every other operation in this file is expressed through this and
* its inverse, which is why "add a month, then add 40 days, then
* ask what weekday it is" cannot drift: there is one representation
* and it is an integer.
*/
export const getDayNumber = ({
day,
month,
year,
}: PlainDate): number => {
const shiftedYear = year - (month <= 2 ? 1 : 0)
const era = Math.floor(shiftedYear / 400)
const yearOfEra = shiftedYear - era * 400
const dayOfYear =
Math.floor(
(153 * (month + (month > 2 ? -3 : 9)) + 2) / 5,
) +
day -
1
const dayOfEra =
yearOfEra * 365 +
Math.floor(yearOfEra / 4) -
Math.floor(yearOfEra / 100) +
dayOfYear
return era * 146097 + dayOfEra - 719468
}
/** The inverse — Hinnant's `civil_from_days`. */
export const getPlainDateFromDayNumber = (
dayNumber: number,
): PlainDate => {
const shifted = dayNumber + 719468
const era = Math.floor(shifted / 146097)
const dayOfEra = shifted - era * 146097
const yearOfEra = Math.floor(
(dayOfEra -
Math.floor(dayOfEra / 1460) +
Math.floor(dayOfEra / 36524) -
Math.floor(dayOfEra / 146096)) /
365,
)
const dayOfYear =
dayOfEra -
(365 * yearOfEra +
Math.floor(yearOfEra / 4) -
Math.floor(yearOfEra / 100))
const monthPrime = Math.floor((5 * dayOfYear + 2) / 153)
const day =
dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1
const month = monthPrime + (monthPrime < 10 ? 3 : -9)
return {
day,
month,
year: yearOfEra + era * 400 + (month <= 2 ? 1 : 0),
}
}
export const addDays = (
date: PlainDate,
days: number,
): PlainDate =>
getPlainDateFromDayNumber(getDayNumber(date) + days)
/**
* Month arithmetic clamps the day rather than overflowing, which is
* the answer every calendar app gives and the one `Date` does not:
* `new Date(2026, 0, 31)` plus a month is **3 March**, because 31
* February rolls forward. One month after 31 January is 28
* February.
*/
export const addMonths = (
date: PlainDate,
months: number,
): PlainDate => {
const monthIndex =
date.year * 12 + (date.month - 1) + months
const year = Math.floor(monthIndex / 12)
const month = monthIndex - year * 12 + 1
return {
day: Math.min(date.day, getDaysInMonth(year, month)),
month,
year,
}
}
export const comparePlainDates = (
first: PlainDate,
second: PlainDate,
): number => getDayNumber(first) - getDayNumber(second)
export const getIsSameDay = (
first: PlainDate,
second: PlainDate,
): boolean => comparePlainDates(first, second) === 0
/**
* Whole days from `from` to `to`. Docket's staleness threshold is
* counted in days, and this is the subtraction that counts it —
* with no hour-of-day and no DST to make "14 days" occasionally mean
* 13.958.
*/
export const getDaysBetween = (
from: PlainDate,
to: PlainDate,
): number => getDayNumber(to) - getDayNumber(from)
export const clampPlainDate = (
date: PlainDate,
minimum?: PlainDate,
maximum?: PlainDate,
): PlainDate => {
if (minimum && comparePlainDates(date, minimum) < 0) {
return minimum
}
if (maximum && comparePlainDates(date, maximum) > 0) {
return maximum
}
return date
}
export const getIsWithinRange = (
date: PlainDate,
minimum?: PlainDate,
maximum?: PlainDate,
): boolean =>
(!minimum || comparePlainDates(date, minimum) >= 0) &&
(!maximum || comparePlainDates(date, maximum) <= 0)
/**
* 0 is Sunday, 6 is Saturday — the numbering `Intl`'s `weekday`
* option and `Date.prototype.getDay` both use, so the two never
* need reconciling at a call site.
*
* Day number 0 (1970-01-01) was a Thursday, which is the `+ 4`.
*/
export const getWeekday = (date: PlainDate): number => {
const dayNumber = getDayNumber(date)
return ((((dayNumber % 7) + 7) % 7) + 4) % 7
}
const pad = (value: number, length: number) =>
String(value).padStart(length, "0")
export const toIsoDate = (date: PlainDate): string =>
`${pad(date.year, 4)}-${pad(date.month, 2)}-${pad(date.day, 2)}`
/**
* `null` rather than a throw, and `null` rather than a "best
* effort" date. A string that is not a calendar date has no nearest
* calendar date, and inventing one is the silent-guess failure this
* component is built to avoid.
*/
export const parseIsoDate = (
isoDate: null | string | undefined,
): null | PlainDate => {
if (!isoDate) {
return null
}
const match = ISO_DATE_PATTERN.exec(isoDate)
if (!match) {
return null
}
const date = {
day: Number(match[3]),
month: Number(match[2]),
year: Number(match[1]),
}
return getIsValidPlainDate(date) ? date : null
}
/**
* A `Date` pinned at UTC midnight, for `Intl` and for nothing else.
*
* `Date.UTC` is not used, because it maps years 0-99 onto 1900-1999
* — a two-digit year silently becomes the twentieth century inside
* the formatter, which is a bug the *parser* takes an explicit
* position on and the formatter has no business re-deciding.
*/
const toUtcInstant = (date: PlainDate) => {
const instant = new Date(0)
instant.setUTCFullYear(
date.year,
date.month - 1,
date.day,
)
instant.setUTCHours(0, 0, 0, 0)
return instant
}
export type PlainDateFormatOptions =
Intl.DateTimeFormatOptions & {
locale?: string
}
/**
* `timeZone: "UTC"` is forced, and it is not configurable.
*
* The instant above is UTC midnight, so formatting it in any other
* zone renders the previous or the next day — which is the same
* one-day error as storing an instant, arriving through the display
* layer instead. There is no legitimate reason to format a calendar
* date in a zone: the date *is* the answer.
*/
export const formatPlainDate = (
date: PlainDate,
{ locale, ...options }: PlainDateFormatOptions = {},
): string =>
new Intl.DateTimeFormat(locale, {
...options,
timeZone: "UTC",
}).format(toUtcInstant(date))
/**
* The one instant-to-calendar-date conversion in the library, and it
* makes you name the zone.
*
* Defaulting `timeZone` to the device's own resolved zone is right
* for "what is today on this screen"; passing one explicitly is
* right for "what is today for this household". Either way the
* question is asked out loud, which is the difference between this
* and `new Date().toISOString().slice(0, 10)` — a line that appears
* in three of the fleet's repos and is wrong for everybody west of
* Greenwich after 17:00.
*/
export const getLocalPlainDate = (
now: Date,
timeZone?: string,
): PlainDate => {
const parts = new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "2-digit",
timeZone,
year: "numeric",
}).formatToParts(now)
const read = (type: string) =>
Number(
parts.find((part) => part.type === type)?.value ??
"0",
)
return {
day: read("day"),
month: read("month"),
year: read("year"),
}
}
/**
* Which weekday a calendar starts on, from the locale rather than
* from a guess.
*
* `Intl.Locale.prototype.getWeekInfo` is the platform's own answer
* (Monday in most of the world, Sunday in the US and Japan, Saturday
* in much of the Middle East) and it is the whole reason this
* component does not ship a `isMondayFirst` boolean. It is not in
* TypeScript's `lib.es*` yet, so the shape is stated here and the
* absence is handled — a runtime without it falls back to Sunday,
* which is `Date.prototype.getDay`'s own origin and therefore the
* least surprising default.
*/
type LocaleWithWeekInfo = {
getWeekInfo?: () => { firstDay?: number }
weekInfo?: { firstDay?: number }
}
export const getFirstDayOfWeek = (
locale?: string,
): number => {
const resolved = new Intl.Locale(
locale ??
new Intl.DateTimeFormat().resolvedOptions().locale,
) as Intl.Locale & LocaleWithWeekInfo
const firstDay =
resolved.getWeekInfo?.().firstDay ??
resolved.weekInfo?.firstDay
// `getWeekInfo` numbers Monday 1 through Sunday 7; this file
// numbers Sunday 0 through Saturday 6.
return firstDay === undefined ? 0 : firstDay % 7
}
/**
* The seven weekday names, rotated so index 0 is the locale's own
* first day. A fixed sample week is formatted rather than a
* hand-written table, so every locale the browser knows is covered
* and nothing here needs translating.
*/
export const getWeekdayNames = ({
firstDayOfWeek,
locale,
weekday,
}: {
firstDayOfWeek: number
locale?: string
weekday: "long" | "narrow" | "short"
}): string[] => {
// 2026-08-16 is a Sunday, so `+ index` walks Sunday to Saturday.
const sundayDayNumber = getDayNumber({
day: 16,
month: 8,
year: 2026,
})
return Array.from({ length: 7 }, (_unused, index) =>
formatPlainDate(
getPlainDateFromDayNumber(
sundayDayNumber + ((firstDayOfWeek + index) % 7),
),
{ locale, weekday },
),
)
}
/** January-first month names, for the parser and the header. */
export const getMonthNames = ({
locale,
month,
}: {
locale?: string
month: "long" | "short"
}): string[] =>
Array.from({ length: 12 }, (_unused, index) =>
formatPlainDate(
{ day: 1, month: index + 1, year: 2026 },
{ locale, month },
),
)