fix(calendar): integrate recurrence dialog and external calendars into UI

- Wire RecurrenceEditDialog into EventDetailModal and QuickEventOverlay
  so deleting recurring events shows "this/all/future" options
- Add external calendars section to CalendarSidebar with visibility
  toggle and sync error indicator
- Update COMPLEXITY_AUDIT.md to mark sync and recurrence as implemented

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 19:22:28 +01:00
parent eb859c18bc
commit 5c9e16f634
4 changed files with 142 additions and 6 deletions

View file

@ -1,14 +1,27 @@
<script lang="ts">
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { externalCalendarsStore } from '$lib/stores/external-calendars.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
function handleToggle(calendarId: string) {
calendarsStore.toggleVisibility(calendarId);
}
function handleExternalToggle(id: string, currentVisible: boolean) {
externalCalendarsStore.update(id, { isVisible: !currentVisible });
}
function handleAddCalendar() {
goto('/settings');
}
onMount(() => {
if (authStore.isAuthenticated && externalCalendarsStore.calendars.length === 0) {
externalCalendarsStore.fetchCalendars();
}
});
</script>
<div class="calendar-sidebar-section">
@ -41,6 +54,41 @@
<p class="empty-message">Keine Kalender vorhanden</p>
{/if}
</div>
{#if externalCalendarsStore.calendars.length > 0}
<div class="section-header external-header">
<h3 class="section-title">Externe Kalender</h3>
<button class="add-btn" onclick={() => goto('/settings/sync')} aria-label="Sync verwalten">
<svg class="w-4 h-4" 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>
</button>
</div>
<div class="calendar-list" role="group" aria-label="Externe Kalender Sichtbarkeit">
{#each externalCalendarsStore.calendars as cal}
<label class="calendar-item">
<input
type="checkbox"
checked={cal.isVisible}
onchange={() => handleExternalToggle(cal.id, cal.isVisible)}
style="accent-color: {cal.color}"
aria-label="{cal.name} {cal.isVisible ? 'sichtbar' : 'ausgeblendet'}"
/>
<span class="color-dot" style="background-color: {cal.color}" aria-hidden="true"></span>
<span class="calendar-name">{cal.name}</span>
{#if cal.lastSyncError}
<span class="sync-error-dot" title="Sync-Fehler"></span>
{/if}
</label>
{/each}
</div>
{/if}
</div>
<style>
@ -121,4 +169,19 @@
text-align: center;
padding: 1rem;
}
.external-header {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid hsl(var(--color-border));
}
.sync-error-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: hsl(0 84% 60%);
flex-shrink: 0;
margin-left: auto;
}
</style>

View file

@ -3,8 +3,10 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import EventForm from './EventForm.svelte';
import RecurrenceEditDialog from './RecurrenceEditDialog.svelte';
import { TagBadge, toastStore as toast } from '@manacore/shared-ui';
import type { CalendarEvent, UpdateEventInput } from '@calendar/shared';
import { describeRecurrence, parseRRule } from '@calendar/shared';
import * as api from '$lib/api/events';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
@ -21,6 +23,8 @@
let event = $state<CalendarEvent | null>(null);
let loading = $state(true);
let isEditing = $state(false);
let showRecurrenceDialog = $state(false);
let recurrenceDialogMode = $state<'edit' | 'delete'>('delete');
// Load event data
$effect(() => {
@ -60,6 +64,13 @@
async function handleDelete() {
if (!event) return;
// For recurring events, show the recurrence dialog
if (event.recurrenceRule || eventsStore.isRecurrenceOccurrence(event.id)) {
recurrenceDialogMode = 'delete';
showRecurrenceDialog = true;
return;
}
if (!confirm('Möchten Sie diesen Termin wirklich löschen?')) {
return;
}
@ -75,6 +86,21 @@
onClose();
}
async function handleRecurrenceAction(action: 'this' | 'all' | 'this_and_future') {
if (!event) return;
showRecurrenceDialog = false;
if (recurrenceDialogMode === 'delete') {
let result;
if (action === 'this') {
result = await eventsStore.deleteRecurrenceOccurrence(event.id);
} else {
result = await eventsStore.deleteRecurrenceSeries(event.id);
}
if (!result.error) onClose();
}
}
function handleCancel() {
if (isEditing) {
isEditing = false;
@ -145,6 +171,13 @@
<svelte:window onkeydown={handleKeydown} />
<RecurrenceEditDialog
visible={showRecurrenceDialog}
mode={recurrenceDialogMode}
onSelect={handleRecurrenceAction}
onCancel={() => (showRecurrenceDialog = false)}
/>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick} role="presentation">
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="modal-title">

View file

@ -3,6 +3,7 @@
import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import RecurrenceEditDialog from './RecurrenceEditDialog.svelte';
import type {
LocationDetails,
CalendarEvent,
@ -207,6 +208,7 @@
let locationCity = $state('');
let locationCountry = $state('');
let submitting = $state(false);
let showRecurrenceDialog = $state(false);
// People state
let responsiblePerson = $state<ResponsiblePerson | null>(null);
@ -605,6 +607,12 @@
async function handleDelete() {
if (!event) return;
// For recurring events, show recurrence dialog
if (event.recurrenceRule || eventsStore.isRecurrenceOccurrence(event.id)) {
showRecurrenceDialog = true;
return;
}
submitting = true;
try {
const result = await eventsStore.deleteEvent(event.id);
@ -623,6 +631,26 @@
}
}
async function handleRecurrenceDeleteAction(action: 'this' | 'all' | 'this_and_future') {
if (!event) return;
showRecurrenceDialog = false;
submitting = true;
try {
let result;
if (action === 'this') {
result = await eventsStore.deleteRecurrenceOccurrence(event.id);
} else {
result = await eventsStore.deleteRecurrenceSeries(event.id);
}
if (!result.error) {
onDeleted?.();
onClose();
}
} finally {
submitting = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
@ -632,6 +660,13 @@
<svelte:window onkeydown={handleKeydown} />
<RecurrenceEditDialog
visible={showRecurrenceDialog}
mode="delete"
onSelect={handleRecurrenceDeleteAction}
onCancel={() => (showRecurrenceDialog = false)}
/>
<!-- Overlay (no blocking backdrop - allows interaction with calendar) -->
<!-- Portal to body to escape stacking contexts -->
<div

View file

@ -80,15 +80,20 @@ Die Legacy-Composables (`useDragDrop`, `useResize`) sind von keiner Komponente i
## Teil 2: Unterentwickelte Bereiche
### 1. Keine Kalender-Synchronisation (CalDAV/iCal) — Priorität: Hoch
### 1. Keine Kalender-Synchronisation (CalDAV/iCal) — ✅ Implementiert
Backend hat `external_calendars`-Tabelle und Sync-Endpunkte. Frontend hat null UI dafür.
Für eine Kalender-App ist das das größte fehlende Feature.
- API-Client (`lib/api/sync.ts`) für alle Sync-Endpunkte
- External Calendars Store mit connect/disconnect/sync
- Settings-Seite `/settings/sync` mit Provider-Auswahl, CalDAV-Discovery, Sync-Status
- Sidebar zeigt externe Kalender mit Sichtbarkeits-Toggle
### 2. Keine wiederkehrenden Termine (Recurring Events) — Priorität: Hoch
### 2. Keine wiederkehrenden Termine (Recurring Events) — ✅ Implementiert
Backend-Schema unterstützt RFC 5545 RRULE. Kein UI oder Store-Logik dafür.
Essentiell für eine nutzbare Kalender-App.
- RecurrenceSelector-Komponente mit Presets + benutzerdefinierter Konfiguration
- Im EventForm integriert, sendet `recurrenceRule` + `recurrenceEndDate`
- Events Store expandiert RRULE zu Einzelterminen per `generateOccurrences()`
- RecurrenceEditDialog für "Diesen/Alle/Zukünftige" beim Löschen
- In EventDetailModal und QuickEventOverlay integriert
### 3. Erinnerungen / Notifications nur rudimentär — Priorität: Mittel