@karmaniverous/rrstack
Version:
Manage a stack of RRULEs.
628 lines (502 loc) • 23 kB
Markdown
//img.shields.io/npm/v/@karmaniverous/rrstack.svg)](https://www.npmjs.com/package/@karmaniverous/rrstack)  <!-- TYPEDOC_EXCLUDE --> [](https://docs.karmanivero.us/rrstack) [](https://github.com/karmaniverous/rrstack/tree/main/CHANGELOG.md)<!-- /TYPEDOC_EXCLUDE --> [](https://github.com/karmaniverous/rrstack/tree/main/LICENSE.md)
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.
- Built on rrule for recurrence logic
- Uses Luxon for timezone/DST-correct duration arithmetic
- Pure library surface (no I/O side effects)
- JSON persistence and round-tripping
- Tested against realistic scenarios (nth-weekday monthly patterns, daylight saving transitions, etc.)
## Continuous (span) rules
Not every schedule needs a recurrence. RRStack supports “span” rules for continuous coverage bounded by optional clamps:
- Omit `options.freq` to declare a span rule.
- Omit `duration` (required for spans).
- Coverage is continuous across `[starts, ends)`; either side may be open.
- Spans participate in the cascade just like recurring rules; later rules still override earlier ones.
Example (active span; Jan 10 05:00–07:00 UTC):
```ts
const rules = [
{
effect: 'active' as const,
// duration omitted
options: {
starts: Date.UTC(2024, 0, 10, 5, 0, 0),
ends: Date.UTC(2024, 0, 10, 7, 0, 0),
},
},
];
```
UI tip: this maps naturally to a “Does not repeat” option that disables RRULE inputs.
```bash
npm install @karmaniverous/rrstack
yarn add @karmaniverous/rrstack
pnpm add @karmaniverous/rrstack
```
- ESM and CJS consumers are supported.
- TypeScript typings are included.
```ts
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?
const t = Date.now();
const isActive = stack.isActiveAt(t); // boolean
// 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 (roundtrip)
const json = stack.toJson(); // RRStackOptions (includes version)
const stack2 = new RRStack(json);
// 7) Describe a rule (plain-language)
// e.g., "Active for 1 hour: every day at 5:00 (timezone America/Chicago)"
const description = stack.describeRule(0);
```
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:
- Rules are evaluated in order; the last rule that covers an instant determines that instant’s status.
- Where no rule covers an instant, the baseline is blackout.
- Computation is performed in the rule’s timezone, with correct handling of daylight saving time (using Luxon for duration arithmetic).
## Core Concepts
- DurationParts: a structured object describing how long each occurrence lasts (non-negative integer fields; at least one > 0).
- Example: { minutes: 15 }, { hours: 1 }, { days: 1 } (calendar), { hours: 24 } (exact day)
- RuleJson: a single rule that specifies an effect ('active' or 'blackout'), a DurationParts duration, and a subset of rrule Options (plus optional starts/ends to clamp the domain).
- RRStack: an ordered list of RuleJson applied in a cascade; later rules override earlier coverage.
- Query surface:
- isActiveAt(ms): point query
- getSegments(from, to): yields contiguous segments of active/blackout status
- classifyRange(from, to): active | blackout | partial
- getEffectiveBounds(): first/last active bounds (with open-ended detection)
## API Overview
```ts
import { RRStack, toIsoDuration, fromIsoDuration, describeRule } from '@karmaniverous/rrstack';
new RRStack(opts: { version?: string; timezone: string; timeUnit?: 'ms' | 's'; rules?: RuleJson[] });
stack.toJson(): RRStackOptions // with version
// 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
// Rule management (convenience mutators; each performs one recompile)
stack.addRule(rule: RuleJson, index?: number): void
stack.removeRule(index: number): void
stack.swap(i: number, j: number): void
stack.up(i: number): void;
stack.down(i: number): void
stack.top(i: number): void;
stack.bottom(i: number): 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): boolean // true when active
stack.getSegments(
from: number,
to: number,
opts?: { limit?: 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 }
// Plain-language description
stack.describeRule(index: number, opts?: DescribeOptions): string
```
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.
```ts
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 optional. When present, it is a lower-case string
* ('yearly'|'monthly'|'weekly'|'daily'|'hourly'|'minutely'|'secondly').
* When absent, the rule is a continuous span; duration must be omitted.
*/
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; // recurring only; spans must omit
options: RuleOptionsJson;
label?: string;
}
/**
* Unified, round-trippable options shape.
* - version is optional on input and ignored by the constructor,
* and always written by toJson().
*/
export interface RRStackOptions {
version?: string;
timezone: string;
timeUnit?: 'ms' | 's';
// Baseline effect for uncovered instants. Defaults to 'auto'.
defaultEffect?: 'active' | 'blackout' | 'auto';
rules?: RuleJson[];
```
Notes
- The library compiles DurationParts into a Luxon Duration and computes ends in the rule timezone to remain DST-correct.- Half-open intervals [start, end): in 's' mode, end is rounded up to the next second to avoid boundary false negatives.
- Calendar vs exact:
- { days: 1 } means “same local time next day” (can be 23 or 25 hours across DST),
- { hours: 24 } means “exactly 24 hours.”
A JSON Schema for the serialized RRStack options (constructor input) is generated from the Zod source of truth and published with the package.
- Browse the schema file in this repo:
- assets/rrstackconfig.schema.json
- Import it at runtime:
- export constant: RRSTACK_CONFIG_SCHEMA (from '@karmaniverous/rrstack')
Generation details:
- The schema is produced by scripts/gen-schema.ts using zod-to-json-schema.
- DurationParts positivity is enforced by adding an anyOf that requires at least one of the fields (years|months|weeks|days|hours|minutes|seconds) to be an integer with minimum 1.
Baseline (defaultEffect)
- RRStack behaves as if a virtual, open-ended span rule is prepended:
- defaultEffect: 'auto' → opposite of rule 0’s effect, or 'active' if no rules,
- defaultEffect: 'active' | 'blackout' → use exactly that effect.
- The baseline applies uniformly to isActiveAt, getSegments, classifyRange, and getEffectiveBounds.
Example (programmatic access):
```ts
import { RRSTACK_CONFIG_SCHEMA } from '@karmaniverous/rrstack';
// pass to your JSON Schema validator of choice (e.g., Ajv)
console.log(RRSTACK_CONFIG_SCHEMA.$schema, 'RRStackOptions schema loaded');
```
RRStack ships a tiny React adapter at the subpath `@karmaniverous/rrstack/react`. The hooks observe a live RRStack instance without re‑wrapping its control surface (RRStack remains the single source of truth).
- `useRRStack({ json, onChange?, resetKey?, changeDebounce?, mutateDebounce?, renderDebounce?, logger? })` → `{ rrstack, version, flushChanges, flushMutations, cancelMutations, flushRender }`
- `useRRStackSelector({ rrstack, selector, isEqual?, renderDebounce?, logger?, resetKey? })` → `{ selection, version, flushRender }` (re‑renders only when selection changes).
Debounce knobs (trailing is always true)
- `changeDebounce`: coalesce autosave (`onChange`) calls.
- `mutateDebounce`: coalesce frequent UI → `rrstack` edits (e.g., typing). Mutations are staged (rules/timezone) and committed once per window.
- `renderDebounce`: coalesce version bumps from rrstack notifications to reduce repaint churn (optional leading paint).
Helpers
- `flushChanges()`: flush pending trailing autosave immediately.
- `flushMutations()`: commit staged edits right now.
- `cancelMutations()`: discard staged edits.
- `flushRender()`: force a paint now when `renderDebounce` is configured.
Staged vs compiled (mutateDebounce)
- Reads of `rrstack.rules` and `rrstack.timezone` reflect staged values prior to commit.
- `toJson()` also overlays staged values.
- Queries (`isActiveAt`, `getSegments`, etc.) continue to reflect the last committed compiled state until a commit occurs. Example (debounced autosave + staged edits)
```tsx
import { useRRStack } from '@karmaniverous/rrstack/react';
import type { RRStackOptions } from '@karmaniverous/rrstack';
function Editor({ json }: { json: RRStackOptions }) {
const {
rrstack,
version,
flushChanges,
flushMutations,
cancelMutations,
flushRender,
} = useRRStack({
json,
onChange: (s) => {
void saveToServer(s.toJson());
},
changeDebounce: { delay: 600 },
mutateDebounce: { delay: 150 },
renderDebounce: { delay: 50, leading: true },
});
// Use `version` to memoize heavy derived values (e.g., segments)
return (
<button
onClick={() => {
// Optionally force a commit + autosave now
flushMutations();
flushChanges();
}}
>
Save now
</button>
);
}
```
See “Handbook → React” for full details (options, staged vs compiled behavior, examples): https://docs.karmanivero.us/rrstack
## Rule description helpers
Build a human-readable string describing a rule’s cadence using rrule’s toText(), augmented with effect and duration.
- Instance method (describe compiled rule by index):
```ts
const text = stack.describeRule(0); // "Active for 1 hour: every day at 5:00 (timezone America/Chicago)"
const textWithBounds = stack.describeRule(0, {
includeTimeZone: true, // default true
includeBounds: false, // default false; when true, appends [from ...; until ...] if present
});
```
- Helper function (compile on the fly from JSON):
```ts
import { describeRule, RRStack } from '@karmaniverous/rrstack';
const rule = {
effect: 'active' as const,
duration: { hours: 1 },
options: {
freq: 'daily' as const,
byhour: [9],
byminute: [0],
bysecond: [0],
},
};
const text = describeRule(rule, RRStack.asTimeZoneId('UTC'), 'ms');
// => "Active for 1 hour: every day at 9:00 (timezone UTC)"
```
These utilities can be handy for interop (config files, CLI, or user input).
```ts
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'
```
- getSegments accepts an optional per-call limit to bound enumeration explicitly:
- [...stack.getSegments(from, to, { limit: 1000 })] throws once the limit would be exceeded (no silent truncation).
Performance note
- The iterator is streaming and memory-bounded, but the number of segments can grow large when many rules overlap across long windows. For very large windows or real-time UI, prefer chunking by day/week and use the limit option to guard enumeration.
## Open-ended bounds example
```ts
// Daily 05:00–06:00 starting on 2024-01-10, with no end clamp (open end)
const stack = new RRStack({
timezone: 'UTC',
rules: [
{
effect: 'active',
duration: { hours: 1 },
options: {
freq: 'daily',
byhour: [5],
byminute: [0],
bysecond: [0],
starts: Date.UTC(2024, 0, 10, 0, 0, 0),
},
},
],
});
const b = stack.getEffectiveBounds(); // { start: 2024-01-10T05:00Z, end: undefined, empty: false }
```
- All coverage is computed in the rule’s IANA timezone (tzid).
- Occurrence end times are computed by adding the rule’s duration in the rule’s timezone using Luxon. This keeps “spring forward” and “fall back” behavior correct:
- Example: “2021-03-14 01:30 + 1h” in America/Chicago → 03:30 local (spring forward)
- Example: “2021-11-07 01:30 + 1h” → 01:30 local (repeated hour on fall back)
### Selecting and enumerating time zones
- 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:
- RRStack.isValidTimeZone('America/Chicago') => boolean
- RRStack.asTimeZoneId('America/Chicago') => branded type or throws
- Enumerate supported zones in the current environment (when available):
```ts
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)
- Lightweight: @vvo/tzdb (JSON of IANA zones + metadata)
- Heavier: moment-timezone (moment.tz.names())
- References
- IANA TZDB: https://www.iana.org/time-zones
- Wikipedia list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
## Bounds and clamp semantics
- Cascade and ties
- Later rules override earlier ones at covered instants. If a blackout and an active rule both start at the same instant, the later rule in the list wins for that instant (and its duration).
- Domain clamps (starts/ends)
- RRStack maps `options.starts` to RRULE `dtstart` and `options.ends` to RRULE `until`. RRULE’s `until` is inclusive of the last start; i.e., a start that is exactly equal to `until` may still occur. Plan clamps with this inclusive behavior in mind.
- Intervals produced by RRStack are evaluated as half‑open `[start, end)`. In `'s'` timeUnit mode, RRStack rounds computed ends up to the next integer second to avoid boundary false negatives.
- getEffectiveBounds
- `start` is the first instant the cascade is active (omitted when the cascade is open‑start and already active from the domain minimum).
- `end` is the last instant after which the cascade is not active anymore. For open‑ended coverage, `end` is omitted (`undefined`).
- Internally, the latest bound is determined relative to a far‑future probe: for closed schedules, this yields the last finite active end; for truly open‑ended actives, `end` remains `undefined`.
- Time zones and DST
- All coverage (including end arithmetic) is computed in the rule’s IANA time zone. In `'s'` mode, duration spans remain exact integer seconds across DST transitions (e.g., 3600 seconds for a 1‑hour rule).
## Version handling
- toJson writes the current package version via a build-time injected constant (`__RRSTACK_VERSION__`) so no package.json import is needed at runtime.- The constructor accepts RRStackOptions with an optional version key and ignores it. Version-based transforms may be added in the future without changing the public shape.
## Common Patterns
Third Tuesday monthly at 05:00–06:00
```ts
import { RRule } from 'rrule';
const thirdTuesday = {
effect: 'active' as const,
duration: { hours: 1 },
options: {
freq: 'monthly',
bysetpos: 3,
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(2024, 0, 16, 5, 0, 0),
},
label: '3rd-tue-05',
};
```
Daily at 09:00 starting on a date boundary
```ts
// 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
```ts
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],
},
};
```
- Later rules override earlier ones at covered instants; order matters.
- When using interval-based monthly rules, anchoring starts to the first real occurrence can be helpful to define cadence.
- starts/ends (timestamps in the configured unit) are optional domain clamps; open sides are allowed and detected by getEffectiveBounds.
- The library ships with unit tests (Vitest) covering:
- DST transitions (spring forward/fall back)
- Daily start-at-midnight patterns
- Monthly odd-month and every-2-month scenarios with blackout/reactivation cascades
- Segment sweeps and range classification
Run locally:
```bash
npm run test
npm run lint
npm run typecheck
npm run build
```
## License
BSD-3-Clause © Jason Williscroft
Built for you with ❤️ on Bali! Find more great tools & templates on my GitHub Profile: https://github.com/karmaniverous
[![npm version](https: