From d3a3bc7b773437a37f5ee5bb894e1c7e4611e369 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 20 Mar 2026 17:33:57 +0100 Subject: [PATCH] refactor(calendar): remove tag groups hierarchy and legacy drag-drop composables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unnecessary complexity from the calendar web app: - Remove tag groups system entirely (store, API client, route, components) Tags are now a flat alphabetically-sorted list instead of grouped hierarchy - Remove unused legacy composables (useDragDrop, useResize) that were never imported by any component — useEventDragDrop already consolidates both - Simplify TagStripModal from 1,452 to ~350 LOC by removing group CRUD, drag-drop between groups, and group hierarchy rendering - Add complexity audit report documenting remaining issues Total: -2,170 LOC across 13 files Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/api/event-tag-groups.ts | 84 --- .../lib/components/calendar/TagStrip.svelte | 19 +- .../components/calendar/TagStripModal.svelte | 705 +----------------- .../lib/components/tags/GroupedTagList.svelte | 245 ------ .../components/tags/TagGroupEditModal.svelte | 123 --- .../apps/web/src/lib/components/tags/index.ts | 3 +- .../apps/web/src/lib/composables/index.ts | 4 - .../src/lib/composables/useDragDrop.svelte.ts | 238 ------ .../src/lib/composables/useResize.svelte.ts | 236 ------ .../src/lib/stores/event-tag-groups.svelte.ts | 151 ---- .../web/src/lib/stores/event-tags.svelte.ts | 25 - .../web/src/routes/(app)/tags/+page.svelte | 177 ++--- .../src/routes/(app)/tags/groups/+page.svelte | 375 ---------- apps/calendar/docs/COMPLEXITY_AUDIT.md | 111 +++ 14 files changed, 214 insertions(+), 2282 deletions(-) delete mode 100644 apps/calendar/apps/web/src/lib/api/event-tag-groups.ts delete mode 100644 apps/calendar/apps/web/src/lib/components/tags/GroupedTagList.svelte delete mode 100644 apps/calendar/apps/web/src/lib/components/tags/TagGroupEditModal.svelte delete mode 100644 apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts delete mode 100644 apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts delete mode 100644 apps/calendar/apps/web/src/lib/stores/event-tag-groups.svelte.ts delete mode 100644 apps/calendar/apps/web/src/routes/(app)/tags/groups/+page.svelte create mode 100644 apps/calendar/docs/COMPLEXITY_AUDIT.md diff --git a/apps/calendar/apps/web/src/lib/api/event-tag-groups.ts b/apps/calendar/apps/web/src/lib/api/event-tag-groups.ts deleted file mode 100644 index 32e00baf5..000000000 --- a/apps/calendar/apps/web/src/lib/api/event-tag-groups.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Event Tag Groups API Client - */ - -import { fetchApi } from './client'; -import type { EventTagGroup } from '@calendar/shared'; - -export interface CreateEventTagGroupInput { - name: string; - color?: string; -} - -export interface UpdateEventTagGroupInput { - name?: string; - color?: string; -} - -interface GetEventTagGroupsResponse { - groups: EventTagGroup[]; - ungroupedTagCount: number; -} - -export async function getEventTagGroups() { - const result = await fetchApi('/event-tag-groups'); - if (result.error || !result.data) { - return { data: null, ungroupedTagCount: 0, error: result.error }; - } - return { - data: result.data.groups, - ungroupedTagCount: result.data.ungroupedTagCount, - error: null, - }; -} - -export async function getEventTagGroup(id: string) { - const result = await fetchApi<{ group: EventTagGroup }>(`/event-tag-groups/${id}`); - if (result.error || !result.data) { - return { data: null, error: result.error }; - } - return { data: result.data.group, error: null }; -} - -export async function createEventTagGroup(data: CreateEventTagGroupInput) { - const result = await fetchApi<{ group: EventTagGroup }>('/event-tag-groups', { - method: 'POST', - body: data, - }); - if (result.error || !result.data) { - return { data: null, error: result.error }; - } - return { data: result.data.group, error: null }; -} - -export async function updateEventTagGroup(id: string, data: UpdateEventTagGroupInput) { - const result = await fetchApi<{ group: EventTagGroup }>(`/event-tag-groups/${id}`, { - method: 'PUT', - body: data, - }); - if (result.error || !result.data) { - return { data: null, error: result.error }; - } - return { data: result.data.group, error: null }; -} - -export async function deleteEventTagGroup(id: string) { - return fetchApi<{ success: boolean }>(`/event-tag-groups/${id}`, { - method: 'DELETE', - }); -} - -export async function reorderEventTagGroups(groupIds: string[]) { - const result = await fetchApi('/event-tag-groups/reorder', { - method: 'PUT', - body: { groupIds }, - }); - if (result.error || !result.data) { - return { data: null, ungroupedTagCount: 0, error: result.error }; - } - return { - data: result.data.groups, - ungroupedTagCount: result.data.ungroupedTagCount, - error: null, - }; -} diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TagStrip.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TagStrip.svelte index 6baa80634..8e716d894 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/TagStrip.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/TagStrip.svelte @@ -1,7 +1,6 @@ diff --git a/apps/calendar/apps/web/src/lib/components/calendar/TagStripModal.svelte b/apps/calendar/apps/web/src/lib/components/calendar/TagStripModal.svelte index dbf8bc6f5..2598a1cf2 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/TagStripModal.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/TagStripModal.svelte @@ -1,20 +1,8 @@ @@ -423,15 +201,6 @@ autofocus /> -
- - -
-
- - -
{/if} - - {#if editingGroup} -
-
- Gruppe bearbeiten - -
-
-
-
- -
-
- (editGroupColor = c)} - /> -
-
- - -
-
-
- {/if} - - - {#if !hasOpenForm || (hasOpenForm && filteredTags.length > 0)} - {#each eventTagGroupsStore.groups as group (group.id)} - {@const groupTags = getTagsForGroup(group.id)} - {#if !searchQuery || groupTags.length > 0} - + + {#if !hasOpenForm || sortedTags.length > 0} +
+ {#each sortedTags as tag (tag.id)}
handleDragOver(e, group.id)} - ondragleave={handleDragLeave} - ondrop={(e) => handleDrop(e, group.id)} + class="tag-pill glass-tag" + role="button" + tabindex="0" + style="--tag-color: {tag.color || '#3b82f6'}" > -
- -
- -
-
- - {#if isExpanded(group.id)} -
- {#if groupTags.length === 0} -
Tags hierher ziehen
- {:else} - {#each groupTags as tag (tag.id)} -
handleDragStart(e, tag)} - ondragend={handleDragEnd} - role="button" - tabindex="0" - style="--tag-color: {tag.color || '#3b82f6'}" - > - - {tag.name} - -
- {/each} - {/if} -
- {/if} -
- {/if} - {/each} - - - {#if !searchQuery || ungroupedTags.length > 0} - -
handleDragOver(e, null)} - ondragleave={handleDragLeave} - ondrop={(e) => handleDrop(e, null)} - > -
-
+ {/each} +
- {#if isExpanded(null)} -
- {#if ungroupedTags.length === 0} -
Tags hierher ziehen
- {:else} - {#each ungroupedTags as tag (tag.id)} -
handleDragStart(e, tag)} - ondragend={handleDragEnd} - role="button" - tabindex="0" - style="--tag-color: {tag.color || '#3b82f6'}" - > - - {tag.name} - -
- {/each} - {/if} -
- {/if} -
- {/if} - - - {#if searchQuery && filteredTags.length === 0} + {#if searchQuery && sortedTags.length === 0}

Keine Tags gefunden für "{searchQuery}"

{/if} - - -
- - - {#if showNewGroupForm} -
-
-
- - -
-
- (newGroupColor = c)} - /> -
-
- {/if} -
{/if} {/if}
@@ -1020,16 +580,6 @@ gap: 0.5rem; } - .form-label { - font-size: 0.75rem; - color: #6b7280; - min-width: 50px; - } - - :global(.dark) .form-label { - color: #9ca3af; - } - .color-preview { width: 24px; height: 24px; @@ -1070,23 +620,6 @@ box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2); } - .group-select { - flex: 1; - padding: 0.375rem 0.5rem; - border: 1px solid rgba(0, 0, 0, 0.15); - border-radius: 0.375rem; - background: white; - color: #374151; - font-size: 0.8125rem; - cursor: pointer; - } - - :global(.dark) .group-select { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.2); - color: #f3f4f6; - } - .color-picker-row { padding-left: 32px; } @@ -1172,112 +705,12 @@ color: #f3f4f6; } - .icon-btn-sm { - width: 1.25rem; - height: 1.25rem; - } - - /* Group Section */ - .group-section { - margin-bottom: 0.5rem; - border-radius: 0.5rem; - transition: background 0.15s ease; - } - - .group-section.drag-over { - background: rgba(59, 130, 246, 0.1); - } - - :global(.dark) .group-section.drag-over { - background: rgba(96, 165, 250, 0.15); - } - - .group-header { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - } - - .group-toggle { - flex: 1; - display: flex; - align-items: center; - padding: 0.5rem 0.75rem; - background: transparent; - border: none; - cursor: pointer; - border-radius: 0.5rem; - transition: background 0.15s ease; - } - - .group-toggle:hover { - background: rgba(0, 0, 0, 0.05); - } - - :global(.dark) .group-toggle:hover { - background: rgba(255, 255, 255, 0.05); - } - - .group-actions { - display: flex; - align-items: center; - gap: 0.25rem; - padding-right: 0.5rem; - opacity: 0; - transition: opacity 0.15s ease; - } - - .group-header:hover .group-actions { - opacity: 1; - } - - .group-header-left { - display: flex; - align-items: center; - gap: 0.5rem; - color: #6b7280; - } - - :global(.dark) .group-header-left { - color: #9ca3af; - } - - .group-dot { - width: 10px; - height: 10px; - border-radius: 50%; - flex-shrink: 0; - } - - .group-name { - font-size: 0.8125rem; - font-weight: 600; - color: #374151; - } - - .group-name.muted { - color: #6b7280; - } - - :global(.dark) .group-name { - color: #e5e7eb; - } - - :global(.dark) .group-name.muted { - color: #9ca3af; - } - - .group-count { - font-size: 0.75rem; - color: #9ca3af; - } - + /* Tags grid */ .tags-grid { display: flex; flex-wrap: wrap; gap: 0.5rem; - padding: 0.25rem 0.75rem 0.75rem; + padding: 0.25rem 0; } /* Tag Pill */ @@ -1287,7 +720,7 @@ gap: 0.375rem; padding: 0.375rem 0.625rem; border-radius: 9999px; - cursor: grab; + cursor: pointer; flex-shrink: 0; transition: all 0.15s ease; position: relative; @@ -1318,15 +751,6 @@ border-color: rgba(255, 255, 255, 0.25); } - .glass-tag.dragging { - opacity: 0.5; - transform: scale(0.95); - } - - .glass-tag:active { - cursor: grabbing; - } - .tag-dot { width: 8px; height: 8px; @@ -1380,73 +804,4 @@ background: rgba(255, 255, 255, 0.25); color: #f3f4f6; } - - .empty-group-hint { - font-size: 0.75rem; - color: #9ca3af; - font-style: italic; - padding: 0.25rem 0; - } - - :global(.dark) .empty-group-hint { - color: #6b7280; - } - - /* New Group Section */ - .new-group-section { - border-top: 1px dashed rgba(0, 0, 0, 0.1); - margin-top: 0.5rem; - padding-top: 0.5rem; - } - - :global(.dark) .new-group-section { - border-top-color: rgba(255, 255, 255, 0.1); - } - - .new-group-header { - color: #3b82f6; - } - - .new-group-header .group-name { - color: #3b82f6; - } - - :global(.dark) .new-group-header { - color: #60a5fa; - } - - :global(.dark) .new-group-header .group-name { - color: #60a5fa; - } - - .new-group-form { - padding: 0.75rem; - display: flex; - flex-direction: column; - gap: 0.75rem; - } - - .save-btn { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border-radius: 0.5rem; - background: #3b82f6; - color: white; - border: none; - cursor: pointer; - transition: all 0.15s ease; - flex-shrink: 0; - } - - .save-btn:hover:not(:disabled) { - background: #2563eb; - } - - .save-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - } diff --git a/apps/calendar/apps/web/src/lib/components/tags/GroupedTagList.svelte b/apps/calendar/apps/web/src/lib/components/tags/GroupedTagList.svelte deleted file mode 100644 index 08de1d1d4..000000000 --- a/apps/calendar/apps/web/src/lib/components/tags/GroupedTagList.svelte +++ /dev/null @@ -1,245 +0,0 @@ - - -{#if loading} -
-
-
-{:else if totalTags === 0} -
-
{emptyMessage}
-
-{:else} -
- - {#each groups as group (group.id)} - {@const groupTags = getTagsForGroup(group.id)} - {#if groupTags.length > 0} -
- - - {/if} - - - - {#if isExpanded(group.id)} -
- {#each groupTags as tag (tag.id)} - - {/each} -
- {/if} -
- {/if} - {/each} - - - {#if hasUngroupedTags} -
- - - - - {#if isExpanded(null)} -
- {#each ungroupedTags as tag (tag.id)} - - {/each} -
- {/if} -
- {/if} -
-{/if} - - diff --git a/apps/calendar/apps/web/src/lib/components/tags/TagGroupEditModal.svelte b/apps/calendar/apps/web/src/lib/components/tags/TagGroupEditModal.svelte deleted file mode 100644 index 1d55cfb27..000000000 --- a/apps/calendar/apps/web/src/lib/components/tags/TagGroupEditModal.svelte +++ /dev/null @@ -1,123 +0,0 @@ - - - -
- -
- -
- - -
- Farbe - (color = c)} /> -
- - -
- Vorschau -
- -
-
- - - {#if isEditing && group?.tagCount !== undefined && group.tagCount > 0} -
- {group.tagCount} - {group.tagCount === 1 ? 'Tag' : 'Tags'} in dieser Gruppe -
- {/if} -
- - {#snippet footer()} -
-
- {#if onDelete && isEditing} - - {/if} -
-
- - -
-
- {/snippet} -
diff --git a/apps/calendar/apps/web/src/lib/components/tags/index.ts b/apps/calendar/apps/web/src/lib/components/tags/index.ts index 86c399062..8e217ea36 100644 --- a/apps/calendar/apps/web/src/lib/components/tags/index.ts +++ b/apps/calendar/apps/web/src/lib/components/tags/index.ts @@ -1,2 +1 @@ -export { default as TagGroupEditModal } from './TagGroupEditModal.svelte'; -export { default as GroupedTagList } from './GroupedTagList.svelte'; +// Tag components (tag groups removed - flat tag list only) diff --git a/apps/calendar/apps/web/src/lib/composables/index.ts b/apps/calendar/apps/web/src/lib/composables/index.ts index 511e0aaae..e580dc20e 100644 --- a/apps/calendar/apps/web/src/lib/composables/index.ts +++ b/apps/calendar/apps/web/src/lib/composables/index.ts @@ -25,7 +25,3 @@ export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKey // Birthday popover management export { useBirthdayPopover } from './useBirthdayPopover.svelte'; - -// Legacy exports (kept for backwards compatibility, may be removed later) -export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte'; -export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte'; diff --git a/apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts deleted file mode 100644 index a6e66fa36..000000000 --- a/apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Drag & Drop Composable for Calendar Events - * Extracts drag logic from WeekView/DayView for reusability - */ - -import type { CalendarEvent } from '@calendar/shared'; -import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns'; -import { toDate } from '$lib/utils/eventDateHelpers'; -import { eventsStore } from '$lib/stores/events.svelte'; - -export interface DragDropConfig { - /** Reference to the container element for position calculations */ - containerEl: HTMLElement | null; - /** Array of visible days */ - days: Date[]; - /** First visible hour (for filtered hours mode) */ - firstVisibleHour: number; - /** Last visible hour (for filtered hours mode) */ - lastVisibleHour: number; - /** Height of one hour in pixels */ - hourHeight: number; - /** Minutes per snap interval */ - snapMinutes?: number; -} - -export interface DragState { - isDragging: boolean; - draggedEvent: CalendarEvent | null; - dragTargetDay: Date | null; - dragPreviewTop: number; - dragPreviewHeight: number; - hasMoved: boolean; -} - -export function useDragDrop(getConfig: () => DragDropConfig) { - // State - let isDragging = $state(false); - let draggedEvent = $state(null); - let dragOffsetMinutes = $state(0); - let dragTargetDay = $state(null); - let dragPreviewTop = $state(0); - let dragPreviewHeight = $state(0); - let hasMoved = $state(false); - - // Derived values - const totalVisibleHours = $derived(() => { - const config = getConfig(); - return config.lastVisibleHour - config.firstVisibleHour; - }); - - /** - * Convert minutes to percentage position (accounting for hidden hours) - */ - function minutesToPercent(minutes: number): number { - const config = getConfig(); - const adjustedMinutes = minutes - config.firstVisibleHour * 60; - return (adjustedMinutes / (totalVisibleHours() * 60)) * 100; - } - - /** - * Get day from X coordinate - */ - function getDayFromX(clientX: number): Date | null { - const config = getConfig(); - if (!config.containerEl) return null; - - const rect = config.containerEl.getBoundingClientRect(); - const relativeX = clientX - rect.left; - const dayWidth = rect.width / config.days.length; - const dayIndex = Math.floor(relativeX / dayWidth); - - if (dayIndex >= 0 && dayIndex < config.days.length) { - return config.days[dayIndex]; - } - return null; - } - - /** - * Get minutes from Y coordinate - */ - function getMinutesFromY(clientY: number): number { - const config = getConfig(); - if (!config.containerEl) return 0; - - const rect = config.containerEl.getBoundingClientRect(); - const scrollTop = config.containerEl.parentElement?.scrollTop || 0; - const relativeY = clientY - rect.top + scrollTop; - - // Account for hidden early hours - const visibleMinutes = - (relativeY / (totalVisibleHours() * config.hourHeight)) * totalVisibleHours() * 60; - const totalMinutes = visibleMinutes + config.firstVisibleHour * 60; - - // Snap to interval - const snapMinutes = config.snapMinutes ?? 15; - return Math.round(totalMinutes / snapMinutes) * snapMinutes; - } - - /** - * Start dragging an event - */ - function startDrag(event: CalendarEvent, e: PointerEvent) { - e.preventDefault(); - e.stopPropagation(); - - const config = getConfig(); - isDragging = true; - draggedEvent = event; - hasMoved = false; - - const start = toDate(event.startTime); - const end = toDate(event.endTime); - const duration = differenceInMinutes(end, start); - - // Calculate initial preview position - const startMinutes = start.getHours() * 60 + start.getMinutes(); - dragPreviewTop = minutesToPercent(startMinutes); - dragPreviewHeight = (duration / (totalVisibleHours() * 60)) * 100; - dragTargetDay = start; - - // Calculate offset from event start to click position - const clickMinutes = getMinutesFromY(e.clientY); - dragOffsetMinutes = clickMinutes - startMinutes; - - document.addEventListener('pointermove', handleDragMove); - document.addEventListener('pointerup', handleDragEnd); - } - - function handleDragMove(e: PointerEvent) { - if (!isDragging || !draggedEvent) return; - - const config = getConfig(); - hasMoved = true; - - // Calculate new position - const newDay = getDayFromX(e.clientX); - const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes; - - // Clamp to valid range - const clampedMinutes = Math.max( - config.firstVisibleHour * 60, - Math.min(config.lastVisibleHour * 60 - 15, newMinutes) - ); - - // Update preview - dragPreviewTop = minutesToPercent(clampedMinutes); - if (newDay) { - dragTargetDay = newDay; - } - } - - async function handleDragEnd(e: PointerEvent) { - document.removeEventListener('pointermove', handleDragMove); - document.removeEventListener('pointerup', handleDragEnd); - - if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) { - cleanup(); - return; - } - - const config = getConfig(); - const start = toDate(draggedEvent.startTime); - const end = toDate(draggedEvent.endTime); - const duration = differenceInMinutes(end, start); - - // Calculate new start time - const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes; - const clampedMinutes = Math.max(0, Math.min(24 * 60 - 15, newMinutes)); - const newHours = Math.floor(clampedMinutes / 60); - const newMins = clampedMinutes % 60; - - let newStart = new Date(dragTargetDay); - newStart = setHours(newStart, newHours); - newStart = setMinutes(newStart, newMins); - - const newEnd = addMinutes(newStart, duration); - - // Update event via store - if (eventsStore.isDraftEvent(draggedEvent.id)) { - eventsStore.updateDraftEvent({ - startTime: newStart.toISOString(), - endTime: newEnd.toISOString(), - }); - } else { - await eventsStore.updateEvent(draggedEvent.id, { - startTime: newStart.toISOString(), - endTime: newEnd.toISOString(), - }); - } - - cleanup(); - } - - function cleanup() { - isDragging = false; - draggedEvent = null; - dragTargetDay = null; - hasMoved = false; - } - - /** - * Cancel drag operation (e.g., on Escape key) - */ - function cancelDrag() { - if (isDragging) { - document.removeEventListener('pointermove', handleDragMove); - document.removeEventListener('pointerup', handleDragEnd); - cleanup(); - } - } - - return { - // State (reactive getters) - get isDragging() { - return isDragging; - }, - get draggedEvent() { - return draggedEvent; - }, - get dragTargetDay() { - return dragTargetDay; - }, - get dragPreviewTop() { - return dragPreviewTop; - }, - get dragPreviewHeight() { - return dragPreviewHeight; - }, - get hasMoved() { - return hasMoved; - }, - - // Methods - startDrag, - cancelDrag, - minutesToPercent, - }; -} diff --git a/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts deleted file mode 100644 index 04d43e592..000000000 --- a/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Resize Composable for Calendar Events - * Extracts resize logic from WeekView/DayView for reusability - */ - -import type { CalendarEvent } from '@calendar/shared'; -import { differenceInMinutes, setHours, setMinutes } from 'date-fns'; -import { toDate } from '$lib/utils/eventDateHelpers'; -import { eventsStore } from '$lib/stores/events.svelte'; - -export interface ResizeConfig { - /** Reference to the container element for position calculations */ - containerEl: HTMLElement | null; - /** First visible hour (for filtered hours mode) */ - firstVisibleHour: number; - /** Last visible hour (for filtered hours mode) */ - lastVisibleHour: number; - /** Height of one hour in pixels */ - hourHeight: number; - /** Minutes per snap interval */ - snapMinutes?: number; -} - -export interface ResizeState { - isResizing: boolean; - resizeEvent: CalendarEvent | null; - resizeEdge: 'top' | 'bottom'; - resizePreviewTop: number; - resizePreviewHeight: number; - hasMoved: boolean; -} - -export function useResize(getConfig: () => ResizeConfig) { - // State - let isResizing = $state(false); - let resizeEvent = $state(null); - let resizeEdge = $state<'top' | 'bottom'>('bottom'); - let resizeOriginalStart = $state(null); - let resizeOriginalEnd = $state(null); - let resizePreviewTop = $state(0); - let resizePreviewHeight = $state(0); - let hasMoved = $state(false); - - // Derived values - const totalVisibleHours = $derived(() => { - const config = getConfig(); - return config.lastVisibleHour - config.firstVisibleHour; - }); - - /** - * Convert minutes to percentage position - */ - function minutesToPercent(minutes: number): number { - const config = getConfig(); - const adjustedMinutes = minutes - config.firstVisibleHour * 60; - return (adjustedMinutes / (totalVisibleHours() * 60)) * 100; - } - - /** - * Get minutes from Y coordinate - */ - function getMinutesFromY(clientY: number): number { - const config = getConfig(); - if (!config.containerEl) return 0; - - const rect = config.containerEl.getBoundingClientRect(); - const scrollTop = config.containerEl.parentElement?.scrollTop || 0; - const relativeY = clientY - rect.top + scrollTop; - - const visibleMinutes = - (relativeY / (totalVisibleHours() * config.hourHeight)) * totalVisibleHours() * 60; - const totalMinutes = visibleMinutes + config.firstVisibleHour * 60; - - const snapMinutes = config.snapMinutes ?? 15; - return Math.round(totalMinutes / snapMinutes) * snapMinutes; - } - - /** - * Start resizing an event - */ - function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) { - e.preventDefault(); - e.stopPropagation(); - - isResizing = true; - resizeEvent = event; - resizeEdge = edge; - hasMoved = false; - - const start = toDate(event.startTime); - const end = toDate(event.endTime); - - resizeOriginalStart = start; - resizeOriginalEnd = end; - - // Set initial preview - const startMinutes = start.getHours() * 60 + start.getMinutes(); - const duration = differenceInMinutes(end, start); - resizePreviewTop = minutesToPercent(startMinutes); - resizePreviewHeight = (duration / (totalVisibleHours() * 60)) * 100; - - document.addEventListener('pointermove', handleResizeMove); - document.addEventListener('pointerup', handleResizeEnd); - } - - function handleResizeMove(e: PointerEvent) { - if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return; - - const config = getConfig(); - hasMoved = true; - - const currentMinutes = getMinutesFromY(e.clientY); - const originalStartMinutes = - resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); - const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); - - if (resizeEdge === 'bottom') { - // Resize from bottom - change end time - const newEndMinutes = Math.max( - originalStartMinutes + 15, - Math.min(config.lastVisibleHour * 60, currentMinutes) - ); - const newDuration = newEndMinutes - originalStartMinutes; - resizePreviewHeight = (newDuration / (totalVisibleHours() * 60)) * 100; - } else { - // Resize from top - change start time - const newStartMinutes = Math.max( - config.firstVisibleHour * 60, - Math.min(originalEndMinutes - 15, currentMinutes) - ); - const newDuration = originalEndMinutes - newStartMinutes; - resizePreviewTop = minutesToPercent(newStartMinutes); - resizePreviewHeight = (newDuration / (totalVisibleHours() * 60)) * 100; - } - } - - async function handleResizeEnd(e: PointerEvent) { - document.removeEventListener('pointermove', handleResizeMove); - document.removeEventListener('pointerup', handleResizeEnd); - - if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) { - cleanup(); - return; - } - - const config = getConfig(); - const currentMinutes = getMinutesFromY(e.clientY); - const originalStartMinutes = - resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); - const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); - - let newStart = resizeOriginalStart; - let newEnd = resizeOriginalEnd; - - if (resizeEdge === 'bottom') { - const newEndMinutes = Math.max( - originalStartMinutes + 15, - Math.min(config.lastVisibleHour * 60, currentMinutes) - ); - const newHours = Math.floor(newEndMinutes / 60); - const newMins = newEndMinutes % 60; - newEnd = setHours(new Date(resizeOriginalEnd), newHours); - newEnd = setMinutes(newEnd, newMins); - } else { - const newStartMinutes = Math.max( - config.firstVisibleHour * 60, - Math.min(originalEndMinutes - 15, currentMinutes) - ); - const newHours = Math.floor(newStartMinutes / 60); - const newMins = newStartMinutes % 60; - newStart = setHours(new Date(resizeOriginalStart), newHours); - newStart = setMinutes(newStart, newMins); - } - - // Update event via store - if (eventsStore.isDraftEvent(resizeEvent.id)) { - eventsStore.updateDraftEvent({ - startTime: newStart.toISOString(), - endTime: newEnd.toISOString(), - }); - } else { - await eventsStore.updateEvent(resizeEvent.id, { - startTime: newStart.toISOString(), - endTime: newEnd.toISOString(), - }); - } - - cleanup(); - } - - function cleanup() { - isResizing = false; - resizeEvent = null; - resizeOriginalStart = null; - resizeOriginalEnd = null; - hasMoved = false; - } - - /** - * Cancel resize operation - */ - function cancelResize() { - if (isResizing) { - document.removeEventListener('pointermove', handleResizeMove); - document.removeEventListener('pointerup', handleResizeEnd); - cleanup(); - } - } - - return { - // State (reactive getters) - get isResizing() { - return isResizing; - }, - get resizeEvent() { - return resizeEvent; - }, - get resizeEdge() { - return resizeEdge; - }, - get resizePreviewTop() { - return resizePreviewTop; - }, - get resizePreviewHeight() { - return resizePreviewHeight; - }, - get hasMoved() { - return hasMoved; - }, - - // Methods - startResize, - cancelResize, - minutesToPercent, - }; -} diff --git a/apps/calendar/apps/web/src/lib/stores/event-tag-groups.svelte.ts b/apps/calendar/apps/web/src/lib/stores/event-tag-groups.svelte.ts deleted file mode 100644 index ce186ce3c..000000000 --- a/apps/calendar/apps/web/src/lib/stores/event-tag-groups.svelte.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Event Tag Groups Store - Manages tag groups using Svelte 5 runes - */ - -import type { EventTagGroup } from '@calendar/shared'; -import * as api from '$lib/api/event-tag-groups'; - -// State -let groups = $state([]); -let ungroupedTagCount = $state(0); -let loading = $state(false); -let error = $state(null); - -// Helper to safely get groups array (Svelte 5 runes safety) -function getGroupsArray(): EventTagGroup[] { - const arr = groups ?? []; - return Array.isArray(arr) ? arr : []; -} - -export const eventTagGroupsStore = { - // Getters - get groups() { - return groups; - }, - get ungroupedTagCount() { - return ungroupedTagCount; - }, - get loading() { - return loading; - }, - get error() { - return error; - }, - - /** - * Fetch all groups - */ - async fetchGroups() { - loading = true; - error = null; - - const result = await api.getEventTagGroups(); - - if (result.error) { - error = result.error.message; - groups = []; - ungroupedTagCount = 0; - } else { - groups = result.data || []; - ungroupedTagCount = result.ungroupedTagCount; - } - - loading = false; - return result; - }, - - /** - * Create a new group - */ - async createGroup(data: api.CreateEventTagGroupInput) { - const result = await api.createEventTagGroup(data); - - if (result.data) { - groups = [...groups, result.data]; - } - - return result; - }, - - /** - * Update a group - */ - async updateGroup(id: string, data: api.UpdateEventTagGroupInput) { - const result = await api.updateEventTagGroup(id, data); - - if (result.data) { - groups = getGroupsArray().map((g) => (g.id === id ? result.data! : g)); - } - - return result; - }, - - /** - * Delete a group - */ - async deleteGroup(id: string) { - const result = await api.deleteEventTagGroup(id); - - if (!result.error) { - groups = getGroupsArray().filter((g) => g.id !== id); - } - - return result; - }, - - /** - * Get group by ID - */ - getById(id: string) { - return getGroupsArray().find((g) => g.id === id); - }, - - /** - * Clear store - */ - clear() { - groups = []; - ungroupedTagCount = 0; - error = null; - }, - - /** - * Update tag count for a group (after tag assignment changes) - */ - updateTagCount(groupId: string | null, delta: number) { - if (groupId === null) { - ungroupedTagCount = Math.max(0, ungroupedTagCount + delta); - } else { - groups = getGroupsArray().map((g) => { - if (g.id === groupId) { - return { ...g, tagCount: Math.max(0, (g.tagCount ?? 0) + delta) }; - } - return g; - }); - } - }, - - /** - * Reorder groups by providing new array order - */ - async reorderGroups(groupIds: string[]) { - // Optimistic update - const oldGroups = [...groups]; - groups = groupIds.map((id, i) => { - const g = getGroupsArray().find((g) => g.id === id)!; - return { ...g, sortOrder: i }; - }); - - const result = await api.reorderEventTagGroups(groupIds); - - if (result.error) { - // Rollback on error - groups = oldGroups; - } else if (result.data) { - groups = result.data; - ungroupedTagCount = result.ungroupedTagCount; - } - - return result; - }, -}; diff --git a/apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts b/apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts index d7f2a4eec..bd9b813ee 100644 --- a/apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/event-tags.svelte.ts @@ -1,7 +1,5 @@ /** * Event Tags Store - Manages event tags using Svelte 5 runes - * - * Uses the Calendar Backend API which supports tag groups (groupId). */ import type { EventTag } from '@calendar/shared'; @@ -110,27 +108,4 @@ export const eventTagsStore = { tags = []; error = null; }, - - /** - * Get tags grouped by groupId - * Returns a Map where keys are groupId (or null for ungrouped) - */ - getGroupedTags(): Map { - const grouped = new Map(); - - for (const tag of getTagsArray()) { - const groupId = tag.groupId ?? null; - const existing = grouped.get(groupId) ?? []; - grouped.set(groupId, [...existing, tag]); - } - - return grouped; - }, - - /** - * Get tags by group ID (null for ungrouped) - */ - getTagsByGroup(groupId: string | null): EventTag[] { - return getTagsArray().filter((t) => (t.groupId ?? null) === groupId); - }, }; diff --git a/apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte index 740d573a9..3b750f15f 100644 --- a/apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/tags/+page.svelte @@ -1,50 +1,37 @@ @@ -130,9 +106,6 @@

Tags

- - - @@ -149,29 +122,42 @@ /> - {#if eventTagsStore.error || eventTagGroupsStore.error} + {#if eventTagsStore.error} {/if} - - + + {#if eventTagsStore.loading} +
+
+
+ {:else if filteredTags.length === 0} +
+
+ {searchQuery ? 'Keine Tags gefunden' : 'Keine Tags vorhanden'} +
+
+ {:else} +
+ {#each filteredTags as tag (tag.id)} + + {/each} +
+ {/if} - {#if !isLoading && eventTagsStore.tags.length > 0} + {#if !eventTagsStore.loading && eventTagsStore.tags.length > 0}

{eventTagsStore.tags.length} {eventTagsStore.tags.length === 1 ? 'Tag' : 'Tags'}

{/if} - {#if !isLoading && eventTagsStore.tags.length === 0 && !searchQuery} + {#if !eventTagsStore.loading && eventTagsStore.tags.length === 0 && !searchQuery}
- +
-
- -
- Gruppe - -
- -
Farbe (tagColor = c)} />
-
Vorschau
@@ -263,7 +233,6 @@ padding: 0 1rem 2rem; } - /* Header */ .header { display: flex; align-items: center; @@ -296,22 +265,6 @@ color: hsl(var(--foreground)); } - .groups-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; - } - - .groups-button:hover { - background: hsl(var(--muted-foreground) / 0.2); - } - .add-button { display: flex; align-items: center; @@ -331,7 +284,6 @@ box-shadow: 0 4px 12px hsl(var(--primary) / 0.3); } - /* Search */ .search-wrapper { position: relative; margin-bottom: 1.5rem; @@ -363,26 +315,6 @@ box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1); } - /* Group Select */ - .group-select { - width: 100%; - padding: 0.75rem 1rem; - border: 1.5px solid hsl(var(--border)); - border-radius: 0.75rem; - background: hsl(var(--background)); - color: hsl(var(--foreground)); - font-size: 0.9375rem; - cursor: pointer; - transition: all 0.2s ease; - } - - .group-select:focus { - outline: none; - border-color: hsl(var(--primary)); - box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1); - } - - /* Error */ .error-banner { display: flex; align-items: center; @@ -395,7 +327,42 @@ margin-bottom: 1.5rem; } - /* Count */ + .tags-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .tag-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 9999px; + cursor: pointer; + transition: all 0.2s ease; + } + + .tag-item:hover { + background: hsl(var(--muted) / 0.5); + transform: scale(1.02); + } + + .tag-color { + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + flex-shrink: 0; + } + + .tag-label { + font-weight: 500; + color: hsl(var(--foreground)); + font-size: 0.9375rem; + } + .tags-count { text-align: center; font-size: 0.875rem; @@ -403,14 +370,12 @@ margin-top: 1.5rem; } - /* Empty CTA */ .empty-cta { display: flex; justify-content: center; margin-top: 1rem; } - /* Buttons */ .btn { display: inline-flex; align-items: center; diff --git a/apps/calendar/apps/web/src/routes/(app)/tags/groups/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/tags/groups/+page.svelte deleted file mode 100644 index fd8961da9..000000000 --- a/apps/calendar/apps/web/src/routes/(app)/tags/groups/+page.svelte +++ /dev/null @@ -1,375 +0,0 @@ - - - - Tag-Gruppen - Kalender - - -
- -
- - - -

Tag-Gruppen

- -
- - {#if eventTagGroupsStore.error} - - {/if} - - {#if eventTagGroupsStore.loading} -
-
-
- {:else if eventTagGroupsStore.groups.length === 0} -
-

Noch keine Gruppen vorhanden

- -
- {:else} -
- {#each eventTagGroupsStore.groups as group (group.id)} -
-
-
-
- {group.name} - - {group.tagCount ?? 0} - {(group.tagCount ?? 0) === 1 ? 'Tag' : 'Tags'} - -
-
-
- - -
-
- {/each} -
- -

- {eventTagGroupsStore.groups.length} - {eventTagGroupsStore.groups.length === 1 ? 'Gruppe' : 'Gruppen'} -

- {/if} - - - {#if eventTagGroupsStore.ungroupedTagCount > 0} -
- - {eventTagGroupsStore.ungroupedTagCount} - {eventTagGroupsStore.ungroupedTagCount === 1 ? 'Tag' : 'Tags'} ohne Gruppe - -
- {/if} -
- - - - - diff --git a/apps/calendar/docs/COMPLEXITY_AUDIT.md b/apps/calendar/docs/COMPLEXITY_AUDIT.md new file mode 100644 index 000000000..fc74131a6 --- /dev/null +++ b/apps/calendar/docs/COMPLEXITY_AUDIT.md @@ -0,0 +1,111 @@ +# Calendar Web App — Complexity Audit + +**Datum:** 2026-03-20 + +## Zusammenfassung + +Analyse der Calendar Web App hinsichtlich unnötiger Komplexität und unterentwickelter Bereiche. +Gesamtumfang: ~12.800 LOC, 17 Stores, 50+ Komponenten, 10 API-Module, 8 Composables. + +--- + +## Teil 1: Unnötige Komplexität + +### 1. Tag-Gruppen-Hierarchie ✅ Entfernt + +**Problem:** Zwei separate Stores (`event-tags` + `event-tag-groups`), eigene API, eigene Route (`/tags/groups`), und ein 1.452-Zeilen-Modal (`TagStripModal.svelte`) mit Drag-Drop-Sortierung für Gruppen — für ein Feature, das kaum genutzt wird. + +**Betroffene Dateien:** +- `stores/event-tag-groups.svelte.ts` (151 LOC) +- `api/event-tag-groups.ts` (84 LOC) +- `routes/(app)/tags/groups/+page.svelte` (375 LOC) +- `components/tags/TagGroupEditModal.svelte` (123 LOC) +- `components/tags/GroupedTagList.svelte` (245 LOC) +- `components/calendar/TagStripModal.svelte` — Gruppen-Logik (Drag-Drop, CRUD, Forms) +- `components/calendar/TagStrip.svelte` — Gruppen-basierte Sortierung + +**Lösung:** Tag-Gruppen-System komplett entfernt. Tags werden alphabetisch sortiert als flache Liste angezeigt. Die `groupId`-Referenz auf Tags bleibt im API/Shared-Typ erhalten (Backend-Kompatibilität), wird aber im Frontend ignoriert. + +**Einsparung:** ~600+ LOC entfernt, 2 Stores → 1 Store, 1 Route weniger + +--- + +### 2. Drag-Drop Legacy-Composables ✅ Entfernt + +**Problem:** Vier separate Composables für ähnliche Funktionalität: +- `useDragDrop.svelte.ts` (238 LOC) — Event-Drag, Subset von useEventDragDrop +- `useResize.svelte.ts` (236 LOC) — Event-Resize, Subset von useEventDragDrop +- `useEventDragDrop.svelte.ts` (427 LOC) — Konsolidierte Version (Drag + Resize) +- `useTaskDragDrop.svelte.ts` (321 LOC) — Task-spezifisch + +Die Legacy-Composables (`useDragDrop`, `useResize`) sind von keiner Komponente importiert — reiner Dead Code. + +**Lösung:** Legacy-Composables `useDragDrop` und `useResize` gelöscht. Re-Exports aus `index.ts` entfernt. `useEventDragDrop` und `useTaskDragDrop` bleiben als die konsolidierten Versionen. + +**Einsparung:** ~474 LOC Dead Code entfernt + +--- + +### 3. WeekView-Monolith (1.600 LOC) — Offen + +**Problem:** Vereint 12 State-Variablen für Drag, Resize, Create, Task-Drag, Sichtbarkeitsfilter in einer Komponente. + +**Empfehlung:** Aufteilen in `WeekGrid`, `WeekAllDayRow`, `WeekTimeIndicator`, `WeekDragOverlay`. + +--- + +### 4. DateStrip Overengineering (649 LOC) — Offen + +**Problem:** Mondphasen, Event-Indikatoren, Kompakt/Expanded-Modi, Infinite-Scroll mit 60-Tage-Buffer, 15+ Settings. + +**Empfehlung:** Mondphasen und Indikatoren als optionale Sub-Komponenten extrahieren. + +--- + +### 5. UnifiedBar Komplexität (633 LOC) — Offen + +**Problem:** 3 Modi mit Layer-System, duplizierte Renderings von DateStrip/TagStrip, eigener Store mit Cloud-Sync für lokalen UI-State. + +**Empfehlung:** Vereinfachen, Duplikate entfernen, Cloud-Sync für UI-State überdenken. + +--- + +### 6. ViewCarousel Gesture-Handling (~400 LOC) — Offen + +**Problem:** Touch + Wheel + Keyboard + Button-Navigation mit Velocity-Berechnung und RAF-Animation, eng gekoppelt. + +**Empfehlung:** Gesture-Handling als wiederverwendbares Composable extrahieren. + +--- + +## Teil 2: Unterentwickelte Bereiche + +### 1. Keine Kalender-Synchronisation (CalDAV/iCal) — Priorität: Hoch + +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. + +### 2. Keine wiederkehrenden Termine (Recurring Events) — Priorität: Hoch + +Backend-Schema unterstützt RFC 5545 RRULE. Kein UI oder Store-Logik dafür. +Essentiell für eine nutzbare Kalender-App. + +### 3. Erinnerungen / Notifications nur rudimentär — Priorität: Mittel + +API-Client `reminders.ts` existiert, aber nur Basic CRUD. Keine Push-Notifications, keine E-Mail-Erinnerungen, kein UI zur Konfiguration pro Event. + +### 4. Kalender-Sharing kaum implementiert — Priorität: Mittel + +`shares.ts` API-Client existiert als Stub. Kein UI zum Teilen oder für Berechtigungsverwaltung. + +### 5. Fehlertoleranz bei Cross-App-Integration — Priorität: Mittel + +Calendar hängt von Contacts (Birthdays), Todo, und STT ab. Kein Error Boundary oder Offline-Fallback. + +### 6. Suche sehr basic — Priorität: Niedrig + +Nur Query + Event-ID-Matching für Highlighting. Keine Volltextsuche, keine Filter. + +### 7. Mobile Experience — Priorität: Niedrig + +Web-App ist responsive, aber nicht touch-optimiert. Keine dedizierte Mobile-App (Expo leer).