02examples
ICS import and export
Drop a .ics feed onto the calendar, then export the visible week back out as a file.
An empty calendar and three buttons. "Import sample feed" parses a small
team feed — two offsites, a bank holiday, a weekly sync, a product demo — and
adds what it can, then lists what it refused and why. Open or drop your own
.ics and the same happens to it. "Export visible week" serialises every
event in the range back to a file, and shows the text so the round trip is
visible without opening the download.
"use client";
import { useState, type DragEvent } from "react";
import {
Kloq,
eventsToICS,
expandRecurrence,
parseICS,
rangeOfView,
useKloq,
useKloqStore,
type CalEvent,
type ICSImportResult,
} from "kloq";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { sampleFeed } from "./data";
// without this the store starts from kloq's sample week
const EMPTY: CalEvent[] = [];
function download(text: string, name: string) {
const url = URL.createObjectURL(new Blob([text], { type: "text/calendar;charset=utf-8" }));
const a = Object.assign(document.createElement("a"), { href: url, download: name });
a.click();
URL.revokeObjectURL(url);
}
interface Shown {
label: string;
text: string;
result?: ICSImportResult;
added?: number;
}
function IcsPanel() {
const store = useKloqStore();
const k = useKloq();
const [shown, setShown] = useState<Shown | null>(null);
const [open, setOpen] = useState(false);
const [over, setOver] = useState(false);
const importText = (label: string, text: string) => {
const result = parseICS(text, { color: "amber" });
// UIDs become ids, so re-importing the same feed is a no-op rather than a duplicate
const fresh = result.events.filter((e) => !store.get(e.id));
store.batch(fresh.map((event) => ({ type: "add" as const, event })));
setShown({ label, text, result, added: fresh.length });
};
const importFile = async (file: File | undefined) => {
if (file) importText(file.name, await file.text());
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setOver(false);
void importFile(e.dataTransfer.files[0]);
};
const exportWeek = () => {
const range = rangeOfView(k.view);
const visible = store.getSnapshot().filter((e) => expandRecurrence(e, range).occurrences.length > 0);
const text = eventsToICS(visible, { prodId: "kloq-docs" });
setShown({ label: `week.ics · ${visible.length} events`, text });
setOpen(true);
if (text) download(text, "week.ics");
};
const notes = shown?.result ? [...shown.result.skipped, ...shown.result.warnings] : [];
return (
<div
className="border-b"
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={onDrop}
>
<div className={cn("flex flex-wrap items-center gap-2 px-4 py-2.5 transition-colors", over && "bg-red-500/5")}>
<Button size="sm" variant="outline" onClick={() => importText("sample feed", sampleFeed())}>
Import sample feed
</Button>
<label className={cn(buttonVariants({ size: "sm", variant: "outline" }))}>
Open your own .ics
<input
type="file"
accept=".ics,text/calendar"
className="sr-only"
onChange={(e) => void importFile(e.target.files?.[0])}
/>
</label>
<Button size="sm" variant="outline" onClick={exportWeek}>
Export visible week
</Button>
<span className="font-mono text-xs text-muted-foreground">or drop a file on this bar</span>
{shown ? (
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="ml-auto font-mono text-xs text-muted-foreground transition-colors hover:text-foreground"
>
{open ? "hide" : "show"} ICS · {shown.label}
</button>
) : null}
</div>
{shown?.result ? (
<div className="border-t px-4 py-2 font-mono text-xs text-muted-foreground" aria-live="polite">
<span className="text-foreground">
<span className="text-red-500">✓</span> {shown.added} added
</span>
{" · "}
{shown.result.events.length - (shown.added ?? 0)} already there · {shown.result.skipped.length} skipped ·{" "}
{shown.result.warnings.length} warnings
{notes.map((n, i) => (
<span key={i} className="block">
<span className="text-foreground">{n.what}</span>
{n.uid ? ` (${n.uid})` : ""} — {n.reason}
</span>
))}
</div>
) : null}
{open && shown ? (
<pre className="max-h-44 overflow-auto border-t bg-muted/40 px-4 py-3 font-mono text-[11px] leading-relaxed text-muted-foreground">
{shown.text || "nothing in the visible range"}
</pre>
) : null}
</div>
);
}
export default function IcsImportExportExample() {
return (
<div className="h-[38rem]">
<Kloq defaultEvents={EMPTY} persistence={false} designMode={false} storage={false}>
<Kloq.Toolbar />
<IcsPanel />
</Kloq>
</div>
);
}
import { resolveView } from "kloq";
const pad = (n: number) => String(n).padStart(2, "0");
const date = (d: Date) => `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
const dateTime = (d: Date, hour: number, minute = 0) => `${date(d)}T${pad(hour)}${pad(minute)}00`;
const plus = (d: Date, days: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + days);
/**
* A small team feed anchored on the current week. It deliberately carries three things the
* importer refuses (a VALARM, a cancelled event, a vendor X- property) and one it approximates (TZID).
*/
export function sampleFeed(now: Date = new Date()): string {
const [mon, tue, , thu, fri] = resolveView("week", 1, now).days;
return [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Acme//Team calendar//EN",
"X-WR-CALNAME:Acme · offsites & holidays",
"BEGIN:VEVENT",
"UID:offsite-q4@acme.example",
`DTSTART;VALUE=DATE:${date(plus(tue, 7))}`,
`DTEND;VALUE=DATE:${date(plus(thu, 7))}`,
"SUMMARY:Q4 planning offsite",
"LOCATION:The Hoxton\\, Southwark",
"DESCRIPTION:Two days\\, whole company. Bring the roadmap.",
"END:VEVENT",
"BEGIN:VEVENT",
"UID:demo@acme.example",
`DTSTART:${dateTime(thu, 15)}`,
`DTEND:${dateTime(thu, 16)}`,
"SUMMARY:Product demo",
"ORGANIZER:mailto:ryan@acme.example",
"ATTENDEE;CN=Sam Rivera;PARTSTAT=ACCEPTED:mailto:sam@acme.example",
"ATTENDEE;CN=Jules Okafor;ROLE=OPT-PARTICIPANT;PARTSTAT=TENTATIVE:mailto:jules@acme.example",
"URL:https://meet.example.com/demo",
"END:VEVENT",
"BEGIN:VEVENT",
"UID:bank-holiday@acme.example",
`DTSTART;VALUE=DATE:${date(plus(mon, 7))}`,
`DTEND;VALUE=DATE:${date(plus(mon, 8))}`,
"SUMMARY:Bank holiday",
"TRANSP:TRANSPARENT",
"END:VEVENT",
"BEGIN:VEVENT",
"UID:eng-sync@acme.example",
`DTSTART:${dateTime(tue, 11)}`,
`DTEND:${dateTime(tue, 11, 30)}`,
"RRULE:FREQ=WEEKLY;BYDAY=TU",
"SUMMARY:Engineering sync",
"BEGIN:VALARM",
"TRIGGER:-PT10M",
"ACTION:DISPLAY",
"END:VALARM",
"END:VEVENT",
"BEGIN:VEVENT",
"UID:vendor-lunch@acme.example",
`DTSTART:${dateTime(fri, 12, 30)}`,
`DTEND:${dateTime(fri, 13, 30)}`,
"SUMMARY:Vendor lunch",
"STATUS:CANCELLED",
"END:VEVENT",
"BEGIN:VEVENT",
"UID:dinner@acme.example",
`DTSTART;TZID=America/New_York:${dateTime(fri, 19)}`,
`DTEND;TZID=America/New_York:${dateTime(fri, 21)}`,
"SUMMARY:Founders' dinner",
"X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC",
"END:VEVENT",
"END:VCALENDAR",
].join("\r\n");
}
How it works
parseICS(text, { color })never throws. It returnseventsready for the store plusskipped(dropped, with a reason) andwarnings(kept, but approximated). The sample feed carries one of each kind on purpose: aVALARM(reminders are not imported), aSTATUS:CANCELLEDevent, anX-vendor property, and aDTSTART;TZID=…that is read as local wall-clock time and preserved ontimeZone. See ICS.- A
UIDbecomes the eventid, so importing the same feed twice is idempotent: the panel filters out ids the store already has and adds the rest with onestore.batch, which is one committed change and one undo step. - A dropped file is read with
File.text(); the drop zone is the whole bar, and the file input under "Open your own .ics" is the keyboard-reachable route to the same function. - Export takes the visible range from
useKloq().viewthroughrangeOfView, keeps every event thatexpandRecurrenceplaces inside it (a series counts if any occurrence does; the master goes out with itsRRULE), and serialises them witheventsToICS, which folds the per-eventtoICSoutput into oneVCALENDAR. For a single event,downloadICS(event)does the download in one call. - The feed in
data.tsis built from the current week so it always lands where you are looking. Times are floating local time; all-day events use date-only values with the exclusiveDTENDthe spec — andCalEvent— require.
Take it further
Copy and paste speak the same format: eventsToICS / parseClipboardEvents
are what ⌘C and ⌘V use, so events copied here paste
into any calendar app that accepts ICS, and back. Wire parseICS to a URL
fetch and you have a subscription; wire the export to onCommit and every
change streams out — see Optimistic sync.