@dayflow/google-sync

@dayflow/google-sync connects DayFlow to the Google Calendar REST API v3. It is a separate package from @dayflow/caldav — it speaks the Google Calendar API directly, not CalDAV.

Installation

npm install @dayflow/google-sync
pnpm add @dayflow/google-sync
yarn add @dayflow/google-sync
bun add @dayflow/google-sync

Quick Start

import { useRef, useEffect, useState } from 'react';
import {
  DayFlowCalendar,
  useCalendarApp,
  createMonthView,
} from '@dayflow/react';
import {
  attachGoogleSyncToDayFlow,
  createGoogleSync,
  createGoogleSyncAdapter,
  type GoogleDayFlowController,
  type GoogleSyncStatus,
} from '@dayflow/google-sync';

function MyCalendar() {
  const calendar = useCalendarApp({
    views: [createMonthView()],
    calendars: [],
    events: [],
  });

  const controllerRef = useRef<GoogleDayFlowController | null>(null);
  const [syncStatus, setSyncStatus] = useState<GoogleSyncStatus>({
    state: 'idle',
  });

  useEffect(() => {
    if (controllerRef.current) return;

    const adapter = createGoogleSyncAdapter({
      baseUrl: '/api/google-calendar',
    });

    const sync = createGoogleSync(adapter);
    const controller = attachGoogleSyncToDayFlow(calendar.app, sync, {
      writable: true,
      onStatusChange: setSyncStatus,
      onWriteError: (error, ctx) =>
        console.error(`[google-sync] ${ctx.action} failed:`, error.message),
      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} />;
}

Token Injection

When you have a token available at runtime (e.g. from a Supabase session or an auth provider), use getToken. It is called before every request, so token refreshes are transparent — the adapter never needs to be recreated when the token changes:

const adapter = createGoogleSyncAdapter({
  getToken: async () => {
    const { data } = await supabase.auth.getSession();
    return data.session?.provider_token ?? '';
  },
});

The Google Calendar API supports CORS, but sending OAuth tokens from the browser exposes them to anyone who can inspect network requests. For production, keep tokens on the server:

const adapter = createGoogleSyncAdapter({
  baseUrl: '/api/google-calendar',
  // No getToken needed — your proxy injects Authorization
});
// proxy.mjs (Node.js example)
import { createServer } from 'node:http';

const {
  GOOGLE_ACCESS_TOKEN,
  GOOGLE_CLIENT_ID,
  GOOGLE_CLIENT_SECRET,
  GOOGLE_REFRESH_TOKEN,
  PORT = '3002',
} = process.env;

const GOOGLE_API_BASE = 'https://www.googleapis.com/calendar/v3';
const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);

let cachedToken = GOOGLE_ACCESS_TOKEN
  ? { token: GOOGLE_ACCESS_TOKEN, expiresAt: Infinity }
  : null;

async function getAccessToken() {
  if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) {
    return cachedToken.token;
  }
  const res = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: GOOGLE_CLIENT_ID,
      client_secret: GOOGLE_CLIENT_SECRET,
      refresh_token: GOOGLE_REFRESH_TOKEN,
      grant_type: 'refresh_token',
    }),
  });
  const data = await res.json();
  cachedToken = {
    token: data.access_token,
    expiresAt: Date.now() + data.expires_in * 1000,
  };
  return cachedToken.token;
}

createServer(async (req, res) => {
  const upstreamPath = req.url.replace(/^\/api\/google-calendar/, '');
  const upstreamUrl = `${GOOGLE_API_BASE}${upstreamPath}`;

  if (!ALLOWED_METHODS.has(req.method)) {
    res.writeHead(405);
    res.end();
    return;
  }

  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const body =
    req.method === 'GET' || req.method === 'DELETE'
      ? undefined
      : Buffer.concat(chunks).toString();

  const token = await getAccessToken();
  const upstream = await fetch(upstreamUrl, {
    method: req.method,
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
      ...(req.headers['if-match']
        ? { 'If-Match': req.headers['if-match'] }
        : {}),
    },
    body,
  });

  const responseBody = upstream.status === 204 ? '' : await upstream.text();
  res.writeHead(upstream.status, { 'Content-Type': 'application/json' });
  res.end(responseBody);
}).listen(Number(PORT));

Start the proxy:

# Quick test with an access token
GOOGLE_ACCESS_TOKEN=ya29.xxx node proxy.mjs

# Long-lived with refresh token
GOOGLE_CLIENT_ID=xxx \
GOOGLE_CLIENT_SECRET=xxx \
GOOGLE_REFRESH_TOKEN=xxx \
node proxy.mjs

Generate an access token for testing at Google OAuth 2.0 Playground. Select the https://www.googleapis.com/auth/calendar scope.

Sync Token Persistence

By default, Google Calendar sync tokens are stored in memory and are lost on page reload, causing the next session to do a full sync. Provide a GoogleSyncStorage implementation to persist them:

import { createGoogleSync, type GoogleSyncStorage } from '@dayflow/google-sync';

