02examples
Recurring events (RRULE)
Weekday standups, a fortnightly review and a monthly payday — as RRULE strings, expanded live, edited this / following / all.
Three series, stored as three events. Each card above the grid shows the
rrule string as it sits on the CalEvent, the same rule in plain English,
and the next time it fires. Wednesday's standup has already been moved once,
so it is drawn from a detached event rather than the series.
Click a card and edit its rule with the builder — the grid re-expands on every change. Then drag any instance: kloq asks whether you meant this event, this and following, or all of them.
"use client";
import { useState, useSyncExternalStore } from "react";
import {
Kloq,
RRuleBuilder,
describeRRule,
formatEventWhen,
formatInstant,
nextOccurrence,
parseInstant,
useIsClient,
useKloqStore,
} from "kloq";
import { cn } from "@/lib/utils";
import { SERIES } from "./data";
const MASTERS = SERIES.filter((e) => e.rrule).map((e) => e.id);
function SeriesPanel() {
const store = useKloqStore();
const events = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
const isClient = useIsClient();
const [selectedId, setSelectedId] = useState(MASTERS[0]);
const masters = MASTERS.map((id) => events.find((e) => e.id === id)).filter((e) => e !== undefined);
const selected = masters.find((e) => e.id === selectedId);
const now = isClient ? new Date() : null;
return (
<div className="border-b">
<div className="flex divide-x overflow-x-auto sm:grid sm:grid-cols-3">
{masters.map((e) => {
const next = now ? nextOccurrence(e, formatInstant(now)) : null;
const active = e.id === selectedId;
return (
<button
key={e.id}
type="button"
aria-pressed={active}
onClick={() => setSelectedId(e.id)}
className={cn(
"w-60 shrink-0 border-l-2 px-4 py-2.5 text-left transition-colors hover:bg-muted/40 sm:w-auto sm:min-w-0",
active ? "border-l-red-500" : "border-l-transparent",
)}
>
<span className="block truncate text-sm font-medium text-foreground">{e.title}</span>
<span className="block text-sm text-muted-foreground">
{e.rrule ? describeRRule(e.rrule) : "Does not repeat"}
</span>
<span className="mt-1.5 block truncate font-mono text-[11px] text-muted-foreground">{e.rrule ?? "—"}</span>
<span className="block truncate font-mono text-[11px] text-muted-foreground tabular-nums">
next · {next && now ? formatEventWhen({ ...e, start: next.start, end: next.end }, now) : "—"}
</span>
</button>
);
})}
</div>
{selected ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t px-4 py-1.5">
<span className="font-mono text-xs text-muted-foreground">
<span className="text-red-500">edit</span> {selected.title}
</span>
<RRuleBuilder
value={selected.rrule}
date={new Date(parseInstant(selected.start))}
onChange={(rule) => store.update(selected.id, { rrule: rule })}
className="w-72 max-w-full"
/>
<span className="font-mono text-xs text-muted-foreground">drag any instance to see this / following / all</span>
</div>
) : null}
</div>
);
}
export default function RecurringEventsExample() {
return (
<div className="h-[38rem]">
<Kloq defaultEvents={SERIES} persistence={false} designMode={false} storage={false}>
<Kloq.Toolbar />
<SeriesPanel />
</Kloq>
</div>
);
}
import { instantAt, resolveView, toDateOnlyString, type CalEvent } from "kloq";
/** Three series anchored on the Monday-start week containing `now`, plus one detached occurrence. */
export function buildSeries(now: Date = new Date()): CalEvent[] {
const week = resolveView("week", 1, now);
const at = (day: number, hour: number, minute = 0) => instantAt(week, day, hour * 60 + minute);
const payday = new Date(now.getFullYear(), now.getMonth(), 25);
const wednesdayStandup = at(2, 9);
return [
{
id: "standup",
title: "Standup",
color: "blue",
rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
start: at(0, 9),
end: at(0, 9, 15),
// Wednesday's instance was moved — it lives below as its own event
exdates: [wednesdayStandup],
},
{
id: "standup-wed",
title: "Standup · moved",
color: "blue",
start: at(2, 11),
end: at(2, 11, 15),
recurrenceId: wednesdayStandup,
},
{
id: "review",
title: "Fortnightly review",
color: "violet",
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=TH",
start: at(3, 14),
end: at(3, 15),
},
{
id: "payday",
title: "Payday",
color: "emerald",
allDay: true,
busy: false,
rrule: "FREQ=MONTHLY;BYMONTHDAY=25",
start: toDateOnlyString(payday),
end: toDateOnlyString(new Date(payday.getFullYear(), payday.getMonth(), 26)),
},
];
}
export const SERIES = buildSeries();
How it works
- A series is one event with an
rrule— the RFC 5545 value without itsRRULE:prefix.data.tsseedsFREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,FREQ=WEEKLY;INTERVAL=2;BYDAY=THand an all-dayFREQ=MONTHLY;BYMONTHDAY=25, all inside the subset the built-inexpandRecurrencehandles. See Recurrence for the frequencies and by-parts it models. - The moved Wednesday instance is two fields:
exdateson the master (the occurrence start that no longer draws) andrecurrenceIdon the detached event (the occurrence it replaces). That is exactly what "change this event" writes when you drag one instance. describeRRule(rule)renders the plain-English line;nextOccurrence(event, from)finds the next instance strictly after an instant. Both are pure and take the rule as a string or asparseRRuleoutput.RRuleBuilderis the panel's recurrence control, standalone. It takes the rule asvalue, the series' start asdate(presets like "the first Thursday" derive from it) and commits throughonChange; the panel writes the new rule withstore.updatefromuseKloqStore.- The panel is a child of
<Kloq>, so it renders above the surface and can read the store withuseSyncExternalStore(store.subscribe, store.getSnapshot). The "next" column is gated onuseIsClient()because it depends on the clock and must not hydrate. - Dragging, resizing or deleting an instance opens the built-in
RecurrenceScopeDialog; the chosen scope becomes aRecurrencePlanfromeditOccurrence/deleteOccurrenceand is applied as one batch, so undo reverts the whole answer. On the first occurrence "this and following" is the same as "all", and the dialog offers only two choices.
Take it further
Pass a controlled events array and the same drags arrive through
onEventsChange as plain data — see
Controlled state. A rule the builder cannot express
(BYWEEKNO, several BYMONTHDAY values) is never rewritten: it shows as a
read-only "Custom rule" chip, and registerRecurrenceEngine lets a richer
engine expand it.