temporals
v0.0.2
Published
Lazy sequences, ranges, intervals, and RRULE recurrence built on the TC39 Temporal API.
Maintainers
Readme
temporals
Lazy sequences, ranges, intervals, and RRULE recurrence built on the TC39 Temporal API.
Temporal ships the atoms of date/time — immutable points, durations, and
calendar-aware arithmetic — but no sequence layer. temporals fills that
gap: feed in parameters, get back a lazy collection of Temporal objects, either
points (PlainDate, ZonedDateTime, …) or intervals ({ start, end }
spans).
import { range } from "temporals";
// The next 10 weekdays, lazily.
range({ start: Temporal.Now.plainDateISO(), step: { days: 1 } })
.filter((d) => d.dayOfWeek <= 5)
.take(10)
.toArray();Design
- Iterator-first. Generators return a re-iterable
Seqwith the standard iterator-helper surface (map/filter/take/drop/flatMap/toArray/…), so composition is native. On runtimes with native iterator helpers (Node 22+, modern browsers) those work too;Seqprovides them portably so behaviour is identical on older runtimes. - Thin builder.
seq()/recurBuilder()wrap the core generators for discoverability. They hold no logic — every terminal delegates to a generator. - Temporal is a peer. The engines derive everything from the values you
pass in (their constructor, static
compare, instance.add), so nothing is bundled. On Node < 22, installtemporal-polyfillandimport "temporal-polyfill/global"once at your entry point. - Half-open by default. Ranges and intervals are
[start, end)unless you opt into inclusive bounds. - Calendar-correct. Stepping in calendar units keeps DST and month lengths
right; month steps are anchor-relative so they don't drift
(
Jan 31 → Feb 28 → Mar 31, not→ Mar 28).
Install
npm install temporals
# On Node < 22, also:
npm install temporal-polyfillDocumentation
- API reference — a complete, generated reference for every export (all
subpaths) lives at https://johnhenry.github.io/temporals/ (built from JSDoc
via TypeDoc; run
npm run docsto generate it locally intodocs/). - Examples — runnable, per-feature programs in
examples/(range,recur,cron,intervals,business,humanize,backoff,ics, plus a reference scheduler).npm run examplesruns them all. - This README is the guided tour below.
API
range(options): Seq<T>
A stepped sequence of points from start.
| option | meaning |
| ----------- | ------------------------------------------------------------- |
| start | first point (always included) |
| step | increment (Temporal.Duration or { days: 1 }); negative descends |
| end? | bound; upper for positive steps, lower for negative |
| count? | max number of points |
| inclusive?| include a point landing exactly on end (default false) |
| overflow? | "constrain" (default) or "reject" for calendar arithmetic |
With neither end nor count, the sequence is infinite — bound it with
.take(n).
chunks(options): Seq<Interval<T>> — partition
Adjacent, non-overlapping intervals of width by covering [start, end). The
final partial chunk is clamped to end (pass partial: false to drop it).
windows(options): Seq<Interval<T>> — sliding
Overlapping windows of width size, advancing each start by step. Only
full-width windows by default (partial: true emits clamped trailing windows).
recur(rule): Seq<T> — RRULE recurrence
A pragmatic subset of RFC 5545:
recur({
start: Temporal.PlainDate.from("2026-01-01"),
freq: "monthly",
byWeekday: [{ weekday: "TU", nth: 2 }], // 2nd Tuesday
count: 12,
}).toArray();Supported: freq (yearly/monthly/weekly/daily/hourly/minutely/secondly),
interval, count, until, byMonth, byWeekNo (yearly), byYearDay,
byMonthDay (incl. negatives), byWeekday (incl. nth),
byHour/byMinute/bySecond, bySetPos, weekStart.
Sub-daily frequencies require a time-bearing start (PlainDateTime,
ZonedDateTime, or PlainTime) and treat by* rules as filters; bySetPos is
not combined with sub-daily frequencies.
Also supported:
include(RFC 5545 RDATE) andexclude(EXDATE) — merge extra dates or drop occurrences (countcounts what's actually returned).- DST policy for
ZonedDateTimestarts —dstGap: "fire" | "skip",dstOverlap: "first" | "second"(same knobs as cron). - Month/year stepping is calendar-aware (correct across Hebrew leap years, Islamic, Persian, … — see Scope & limitations).
- Impossible rules throw rather than silently stopping.
splitSeries(rule, at)→{ before, after }for "this and following" edits — dividescount, partitions EXDATE/RDATE, so the two halves reproduce the original. Editafterto change this-and-following instances.
recur.fromString(...), ruleFromString(...), and formatRule(rule) provide
RRULE-string interop; the temporals/ics subpath adds full .ics import/export.
Interval<T>
The interval value type Temporal lacks:
const q1 = Interval.from("2026-01-01/2026-04-01");
q1.contains(someDate);
q1.overlaps(other);
q1.intersection(other); // Interval | null
q1.toDuration(); // Temporal.Duration
q1.points({ days: 1 }); // Seq<T> of points inside
q1.toString(); // "2026-01-01/2026-04-01"Fluent builders
seq(start).step({ days: 1 }).until(end).toArray();
seq(start).step({ days: 1 }).until(end).chunks({ weeks: 1 });
recurBuilder(start).monthly().on({ weekday: "FR", nth: -1 }).count(3).toArray();
recurBuilder(start).weekly().every(2).on("MO", "WE").toString(); // -> RRULE stringtemporals/cron — Temporal-native cron
Cron lives in a subpath (temporals/cron) so the core stays lean. It's a
matching schedule ("fire when the wall clock matches"), evaluated in an explicit
time zone with explicit, correct DST behaviour — the main advantage over
Date-based cron libraries.
import { cron, cronSchedule, describeCron } from "temporals/cron";
cron("0 9 * * 1-5", { timeZone: "America/New_York" }).take(3).toArray(); // next 3 weekday 9am fire timesFaithful to cron semantics (and different from RRULE): clock-aligned rather than
anchored to a start (*/15 → :00 :15 :30 :45), and the day-of-month /
day-of-week OR quirk (when both are restricted, either matches). Full field
syntax — *, ?, ranges, steps, lists, names (JAN, MON, …); @daily/
@hourly macros; an optional leading seconds field; and Quartz day specials:
L / L-n / LW / nW (day-of-month) and dL / d#n (day-of-week).
DST policy is explicit: dstGap: "fire" (default, shift forward) or "skip";
dstOverlap: "first" (default) or "second". Also parseCron, describeCron
(humaniser), and best-effort cronToRule / ruleToCron converters (lossy — they
return null rather than guess; L/# map to RRULE nth/last).
Schedule — one interface for cron, RRULE, and ranges
A Schedule is the unifying answer to "when does this happen?" — pure, never
executes. Cron, recur, and range all compile to it.
import { Schedule } from "temporals";
import { cronSchedule } from "temporals/cron";
const s = cronSchedule("0 9 * * 1-5", { timeZone: "America/New_York" });
s.next(now); // next fire strictly after `now`
s.nextN(now, 5); // next 5
s.between(start, end); // occurrences in [start, end)
Schedule.rule({ start, freq: "monthly", byWeekday: [{ weekday: "TU", nth: 2 }] });
Schedule.range({ start, step: { days: 1 } });See examples/scheduler for a minimal reference scheduler
that runs a Schedule (the when vs do it boundary).
Calendar rounding & bucketing
import { startOf, endOf, quarterOf, fiscalQuarterOf } from "temporals";
startOf(dt, "week", { weekStart: "MO" }); // floor to a unit
endOf(dt, "month"); // exclusive upper bound (start of next)
quarterOf(date); // 1–4
fiscalQuarterOf(date, 10); // fiscal year starting in OctoberUnits: year quarter month week day hour minute second. endOf
is the exclusive next-unit start, so [startOf(p,u), endOf(p,u)) is the bucket.
Interval algebra & sets
Interval gains Allen's 13 relations (a.relation(b) → meets/overlaps/
during/…). IntervalSet is a normalized (merged, sorted) set with the
operations you need for free/busy availability:
import { Interval, IntervalSet } from "temporals";
const free = work.difference(busy); // union / intersection / difference / gaps
free.totalDuration(); // summed coverage
conflicts(bookings); // [ [a, b], … ] overlapping pairs (detection; policy is yours)See examples/availability.mjs — working hours −
meetings = open slots.
temporals/business — working time
import {
BusinessCalendar, Holidays, usFederalHolidays, easterHoliday,
WorkingHours, businessDuration, meetingSlots,
} from "temporals/business";
const cal = new BusinessCalendar({ holidays: usFederalHolidays() }); // or build your own rules
cal.isBusinessDay(date);
cal.addBusinessDays(date, 5);
cal.nthBusinessDay(2026, 1, -1); // last business day of the month (payroll)
const hours = new WorkingHours({ windows: [["22:00", "06:00"]], calendar: cal }); // overnight OK
businessDuration(start, end, hours); // elapsed working time (skips weekends/holidays/off-hours)
// Mutual availability across people, each in their own zone:
const slots = meetingSlots({
participants: [{ hours, timeZone: "America/New_York" }, { hours, timeZone: "America/Los_Angeles" }],
within, duration: { minutes: 30 },
});
// Each slot carries per-participant local times, so ranking is a one-liner:
slots.sort((a, b) => a.latestLocalHour - b.latestLocalHour); // "not too late for anyone"Holiday rules reuse the same nth-weekday logic as RRULE (Thanksgiving is
nthWeekdayHoliday(11, "TH", 4)); easterHoliday(-2) is Good Friday. observed
supports "us" / "uk" weekend-shift styles. meetingSlots is the availability
substrate — it hands you localStarts / earliestLocalHour / latestLocalHour
per slot so ranking is trivial, but the ranking policy is yours.
temporals/humanize — durations & relative time
import { humanizeDuration, formatRelative, fromNow, parseDuration } from "temporals/humanize";
humanizeDuration(Temporal.Duration.from({ hours: 2, minutes: 3 })); // "2 hours, 3 minutes"
humanizeDuration(dur, { locale: "fr" }); // localized via Intl.DurationFormat when available
parseDuration("1h30m"); // Temporal.Duration
formatRelative(from, to); // "in 5 days" (via Intl.RelativeTimeFormat)
fromNow(someDate); // "3 days ago"temporals/ics — iCalendar import/export
import { toICS, fromICS, icsToSeq } from "temporals/ics";
const ics = toICS([{ start, rrule: "FREQ=WEEKLY;COUNT=4", exdate: [skipDay] }]);
const [event] = fromICS(ics); // Temporal values + RRULE string
icsToSeq(event).toArray(); // expand DTSTART + RRULE + EXDATE/RDATEBackoff & DST helpers
import { backoff, isDST, nextTransition, transitionsBetween } from "temporals";
backoff({ base: 100, max: 5000, jitter: "equal", attempts: 6 }); // Seq<Duration> of retry delays
isDST(zdt); // heuristic (offset vs standard)
nextTransition(zdt); // next UTC-offset change, or null
transitionsBetween(a, b); // all transitions in a rangeNatural-language date parsing ("next Tuesday") is intentionally not in core —
it lives in a separate extension that wraps temporals.
Supported point types
PlainDate, PlainDateTime, ZonedDateTime (DST-correct), PlainYearMonth,
PlainTime, and Instant for range/chunks/windows. Recurrence
(recur) requires a date-bearing start (PlainDate/PlainDateTime/ZonedDateTime).
cron always produces ZonedDateTime.
Scope & limitations
Being upfront about the edges:
- Proleptic Gregorian. Like Temporal, dates use the proleptic Gregorian calendar — there is no historical Julian→Gregorian 1582 cut-over (no skipped days, no per-country adoption). Not suitable for historical dating before ~1582.
- Non-ISO calendars (partial).
range, intervals,startOf(day/week/month/ year), business-day/weekday logic, andrecurmonth/year stepping are calendar-aware and work with Hebrew, Islamic, Persian, etc. (needstemporal-polyfill/fullor native Temporal + ICU). Not generalized:quarterOf/fiscalQuarterOfassume 12-month years;byWeekNo/byYearDayare ISO-oriented; the Chinese calendar's leap months (numericmonthvsmonthCode) are only best-effort.cronis Gregorian civil time by design. isDSTis a definition, not a law. Defined as "offset above the year's minimum offset." Correct for standard summer-DST zones; negative-DST zones (e.g. Europe/Dublin) are inherently ambiguous — treat as advisory.- Leap seconds are not modeled (Temporal doesn't — it uses a POSIX-like time scale). Durations won't reflect them.
meetingSlotsfinds availability, not the optimal time. It returns the windows when everyone is free; scoring by preference, timezone fairness, or fragmentation is a solver concern left to the caller.Scheduleisn't serializable as such (it's an opaque occurrence function); serialize the source instead — cron string,formatRule(rule), or.ics.- The reference scheduler (
examples/scheduler) is a demo, not a durable/clustered job runner.
License
MIT
