sdxc

Type to search, or start from one of these:

@sdxc/dates

Zone-aware date operations with Intl-only formatting

npm add @sdxc/dates
pnpm add @sdxc/dates
yarn add @sdxc/dates
bun add @sdxc/dates
Used by
uptime

Calendar operations over the platform Date, with every human-facing string formatted by Intl and every time zone passed in explicitly.

Installation

npm add @sdxc/dates

Durations are expressed with @sdxc/duration, and the parsers return the Result value from @sdxc/result. Both install alongside this package.

Usage

Formatting For A Reader

Every formatter takes the locale, and every formatter that renders an instant takes the zone.

import { formatDate, formatDateTime, formatRelative, formatTime } from "@sdxc/dates";

let publishedAt = new Date("2026-07-29T10:00:00Z");
let locale = "en-US";
let timeZone = "America/New_York";
let now = new Date("2026-08-01T10:00:00Z");

formatDate(publishedAt, { locale, timeZone }); // "Jul 29, 2026"
formatTime(publishedAt, { locale, timeZone }); // "6:00 AM"
formatDateTime(publishedAt, { locale, timeZone }); // "Jul 29, 2026 at 6:00 AM"
formatRelative(publishedAt, { locale, now }); // "3 days ago"

Calendar Operations

Operations that answer a calendar question take the zone as a required argument, so the answer is always about a stated calendar.

import { diffInDays, eachDayOfInterval, isSameDay, startOfDay, toDayKey } from "@sdxc/dates";

let timeZone = "America/New_York";

startOfDay(new Date(), timeZone);
isSameDay(a, b, timeZone);
diffInDays(b, a, timeZone);
eachDayOfInterval({ start, end }, timeZone);
toDayKey(new Date("2026-07-29T02:00:00Z"), timeZone); // "2026-07-28"

Instant Arithmetic

Operations that move an instant by a fixed length take no zone, because a length of time is the same length everywhere.

import { add, addDays, elapsed, subtract } from "@sdxc/dates";

let expiresAt = add(new Date(), "30 days");
let retryAt = add(new Date(), "250ms");
let cutoff = subtract(new Date(), "1 hour");
let lastWeek = addDays(new Date(), -7);

let startedAt = Date.now();
await fetchUser(id);
console.log("finished", elapsed(startedAt)); // milliseconds

A Day Grid

import { formatWeekday, groupByWeek, lastNDays } from "@sdxc/dates";

let timeZone = "America/New_York";

let days = lastNDays(90, { timeZone });
let weeks = groupByWeek(days, { weekStartsOn: 0, timeZone });
let headers = [0, 1, 2, 3, 4, 5, 6].map((weekday) => formatWeekday(weekday, { locale: "en-US" }));

for (let week of weeks) {
	for (let day of week) render(day.key, countsByDay.get(day.key));
}

API

Formatting

formatDate(date: Date, options: FormatDateOptions): string

Renders the calendar date an instant falls on, in a zone. dateStyle defaults to "medium".

formatDate(date, { locale: "en-US", timeZone: "UTC" }); // "Jul 29, 2026"
formatDate(date, { locale: "es-AR", timeZone: "UTC", dateStyle: "long" }); // "29 de julio de 2026"
formatDate(date, { locale, timeZone });
// same as
new Intl.DateTimeFormat(locale, { timeZone, dateStyle: "medium" }).format(date);

Every formatter below reuses one cached Intl instance per locale and options pair, so the shorthand also skips rebuilding the formatter on each call.

formatTime(date: Date, options: FormatTimeOptions): string

Renders the time of day an instant reads as on a clock in a zone. timeStyle defaults to "short".

formatTime(date, { locale, timeZone });
// same as
new Intl.DateTimeFormat(locale, { timeZone, timeStyle: "short" }).format(date);

formatDateTime(date: Date, options: FormatDateTimeOptions): string

Renders an instant as a date and a time of day, joined the way the locale joins them. dateStyle defaults to "medium" and timeStyle to "short".

