@arki/date

archive

for @arki/[email protected] · Tier 2: standalone libraries

Date and time math for ARKI, built on Temporal semantics with a small, explicit API.

Version 0.2.0 archive

Install

bun add @arki/date
Other package managers
npm install @arki/date
pnpm add @arki/date
yarn add @arki/date

Release health for @arki/[email protected]

Doc examples

No example report was recorded for this version.

Open gaps

6 release checks fail

  • checkfile-llms-txt
  • checkfile-agents-md
  • checkfile-agent-guide
  • checkreadme-h2-order
  • checkpkg-engines
  • checkfiles-ship-dist-docs

Used by demos

No demo uses this package yet.

Runtimes

No engines field, so no runtime requirement is declared.

Benchmarks

No benchmarks are published for this version.

README

Date and time math for ARKI, built on Temporal semantics with a small, explicit API. Calendar dates, instants, zoned date-times, durations, interval algebra, business days, formatting and relative text — immutable values, one arithmetic policy, no hidden "now", no hidden zone.

Runs unchanged on Node 22+, Bun, Deno, browsers and edge runtimes: ECMAScript + Intl only. The temporal-polyfill ponyfill hands back the runtime's native Temporal where one exists (Node 26, Chrome 144, Firefox 139) and costs about 19 kB gzipped where it does not.

Installation

bun add @arki/date
# npm install @arki/date · pnpm add @arki/date

The five value types

TypeWhat it isUse it for
PlainDatea calendar day, no time, no zonebirthdays, due dates, holidays, date keys
PlainDateTimea wall-clock date + time, no zone"the meeting is at 09:00 local" before the zone is known
ZonedDateTimean exact moment shown in an IANA zonescheduling, display, everything users see
Instantan exact moment, zone-lesstimestamps, logs, timestamptz columns
Durationa signed amount of time"3 h 20 min", billing periods

They are Temporal's own types; @arki/date adds no parallel classes. The only shape it adds is Interval<T> = { start, end } (end exclusive).

A Date is an instant that pretends to be a calendar date. That is the root of most date bugs (new Date('2026-05-20') renders as May 19 in the Americas). Keep Date at the edges — DB drivers, pickers, third-party APIs — and convert once with fromDate / toDate / fromPickerDate / toPickerDate.

Quick tour

import {
  add,
  date,
  diff,
  each,
  format,
  instant,
  interval,
  isWeekend,
  nextBusinessDay,
  overlaps,
  plainDate,
  relative,
  startOf,
  zoned,
} from '@arki/date';
 
const due = add(plainDate('2026-01-31'), { months: 1 }); // 2026-02-28 (clamped, never March 3)
const monthStart = startOf(zoned(new Date(), 'Europe/Bucharest'), 'month');
diff(plainDate('2026-01-01'), plainDate('2026-12-31'), 'day'); // 364  (to − from)
isWeekend(due) ? nextBusinessDay(due) : due;
 
format(zoned('2026-10-09T15:37:48+03:00[Europe/Bucharest]'), 'EEEE, MMMM do, yyyy HH:mm');
// "Friday, October 9th, 2026 15:37"
relative(instant('2026-10-09T09:00:00Z'), instant('2026-10-09T12:00:00Z')); // "3 hours ago"
 
const booking = interval(plainDate('2026-02-09'), plainDate('2026-03-01'));
overlaps(interval(plainDate('2026-01-31'), plainDate('2026-02-10')), booking); // true
[...each(interval(plainDate('2026-01-31'), plainDate('2026-05-01')), { months: 1 })].map(String);
// ["2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30"] — anchored, not drifting
 
date('2026-01-31T10:00:00Z', { timeZone: 'Europe/Bucharest' })
  .add({ months: 1 })
  .set({ hour: 9, minute: 0 })
  .startOf('minute')
  .format('yyyy-MM-dd HH:mm'); // "2026-02-28 09:00"

Two ways to use it: pure functions (tree-shakeable, one subpath per module) and the fluent date() wrapper over ZonedDateTime for chains. The fluent form imports everything it delegates to; browser code that needs two operations should import those two.

Which constructor, and where the zone comes from

