@dayflow/outlook-sync

@dayflow/outlook-sync verbindet DayFlow mit der Kalender-API von Microsoft Graph. Es ist ein eigenes Paket, getrennt von @dayflow/caldav – es spricht direkt die Microsoft-Graph-API.

Installation

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

Schnellstart

import { useRef, useEffect, useState } from 'react';
import {
  DayFlowCalendar,
  useCalendarApp,
  createMonthView,
} from '@dayflow/react';
import {
  attachOutlookSyncToDayFlow,
  createOutlookSync,
  createOutlookSyncAdapter,
  type OutlookDayFlowController,
  type OutlookSyncStatus,
} from '@dayflow/outlook-sync';

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

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

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

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

    const sync = createOutlookSync(adapter);
    const controller = attachOutlookSyncToDayFlow(calendar.app, sync, {
      writable: true,
      onStatusChange: setSyncStatus,
      onWriteError: (error, ctx) =>
        console.error(`[outlook-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 {
    attachOutlookSyncToDayFlow,
    createOutlookSync,
    createOutlookSyncAdapter,
  } from '@dayflow/outlook-sync';

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

  const syncStatus = ref({ state: 'idle' });
  let controller;

  onMounted(() => {
    const adapter = createOutlookSyncAdapter({
      baseUrl: '/api/outlook-calendar',
    });

    const sync = createOutlookSync(adapter);
    controller = attachOutlookSyncToDayFlow(calendar.app, sync, {
      writable: true,
      onStatusChange: status => {
        syncStatus.value = status;
      },
      onWriteError: (error, ctx) =>
        console.error(`[outlook-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 {
  attachOutlookSyncToDayFlow,
  createOutlookSync,
  createOutlookSyncAdapter,
  type OutlookDayFlowController,
  type OutlookSyncStatus,
} from '@dayflow/outlook-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: OutlookSyncStatus = { state: 'idle' };
  private controller?: OutlookDayFlowController;

  ngOnInit() {
    const adapter = createOutlookSyncAdapter({
      baseUrl: '/api/outlook-calendar',
    });

    const sync = createOutlookSync(adapter);
    this.controller = attachOutlookSyncToDayFlow(this.calendar, sync, {
      writable: true,
      onStatusChange: status => {
        this.syncStatus = status;
      },
      onWriteError: (error, ctx) =>
        console.error(`[outlook-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 {
    attachOutlookSyncToDayFlow,
    createOutlookSync,
    createOutlookSyncAdapter,
  } from '@dayflow/outlook-sync';

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

  let syncStatus = { state: 'idle' };
  let controller;

  onMount(() => {
    const adapter = createOutlookSyncAdapter({
      baseUrl: '/api/outlook-calendar',
    });

    const sync = createOutlookSync(adapter);
    controller = attachOutlookSyncToDayFlow(calendar.app, sync, {
      writable: true,
      onStatusChange: status => {
        syncStatus = status;
      },
      onWriteError: (error, ctx) =>
        console.error(`[outlook-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)

Übergeben Sie eine getToken-Factory, damit der Adapter vor jeder Anfrage ein frisches Token holt. Ideal, wenn Sie MSAL oder eine andere Auth-Bibliothek einsetzen, die die Token-Erneuerung übernimmt:

import { PublicClientApplication } from '@azure/msal-browser';

const msalInstance = new PublicClientApplication(msalConfig);

const adapter = createOutlookSyncAdapter({
  getToken: async () => {
    const result = await msalInstance.acquireTokenSilent({
      scopes: ['Calendars.ReadWrite'],
    });
    return result.accessToken;
  },
});

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

Halten Sie OAuth-Tokens auf dem Server und leiten Sie alle Graph-API-Anfragen über einen Proxy.

const adapter = createOutlookSyncAdapter({
  baseUrl: '/api/outlook-calendar',
  // No getToken needed — the proxy injects Authorization
});
// proxy.mjs (Node.js example using MSAL Node)
import { createServer } from 'node:http';
import { ConfidentialClientApplication } from '@azure/msal-node';

const msalClient = new ConfidentialClientApplication({
  auth: {
    clientId: process.env.AZURE_CLIENT_ID,
    authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`,
    clientSecret: process.env.AZURE_CLIENT_SECRET,
  },
});

const GRAPH_BASE = 'https://graph.microsoft.com/v1.0';
const ALLOWED_METHODS = new Set(['GET', 'POST', 'PATCH', 'DELETE']);

async function getToken() {
  const result = await msalClient.acquireTokenByClientCredential({
    scopes: ['https://graph.microsoft.com/.default'],
  });
  return result?.accessToken ?? '';
}

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

  if (!ALLOWED_METHODS.has(req.method ?? 'GET')) {
    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 getToken();
  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(3003);

Delta-Tokens dauerhaft speichern

Standardmäßig liegen die Outlook-Sync-Tokens (Delta-Tokens) nur im Arbeitsspeicher und gehen beim Neuladen der Seite verloren. Mit einer OutlookSyncStorage-Implementierung bleiben sie über Sitzungen hinweg erhalten:

import {
  createOutlookSync,
  type OutlookSyncStorage,
} from '@dayflow/outlook-sync';

const storage: OutlookSyncStorage = {
  getDeltaToken: async calendarId =>
    localStorage.getItem(`outlook-delta:${calendarId}`),
  setDeltaToken: async (calendarId, token) =>
    token
      ? localStorage.setItem(`outlook-delta:${calendarId}`, token)
      : localStorage.removeItem(`outlook-delta:${calendarId}`),
};

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

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

Aus dem lokalen Cache vorbefüllen

Befüllen Sie DayFlow vor der ersten Remote-Synchronisierung aus einem lokalen Speicher, damit der Kalender sofort erscheint:

const controller = attachOutlookSyncToDayFlow(calendar.app, sync, {
  getInitialSnapshot: async () => {
    const { calendars, events } = await loadFromLocalDB();
    return { calendars, events };
  },
  onSyncComplete: delta => {
    saveChanges(delta);
  },
  onWriteComplete: (operation, event) => {
    persistEvent(operation, event);
  },
});

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, getOutlookMeta } from '@dayflow/outlook-sync';

const delta = await applyRemoteSnapshot(
  calendar.app,
  { calendars, events },
  {
    isOwnedEvent: event => Boolean(getOutlookMeta(event)),
    isOwnedCalendar: calendar => calendar.source === 'Outlook',
    snapshotMode: 'authoritative',
    resolveConflict: (remote, local) =>
      mergeLocalEditsOntoRemote(remote, local),
  }
);

Setzen Sie snapshotMode: 'authoritative' nur bei vollständigen Anbieter-Snapshots. Bereichsbegrenzte, gefilterte oder seitenweise Snapshots sollten beim voreingestellten Teilmodus bleiben, damit fehlende lokale Datensätze erhalten bleiben.

Optionsreferenz

Optionen von attachOutlookSyncToDayFlow

OptionTypStandardBeschreibung
writablebooleantrueErlaubt, lokale Änderungen nach Outlook Calendar zurückzuschreiben.
onStatusChange(status: OutlookSyncStatus) => voidWird bei jeder Änderung des Sync-Zustands aufgerufen.
onWriteError(error: Error, ctx) => voidconsole.errorWird aufgerufen, wenn eine Rückschreibung 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: OutlookSyncDelta) => voidWird nach jeder erfolgreichen Synchronisierung mit den Änderungszählern aufgerufen.
onWriteComplete(operation, event) => voidWird aufgerufen, sobald eine lokale Änderung erfolgreich nach Outlook Calendar geschrieben wurde.

Optionen von createOutlookSyncAdapter

OptionTypStandardBeschreibung
baseUrlstringhttps://graph.microsoft.com/v1.0Überschreiben, um auf einen Backend-Proxy zu zeigen.
fetchfunctionglobalThis.fetchEigene fetch-Implementierung.
getToken() => string | Promise<string>Wird vor jeder Anfrage aufgerufen und liefert das Zugriffstoken, das als Authorization: Bearer <token> gesetzt wird.

OutlookSyncStatus

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

OutlookSyncDelta

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

Controller-API

// Load calendars, sync initial events, and subscribe to 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: 'AAMk...' });

// Re-sync with an explicit range
await controller.refresh({
  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 Nutzenden (GET /me/calendars) und registriert jeden Kalender in DayFlow. Kalender, bei denen canEdit auf false steht, werden als readOnly: true gekennzeichnet.

Termine laden

Termine werden über den Endpunkt calendarView/delta von Microsoft Graph mit den Parametern startDateTime und endDateTime geladen; dabei werden wiederkehrende Termine innerhalb des Zeitfensters aufgelöst. Beim Navigieren lädt DayFlow die Termine des neuen Bereichs automatisch nach.

Inkrementelle Synchronisierung mit Delta-Tokens

Nach dem ersten Laden liefert die Graph-API einen @odata.deltaLink. Bei den folgenden Synchronisierungen folgt das Paket diesem Link und holt nur geänderte Termine statt des gesamten Bereichs. Ist OutlookSyncStorage eingerichtet, überstehen die Delta-Tokens ein Neuladen der Seite.

Läuft ein Delta-Token ab (Graph antwortet mit 410 Gone), fällt das Paket automatisch auf eine vollständige Bereichsabfrage zurück.

Rückschreiben

Bei writable: true werden lokale Terminänderungen nach Outlook Calendar zurückgeschrieben:

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

Antwortet eine Aktualisierung mit 412 Precondition Failed (ETag-Konflikt), holt das Paket das aktuelle ETag und versucht es einmal erneut.

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

Kalenderfarben

Outlook verwendet benannte Farben (etwa lightBlue, darkGreen) statt Hex-Codes. Das Paket bildet sie auf ungefähre Hex-Werte ab und reicht sie durch getCalendarColorsForHex, damit das DayFlow-Theming einheitlich bleibt.

Scopes der Microsoft-Graph-API

Ihr OAuth-Token muss mindestens einen dieser Scopes enthalten:

ScopeZugriff
Calendars.ReadWriteVoller Lese- und Schreibzugriff
Calendars.ReadNur-Lese-Zugriff (mit writable: false verwenden)

Für reine App-Abläufe (Server zu Server) verwenden Sie den Scope .default mit einem Dienstprinzipal:

https://graph.microsoft.com/.default

Auf dieser Seite