Foundations
Calendars
A month laid out in weeks as its locale lays them out, and how the keyboard moves a day through it.
MarkdownA calendar shows a month as weeks, so that a date can be found by where it falls: which Friday, how far from today, what the day after a holiday is. It answers what typing a date cannot, and is laid out as its reader's locale lays out a week.
A month, in four languages
The week starts on Sunday in American English and Japanese, on Monday in British English, on Saturday in Egyptian Arabic, where the days also run from the right. Arrow keys move by a day and a week, Home and End to the ends of the week, Page Up and Page Down by a month, with Shift by a year.
Code
"use client";
import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import { DateField } from "@publictheta/ui/components/date-field/date-field.tsx";
import type { DateFieldMessages } from "@publictheta/ui/components/date-field/date-field.tsx";
import { dateOfDay, dayNumber } from "@publictheta/ui/primitives/calendar.ts";
import { localToday } from "@publictheta/ui/primitives/date-type.ts";
import { useId, useState } from "react";
import { Controls } from "../../ui/controls.tsx";
interface Language {
readonly name: string;
readonly label: string;
readonly direction: "ltr" | "rtl";
/** What the calendar says; English when left out. */
readonly messages?: DateFieldMessages;
}
const LANGUAGES = {
"en-US": { name: "English (US)", label: "Due", direction: "ltr" },
"en-GB": { name: "English (UK)", label: "Due", direction: "ltr" },
"ja-JP": {
name: "日本語",
label: "期限",
direction: "ltr",
messages: {
// The field's name comes first in Japanese: the sentence is the translator's to order.
chooseDate: (field) => (field === undefined ? "日付を選択" : `${field}の日付を選択`),
previousMonth: "前の月",
nextMonth: "次の月",
},
},
"ar-EG": {
name: "العربية",
label: "تاريخ الاستحقاق",
direction: "rtl",
messages: {
chooseDate: (field) => (field === undefined ? "اختيار التاريخ" : `اختيار التاريخ، ${field}`),
previousMonth: "الشهر السابق",
nextMonth: "الشهر التالي",
},
},
} satisfies Record<string, Language>;
type Locale = keyof typeof LANGUAGES;
const plus = (date: string, days: number) => dateOfDay(dayNumber(date) + days) ?? date;
export default function Deadline() {
const id = useId();
const [today] = useState(localToday);
const [locale, setLocale] = useState<Locale>("en-US");
const [due, setDue] = useState<string | null>(() => plus(today, 3));
const language: Language = LANGUAGES[locale];
const away = due === null ? null : dayNumber(due) - dayNumber(today);
return (
<div className="w-full">
<Controls>
<RadioGroup label="Language" size="small" value={locale} onValueChange={setLocale}>
{(Object.keys(LANGUAGES) as Locale[]).map((key) => (
<Radio key={key} value={key}>
{LANGUAGES[key].name}
</Radio>
))}
</RadioGroup>
</Controls>
<div
lang={locale}
dir={language.direction}
className="mx-auto grid max-w-xs grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2 text-sm"
>
<label htmlFor={id} className="text-fg-muted">
{language.label}
</label>
<DateField
id={id}
locale={locale}
weekday="short"
minimum={today}
maximum={plus(today, 90)}
value={due}
onValueChange={setDue}
{...(language.messages !== undefined && { messages: language.messages })}
/>
<span />
<output aria-live="polite" className="text-fg-muted">
{away === null ? "—" : new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(away, "day")}
</output>
</div>
</div>
);
}Best practices
Lay the month out in whole weeks. Seven columns, one for each day of the week, under the days' names. The days of the months either side fill out the first and last weeks, set apart from the month's own, and can be chosen like any other: the week a month ends in is still a week.
Keep the grid the same size. Always six weeks, whatever the month, so that the controls around the grid stay where they are from month to month and a second press lands where the first did.
Start the week where the locale does. On Sunday in the United States and Japan, Monday in most of Europe, Saturday in Egypt. Ask the platform; where it cannot say, use the locale's region. Mark the weekend as the locale counts it — Friday and Saturday in Israel — rather than assuming Saturday and Sunday.
Write it in the reader's language. The month and year as the locale orders them — October 2026, 2026年10月 — the days of the week by their short names, and the days of the month in the digits the locale writes. A name with a place for a value, such as the name of the control that opens the calendar, is a sentence the translator orders, not words joined in English order.
Mark today, and the chosen day, differently. The chosen day is filled. Today is marked by more than a colour — heavier, underlined — so it is found without relying on colour, and is never mistaken for the choice.
Show what cannot be chosen, and do not offer it. Days outside the allowed range stay in view, marked unavailable by more than a colour, and nothing chooses them. A control that would lead to a month with no allowed day is unavailable too.
Open where the person is. On the date already chosen; with none, on today, or on the end of the range nearest today.
Move by the calendar's own units. A day, a week, a month, a year. A month too short for the day lands on its last day: 31 January, a month on, is the last day of February. Moving a day or a week past the end of a month goes on into the next, and the month shown follows.
Stop at the ends. A move that would pass the end of the allowed range stops at the end, so the keyboard is never somewhere nothing can be chosen.
Name each day in full. To assistive technology a day is its whole date, weekday included — Tuesday, October 13, 2026 — not a number that means nothing without the title above it and the column it is in.
Announce the month. When the month shown changes, say its name: the title is what tells someone who cannot see the grid that it has turned.
Keys
| Key | Moves to |
|---|---|
| Left Arrow, Right Arrow | The day before, the day after |
| Up Arrow, Down Arrow | The same day a week before, a week after |
| Home, End | The first, the last day of the week |
| Page Up, Page Down | The same day a month before, a month after |
| Shift-Page Up, Shift-Page Down | The same day a year before, a year after |
Where text reads right to left, the days run from the right and Left and Right Arrow are mirrored with them. Home is still the week's first day.
The days are one stop in the Tab sequence: Tab reaches the day in focus, and the arrow keys move within.
Platform considerations
Pointer. A day is at least 32 CSS pixels square; it shows when the pointer is over it and when it is pressed.
Touch. A day is at least 44 CSS pixels square. Where the platform has a date picker of its own, prefer it: it is the one people already know, sized for a finger and read by the platform's assistive technology.
Assistive technology. The days are a grid named by the month and year; the chosen day is selected, today is the current date, and days outside the range are disabled.
Contrast and motion. Under forced colours the chosen day takes the system's highlight colours; nothing moves for those who ask for less motion.
See also Dates, Grid navigation and Date field.
Behaviour
Counting days
Every day from the year 1 to the year 9999 is counted as the platform's calendar counts it
fc.assert(
fc.property(anyYear, anyMonthNumber, fc.integer({ min: 1, max: 28 }), (year, month, day) => {
const date = platformDate(year, month, day);
expect(dayNumber(iso(date))).toBe(date.getTime() / DAY);
expect(dateOfDay(date.getTime() / DAY)).toBe(iso(date));
expect(weekdayOf(iso(date))).toBe(date.getUTCDay());
}),
);The last day of every month is the one the platform's calendar ends it on
fc.assert(
fc.property(anyYear, anyMonthNumber, (year, month) => {
// Day 0 of the next month is the last of this one.
const last = platformDate(year, month + 1, 0);
expect(dateOfDay(dayNumber(iso(last)))).toBe(iso(last));
expect(daysInMonth(year, month)).toBe(last.getUTCDate());
expect(dateOfDay(dayNumber(iso(last)) + 1)?.slice(8) ?? "01").toBe("01");
}),
);There is no day before the year 1 or after the year 9999
expect(dateOfDay(dayNumber("0001-01-01"))).toBe("0001-01-01");
expect(dateOfDay(dayNumber("0001-01-01") - 1)).toBeNull();
expect(dateOfDay(dayNumber("9999-12-31"))).toBe("9999-12-31");
expect(dateOfDay(dayNumber("9999-12-31") + 1)).toBeNull();A month
A month is six weeks of seven days, each day the one after the last
fc.assert(
fc.property(anyMonth, anyFirstDay, (month, firstDay) => {
const weeks = monthWeeks(month, firstDay);
expect(weeks.map((days) => days.length)).toEqual([7, 7, 7, 7, 7, 7]);
const days = weeks.flat().map((date) => dayNumber(date!));
expect(days).toEqual(days.map((_, index) => days[0]! + index));
}),
);Every day of the month is in it exactly once
fc.assert(
fc.property(anyMonth, anyFirstDay, (month, firstDay) => {
const shown = monthWeeks(month, firstDay)
.flat()
.filter((date) => date?.startsWith(month));
const length = platformDate(Number(month.slice(0, 4)), Number(month.slice(5)) + 1, 0).getUTCDate();
expect(shown).toEqual(
Array.from({ length }, (_, index) => `${month}-${String(index + 1).padStart(2, "0")}`),
);
}),
);The first column is the first day of the week, whichever day that is
fc.assert(
fc.property(anyMonth, anyFirstDay, (month, firstDay) => {
for (const days of monthWeeks(month, firstDay)) {
expect(days.map((date) => weekdayOf(date!))).toEqual(
Array.from({ length: 7 }, (_, column) => (firstDay + column) % 7),
);
}
}),
);The days of the months either side fill out the first and last weeks
const weeks = monthWeeks("2026-10", SUNDAY);
expect(weeks[0]).toEqual([
"2026-09-27",
"2026-09-28",
"2026-09-29",
"2026-09-30",
"2026-10-01",
"2026-10-02",
"2026-10-03",
]);
expect(weeks.at(-1)!.at(-1)).toBe("2026-11-07");
// A month that begins on the week's first day begins the grid.
expect(monthWeeks("2026-02", SUNDAY)[0]![0]).toBe("2026-02-01");A month may be given as any date in it
expect(monthWeeks("2026-10-13", MONDAY)).toEqual(monthWeeks("2026-10", MONDAY));Where the years a date can write run out, the days beyond are left blank
// 1 January of the year 1 is a Monday.
expect(monthWeeks("0001-01", SUNDAY)[0]).toEqual([
null,
"0001-01-01",
"0001-01-02",
"0001-01-03",
"0001-01-04",
"0001-01-05",
"0001-01-06",
]);
const last = monthWeeks("9999-12", SUNDAY).flat();
expect(last.findLast((date) => date !== null)).toBe("9999-12-31");
expect(last.at(-1)).toBeNull();The range in view runs from the first day shown to the last
fc.assert(
fc.property(
fc
.tuple(anyYear, anyMonthNumber)
.map(([year, month]) => iso(platformDate(year, month, 1)).slice(0, 7)),
anyFirstDay,
(month, firstDay) => {
const shown = monthWeeks(month, firstDay).flat();
expect(visibleRange(month, firstDay)).toEqual({
start: shown.find((date) => date !== null),
end: shown.findLast((date) => date !== null),
});
},
),
);
expect(visibleRange("2026-10", SUNDAY)).toEqual({ start: "2026-09-27", end: "2026-11-07" });
expect(visibleRange("0001-01", SUNDAY).start).toBe("0001-01-01");
expect(visibleRange("9999-12", SUNDAY).end).toBe("9999-12-31");The week
The week starts on the day it starts on where the locale is written
expect(weekOf("en-US").firstDay).toBe(SUNDAY);
expect(weekOf("ja-JP").firstDay).toBe(SUNDAY);
expect(weekOf("en-GB").firstDay).toBe(MONDAY);
expect(weekOf("de-DE").firstDay).toBe(MONDAY);
expect(weekOf("fr-FR").firstDay).toBe(MONDAY);
expect(weekOf("ar-EG").firstDay).toBe(SATURDAY);The weekend is the locale's: Saturday and Sunday in most, Friday and Saturday in Egypt and Israel
expect(weekOf("en-US").weekend.toSorted()).toEqual([0, 6]);
expect(weekOf("ja-JP").weekend.toSorted()).toEqual([0, 6]);
expect(weekOf("ar-EG").weekend.toSorted()).toEqual([5, 6]);
expect(weekOf("he-IL").weekend.toSorted()).toEqual([5, 6]);
// 16 October 2026 is a Friday.
expect(isWeekend("2026-10-16", weekOf("ar-EG"))).toBe(true);
expect(isWeekend("2026-10-16", weekOf("en-US"))).toBe(false);
expect(isWeekend("2026-10-18", weekOf("en-US"))).toBe(true);A language given without a region is the region it is most written in
expect(weekOf("ja").firstDay).toBe(SUNDAY);
expect(weekOf("de").firstDay).toBe(MONDAY);
expect(withoutEngineWeeks(() => weekOf("ja").firstDay)).toBe(SUNDAY);
expect(withoutEngineWeeks(() => weekOf("de").firstDay)).toBe(MONDAY);In an engine that does not know the week, the answers are the same
// Locales the engines agree on: WebKit starts a Chinese week on Sunday, the others on Monday.
const known: Record<string, readonly [firstDay: number, weekend: readonly number[]]> = {
"en-US": [0, [0, 6]],
"en-GB": [1, [0, 6]],
"de-DE": [1, [0, 6]],
"fr-FR": [1, [0, 6]],
"ru-RU": [1, [0, 6]],
"pt-BR": [0, [0, 6]],
"ja-JP": [0, [0, 6]],
"ko-KR": [0, [0, 6]],
"he-IL": [0, [5, 6]],
"ar-EG": [6, [5, 6]],
"ar-SA": [0, [5, 6]],
};
for (const [locale, [firstDay, weekend]] of Object.entries(known)) {
const table = withoutEngineWeeks(() => weekOf(locale));
expect([locale, table.firstDay, table.weekend.toSorted()]).toEqual([locale, firstDay, weekend]);
const engine = weekOf(locale);
expect([locale, engine.firstDay, engine.weekend.toSorted()]).toEqual([locale, firstDay, weekend]);
}A locale that names its own first day of the week is given it
expect(withoutEngineWeeks(() => weekOf("en-US-u-fw-mon").firstDay)).toBe(MONDAY);
expect(withoutEngineWeeks(() => weekOf("en-GB-u-ca-gregory-fw-sun").firstDay)).toBe(SUNDAY);A locale that is not one is read as English, not failed on
expect(weekOf("en_US")).toEqual(weekOf("en-US"));Names
The days of the week are named in the locale, in the order of its week
expect(weekdayNames("en-US", SUNDAY, "short")).toEqual([
"Sun",
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
]);
expect(weekdayNames("en-GB", MONDAY, "long")).toEqual([
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
]);
expect(weekdayNames("en-US", SUNDAY, "narrow")).toEqual(["S", "M", "T", "W", "T", "F", "S"]);
expect(weekdayNames("ja-JP", SUNDAY, "narrow")).toEqual(["日", "月", "火", "水", "木", "金", "土"]);
expect(weekdayNames("de-DE", MONDAY, "short")[0]).toBe("Mo");A month is titled with its name and year, in the words and the order of the locale
expect(monthTitle("en-US", "2026-10")).toBe("October 2026");
expect(monthTitle("de-DE", "2026-10")).toBe("Oktober 2026");
expect(monthTitle("ja-JP", "2026-10")).toBe("2026年10月");
expect(monthTitle("en-US", "2026-10-13")).toBe("October 2026");A day is named in full, with its day of the week
expect(dayName("en-US", "2026-10-13")).toBe("Tuesday, October 13, 2026");
// Engines differ over a space before the weekday.
expect(dayName("ja-JP", "2026-10-13")).toMatch(/^2026年10月13日 ?火曜日$/);The day of the month is written in the digits the locale writes numbers in
expect(dayText("en-US", "2026-10-03")).toBe("3");
expect(dayText("ja-JP", "2026-10-13")).toBe("13");
expect(dayText("ar-EG-u-nu-arab", "2026-10-13")).toBe("١٣");Every day of every year is named as the platform names it, with no time zone moving it
const platform = new Intl.DateTimeFormat("en-US", {
dateStyle: "full",
timeZone: "UTC",
calendar: "gregory",
});
fc.assert(
fc.property(anyYear, anyMonthNumber, fc.integer({ min: 1, max: 28 }), (year, month, day) => {
const date = platformDate(year, month, day);
expect(dayName("en-US", iso(date))).toBe(platform.format(date));
}),
);Moving
The arrow keys move by a day to either side, and by a week up and down
expect(press("2026-10-13", "ArrowRight")).toBe("2026-10-14");
expect(press("2026-10-13", "ArrowLeft")).toBe("2026-10-12");
expect(press("2026-10-13", "ArrowDown")).toBe("2026-10-20");
expect(press("2026-10-13", "ArrowUp")).toBe("2026-10-06");
// Into the months either side.
expect(press("2026-10-31", "ArrowRight")).toBe("2026-11-01");
expect(press("2026-10-03", "ArrowUp")).toBe("2026-09-26");Left and right are mirrored where text reads right to left; up and down are not
expect(press("2026-10-13", "ArrowLeft", { direction: "rtl" })).toBe("2026-10-14");
expect(press("2026-10-13", "ArrowRight", { direction: "rtl" })).toBe("2026-10-12");
expect(press("2026-10-13", "ArrowDown", { direction: "rtl" })).toBe("2026-10-20");Home and End go to the first and last days of the week, as the locale counts the week
// 13 October 2026 is a Tuesday.
expect(press("2026-10-13", "Home")).toBe("2026-10-11");
expect(press("2026-10-13", "End")).toBe("2026-10-17");
expect(press("2026-10-13", "Home", { extent: week(MONDAY) })).toBe("2026-10-12");
expect(press("2026-10-13", "End", { extent: week(MONDAY) })).toBe("2026-10-18");
expect(press("2026-10-13", "Home", { extent: week(SATURDAY), direction: "rtl" })).toBe("2026-10-10");
// On the end already, it stays.
expect(press("2026-10-11", "Home")).toBe("2026-10-11");
expect(press("2026-10-17", "End")).toBe("2026-10-17");Page Up and Page Down move by a month; with Shift, by a year
expect(press("2026-10-13", "PageDown")).toBe("2026-11-13");
expect(press("2026-10-13", "PageUp")).toBe("2026-09-13");
expect(press("2026-12-13", "PageDown")).toBe("2027-01-13");
expect(press("2026-10-13", "PageDown", { shiftKey: true })).toBe("2027-10-13");
expect(press("2026-10-13", "PageUp", { shiftKey: true })).toBe("2025-10-13");A month too short for the day ends on its last day
expect(press("2026-01-31", "PageDown")).toBe("2026-02-28");
expect(press("2026-03-31", "PageUp")).toBe("2026-02-28");
expect(press("2024-02-29", "PageDown", { shiftKey: true })).toBe("2025-02-28");
expect(press("2023-01-31", "PageDown", { shiftKey: true })).toBe("2024-01-31");Any other key asks for no move
for (const key of ["Enter", " ", "Escape", "Tab", "a"]) expect(press("2026-10-13", key)).toBeNull();A move by days or weeks, and the same move back, returns to the day it left
fc.assert(
fc.property(
anyDate,
fc.constantFrom("day" as const, "week" as const),
fc.integer({ min: -60, max: 60 }),
(date, by, count) => {
const there = moveDate(date, { by, count }, week(SUNDAY));
expect(moveDate(there, { by, count: -count }, week(SUNDAY))).toBe(date);
expect(dayNumber(there) - dayNumber(date)).toBe(count * (by === "week" ? 7 : 1));
},
),
);A move by months or years, and the same move back, returns to the day it left, where no month was too short for it
fc.assert(
fc.property(
anyDate.filter((date) => Number(date.slice(8)) <= 28),
fc.constantFrom("month" as const, "year" as const),
fc.integer({ min: -30, max: 30 }),
(date, by, count) => {
const there = moveDate(date, { by, count }, week(SUNDAY));
expect(there.slice(8)).toBe(date.slice(8));
expect(moveDate(there, { by, count: -count }, week(SUNDAY))).toBe(date);
},
),
);The ends of a week are in it: seven days apart less one, with the day between them
fc.assert(
fc.property(anyDate, anyFirstDay, (date, firstDay) => {
const start = moveDate(date, { to: "weekStart" }, week(firstDay));
const end = moveDate(date, { to: "weekEnd" }, week(firstDay));
expect(weekdayOf(start)).toBe(firstDay);
expect(dayNumber(end) - dayNumber(start)).toBe(6);
expect(start <= date && date <= end).toBe(true);
}),
);A calendar moves a date as a date field steps it
const field = dateType();
fc.assert(
fc.property(anyDate, fc.constantFrom(1 as const, -1 as const), (date, direction) => {
const move = (by: "day" | "week" | "month" | "year") =>
moveDate(date, { by, count: direction }, week(SUNDAY));
expect(move("day")).toBe(field.step(date, { direction, size: "normal" }));
expect(move("week")).toBe(field.step(date, { direction, size: "large" }));
expect(move("month")).toBe(field.step(date, { direction, size: "page" }));
expect(move("year")).toBe(field.step(date, { direction, size: "normal", part: "year" }));
}),
);A range
A date is within a range that has it, ends included; an end not given is no limit
expect(isWithin("2026-10-13", "2026-10-13", "2026-10-13")).toBe(true);
expect(isWithin("2026-10-12", "2026-10-13", "2026-10-20")).toBe(false);
expect(isWithin("2026-10-21", "2026-10-13", "2026-10-20")).toBe(false);
expect(isWithin("0001-01-01", undefined, "2026-10-20")).toBe(true);
expect(isWithin("9999-12-31", "2026-10-13")).toBe(true);
expect(isWithin("2026-10-13")).toBe(true);A date beyond a range is held at the end it is beyond
expect(holdWithin("2026-09-01", "2026-10-13", "2026-10-20")).toBe("2026-10-13");
expect(holdWithin("2026-11-01", "2026-10-13", "2026-10-20")).toBe("2026-10-20");
expect(holdWithin("2026-10-15", "2026-10-13", "2026-10-20")).toBe("2026-10-15");A move past the end of the range stops at the end
const extent = { firstDay: SUNDAY, minimum: "2026-10-05", maximum: "2026-10-20" };
expect(press("2026-10-20", "ArrowRight", { extent })).toBe("2026-10-20");
expect(press("2026-10-15", "ArrowDown", { extent })).toBe("2026-10-20");
expect(press("2026-10-15", "PageDown", { extent })).toBe("2026-10-20");
expect(press("2026-10-06", "Home", { extent })).toBe("2026-10-05");
expect(press("2026-10-06", "PageUp", { shiftKey: true, extent })).toBe("2026-10-05");A move from outside the range lands inside it
const extent = { firstDay: SUNDAY, minimum: "2026-10-05", maximum: "2026-10-20" };
expect(press("2026-12-25", "ArrowRight", { extent })).toBe("2026-10-20");
expect(press("2026-01-01", "ArrowLeft", { extent })).toBe("2026-10-05");A move past the year 1 or the year 9999 stops at its first or last day
expect(press("9999-12-31", "ArrowRight")).toBe("9999-12-31");
expect(press("9999-12-28", "ArrowDown")).toBe("9999-12-31");
expect(press("9999-12-15", "PageDown")).toBe("9999-12-31");
expect(press("9999-06-15", "PageDown", { shiftKey: true })).toBe("9999-12-31");
expect(press("0001-01-01", "ArrowLeft")).toBe("0001-01-01");
expect(press("0001-01-20", "PageUp")).toBe("0001-01-01");
// 1 January of the year 1 is a Monday: its week began before there were dates.
expect(press("0001-01-01", "Home")).toBe("0001-01-01");React
Install
npx shadcn@latest add https://ui.publictheta.com/r/calendar.jsonSource
src/primitives/calendar.ts
/**
* A calendar: the month a date is in, laid out in weeks as its locale lays
* them out, and how the keyboard moves a day through it.
*
* - A month is six whole weeks of ISO dates, starting on the locale's first day
* of the week. The days of the months either side fill the weeks out, so the
* grid is the same size whatever the month and nothing beneath it moves.
* - Arrow keys move by a day and by a week, mirrored where text reads right to
* left; Home and End go to the ends of the week; Page Up and Page Down move
* by a month, and with Shift by a year.
* - A month too short for the day ends on its last day. A move past the end of
* the range, or of the years a date can write, stops at the end.
*
* Days are counted, not constructed: a cell costs integer arithmetic and one
* string, and no `Date` is made anywhere — `Intl` is asked for a name by the
* count of the day.
*/
import { validLocale } from "../lib/valid-locale.ts";
import { gridMoveForKey } from "./grid-navigation.ts";
import { civilFromDay, dayFromCivil, daysInMonth, FIRST_DAY, LAST_DAY } from "../algorithms/civil-days.ts";
import type { Direction } from "./navigation.ts";
export const FIRST_DATE = "0001-01-01";
export const LAST_DATE = "9999-12-31";
const DAY = 86_400_000;
// Written into by `dateOfDay`: one array for every call.
const civilParts = [0, 0, 0] as [number, number, number];
const pad = (value: number, length: number) => String(value).padStart(length, "0");
function toISO(year: number, month: number, day: number): string {
return `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`;
}
const yearOf = (date: string) => Number(date.slice(0, 4));
const monthNumberOf = (date: string) => Number(date.slice(5, 7));
/** The count of days a date is from 1 January 1970. */
export function dayNumber(date: string): number {
return dayFromCivil(yearOf(date), monthNumberOf(date), Number(date.slice(8, 10)));
}
/** The date so many days from 1 January 1970, or null beyond the years a date can write. */
export function dateOfDay(days: number): string | null {
if (days < FIRST_DAY || days > LAST_DAY) return null;
civilFromDay(days, civilParts);
return toISO(civilParts[0], civilParts[1], civilParts[2]);
}
/** The day of the week, counted from Sunday as 0. */
export function weekdayOf(date: string): number {
// 1 January 1970 was a Thursday.
return (((dayNumber(date) + 4) % 7) + 7) % 7;
}
export { daysInMonth };
/** The month a date is in, as `2026-10`. */
export function monthOf(date: string): string {
return date.slice(0, 7);
}
export interface Week {
/** The day a week starts on, counted from Sunday as 0. */
readonly firstDay: number;
/** The days that are the weekend, counted the same way. */
readonly weekend: readonly number[];
}
const WEEKDAY_CODES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
// Where the engine cannot say — Firefox — the regions whose week does not start
// on Monday, and those whose weekend is not Saturday and Sunday, as CLDR has them.
const FIRST_DAY_BY_REGION: ReadonlyArray<readonly [day: number, regions: string]> = [
[5, "MV"],
[6, "AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY"],
[
0,
"AG AS BD BR BS BT BW BZ CA CO DM DO ET GT GU HK HN ID IL IN IS JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW",
],
];
const WEEKEND_BY_REGION: ReadonlyArray<readonly [days: readonly number[], regions: string]> = [
[[5], "IR"],
[[0], "IN UG"],
[[4, 5], "AF"],
[[5, 6], "BH DZ EG IL IQ JO KW LY OM QA SA SD SY YE"],
];
function byRegion<T>(table: ReadonlyArray<readonly [T, string]>, region: string, otherwise: T): T {
return table.find(([, regions]) => regions.split(" ").includes(region))?.[0] ?? otherwise;
}
interface EngineWeek {
readonly firstDay: number;
readonly weekend: readonly number[];
}
/** The first day of the week and the weekend where a locale is written, from the engine where it knows. */
export function weekOf(locale: string): Week {
const tag = new Intl.Locale(validLocale(locale));
// A method in the standard, a property in the engines that came before it.
const asked = tag as Intl.Locale & { getWeekInfo?: () => EngineWeek; weekInfo?: EngineWeek };
const known = typeof asked.getWeekInfo === "function" ? asked.getWeekInfo() : asked.weekInfo;
// The engine counts Monday as 1 through Sunday as 7.
if (known !== undefined)
return { firstDay: known.firstDay % 7, weekend: known.weekend.map((day) => day % 7) };
const region = tag.maximize().region ?? "";
// A locale may ask for its own first day: `en-US-u-fw-mon`.
const chosen = WEEKDAY_CODES.indexOf(/-u(?:-[a-z\d]+)*?-fw-([a-z]{3})\b/i.exec(tag.toString())?.[1] ?? "");
return {
firstDay: chosen >= 0 ? chosen : byRegion(FIRST_DAY_BY_REGION, region, 1),
weekend: byRegion(WEEKEND_BY_REGION, region, [6, 0]),
};
}
export function isWeekend(date: string, week: Week): boolean {
return week.weekend.includes(weekdayOf(date));
}
export const WEEKS = 6;
/** The first cell of a month's grid, as a count of days. */
function gridStart(month: string, firstDay: number): number {
const first = dayFromCivil(yearOf(month), monthNumberOf(month), 1);
return first - ((((first + 4 - firstDay) % 7) + 7) % 7);
}
/**
* A month as six weeks of seven dates, the first column the week's first day.
* `month` is `2026-10`, or any date in it. A cell beyond the years a date can
* write is null.
*/
export function monthWeeks(month: string, firstDay: number): ReadonlyArray<ReadonlyArray<string | null>> {
const start = gridStart(month, firstDay);
return Array.from({ length: WEEKS }, (_, week) =>
Array.from({ length: 7 }, (__, day) => dateOfDay(start + week * 7 + day)),
);
}
/** The first and last dates a month's grid shows. */
export function visibleRange(
month: string,
firstDay: number,
): { readonly start: string; readonly end: string } {
const start = gridStart(month, firstDay);
return {
start: dateOfDay(Math.max(start, FIRST_DAY))!,
end: dateOfDay(Math.min(start + WEEKS * 7 - 1, LAST_DAY))!,
};
}
export function isWithin(date: string, minimum?: string, maximum?: string): boolean {
return (minimum === undefined || date >= minimum) && (maximum === undefined || date <= maximum);
}
/** The date, or the end of the range it is beyond. */
export function holdWithin(date: string, minimum: string = FIRST_DATE, maximum: string = LAST_DATE): string {
// Where the range is empty, its start is as good as any.
return date < minimum ? minimum : date > maximum ? maximum : date;
}
export type CalendarMove =
| { readonly by: "day" | "week" | "month" | "year"; readonly count: number }
| { readonly to: "weekStart" | "weekEnd" };
export interface CalendarKey {
readonly key: string;
readonly shiftKey: boolean;
}
/** The move a key asks for in a calendar, or null. */
export function calendarMoveForKey(
{ key, shiftKey }: CalendarKey,
direction: Direction,
): CalendarMove | null {
// A calendar is a grid whose columns are days and whose rows are weeks.
const move = gridMoveForKey({ key, command: false }, direction);
switch (move?.kind) {
case "step":
return move.columns === 0 ? { by: "week", count: move.rows } : { by: "day", count: move.columns };
case "edge":
return { to: move.columns < 0 ? "weekStart" : "weekEnd" };
case "page":
return { by: shiftKey ? "year" : "month", count: move.pages };
default:
return null;
}
}
export interface CalendarExtent {
readonly firstDay: number;
readonly minimum?: string | undefined;
readonly maximum?: string | undefined;
}
function moved(date: string, move: CalendarMove, firstDay: number): string | null {
if ("to" in move) {
const into = (weekdayOf(date) - firstDay + 7) % 7;
return dateOfDay(dayNumber(date) + (move.to === "weekStart" ? -into : 6 - into));
}
if (move.by === "day" || move.by === "week") {
return dateOfDay(dayNumber(date) + move.count * (move.by === "week" ? 7 : 1));
}
const index = yearOf(date) * 12 + (monthNumberOf(date) - 1) + move.count * (move.by === "year" ? 12 : 1);
const year = Math.floor(index / 12);
const month = index - year * 12 + 1;
if (year < 1 || year > 9999) return null;
return toISO(year, month, Math.min(Number(date.slice(8, 10)), daysInMonth(year, month)));
}
/** Where a move from a date lands, held within the range and within the years a date can write. */
export function moveDate(
date: string,
move: CalendarMove,
{ firstDay, minimum, maximum }: CalendarExtent,
): string {
const forward = "to" in move ? move.to === "weekEnd" : move.count > 0;
return holdWithin(moved(date, move, firstDay) ?? (forward ? LAST_DATE : FIRST_DATE), minimum, maximum);
}
export type NameWidth = "narrow" | "short" | "long";
interface Names {
readonly locale: string;
readonly title: Intl.DateTimeFormat;
readonly label: Intl.DateTimeFormat;
/** The days of a month as the locale writes numbers, from 1. */
readonly days: readonly string[];
/** Sunday first, by width, gathered when first asked for. */
readonly weekdays: Partial<Record<NameWidth, readonly string[]>>;
}
const BASE = { timeZone: "UTC", calendar: "gregory" } as const;
// By the locale as given, so that a cell asking for its name pays for one lookup.
const names = new Map<string, Names>();
function namesOf(given: string): Names {
let made = names.get(given);
if (made === undefined) {
const locale = validLocale(given);
const numbers = new Intl.NumberFormat(locale, { useGrouping: false });
made = {
locale,
title: new Intl.DateTimeFormat(locale, { ...BASE, year: "numeric", month: "long" }),
label: new Intl.DateTimeFormat(locale, { ...BASE, dateStyle: "full" }),
days: Array.from({ length: 31 }, (_, index) => numbers.format(index + 1)),
weekdays: {},
};
names.set(given, made);
}
return made;
}
/** The seven days' names in the locale, the week's first day first. */
export function weekdayNames(locale: string, firstDay: number, width: NameWidth): readonly string[] {
const found = namesOf(locale);
let sundayFirst = found.weekdays[width];
if (sundayFirst === undefined) {
const format = new Intl.DateTimeFormat(found.locale, { ...BASE, weekday: width });
// 4 January 1970 was a Sunday.
sundayFirst = Array.from({ length: 7 }, (_, day) => format.format((3 + day) * DAY));
found.weekdays[width] = sundayFirst;
}
return Array.from({ length: 7 }, (_, column) => sundayFirst[(firstDay + column) % 7]!);
}
/** A month's name with its year, in the words and the order of the locale: `October 2026`, `2026年10月`. */
export function monthTitle(locale: string, month: string): string {
return namesOf(locale).title.format(dayFromCivil(yearOf(month), monthNumberOf(month), 1) * DAY);
}
/** A date in full, weekday and all: what a day in a calendar is called. */
export function dayName(locale: string, date: string): string {
return namesOf(locale).label.format(dayNumber(date) * DAY);
}
/** The day of the month as the locale writes numbers: `13`, `١٣`. */
export function dayText(locale: string, date: string): string {
return namesOf(locale).days[Number(date.slice(8, 10)) - 1] ?? "";
}