@dayflow/caldav
@dayflow/caldav is a headless, adapter-first CalDAV sync engine. It works with iCloud Calendar, Nextcloud, Radicale, Fastmail, and any RFC 4791-compliant CalDAV server.
Installation
Quick Start
import { useRef, useEffect } from 'react';
import {
DayFlowCalendar,
useCalendarApp,
createMonthView,
} from '@dayflow/react';
import {
attachCalDAVToDayFlow,
createCalDAVAdapter,
createCalDAVSync,
type CalDAVDayFlowController,
} from '@dayflow/caldav';
function MyCalendar() {
const calendar = useCalendarApp({
views: [createMonthView()],
calendars: [],
events: [],
});
const controllerRef = useRef<CalDAVDayFlowController | null>(null);
useEffect(() => {
if (controllerRef.current) return;
const adapter = createCalDAVAdapter({
calendarHomeUrl: 'https://caldav.example.com/calendars/alice/',
fetch: (url, init) =>
fetch('/api/caldav-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, init }),
}),
});
const sync = createCalDAVSync({ adapter });
const controller = attachCalDAVToDayFlow(calendar.app, sync, {
writable: true,
maxConcurrentCalendars: 4,
onSyncComplete: delta => {
console.log(
`Sync done: +${delta.events.added} ~${delta.events.updated} -${delta.events.deleted}`
);
},
});
controllerRef.current = controller;
controller.start();
return () => {
controller.stop();
controllerRef.current = null;
};
}, [calendar.app]);
return <DayFlowCalendar calendar={calendar} />;
}Calendar Home Discovery
For servers that require dynamic discovery (such as iCloud), use createCalDAVAdapterFromServer — it runs the two-step PROPFIND and returns a ready adapter in one call:
import {
createCalDAVAdapterFromServer,
ICLOUD_CALDAV_SERVER,
} from '@dayflow/caldav';
const adapter = await createCalDAVAdapterFromServer(ICLOUD_CALDAV_SERVER, {
fetch: proxiedFetch,
});If you need the home URL separately (for caching or logging), use the lower-level discoverCalendarHome:
import {
discoverCalendarHome,
createCalDAVAdapter,
ICLOUD_CALDAV_SERVER,
} from '@dayflow/caldav';
const calendarHomeUrl = await discoverCalendarHome(
ICLOUD_CALDAV_SERVER,
authenticatedFetch
);
const adapter = createCalDAVAdapter({
calendarHomeUrl,
fetch: authenticatedFetch,
});discoverCalendarHome performs a two-step PROPFIND:
- Fetches
current-user-principalfrom the server root - Fetches
calendar-home-setfrom the principal URL
The fetch argument must inject auth credentials — discovery requests require authentication like any other CalDAV request.
Provider Presets
Use the built-in presets to construct the correct calendarHomeUrl for known providers:
import {
ICLOUD_CALDAV_SERVER,
createCalDAVAdapterFromServer,
nextcloudConfig,
radicaleConfig,
fastmailConfig,
createCalDAVAdapter,
} from '@dayflow/caldav';
// iCloud — must always discover dynamically
const icloudAdapter = await createCalDAVAdapterFromServer(
ICLOUD_CALDAV_SERVER,
{ fetch: proxiedFetch }
);
// Nextcloud
const nextcloudAdapter = createCalDAVAdapter({
...nextcloudConfig('https://nextcloud.example.com', 'alice'),
fetch: proxiedFetch,
});
// Radicale
const radicaleAdapter = createCalDAVAdapter({
...radicaleConfig('https://radicale.example.com', 'alice'),
fetch: proxiedFetch,
});
// Fastmail
const fastmailAdapter = createCalDAVAdapter({
...fastmailConfig('https://caldav.fastmail.com/dav', 'alice@fastmail.com'),
fetch: proxiedFetch,
});iCloud Calendar
iCloud CalDAV requires an app-specific password — not your Apple ID password. Generate one at appleid.apple.com → Sign-In and Security → App-Specific Passwords.
iCloud does not support browser CORS, so a backend proxy is required for all requests, including discovery.
iCloud-specific behavior handled automatically by the adapter:
- 8-digit RGBA hex colors (
#RRGGBBAA) — alpha channel is stripped - Conditional writes (
If-MatchETag) work correctly - All-day events use standard
VALUE=DATEformat - Timezone-aware events use the standard
TZIDparameter
Nextcloud
Use an app password from Personal Settings → Security, not your account password.
Nextcloud returns current-user-privilege-set correctly, so read/write permissions are detected automatically. CORS is not supported — use a proxy.
Radicale
Radicale typically does not return current-user-privilege-set, so the adapter defaults to read-only for discovered calendars. If you know the user has write access, set readOnly: false on the CalendarType after discovery.
Fastmail
Fastmail supports standard CalDAV. Use fastmailConfig with your Fastmail email address as the username.
Local Cache Hydration
If you persist synced events in a database or local store (e.g. Supabase, IndexedDB), you can seed DayFlow before the first remote sync so the calendar renders immediately:
const controller = attachCalDAVToDayFlow(calendar.app, sync, {
getInitialSnapshot: async () => {
const { calendars, events } = await loadFromLocalDB();
return { calendars, events };
},
onSyncComplete: delta => {
// Save what changed to your local store
console.log(
`+${delta.events.added} ~${delta.events.updated} -${delta.events.deleted}`
);
},
onWriteComplete: (operation, event) => {
// Update local store after a successful write-back
persistEvent(operation, event);
},
});getInitialSnapshot is called once during start(), before any CalDAV requests are made. DayFlow renders with cached data while the background sync runs. Errors from getInitialSnapshot are passed to onError and do not abort the remote sync.
Applying a Remote Snapshot Manually
For applications that manage their own sync orchestration (e.g. syncing via a backend job rather than directly from the browser), applyRemoteSnapshot applies a batch of remote events to DayFlow without triggering write-back:
import { applyRemoteSnapshot, getCalDAVMeta } from '@dayflow/caldav';
const delta = await applyRemoteSnapshot(
calendar.app,
{ calendars, events },
{
// Identify events owned by this provider so stale ones are cleaned up
isOwnedEvent: event => Boolean(getCalDAVMeta(event)),
isOwnedCalendar: calendar => calendar.source === 'iCloud',
snapshotMode: 'authoritative',
// Optionally preserve local in-progress edits during optimistic sync
resolveConflict: (remote, local) =>
mergeLocalEditsOntoRemote(remote, local),
}
);
console.log(delta.events.added, delta.events.updated, delta.events.deleted);applyRemoteSnapshot computes the diff between the incoming snapshot and the current app state, then applies changes with source: 'remote' to prevent write-back loops. It returns a RemoteSnapshotDelta with counts per operation.
Snapshots are treated as partial by default, so owned local records missing from the snapshot are preserved. Pass snapshotMode: 'authoritative' only when the snapshot fully represents all provider-owned calendars and events. For visible-range, filtered, or paginated responses, keep the default partial mode or explicitly set deleteMissingEvents: false and deleteMissingCalendars: false.
Backend Proxy
CalDAV servers do not support browser CORS. The browser cannot call them directly. You need a backend proxy that:
- Receives requests from DayFlow (as JSON-wrapped CalDAV requests)
- Injects credentials (Basic Auth, token, etc.)
- Forwards to the CalDAV server and returns the response
// proxy.mjs (minimal Node.js example)
import { createServer } from 'node:http';
const {
CALDAV_BASE_URL, // e.g. https://caldav.icloud.com
CALDAV_USERNAME,
CALDAV_PASSWORD,
PORT = '3001',
} = process.env;
const ALLOWED_METHODS = new Set(['PROPFIND', 'REPORT', 'PUT', 'DELETE']);
const CORS_HEADERS = {
'Access-Control-Allow-Origin': 'http://localhost:5173',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Expose-Headers': 'ETag, DAV',
};
createServer(async (req, res) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, CORS_HEADERS);
res.end();
return;
}
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const { url, init } = JSON.parse(Buffer.concat(chunks).toString());
if (!url.startsWith(CALDAV_BASE_URL)) {
res.writeHead(403);
res.end();
return;
}
if (!ALLOWED_METHODS.has(init?.method ?? 'PROPFIND')) {
res.writeHead(405);
res.end();
return;
}
const upstream = await fetch(url, {
...init,
headers: {
...init?.headers,
Authorization:
'Basic ' +
Buffer.from(`${CALDAV_USERNAME}:${CALDAV_PASSWORD}`).toString('base64'),
},
});
const body = await upstream.text();
res.writeHead(upstream.status, {
'Content-Type': upstream.headers.get('Content-Type') ?? 'text/xml',
ETag: upstream.headers.get('ETag') ?? '',
...CORS_HEADERS,
});
res.end(body);
}).listen(Number(PORT));Start the proxy with credentials in environment variables:
CALDAV_BASE_URL=https://caldav.icloud.com \
CALDAV_USERNAME=alice@icloud.com \
CALDAV_PASSWORD=xxxx-xxxx-xxxx-xxxx \
node proxy.mjsOptions Reference
attachCalDAVToDayFlow Options
| Option | Type | Default | Description |
|---|---|---|---|
writable | boolean | true | Allow local mutations to be written back to the CalDAV server. |
refreshOnVisibleRangeChange | boolean | true | Re-sync when the user navigates to a new date range. |
maxConcurrentCalendars | number | 4 | Maximum number of remote calendars to sync in parallel. |
eventMode.recurring | 'read-only' | 'read-only' | Recurring events are read-only. Only 'read-only' is supported. |
onError | (error, context) => void | — | Called on any sync or write failure. |
getInitialSnapshot | () => Promise<{ events, calendars }> | — | Seed DayFlow from a local cache before the first remote sync. Errors are passed to onError. |
onSyncComplete | (delta: CalDAVSyncDelta) => void | — | Called after each successful sync with counts of what changed. |
onWriteComplete | (operation, event) => void | — | Called after a local mutation is successfully written back to the CalDAV server. |
createEventId | (input) => string | namespaced | Build DayFlow ids for remote CalDAV events. Defaults to provider-scoped ids to avoid collisions. |
CalDAVErrorContext includes operation ('list-calendars' | 'initial-sync' | 'range-sync' | 'create' | 'update' | 'delete'), calendarId, and eventId.
CalDAVSyncDelta:
type CalDAVSyncDelta = {
calendars: { added: number; updated: number; deleted: number };
events: { added: number; updated: number; deleted: number };
};createCalDAVAdapter Options
| Option | Type | Required | Description |
|---|---|---|---|
calendarHomeUrl | string | ✓ | The CalDAV calendar home collection URL. |
fetch | function | ✓ | Authenticated fetch function. Route through your backend proxy. |
createCalDAVAdapterFromServer Options
| Parameter | Type | Description |
|---|---|---|
serverUrl | string | CalDAV server root URL (e.g. ICLOUD_CALDAV_SERVER). |
options | object | Same as createCalDAVAdapter options, minus calendarHomeUrl. |
Performs discovery internally and returns a ready CalDAVAdapter.
Controller API
// Discover remote calendars, load initial events, and subscribe to DayFlow changes
await controller.start();
// Unsubscribe all listeners (does not clear DayFlow state)
controller.stop();
// Re-sync all calendars using the last known visible range
await controller.refresh();
// Re-sync a specific calendar
await controller.refresh({ calendarId: 'my-calendar-id' });
// Re-sync with an explicit date range
await controller.refresh({
range: { start: new Date('2025-01-01'), end: new Date('2025-02-01') },
});
// Get current sync status
const status = controller.getStatus();
// { state: 'idle' | 'syncing' | 'error', lastSyncedAt?: Date, error?: unknown }Storage Interface
createCalDAVSync accepts an optional storage object for persisting sync tokens, calendar ctags, and ETags across page reloads. If not provided, sync state is kept in memory only. That default is useful for tests and demos, but production apps should provide durable storage so incremental sync and conditional writes survive reloads.
import { createCalDAVSync, type CalDAVStorage } from '@dayflow/caldav';
const storage: CalDAVStorage = {
getSyncToken: async calendarId =>
localStorage.getItem(`sync:${calendarId}`) ?? null,
setSyncToken: async (calendarId, token) => {
if (token) localStorage.setItem(`sync:${calendarId}`, token);
else localStorage.removeItem(`sync:${calendarId}`);
},
getCtag: async calendarId =>
localStorage.getItem(`ctag:${calendarId}`) ?? null,
setCtag: async (calendarId, ctag) =>
localStorage.setItem(`ctag:${calendarId}`, ctag),
getEtag: async href => localStorage.getItem(`etag:${href}`) ?? null,
setEtag: async (href, etag) => localStorage.setItem(`etag:${href}`, etag),
deleteEtag: async href => localStorage.removeItem(`etag:${href}`),
getEventState: async id =>
JSON.parse(localStorage.getItem(`event:${id}`) ?? 'null'),
setEventState: async (id, state) =>
localStorage.setItem(`event:${id}`, JSON.stringify(state)),
deleteEventState: async id => localStorage.removeItem(`event:${id}`),
clearCalendar: async calendarId => {
for (const key of Object.keys(localStorage)) {
if (
key.startsWith(`sync:${calendarId}`) ||
key.startsWith(`ctag:${calendarId}`)
) {
localStorage.removeItem(key);
}
}
},
};
const sync = createCalDAVSync({
adapter,
storage,
// Optional: split broad visible-range REPORTs into smaller windows.
rangeChunkDays: 31,
});When calendar ctag values are available, collection-wide syncs can skip network work if the collection has not changed since the last completed sync. Visible-range syncs still query the requested range so navigating into a new range can load previously unseen events.
Event Identity
The DayFlow binding uses provider-scoped ids for remote CalDAV events by default:
import { createNamespacedCalDAVEventId } from '@dayflow/caldav';
const controller = attachCalDAVToDayFlow(calendar.app, sync, {
createEventId: createNamespacedCalDAVEventId,
});Direct mapper calls keep UID-as-id compatibility unless you pass a factory:
const event = mapCalDAVEventToDayFlow(data, {
createEventId: createNamespacedCalDAVEventId,
});This avoids collisions with local events and other providers while still matching existing events by CalDAV metadata during sync.