@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

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

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.mjs

Gé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

OptionTypePar défautDescription
writablebooleantrueAutorise la propagation des modifications locales vers Google Calendar.
onStatusChange(status: GoogleSyncStatus) => void—Appelé à chaque changement d'état de la synchronisation.
onWriteError(error: Error, ctx) => voidconsole.errorAppelé 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

OptionTypePar défautDescription
baseUrlstringhttps://www.googleapis.com/calendar/v3À modifier pour pointer vers un proxy backend.
fetchfunctionglobalThis.fetchImplé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} avec If-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éeAccès
https://www.googleapis.com/auth/calendarAccès complet en lecture/écriture
https://www.googleapis.com/auth/calendar.readonlyAccès en lecture seule (à utiliser avec writable: false)

Dans cette page