feat(calendar): add CalDAV/iCal sync UI and recurring events support

CalDAV/iCal Sync:
- Add sync API client (lib/api/sync.ts) for all external calendar endpoints
- Add external calendars store with connect, disconnect, sync operations
- Add /settings/sync page with provider selection (Google, CalDAV, iCal URL, Apple),
  credentials form, CalDAV discovery, sync status display, and manual sync trigger
- Add link to sync settings from main settings page

Recurring Events:
- Add RecurrenceSelector component with preset selection (daily, weekly, monthly,
  yearly, weekdays) and custom configuration (interval, weekday picker, end date)
- Integrate RecurrenceSelector into EventForm between date fields and location
- Expand recurring events into individual occurrences in events store using
  generateOccurrences() from @calendar/shared
- Add recurrence-aware delete: single occurrence (exception), all occurrences,
  or series update via dedicated store methods
- Add RecurrenceEditDialog component for "this/all/this and future" selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 18:46:33 +01:00
parent 43a2226290
commit ab42c265e1
8 changed files with 1720 additions and 2 deletions

View file

@ -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<CalDavDiscoveryResult>('/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`;
}

View file

@ -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<EventAttendee[]>(event?.metadata?.attendees || []);
// Recurrence state
let recurrenceRule = $state<string | null>(event?.recurrenceRule || null);
let recurrenceEndDate = $state<string | null>(
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}
</div>
<!-- Wiederholung -->
<RecurrenceSelector
{recurrenceRule}
{recurrenceEndDate}
onRecurrenceChange={(rule, endDt) => {
recurrenceRule = rule;
recurrenceEndDate = endDt;
}}
/>
<div class="flex flex-col gap-2">
<label for="location" class="text-sm font-medium text-foreground">Ort</label>
<input

View file

@ -0,0 +1,109 @@
<script lang="ts">
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'
);
</script>
<Modal {visible} onClose={onCancel} {title} maxWidth="sm">
<div class="options">
<button class="option-btn" onclick={() => onSelect('this')}>
<span class="option-title">
{mode === 'edit' ? 'Nur diesen Termin' : 'Nur diesen Termin löschen'}
</span>
<span class="option-desc">Andere Wiederholungen bleiben unverändert</span>
</button>
<button class="option-btn" onclick={() => onSelect('this_and_future')}>
<span class="option-title">
{mode === 'edit' ? 'Diesen und alle zukünftigen' : 'Diesen und alle zukünftigen löschen'}
</span>
<span class="option-desc">Vergangene Wiederholungen bleiben erhalten</span>
</button>
<button class="option-btn" onclick={() => onSelect('all')}>
<span class="option-title">
{mode === 'edit' ? 'Alle Termine der Serie' : 'Alle Termine der Serie löschen'}
</span>
<span class="option-desc">Die gesamte Wiederholungsserie wird betroffen</span>
</button>
</div>
{#snippet footer()}
<div class="footer">
<button class="cancel-btn" onclick={onCancel}>Abbrechen</button>
</div>
{/snippet}
</Modal>
<style>
.options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.option-btn {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
padding: 0.875rem 1rem;
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
background: hsl(var(--background));
cursor: pointer;
text-align: left;
transition: all 0.15s ease;
}
.option-btn:hover {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.05);
}
.option-title {
font-weight: 500;
font-size: 0.9375rem;
color: hsl(var(--foreground));
}
.option-desc {
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
}
.footer {
display: flex;
justify-content: flex-end;
}
.cancel-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.cancel-btn:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted));
}
</style>

View file