formatDateTime(date, { locale: "en-US", timeZone: "UTC" }); // "Jul 29, 2026 at 10:00 AM"
formatDateTime(date, { locale, timeZone });
// same as
new Intl.DateTimeFormat(locale, { timeZone, dateStyle: "medium", timeStyle: "short" }).format(date);

formatRange(start: Date, end: Date, options: FormatRangeOptions): string

Renders the span between two instants as one range, collapsing whatever the two ends share. The time half appears once timeStyle is given.

formatRange(start, end, { locale: "en-US", timeZone: "UTC" }); // "Jul 29 – 31, 2026"
formatRange(start, end, { locale, timeZone });
// same as
new Intl.DateTimeFormat(locale, { timeZone, dateStyle: "medium" }).formatRange(start, end);

formatRelative(date: Date, options: FormatRelativeOptions): string

Words the distance from now to an instant, picking the largest unit that still rounds to a whole number. now defaults to the current time, numeric to "auto" so the locale may say "yesterday", and style to "long".

formatRelative(yesterday, { locale: "en-US", now }); // "yesterday"
formatRelative(yesterday, { locale: "en-US", now, numeric: "always" }); // "1 day ago"

Intl words a value and a unit you have already chosen; picking that pair from two instants is what this adds. Once it has them, the wording is Intl's:

formatRelative(threeDaysAgo, { locale, now });
// measures the distance, picks the unit, then words it as
new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "long" }).format(-3, "day");

formatDuration(input: DurationInput, options: FormatDurationOptions): string

Words a length of time, skipping zero-valued components. style defaults to "long", and maxUnits keeps only that many components, largest first.

formatDuration("90 minutes", { locale: "en-US" }); // "1 hour, 30 minutes"
formatDuration("90 minutes", { locale: "en-US", style: "short" }); // "1 hr, 30 min"
formatDuration(5_400_000, { locale: "en-US", maxUnits: 1 }); // "1 hour"

formatParts(date: Date, options: FormatPartsOptions): Intl.DateTimeFormatPart[]

Breaks an instant into its localized pieces, each tagged with what it is, for a layout composed by the caller. Beyond the required locale and timeZone, it accepts any Intl.DateTimeFormatOptions field, and the parts come back in the locale's own order.

formatParts(date, { locale: "en-US", timeZone: "UTC", month: "long", day: "numeric" });
// [{ type: "month", value: "July" }, { type: "literal", value: " " }, { type: "day", value: "29" }]
formatParts(date, { locale, timeZone, month: "long", day: "numeric" });
// same as
new Intl.DateTimeFormat(locale, { timeZone, month: "long", day: "numeric" }).formatToParts(date);

formatWeekday(weekday: Weekday, options: FormatWeekdayOptions): string

The localized name of a weekday on its own, for a grid header. style defaults to "short".

formatWeekday(1, { locale: "en-US" }); // "Mon"
formatWeekday(0, { locale: "es-AR", style: "long" }); // "domingo"

Zoned Operations

startOfDay(date: Date, timeZone: TimeZone): Date

The first instant of the calendar day an instant falls on, in a zone. On a day whose DST transition skips midnight it is the first instant that exists that day.

startOfDay(new Date("2026-07-29T02:00:00Z"), "America/New_York"); // 2026-07-28T04:00:00Z

endOfDay(date: Date, timeZone: TimeZone): Date

The last instant of that calendar day, one millisecond before the next day starts, so it stays correct on days that are 23 or 25 hours long.

startOfWeek(date: Date, timeZone: TimeZone, options: StartOfWeekOptions): Date

The first instant of the week an instant falls in. weekStartsOn is required, 0 Sunday through 6 Saturday.

startOfWeek(date, "UTC", { weekStartsOn: 1 }); // Monday-based week

diffInDays(a: Date, b: Date, timeZone: TimeZone): number

Calendar days from b to a: the count of day boundaries crossed, positive when a is on a later day.

diffInDays(new Date("2026-07-30T01:00:00Z"), new Date("2026-07-29T23:00:00Z"), "UTC"); // 1

isSameDay(a: Date, b: Date, timeZone: TimeZone): boolean

