From 114c2e9c62b67f9a7e1a9cbe5ec8f56159427b20 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Wed, 10 Dec 2025 18:56:26 +0100 Subject: [PATCH] feat(calendar): auto-create default calendar for new users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New users can now create events without having a pre-existing calendar. The backend automatically creates a default calendar ("Mein Kalender") when an event is created without a calendarId. Changes: - Make calendarId optional in CreateEventDto and CreateEventInput - Event service calls getOrCreateDefaultCalendar when no calendarId provided - Frontend forms show "Standardkalender wird erstellt" when no calendars exist - Frontend refreshes calendars after event creation if none existed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../backend/src/event/dto/create-event.dto.ts | 3 +- .../apps/backend/src/event/event.service.ts | 15 +- .../src/lib/components/event/EventForm.svelte | 29 +- .../components/event/QuickEventOverlay.svelte | 333 ++++++++++++++---- .../apps/web/src/routes/(app)/+layout.svelte | 15 +- .../src/routes/(app)/event/new/+page.svelte | 5 + .../packages/shared/src/types/event.ts | 3 +- 7 files changed, 303 insertions(+), 100 deletions(-) diff --git a/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts b/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts index 70a7fbee0..f1778fb84 100644 --- a/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts +++ b/apps/calendar/apps/backend/src/event/dto/create-event.dto.ts @@ -12,8 +12,9 @@ import { import type { EventMetadata } from '../../db/schema/events.schema'; export class CreateEventDto { + @IsOptional() @IsUUID() - calendarId: string; + calendarId?: string; @IsString() @MaxLength(500) diff --git a/apps/calendar/apps/backend/src/event/event.service.ts b/apps/calendar/apps/backend/src/event/event.service.ts index 91eeb3ac4..6e80e89cc 100644 --- a/apps/calendar/apps/backend/src/event/event.service.ts +++ b/apps/calendar/apps/backend/src/event/event.service.ts @@ -85,11 +85,20 @@ export class EventService { } async create(userId: string, dto: CreateEventDto): Promise { - // Verify user owns the calendar - const calendar = await this.calendarService.findByIdOrThrow(dto.calendarId, userId); + let calendarId = dto.calendarId; + let calendar; + + // If no calendarId provided, get or create default calendar + if (!calendarId) { + calendar = await this.calendarService.getOrCreateDefaultCalendar(userId); + calendarId = calendar.id; + } else { + // Verify user owns the specified calendar + calendar = await this.calendarService.findByIdOrThrow(calendarId, userId); + } const newEvent: NewEvent = { - calendarId: dto.calendarId, + calendarId, userId, title: dto.title, description: dto.description, 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 c8dcf2d70..c69329a78 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte @@ -131,7 +131,7 @@ e.preventDefault(); if (!title.trim()) return; - if (!calendarId) return; + // calendarId is now optional - backend will use/create default calendar if not provided const startDateTime = new Date(`${startDate}T${isAllDay ? '00:00' : startTime}`); const endDateTime = new Date(`${endDate}T${isAllDay ? '23:59' : endTime}`); @@ -177,7 +177,8 @@ isAllDay, startTime: startDateTime.toISOString(), endTime: endDateTime.toISOString(), - calendarId, + // Only include calendarId if set - backend will use default if not provided + ...(calendarId ? { calendarId } : {}), metadata: finalMetadata, tagIds: selectedTags.length > 0 ? selectedTags.map((t) => t.id) : undefined, }; @@ -202,15 +203,19 @@
- + {#if calendarsStore.calendars.length > 0} + + {:else} +

Standardkalender wird automatisch erstellt

+ {/if}
@@ -400,7 +405,7 @@ diff --git a/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte b/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte index 763bd8630..b2304fedf 100644 --- a/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte @@ -2,18 +2,25 @@ import { calendarsStore } from '$lib/stores/calendars.svelte'; import { eventsStore } from '$lib/stores/events.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; - import type { LocationDetails } from '@calendar/shared'; + import { toast } from '$lib/stores/toast'; + import type { LocationDetails, CalendarEvent } from '@calendar/shared'; import { format, addMinutes, parseISO } from 'date-fns'; import { de } from 'date-fns/locale'; import { tick, onMount, onDestroy } from 'svelte'; interface Props { - startTime: Date; + startTime?: Date; + event?: CalendarEvent; onClose: () => void; onCreated?: () => void; + onUpdated?: () => void; + onDeleted?: () => void; } - let { startTime, onClose, onCreated }: Props = $props(); + let { startTime, event, onClose, onCreated, onUpdated, onDeleted }: Props = $props(); + + // Mode: create or edit + let isEditMode = $derived(!!event); // Input ref for programmatic focus let titleInputRef = $state(null); @@ -25,12 +32,17 @@ // Track when draft event was last modified (to ignore clicks after drag/resize) let lastDraftUpdateTime = $state(0); - // Calculate position relative to draft event element + // Calculate position relative to draft event element or existing event function updatePosition() { if (typeof window === 'undefined') return; - const draftElement = document.querySelector('[data-event-id="__draft__"]'); - if (!draftElement) { + // In edit mode, position relative to the existing event element + const eventSelector = isEditMode + ? `[data-event-id="${event!.id}"]` + : '[data-event-id="__draft__"]'; + const eventElement = document.querySelector(eventSelector); + + if (!eventElement) { // Fallback: center in viewport const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; @@ -42,7 +54,7 @@ return; } - const rect = draftElement.getBoundingClientRect(); + const rect = eventElement.getBoundingClientRect(); const overlayWidth = 380; const maxOverlayHeight = 450; const margin = 16; @@ -79,7 +91,7 @@ positionInitialized = true; } - // Handle clicks outside overlay (but allow clicks on draft event) + // Handle clicks outside overlay (but allow clicks on event) function handleDocumentClick(e: MouseEvent) { // Ignore clicks within 250ms of draft event update (drag/resize just ended) if (Date.now() - lastDraftUpdateTime < 250) { @@ -88,10 +100,13 @@ const target = e.target as HTMLElement; const overlay = document.querySelector('.quick-event-overlay'); - const draftEvent = document.querySelector('[data-event-id="__draft__"]'); + const eventSelector = isEditMode + ? `[data-event-id="${event!.id}"]` + : '[data-event-id="__draft__"]'; + const eventElement = document.querySelector(eventSelector); - // Don't close if clicking on overlay or draft event - if (overlay?.contains(target) || draftEvent?.contains(target)) { + // Don't close if clicking on overlay or event element + if (overlay?.contains(target) || eventElement?.contains(target)) { return; } @@ -115,18 +130,19 @@ document.removeEventListener('click', handleDocumentClick); }); - // Update position when draft event changes (user dragged it) - // Also track the update time to prevent closing overlay after drag/resize + // Update position when draft event changes (user dragged it) - only in create mode $effect(() => { - const draft = eventsStore.draftEvent; - if (draft && positionInitialized) { - // Track when draft was updated (for click ignore logic) - lastDraftUpdateTime = Date.now(); + if (!isEditMode) { + const draft = eventsStore.draftEvent; + if (draft && positionInitialized) { + // Track when draft was updated (for click ignore logic) + lastDraftUpdateTime = Date.now(); - // Use requestAnimationFrame to wait for DOM update - requestAnimationFrame(() => { - updatePosition(); - }); + // Use requestAnimationFrame to wait for DOM update + requestAnimationFrame(() => { + updatePosition(); + }); + } } }); @@ -135,11 +151,15 @@ if (titleInputRef) { tick().then(() => { titleInputRef?.focus(); + // Select all text in edit mode for easy replacement + if (isEditMode) { + titleInputRef?.select(); + } }); } }); - // Form state - initialize from draft event + // Form state - initialize from event (edit mode) or draft event (create mode) let title = $state(''); let calendarId = $state(''); let description = $state(''); @@ -155,82 +175,132 @@ let locationCountry = $state(''); let submitting = $state(false); - // Date/time fields - derive from draft event + // Editable date/time strings (for form inputs) + let startDateStr = $state(''); + let startTimeStr = $state(''); + let endDateStr = $state(''); + let endTimeStr = $state(''); + + // Initialize form state from event in edit mode + $effect(() => { + if (isEditMode && event) { + title = event.title || ''; + calendarId = event.calendarId || ''; + description = event.description || ''; + location = event.location || ''; + isAllDay = event.isAllDay || false; + allDayDisplayMode = + (event.metadata?.allDayDisplayMode as 'default' | 'header' | 'block') || 'default'; + + // Initialize location details + const loc = event.metadata?.locationDetails; + if (loc) { + showLocationDetails = true; + locationStreet = loc.street || ''; + locationPostalCode = loc.postalCode || ''; + locationCity = loc.city || ''; + locationCountry = loc.country || ''; + } + + // Initialize time fields + const eventStart = + typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + startDateStr = format(eventStart, 'yyyy-MM-dd'); + startTimeStr = format(eventStart, 'HH:mm'); + endDateStr = format(eventEnd, 'yyyy-MM-dd'); + endTimeStr = format(eventEnd, 'HH:mm'); + } + }); + + // Date/time fields - derive from draft event (create mode) or event (edit mode) let draftStart = $derived(() => { + if (isEditMode && event) { + return typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + } const draft = eventsStore.draftEvent; if (draft) { return typeof draft.startTime === 'string' ? parseISO(draft.startTime) : draft.startTime; } - return startTime; + return startTime || new Date(); }); let draftEnd = $derived(() => { + if (isEditMode && event) { + return typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + } const draft = eventsStore.draftEvent; if (draft) { return typeof draft.endTime === 'string' ? parseISO(draft.endTime) : draft.endTime; } - return addMinutes(startTime, settingsStore.defaultEventDuration); + return addMinutes(startTime || new Date(), settingsStore.defaultEventDuration); }); - // Display date/time - derived from draft event + // Display date/time - derived from draft event or event let displayStartDate = $derived(format(draftStart(), 'yyyy-MM-dd')); let displayStartTime = $derived(format(draftStart(), 'HH:mm')); let displayEndDate = $derived(format(draftEnd(), 'yyyy-MM-dd')); let displayEndTime = $derived(format(draftEnd(), 'HH:mm')); - // Editable date/time strings (for form inputs) - let startDateStr = $state(format(startTime, 'yyyy-MM-dd')); - let startTimeStr = $state(format(startTime, 'HH:mm')); - let endDateStr = $state(''); - let endTimeStr = $state(''); - - // Sync form fields from draft event when it changes (e.g., user drags it) + // Sync form fields from draft event when it changes (e.g., user drags it) - only in create mode $effect(() => { - startDateStr = displayStartDate; - startTimeStr = displayStartTime; - endDateStr = displayEndDate; - endTimeStr = displayEndTime; + if (!isEditMode) { + startDateStr = displayStartDate; + startTimeStr = displayStartTime; + endDateStr = displayEndDate; + endTimeStr = displayEndTime; + } }); - // Set default calendar + // Set default calendar - only in create mode $effect(() => { - if (!calendarId && calendarsStore.defaultCalendar?.id) { + if (!isEditMode && !calendarId && calendarsStore.defaultCalendar?.id) { calendarId = calendarsStore.defaultCalendar.id; // Update draft event with calendar eventsStore.updateDraftEvent({ calendarId }); } }); - // Update draft event when title changes + // Update draft event when title changes - only in create mode function handleTitleChange(e: Event) { const target = e.target as HTMLInputElement; title = target.value; - eventsStore.updateDraftEvent({ title: target.value }); + if (!isEditMode) { + eventsStore.updateDraftEvent({ title: target.value }); + } } // Update draft event when time fields change function handleStartDateChange(e: Event) { const target = e.target as HTMLInputElement; startDateStr = target.value; - updateDraftTimes(); + if (!isEditMode) { + updateDraftTimes(); + } } function handleStartTimeChange(e: Event) { const target = e.target as HTMLInputElement; startTimeStr = target.value; - updateDraftTimes(); + if (!isEditMode) { + updateDraftTimes(); + } } function handleEndDateChange(e: Event) { const target = e.target as HTMLInputElement; endDateStr = target.value; - updateDraftTimes(); + if (!isEditMode) { + updateDraftTimes(); + } } function handleEndTimeChange(e: Event) { const target = e.target as HTMLInputElement; endTimeStr = target.value; - updateDraftTimes(); + if (!isEditMode) { + updateDraftTimes(); + } } function updateDraftTimes() { @@ -252,13 +322,17 @@ function handleCalendarChange(e: Event) { const target = e.target as HTMLSelectElement; calendarId = target.value; - eventsStore.updateDraftEvent({ calendarId: target.value }); + if (!isEditMode) { + eventsStore.updateDraftEvent({ calendarId: target.value }); + } } // Update draft when all-day changes function handleAllDayToggle() { isAllDay = !isAllDay; - updateDraftTimes(); + if (!isEditMode) { + updateDraftTimes(); + } } // Overlay style @@ -292,18 +366,32 @@ } : undefined; - // Build metadata - let metadata: Record | undefined = undefined; + // Build metadata - preserve existing metadata in edit mode + let metadata: Record | undefined = isEditMode + ? { ...(event?.metadata || {}) } + : undefined; if (isAllDay && allDayDisplayMode !== 'default') { - metadata = { allDayDisplayMode: allDayDisplayMode as 'header' | 'block' }; + metadata = { + ...(metadata || {}), + allDayDisplayMode: allDayDisplayMode as 'header' | 'block', + }; + } else if (metadata) { + delete metadata.allDayDisplayMode; } if (locationDetails) { metadata = { ...(metadata || {}), locationDetails }; + } else if (metadata) { + delete metadata.locationDetails; } - await eventsStore.createEvent({ + // Clean up empty metadata + if (metadata && Object.keys(metadata).length === 0) { + metadata = undefined; + } + + const eventData = { title: title.trim(), calendarId, startTime: startDateTime.toISOString(), @@ -312,12 +400,56 @@ description: description.trim() || undefined, location: location.trim() || undefined, metadata, - }); + }; + + if (isEditMode && event) { + // Update existing event + const result = await eventsStore.updateEvent(event.id, eventData); + if (result.error) { + toast.error(`Fehler beim Speichern: ${result.error.message}`); + return; + } + toast.success('Termin aktualisiert'); + onUpdated?.(); + } else { + // Create new event + await eventsStore.createEvent(eventData); + // Refresh calendars if none existed (in case default was created) + if (calendarsStore.calendars.length === 0) { + await calendarsStore.fetchCalendars(); + } + onCreated?.(); + } - onCreated?.(); onClose(); } catch (error) { - console.error('Failed to create event:', error); + console.error('Failed to save event:', error); + toast.error('Fehler beim Speichern'); + } finally { + submitting = false; + } + } + + async function handleDelete() { + if (!event) return; + + if (!confirm('Möchten Sie diesen Termin wirklich löschen?')) { + return; + } + + submitting = true; + try { + const result = await eventsStore.deleteEvent(event.id); + if (result.error) { + toast.error(`Fehler beim Löschen: ${result.error.message}`); + return; + } + toast.success('Termin gelöscht'); + onDeleted?.(); + onClose(); + } catch (error) { + console.error('Failed to delete event:', error); + toast.error('Fehler beim Löschen'); } finally { submitting = false; } @@ -338,22 +470,42 @@ style={overlayStyle} role="dialog" aria-modal="true" - aria-label="Termin erstellen" + aria-label={isEditMode ? 'Termin bearbeiten' : 'Termin erstellen'} >
- Neuer Termin - + {isEditMode ? 'Termin bearbeiten' : 'Neuer Termin'} +
+ {#if isEditMode} + + {/if} + +
@@ -400,11 +552,15 @@
- + {#if calendarsStore.calendars.length > 0} + + {:else} + Standardkalender wird erstellt + {/if}
@@ -664,14 +820,14 @@ display: flex; flex-direction: column; animation: slideIn 150ms ease-out; - overflow: hidden; /* Prevent any content from overflowing */ + overflow: hidden; } .quick-event-overlay form { display: flex; flex-direction: column; flex: 1; - min-height: 0; /* Allow form to shrink below content size */ + min-height: 0; height: 100%; } @@ -701,7 +857,14 @@ color: hsl(var(--color-foreground)); } - .close-btn { + .header-actions { + display: flex; + align-items: center; + gap: 0.25rem; + } + + .close-btn, + .delete-btn { padding: 0.375rem; border: none; background: transparent; @@ -716,11 +879,21 @@ color: hsl(var(--color-foreground)); } + .delete-btn:hover { + background: hsl(var(--color-error) / 0.1); + color: hsl(var(--color-error)); + } + + .delete-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .overlay-content { flex: 1; - min-height: 0; /* Important for flex scroll */ + min-height: 0; overflow-y: auto; - overscroll-behavior: contain; /* Prevent scroll chaining to background */ + overscroll-behavior: contain; padding: 0.75rem 0; } @@ -841,6 +1014,14 @@ border-color: hsl(var(--color-primary)); } + .field-placeholder { + display: block; + padding: 0.5rem 0.625rem; + font-size: 0.875rem; + color: hsl(var(--color-muted-foreground)); + font-style: italic; + } + .field-input.full { padding: 0.625rem; } diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index d97272905..d321c5b31 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -104,12 +104,6 @@ const tags = eventTagsStore.tags.map((t) => ({ id: t.id, name: t.name })); const resolved = resolveEventIds(parsed, calendars, tags); - // Ensure we have a calendar - if (!resolved.calendarId) { - console.error('No calendar available'); - return; - } - // Ensure we have start and end times if (!resolved.startTime) { // Default to now + 1 hour @@ -119,8 +113,10 @@ resolved.endTime = end.toISOString(); } + // Create event - calendarId is now optional, backend will use/create default if not provided await eventsStore.createEvent({ - calendarId: resolved.calendarId, + // Only include calendarId if resolved (from command or default calendar) + ...(resolved.calendarId ? { calendarId: resolved.calendarId } : {}), title: resolved.title, startTime: resolved.startTime, endTime: resolved.endTime || resolved.startTime, @@ -128,6 +124,11 @@ location: resolved.location, tagIds: resolved.tagIds, }); + + // Refresh calendars if none existed (in case default was created) + if (calendarsStore.calendars.length === 0) { + await calendarsStore.fetchCalendars(); + } } let isSidebarMode = $state(false); diff --git a/apps/calendar/apps/web/src/routes/(app)/event/new/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/event/new/+page.svelte index ff5ff819d..fb375c300 100644 --- a/apps/calendar/apps/web/src/routes/(app)/event/new/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/event/new/+page.svelte @@ -34,6 +34,11 @@ return; } + // Refresh calendars in case a default calendar was created + if (calendarsStore.calendars.length === 0) { + await calendarsStore.fetchCalendars(); + } + toast.success('Termin erstellt'); goto('/'); } diff --git a/apps/calendar/packages/shared/src/types/event.ts b/apps/calendar/packages/shared/src/types/event.ts index 8260afb15..f3f6241df 100644 --- a/apps/calendar/packages/shared/src/types/event.ts +++ b/apps/calendar/packages/shared/src/types/event.ts @@ -126,7 +126,8 @@ export interface CalendarEventWithCalendar extends CalendarEvent { * Data required to create a new event */ export interface CreateEventInput { - calendarId: string; + /** Calendar ID. If not provided, the default calendar will be used (or created if none exists) */ + calendarId?: string; title: string; description?: string; location?: string;