@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

npm install @dayflow/caldav
pnpm add @dayflow/caldav
yarn add @dayflow/caldav
bun add @dayflow/caldav

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:

  1. Fetches current-user-principal from the server root
  2. Fetches calendar-home-set from 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-Match ETag) work correctly
  • All-day events use standard VALUE=DATE format
  • Timezone-aware events use the standard TZID parameter

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:

  1. Receives requests from DayFlow (as JSON-wrapped CalDAV requests)
  2. Injects credentials (Basic Auth, token, etc.)
  3. 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.mjs

Options Reference

attachCalDAVToDayFlow Options

OptionTypeDefaultDescription
writablebooleantrueAllow local mutations to be written back to the CalDAV server.
refreshOnVisibleRangeChangebooleantrueRe-sync when the user navigates to a new date range.
maxConcurrentCalendarsnumber4Maximum 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) => voidCalled 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) => voidCalled after each successful sync with counts of what changed.
onWriteComplete(operation, event) => voidCalled after a local mutation is successfully written back to the CalDAV server.
createEventId(input) => stringnamespacedBuild 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

OptionTypeRequiredDescription
calendarHomeUrlstringThe CalDAV calendar home collection URL.
fetchfunctionAuthenticated fetch function. Route through your backend proxy.

createCalDAVAdapterFromServer Options

ParameterTypeDescription
serverUrlstringCalDAV server root URL (e.g. ICLOUD_CALDAV_SERVER).
optionsobjectSame 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.

On this page