@ -0,0 +1,306 @@
<script lang="ts">
import {
RECURRENCE_PRESETS,
type RecurrencePattern,
type Weekday,
WEEKDAY_SHORT_LABELS,
} from '@calendar/shared';
import { parseRRule, formatRRule, describeRecurrence } from '@calendar/shared';
import { ArrowsClockwise } from '@manacore/shared-icons';
interface Props {
recurrenceRule: string | null;
recurrenceEndDate: string | null;
onRecurrenceChange: (rule: string | null, endDate: string | null) => void;
}
let { recurrenceRule, recurrenceEndDate, onRecurrenceChange }: Props = $props();
let showCustom = $state(false);
let selectedPreset = $state<string | null>(recurrenceRule);
// Custom recurrence state
let pattern = $state<RecurrencePattern>(
(recurrenceRule ? parseRRule(recurrenceRule) : null) || { frequency: 'WEEKLY' }
);
let endDate = $state(recurrenceEndDate || '');
// Check if current rule matches a preset
const isCustom = $derived.by(() => {
if (!recurrenceRule) return false;
return !RECURRENCE_PRESETS.some((p) => p.value === recurrenceRule);
});
$effect(() => {
if (isCustom && recurrenceRule) {
showCustom = true;
selectedPreset = '__custom__';
} else {
selectedPreset = recurrenceRule;
}
});
function handlePresetChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
if (value === '') {
// No repeat
selectedPreset = null;
showCustom = false;
onRecurrenceChange(null, null);
} else if (value === '__custom__') {
showCustom = true;
selectedPreset = '__custom__';
// Apply current pattern
const rule = formatRRule(pattern);
onRecurrenceChange(rule, endDate || null);
} else {
selectedPreset = value;
showCustom = false;
onRecurrenceChange(value, null);
}
}
function handleFrequencyChange(e: Event) {
pattern = {
...pattern,
frequency: (e.target as HTMLSelectElement).value as RecurrencePattern['frequency'],
};
applyCustomPattern();
}
function handleIntervalChange(e: Event) {
const val = parseInt((e.target as HTMLInputElement).value, 10);
pattern = { ...pattern, interval: val > 1 ? val : undefined };
applyCustomPattern();
}
function toggleWeekday(day: Weekday) {
const current = pattern.byDay || [];
const updated = current.includes(day) ? current.filter((d) => d !== day) : [...current, day];
pattern = { ...pattern, byDay: updated.length > 0 ? updated : undefined };
applyCustomPattern();
}
function handleEndDateChange(e: Event) {
endDate = (e.target as HTMLInputElement).value;
const rule = formatRRule(pattern);
onRecurrenceChange(rule, endDate || null);
}
function applyCustomPattern() {
const rule = formatRRule(pattern);
onRecurrenceChange(rule, endDate || null);
}
const weekdays: Weekday[] = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
const currentDescription = $derived(
recurrenceRule ? describeRecurrence(parseRRule(recurrenceRule)) : 'Wiederholt sich nicht'
);
</script>
<div class="recurrence-selector">
<div class="flex items-center gap-2 mb-2">
<ArrowsClockwise size={16} class="text-muted-foreground" />
<span class="text-sm font-medium text-foreground">Wiederholung</span>
</div>
<select class="select-input" value={selectedPreset ?? ''} onchange={handlePresetChange}>
{#each RECURRENCE_PRESETS as preset}
<option value={preset.value ?? ''}>{preset.label}</option>
{/each}
<option value="__custom__">Benutzerdefiniert...</option>
</select>
{#if showCustom}
<div class="custom-section">
<div class="custom-row">
<label class="custom-label">Alle</label>
<input
type="number"
min="1"
max="99"
value={pattern.interval || 1}
onchange={handleIntervalChange}
class="interval-input"
/>
<select class="frequency-select" value={pattern.frequency} onchange={handleFrequencyChange}>
<option value="DAILY">Tage</option>
<option value="WEEKLY">Wochen</option>
<option value="MONTHLY">Monate</option>
<option value="YEARLY">Jahre</option>
</select>
</div>
{#if pattern.frequency === 'WEEKLY'}
<div class="weekday-row">
<label class="custom-label">An</label>
<div class="weekday-buttons">
{#each weekdays as day}
<button
type="button"
class="weekday-btn"
class:active={pattern.byDay?.includes(day)}
onclick={() => toggleWeekday(day)}
>
{WEEKDAY_SHORT_LABELS[day].substring(0, 2)}
</button>
{/each}
</div>
</div>
{/if}
<div class="custom-row">
<label class="custom-label">Bis</label>
<input
type="date"
value={endDate}
onchange={handleEndDateChange}
class="date-input"
placeholder="Kein Enddatum"
/>
</div>
</div>
{/if}
{#if recurrenceRule}
<p class="recurrence-description">{currentDescription}</p>
{/if}
</div>
<style>
.recurrence-selector {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.select-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 2px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
cursor: pointer;
}
.select-input:focus {
outline: none;
border-color: hsl(var(--primary));
}
.custom-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.5rem;
border: 1px solid hsl(var(--border));
}
.custom-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.custom-label {
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
min-width: 2.5rem;
}
.interval-input {
width: 4rem;
padding: 0.375rem 0.5rem;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
text-align: center;
}
.interval-input:focus {
outline: none;
border-color: hsl(var(--primary));
}
.frequency-select {
flex: 1;
padding: 0.375rem 0.5rem;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
cursor: pointer;
}
.frequency-select:focus {
outline: none;
border-color: hsl(var(--primary));
}
.weekday-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.weekday-buttons {
display: flex;
gap: 0.25rem;
}
.weekday-btn {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid hsl(var(--border));
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.weekday-btn:hover {
border-color: hsl(var(--primary));
}
.weekday-btn.active {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
}
.date-input {
flex: 1;
padding: 0.375rem 0.5rem;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
}
.date-input:focus {
outline: none;
border-color: hsl(var(--primary));
}
.recurrence-description {
font-size: 0.75rem;
color: hsl(var(--primary));
font-style: italic;
}
</style>

View file

@ -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<CalendarEvent | null>(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;
},
};

View file

@ -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<ExternalCalendar[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let syncingIds = $state<Set<string>>(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;
},
};

View file

@ -358,6 +358,33 @@
</SettingsCard>
</SettingsSection>
<!-- Externe Kalender -->
<SettingsSection title="Externe Kalender">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{/snippet}
<SettingsCard>
<div class="flex flex-col gap-3">
<p class="text-sm text-muted-foreground">
Verbinde Google Calendar, Apple Calendar, CalDAV oder iCal-URLs.
</p>
<a
href="/settings/sync"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg font-medium text-sm bg-primary text-primary-foreground hover:bg-primary/90 transition-colors self-start"
>
Kalender-Sync verwalten
</a>
</div>
</SettingsCard>
</SettingsSection>
<!-- Global App Settings (synced across all apps) -->
<GlobalSettingsSection
{userSettings}

View file

@ -0,0 +1,883 @@
<script lang="ts">
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<CalendarProvider | null>(null);
let connectName = $state('');
let connectUrl = $state('');
let connectUsername = $state('');
let connectPassword = $state('');
let connectDirection = $state<SyncDirection>('import');
let isConnecting = $state(false);
// CalDAV discovery
let discoveredCalendars = $state<Array<{ url: string; name: string; color?: string }>>([]);
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();
});
</script>
<svelte:head>
<title>Kalender-Sync - Einstellungen</title>
</svelte:head>
<div class="page-container">
<header class="header">
<a href="/settings" class="back-button" aria-label="Zurück">
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Kalender-Sync</h1>
<button
onclick={() => (showConnectForm = true)}
class="add-button"
aria-label="Kalender verbinden"
>
<Plus size={20} weight="bold" />
</button>
</header>
<p class="description">
Verbinde externe Kalender, um Termine zu importieren und zu synchronisieren.
</p>
{#if externalCalendarsStore.error}
<div class="error-banner" role="alert">
<Warning size={16} />
<span>{externalCalendarsStore.error}</span>
</div>
{/if}
{#if externalCalendarsStore.loading}
<div class="loading">
<div class="spinner"></div>
</div>
{:else if externalCalendarsStore.calendars.length === 0}
<div class="empty-state">
<Globe size={48} class="text-muted-foreground" />
<p>Keine externen Kalender verbunden</p>
<button class="btn btn-primary" onclick={() => (showConnectForm = true)}>
<Plus size={16} weight="bold" />
Kalender verbinden
</button>
</div>
{:else}
<div class="calendar-list">
{#each externalCalendarsStore.calendars as cal (cal.id)}
<div class="calendar-card">
<div class="calendar-header">
<div class="calendar-info">
<div class="calendar-color" style="background-color: {cal.color}"></div>
<div>
<h3 class="calendar-name">{cal.name}</h3>
<span class="calendar-provider"
>{PROVIDER_INFO[cal.provider]?.label || cal.provider}</span
>
</div>
</div>
<div class="calendar-actions">
<button
class="icon-btn"
onclick={() => handleSync(cal.id)}
disabled={externalCalendarsStore.isSyncing(cal.id)}
title="Jetzt synchronisieren"
>
<ArrowsClockwise
size={16}
class={externalCalendarsStore.isSyncing(cal.id) ? 'animate-spin' : ''}
/>
</button>
<button
class="icon-btn icon-btn-danger"
onclick={() => handleDisconnect(cal.id, cal.name)}
title="Verbindung trennen"
>
<Trash size={16} />
</button>
</div>
</div>
<div class="calendar-details">
<div class="detail-row">
<span class="detail-label">Richtung</span>
<span class="detail-value">
{#if cal.syncDirection === 'import'}
<CloudArrowDown size={14} />
{:else if cal.syncDirection === 'export'}
<CloudArrowUp size={14} />
{:else}
<ArrowsClockwise size={14} />
{/if}
{getSyncDirectionLabel(cal.syncDirection)}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Letzte Sync</span>
<span class="detail-value">
{formatSyncTime(cal.lastSyncAt)}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Status</span>
<span class="detail-value">
{#if cal.lastSyncError}
<span class="status-error">
<Warning size={14} />
Fehler
</span>
{:else if cal.syncEnabled}
<span class="status-ok">
<CheckCircle size={14} />
Aktiv (alle {cal.syncInterval} Min.)
</span>
{:else}
<span class="status-paused">Pausiert</span>
{/if}
</span>
</div>
{#if cal.lastSyncError}
<div class="sync-error">
{cal.lastSyncError}
</div>
{/if}
</div>
<div class="calendar-footer">
<label class="toggle-label">
<input
type="checkbox"
checked={cal.syncEnabled}
onchange={() => handleToggleSync(cal.id, cal.syncEnabled)}
class="toggle"
/>
<span>Auto-Sync</span>
</label>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Connect Modal -->
<Modal
visible={showConnectForm}
onClose={closeConnectForm}
title={connectStep === 'provider'
? 'Kalender verbinden'
: connectStep === 'caldav-discover'
? 'CalDAV-Server verbinden'
: `${PROVIDER_INFO[selectedProvider!]?.label || ''} verbinden`}
maxWidth="md"
>
{#if connectStep === 'provider'}
<div class="provider-list">
{#each providers as provider}
<button class="provider-item" onclick={() => selectProvider(provider.id)}>
<div class="provider-icon">
{#if provider.id === 'google'}
<GoogleLogo size={24} />
{:else if provider.id === 'ical_url'}
<Link size={24} />
{:else}
<Globe size={24} />
{/if}
</div>
<div>
<span class="provider-name">{provider.label}</span>
<span class="provider-desc">{provider.description}</span>
</div>
</button>
{/each}
</div>
{:else if connectStep === 'caldav-discover'}
<div class="connect-form">
<div class="form-field">
<label for="caldav-url">Server-URL</label>
<Input bind:value={connectUrl} placeholder="https://caldav.example.com" />
</div>
<div class="form-field">
<label for="caldav-user">Benutzername</label>
<Input bind:value={connectUsername} placeholder="user@example.com" />
</div>
<div class="form-field">
<label for="caldav-pass">Passwort</label>
<input
type="password"
bind:value={connectPassword}
placeholder="Passwort"
class="password-input"
/>
</div>
{#if discoveredCalendars.length > 0}
<div class="discovered-list">
<h4>Gefundene Kalender:</h4>
{#each discoveredCalendars as cal}
<button
class="discovered-item"
onclick={() => connectCalDavCalendar(cal)}
disabled={isConnecting}
>
<div class="calendar-color" style="background-color: {cal.color || '#6b7280'}"></div>
<span>{cal.name}</span>
</button>
{/each}
</div>
{/if}
</div>
{#snippet footer()}
<div class="modal-footer">
<button class="btn btn-secondary" onclick={() => (connectStep = 'provider')}>
Zurück
</button>
<button
class="btn btn-primary"
onclick={handleCalDavDiscover}
disabled={isDiscovering || !connectUrl.trim() || !connectUsername.trim()}
>
{isDiscovering ? 'Suche...' : 'Kalender suchen'}
</button>
</div>
{/snippet}
{:else if connectStep === 'credentials'}
<div class="connect-form">
<div class="form-field">
<label>Name</label>
<Input bind:value={connectName} placeholder="Mein externer Kalender" />
</div>
<div class="form-field">
<label>URL</label>
<Input
bind:value={connectUrl}
placeholder={selectedProvider === 'ical_url'
? 'https://example.com/calendar.ics'
: 'https://caldav.example.com/calendar'}
/>
</div>
{#if selectedProvider !== 'ical_url'}
<div class="form-field">
<label>Benutzername</label>
<Input bind:value={connectUsername} placeholder="user@example.com" />
</div>
<div class="form-field">
<label>Passwort</label>
<input
type="password"
bind:value={connectPassword}
placeholder="Passwort"
class="password-input"
/>
</div>
{/if}
<div class="form-field">
<label>Sync-Richtung</label>
<select bind:value={connectDirection} class="select-input">
<option value="import">Nur Import</option>
{#if selectedProvider !== 'ical_url'}
<option value="export">Nur Export</option>
<option value="both">Bidirektional</option>
{/if}
</select>
</div>
</div>
{#snippet footer()}
<div class="modal-footer">
<button class="btn btn-secondary" onclick={() => (connectStep = 'provider')}>
Zurück
</button>
<button
class="btn btn-primary"
onclick={handleConnect}
disabled={isConnecting || !connectName.trim() || !connectUrl.trim()}
>
{isConnecting ? 'Verbinde...' : 'Verbinden'}
</button>
</div>
{/snippet}
{/if}
</Modal>
<style>
.page-container {
max-width: 640px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
.header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 0;
margin-bottom: 0.5rem;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--muted));
color: hsl(var(--foreground));
transition: all 0.2s ease;
}
.back-button:hover {
background: hsl(var(--muted-foreground) / 0.2);
}
.title {
flex: 1;
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.add-button:hover {
transform: scale(1.05);
}
.description {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-bottom: 1.5rem;
}
.error-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: hsl(0 84% 60% / 0.1);
border: 1px solid hsl(0 84% 60% / 0.3);
border-radius: 0.75rem;
color: hsl(0 84% 60%);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.loading {
display: flex;
justify-content: center;
padding: 3rem;
}
.spinner {
width: 2rem;
height: 2rem;
border: 2px solid hsl(var(--border));
border-top-color: hsl(var(--primary));
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 3rem 1rem;
text-align: center;
color: hsl(var(--muted-foreground));
}
.calendar-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.calendar-card {
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
background: hsl(var(--card));
overflow: hidden;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem;
}
.calendar-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.calendar-color {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
flex-shrink: 0;
}
.calendar-name {
font-weight: 600;
font-size: 0.9375rem;
color: hsl(var(--foreground));
}
.calendar-provider {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.calendar-actions {
display: flex;
gap: 0.25rem;
}
.calendar-details {
padding: 0 1rem;
border-top: 1px solid hsl(var(--border));
}
.detail-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
font-size: 0.8125rem;
}
.detail-row + .detail-row {
border-top: 1px solid hsl(var(--border) / 0.5);
}
.detail-label {
color: hsl(var(--muted-foreground));
}
.detail-value {
display: flex;
align-items: center;
gap: 0.375rem;
color: hsl(var(--foreground));
}
.status-ok {
display: flex;
align-items: center;
gap: 0.25rem;
color: hsl(142 71% 45%);
}
.status-error {
display: flex;
align-items: center;
gap: 0.25rem;
color: hsl(0 84% 60%);
}
.status-paused {
color: hsl(var(--muted-foreground));
}
.sync-error {
padding: 0.5rem 0;
font-size: 0.75rem;
color: hsl(0 84% 60%);
border-top: 1px solid hsl(var(--border) / 0.5);
}
.calendar-footer {
padding: 0.75rem 1rem;
border-top: 1px solid hsl(var(--border));
}
.toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--foreground));
cursor: pointer;
}
.toggle {
width: 1rem;
height: 1rem;
accent-color: hsl(var(--primary));
}
/* Icon buttons */
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
color: hsl(var(--muted-foreground));
transition: all 0.15s ease;
}
.icon-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon-btn-danger:hover {
color: hsl(0 84% 60%);
background: hsl(0 84% 60% / 0.1);
}
/* Provider list */
.provider-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.provider-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
background: hsl(var(--background));
cursor: pointer;
text-align: left;
transition: all 0.15s ease;
}
.provider-item:hover {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.05);
}
.provider-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
background: hsl(var(--muted));
color: hsl(var(--foreground));
flex-shrink: 0;
}
.provider-name {
display: block;
font-weight: 600;
font-size: 0.9375rem;
color: hsl(var(--foreground));
}
.provider-desc {
display: block;
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
}
/* Connect form */
.connect-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-field label {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--foreground));
}
.password-input,
.select-input {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1.5px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
}
.password-input:focus,
.select-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
/* Discovered calendars */
.discovered-list {
margin-top: 0.5rem;
}
.discovered-list h4 {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.5rem;
}
.discovered-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
cursor: pointer;
text-align: left;
font-size: 0.875rem;
color: hsl(var(--foreground));
transition: all 0.15s ease;
}
.discovered-item:hover:not(:disabled) {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.05);
}
.discovered-item:disabled {
opacity: 0.5;
cursor: wait;
}
.discovered-item + .discovered-item {
margin-top: 0.375rem;
}
/* Modal footer */
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-secondary {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.btn-secondary:hover:not(:disabled) {
background: hsl(var(--muted-foreground) / 0.2);
}
</style>