From 6806ef8bdeff4f489834ad1b7a50816a43e98b2a Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 20 Mar 2026 20:48:26 +0100 Subject: [PATCH] feat(calendar): add reminder UI for creating and managing event reminders - Create ReminderSelector component with two modes: - Draft mode (new events): configure reminders before save with preset dropdown, push/email toggles, add/remove multiple reminders - Saved mode (existing events): view reminders with status (pending/sent/ failed), add via preset dropdown, delete individual reminders - Integrate into EventForm: default reminder pre-configured from settings, drafts passed to parent via onSave callback for creation after event save - Integrate into EventDetailModal: load and display reminders for existing events, add/remove reminders directly via API, auto-refresh on change - German UI labels for all reminder presets (Zum Zeitpunkt, 5/10/15/30 Min., 1/2 Stunden, 1/2 Tage, 1 Woche vorher) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/event/EventDetailModal.svelte | 37 +- .../src/lib/components/event/EventForm.svelte | 26 +- .../components/event/ReminderSelector.svelte | 383 ++++++++++++++++++ 3 files changed, 443 insertions(+), 3 deletions(-) create mode 100644 apps/calendar/apps/web/src/lib/components/event/ReminderSelector.svelte diff --git a/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte index ab7ed9feb..21873de10 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte @@ -4,10 +4,12 @@ import { calendarsStore } from '$lib/stores/calendars.svelte'; import EventForm from './EventForm.svelte'; import RecurrenceEditDialog from './RecurrenceEditDialog.svelte'; + import ReminderSelector from './ReminderSelector.svelte'; import { TagBadge, toastStore as toast } from '@manacore/shared-ui'; - import type { CalendarEvent, UpdateEventInput } from '@calendar/shared'; + import type { CalendarEvent, UpdateEventInput, Reminder } from '@calendar/shared'; import { describeRecurrence, parseRRule } from '@calendar/shared'; import * as api from '$lib/api/events'; + import * as reminderApi from '$lib/api/reminders'; import { format } from 'date-fns'; import { de } from 'date-fns/locale'; import { toDate } from '$lib/utils/eventDateHelpers'; @@ -25,6 +27,7 @@ let isEditing = $state(false); let showRecurrenceDialog = $state(false); let recurrenceDialogMode = $state<'edit' | 'delete'>('delete'); + let reminders = $state([]); // Load event data $effect(() => { @@ -43,6 +46,12 @@ event = result.data; loading = false; + + // Load reminders for this event + const reminderResult = await reminderApi.getReminders(eventId); + if (reminderResult.data) { + reminders = Array.isArray(reminderResult.data) ? reminderResult.data : []; + } } async function handleSave(data: UpdateEventInput) { @@ -284,6 +293,32 @@ {/if} + +
+ + + + + +
+ { + const result = await reminderApi.getReminders(eventId); + if (result.data) { + reminders = Array.isArray(result.data) ? result.data : []; + } + }} + /> +
+
+ {#if event.location || event.metadata?.locationDetails}
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 d29b9269c..a3f5eaf19 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte @@ -12,6 +12,7 @@ import AttendeeSelector from './AttendeeSelector.svelte'; import ResponsiblePersonSelector from './ResponsiblePersonSelector.svelte'; import RecurrenceSelector from './RecurrenceSelector.svelte'; + import ReminderSelector from './ReminderSelector.svelte'; import type { CalendarEvent, CreateEventInput, @@ -24,11 +25,17 @@ import { format, addMinutes } from 'date-fns'; import { toDate } from '$lib/utils/eventDateHelpers'; + interface ReminderDraft { + minutesBefore: number; + notifyPush: boolean; + notifyEmail: boolean; + } + interface Props { mode: 'create' | 'edit'; event?: CalendarEvent; initialStartTime?: Date | null; - onSave: (data: CreateEventInput | UpdateEventInput) => void; + onSave: (data: CreateEventInput | UpdateEventInput, reminderDrafts?: ReminderDraft[]) => void; onCancel: () => void; } @@ -68,6 +75,15 @@ // Attendees state let attendees = $state(event?.metadata?.attendees || []); + // Reminder drafts (for create mode) + let reminderDrafts = $state([ + { + minutesBefore: settingsStore.defaultReminder, + notifyPush: true, + notifyEmail: false, + }, + ]); + // Recurrence state let recurrenceRule = $state(event?.recurrenceRule || null); let recurrenceEndDate = $state( @@ -238,7 +254,7 @@ }; submitting = true; - onSave(data); + onSave(data, mode === 'create' ? reminderDrafts : undefined); } @@ -352,6 +368,12 @@ }} /> + + (reminderDrafts = drafts)} + /> +
+ import { REMINDER_PRESETS, type Reminder } from '@calendar/shared'; + import * as api from '$lib/api/reminders'; + import { settingsStore } from '$lib/stores/settings.svelte'; + import { Bell, Plus, Trash, Envelope, DeviceMobile } from '@manacore/shared-icons'; + + interface ReminderDraft { + minutesBefore: number; + notifyPush: boolean; + notifyEmail: boolean; + } + + interface Props { + /** Event ID (null for unsaved events) */ + eventId: string | null; + /** Existing reminders (for edit mode) */ + reminders?: Reminder[]; + /** For create mode: callback with draft reminders to create after event save */ + onDraftsChange?: (drafts: ReminderDraft[]) => void; + /** Called after a reminder is added/removed on a saved event */ + onRemindersChange?: () => void; + } + + let { eventId, reminders = [], onDraftsChange, onRemindersChange }: Props = $props(); + + // Draft reminders for unsaved events + let drafts = $state([ + { + minutesBefore: settingsStore.defaultReminder, + notifyPush: true, + notifyEmail: false, + }, + ]); + + let isAdding = $state(false); + + // German labels for presets + const PRESET_LABELS: Record = { + 0: 'Zum Zeitpunkt', + 5: '5 Minuten vorher', + 10: '10 Minuten vorher', + 15: '15 Minuten vorher', + 30: '30 Minuten vorher', + 60: '1 Stunde vorher', + 120: '2 Stunden vorher', + 1440: '1 Tag vorher', + 2880: '2 Tage vorher', + 10080: '1 Woche vorher', + }; + + function getLabel(minutes: number): string { + return PRESET_LABELS[minutes] || `${minutes} Min. vorher`; + } + + // ==================== Draft Mode (for new events) ==================== + + function addDraft() { + drafts = [ + ...drafts, + { + minutesBefore: settingsStore.defaultReminder, + notifyPush: true, + notifyEmail: false, + }, + ]; + onDraftsChange?.(drafts); + } + + function removeDraft(index: number) { + drafts = drafts.filter((_, i) => i !== index); + onDraftsChange?.(drafts); + } + + function updateDraft(index: number, field: keyof ReminderDraft, value: number | boolean) { + drafts = drafts.map((d, i) => (i === index ? { ...d, [field]: value } : d)); + onDraftsChange?.(drafts); + } + + // ==================== Saved Event Mode ==================== + + async function addReminder(minutes: number) { + if (!eventId) return; + isAdding = true; + + const result = await api.createReminder(eventId, { + eventId, + minutesBefore: minutes, + notifyPush: true, + notifyEmail: false, + }); + + isAdding = false; + if (!result.error) { + onRemindersChange?.(); + } + } + + async function removeReminder(id: string) { + const result = await api.deleteReminder(id); + if (!result.error) { + onRemindersChange?.(); + } + } + + // Which mode? + const isSavedEvent = $derived(!!eventId && eventId !== '__draft__'); + const displayReminders = $derived(isSavedEvent ? reminders : []); + + +
+
+ + Erinnerungen +
+ + {#if isSavedEvent} + + {#if displayReminders.length === 0} +

Keine Erinnerungen

+ {/if} + + {#each displayReminders as reminder (reminder.id)} +
+
+ {getLabel(reminder.minutesBefore)} +
+ {#if reminder.notifyPush} + + + + {/if} + {#if reminder.notifyEmail} + + + + {/if} +
+ {#if reminder.status === 'sent'} + Gesendet + {:else if reminder.status === 'failed'} + Fehlgeschlagen + {/if} +
+ +
+ {/each} + + +
+ +
+ {:else} + + {#each drafts as draft, index} +
+
+ + + +
+ +
+ {/each} + + + {/if} +
+ +