diff --git a/apps/calendar/apps/web/src/lib/api/sync.ts b/apps/calendar/apps/web/src/lib/api/sync.ts new file mode 100644 index 000000000..c86505d6e --- /dev/null +++ b/apps/calendar/apps/web/src/lib/api/sync.ts @@ -0,0 +1,109 @@ +/** + * External Calendar Sync API Client + */ + +import { fetchApi } from './client'; +import type { + ExternalCalendar, + ConnectExternalCalendarInput, + CalDavDiscoveryResult, +} from '@calendar/shared'; + +export interface UpdateExternalCalendarInput { + name?: string; + syncEnabled?: boolean; + syncDirection?: 'import' | 'export' | 'both'; + syncInterval?: number; + color?: string; + isVisible?: boolean; +} + +// ==================== External Calendars CRUD ==================== + +export async function getExternalCalendars() { + const result = await fetchApi<{ calendars: ExternalCalendar[] }>('/sync/external'); + if (result.error || !result.data) { + return { data: null, error: result.error }; + } + return { data: result.data.calendars, error: null }; +} + +export async function getExternalCalendar(id: string) { + const result = await fetchApi<{ calendar: ExternalCalendar }>(`/sync/external/${id}`); + if (result.error || !result.data) { + return { data: null, error: result.error }; + } + return { data: result.data.calendar, error: null }; +} + +export async function connectExternalCalendar(data: ConnectExternalCalendarInput) { + const result = await fetchApi<{ calendar: ExternalCalendar }>('/sync/external', { + method: 'POST', + body: data, + }); + if (result.error || !result.data) { + return { data: null, error: result.error }; + } + return { data: result.data.calendar, error: null }; +} + +export async function updateExternalCalendar(id: string, data: UpdateExternalCalendarInput) { + const result = await fetchApi<{ calendar: ExternalCalendar }>(`/sync/external/${id}`, { + method: 'PUT', + body: data, + }); + if (result.error || !result.data) { + return { data: null, error: result.error }; + } + return { data: result.data.calendar, error: null }; +} + +export async function disconnectExternalCalendar(id: string) { + return fetchApi<{ success: boolean }>(`/sync/external/${id}`, { + method: 'DELETE', + }); +} + +// ==================== Sync Operations ==================== + +export async function triggerSync(id: string) { + const result = await fetchApi<{ + success: boolean; + eventsImported?: number; + eventsExported?: number; + }>(`/sync/external/${id}/sync`, { method: 'POST' }); + if (result.error || !result.data) { + return { data: null, error: result.error }; + } + return { data: result.data, error: null }; +} + +// ==================== CalDAV Discovery ==================== + +export async function discoverCalDav(serverUrl: string, username: string, password: string) { + const result = await fetchApi('/sync/caldav/discover', { + method: 'POST', + body: { serverUrl, username, password }, + }); + if (result.error || !result.data) { + return { data: null, error: result.error }; + } + return { data: result.data.calendars, error: null }; +} + +// ==================== Google OAuth ==================== + +export async function getGoogleAuthUrl() { + const result = await fetchApi<{ url: string }>('/sync/google/auth-url'); + if (result.error || !result.data) { + return { data: null, error: result.error }; + } + return { data: result.data.url, error: null }; +} + +// ==================== iCal Export ==================== + +export function getICalExportUrl(calendarId: string): string { + // This returns the URL for direct browser download + return `/api/v1/calendars/${calendarId}/export.ics`; +} diff --git a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte index 79bef31d2..d29b9269c 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte @@ -11,6 +11,7 @@ } from '@manacore/shared-ui'; import AttendeeSelector from './AttendeeSelector.svelte'; import ResponsiblePersonSelector from './ResponsiblePersonSelector.svelte'; + import RecurrenceSelector from './RecurrenceSelector.svelte'; import type { CalendarEvent, CreateEventInput, @@ -67,6 +68,12 @@ // Attendees state let attendees = $state(event?.metadata?.attendees || []); + // Recurrence state + let recurrenceRule = $state(event?.recurrenceRule || null); + let recurrenceEndDate = $state( + event?.recurrenceEndDate ? format(toDate(event.recurrenceEndDate), 'yyyy-MM-dd') : null + ); + // Convert EventTag to Tag type for shared-ui components function eventTagToTag(tag: EventTag): Tag { return { @@ -222,6 +229,10 @@ endTime: endDateTime.toISOString(), // Only include calendarId if set - backend will use default if not provided ...(calendarId ? { calendarId } : {}), + recurrenceRule: recurrenceRule || undefined, + recurrenceEndDate: recurrenceEndDate + ? new Date(`${recurrenceEndDate}T23:59:59`).toISOString() + : undefined, metadata: finalMetadata, tagIds: selectedTags.length > 0 ? selectedTags.map((t) => t.id) : undefined, }; @@ -331,6 +342,16 @@ {/if} + + { + recurrenceRule = rule; + recurrenceEndDate = endDt; + }} + /> +
+ import { Modal } from '@manacore/shared-ui'; + + type RecurrenceAction = 'this' | 'all' | 'this_and_future'; + + interface Props { + visible: boolean; + mode: 'edit' | 'delete'; + onSelect: (action: RecurrenceAction) => void; + onCancel: () => void; + } + + let { visible, mode, onSelect, onCancel }: Props = $props(); + + const title = $derived( + mode === 'edit' ? 'Wiederkehrenden Termin bearbeiten' : 'Wiederkehrenden Termin löschen' + ); + + + +
+ + + + + +
+ + {#snippet footer()} + + {/snippet} +
+ + diff --git a/apps/calendar/apps/web/src/lib/components/event/RecurrenceSelector.svelte b/apps/calendar/apps/web/src/lib/components/event/RecurrenceSelector.svelte new file mode 100644 index 000000000..17d8595ef --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/event/RecurrenceSelector.svelte @@ -0,0 +1,306 @@ + + +
+
+ + Wiederholung +
+ + + + {#if showCustom} +
+
+ + + +
+ + {#if pattern.frequency === 'WEEKLY'} +
+ +
+ {#each weekdays as day} + + {/each} +
+
+ {/if} + +
+ + +
+
+ {/if} + + {#if recurrenceRule} +

{currentDescription}

+ {/if} +
+ + diff --git a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts index 8e34815c4..d02d05650 100644 --- a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts @@ -3,8 +3,9 @@ */ import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared'; +import { parseRRule, generateOccurrences } from '@calendar/shared'; import * as api from '$lib/api/events'; -import { format, isWithinInterval, isSameDay } from 'date-fns'; +import { format, isWithinInterval, isSameDay, differenceInMilliseconds } from 'date-fns'; import { toDate } from '$lib/utils/eventDateHelpers'; import { toastStore } from '@manacore/shared-ui'; @@ -17,6 +18,53 @@ let loadedRange = $state<{ start: Date; end: Date } | null>(null); // Draft event for quick create (temporary event shown in grid before saving) let draftEvent = $state(null); +/** + * Expand recurring events into individual occurrences for the current view range. + * Each occurrence gets a synthetic ID: `{parentId}__recurrence__{dateISO}` + */ +function expandRecurringEvents( + rawEvents: CalendarEvent[], + rangeStart: Date, + rangeEnd: Date +): CalendarEvent[] { + const result: CalendarEvent[] = []; + + for (const event of rawEvents) { + if (!event.recurrenceRule) { + result.push(event); + continue; + } + + const pattern = parseRRule(event.recurrenceRule); + if (!pattern) { + result.push(event); + continue; + } + + const eventStart = toDate(event.startTime); + const eventEnd = toDate(event.endTime); + const durationMs = differenceInMilliseconds(eventEnd, eventStart); + const exceptions = (event.recurrenceExceptions as string[]) || []; + + const occurrences = generateOccurrences(eventStart, pattern, rangeStart, rangeEnd, exceptions); + + for (const occurrenceDate of occurrences) { + const occEnd = new Date(occurrenceDate.getTime() + durationMs); + const dateKey = format(occurrenceDate, 'yyyy-MM-dd'); + + result.push({ + ...event, + id: `${event.id}__recurrence__${dateKey}`, + parentEventId: event.id, + startTime: occurrenceDate.toISOString(), + endTime: occEnd.toISOString(), + }); + } + } + + return result; +} + export const eventsStore = { // Getters - always return safe values get events() { @@ -51,7 +99,8 @@ export const eventsStore = { } else { // API returns events array directly (already extracted in api/events.ts) const eventsData = result.data as CalendarEvent[] | null; - events = eventsData || []; + // Expand recurring events into individual occurrences for the view range + events = expandRecurringEvents(eventsData || [], startDate, endDate); loadedRange = { start: startDate, end: endDate }; } @@ -234,4 +283,85 @@ export const eventsStore = { isDraftEvent(eventId: string) { return eventId === '__draft__'; }, + + /** + * Check if an event ID is a recurrence occurrence + */ + isRecurrenceOccurrence(eventId: string) { + return eventId.includes('__recurrence__'); + }, + + /** + * Get the parent event ID from a recurrence occurrence ID + */ + getParentEventId(eventId: string): string { + if (eventId.includes('__recurrence__')) { + return eventId.split('__recurrence__')[0]; + } + return eventId; + }, + + /** + * Delete a single occurrence of a recurring event by adding an exception date + */ + async deleteRecurrenceOccurrence(eventId: string) { + const parentId = this.getParentEventId(eventId); + const dateKey = eventId.split('__recurrence__')[1]; // yyyy-MM-dd + + // Find the parent event to get existing exceptions + const parent = events.find( + (e) => e.id === parentId || this.getParentEventId(e.id) === parentId + ); + if (!parent) return { error: { message: 'Event not found' } }; + + const realParentId = this.getParentEventId(parent.id); + const existingExceptions = (parent.recurrenceExceptions as string[]) || []; + const updatedExceptions = [...existingExceptions, dateKey]; + + // Optimistic: remove this occurrence from local state + events = events.filter((e) => e.id !== eventId); + + const result = await api.updateEvent(realParentId, { + recurrenceExceptions: updatedExceptions as unknown as undefined, + }); + + if (result.error) { + toastStore.error(`Fehler: ${result.error.message}`); + // Refetch to restore state + if (loadedRange) { + this.fetchEvents(loadedRange.start, loadedRange.end); + } + } else { + toastStore.success('Termin gelöscht'); + } + + return result; + }, + + /** + * Delete all occurrences of a recurring event (deletes the parent) + */ + async deleteRecurrenceSeries(eventId: string) { + const parentId = this.getParentEventId(eventId); + return this.deleteEvent(parentId); + }, + + /** + * Update all occurrences of a recurring event (updates the parent) + */ + async updateRecurrenceSeries(eventId: string, data: UpdateEventInput) { + const parentId = this.getParentEventId(eventId); + const result = await api.updateEvent(parentId, data); + + if (result.error) { + toastStore.error(`Fehler: ${result.error.message}`); + } else { + // Refetch to regenerate occurrences + if (loadedRange) { + await this.fetchEvents(loadedRange.start, loadedRange.end); + } + } + + return result; + }, }; diff --git a/apps/calendar/apps/web/src/lib/stores/external-calendars.svelte.ts b/apps/calendar/apps/web/src/lib/stores/external-calendars.svelte.ts new file mode 100644 index 000000000..e72ffb738 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/external-calendars.svelte.ts @@ -0,0 +1,133 @@ +/** + * External Calendars Store - Manages CalDAV/iCal/Google calendar connections + */ + +import type { ExternalCalendar, ConnectExternalCalendarInput } from '@calendar/shared'; +import * as api from '$lib/api/sync'; +import { toastStore } from '@manacore/shared-ui'; + +// State +let externalCalendars = $state([]); +let loading = $state(false); +let error = $state(null); +let syncingIds = $state>(new Set()); + +function getArray(): ExternalCalendar[] { + const arr = externalCalendars ?? []; + return Array.isArray(arr) ? arr : []; +} + +export const externalCalendarsStore = { + get calendars() { + return externalCalendars; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + + isSyncing(id: string) { + return syncingIds.has(id); + }, + + async fetchCalendars() { + loading = true; + error = null; + + const result = await api.getExternalCalendars(); + + if (result.error) { + error = result.error.message; + externalCalendars = []; + } else { + externalCalendars = result.data || []; + } + + loading = false; + return result; + }, + + async connect(data: ConnectExternalCalendarInput) { + const result = await api.connectExternalCalendar(data); + + if (result.error) { + toastStore.error(`Verbindung fehlgeschlagen: ${result.error.message}`); + } else if (result.data) { + externalCalendars = [...externalCalendars, result.data]; + toastStore.success(`${data.name} verbunden`); + } + + return result; + }, + + async update(id: string, data: api.UpdateExternalCalendarInput) { + const result = await api.updateExternalCalendar(id, data); + + if (result.error) { + toastStore.error(`Aktualisierung fehlgeschlagen: ${result.error.message}`); + } else if (result.data) { + externalCalendars = getArray().map((c) => (c.id === id ? result.data! : c)); + } + + return result; + }, + + async disconnect(id: string) { + const cal = getArray().find((c) => c.id === id); + const result = await api.disconnectExternalCalendar(id); + + if (result.error) { + toastStore.error(`Trennung fehlgeschlagen: ${result.error.message}`); + } else { + externalCalendars = getArray().filter((c) => c.id !== id); + toastStore.success(`${cal?.name || 'Kalender'} getrennt`); + } + + return result; + }, + + async triggerSync(id: string) { + syncingIds = new Set([...syncingIds, id]); + + const result = await api.triggerSync(id); + + if (result.error) { + toastStore.error(`Sync fehlgeschlagen: ${result.error.message}`); + // Update last sync error in local state + externalCalendars = getArray().map((c) => + c.id === id ? { ...c, lastSyncError: result.error!.message } : c + ); + } else { + toastStore.success('Synchronisation abgeschlossen'); + // Update local state with new sync time + externalCalendars = getArray().map((c) => + c.id === id ? { ...c, lastSyncAt: new Date().toISOString(), lastSyncError: null } : c + ); + } + + const newSet = new Set(syncingIds); + newSet.delete(id); + syncingIds = newSet; + + return result; + }, + + async discoverCalDav(serverUrl: string, username: string, password: string) { + return api.discoverCalDav(serverUrl, username, password); + }, + + async getGoogleAuthUrl() { + return api.getGoogleAuthUrl(); + }, + + getById(id: string) { + return getArray().find((c) => c.id === id); + }, + + clear() { + externalCalendars = []; + error = null; + }, +}; diff --git a/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte index 6f40da216..804e68382 100644 --- a/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/settings/+page.svelte @@ -358,6 +358,33 @@ + + + {#snippet icon()} + + + + {/snippet} + +
+

+ Verbinde Google Calendar, Apple Calendar, CalDAV oder iCal-URLs. +

+ + Kalender-Sync verwalten + +
+
+
+ + import { onMount } from 'svelte'; + import { goto } from '$app/navigation'; + import { authStore } from '$lib/stores/auth.svelte'; + import { externalCalendarsStore } from '$lib/stores/external-calendars.svelte'; + import { + CaretLeft, + Plus, + ArrowsClockwise, + Trash, + Globe, + GoogleLogo, + Link, + CloudArrowDown, + CloudArrowUp, + CheckCircle, + Warning, + X, + } from '@manacore/shared-icons'; + import { Modal, Input } from '@manacore/shared-ui'; + import { PROVIDER_INFO, type CalendarProvider, type SyncDirection } from '@calendar/shared'; + import { formatDistanceToNow } from 'date-fns'; + import { de } from 'date-fns/locale'; + + // Connect form state + let showConnectForm = $state(false); + let connectStep = $state<'provider' | 'credentials' | 'caldav-discover'>('provider'); + let selectedProvider = $state(null); + let connectName = $state(''); + let connectUrl = $state(''); + let connectUsername = $state(''); + let connectPassword = $state(''); + let connectDirection = $state('import'); + let isConnecting = $state(false); + + // CalDAV discovery + let discoveredCalendars = $state>([]); + let isDiscovering = $state(false); + + // Provider selection + const providers: { id: CalendarProvider; label: string; description: string }[] = [ + { id: 'ical_url', label: 'iCal URL', description: 'ICS-Link importieren (z.B. Feiertage)' }, + { id: 'caldav', label: 'CalDAV', description: 'CalDAV-Server verbinden' }, + { id: 'google', label: 'Google Calendar', description: 'Mit Google Kalender synchronisieren' }, + { id: 'apple', label: 'Apple Calendar', description: 'iCloud Kalender verbinden' }, + ]; + + function selectProvider(provider: CalendarProvider) { + selectedProvider = provider; + + if (provider === 'google') { + handleGoogleConnect(); + return; + } + + if (provider === 'caldav') { + connectStep = 'caldav-discover'; + return; + } + + connectStep = 'credentials'; + } + + async function handleGoogleConnect() { + const result = await externalCalendarsStore.getGoogleAuthUrl(); + if (result.data) { + window.location.href = result.data; + } + } + + async function handleCalDavDiscover() { + if (!connectUrl.trim() || !connectUsername.trim() || !connectPassword.trim()) return; + + isDiscovering = true; + const result = await externalCalendarsStore.discoverCalDav( + connectUrl.trim(), + connectUsername.trim(), + connectPassword.trim() + ); + + if (result.data) { + discoveredCalendars = result.data; + } + isDiscovering = false; + } + + async function connectCalDavCalendar(cal: { url: string; name: string; color?: string }) { + isConnecting = true; + await externalCalendarsStore.connect({ + name: cal.name, + provider: 'caldav', + calendarUrl: cal.url, + username: connectUsername.trim(), + password: connectPassword.trim(), + syncDirection: connectDirection, + color: cal.color, + }); + isConnecting = false; + closeConnectForm(); + } + + async function handleConnect() { + if (!connectName.trim() || !connectUrl.trim()) return; + if (!selectedProvider) return; + + isConnecting = true; + + await externalCalendarsStore.connect({ + name: connectName.trim(), + provider: selectedProvider, + calendarUrl: connectUrl.trim(), + username: connectUsername.trim() || undefined, + password: connectPassword.trim() || undefined, + syncDirection: connectDirection, + }); + + isConnecting = false; + closeConnectForm(); + } + + function closeConnectForm() { + showConnectForm = false; + connectStep = 'provider'; + selectedProvider = null; + connectName = ''; + connectUrl = ''; + connectUsername = ''; + connectPassword = ''; + connectDirection = 'import'; + discoveredCalendars = []; + } + + async function handleDisconnect(id: string, name: string) { + if (!confirm(`"${name}" wirklich trennen? Synchronisierte Termine werden gelöscht.`)) return; + await externalCalendarsStore.disconnect(id); + } + + async function handleSync(id: string) { + await externalCalendarsStore.triggerSync(id); + } + + async function handleToggleSync(id: string, currentEnabled: boolean) { + await externalCalendarsStore.update(id, { syncEnabled: !currentEnabled }); + } + + function formatSyncTime(date: Date | string | null | undefined): string { + if (!date) return 'Noch nie'; + return formatDistanceToNow(new Date(date), { addSuffix: true, locale: de }); + } + + function getSyncDirectionLabel(direction: SyncDirection): string { + switch (direction) { + case 'import': + return 'Nur Import'; + case 'export': + return 'Nur Export'; + case 'both': + return 'Bidirektional'; + } + } + + onMount(async () => { + if (!authStore.isAuthenticated) { + goto('/login'); + return; + } + await externalCalendarsStore.fetchCalendars(); + }); + + + + Kalender-Sync - Einstellungen + + +
+
+ + + +

Kalender-Sync

+ +
+ +

+ Verbinde externe Kalender, um Termine zu importieren und zu synchronisieren. +

+ + {#if externalCalendarsStore.error} + + {/if} + + {#if externalCalendarsStore.loading} +
+
+
+ {:else if externalCalendarsStore.calendars.length === 0} +
+ +

Keine externen Kalender verbunden

+ +
+ {:else} +
+ {#each externalCalendarsStore.calendars as cal (cal.id)} +
+
+
+
+
+

{cal.name}

+ {PROVIDER_INFO[cal.provider]?.label || cal.provider} +
+
+
+ + +
+
+ +
+
+ Richtung + + {#if cal.syncDirection === 'import'} + + {:else if cal.syncDirection === 'export'} + + {:else} + + {/if} + {getSyncDirectionLabel(cal.syncDirection)} + +
+
+ Letzte Sync + + {formatSyncTime(cal.lastSyncAt)} + +
+
+ Status + + {#if cal.lastSyncError} + + + Fehler + + {:else if cal.syncEnabled} + + + Aktiv (alle {cal.syncInterval} Min.) + + {:else} + Pausiert + {/if} + +
+ {#if cal.lastSyncError} +
+ {cal.lastSyncError} +
+ {/if} +
+ + +
+ {/each} +
+ {/if} +
+ + + + {#if connectStep === 'provider'} +
+ {#each providers as provider} + + {/each} +
+ {:else if connectStep === 'caldav-discover'} +
+
+ + +
+
+ + +
+
+ + +
+ + {#if discoveredCalendars.length > 0} +
+

Gefundene Kalender:

+ {#each discoveredCalendars as cal} + + {/each} +
+ {/if} +
+ + {#snippet footer()} + + {/snippet} + {:else if connectStep === 'credentials'} +
+
+ + +
+
+ + +
+ {#if selectedProvider !== 'ical_url'} +
+ + +
+
+ + +
+ {/if} +
+ + +
+
+ + {#snippet footer()} + + {/snippet} + {/if} +
+ +