Date helpers
StableSmall date maths and Intl formatting on plain Dates: month grids, week starts from the locale, ISO weeks, relative days, durations.
Every kit (tasks, calendar, timer, notes) shares these. They work on plain Dates in local time and format with Intl in one locale: en-US until setDateLocale picks another. A page rendered only in the browser can follow the visitor with setDateLocale(browserLocale()); a prerendered one keeps a fixed locale so its HTML and the browser agree. The first day of the week comes from the locale (Sunday in the US, Monday in most of Europe, Saturday in parts of the Middle East). formatDuration writes one length three ways: for clocks, for logs and for screen readers.
Each helper, one fixed date
import { addMonths, dtf, firstDayOfWeek, formatDuration, isoWeek, relativeDay, weekdayNames } from '@rchursin/product/lib/dates'
/** * Each helper with a fixed date, Tuesday 13 October 2026 at 9:30. Names and order follow the * dates locale (en-US on this prerendered site; see setDateLocale). dtf caches one formatter per * set of options. */const day = new Date(2026, 9, 13, 9, 30)const short = dtf({ weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })
const rows = [ { call: "dtf({ weekday: 'long', month: 'long', day: 'numeric' }).format(day)", result: dtf({ weekday: 'long', month: 'long', day: 'numeric' }).format(day) }, { call: 'addMonths(new Date(2026, 0, 31), 1)', result: short.format(addMonths(new Date(2026, 0, 31), 1)) }, { call: 'firstDayOfWeek()', result: `${firstDayOfWeek()} (${weekdayNames('long')[0]})` }, { call: "weekdayNames('narrow')", result: weekdayNames('narrow').join(' ') }, { call: 'isoWeek(day)', result: String(isoWeek(day)) }, { call: 'relativeDay(new Date(2026, 9, 14), day)', result: relativeDay(new Date(2026, 9, 14), day) }, { call: 'relativeDay(new Date(2026, 9, 16), day)', result: relativeDay(new Date(2026, 9, 16), day) }, { call: 'relativeDay(new Date(2027, 1, 2), day)', result: relativeDay(new Date(2027, 1, 2), day) }, { call: 'formatDuration(3_909_000)', result: formatDuration(3_909_000) },]
export default function DateOutputs() { return ( <div className="flex w-full max-w-2xl flex-col gap-3"> <code className="font-mono text-[12px] text-fg-3">const day = new Date(2026, 9, 13, 9, 30)</code> <dl className="lux-card flex flex-col divide-y divide-line rounded-xl bg-raised"> {rows.map((r) => ( <div key={r.call} className="flex flex-col gap-1 px-4 py-2.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-6"> <dt className="min-w-0"> <code className="font-mono text-[12px]/5 break-words text-fg-2">{r.call}</code> </dt> <dd className="shrink-0 text-[13.5px] font-medium text-fg tabular-nums">{r.result}</dd> </div> ))} </dl> </div> )}Usage
import { dtf, setDateLocale, addMonths, firstDayOfWeek, monthMatrix, weekdayNames, isoWeek, relativeDay, formatDuration } from '@rchursin/product/lib/dates'setDateLocale(browserLocale()) // browser-only pages: follow the visitor (before the first render)const weeks = monthMatrix(2026, 9) // October 2026, 6 rows × 7 daysconst head = weekdayNames('short') // in the locale's orderrelativeDay(new Date(2026, 9, 14), today) // "Tomorrow"formatDuration(3_909_000, 'clock') // "1:05:09"Examples
Week start from the locale
firstDayOfWeek(tag) feeds monthMatrix and weekdayNames, so the grid and its headings agree. The names stay in the page’s locale (en-US here); the order follows the tag you pick.
import { useState } from 'react'import { dtf, firstDayOfWeek, isSameDay, monthMatrix, weekdayNames } from '@rchursin/product/lib/dates'import { Segmented } from '@rchursin/product/ui'
/** * The same month for three locales. firstDayOfWeek(tag) reads the week from Intl.Locale * (Sunday in the US, Monday in Germany, Saturday in Egypt), and monthMatrix and weekdayNames * take it as their last argument, so the grid and its headings always agree. */const locales = ['en-US', 'de-DE', 'ar-EG'] as consttype Tag = (typeof locales)[number]
const day = new Date(2026, 9, 13)const month = dtf({ month: 'long', year: 'numeric' }).format(day)
export default function WeekStart() { const [tag, setTag] = useState<Tag>('en-US') const first = firstDayOfWeek(tag) const weeks = monthMatrix(2026, 9, first) const short = weekdayNames('narrow', first)Three ways to write a length
Drag the slider. Clock for timers, compact for logs and chips, long for screen readers.
import { useId, useState, type CSSProperties } from 'react'import { formatDuration } from '@rchursin/product/lib/dates'import { Switch } from '@rchursin/product/ui'
/** * One length, three ways to write it: clock for timers, compact for logs and chips, long for * screen readers. A negative length (overtime) gets a real minus sign. */const styles = [ { style: 'clock', use: 'Timers' }, { style: 'compact', use: 'Logs and chips' }, { style: 'long', use: 'Screen readers' },] as constconst MAX = 3 * 3600
export default function Durations() { const id = useId() const [seconds, setSeconds] = useState(3909) const [over, setOver] = useState(false) const ms = (over ? -1 : 1) * seconds * 1000API
setDateLocale(tag)
| Prop | Type | Default | Description |
|---|---|---|---|
tagrequired | string | – | A BCP 47 tag like "en-GB", or browserLocale(). An invalid tag falls back to en-US. |
returns | void | – | Every helper formats in this locale from now on (cached formatters are dropped). Call it before the first render: what is already on screen does not change by itself. |
browserLocale() · getDateLocale()
| Prop | Type | Default | Description |
|---|---|---|---|
returns | string | – | browserLocale: navigator.language as a tag Intl accepts (some systems report tags like "en-US@posix"), or en-US where there is no navigator. getDateLocale: the locale in use now. |
dtf(options)
| Prop | Type | Default | Description |
|---|---|---|---|
optionsrequired | Intl.DateTimeFormatOptions | – | Any Intl options. One formatter is made per distinct set and cached. |
returns | Intl.DateTimeFormat | – | In the locale from getDateLocale(): en-US until setDateLocale picks another. The same text in Node and browsers (Node’s narrow no-break space before AM/PM becomes a plain space). |
addMonths(d, n)
| Prop | Type | Default | Description |
|---|---|---|---|
drequired | Date | – | Start date. Hours and minutes are kept; seconds are dropped. |
nrequired | number | – | Months to add; negative goes back. |
returns | Date | – | A new Date with the day clamped to the target month: January 31 + 1 month is February 28 (29 in a leap year). |
firstDayOfWeek(tag?)
| Prop | Type | Default | Description |
|---|---|---|---|
tag | string | getDateLocale() | A BCP 47 tag like "en-GB". |
returns | number | – | 0 = Sunday … 6 = Saturday, from Intl.Locale week info. Engines without it get Sunday for en-US, en-CA, ja, ko, zh-TW, he and pt-BR, Monday for the rest. |
monthMatrix(year, month, firstDay?)
| Prop | Type | Default | Description |
|---|---|---|---|
yearrequired | number | – | Full year. |
monthrequired | number | – | 0-based, like Date: 9 is October. |
firstDay | number | firstDayOfWeek() | Weekday each row starts on. |
returns | Date[][] | – | Always 6 rows of 7 days, with leading and trailing days from the months around it, so the grid never changes height. |
weekdayNames(style?, firstDay?)
| Prop | Type | Default | Description |
|---|---|---|---|
style | 'long' | 'short' | 'narrow' | 'short' | “Tuesday”, “Tue” or “T”. |
firstDay | number | firstDayOfWeek() | Where the list starts. |
returns | string[] | – | Seven names in the dates locale (getDateLocale), starting at firstDay. |
isoWeek(d) · relativeDay(d, now?)
| Prop | Type | Default | Description |
|---|---|---|---|
isoWeek(d) | (d: Date) => number | – | ISO 8601 week number, 1–53. Weeks start on Monday and week 1 holds the year’s first Thursday. |
relativeDay(d, now?) | (d: Date, now = new Date()) => string | – | “Today”, “Tomorrow”, “Yesterday”, the weekday name for the next 2–6 days, otherwise “Oct 21” (with the year when it differs). Pass a fixed now in prerendered markup. |
formatDuration(ms, style?)
| Prop | Type | Default | Description |
|---|---|---|---|
msrequired | number | – | Length in milliseconds, cut to whole seconds. Negative gets a minus sign (−). |
style | 'clock' | 'compact' | 'long' | 'compact' | clock “25:00”, “1:05:09” · compact “1h 05m”, “25m”, “40s” · long “1 hour 5 minutes”. compact and long leave out seconds once a larger unit is there (long keeps them under an hour). |
returns | string | – | The formatted length. |
Also in this file
| Prop | Type | Default | Description |
|---|---|---|---|
Arithmetic | addDays · addMinutes · addYears · at(d, h, m) | – | New Dates in local time; at() is a time on the same day. |
Boundaries | startOfDay · endOfDay · startOfWeek · startOfMonth · daysInMonth | – | startOfWeek takes a firstDay like monthMatrix. |
Comparisons | isSameDay · isSameMonth · isToday · dayDiff | – | dayDiff counts calendar days, so a daylight-saving change doesn’t skew it. |
ISO strings | toISODate · fromISODate | – | "2026-10-13" from a local Date and back. See ISO dates for the strict parser. |
Labels | formatDay · formatDayLong · formatShort · formatMonth · formatTime · formatTimeShort · relativeDayTime | – | “Tue, Oct 13”, “Tuesday, October 13, 2026”, “9 AM” and friends, all through dtf. |
Constants | MINUTE · HOUR · DAY | – | Milliseconds. |
Accessibility
- Write screen-reader text with formatDuration(ms, "long") and formatDayLong: “1 hour 5 minutes” and “Tuesday, October 13, 2026” read clearly; “1:05:09” and “10/13” don’t.
- Names come from Intl in one locale. On a page rendered only in the browser, setDateLocale(browserLocale()) writes them in the visitor’s language, which their screen reader pronounces correctly.
Guidelines
Do
- Pass a fixed `now` to relativeDay in anything prerendered.
- Call setDateLocale once, before the first render; keep the default on prerendered pages.
- Take the week start from firstDayOfWeek instead of assuming Sunday or Monday.
- Use the long duration style for aria-labels and announcements.
Don’t
- Don’t build date strings by hand for people to read; go through dtf.
- Don’t add 30 days to mean “a month”; use addMonths.
- Don’t hold a Date in JSON or URLs; store an ISO date string instead.
In use
Used on this site
Related
- ISO datesDates as "2026-10-12" strings: strict and forgiving parsers, range labels with a spoken form, and the date grid’s arrow-key model.
- useMediaQuery & useNowTwo small hooks from the calendar: a media query as a boolean, and the current time that re-renders on each whole minute or second.
- Calendar gridOne month as an ARIA grid with the APG date-picker keys. Every picker in the kit is built on it.
- Time displayThe clock face: Geist Mono digits in fixed cells, lit like the buttons, still or live, with a spoken form for screen readers.
Source
The component’s own files, exactly as they run on this site.
/** * Date helpers shared by every kit (tasks, calendar, timer, notes). * Plain Date in local time, formatted with Intl. Words, order and the 12/24-hour clock follow one * locale: en-US unless you choose another with setDateLocale before the first render. * (Temporal is not in Safari yet as of late 2026; swap these for Temporal.PlainDate when it is.) */
export const MINUTE = 60_000export const HOUR = 60 * MINUTEexport const DAY = 24 * HOUR
/** A tag Intl accepts, or en-US (some systems report tags like "en-US@posix") */function canonical(tag: string | undefined) { try { return (tag && Intl.getCanonicalLocales(tag)[0]) || "en-US" } catch { return "en-US" }}