@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
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
Using getToken (recommended for client-side tokens)
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 ?? '';
},
});Using a backend proxy (recommended for production)
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.mjsGenerate 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
| Option | Type | Default | Description |
|---|---|---|---|
writable | boolean | true | Allow local mutations to be written back to Google Calendar. |
onStatusChange | (status: GoogleSyncStatus) => void | — | Called whenever the sync state changes. |
onWriteError | (error: Error, ctx) => void | console.error | Called 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
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl | string | https://www.googleapis.com/calendar/v3 | Override to point at a backend proxy. |
fetch | function | globalThis.fetch | Custom 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}withIf-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:
| Scope | Access |
|---|---|
https://www.googleapis.com/auth/calendar | Full read/write access |
https://www.googleapis.com/auth/calendar.readonly | Read-only access (use with writable: false) |