You haveCallResult
'2026-05-20'plainDate(text)PlainDate — no time, no zone
'2026-05-20T09:30'plainDateTime(text)PlainDateTime — wall clock, no zone
'2026-05-20T09:30' + a zonezoned(text, 'Europe/Bucharest')that wall clock in that zone
'…T06:30:00Z' or '…+03:00'instant(text)Instant — exact time, no zone
'…T06:30:00Z' + a zonezoned(text, 'Europe/Bucharest')that exact time shown in that zone
'…+03:00[Europe/Bucharest]'zoned(text)ZonedDateTime (zone from the annotation)
a Dateinstant(d) / zoned(d, zone) / fromDate(d)exact time; zoned adds the zone to show it
a Date from a pickerfromPickerDate(d, { basis: 'local' })the calendar fields the picker showed
{ year, month, day, hour } parts + zonezoned(parts, zone, { disambiguation? })DST gaps/folds resolved by 'compatible'

A zone-less string is never guessed into a zone: zoned('2026-05-20T09:30') throws and tells you to pass one. format/formatIntl on an Instant or Date use options.timeZone ?? systemTimeZone(); on a ZonedDateTime they use its own zone.

Server rendering: never let the server's zone leak into output. Resolve the user's zone and locale once per request and pass them everywhere, or bind them: const dates = createDateContext({ timeZone: user.timeZone, locale: user.locale }). systemTimeZone() is for client-side code that wants the browser's zone.

Common tasks

// Deadline in business days, skipping Romanian public holidays
const calendar = businessCalendar({ holidays: ['2026-12-01', '2026-12-25'] });
addBusinessDays(plainDate('2026-11-27'), 3, calendar); // 2026-12-03
 
// Is it overdue? (clock-driven, test with MockClock)
isPast(zoned(order.dueAt, 'Europe/Bucharest'), { clock });
 
// Age today
age(plainDate(user.birthDate), today({ clock, timeZone: 'Europe/Bucharest' }));
 
// A month grid (Monday-first weeks)
[...each(interval(startOf(month, 'week'), endOf(month, 'week')), { days: 1 })];
 
// "May 20 – 24, 2026"
formatRange(stay.start, stay.end, { dateStyle: 'medium' });
 
// "09:00 in Tokyo" as seen from Bucharest
inZone(zoned('2026-05-20T09:00', 'Asia/Tokyo'), 'Europe/Bucharest'); // 03:00+03:00
 
// Countdown text: "in 3 days" / "tomorrow"
relativeToNow(launch, { numeric: 'auto', clock });

Policies (the whole package follows the same rules)

  • Overflow clamps. add, sub and set constrain out-of-range fields (Jan 31 + 1 month = Feb 28). Pass { overflow: 'reject' } to throw instead. Subtraction is therefore not always the inverse of addition.
  • Calendar vs elapsed. On a ZonedDateTime, days/weeks/months/years move the wall clock, hours and below move the instant. Adding one day across a DST change is 23 or 25 elapsed hours; adding 24 hours is always 24. diffElapsed counts exact time (a day is 24 h there); diff counts calendar units.
  • diff(from, to) is to − from. Positive means forward. Whole units truncate toward zero; { fractional: true } gives the exact total anchored at from.
  • DST gaps and folds resolve with Temporal's compatible rule (a skipped 02:30 becomes 03:30; a repeated 02:30 takes the first). Every constructor and set accepts { disambiguation: 'earlier' | 'later' | 'reject' }.
  • Weeks are ISO by default (Monday start, week 1 contains January 4). Pass { week: SUNDAY_WEEK } or your own WeekRules.
  • No hidden now, no hidden zone. Time comes from an @arki/clock Clock passed in (or SystemClock). Functions that need a zone take it explicitly; today()/nowIn() default to the runtime zone and say so — pass it when rendering on both server and client.
  • Default locale is en-US so server and client agree. Pass your app's locale.
  • Half-open intervals. [start, end) everywhere; touching intervals do not overlap; union returns sorted disjoint pieces and never bridges a gap.
  • Fail loud. Invalid input throws RangeError at construction; mixed kinds throw TypeError; unknown format tokens throw. tryParse is the non-throwing door for user input.

Modules

Every module is also a subpath: @arki/date/math, @arki/date/interval, …

