refactor(calendar): remove tag groups hierarchy and legacy drag-drop composables

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 17:33:57 +01:00
parent e7fb2074b4
commit d3a3bc7b77
14 changed files with 214 additions and 2282 deletions

View file

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

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { settingsStore } from '$lib/stores/settings.svelte';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { DotsThree, Plus, X } from '@manacore/shared-icons';
@ -10,7 +9,6 @@
let showModal = $state(false);
function handleTagClick(tagId: string) {
// Toggle tag selection for filtering calendar view
settingsStore.toggleTagSelection(tagId);
}
@ -28,31 +26,16 @@
showModal = false;
}
// Sort tags by group, then by name
const sortedTags = $derived.by(() => {
const tags = [...eventTagsStore.tags];
const groupOrder = new Map(eventTagGroupsStore.groups.map((g, i) => [g.id, i]));
return tags.sort((a, b) => {
// Ungrouped tags go last
const aOrder = a.groupId ? (groupOrder.get(a.groupId) ?? 999) : 1000;
const bOrder = b.groupId ? (groupOrder.get(b.groupId) ?? 999) : 1000;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.name.localeCompare(b.name, 'de');
});
return [...eventTagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de'));
});
const hasTags = $derived(eventTagsStore.tags.length > 0);
onMount(async () => {
// Fetch tags and groups if not already loaded
if (eventTagsStore.tags.length === 0) {
await eventTagsStore.fetchTags();
}
if (eventTagGroupsStore.groups.length === 0) {
await eventTagGroupsStore.fetchGroups();
}
});
</script>

View file

@ -1,20 +1,8 @@
<script lang="ts">
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
import {
CaretDown,
CaretRight,
Plus,
X,
Check,
FolderSimplePlus,
Pencil,
Trash,
MagnifyingGlass,
DotsSixVertical,
} from '@manacore/shared-icons';
import { Plus, X, Check, Pencil, Trash, MagnifyingGlass } from '@manacore/shared-icons';
import { TagColorPicker } from '@manacore/shared-ui';
import type { EventTag, EventTagGroup } from '@calendar/shared';
import type { EventTag } from '@calendar/shared';
interface Props {
visible: boolean;
@ -26,84 +14,37 @@
// Search state
let searchQuery = $state('');
// Track collapsed state
let collapsedGroups = $state<Set<string | null>>(new Set());
// New group form state
let showNewGroupForm = $state(false);
let newGroupName = $state('');
let newGroupColor = $state('#3b82f6');
let isCreatingGroup = $state(false);
// New tag form state
let showNewTagForm = $state(false);
let newTagName = $state('');
let newTagColor = $state('#3b82f6');
let newTagGroupId = $state<string | null>(null);
let isCreatingTag = $state(false);
// Edit tag state
let editingTag = $state<EventTag | null>(null);
let editTagName = $state('');
let editTagColor = $state('#3b82f6');
let editTagGroupId = $state<string | null>(null);
let isSavingTag = $state(false);
// Edit group state
let editingGroup = $state<EventTagGroup | null>(null);
let editGroupName = $state('');
let editGroupColor = $state('#3b82f6');
let isSavingGroup = $state(false);
// Drag and drop state for tags
let draggedTag = $state<EventTag | null>(null);
let dragOverGroupId = $state<string | null | 'none'>(null);
// Drag and drop state for groups
let draggedGroup = $state<EventTagGroup | null>(null);
let dragOverGroupIndex = $state<number | null>(null);
// Filtered tags based on search
const filteredTags = $derived.by(() => {
if (!searchQuery.trim()) return eventTagsStore.tags;
// Filtered and sorted tags
const sortedTags = $derived.by(() => {
const tags = [...eventTagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de'));
if (!searchQuery.trim()) return tags;
const query = searchQuery.toLowerCase();
return eventTagsStore.tags.filter((t) => t.name.toLowerCase().includes(query));
return tags.filter((t) => t.name.toLowerCase().includes(query));
});
function toggleGroup(groupId: string | null) {
const newSet = new Set(collapsedGroups);
if (newSet.has(groupId)) {
newSet.delete(groupId);
} else {
newSet.add(groupId);
}
collapsedGroups = newSet;
}
function isExpanded(groupId: string | null): boolean {
return !collapsedGroups.has(groupId);
}
function getTagsForGroup(groupId: string | null): EventTag[] {
return filteredTags.filter((t) => (t.groupId ?? null) === groupId);
}
// Get ungrouped tags
const ungroupedTags = $derived(getTagsForGroup(null));
// ==================== NEW TAG ====================
function openNewTagForm() {
showNewTagForm = true;
newTagName = '';
newTagColor = '#3b82f6';
newTagGroupId = null;
}
function closeNewTagForm() {
showNewTagForm = false;
newTagName = '';
newTagColor = '#3b82f6';
newTagGroupId = null;
}
async function handleCreateTag() {
@ -113,14 +54,12 @@
const result = await eventTagsStore.createTag({
name: newTagName.trim(),
color: newTagColor,
groupId: newTagGroupId,
});
if (result.error) {
console.error('Failed to create tag:', result.error);
} else {
closeNewTagForm();
eventTagGroupsStore.fetchGroups();
}
isCreatingTag = false;
@ -141,14 +80,12 @@
editingTag = tag;
editTagName = tag.name;
editTagColor = tag.color;
editTagGroupId = tag.groupId ?? null;
}
function closeEditTag() {
editingTag = null;
editTagName = '';
editTagColor = '#3b82f6';
editTagGroupId = null;
}
async function handleSaveTag() {
@ -158,14 +95,12 @@
const result = await eventTagsStore.updateTag(editingTag.id, {
name: editTagName.trim(),
color: editTagColor,
groupId: editTagGroupId,
});
if (result.error) {
console.error('Failed to update tag:', result.error);
} else {
closeEditTag();
eventTagGroupsStore.fetchGroups();
}
isSavingTag = false;
@ -180,7 +115,6 @@
console.error('Failed to delete tag:', result.error);
} else {
closeEditTag();
eventTagGroupsStore.fetchGroups();
}
}
@ -194,176 +128,20 @@
}
}
// ==================== NEW GROUP ====================
function toggleNewGroupForm() {
showNewGroupForm = !showNewGroupForm;
if (!showNewGroupForm) {
resetNewGroupForm();
}
}
function resetNewGroupForm() {
newGroupName = '';
newGroupColor = '#3b82f6';
}
async function handleCreateGroup() {
if (!newGroupName.trim() || isCreatingGroup) return;
isCreatingGroup = true;
const result = await eventTagGroupsStore.createGroup({
name: newGroupName.trim(),
color: newGroupColor,
});
if (result.error) {
console.error('Failed to create group:', result.error);
} else {
resetNewGroupForm();
showNewGroupForm = false;
}
isCreatingGroup = false;
}
function handleNewGroupKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && newGroupName.trim()) {
e.preventDefault();
handleCreateGroup();
} else if (e.key === 'Escape') {
e.stopPropagation();
showNewGroupForm = false;
resetNewGroupForm();
}
}
// ==================== EDIT GROUP ====================
function openEditGroup(e: MouseEvent, group: EventTagGroup) {
e.stopPropagation();
editingGroup = group;
editGroupName = group.name;
editGroupColor = group.color;
}
function closeEditGroup() {
editingGroup = null;
editGroupName = '';
editGroupColor = '#3b82f6';
}
async function handleSaveGroup() {
if (!editingGroup || !editGroupName.trim() || isSavingGroup) return;
isSavingGroup = true;
const result = await eventTagGroupsStore.updateGroup(editingGroup.id, {
name: editGroupName.trim(),
color: editGroupColor,
});
if (result.error) {
console.error('Failed to update group:', result.error);
} else {
closeEditGroup();
}
isSavingGroup = false;
}
async function handleDeleteGroup() {
if (!editingGroup) return;
const result = await eventTagGroupsStore.deleteGroup(editingGroup.id);
if (result.error) {
console.error('Failed to delete group:', result.error);
} else {
closeEditGroup();
}
}
function handleEditGroupKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && editGroupName.trim()) {
e.preventDefault();
handleSaveGroup();
} else if (e.key === 'Escape') {
e.stopPropagation();
closeEditGroup();
}
}
// ==================== DRAG AND DROP ====================
function handleDragStart(e: DragEvent, tag: EventTag) {
draggedTag = tag;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', tag.id);
}
}
function handleDragEnd() {
draggedTag = null;
dragOverGroupId = null;
}
function handleDragOver(e: DragEvent, groupId: string | null) {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
}
dragOverGroupId = groupId ?? 'none';
}
function handleDragLeave() {
dragOverGroupId = null;
}
async function handleDrop(e: DragEvent, targetGroupId: string | null) {
e.preventDefault();
dragOverGroupId = null;
if (!draggedTag) return;
const currentGroupId = draggedTag.groupId ?? null;
if (currentGroupId === targetGroupId) {
draggedTag = null;
return;
}
const result = await eventTagsStore.updateTag(draggedTag.id, {
groupId: targetGroupId,
});
if (result.error) {
console.error('Failed to move tag:', result.error);
} else {
eventTagGroupsStore.fetchGroups();
}
draggedTag = null;
}
// ==================== KEYBOARD ====================
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (editingTag) {
closeEditTag();
} else if (editingGroup) {
closeEditGroup();
} else if (showNewTagForm) {
closeNewTagForm();
} else if (showNewGroupForm) {
showNewGroupForm = false;
resetNewGroupForm();
} else {
onClose();
}
}
}
// Check if any form is open
const hasOpenForm = $derived(
showNewTagForm || showNewGroupForm || editingTag !== null || editingGroup !== null
);
const hasOpenForm = $derived(showNewTagForm || editingTag !== null);
</script>
<svelte:window onkeydown={handleKeydown} />
@ -423,15 +201,6 @@
autofocus
/>
</div>
<div class="form-row">
<label class="form-label">Gruppe</label>
<select bind:value={newTagGroupId} class="group-select">
<option value={null}>Keine Gruppe</option>
{#each eventTagGroupsStore.groups as group (group.id)}
<option value={group.id}>{group.name}</option>
{/each}
</select>
</div>
<div class="color-picker-row">
<TagColorPicker
selectedColor={newTagColor}
@ -473,15 +242,6 @@
autofocus
/>
</div>
<div class="form-row">
<label class="form-label">Gruppe</label>
<select bind:value={editTagGroupId} class="group-select">
<option value={null}>Keine Gruppe</option>
{#each eventTagGroupsStore.groups as group (group.id)}
<option value={group.id}>{group.name}</option>
{/each}
</select>
</div>
<div class="color-picker-row">
<TagColorPicker
selectedColor={editTagColor}
@ -505,234 +265,34 @@
</div>
{/if}
<!-- Edit Group Form -->
{#if editingGroup}
<div class="edit-form-section">
<div class="edit-form-header">
<span class="edit-form-title">Gruppe bearbeiten</span>
<button class="icon-btn" onclick={closeEditGroup} title="Abbrechen">
<X size={14} weight="bold" />
</button>
</div>
<div class="edit-form">
<div class="form-row">
<div class="color-preview" style="background-color: {editGroupColor}"></div>
<input
type="text"
bind:value={editGroupName}
onkeydown={handleEditGroupKeydown}
placeholder="Gruppenname"
class="name-input"
autofocus
/>
</div>
<div class="color-picker-row">
<TagColorPicker
selectedColor={editGroupColor}
onColorChange={(c) => (editGroupColor = c)}
/>
</div>
<div class="form-actions">
<button class="btn btn-danger" onclick={handleDeleteGroup} title="Gruppe löschen">
<Trash size={14} weight="bold" />
</button>
<button
class="btn btn-primary"
onclick={handleSaveGroup}
disabled={!editGroupName.trim() || isSavingGroup}
>
<Check size={14} weight="bold" />
Speichern
</button>
</div>
</div>
</div>
{/if}
<!-- Groups with their tags (show all groups) -->
{#if !hasOpenForm || (hasOpenForm && filteredTags.length > 0)}
{#each eventTagGroupsStore.groups as group (group.id)}
{@const groupTags = getTagsForGroup(group.id)}
{#if !searchQuery || groupTags.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- Tags list -->
{#if !hasOpenForm || sortedTags.length > 0}
<div class="tags-grid">
{#each sortedTags as tag (tag.id)}
<div
class="group-section"
class:drag-over={dragOverGroupId === group.id}
ondragover={(e) => 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'}"
>
<div class="group-header">
<button type="button" onclick={() => toggleGroup(group.id)} class="group-toggle">
<div class="group-header-left">
{#if isExpanded(group.id)}
<CaretDown size={14} weight="bold" />
{:else}
<CaretRight size={14} weight="bold" />
{/if}
<div class="group-dot" style="background-color: {group.color}"></div>
<span class="group-name">{group.name}</span>
<span class="group-count">({groupTags.length})</span>
</div>
</button>
<div class="group-actions">
<button
class="icon-btn icon-btn-sm"
onclick={(e) => openEditGroup(e, group)}
title="Gruppe bearbeiten"
>
<Pencil size={12} weight="bold" />
</button>
</div>
</div>
{#if isExpanded(group.id)}
<div class="tags-grid">
{#if groupTags.length === 0}
<div class="empty-group-hint">Tags hierher ziehen</div>
{:else}
{#each groupTags as tag (tag.id)}
<div
class="tag-pill glass-tag"
class:dragging={draggedTag?.id === tag.id}
draggable="true"
ondragstart={(e) => handleDragStart(e, tag)}
ondragend={handleDragEnd}
role="button"
tabindex="0"
style="--tag-color: {tag.color || '#3b82f6'}"
>
<span class="tag-dot"></span>
<span class="tag-name">{tag.name}</span>
<button
class="tag-edit-btn"
onclick={() => openEditTag(tag)}
title="Tag bearbeiten"
>
<Pencil size={10} weight="bold" />
</button>
</div>
{/each}
{/if}
</div>
{/if}
</div>
{/if}
{/each}
<!-- Ungrouped tags -->
{#if !searchQuery || ungroupedTags.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group-section"
class:drag-over={dragOverGroupId === 'none'}
ondragover={(e) => handleDragOver(e, null)}
ondragleave={handleDragLeave}
ondrop={(e) => handleDrop(e, null)}
>
<div class="group-header">
<button type="button" onclick={() => toggleGroup(null)} class="group-toggle">
<div class="group-header-left">
{#if isExpanded(null)}
<CaretDown size={14} weight="bold" />
{:else}
<CaretRight size={14} weight="bold" />
{/if}
<span class="group-name muted">Ohne Gruppe</span>
<span class="group-count">({ungroupedTags.length})</span>
</div>
<span class="tag-dot"></span>
<span class="tag-name">{tag.name}</span>
<button
class="tag-edit-btn"
onclick={() => openEditTag(tag)}
title="Tag bearbeiten"
>
<Pencil size={10} weight="bold" />
</button>
</div>
{/each}
</div>
{#if isExpanded(null)}
<div class="tags-grid">
{#if ungroupedTags.length === 0}
<div class="empty-group-hint">Tags hierher ziehen</div>
{:else}
{#each ungroupedTags as tag (tag.id)}
<div
class="tag-pill glass-tag"
class:dragging={draggedTag?.id === tag.id}
draggable="true"
ondragstart={(e) => handleDragStart(e, tag)}
ondragend={handleDragEnd}
role="button"
tabindex="0"
style="--tag-color: {tag.color || '#3b82f6'}"
>
<span class="tag-dot"></span>
<span class="tag-name">{tag.name}</span>
<button
class="tag-edit-btn"
onclick={() => openEditTag(tag)}
title="Tag bearbeiten"
>
<Pencil size={10} weight="bold" />
</button>
</div>
{/each}
{/if}
</div>
{/if}
</div>
{/if}
<!-- Search empty state -->
{#if searchQuery && filteredTags.length === 0}
{#if searchQuery && sortedTags.length === 0}
<div class="search-empty">
<p>Keine Tags gefunden für "{searchQuery}"</p>
</div>
{/if}
<!-- New Group Section -->
<div class="group-section new-group-section">
<button
type="button"
onclick={toggleNewGroupForm}
class="group-toggle new-group-header"
>
<div class="group-header-left">
{#if showNewGroupForm}
<CaretDown size={14} weight="bold" />
{:else}
<Plus size={14} weight="bold" />
{/if}
<FolderSimplePlus size={14} />
<span class="group-name muted">Neue Gruppe</span>
</div>
</button>
{#if showNewGroupForm}
<div class="new-group-form">
<div class="form-row">
<div class="color-preview" style="background-color: {newGroupColor}"></div>
<input
type="text"
bind:value={newGroupName}
onkeydown={handleNewGroupKeydown}
placeholder="Gruppenname"
class="name-input"
autofocus
/>
<button
type="button"
onclick={handleCreateGroup}
disabled={!newGroupName.trim() || isCreatingGroup}
class="save-btn"
title="Gruppe erstellen"
>
<Check size={16} weight="bold" />
</button>
</div>
<div class="color-picker-row">
<TagColorPicker
selectedColor={newGroupColor}
onColorChange={(c) => (newGroupColor = c)}
/>
</div>
</div>
{/if}
</div>
{/if}
{/if}
</div>
@ -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;
}
</style>

View file

@ -1,245 +0,0 @@
<script lang="ts">
import { TagBadge, type Tag } from '@manacore/shared-ui';
import { CaretDown, CaretRight, Pencil, FolderSimple } from '@manacore/shared-icons';
import type { EventTag, EventTagGroup } from '@calendar/shared';
interface Props {
groups: EventTagGroup[];
tags: EventTag[];
ungroupedLabel?: string;
onEditTag: (tag: EventTag) => void;
onEditGroup?: (group: EventTagGroup) => void;
loading?: boolean;
emptyMessage?: string;
}
let {
groups,
tags,
ungroupedLabel = 'Ohne Gruppe',
onEditTag,
onEditGroup,
loading = false,
emptyMessage = 'Keine Tags vorhanden',
}: Props = $props();
// Track collapsed state (inverted - we track what's collapsed, not expanded)
// This way new groups are automatically expanded
let collapsedGroups = $state<Set<string | null>>(new Set());
function toggleGroup(groupId: string | null) {
const newSet = new Set(collapsedGroups);
if (newSet.has(groupId)) {
newSet.delete(groupId);
} else {
newSet.add(groupId);
}
collapsedGroups = newSet;
}
function isExpanded(groupId: string | null): boolean {
return !collapsedGroups.has(groupId);
}
// Get tags for a specific group
function getTagsForGroup(groupId: string | null): EventTag[] {
return tags.filter((t) => (t.groupId ?? null) === groupId);
}
// Convert EventTag to Tag for TagBadge
function toTag(eventTag: EventTag): Tag {
return {
id: eventTag.id,
name: eventTag.name,
color: eventTag.color,
};
}
// Get ungrouped tags
const ungroupedTags = $derived(getTagsForGroup(null));
const hasUngroupedTags = $derived(ungroupedTags.length > 0);
const totalTags = $derived(tags.length);
</script>
{#if loading}
<div class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else if totalTags === 0}
<div class="text-center py-12">
<div class="text-muted-foreground mb-2">{emptyMessage}</div>
</div>
{:else}
<div class="space-y-2">
<!-- Groups with their tags -->
{#each groups as group (group.id)}
{@const groupTags = getTagsForGroup(group.id)}
{#if groupTags.length > 0}
<div class="group-section">
<!-- Group Header -->
<button type="button" onclick={() => toggleGroup(group.id)} class="group-header">
<div class="flex items-center gap-2">
{#if isExpanded(group.id)}
<CaretDown size={16} weight="bold" class="text-muted-foreground" />
{:else}
<CaretRight size={16} weight="bold" class="text-muted-foreground" />
{/if}
<div
class="w-3 h-3 rounded-full flex-shrink-0"
style="background-color: {group.color}"
></div>
<span class="font-medium">{group.name}</span>
<span class="text-xs text-muted-foreground">({groupTags.length})</span>
</div>
{#if onEditGroup}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onEditGroup(group);
}}
class="edit-group-btn"
aria-label="Gruppe bearbeiten"
>
<Pencil size={14} />
</button>
{/if}
</button>
<!-- Tags in this group -->
{#if isExpanded(group.id)}
<div class="tags-container">
{#each groupTags as tag (tag.id)}
<button type="button" class="tag-pill" onclick={() => onEditTag(tag)}>
<TagBadge tag={toTag(tag)} />
<span class="edit-icon">
<Pencil size={12} />
</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
{/each}
<!-- Ungrouped tags -->
{#if hasUngroupedTags}
<div class="group-section">
<!-- Ungrouped Header -->
<button type="button" onclick={() => toggleGroup(null)} class="group-header">
<div class="flex items-center gap-2">
{#if isExpanded(null)}
<CaretDown size={16} weight="bold" class="text-muted-foreground" />
{:else}
<CaretRight size={16} weight="bold" class="text-muted-foreground" />
{/if}
<FolderSimple size={14} class="text-muted-foreground" />
<span class="font-medium text-muted-foreground">{ungroupedLabel}</span>
<span class="text-xs text-muted-foreground">({ungroupedTags.length})</span>
</div>
</button>
<!-- Ungrouped Tags -->
{#if isExpanded(null)}
<div class="tags-container">
{#each ungroupedTags as tag (tag.id)}
<button type="button" class="tag-pill" onclick={() => onEditTag(tag)}>
<TagBadge tag={toTag(tag)} />
<span class="edit-icon">
<Pencil size={12} />
</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<style>
.group-section {
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
overflow: hidden;
background: hsl(var(--card));
}
.group-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
background: hsl(var(--muted) / 0.3);
border: none;
cursor: pointer;
transition: background 0.2s ease;
text-align: left;
}
.group-header:hover {
background: hsl(var(--muted) / 0.5);
}
.edit-group-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
border-radius: 0.375rem;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
}
.group-header:hover .edit-group-btn {
opacity: 1;
}
.edit-group-btn:hover {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.tags-container {
padding: 0.75rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0;
padding-right: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
border-radius: 9999px;
transition: all 0.15s ease;
}
.tag-pill:hover {
background: hsl(var(--muted) / 0.5);
}
.edit-icon {
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--muted-foreground));
opacity: 0;
transition: opacity 0.15s ease;
}
.tag-pill:hover .edit-icon {
opacity: 1;
}
</style>

View file

@ -1,123 +0,0 @@
<script lang="ts">
import { Modal, Input, TagColorPicker, TagBadge } from '@manacore/shared-ui';
import type { EventTagGroup } from '@calendar/shared';
interface Props {
group?: EventTagGroup | null;
isOpen: boolean;
onClose: () => void;
onSave: (name: string, color: string) => void;
onDelete?: () => void;
}
let { group = null, isOpen, onClose, onSave, onDelete }: Props = $props();
const DEFAULT_COLOR = '#3B82F6';
let name = $state(group?.name ?? '');
let color = $state(group?.color ?? DEFAULT_COLOR);
// Reset form when group changes
$effect(() => {
if (isOpen) {
name = group?.name ?? '';
color = group?.color ?? DEFAULT_COLOR;
}
});
function handleSave() {
if (name.trim()) {
onSave(name.trim(), color);
}
}
function handleDelete() {
if (
onDelete &&
confirm(
`Gruppe "${group?.name}" wirklich löschen? Tags in dieser Gruppe werden nicht gelöscht.`
)
) {
onDelete();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && name.trim()) {
e.preventDefault();
handleSave();
}
}
const previewTag = $derived({ name: name || 'Gruppenname', color });
const isEditing = $derived(!!group);
</script>
<Modal
visible={isOpen}
{onClose}
title={isEditing ? 'Gruppe bearbeiten' : 'Neue Gruppe'}
maxWidth="sm"
>
<div class="space-y-6">
<!-- Name Input -->
<div>
<Input bind:value={name} placeholder="Gruppenname" onkeydown={handleKeyDown} />
</div>
<!-- Color Picker -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Farbe </span>
<TagColorPicker selectedColor={color} onColorChange={(c) => (color = c)} />
</div>
<!-- Preview -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Vorschau </span>
<div class="flex items-center gap-2">
<TagBadge tag={previewTag} />
</div>
</div>
<!-- Tag Count Info (only when editing) -->
{#if isEditing && group?.tagCount !== undefined && group.tagCount > 0}
<div class="text-sm text-muted-foreground">
{group.tagCount}
{group.tagCount === 1 ? 'Tag' : 'Tags'} in dieser Gruppe
</div>
{/if}
</div>
{#snippet footer()}
<div class="flex items-center justify-between">
<div>
{#if onDelete && isEditing}
<button
type="button"
onclick={handleDelete}
class="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
Löschen
</button>
{/if}
</div>
<div class="flex items-center gap-3">
<button
type="button"
onclick={onClose}
class="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="button"
onclick={handleSave}
disabled={!name.trim()}
class="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{isEditing ? 'Speichern' : 'Erstellen'}
</button>
</div>
</div>
{/snippet}
</Modal>

View file

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

View file

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

View file

@ -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<CalendarEvent | null>(null);
let dragOffsetMinutes = $state(0);
let dragTargetDay = $state<Date | null>(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,
};
}

View file

@ -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<CalendarEvent | null>(null);
let resizeEdge = $state<'top' | 'bottom'>('bottom');
let resizeOriginalStart = $state<Date | null>(null);
let resizeOriginalEnd = $state<Date | null>(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,
};
}

View file

@ -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<EventTagGroup[]>([]);
let ungroupedTagCount = $state(0);
let loading = $state(false);
let error = $state<string | null>(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;
},
};

View file

@ -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<string | null, EventTag[]> {
const grouped = new Map<string | null, EventTag[]>();
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);
},
};

View file

@ -1,50 +1,37 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Modal, Input, TagColorPicker, TagBadge } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft, FolderSimple } from '@manacore/shared-icons';
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
import { GroupedTagList } from '$lib/components/tags';
import type { EventTag } from '@calendar/shared';
let searchQuery = $state('');
let showTagModal = $state(false);
let editingTag = $state<EventTag | null>(null);
let selectedGroupId = $state<string | null>(null);
// Filtered tags based on search
// Filtered tags based on search, sorted alphabetically
const filteredTags = $derived.by(() => {
if (!searchQuery.trim()) return eventTagsStore.tags;
const sorted = [...eventTagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de'));
if (!searchQuery.trim()) return sorted;
const query = searchQuery.toLowerCase();
return eventTagsStore.tags.filter((t) => t.name.toLowerCase().includes(query));
return sorted.filter((t) => t.name.toLowerCase().includes(query));
});
// Filter groups that have matching tags (for search)
const filteredGroups = $derived.by(() => {
if (!searchQuery.trim()) return eventTagGroupsStore.groups;
// Only show groups that have at least one matching tag
const tagGroupIds = new Set(filteredTags.map((t) => t.groupId).filter(Boolean));
return eventTagGroupsStore.groups.filter((g) => tagGroupIds.has(g.id));
});
// Form state for custom tag edit modal with group selection
// Form state
let tagName = $state('');
let tagColor = $state('#3B82F6');
const DEFAULT_COLOR = '#3B82F6';
// Reset form when editing tag changes
$effect(() => {
if (showTagModal) {
tagName = editingTag?.name ?? '';
tagColor = editingTag?.color ?? DEFAULT_COLOR;
selectedGroupId = editingTag?.groupId ?? null;
}
});
function openCreateTagModal() {
editingTag = null;
selectedGroupId = null;
showTagModal = true;
}
@ -66,18 +53,14 @@
await eventTagsStore.updateTag(editingTag.id, {
name: tagName.trim(),
color: tagColor,
groupId: selectedGroupId,
});
} else {
await eventTagsStore.createTag({
name: tagName.trim(),
color: tagColor,
groupId: selectedGroupId ?? undefined,
});
}
closeTagModal();
// Refresh groups to update tag counts
eventTagGroupsStore.fetchGroups();
} catch (e) {
console.error('Failed to save tag:', e);
}
@ -91,8 +74,6 @@
try {
await eventTagsStore.deleteTag(editingTag.id);
closeTagModal();
// Refresh groups to update tag counts
eventTagGroupsStore.fetchGroups();
} catch (e) {
console.error('Failed to delete tag:', e);
}
@ -106,16 +87,11 @@
}
const previewTag = $derived({ name: tagName || 'Tag Name', color: tagColor });
const isLoading = $derived(eventTagsStore.loading || eventTagGroupsStore.loading);
onMount(async () => {
// Fetch both tags and groups in parallel
await Promise.all([
eventTagsStore.tags.length === 0 ? eventTagsStore.fetchTags() : Promise.resolve(),
eventTagGroupsStore.groups.length === 0
? eventTagGroupsStore.fetchGroups()
: Promise.resolve(),
]);
if (eventTagsStore.tags.length === 0) {
await eventTagsStore.fetchTags();
}
});
</script>
@ -130,9 +106,6 @@
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Tags</h1>
<a href="/tags/groups" class="groups-button" aria-label="Gruppen verwalten">
<FolderSimple size={20} weight="bold" />
</a>
<button onclick={openCreateTagModal} class="add-button" aria-label="Neues Tag">
<Plus size={20} weight="bold" />
</button>
@ -149,29 +122,42 @@
/>
</div>
{#if eventTagsStore.error || eventTagGroupsStore.error}
{#if eventTagsStore.error}
<div class="error-banner" role="alert">
<span>{eventTagsStore.error || eventTagGroupsStore.error}</span>
<span>{eventTagsStore.error}</span>
</div>
{/if}
<!-- Grouped Tag List -->
<GroupedTagList
groups={filteredGroups}
tags={filteredTags}
loading={isLoading}
onEditTag={openEditTagModal}
emptyMessage={searchQuery ? 'Keine Tags gefunden' : 'Keine Tags vorhanden'}
/>
<!-- Tag List -->
{#if eventTagsStore.loading}
<div class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else if filteredTags.length === 0}
<div class="text-center py-12">
<div class="text-muted-foreground mb-2">
{searchQuery ? 'Keine Tags gefunden' : 'Keine Tags vorhanden'}
</div>
</div>
{:else}
<div class="tags-list">
{#each filteredTags as tag (tag.id)}
<button type="button" class="tag-item" onclick={() => openEditTagModal(tag)}>
<div class="tag-color" style="background-color: {tag.color}"></div>
<span class="tag-label">{tag.name}</span>
</button>
{/each}
</div>
{/if}
{#if !isLoading && eventTagsStore.tags.length > 0}
{#if !eventTagsStore.loading && eventTagsStore.tags.length > 0}
<p class="tags-count">
{eventTagsStore.tags.length}
{eventTagsStore.tags.length === 1 ? 'Tag' : 'Tags'}
</p>
{/if}
{#if !isLoading && eventTagsStore.tags.length === 0 && !searchQuery}
{#if !eventTagsStore.loading && eventTagsStore.tags.length === 0 && !searchQuery}
<div class="empty-cta">
<button onclick={openCreateTagModal} class="btn btn-primary">
<Plus size={16} weight="bold" />
@ -181,7 +167,7 @@
{/if}
</div>
<!-- Custom Tag Edit Modal with Group Selection -->
<!-- Tag Edit Modal -->
<Modal
visible={showTagModal}
onClose={closeTagModal}
@ -189,31 +175,15 @@
maxWidth="sm"
>
<div class="space-y-6">
<!-- Name Input -->
<div>
<Input bind:value={tagName} placeholder="Tag Name" onkeydown={handleKeyDown} />
</div>
<!-- Group Selection -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Gruppe </span>
<select bind:value={selectedGroupId} class="group-select">
<option value={null}>Keine Gruppe</option>
{#each eventTagGroupsStore.groups as group (group.id)}
<option value={group.id}>
{group.name}
</option>
{/each}
</select>
</div>
<!-- Color Picker -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Farbe </span>
<TagColorPicker selectedColor={tagColor} onColorChange={(c) => (tagColor = c)} />
</div>
<!-- Preview -->
<div>
<span class="block text-sm font-medium text-muted-foreground mb-3"> Vorschau </span>
<div class="flex items-center gap-2">
@ -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;

View file

@ -1,375 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { CaretLeft, Plus, Pencil, Trash } from '@manacore/shared-icons';
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
import { TagGroupEditModal } from '$lib/components/tags';
import type { EventTagGroup } from '@calendar/shared';
let showModal = $state(false);
let editingGroup = $state<EventTagGroup | null>(null);
function openCreateModal() {
editingGroup = null;
showModal = true;
}
function openEditModal(group: EventTagGroup) {
editingGroup = group;
showModal = true;
}
function closeModal() {
showModal = false;
editingGroup = null;
}
async function handleSave(name: string, color: string) {
try {
if (editingGroup) {
await eventTagGroupsStore.updateGroup(editingGroup.id, { name, color });
} else {
await eventTagGroupsStore.createGroup({ name, color });
}
closeModal();
} catch (e) {
console.error('Failed to save group:', e);
}
}
async function handleDelete() {
if (!editingGroup) return;
try {
await eventTagGroupsStore.deleteGroup(editingGroup.id);
closeModal();
} catch (e) {
console.error('Failed to delete group:', e);
}
}
async function handleDeleteFromList(group: EventTagGroup) {
if (
!confirm(
`Gruppe "${group.name}" wirklich löschen? Tags in dieser Gruppe werden nicht gelöscht.`
)
)
return;
try {
await eventTagGroupsStore.deleteGroup(group.id);
} catch (e) {
console.error('Failed to delete group:', e);
}
}
onMount(() => {
if (eventTagGroupsStore.groups.length === 0) {
eventTagGroupsStore.fetchGroups();
}
});
</script>
<svelte:head>
<title>Tag-Gruppen - Kalender</title>
</svelte:head>
<div class="page-container">
<!-- Header -->
<header class="header">
<a href="/tags" class="back-button" aria-label="Zurück zu Tags">
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Tag-Gruppen</h1>
<button onclick={openCreateModal} class="add-button" aria-label="Neue Gruppe">
<Plus size={20} weight="bold" />
</button>
</header>
{#if eventTagGroupsStore.error}
<div class="error-banner" role="alert">
<span>{eventTagGroupsStore.error}</span>
</div>
{/if}
{#if eventTagGroupsStore.loading}
<div class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
{:else if eventTagGroupsStore.groups.length === 0}
<div class="empty-state">
<p class="text-muted-foreground mb-4">Noch keine Gruppen vorhanden</p>
<button onclick={openCreateModal} class="btn btn-primary">
<Plus size={16} weight="bold" />
Neue Gruppe erstellen
</button>
</div>
{:else}
<div class="groups-list">
{#each eventTagGroupsStore.groups as group (group.id)}
<div class="group-item">
<div class="group-info">
<div class="group-color" style="background-color: {group.color}"></div>
<div class="group-details">
<span class="group-name">{group.name}</span>
<span class="group-tag-count">
{group.tagCount ?? 0}
{(group.tagCount ?? 0) === 1 ? 'Tag' : 'Tags'}
</span>
</div>
</div>
<div class="group-actions">
<button
type="button"
onclick={() => openEditModal(group)}
class="action-btn"
aria-label="Gruppe bearbeiten"
>
<Pencil size={16} />
</button>
<button
type="button"
onclick={() => handleDeleteFromList(group)}
class="action-btn action-btn-delete"
aria-label="Gruppe löschen"
>
<Trash size={16} />
</button>
</div>
</div>
{/each}
</div>
<p class="groups-count">
{eventTagGroupsStore.groups.length}
{eventTagGroupsStore.groups.length === 1 ? 'Gruppe' : 'Gruppen'}
</p>
{/if}
<!-- Info about ungrouped tags -->
{#if eventTagGroupsStore.ungroupedTagCount > 0}
<div class="ungrouped-info">
<span>
{eventTagGroupsStore.ungroupedTagCount}
{eventTagGroupsStore.ungroupedTagCount === 1 ? 'Tag' : 'Tags'} ohne Gruppe
</span>
</div>
{/if}
</div>
<!-- Group Edit Modal -->
<TagGroupEditModal
group={editingGroup}
isOpen={showModal}
onClose={closeModal}
onSave={handleSave}
onDelete={editingGroup ? handleDelete : undefined}
/>
<style>
.page-container {
max-width: 640px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
/* Header */
.header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0;
margin-bottom: 1rem;
}
.back-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;
}
.back-button:hover {
background: hsl(var(--muted-foreground) / 0.2);
transform: translateX(-2px);
}
.title {
flex: 1;
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
}
.add-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.add-button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
/* Error */
.error-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: hsl(0 84% 60% / 0.1);
border: 1px solid hsl(0 84% 60% / 0.3);
border-radius: 0.75rem;
color: hsl(0 84% 60%);
margin-bottom: 1.5rem;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
/* Groups List */
.groups-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.group-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
transition: all 0.2s ease;
}
.group-item:hover {
background: hsl(var(--muted) / 0.3);
}
.group-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.group-color {
width: 1rem;
height: 1rem;
border-radius: 50%;
flex-shrink: 0;
}
.group-details {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.group-name {
font-weight: 500;
color: hsl(var(--foreground));
}
.group-tag-count {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.group-actions {
display: flex;
align-items: center;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s ease;
}
.group-item:hover .group-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border-radius: 0.5rem;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.action-btn:hover {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.1);
}
.action-btn-delete:hover {
color: hsl(0 84% 60%);
background: hsl(0 84% 60% / 0.1);
}
/* Count */
.groups-count {
text-align: center;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin-top: 1.5rem;
}
/* Ungrouped Info */
.ungrouped-info {
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
margin-top: 0.75rem;
padding: 0.5rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.5rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 0.625rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
text-decoration: none;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover {
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
</style>

View file

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