calendaryjs
v0.9.0
Published
Composable calendar & recurrence engine with pluggable calendar systems.
Downloads
3,246
Maintainers
Readme
- 🔁 Recurrence engine — daily, weekly, monthly, nth-weekday, formula, relative
- 🔌 Pluggable calendar systems — one engine, every calendar
- ✍️ Fluent builder —
every("week").on("mon", "wed", "fri") - 🔍 Query API — filter events across groups & date ranges
- 🧩 Collections (
.cdy) + ICS export — plain, serializable data - 🪶 Zero-dependency · ~7KB gzip · browser · Node · edge · RN
Overview
calendaryjs turns event rules into dated occurrences. You declare events (Christmas, every other Tuesday, the 2nd Sunday of May…), the engine expands their recurrence into concrete dates, and you read, query, or export the result. The core speaks the Gregorian calendar; plugins teach it others — lunar, hijri, liturgical, or your own.
declare (builder) → expand (engine) → read · query · exportWhat it isn't. calendaryjs is the expansion engine — rules → dated occurrences
as plain data. It is timezone-neutral, plain-date (YYYY-MM-DD): ideal for all-day /
annual events, not a meeting scheduler (time-of-day + cross-timezone resolution is
the host's job). It doesn't persist your data, render the calendar, or deliver
notifications (reminders are pre-computed offsets, not fired). Licensed
PolyForm Noncommercial — commercial use needs a
separate license. Wire the rest to your own stack.
Install
npm i calendaryjs1 · Your first event
Declare events, then read them back as dated occurrences:
import { calendary } from "calendaryjs";
import { every, date } from "calendaryjs/builder";
const cal = calendary();
cal.add(every("year").on(date(12, 25)).title("Christmas"));
cal.getEvents("2026-12-25");
// → [{ title: "Christmas", date: "2026-12-25", type: "const", … }]To organize events into named groups you can toggle or export separately, use
cal.addGroup({ id: "holidays", events: [...] }) instead.
2 · Recurring events — the builder
Every declaration reads like a sentence — frequency → position → payload — and compiles to a plain, serializable config:
import { every, once, date, nth } from "calendaryjs/builder";
every("year").on(date(1, 1)).title("New Year"); // Jan 1, every year
every("year")
.on(nth(4, "thursday", "nov"))
.title("Thanksgiving");
every(2, "weeks").on("tuesday").title("Standup"); // every other Tuesday
every("week").on("monday", "wednesday", "friday").title("Gym"); // pick weekdays
every("month").on(nth(1, "monday")).title("Retro"); // 1st Monday of every month
every("month").on(nth(-2, "friday")).title("Review"); // 2nd-to-last Friday
every("month").on(-1).title("Rent"); // last day of every month
every(3, "days").title("Water plants"); // every 3 days
every(2, "years").on(date(6, 1)).starting(2025).title("Reunion"); // every other year
every("week").on("tuesday").starting("2026-09-01").times(10).title("Course"); // ends after 10
once("2026-06-15").title("Wedding"); // a single datePrefer raw objects? The builder just
build()s into a plain config — hand-write it if you like; both compile to the same model, anddescribe(config)renders a config back as its builder sentence for debugging. Full grammar: the builder guide.
3 · Read & query
cal.getEvents("2026-12-25"); // a single day
cal.getEventsInRange("2026-01-01", "2026-12-31"); // a range
cal.search().group("holidays").range("2026-01-01", "2026-12-31").getEvents();
// Filter by kind, status, or source (each matches any of the listed values).
// Every search sets a window — .range(), .date(), .year(), or .month().
cal.search().type("weekly").status("confirmed").source("work-feed").year(2026).getEvents();4 · Add a calendar system
A plugin extends the engine and the builder's vocabulary — install it, use() it,
and new event types become available:
import { lunar } from "calendaryjs-plugin-lunar";
const cal = calendary().use(lunar());
cal.addGroup({
id: "lunar",
events: [every("year").on(lunar.date(1, 1)).title("Lunar New Year")],
});| Plugin | Adds | | -------------------------------------------------------------------------------------------- | ------------------------------------------ | | calendaryjs-plugin-lunar | Lunar (lunisolar) date conversion + events | | calendaryjs-plugin-hijri | Islamic (Hijri) date conversion + events | | calendaryjs-plugin-liturgical | Easter computus, movable feasts, seasons |
5 · Bundle & share — collections
Bundle events into a portable collection (a .cdy file is its JSON form) and load it
with cal.load(). It declares the plugins its events need; register those first —
load() validates them and errors clearly if any is missing.
const cal = calendary().use(lunar());
cal.load({
collection: "holidays",
plugins: ["calendaryjs-plugin-lunar"],
events: [
{ type: "const", id: "new-year", month: 1, day: 1, title: "New Year" },
{ type: "lunar", id: "tet", lunarMonth: 1, lunarDay: 1, title: "Lunar New Year" },
],
});
cal.load(jsonString); // …or from a .cdy (JSON) string, read from disk or fetchedThe reverse — cal.toCollection() — serializes events back out, declaring the plugins
their types need so the result re-loads cleanly. JSON.stringify it for a .cdy file:
const doc = cal.toCollection({ name: "holidays" });
// → { collection: "holidays", plugins: ["calendaryjs-plugin-lunar"], events: [ … ] }
JSON.stringify(doc); // the .cdy form — write to disk, or POST it somewhereValidate a .cdy (editors + AI). A JSON Schema ships with the package
(calendaryjs/schema/cdy.schema.json) and is published at
https://calendaryjs.dev/schema/cdy.json. Add it to any collection for
autocomplete + on-the-spot validation in VS Code — and so an AI generating a
collection self-corrects:
{
"$schema": "https://calendaryjs.dev/schema/cdy.json",
"collection": "holidays",
"events": [{ "type": "const", "id": "ny", "month": 1, "day": 1, "title": "New Year" }]
}Building tooling on top of calendaryjs? See
/llms.txt— a compact, full API reference written for LLMs/agents.
More examples — copy-paste recipes, simplest first (a first calendar → recurring patterns → milestone anniversaries → composing collections): calendaryjs.dev/examples.
Reference
Event types
| Type | Description | Example |
| ------------- | ------------------------------------------------- | --------------------------- |
| const | Fixed annual date (negative day = from month end) | Christmas (Dec 25) |
| fixed | One-time date | Wedding (Jun 15, 2025) |
| monthly | Same day every month (interval; -1 = last day) | Payday (15th) · Rent (-1) |
| weekly | One or more weekdays (interval/range) | Standup (Mon, Wed, Fri) |
| daily | Every day, or every N days | Water plants (every 3 days) |
| nth-weekday | Nth weekday of a month (±1–±5) — or every month | 1st Monday of every month |
| formula | Custom formula | Last business day of May |
| relative | Offset from a registered anchor | 49 days after an anchor |
Plugins add more types (e.g. lunar, hijri, liturgical).
Event properties
Every event shares BaseEventProperties plus its type-specific date fields.
id, type, and title are required; everything else is optional.
| Property | Type | Notes |
| ---------------------------------- | ------------------------------------- | ---------------------------------------------------- |
| id · type · title | string | required |
| description · location · url | string | display metadata |
| icon · color | string | per-event motif |
| allDay | boolean | — |
| startTime · endTime | string | "09:00" … "17:00" |
| duration | number | minutes |
| keywords · categories | string[] | classification |
| status | confirmed \| tentative \| cancelled | — |
| priority | number | z-index on a shared day; higher = on top (default 0) |
| source | string | origin: plugin / feed / ICS subscription |
| metadata | Record<string, unknown> | arbitrary per-event data |
| reminders | Reminder[] | lead-time offsets (→ ICS VALARM) |
| startDate · endDate | string | validity window, inclusive (ICS DTSTART/UNTIL) |
| count | number | end after N occurrences (RRULE COUNT) |
| onConflict | ConflictResolver | same-day conflict: keep / drop / reschedule |
| overrideDates | OverrideDatesMap | force-move an occurrence to another date |
| exceptions | EventExceptions | per-occurrence skip / override (ICS EXDATE) |
Rescheduling & conflicts
Three optional directives reshape where a recurring event's occurrences land. All are
applied at generation time and keyed by the originally computed date (YYYY-MM-DD),
in this order: count → exceptions → overrideDates → onConflict.
overrideDates — unconditionally move specific occurrences, when you already know
the exact dates to shift:
{
type: "monthly", id: "payday", day: 1, title: "Payday",
// The 1st is closed in these two months → pay on the previous day.
overrideDates: { "2026-01-01": "2025-12-31", "2026-05-01": "2026-04-30" },
}onConflict — decide per occurrence at runtime: keep, drop, or reschedule.
Pass a function (evaluated for every date) or a date-keyed map:
{
type: "weekly", id: "standup", dayOfWeek: 1, title: "Standup", // Mondays
onConflict: date =>
closedDays.has(date) ? { action: "reschedule", to: nextOpenDay(date) } : { action: "keep" },
}exceptions — edit a single occurrence in place (ICS EXDATE / RECURRENCE-ID):
{ skip: true } drops it, { override: { … } } changes just that instance:
{
type: "weekly", id: "class", dayOfWeek: 3, title: "Class", // Wednesdays
exceptions: {
"2026-07-01": { skip: true },
"2026-07-08": { override: { title: "Class (guest speaker)" } },
},
}License
PolyForm Noncommercial 1.0.0 — free for any noncommercial purpose. Commercial use (incl. ad-supported sites) requires a commercial license — see Commercial licensing.