SubpathContents
typesthe value-type aliases, Point/Civil/Timeline unions, unit and option types, ISO_WEEK SUNDAY_WEEK, isPlainDate… guards, kindOf
coreplainDate plainDateTime zoned instant fromUnix fromMillis toUnix toMillis toInstant toPlainDate toISO tryParse parseISO tryParse
interopfromDate toDate fromPickerDate toPickerDate systemTimeZone
mathadd sub set parts startOf endOf nextBoundary floor ceil round diff until diffElapsed elapsedNanoseconds
calendardayOfWeek dayOfYear daysInMonth daysInYear isLeapYear isWeekend isWeekday isoWeek weekOf weeksInYear quarter calendarDaysBetween age nthWeekdayOfMonth lastWeekdayOfMonth easter weekRulesFor
compareequals sameInstant compareAsc compareDesc isBefore isAfter isSameOrBefore isSameOrAfter isSame isSameDay/Week/Month/Quarter/Year isBetween min max clamp
zoneinZone (same instant) reinterpretZone (same wall clock) atTime toUTC offset offsetMinutes isValidTimeZone
durationduration years…milliseconds addDuration subDuration multiplyDuration negateDuration absDuration normalizeDuration totalDuration toUnits compareDurations isZeroDuration isNegativeDuration formatDurationISO humanizeDuration formatDurationIntl
intervalinterval contains overlaps abuts intersection union gap subtract symmetricDifference mergeIntervals gapsBetween intervalLength splitByUnit each range toISOInterval
businessbusinessCalendar isBusinessDay addBusinessDays subBusinessDays nextBusinessDay previousBusinessDay businessDaysBetween businessDaysIn
formatformat (date-fns-compatible tokens) formatIntl formatRange (Intl.DateTimeFormat) formatISO toHumanReadableDate
relativerelative relativeToNow relativeUnit — "3 days ago" via Intl.RelativeTimeFormat
clocknow nowIn today isToday isTomorrow isYesterday isThisWeek isThisMonth isThisYear isPast isFuture
fluentdate DateValue createDateContext
parseparseDate parseDateTime parseZoned tryParseDate tryParseDateTime tryParseZoned (inverse of format)
zodoptional, needs zod: plainDateSchema plainDateTimeSchema instantSchema zonedDateTimeSchema durationSchema timeZoneSchema intervalSchema
reactoptional, needs react: useNow useToday useRelativeTime
serializeoptional, needs @orpc/client and/or superjson: temporalJsonSerializers registerTemporalTypes TEMPORAL_RPC_TYPES
tanstackoptional, needs @tanstack/react-router: temporalSerializationAdapters and one adapter per type
parseISOparseISO(text): Date — legacy, date-fns semantics (zone-less text is local time)

Durations

A Duration keeps calendar fields (years…days) and exact fields (hours…ns) apart because a day is not always 24 hours and a month is never 30 days. addDuration/subDuration combine like with like (P1Y − P2M = P10M, PT2H − PT30M = PT1H30M) and refuse to mix groups of opposite sign (P1D − PT1H throws — give it a zoned relativeTo through normalizeDuration). humanizeDuration uses Intl.NumberFormat unit style and Intl.ListFormat — no Intl.DurationFormat, which Node 22 lacks.

Intervals

const free = subtract(availability, booking); // 0, 1 or 2 pieces
const byDay = [...splitByUnit(shift, 'day')]; // aligned pieces; partial first/last kept
intervalLength(shift, 'hour', { fractional: true });

Iteration (each, range, splitByUnit) is lazy and bounded: more than maxItems (10 000) throws rather than truncating.

Testing time

import { MockClock } from '@arki/clock';
import { createDateContext } from '@arki/date/fluent';
 
const clock = new MockClock(new Date('2026-10-09T09:00:00Z'));
const dates = createDateContext({ clock, timeZone: 'Europe/Bucharest', locale: 'ro-RO' });
const deadline = dates.now().add({ hours: 2 });
deadline.isFuture(); // true
clock.advance(3 * 60 * 60 * 1000);
deadline.isPast(); // true

There is no package-level setTestNow: a global test clock leaks across tests and requests. Pass a clock.

Format tokens

format(value, pattern) takes the date-fns v4 Unicode tokens (same output, en-US):

FieldTokensFieldTokens
yeary yy yyyy yo, ISO week-year R RRRRhourh hh H HH K k (+ o)
quarterQ QQ QQQ QQQQ Qominute / secm mm s ss (+ o)
monthM MM MMM MMMM Mo, stand-alone L…fractionS SS SSS (truncated)
week / dayISO week I II, day d dd do, D DDDAM/PMa aaa aaaa b (noon/midnight)
weekdayE EEEE EEEEE, ISO i iiii iozoneX XX XXX x… O OOOO z zzzz
presetsP PP PPP PPPP p pp Pp PPppliteral'text', '' for a quote

Any other letter throws. Time tokens on a PlainDate, and zone tokens on a value without a zone, throw too. For locale-native output use formatIntl(value, { dateStyle: 'long', locale }).

Parsing text

parse is the inverse of format for the same tokens (no ordinals or presets): the whole text must match, names are matched case-insensitively in the pattern's locale, and impossible dates throw.