const storage: GoogleSyncStorage = {
  getSyncToken: async calendarId =>
    localStorage.getItem(`google-token:${calendarId}`),
  setSyncToken: async (calendarId, token) =>
    token
      ? localStorage.setItem(`google-token:${calendarId}`, token)
      : localStorage.removeItem(`google-token:${calendarId}`),
};

const sync = createGoogleSync(adapter, { storage });

With storage wired up, each session starts with an incremental sync instead of fetching all events from scratch.

Local Cache Hydration

If you persist synced events in a database or local store, you can seed DayFlow before the first remote sync so the calendar renders immediately:

const controller = attachGoogleSyncToDayFlow(calendar.app, sync, {
  getInitialSnapshot: async () => {
    const { calendars, events } = await loadFromLocalDB();
    return { calendars, events };
  },
  onSyncComplete: delta => {
    // Save what changed to your local store
    saveChanges(delta);
  },
  onWriteComplete: (operation, event) => {
    // Update local store after a successful write-back
    persistEvent(operation, event);
  },
});

getInitialSnapshot is called once during start(), before any Google Calendar API requests. DayFlow renders with cached data while the background sync runs.

Applying a Remote Snapshot Manually

For applications that manage their own sync orchestration, applyRemoteSnapshot applies a batch of remote events to DayFlow without triggering write-back:

import { applyRemoteSnapshot, getGoogleMeta } from '@dayflow/google-sync';

const delta = await applyRemoteSnapshot(
  calendar.app,
  { calendars, events },
  {
    isOwnedEvent: event => Boolean(getGoogleMeta(event)),
    isOwnedCalendar: calendar => calendar.source === 'Google',
    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. Use snapshotMode: 'authoritative' only for full provider snapshots; range-limited or paginated snapshots should keep the default partial mode.

Options Reference

attachGoogleSyncToDayFlow Options

OptionTypeDefaultDescription
writablebooleantrueAllow local mutations to be written back to Google Calendar.
onStatusChange(status: GoogleSyncStatus) => void—Called whenever the sync state changes.
onWriteError(error: Error, ctx) => voidconsole.errorCalled when a create / update / delete write-back fails. ctx includes action and eventId.
getInitialSnapshot() => Promise<{ events, calendars }>—Seed DayFlow from a local cache before the first remote sync.
onSyncComplete(delta: GoogleSyncDelta) => 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 Google Calendar.

createGoogleSyncAdapter Options

OptionTypeDefaultDescription
baseUrlstringhttps://www.googleapis.com/calendar/v3Override to point at a backend proxy.
fetchfunctionglobalThis.fetchCustom fetch implementation (e.g. for testing).
getToken() => string | Promise<string>—Called before every request. Returns the access token to inject as Authorization: Bearer <token>. Use this instead of manually wrapping fetch.

GoogleSyncStatus

type GoogleSyncStatus = {
  state: 'idle' | 'syncing' | 'error';
  lastSyncedAt?: string; // ISO timestamp
  error?: {
    message: string;
    calendarId?: string;
  };
};

GoogleSyncDelta

type GoogleSyncDelta = {
  calendars: { added: number; updated: number; deleted: number };
  events: { added: number; updated: number; deleted: number };
};

Controller API

// Load calendars, sync initial events, and subscribe to visible-range and event changes
await controller.start();

// Unsubscribe all listeners
controller.stop();

// Re-sync all calendars for the current visible range
await controller.refresh();

// Re-sync a specific calendar
await controller.refresh({ calendarId: 'primary' });

// Re-sync with an explicit range
await controller.refresh({
  calendarId: 'primary',
  range: { start: new Date('2025-01-01'), end: new Date('2025-02-01') },
});

// Current sync state
const status = controller.getStatus();

How Sync Works

Calendar discovery

On controller.start(), the package fetches the authenticated user's calendar list (GET /calendarList) and registers each calendar as a CalendarType in DayFlow. Calendars with accessRole: 'reader' or 'freeBusyReader' are marked readOnly: true.

Event loading

Events are loaded for the currently visible date range. When the user navigates (week forward, month back, etc.), events for the new range are loaded automatically via the visible-range change listener.

Incremental sync

After the initial load, the package uses Google Calendar's syncToken for incremental sync — only changed events are fetched on subsequent syncs. When GoogleSyncStorage is provided, sync tokens are persisted across page reloads so the next session also starts incrementally.

Write-back

When writable: true, local event changes (create, update, delete) are automatically written back to Google Calendar:

  • Create: POST /calendars/{calendarId}/events
  • Update: PUT /calendars/{calendarId}/events/{eventId} with If-Match: <etag>
  • Delete: DELETE /calendars/{calendarId}/events/{eventId}

If an update returns 412 Precondition Failed (ETag conflict — the event was changed on another device), the package automatically re-fetches the latest ETag and retries the update once.

Recurring events are never written back — they are read-only.

Google Calendar API Scopes

Your OAuth token must include at least one of these scopes:

ScopeAccess
https://www.googleapis.com/auth/calendarFull read/write access
https://www.googleapis.com/auth/calendar.readonlyRead-only access (use with writable: false)

On this page