Model events and tasks as JSCalendar
Store and serve events and tasks as RFC 8984 JSCalendar JSON, expand their recurrences into an agenda, edit them with PatchObjects, and convert them to and from iCalendar.
Last updated
After reading this guide, you will know
- How to validate JSCalendar events and tasks from a request with
@sdxc/jscalendar - How to expand recurring events into an agenda and edit one occurrence with a PatchObject
- When to convert JSCalendar to iCalendar, and how to import an
.icsfile into it
An event people actually schedule repeats every Monday in Madrid, skips a holiday, moves one
week to 11:00 and reminds everyone five minutes before. JSCalendar
is that whole event as one JSON object: the iCalendar model as data you store in a column and
accept in an API body. This guide keeps a team agenda as JSCalendar events and tasks, serves it
as JSON, expands its recurrences, lets a client edit an entry with a PatchObject, and converts
it to and from iCalendar with @sdxc/jscalendar and
@sdxc/icalendar.
npm add @sdxc/http @sdxc/icalendar @sdxc/jscalendar @sdxc/result @sdxc/validate remixDeclare the agenda's routes
The agenda takes entries and edits as JSON, serves the collection as JSCalendar and as an
iCalendar feed, and accepts an uploaded .ics file:
import { get, patch, post, route } from "remix/routes";
export default route({
agenda: {
show: get("/agenda"),
json: get("/agenda.json"),
feed: get("/agenda.ics"),
create: post("/agenda/entries"),
update: patch("/agenda/entries/:uid"),
import: post("/agenda/import"),
},
});Accept an event or a task
parse reads JSON text as an Event, a Task or a Group, discriminated by @type, and checks
every value format and the rules that span properties:
import { created, unprocessableEntity } from "@sdxc/http/response/json";
import { parse } from "@sdxc/jscalendar";
import { toICalendar } from "@sdxc/jscalendar/icalendar";
import { isFailure } from "@sdxc/result";
import { createAction } from "remix/router";
import { Entries } from "~/app/models/entries";
import routes from "~/routes/web";
export default createAction(routes.agenda.create, async (ctx) => {
let parsed = parse(await ctx.request.text());
if (isFailure(parsed))
return unprocessableEntity({ issues: parsed.error.issues });
let entry = parsed.data;
if (entry["@type"] === "Group") {
return unprocessableEntity({ error: "Send one Event or Task at a time." });
}
let convertible = toICalendar(entry);
if (isFailure(convertible))
return unprocessableEntity({ error: convertible.error.message });
await Entries.save(ctx.db, entry);
return created({ uid: entry.uid });
});A failure carries issues, each a path and a message.
Entries is your own model. Store stringify(entry) from @sdxc/jscalendar in a text column
keyed by uid and read it back through parse, so a row is the same object that came in.
Serve the agenda as JSCalendar
A collection is a Group whose entries are the events and tasks. jscalendarResponse writes
it with the application/jscalendar+json; type=group content type RFC 8984 requires:
import type { JSCalendar } from "@sdxc/jscalendar";
import { jscalendarResponse } from "@sdxc/jscalendar";
import { createAction } from "remix/router";
import { Entries } from "~/app/models/entries";
import routes from "~/routes/web";
export default createAction(routes.agenda.json, async (ctx) => {
let group: JSCalendar.Group = {
"@type": "Group",
uid: "agenda@example.com",
title: "Team agenda",
entries: await Entries.all(ctx.db),
};
return jscalendarResponse(group);
});Expand recurrences into an agenda
A page shows occurrences, not rules. occurrences from @sdxc/jscalendar/recurrence expands
an event or task over a window, applying its overrides. Merge every entry's occurrences and sort
them:
import type { JSCalendar } from "@sdxc/jscalendar";
import type { Occurrence } from "@sdxc/jscalendar/recurrence";
import { occurrences } from "@sdxc/jscalendar/recurrence";
import { isFailure, success } from "@sdxc/result";
const LOOKAHEAD_MS = 14 * 86_400_000;
export function upcoming(entries: JSCalendar.Entry[], now: number, timeZone: string) {
let all: Occurrence[] = [];
for (let entry of entries) {
let range = { from: now, to: now + LOOKAHEAD_MS, timeZone };
let expanded = occurrences(entry, range);
if (isFailure(expanded)) return expanded;
all.push(...expanded.data);
}
return success(all.sort((a, b) => a.start - b.start));
}Each occurrence is { recurrenceId, start, end, object }, with start and end in epoch
milliseconds and object the instance with its override applied. timeZone is the zone an
entry without its own is read in: pass the viewer's.
Edit an entry with a PatchObject
JSCalendar edits are PatchObjects: keys are JSON Pointers without the leading /, a value sets
the property and null removes it. applyPatch applies one to a copy, all or nothing, and
parse checks the result is still a valid object:
import type { JSCalendar, JSCalendarParseError, PatchError } from "@sdxc/jscalendar";
import type { Result } from "@sdxc/result";
import { applyPatch, parse } from "@sdxc/jscalendar";
import { isFailure } from "@sdxc/result";
export function editEntry(
entry: JSCalendar.Entry,
patch: JSCalendar.PatchObject,
): Result<JSCalendar.CalendarObject, PatchError | JSCalendarParseError> {
let patched = applyPatch(entry, patch);
if (isFailure(patched)) return patched;
return parse(patched.data);
}import { notFound, unprocessableEntity } from "@sdxc/http/response/json";
import { jscalendarResponse } from "@sdxc/jscalendar";
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import * as s from "remix/data-schema";
import { createAction } from "remix/router";
import { Entries } from "~/app/models/entries";
import { editEntry } from "~/app/services/agenda/edit";
import routes from "~/routes/web";
const PATCH = s.record(s.string(), s.any());
export default createAction(routes.agenda.update, async (ctx) => {
let { uid } = s.parse(s.object({ uid: s.string() }), ctx.params);
let entry = await Entries.find(ctx.db, uid);
if (entry === null) return notFound({ error: "No such entry." });
let patch = await validate(ctx.request, PATCH);
if (isFailure(patch)) return unprocessableEntity({ issues: patch.error.issues });
let edited = editEntry(entry, patch.data);
if (isFailure(edited))
return unprocessableEntity({ error: edited.error.message });
if (edited.data["@type"] === "Group") {
return unprocessableEntity({ error: "An entry stays an Event or a Task." });
}
await Entries.save(ctx.db, edited.data);
return jscalendarResponse(edited.data);
});A single occurrence is edited through recurrenceOverrides, keyed by the recurrenceId the
expansion reported. { "recurrenceOverrides/2026-03-30T09:30:00": { "excluded": true } }
cancels that Monday, and { "start": "2026-03-30T11:00:00" } in its place moves it.
Publish and import iCalendar
Calendar apps subscribe to iCalendar, so the same agenda goes out as a feed. toICalendar
turns a Group, Event or Task into @sdxc/icalendar's model, time zones and overrides included:
import { internalServerError } from "@sdxc/http/response/html";
import { calendarResponse } from "@sdxc/icalendar";
import { toICalendar } from "@sdxc/jscalendar/icalendar";
import { isFailure } from "@sdxc/result";
import { createAction } from "remix/router";
import { Entries } from "~/app/models/entries";
import routes from "~/routes/web";
export default createAction(routes.agenda.feed, async (ctx) => {
let calendar = toICalendar(
{
"@type": "Group",
uid: "agenda@example.com",
title: "Team agenda",
entries: await Entries.all(ctx.db),
},
{ productId: "-//example//agenda//EN" },
);
if (isFailure(calendar)) return internalServerError(calendar.error.message);
return calendarResponse(calendar.data);
});Importing runs the other way. fromICalendar reads a parsed calendar into a Group: each
VEVENT an Event, each VTODO a Task, with its overrides folded back in:
import { redirect } from "@sdxc/http/response";
import { badRequest } from "@sdxc/http/response/html";
import { parse } from "@sdxc/icalendar";
import { fromICalendar } from "@sdxc/jscalendar/icalendar";
import { isFailure } from "@sdxc/result";
import { createAction } from "remix/router";
import { Entries } from "~/app/models/entries";
import routes from "~/routes/web";
export default createAction(routes.agenda.import, async (ctx) => {
let file = ctx.formData.get("calendar");
if (!(file instanceof File)) return badRequest("Choose an .ics file to import.");
let parsed = parse(await file.text());
if (isFailure(parsed)) return badRequest(parsed.error.message);
let group = fromICalendar(parsed.data.calendar);
for (let entry of group.entries) await Entries.save(ctx.db, entry);
return redirect(routes.agenda.show.href(), { status: redirect.Status.SeeOther });
});An iCalendar property with no JSCalendar counterpart is kept and written back by toICalendar,
so an import followed by a feed loses nothing. Save by uid, and a second import updates rows
instead of duplicating them.
Test the agenda
Test upcoming across a daylight saving change: Madrid moves its clocks on 29 March 2026, so
the 09:30 standup starts at 08:30 UTC before it and 07:30 after. A second test checks a moved
occurrence survives a trip through iCalendar:
import type { JSCalendar } from "@sdxc/jscalendar";
import { parse as parseICalendar, stringify } from "@sdxc/icalendar";
import { fromICalendar, toICalendar } from "@sdxc/jscalendar/icalendar";
import { unwrap } from "@sdxc/result";
import { expect, test } from "vitest";
import { upcoming } from "~/app/services/agenda/upcoming";
const STANDUP: JSCalendar.Event = {
"@type": "Event",
uid: "standup@example.com",
title: "Standup",
start: "2026-03-23T09:30:00",
timeZone: "Europe/Madrid",
duration: "PT15M",
recurrenceRules: [{ frequency: "weekly", byDay: [{ day: "mo" }] }],
};
test("keeps 09:30 Madrid time across the daylight saving change", () => {
let found = unwrap(upcoming([STANDUP], Date.UTC(2026, 2, 22), "UTC"));
expect(found.map((occurrence) => occurrence.start)).toEqual([
Date.UTC(2026, 2, 23, 8, 30),
Date.UTC(2026, 2, 30, 7, 30),
]);
});
test("moves one occurrence and keeps the move through iCalendar", () => {
let moved: JSCalendar.Event = {
...STANDUP,
recurrenceOverrides: {
"2026-03-30T09:30:00": { start: "2026-03-30T11:00:00" },
},
};
let ics = stringify(unwrap(toICalendar(moved)));
let [back] = fromICalendar(unwrap(parseICalendar(ics)).calendar).entries;
expect(back?.recurrenceOverrides).toEqual(moved.recurrenceOverrides);
let [, second] = unwrap(upcoming([moved], Date.UTC(2026, 2, 22), "UTC"));
expect(second?.start).toBe(Date.UTC(2026, 2, 30, 9));
});Run it from the terminal
Check an event, list its occurrences in a window, or turn it into an .ics file from the
terminal, with the same rules the agenda uses:
sdxc pkg jscalendar validate standup.json
sdxc pkg jscalendar expand standup.json --from 2026-03-22 --until 2026-04-06 --time-zone Europe/Madrid
sdxc pkg jscalendar convert standup.json --to icalendar > standup.ics
sdxc pkg jscalendar convert calendar.ics --to jscalendar > group.json--time-zone sets the zone floating times are read in, so the expansion matches what a reader
in Madrid sees; validate exits 1 on any problem. Use a package from the terminal covers the
conventions every package command line shares.
Where to go next
Publish calendar feeds: the iCalendar side in depth, with subscriptions and time zone blocks.
JMAP: the protocol that carries these objects between a calendar server and its clients.
@sdxc/jscalendar: every value format, the conversion options and the standards it follows.