parseDate('20 May 2026', 'd MMMM yyyy'); // PlainDate 2026-05-20
parseDateTime('2026-05-20 09:30 PM', 'yyyy-MM-dd hh:mm a'); // PlainDateTime
parseZoned('20/05/2026 09:30', 'dd/MM/yyyy HH:mm', 'Europe/Bucharest'); // wall clock in that zone
parseZoned('2026-05-20T09:30+03:00', "yyyy-MM-dd'T'HH:mmxxx", 'UTC'); // exact, shown in UTC
tryParseDate('nope', 'yyyy-MM-dd'); // undefined

For lenient ISO text (2026-05-20, 2026-W41-5, 20260520T1030) use parseISO, which returns an Instant and reads zone-less text in options.timeZone (runtime zone by default).

Validating input (@arki/date/zod)

Schemas take an ISO string or an already-built value and output the Temporal value, so router inputs infer string | PlainDate and handlers receive a PlainDate:

import { z } from 'zod';
 
import { instantSchema, intervalSchema, plainDateSchema, timeZoneSchema } from '@arki/date/zod';
 
const input = z.object({
  day: plainDateSchema(),
  stay: intervalSchema(plainDateSchema()),
  seenAt: instantSchema(),
  timeZone: timeZoneSchema(), // canonical IANA id
});

zod is an optional peer dependency; the subpath is not re-exported from the package root.

React (@arki/date/react)

import { useNow, useRelativeTime, useToday } from '@arki/date/react';
 
const now = useNow({ every: 1000 }); // Instant, re-read every second
const today = useToday({ timeZone: user.timeZone }); // PlainDate, re-renders only when the date changes
const label = useRelativeTime(post.publishedAt, { numeric: 'auto' }); // "yesterday", refreshed each minute

Every hook takes clock so tests can drive it with a MockClock. The first render reads the clock synchronously, so server and client agree. react is an optional peer dependency.

Sending values over oRPC and SuperJSON (@arki/date/serialize)

oRPC and SuperJSON keep Date but not Temporal values: without help an Instant arrives as a string typed as Instant, or as {}. Register the codecs on both ends:

import { registerTemporalTypes, temporalJsonSerializers } from '@arki/date/serialize';
 
new RPCHandler(appRouter, { customJsonSerializers: temporalJsonSerializers }); // server
new RPCLink({ url, customJsonSerializers: temporalJsonSerializers }); // web and native clients
registerTemporalTypes(SuperJSON); // where the TanStack Query client dehydrates with SuperJSON

Values travel as ISO text and are checked on arrival. The codecs use oRPC custom type ids 100–104 (TEMPORAL_RPC_TYPES).

Loaders and server functions (@arki/date/tanstack)

TanStack Start sends loader data, server-function results and the SSR payload through seroval, which keeps Date but throws on a Temporal value. Register the adapters once in src/start.ts:

import { createStart } from '@tanstack/react-start';
 
import { temporalSerializationAdapters } from '@arki/date/tanstack';
 
export const startInstance = createStart(() => ({
  serializationAdapters: temporalSerializationAdapters,
}));

Values travel as ISO / RFC 9557 text, so a ZonedDateTime keeps its zone and an Instant its nanoseconds.

Pickers and Date

fromDate(d) keeps the instant. fromPickerDate(d, { basis: 'local' }) keeps the calendar fields the picker showed (new Date(2026, 0, 31) → 2026-01-31). They are different on purpose; toPickerDate round-trips and throws for a day the zone skipped.

Non-goals

Recurrence rules (RRULE), natural-language parsing, a holiday database, non-ISO calendars, a mutable API, macros or plugins, a monotonic clock.

Verification

bun run test (vitest, Node), bun run test:bun (same suite under Bun; the React hooks run in happy-dom), bun run build && bun run smoke (one check per module under node, bun and deno). The token formatter and parseISO are differential-tested against date-fns for every pattern used in the monorepo.

Benchmarks

Compares @arki/date with the three date libraries people most often pick in JavaScript: date-fns, Day.js and Luxon. Native Date and Intl are included where they solve the same task.

Node v22.13.1 on Apple M2 Max, 2026-10-13. Median time per operation, lower is better.

Parse an ISO 8601 instant

Input: 2026-10-09T12:37:48.123Z. @arki/date returns an Instant; the others return a Date, a Day.js, or a Luxon DateTime.

LibraryTime / opvs fastest
@arki/date parseISO18.03 µs35.52×
date-fns parseISO2.86 µs5.64×
Day.js dayjs(string)594.1 ns1.17×
Luxon DateTime.fromISO7.80 µs15.36×
native Date.parse507.7 nsfastest

Add one month

