feat(calendar): auto-create default calendar for new users

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 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-10 18:56:26 +01:00
parent 2123145543
commit 114c2e9c62
7 changed files with 303 additions and 100 deletions

View file

@ -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)

View file

@ -85,11 +85,20 @@ export class EventService {
}
async create(userId: string, dto: CreateEventDto): Promise<Event> {
// 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,

View file

@ -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 @@
<div class="flex flex-col gap-2">
<label for="calendar" class="text-sm font-medium text-foreground">Kalender</label>
<select
id="calendar"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={calendarId}
>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
{#if calendarsStore.calendars.length > 0}
<select
id="calendar"
class="w-full px-3 py-2 border-2 border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary transition-colors"
bind:value={calendarId}
>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
{:else}
<p class="text-sm text-muted-foreground italic">Standardkalender wird automatisch erstellt</p>
{/if}
</div>
<div class="flex flex-col gap-2">
@ -400,7 +405,7 @@
<button
type="submit"
class="px-4 py-2 rounded-lg font-medium text-primary-foreground bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
disabled={submitting || !title.trim() || !calendarId}
disabled={submitting || !title.trim()}
>
{mode === 'create' ? 'Erstellen' : 'Speichern'}
</button>

View file

@ -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<HTMLInputElement | null>(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<string, unknown> | undefined = undefined;
// Build metadata - preserve existing metadata in edit mode
let metadata: Record<string, unknown> | 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'}
>
<form onsubmit={handleSubmit}>
<!-- Header -->
<div class="overlay-header">
<span class="header-title">Neuer Termin</span>
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<span class="header-title">{isEditMode ? 'Termin bearbeiten' : 'Neuer Termin'}</span>
<div class="header-actions">
{#if isEditMode}
<button
type="button"
class="delete-btn"
onclick={handleDelete}
disabled={submitting}
aria-label="Löschen"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Scrollable content -->
@ -400,11 +552,15 @@
</div>
<div class="row-content">
<label class="field-label">Kalender</label>
<select class="field-select" value={calendarId} onchange={handleCalendarChange}>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
{#if calendarsStore.calendars.length > 0}
<select class="field-select" value={calendarId} onchange={handleCalendarChange}>
{#each calendarsStore.calendars as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
{:else}
<span class="field-placeholder">Standardkalender wird erstellt</span>
{/if}
</div>
</div>
@ -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;
}

View file

@ -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);

View file

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

View file

@ -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;