@dayflow/google-sync
@dayflow/google-sync relie DayFlow à l'API REST v3 de Google Calendar. C'est un paquet distinct de @dayflow/caldav : il parle directement l'API Google Calendar, et non CalDAV.
Installation
Démarrage rapide
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} />;
}<template>
<DayFlowCalendar :calendar="calendar" />
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { DayFlowCalendar, useCalendarApp } from '@dayflow/vue';
import { createMonthView } from '@dayflow/core';
import {
attachGoogleSyncToDayFlow,
createGoogleSync,
createGoogleSyncAdapter,
} from '@dayflow/google-sync';
const calendar = useCalendarApp({
views: [createMonthView()],
calendars: [],
events: [],
});
const syncStatus = ref({ state: 'idle' });
let controller;
onMounted(() => {
const adapter = createGoogleSyncAdapter({
baseUrl: '/api/google-calendar',
});
const sync = createGoogleSync(adapter);
controller = attachGoogleSyncToDayFlow(calendar.app, sync, {
writable: true,
onStatusChange: status => {
syncStatus.value = status;
},
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}`
);
},
});
controller.start();
});
onBeforeUnmount(() => {
controller?.stop();
});
</script>import { Component, OnInit, OnDestroy } from '@angular/core';
import { CalendarApp, createMonthView } from '@dayflow/core';
import { DayFlowCalendarModule } from '@dayflow/angular';
import {
attachGoogleSyncToDayFlow,
createGoogleSync,
createGoogleSyncAdapter,
type GoogleDayFlowController,
type GoogleSyncStatus,
} from '@dayflow/google-sync';
@Component({
selector: 'app-root',
standalone: true,
imports: [DayFlowCalendarModule],
template: `<dayflow-calendar [calendar]="calendar"></dayflow-calendar>`,
})
export class AppComponent implements OnInit, OnDestroy {
calendar = new CalendarApp({
views: [createMonthView()],
calendars: [],
events: [],
});
syncStatus: GoogleSyncStatus = { state: 'idle' };
private controller?: GoogleDayFlowController;
ngOnInit() {
const adapter = createGoogleSyncAdapter({
baseUrl: '/api/google-calendar',
});
const sync = createGoogleSync(adapter);
this.controller = attachGoogleSyncToDayFlow(this.calendar, sync, {
writable: true,
onStatusChange: status => {
this.syncStatus = status;
},
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}`
);
},
});
this.controller.start();
}
ngOnDestroy() {
this.controller?.stop();
}
}<script>
import { onMount, onDestroy } from 'svelte';
import { DayFlowCalendar, useCalendarApp } from '@dayflow/svelte';
import { createMonthView } from '@dayflow/core';
import {
attachGoogleSyncToDayFlow,
createGoogleSync,
createGoogleSyncAdapter,
} from '@dayflow/google-sync';
const calendar = useCalendarApp({
views: [createMonthView()],
calendars: [],
events: [],
});
let syncStatus = { state: 'idle' };
let controller;
onMount(() => {
const adapter = createGoogleSyncAdapter({
baseUrl: '/api/google-calendar',
});
const sync = createGoogleSync(adapter);
controller = attachGoogleSyncToDayFlow(calendar.app, sync, {
writable: true,
onStatusChange: status => {
syncStatus = status;
},
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}`
);
},
});
controller.start();
});
onDestroy(() => {
controller?.stop();
});
</script>
<DayFlowCalendar {calendar} />Injection du token
Avec getToken (recommandé pour les tokens côté client)
Lorsque vous disposez d'un token à l'exécution (issu d'une session Supabase ou d'un fournisseur d'authentification, par exemple), utilisez getToken. Il est appelé avant chaque requête : les rafraîchissements sont donc transparents et l'adaptateur n'a jamais besoin d'être recréé quand le token change.
const adapter = createGoogleSyncAdapter({
getToken: async () => {
const { data } = await supabase.auth.getSession();
return data.session?.provider_token ?? '';
},
});Avec un proxy backend (recommandé en production)
L'API Google Calendar gère le CORS, mais envoyer des tokens OAuth depuis le navigateur les expose à quiconque inspecte les requêtes réseau. En production, gardez les tokens côté serveur :
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));Démarrez le 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.mjsGénérez un token d'accès de test sur le Google OAuth 2.0 Playground. Sélectionnez la portée https://www.googleapis.com/auth/calendar.
Persistance du token de synchronisation
Par défaut, les tokens de synchronisation Google Calendar sont conservés en mémoire et perdus au rechargement de la page, ce qui force la session suivante à effectuer une synchronisation complète. Fournissez une implémentation de GoogleSyncStorage pour les persister :
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 });Avec storage en place, chaque session démarre par une synchronisation incrémentale au lieu de retélécharger tous les événements.
Hydratation depuis le cache local
Si vous conservez les événements synchronisés dans une base ou un stockage local, vous pouvez amorcer DayFlow avant la première synchronisation distante pour que le calendrier s'affiche immédiatement :
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 est appelé une seule fois pendant start(), avant toute requête vers l'API Google Calendar. DayFlow s'affiche avec les données en cache pendant que la synchronisation s'exécute en arrière-plan.
Appliquer un instantané distant manuellement
Pour les applications qui orchestrent elles-mêmes la synchronisation, applyRemoteSnapshot applique un lot d'événements distants à DayFlow sans renvoyer de modification au fournisseur :
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 calcule l'écart entre l'instantané entrant et l'état courant de l'application, puis applique les changements avec source: 'remote' afin d'éviter les boucles de synchronisation. Il renvoie un RemoteSnapshotDelta avec le décompte par opération. N'utilisez snapshotMode: 'authoritative' que pour des instantanés complets ; les instantanés limités à une plage ou paginés doivent conserver le mode partiel par défaut.
Référence des options
Options de attachGoogleSyncToDayFlow
| Option | Type | Par défaut | Description |
|---|---|---|---|
writable | boolean | true | Autorise la propagation des modifications locales vers Google Calendar. |
onStatusChange | (status: GoogleSyncStatus) => void | — | Appelé à chaque changement d'état de la synchronisation. |
onWriteError | (error: Error, ctx) => void | console.error | Appelé lorsqu'une création, mise à jour ou suppression échoue côté fournisseur. ctx contient action et eventId. |
getInitialSnapshot | () => Promise<{ events, calendars }> | — | Amorce DayFlow depuis un cache local avant la première synchronisation distante. |
onSyncComplete | (delta: GoogleSyncDelta) => void | — | Appelé après chaque synchronisation réussie, avec le décompte des changements. |
onWriteComplete | (operation, event) => void | — | Appelé lorsqu'une modification locale a bien été répercutée dans Google Calendar. |
Options de createGoogleSyncAdapter
| Option | Type | Par défaut | Description |
|---|---|---|---|
baseUrl | string | https://www.googleapis.com/calendar/v3 | À modifier pour pointer vers un proxy backend. |
fetch | function | globalThis.fetch | Implémentation de fetch personnalisée (pour les tests, par exemple). |
getToken | () => string | Promise<string> | — | Appelé avant chaque requête. Renvoie le token d'accès injecté sous la forme Authorization: Bearer <token>. À préférer à un habillage manuel de 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 };
};API du contrôleur
// 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();Fonctionnement de la synchronisation
Découverte des calendriers
À l'appel de controller.start(), le paquet récupère la liste des calendriers de l'utilisateur authentifié (GET /calendarList) et enregistre chacun comme CalendarType dans DayFlow. Les calendriers en accessRole: 'reader' ou 'freeBusyReader' sont marqués readOnly: true.
Chargement des événements
Les événements sont chargés pour la plage de dates visible. Lorsque l'utilisateur navigue (semaine suivante, mois précédent, etc.), les événements de la nouvelle plage sont chargés automatiquement via l'écouteur de changement de plage visible.
Synchronisation incrémentale
Après le chargement initial, le paquet s'appuie sur le syncToken de Google Calendar : les synchronisations suivantes ne récupèrent que les événements modifiés. Avec GoogleSyncStorage, les tokens survivent aux rechargements de page, si bien que la session suivante démarre elle aussi en incrémental.
Réécriture
Avec writable: true, les modifications locales (création, mise à jour, suppression) sont automatiquement répercutées dans Google Calendar :
- Créer :
POST /calendars/{calendarId}/events - Mettre à jour :
PUT /calendars/{calendarId}/events/{eventId}avecIf-Match: <etag> - Supprimer :
DELETE /calendars/{calendarId}/events/{eventId}
Si une mise à jour renvoie 412 Precondition Failed (conflit d'ETag : l'événement a été modifié sur un autre appareil), le paquet récupère automatiquement l'ETag le plus récent et retente la mise à jour une fois.
Les événements récurrents ne sont jamais renvoyés au fournisseur : ils sont en lecture seule.
Portées de l'API Google Calendar
Votre token OAuth doit inclure au moins une de ces portées :
| Portée | Accès |
|---|---|
https://www.googleapis.com/auth/calendar | Accès complet en lecture/écriture |
https://www.googleapis.com/auth/calendar.readonly | Accès en lecture seule (à utiliser avec writable: false) |