Calendar date 2026-01-31 plus one month (the end-of-month clamping case).

LibraryTime / opvs fastest
@arki/date add(plainDate, { months: 1 })5.17 µs3.95×
date-fns addMonths1.31 µsfastest
Day.js add(1, "month")9.39 µs7.18×
Luxon plus({ months: 1 })6.15 µs4.70×

Start of month

LibraryTime / opvs fastest
@arki/date startOf(plainDate, "month")3.55 µs3.61×
date-fns startOfMonth983.3 nsfastest
Day.js startOf("month")1.68 µs1.71×
Luxon startOf("month")1.75 µs1.78×

Format as yyyy-MM-dd HH:mm

All libraries format the same Date in the machine time zone. The pattern is parsed on every call; none of these libraries offers a precompiled formatter.

LibraryTime / opvs fastest
@arki/date format(Date, pattern)21.56 µs3.58×
date-fns format7.16 µs1.19×
Day.js format10.00 µs1.66×
Luxon toFormat6.03 µsfastest

Difference in calendar days

2026-01-31 to 2026-12-31 (334 days).

LibraryTime / opvs fastest
@arki/date diff(plainDate, plainDate, "day")1.27 µsfastest
date-fns differenceInCalendarDays15.52 µs12.19×
Day.js diff(…, "day")1.84 µs1.45×
Luxon diff(…, "days")11.35 µs8.92×

Convert an instant to a time zone and read the hour

Europe/Bucharest. The native row uses one prepared Intl.DateTimeFormat, which is the fastest native route but returns a string, not a date object.

LibraryTime / opvs fastest
@arki/date inZone(instant, zone).hour10.86 µs9.63×
Day.js tz(zone).hour() (utc + timezone plugins)110.09 µs97.68×
Luxon fromJSDate(date, { zone }).hour13.91 µs12.34×
native Intl.DateTimeFormat (prepared)1.13 µsfastest

Other @arki/date operations (no direct competitor)

These rows are for tracking regressions in this package. They are not a comparison, so ignore the "vs fastest" column.

LibraryTime / opvs fastest
zoned(Date, tz)17.95 µs188.09×
instant(Date)2.82 µs29.58×
plainDate(string)6.53 µs68.45×
add(zoned, { days: 1 })71.97 µs753.96×
startOf(zoned, "month")51.91 µs543.82×
startOf(zoned, "hour")9.17 µs96.12×
endOf(plainDate, "month")25.33 µs265.37×
isSameDay(zoned, zoned)3.62 µs37.92×
format(zoned, "yyyy-MM-dd")13.71 µs143.60×
format(Date, "MMM d, yyyy")22.63 µs237.02×
format(zoned, "PPPP")14.72 µs154.17×
formatIntl(zoned, medium)11.63 µs121.83×
relative(instant, ref)3.53 µs36.98×
relativeToNow(instant)54.00 µs565.72×
isToday(plainDate)21.98 µs230.24×
isoWeek(plainDate)17.86 µs187.08×
addBusinessDays(plainDate, 10)4.55 µs47.65×
each month x1273.39 µs768.78×
splitByUnit day x31160.73 µs1683.76×
date(Date).add.startOf.format74.14 µs776.68×
humanizeDuration(P1DT2H)142.93 µs1497.28×
toDate(zoned)261.0 ns2.73×
systemTimeZone()95.5 nsfastest

Read the rows with the contracts in mind:

  • @arki/date is built on Temporal values: immutable, nanosecond precision, and time zones come from the IANA database, not from the host machine. Day.js and date-fns work on Date, which has millisecond precision and a local-time bias. Luxon is also immutable and zone-aware.
  • @arki/date rejects impossible input (such as 2026-02-31) instead of rolling it over, and it adds months by clamping to the last valid day (Jan 31 + 1 month = Feb 28). date-fns, Day.js and Luxon clamp too; native Date does not, so it is left out of the month group.
  • Day.js diff(…, "day") counts whole 24-hour spans. date-fns differenceInCalendarDays, Luxon diff and @arki/date diff on plain dates count calendar days. The inputs here are midnight local dates, so the answers agree.
  • date-fns has no time-zone conversion without the extra @date-fns/tz package, so it is not in the zone group. Day.js needs its utc and timezone plugins, which are loaded here.
  • The @arki/date numbers include input validation and building a Temporal object, which is what you pay for correctness. The extra cases at the end have no direct competitor and exist to catch regressions.

Reproduce with bun run bench in the package directory; regenerate this section with bun run bench --write-readme.

License

MIT — see LICENSE.

Public imports

This version has no API index.