@johnhenry/temporals
v0.2.0
Published
Lazy sequences, ranges, intervals, and RRULE recurrence built on the TC39 Temporal API.
Maintainers
Readme
temporals
Previously published as
temporals(last release 0.0.2, now deprecated). Renamed to@johnhenry/temporalsand restarted at 0.0.0 on import into the @johnhenry family — a new address and era, not a maturity signal.
Full documentation: opensource.johnhenry.me/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 "@johnhenry/temporals";
// The next 10 weekdays, lazily.
range({ start: Temporal.Now.plainDateISO(), step: { days: 1 } })
.filter((d) => d.dayOfWeek <= 5)
.take(10)
.toArray();Contents
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 @johnhenry/temporals
# On Node < 22, also:
npm install temporal-polyfillDocumentation
- Guided docs — trap-first guides for the core and every subpath at https://opensource.johnhenry.me/temporals/.
- API reference — generated from JSDoc via TypeDoc; run
npm run docsto build 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 "@johnhenry/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 converters:
cronToRule(expr, start) → RecurRule | null— cron → RRULE (lossy; returnsnullrather than guess;L/#map to RRULEnth/last)ruleToCron(rule) → string | null— RRULE → cron (5-field standard). Returnsnullfor non-representable rules (intervals ≠ 1, sub-daily, bySetPos, byWeekNo, byYearDay, unsupportednthvalues).ruleToCronDetailed(rule) → { cron: string | null, reason?: string }— likeruleToCron, but explains why a conversion failed (e.g., "needs byHour", "interval must be 1"). Use this to tell users what to change in their recurrence rule.
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 "@johnhenry/temporals";
import { cronSchedule } from "@johnhenry/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 "@johnhenry/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 "@johnhenry/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 "@johnhenry/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 "@johnhenry/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 "@johnhenry/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 "@johnhenry/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/fiscalQuarterOf/fiscalYearOfassume 12-month years (they throwRangeErrorotherwise);isoWeekOfis ISO-calendar only;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, or usedstSavingsNanoseconds(zdt)for the raw shift and decide yourself.- 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.- Conflict resolution is out of scope.
conflicts()detects overlapping bookings; coalescing, first-fit placement, and priority policy stay app-level. 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.
Family
| Consumes Temporal for | Library | Status |
|---|---|---|
| Lazy sequences, ranges, intervals, RRULE recurrence, cron | @johnhenry/temporals (this package) | — |
| Publishing the current wall-clock time, in any IANA zone, as CSS custom properties | @johnhenry/css-signals | published — independent sibling; no dependency either direction, both read the TC39 Temporal API directly (css-signals' date() source reads Temporal the same way this package's generators do) |
License
MIT