Whether two instants fall on the same year, month and day in a zone. The same pair can be one day in one zone and two in another.

eachDayOfInterval(interval: Interval, timeZone: TimeZone): Date[]

Every calendar day touched by an inclusive interval, as the instant each day starts at. Returns an empty array when end falls on a day before start.

Instant Arithmetic

addDays(date: Date, count: number): Date

Moves an instant forward by whole 24-hour days, returning a new Date. Negative counts move back.

addDays(date, 3);
// same as
new Date(date.getTime() + 3 * 86_400_000);

subDays(date: Date, count: number): Date

Moves an instant back by whole 24-hour days, with the same instant semantics as addDays.

subDays(date, 3);
// same as
new Date(date.getTime() - 3 * 86_400_000);

add(date: Date, duration: DurationInput): Date

Moves an instant forward by a duration string or a number of milliseconds.

add(new Date("2026-07-29T10:00:00Z"), "90 minutes"); // 2026-07-29T11:30:00Z
add(date, "90 minutes");
// same as
new Date(date.getTime() + 90 * 60 * 1000);

subtract(date: Date, duration: DurationInput): Date

Moves an instant back by a duration string or a number of milliseconds.

subtract(date, "30 minutes");
// same as
new Date(date.getTime() - 30 * 60 * 1000);

elapsed(since: Date | number, now?: Date | number): number

Milliseconds between an instant and now, positive once the instant is in the past. now defaults to the current time, and a test supplies both ends.

elapsed(new Date("2026-07-29T10:00:00Z"), new Date("2026-07-29T10:00:05Z")); // 5000
let startedAt = Date.now();
elapsed(startedAt);
// same as
Date.now() - startedAt;

Day Grids

daysOfYear(year: number, timeZone: TimeZone): Day[]

Every day of a calendar year in a zone, in chronological order.

daysOfYear(2024, "UTC").length; // 366

lastNDays(count: number, options: LastNDaysOptions): Day[]

A rolling window of the last count calendar days, ending on the day from falls on and including it. from defaults to the current instant, and the window stays exactly count entries long across a DST transition.

groupByWeek(days: Day[], options: GroupByWeekOptions): Day[][]

Buckets a chronological day list into weeks, one array per week. The first and last buckets stay short when the range starts or ends mid-week.

groupByWeek(daysOfYear(2026, "UTC"), { weekStartsOn: 0, timeZone: "UTC" }).length; // 53

Day Keys

toDayKey(date: Date, timeZone: TimeZone): string

The "YYYY-MM-DD" key for the calendar day an instant falls on in a zone, zero padded. Group and join aggregations on it.

toDayKey(new Date("2026-07-29T02:00:00Z"), "UTC"); // "2026-07-29"
toDayKey(new Date("2026-07-29T02:00:00Z"), "America/New_York"); // "2026-07-28"

parseDayKey(key: string): Result<CalendarDay, InvalidDayKeyError>

Reads a day key into its calendar fields, with no zone involved. A day that does not exist on its month is rejected.

parseDayKey("2026-07-29"); // { status: "success", data: { year: 2026, month: 7, day: 29 } }
parseDayKey("2026-02-30"); // { status: "failure", error: InvalidDayKeyError }

fromDayKey(key: string, timeZone: TimeZone): Result<Date, InvalidDayKeyError>

The instant a day key's day starts at in a zone, the inverse of toDayKey.

fromDayKey("2026-07-29", "America/New_York"); // { status: "success", data: 2026-07-29T04:00:00Z }

parseDate(input: string | number): Result<Date, InvalidDateError>

Reads text or a millisecond timestamp into a Date, failing rather than producing an Invalid Date. A date-only string is read as UTC midnight; use fromDayKey() when the input names a calendar day.

parseDate("2026-07-29T10:00:00Z"); // { status: "success", data: Date }
parseDate("not a date"); // { status: "failure", error: InvalidDateError }

InvalidDayKeyError

The error parseDayKey() and fromDayKey() report, with the rejected text on error.text.

InvalidDateError

The error parseDate() reports, with the rejected value on error.input.

Types

TimeZone

type TimeZone = string;

