Timezone-aware RRULE stacking engine for Node/TypeScript.
RRStack lets you compose a prioritized stack of time-based rules (using the battle-tested rrule library) to compute whether a given instant is active or blackout, enumerate active/blackout segments over a window, classify ranges as active/blackout/partial, and derive effective active bounds. It handles real-world timezone behavior, including DST transitions, by computing coverage in the rule’s IANA timezone.
npm install @karmaniverous/rrstack
# or
yarn add @karmaniverous/rrstack
# or
pnpm add @karmaniverous/rrstack
import { RRStack } from '@karmaniverous/rrstack';
// 1) Define rules (JSON serializable)
const rules = [
// Daily 05:00–06:00 active
{
effect: 'active' as const,
duration: { hours: 1 },
options: {
freq: 'daily',
byhour: [5],
byminute: [0],
bysecond: [0],
},
label: 'daily-05',
},
// Blackout 05:30–05:45 (overrides active during that slice)
{
effect: 'blackout' as const,
duration: { minutes: 15 },
options: {
freq: 'daily',
byhour: [5],
byminute: [30],
bysecond: [0],
},
label: 'blk-0530-15m',
},
];
// 2) Create a stack
const stack = new RRStack({
timezone: 'America/Chicago',
// Optional: timeUnit: 'ms' | 's' (default 'ms')
rules,
});
// 3) Point query: active or blackout?
const t = Date.now();
const status = stack.isActiveAt(t); // 'active' | 'blackout'
// 4) Enumerate segments over a window (half-open [from, to))
const from = Date.UTC(2024, 0, 2, 5, 0, 0);
const to = Date.UTC(2024, 0, 2, 6, 0, 0);
for (const seg of stack.getSegments(from, to)) {
// { start: number; end: number; status: 'active' | 'blackout' }
// 05:00–05:30 active, 05:30–05:45 blackout, 05:45–06:00 active
}
// 5) Classify a whole range
const range = stack.classifyRange(from, to); // 'active' | 'blackout' | 'partial'
// 6) Persist / restore
const json = stack.toJson(); // RRStackJson
const stack2 = RRStack.fromJson(json);
Many scheduling problems require more than a single RRULE. You might have a base “active” cadence and a set of blackout exceptions that override it in specific conditions, or a few “reactivation” windows that override blackouts. RRStack provides a minimal, deterministic cascade:
import { RRStack, toIsoDuration, fromIsoDuration } from '@karmaniverous/rrstack';
new RRStack(opts: { timezone: string; timeUnit?: 'ms' | 's'; rules?: RuleJson[] });
RRStack.fromJson(json: RRStackJson): RRStack
stack.toJson(): RRStackJson
// Options (frozen); property-style setters
stack.timezone: string // getter
stack.timezone = 'America/Chicago' // setter (validates and recompiles)
stack.rules: ReadonlyArray<RuleJson> // getter
stack.rules = [/* ... */] // setter (validates and recompiles)
stack.timeUnit: 'ms' | 's' // getter (immutable)
// Batch update
stack.updateOptions({ timezone?: string, rules?: RuleJson[] }): void
// Helpers
stack.now(): number // current time in configured unit
RRStack.isValidTimeZone(tz: string): boolean
RRStack.asTimeZoneId(tz: string): TimeZoneId // throws if invalid
// Queries
stack.isActiveAt(ms: number): 'active' | 'blackout'
stack.getSegments(
from: number,
to: number,
): Iterable<{ start: number; end: number; status: 'active' | 'blackout' }>
stack.classifyRange(
from: number,
to: number,
): 'active' | 'blackout' | 'partial'
stack.getEffectiveBounds(): { start?: number; end?: number; empty: boolean }
See full API docs: https://karmaniverous.github.io/rrstack
The public types closely mirror rrule’s Options, with a few adjustments to make JSON persistence straightforward and unit-aware operation explicit.
import type { Options as RRuleOptions } from 'rrule';
export type instantStatus = 'active' | 'blackout';
export type rangeStatus = instantStatus | 'partial';
export type UnixTimeUnit = 'ms' | 's';
// Branded IANA timezone id after runtime validation.
export type TimeZoneId = string & { __brand: 'TimeZoneId' };
// Structured duration (all fields non-negative integers; at least one > 0).
export interface DurationParts {
years?: number;
months?: number;
weeks?: number;
days?: number;
hours?: number;
minutes?: number;
seconds?: number;
}
/**
* JSON shape for rule options:
* - Derived from RRuleOptions with dtstart/until/tzid removed (set internally),
* - Adds starts/ends (in configured unit) for domain clamping,
* - freq is a lower-case string ('yearly'|'monthly'|'weekly'|'daily'|'hourly'|'minutely'|'secondly').
*/
export type RuleOptionsJson = Partial<
Omit<RRuleOptions, 'dtstart' | 'until' | 'tzid' | 'freq'>
> & {
freq:
| 'yearly'
| 'monthly'
| 'weekly'
| 'daily'
| 'hourly'
| 'minutely'
| 'secondly';
starts?: number; // optional clamp (timestamp in configured unit)
ends?: number; // optional clamp (timestamp in configured unit)
};
export interface RuleJson {
effect: instantStatus; // 'active' | 'blackout'
duration: DurationParts; // structured, positive total
options: RuleOptionsJson;
label?: string;
}
export interface RRStackJson {
version: string;
timezone: TimeZoneId;
timeUnit: 'ms' | 's';
rules: RuleJson[];
}
Notes
A JSON Schema for the serialized RRStack options (constructor input) is generated from the Zod source of truth and published with the package.
Generation details:
Example (programmatic access):
import { RRSTACK_CONFIG_SCHEMA } from '@karmaniverous/rrstack';
// pass to your JSON Schema validator of choice (e.g., Ajv)
console.log(RRSTACK_CONFIG_SCHEMA.$schema, 'RRStackJson schema loaded');
These utilities can be handy for interop (config files, CLI, or user input).
import { toIsoDuration, fromIsoDuration } from '@karmaniverous/rrstack';
// Build an ISO string from structured parts
toIsoDuration({ hours: 1, minutes: 30 }); // 'PT1H30M'
toIsoDuration({ days: 1 }); // 'P1D' (calendar day)
toIsoDuration({ hours: 24 }); // 'PT24H' (exact day)
toIsoDuration({ weeks: 2 }); // 'P2W'
toIsoDuration({ weeks: 1, days: 2 }); // 'P9D' (weeks normalized to days)
// Parse an ISO string to structured parts (integers only)
fromIsoDuration('PT1H30M'); // { hours: 1, minutes: 30 }
fromIsoDuration('P1D'); // { days: 1 }
fromIsoDuration('PT24H'); // { hours: 24 }
fromIsoDuration('P2W'); // { weeks: 2 }
// Invalid inputs (throw):
// - fractional values like 'PT1.5H'
// - mixed weeks with other fields like 'P1W2D'
RRStackOptions.timezone expects an IANA time zone identifier (e.g., 'America/Chicago', 'Europe/London', 'UTC').
Validation is performed at runtime (Luxon’s IANAZone.isValidZone). Acceptance depends on the host’s ICU/Intl data (Node build, browser, OS). Always validate user input:
Enumerate supported zones in the current environment (when available):
import { RRStack } from '@karmaniverous/rrstack';
// List zones supported by this runtime (modern Node/browsers)
const zones =
typeof Intl.supportedValuesOf === 'function'
? Intl.supportedValuesOf('timeZone')
: [];
// Optional: filter/validate with RRStack to be safe
const validZones = zones.filter(RRStack.isValidTimeZone);
Cross-environment pickers: ship a curated list (still validate at runtime)
References
Third Tuesday monthly at 05:00–06:00
import { RRule } from 'rrule';
const thirdTuesday = {
effect: 'active' as const,
duration: { hours: 1 },
options: {
freq: 'monthly',
byweekday: [RRule.TU.nth(3)],
byhour: [5],
byminute: [0],
bysecond: [0],
// Optional: anchor the cadence with starts to define the interval phase.
// starts: Date.UTC(2021, 0, 19, 5, 0, 0),
},
label: '3rd-tue-05',
};
Daily at 09:00 starting on a date boundary
// starts at midnight local; BYHOUR/BYMINUTE produce the 09:00 occurrence
const daily9 = {
effect: 'active' as const,
duration: { hours: 1 },
options: {
freq: 'daily',
byhour: [9],
byminute: [0],
bysecond: [0],
// Set to midnight on the start date in the target timezone.
// The first occurrence begins at 09:00 on/after this date.
// starts: ms('2021-05-01T00:00:00'),
},
};
Odd months only, with an exception and a reactivation
import { RRule } from 'rrule';
const baseOddMonths = {
effect: 'active' as const,
duration: { hours: 1 },
options: {
freq: 'monthly',
bymonth: [1, 3, 5, 7, 9, 11],
byweekday: [RRule.TU.nth(3)],
byhour: [5],
byminute: [0],
bysecond: [0],
// Anchor to a known occurrence to define stepping
// starts: ms('2021-01-19T05:00:00'),
},
};
const julyBlackout = {
effect: 'blackout' as const,
duration: { hours: 1 },
options: {
freq: 'yearly',
bymonth: [7],
byweekday: [RRule.TU.nth(3)],
byhour: [5],
byminute: [0],
bysecond: [0],
},
};
const july20Reactivate = {
effect: 'active' as const,
duration: { hours: 1 },
options: {
freq: 'yearly',
bymonth: [7],
bymonthday: [20],
byhour: [5],
byminute: [0],
bysecond: [0],
},
};
Run locally:
npm run test
npm run lint
npm run typecheck
npm run build
BSD-3-Clause © Jason Williscroft
Built for you with ❤️ on Bali! Find more great tools & templates on my GitHub Profile: https://github.com/karmaniverous