@dayflow/google-sync

@dayflow/google-sync verbindet DayFlow mit der Google-Calendar-REST-API v3. Es ist ein eigenes Paket, getrennt von @dayflow/caldav – es spricht direkt die Google-Calendar-API, nicht CalDAV.

Installation

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

Schnellstart

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} />

Token-Übergabe

Mit getToken (empfohlen für clientseitige Tokens)

Wenn Ihnen zur Laufzeit ein Token vorliegt – etwa aus einer Supabase-Sitzung oder von Ihrem Auth-Anbieter – verwenden Sie getToken. Es wird vor jeder Anfrage aufgerufen, sodass Token-Erneuerungen transparent bleiben: Der Adapter muss bei einem Tokenwechsel nie neu erzeugt werden.

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

Mit einem Backend-Proxy (empfohlen für den Produktivbetrieb)

Die Google-Calendar-API unterstützt CORS, doch OAuth-Tokens aus dem Browser zu senden macht sie für jeden einsehbar, der die Netzwerkanfragen betrachtet. Halten Sie die Tokens im Produktivbetrieb auf dem 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));

Starten Sie den 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

Ein Zugriffstoken zum Testen erzeugen Sie im Google OAuth 2.0 Playground. Wählen Sie dort den Scope https://www.googleapis.com/auth/calendar.

Sync-Token dauerhaft speichern

Standardmäßig liegen die Sync-Tokens von Google Calendar nur im Arbeitsspeicher und gehen beim Neuladen der Seite verloren – die nächste Sitzung müsste dann vollständig synchronisieren. Mit einer GoogleSyncStorage-Implementierung bleiben sie erhalten:

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 });

Mit eingerichtetem storage startet jede Sitzung mit einer inkrementellen Synchronisierung, statt alle Termine erneut zu laden.

Aus dem lokalen Cache vorbefüllen

Wenn Sie synchronisierte Termine in einer Datenbank oder einem lokalen Speicher ablegen, können Sie DayFlow vor der ersten Remote-Synchronisierung vorbefüllen, sodass der Kalender sofort erscheint:

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 wird einmalig während start() aufgerufen, noch vor jeder Anfrage an die Google-Calendar-API. DayFlow rendert mit den zwischengespeicherten Daten, während die Synchronisierung im Hintergrund läuft.

Einen Remote-Snapshot manuell anwenden

Für Anwendungen mit eigener Sync-Orchestrierung wendet applyRemoteSnapshot einen Stapel von Remote-Terminen auf DayFlow an, ohne eine Rückschreibung auszulösen:

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 berechnet die Differenz zwischen eingehendem Snapshot und aktuellem App-Zustand und wendet die Änderungen dann mit source: 'remote' an, um Rückschreibeschleifen zu vermeiden. Zurück kommt ein RemoteSnapshotDelta mit Zählern je Operation. Setzen Sie snapshotMode: 'authoritative' nur bei vollständigen Anbieter-Snapshots; bereichsbegrenzte oder seitenweise Snapshots sollten beim voreingestellten Teilmodus bleiben.

Optionsreferenz

Optionen von attachGoogleSyncToDayFlow

OptionTypStandardBeschreibung
writablebooleantrueErlaubt, lokale Änderungen nach Google Calendar zurückzuschreiben.
onStatusChange(status: GoogleSyncStatus) => voidWird bei jeder Änderung des Sync-Zustands aufgerufen.
onWriteError(error: Error, ctx) => voidconsole.errorWird aufgerufen, wenn das Zurückschreiben eines Anlegens, Aktualisierens oder Löschens fehlschlägt. ctx enthält action und eventId.
getInitialSnapshot() => Promise<{ events, calendars }>Befüllt DayFlow vor der ersten Remote-Synchronisierung aus einem lokalen Cache.
onSyncComplete(delta: GoogleSyncDelta) => voidWird nach jeder erfolgreichen Synchronisierung mit den Änderungszählern aufgerufen.
onWriteComplete(operation, event) => voidWird aufgerufen, sobald eine lokale Änderung erfolgreich nach Google Calendar geschrieben wurde.

Optionen von createGoogleSyncAdapter

OptionTypStandardBeschreibung
baseUrlstringhttps://www.googleapis.com/calendar/v3Überschreiben, um auf einen Backend-Proxy zu zeigen.
fetchfunctionglobalThis.fetchEigene fetch-Implementierung (etwa für Tests).
getToken() => string | Promise<string>Wird vor jeder Anfrage aufgerufen und liefert das Zugriffstoken, das als Authorization: Bearer <token> gesetzt wird. Verwenden Sie das statt fetch selbst zu umhüllen.

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();

Wie die Synchronisierung arbeitet

Kalender finden

Bei controller.start() lädt das Paket die Kalenderliste der angemeldeten Person (GET /calendarList) und registriert jeden Kalender als CalendarType in DayFlow. Kalender mit accessRole: 'reader' oder 'freeBusyReader' werden als readOnly: true gekennzeichnet.

Termine laden

Termine werden für den aktuell sichtbaren Datumsbereich geladen. Beim Navigieren – eine Woche vor, ein Monat zurück und so weiter – lädt der Listener für Bereichswechsel die Termine des neuen Bereichs automatisch nach.

Inkrementelle Synchronisierung

Nach dem ersten Laden nutzt das Paket den syncToken von Google Calendar: Bei jeder weiteren Synchronisierung werden nur geänderte Termine geholt. Ist GoogleSyncStorage eingerichtet, überstehen die Sync-Tokens ein Neuladen der Seite, sodass auch die nächste Sitzung inkrementell startet.

Rückschreiben

Bei writable: true werden lokale Terminänderungen (anlegen, aktualisieren, löschen) automatisch nach Google Calendar zurückgeschrieben:

  • Anlegen: POST /calendars/{calendarId}/events
  • Aktualisieren: PUT /calendars/{calendarId}/events/{eventId} mit If-Match: <etag>
  • Löschen: DELETE /calendars/{calendarId}/events/{eventId}

Antwortet eine Aktualisierung mit 412 Precondition Failed – ein ETag-Konflikt, weil der Termin auf einem anderen Gerät geändert wurde – holt das Paket automatisch das aktuelle ETag und versucht die Aktualisierung einmal erneut.

Wiederkehrende Termine werden nie zurückgeschrieben; sie sind schreibgeschützt.

Scopes der Google-Calendar-API

Ihr OAuth-Token muss mindestens einen dieser Scopes enthalten:

ScopeZugriff
https://www.googleapis.com/auth/calendarVoller Lese- und Schreibzugriff
https://www.googleapis.com/auth/calendar.readonlyNur-Lese-Zugriff (mit writable: false verwenden)

Auf dieser Seite