An IANA time zone name, e.g. "America/New_York" or "UTC".

Locale

type Locale = string | readonly string[];

A BCP 47 locale, or a preference list the platform resolves in order. Passed straight to Intl.

Weekday

type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;

A weekday index, 0 Sunday through 6 Saturday, matching Date#getDay. Used both for weekStartsOn and for the weekday a day falls on.

CalendarDay

interface CalendarDay {
	year: number;
	month: number; // 1-12, not zero-based
	day: number;
}

A calendar day with no zone and no time attached: what a human reads off a wall calendar.

Day

interface Day extends CalendarDay {
	date: Date; // the day's first instant in timeZone
	key: string; // "YYYY-MM-DD"
	weekday: Weekday;
	timeZone: TimeZone;
}

One cell of a day grid, with everything zone-dependent resolved once so a renderer never recomputes it.

Interval

interface Interval {
	start: Date;
	end: Date;
}

A closed range of instants. Both ends are inclusive for day enumeration.

DateStyle, TimeStyle

The dateStyle and timeStyle lengths from Intl.DateTimeFormat: "full" | "long" | "medium" | "short".

Options

FormatDateOptions, FormatTimeOptions, FormatDateTimeOptions, FormatRangeOptions, FormatRelativeOptions, FormatDurationOptions, FormatPartsOptions, FormatWeekdayOptions, StartOfWeekOptions, LastNDaysOptions and GroupByWeekOptions are exported for callers that pass options through their own signatures.

Pattern: One Zone Per Request

Resolve the reader's zone once, at the edge, and pass it down. Every call site below then reads as a statement about that reader's calendar instead of the server's.

import { formatDate, lastNDays, toDayKey } from "@sdxc/dates";

function buildView(preferences: { locale: string; timeZone: string }) {
	let { locale, timeZone } = preferences;

	return {
		today: toDayKey(new Date(), timeZone),
		heading: formatDate(new Date(), { locale, timeZone, dateStyle: "full" }),
		window: lastNDays(30, { timeZone }),
	};
}

Pattern: Aggregating By Day

Group rows on the day key rather than on a truncated timestamp, so the buckets land on the reader's calendar and the grid looks each day up in constant time.

import { lastNDays, toDayKey } from "@sdxc/dates";

function ordersPerDay(orders: { placedAt: Date }[], timeZone: string) {
	let counts = new Map<string, number>();

	for (let order of orders) {
		let key = toDayKey(order.placedAt, timeZone);
		counts.set(key, (counts.get(key) ?? 0) + 1);
	}

	return lastNDays(90, { timeZone }).map((day) => ({
		key: day.key,
		weekday: day.weekday,
		orders: counts.get(day.key) ?? 0,
	}));
}

Pattern: Querying One Day's Rows

Turn a day into the pair of instants a query filters on. The range is closed: endOfDay() is the day's last millisecond in that zone, so a row at 23:59 local is included and one at the next midnight is not. fromDayKey() returns a Result because a key usually arrives from a URL.

import { endOfDay, fromDayKey, startOfDay } from "@sdxc/dates";
import { isFailure } from "@sdxc/result";

function boundsForDate(date: Date, timeZone: string) {
	return { from: startOfDay(date, timeZone), to: endOfDay(date, timeZone) };
}

function boundsForKey(key: string, timeZone: string) {
	let start = fromDayKey(key, timeZone);
	if (isFailure(start)) return start;
	return { from: start.data, to: endOfDay(start.data, timeZone) };
}

Pattern: A Layout Intl Will Not Produce

When a design needs the pieces of a date in their own elements, compose formatParts() output instead of reaching for a pattern string. Each part is tagged with what it is, and they arrive in the locale's own order, so rendering them in order stays correct where the day precedes the month.

import { formatParts } from "@sdxc/dates";

function stackedDate(date: Date, locale: string, timeZone: string) {
	let parts = formatParts(date, { locale, timeZone, month: "short", day: "numeric" });
	let read = (type: string) => parts.find((part) => part.type === type)?.value ?? "";

	return { month: read("month"), day: read("day") };
}