From 3b5f77dd8633286145c385400f9ee1e1a28731f8 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 11:05:49 +0200 Subject: [PATCH] feat(manacore/web): port calendar UI components from standalone app Extract the monolithic calendar page into proper component architecture ported from the original calendar app. Adds WeekView, MonthView, AgendaView, EventCard, EventDetailModal, EventForm, CalendarHeader, and MiniCalendar as separate components. Includes composables for drag-to-create, event drag & drop, resize, current time indicator, and keyboard shortcuts. Adds desktop sidebar with mini calendar and calendar list. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../calendar/components/AgendaView.svelte | 302 +++++++ .../calendar/components/CalendarHeader.svelte | 177 +++++ .../calendar/components/EventCard.svelte | 246 ++++++ .../components/EventDetailModal.svelte | 347 ++++++++ .../calendar/components/EventForm.svelte | 317 ++++++++ .../calendar/components/MiniCalendar.svelte | 164 ++++ .../calendar/components/MonthView.svelte | 418 ++++++++++ .../calendar/components/WeekView.svelte | 515 ++++++++++++ .../lib/modules/calendar/components/index.ts | 8 + .../lib/modules/calendar/composables/index.ts | 4 + .../composables/useCalendarKeyboard.svelte.ts | 27 + .../composables/useDragToCreate.svelte.ts | 169 ++++ .../composables/useEventDragDrop.svelte.ts | 353 +++++++++ .../composables/useVisibleHours.svelte.ts | 59 ++ .../lib/modules/calendar/utils/constants.ts | 9 + .../modules/calendar/utils/drag-helpers.ts | 56 ++ .../calendar/utils/event-date-helpers.ts | 17 + .../src/routes/(app)/calendar/+page.svelte | 747 ++++++------------ 18 files changed, 3436 insertions(+), 499 deletions(-) create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/components/AgendaView.svelte create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/components/EventCard.svelte create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/components/EventForm.svelte create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/components/MiniCalendar.svelte create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/components/MonthView.svelte create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/components/WeekView.svelte create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/components/index.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/composables/index.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/composables/useCalendarKeyboard.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/composables/useDragToCreate.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/composables/useEventDragDrop.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/composables/useVisibleHours.svelte.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/utils/constants.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/utils/drag-helpers.ts create mode 100644 apps/manacore/apps/web/src/lib/modules/calendar/utils/event-date-helpers.ts diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/components/AgendaView.svelte b/apps/manacore/apps/web/src/lib/modules/calendar/components/AgendaView.svelte new file mode 100644 index 000000000..576dc8417 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/components/AgendaView.svelte @@ -0,0 +1,302 @@ + + +
+ {#if groupedEvents.length === 0} +
+ +

Keine Termine in diesem Zeitraum

+
+ {:else} +
+ {#each groupedEvents as group} +
+

+ {formatDateHeader(group.date)} +

+ +
+ {#each group.events as event} + +
+
+
+
+ {#if event.isAllDay} + Ganztägig + {:else} + {format(toDate(event.startTime), 'HH:mm')} - {format( + toDate(event.endTime), + 'HH:mm' + )} + {/if} +
+ + handleTitleKeydown(e, event)} + onblur={(e) => handleTitleBlur(event, e.target as HTMLSpanElement)} + onclick={(e) => e.stopPropagation()} + > + {event.title} + + {#if event.location} +
+ + {event.location} +
+ {/if} +
+ +
+ {/each} +
+
+ {/each} +
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte b/apps/manacore/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte new file mode 100644 index 000000000..c6204aefc --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/components/CalendarHeader.svelte @@ -0,0 +1,177 @@ + + +
+
+

{headerLabel}

+ +
+ +
+
+ {#each ['week', 'month', 'agenda'] as CalendarViewType[] as view} + + {/each} +
+ + +
+
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/components/EventCard.svelte b/apps/manacore/apps/web/src/lib/modules/calendar/components/EventCard.svelte new file mode 100644 index 000000000..3032ec0d7 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/components/EventCard.svelte @@ -0,0 +1,246 @@ + + +
+ {#if onResizeStart} +
+ {/if} + + {formattedTime} + {event.title || (isDraft ? 'Neuer Termin' : '')} + {#if event.location} + {event.location} + {/if} + + {#if onResizeStart} +
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte b/apps/manacore/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte new file mode 100644 index 000000000..6d5aeb161 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/components/EventDetailModal.svelte @@ -0,0 +1,347 @@ + + + + + + + + diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/components/EventForm.svelte b/apps/manacore/apps/web/src/lib/modules/calendar/components/EventForm.svelte new file mode 100644 index 000000000..6ef75d756 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/components/EventForm.svelte @@ -0,0 +1,317 @@ + + +
+
+ + +
+ + {#if mode === 'create' && calendarOptions.length > 1} +
+ + +
+ {/if} + +
+ +
+ +
+
+ + +
+ {#if !isAllDay} +
+ + +
+ {/if} +
+ +
+
+ + +
+ {#if !isAllDay} +
+ + +
+ {/if} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/components/MiniCalendar.svelte b/apps/manacore/apps/web/src/lib/modules/calendar/components/MiniCalendar.svelte new file mode 100644 index 000000000..36d32c765 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/components/MiniCalendar.svelte @@ -0,0 +1,164 @@ + + +
+
+ + {format(currentMonth, 'MMMM yyyy', { locale: de })} + +
+ +
+ {#each weekDays as day} + {day} + {/each} +
+ +
+ {#each calendarDays as day} + + {/each} +
+
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/components/MonthView.svelte b/apps/manacore/apps/web/src/lib/modules/calendar/components/MonthView.svelte new file mode 100644 index 000000000..5120dad60 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/components/MonthView.svelte @@ -0,0 +1,418 @@ + + +
+ +
+ {#each weekDays as day} +
{day}
+ {/each} +
+ + +
+ {#each weeks as week} +
+ {#each week as day} + {@const isDropTarget = isDragging && dragTargetDay && isSameDay(day, dragTargetDay)} +
handleDayClick(day, e)} + onkeydown={(e) => e.key === 'Enter' && handleDayClick(day, e as unknown as MouseEvent)} + role="gridcell" + tabindex="0" + aria-selected={isToday(day)} + > +
+ + {format(day, 'd')} + +
+ +
+ {#each getEventsForDayFiltered(day) as event} + {@const isBeingDragged = isDragging && draggedEvent?.id === event.id} + +
startDrag(event, e)} + onclick={(e) => handleEventClick(event, e)} + role="button" + tabindex="0" + aria-label={event.title} + > + {#if !event.isAllDay} + {format(toDate(event.startTime), 'HH:mm')} + {/if} + {event.title} +
+ {/each} + + {#if getAllEventsForDay(day).length > 3} + + {/if} +
+
+ {/each} +
+ {/each} +
+
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/components/WeekView.svelte b/apps/manacore/apps/web/src/lib/modules/calendar/components/WeekView.svelte new file mode 100644 index 000000000..8b349c5cc --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/components/WeekView.svelte @@ -0,0 +1,515 @@ + + +
+ + + + +
+ +
+ {#each hours as hour} +
+ {formatHour(hour)} +
+ {/each} +
+ + +
+ {#each days as day} + +
+ {#each hours as hour} +
+ {/each} + + + {#each getTimedEventsForDay(day) as event (event.id)} + {@const isBeingDragged = + eventDragDrop.isDragging && eventDragDrop.draggedEvent?.id === event.id} + {@const isBeingResized = + eventDragDrop.isResizing && eventDragDrop.resizeEvent?.id === event.id} + {@const isCrossDayDrag = + isBeingDragged && + eventDragDrop.dragTargetDay !== null && + !isSameDay(day, eventDragDrop.dragTargetDay)} + + {/each} + + + {#if eventDragDrop.isDragging && eventDragDrop.draggedEvent && eventDragDrop.dragTargetDay && isSameDay(day, eventDragDrop.dragTargetDay) && !getTimedEventsForDay(day).some((e) => e.id === eventDragDrop.draggedEvent!.id)} + + {/if} + + + {#if dragToCreate.isCreating && dragToCreate.createTargetDay && isSameDay(day, dragToCreate.createTargetDay)} +
+ {dragToCreate.getCreatePreviewTime()} + (Neuer Termin) +
+ {/if} + + + {#if isToday(day)} +
+ {format(timeIndicator.now, 'HH:mm')} +
+ {/if} +
+ {/each} +
+
+
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/components/index.ts b/apps/manacore/apps/web/src/lib/modules/calendar/components/index.ts new file mode 100644 index 000000000..bde89ce14 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/components/index.ts @@ -0,0 +1,8 @@ +export { default as WeekView } from './WeekView.svelte'; +export { default as MonthView } from './MonthView.svelte'; +export { default as AgendaView } from './AgendaView.svelte'; +export { default as EventCard } from './EventCard.svelte'; +export { default as EventDetailModal } from './EventDetailModal.svelte'; +export { default as EventForm } from './EventForm.svelte'; +export { default as CalendarHeader } from './CalendarHeader.svelte'; +export { default as MiniCalendar } from './MiniCalendar.svelte'; diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/composables/index.ts b/apps/manacore/apps/web/src/lib/modules/calendar/composables/index.ts new file mode 100644 index 000000000..8b0c7af84 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/composables/index.ts @@ -0,0 +1,4 @@ +export { useVisibleHours, useCurrentTimeIndicator } from './useVisibleHours.svelte'; +export { useEventDragDrop } from './useEventDragDrop.svelte'; +export { useDragToCreate } from './useDragToCreate.svelte'; +export { useCalendarKeyboard } from './useCalendarKeyboard.svelte'; diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/composables/useCalendarKeyboard.svelte.ts b/apps/manacore/apps/web/src/lib/modules/calendar/composables/useCalendarKeyboard.svelte.ts new file mode 100644 index 000000000..15abcc883 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/composables/useCalendarKeyboard.svelte.ts @@ -0,0 +1,27 @@ +/** + * Calendar Keyboard Handling Composable + */ + +export interface CancellableOperation { + isActive: () => boolean; + cancel: () => void; +} + +export function useCalendarKeyboard(operations: CancellableOperation[]) { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') { + const activeOperation = operations.find((op) => op.isActive()); + if (activeOperation) { + e.preventDefault(); + activeOperation.cancel(); + } + } + } + + function setup() { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + } + + return { setup, handleKeyDown }; +} diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/composables/useDragToCreate.svelte.ts b/apps/manacore/apps/web/src/lib/modules/calendar/composables/useDragToCreate.svelte.ts new file mode 100644 index 000000000..d3907b988 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/composables/useDragToCreate.svelte.ts @@ -0,0 +1,169 @@ +/** + * Drag-to-Create Composable + * Click-and-drag on the calendar grid to create new events + */ + +import { DEFAULT_EVENT_DURATION_MINUTES } from '../utils/constants'; +import { formatTime, getSnapMinutes, getDayFromX, getMinutesFromY } from '../utils/drag-helpers'; + +export interface DragToCreateConfig { + containerEl: HTMLElement | null; + days: Date[]; + firstVisibleHour: number; + lastVisibleHour: number; + totalVisibleHours: number; + hourHeight: number; + minutesToPercent: (minutes: number) => number; + snapMinutes?: number; + isOtherOperationActive: () => boolean; + onCreateEnd?: (startTime: Date, endTime: Date, position: { x: number; y: number }) => void; +} + +export function useDragToCreate(getConfig: () => DragToCreateConfig) { + let isCreating = $state(false); + let createTargetDay = $state(null); + let createStartMinutes = $state(0); + let createEndMinutes = $state(0); + let createPreviewTop = $state(0); + let createPreviewHeight = $state(0); + let hasMoved = $state(false); + + function dayFromX(clientX: number): Date | null { + const config = getConfig(); + return getDayFromX(clientX, config.containerEl, config.days); + } + + function minutesFromY(clientY: number): number { + const config = getConfig(); + return getMinutesFromY( + clientY, + config.containerEl, + config.totalVisibleHours, + config.hourHeight, + config.firstVisibleHour, + config.snapMinutes + ); + } + + function updatePreview() { + const config = getConfig(); + createPreviewTop = config.minutesToPercent(createStartMinutes); + const duration = createEndMinutes - createStartMinutes; + createPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100; + } + + function startCreate(e: PointerEvent) { + const config = getConfig(); + if (config.isOtherOperationActive()) return; + + const target = e.target as HTMLElement; + if ( + target.closest( + '.event-card, .all-day-event, .all-day-block-event, .overflow-indicator, .resize-handle' + ) + ) { + return; + } + + e.preventDefault(); + + const day = dayFromX(e.clientX); + if (!day) return; + + const minutes = minutesFromY(e.clientY); + const snap = getSnapMinutes(config.snapMinutes); + const snappedMinutes = Math.round(minutes / snap) * snap; + + isCreating = true; + hasMoved = false; + createTargetDay = day; + createStartMinutes = snappedMinutes; + createEndMinutes = snappedMinutes + DEFAULT_EVENT_DURATION_MINUTES; + + updatePreview(); + + document.addEventListener('pointermove', handleCreateMove); + document.addEventListener('pointerup', handleCreateEnd); + } + + function handleCreateMove(e: PointerEvent) { + if (!isCreating) return; + + hasMoved = true; + const config = getConfig(); + const snap = getSnapMinutes(config.snapMinutes); + + const day = dayFromX(e.clientX); + if (day) createTargetDay = day; + + const minutes = minutesFromY(e.clientY); + const snappedMinutes = Math.round(minutes / snap) * snap; + + if (snappedMinutes >= createStartMinutes) { + createEndMinutes = Math.max(snappedMinutes, createStartMinutes + snap); + } else { + createEndMinutes = createStartMinutes + snap; + createStartMinutes = snappedMinutes; + } + + createStartMinutes = Math.max(config.firstVisibleHour * 60, createStartMinutes); + createEndMinutes = Math.min(config.lastVisibleHour * 60, createEndMinutes); + + updatePreview(); + } + + function handleCreateEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleCreateMove); + document.removeEventListener('pointerup', handleCreateEnd); + + if (!isCreating || !createTargetDay) { + isCreating = false; + return; + } + + const startTime = new Date(createTargetDay); + startTime.setHours(Math.floor(createStartMinutes / 60), createStartMinutes % 60, 0, 0); + + const endTime = new Date(createTargetDay); + endTime.setHours(Math.floor(createEndMinutes / 60), createEndMinutes % 60, 0, 0); + + isCreating = false; + createTargetDay = null; + hasMoved = false; + + const config = getConfig(); + config.onCreateEnd?.(startTime, endTime, { x: e.clientX, y: e.clientY }); + } + + function getCreatePreviewTime(): string { + return `${formatTime(Math.floor(createStartMinutes / 60), createStartMinutes % 60)} - ${formatTime(Math.floor(createEndMinutes / 60), createEndMinutes % 60)}`; + } + + function cancel() { + if (isCreating) { + document.removeEventListener('pointermove', handleCreateMove); + document.removeEventListener('pointerup', handleCreateEnd); + isCreating = false; + createTargetDay = null; + hasMoved = false; + } + } + + return { + get isCreating() { + return isCreating; + }, + get createTargetDay() { + return createTargetDay; + }, + get createPreviewTop() { + return createPreviewTop; + }, + get createPreviewHeight() { + return createPreviewHeight; + }, + startCreate, + cancel, + getCreatePreviewTime, + }; +} diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/composables/useEventDragDrop.svelte.ts b/apps/manacore/apps/web/src/lib/modules/calendar/composables/useEventDragDrop.svelte.ts new file mode 100644 index 000000000..55ed62f80 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/composables/useEventDragDrop.svelte.ts @@ -0,0 +1,353 @@ +/** + * Event Drag & Drop + Resize Composable + */ + +import type { CalendarEvent } from '../types'; +import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns'; +import { toDate } from '../utils/event-date-helpers'; +import { eventsStore } from '../stores/events.svelte'; +import { formatTime, getDayFromX, getMinutesFromY } from '../utils/drag-helpers'; + +export interface EventDragDropConfig { + containerEl: HTMLElement | null; + days: Date[]; + firstVisibleHour: number; + lastVisibleHour: number; + totalVisibleHours: number; + hourHeight: number; + snapMinutes?: number; + minutesToPercent: (minutes: number) => number; +} + +export function useEventDragDrop(getConfig: () => EventDragDropConfig) { + 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 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 resizeOffsetMinutes = $state(0); + + let hasMoved = $state(false); + + function dayFromX(clientX: number): Date | null { + const config = getConfig(); + return getDayFromX(clientX, config.containerEl, config.days); + } + + function minutesFromY(clientY: number): number { + const config = getConfig(); + return getMinutesFromY( + clientY, + config.containerEl, + config.totalVisibleHours, + config.hourHeight, + config.firstVisibleHour, + config.snapMinutes + ); + } + + // ========== Drag ========== + + 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); + + const startMinutes = start.getHours() * 60 + start.getMinutes(); + dragPreviewTop = config.minutesToPercent(startMinutes); + dragPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100; + dragTargetDay = start; + + const clickMinutes = minutesFromY(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; + + const newDay = dayFromX(e.clientX); + const newMinutes = minutesFromY(e.clientY) - dragOffsetMinutes; + + const clampedMinutes = Math.max( + config.firstVisibleHour * 60, + Math.min(config.lastVisibleHour * 60 - 15, newMinutes) + ); + + dragPreviewTop = config.minutesToPercent(clampedMinutes); + if (newDay) { + dragTargetDay = newDay; + } + } + + async function handleDragEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleDragMove); + document.removeEventListener('pointerup', handleDragEnd); + + if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) { + cleanupDrag(); + return; + } + + const start = toDate(draggedEvent.startTime); + const end = toDate(draggedEvent.endTime); + const duration = differenceInMinutes(end, start); + + const newMinutes = minutesFromY(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); + + 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(), + }); + } + + cleanupDrag(); + } + + function cleanupDrag() { + isDragging = false; + draggedEvent = null; + dragTargetDay = null; + hasMoved = false; + } + + // ========== Resize ========== + + function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + + const config = getConfig(); + + isResizing = true; + resizeEvent = event; + resizeEdge = edge; + hasMoved = false; + + const start = toDate(event.startTime); + const end = toDate(event.endTime); + + resizeOriginalStart = start; + resizeOriginalEnd = end; + + const startMinutes = start.getHours() * 60 + start.getMinutes(); + const endMinutes = end.getHours() * 60 + end.getMinutes(); + const duration = differenceInMinutes(end, start); + resizePreviewTop = config.minutesToPercent(startMinutes); + resizePreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100; + + const clickMinutes = minutesFromY(e.clientY); + resizeOffsetMinutes = edge === 'top' ? clickMinutes - startMinutes : clickMinutes - endMinutes; + + 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 = minutesFromY(e.clientY); + const adjustedMinutes = currentMinutes - resizeOffsetMinutes; + const originalStartMinutes = + resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); + const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); + + if (resizeEdge === 'bottom') { + const newEndMinutes = Math.max( + originalStartMinutes + 15, + Math.min(config.lastVisibleHour * 60, adjustedMinutes) + ); + const newDuration = newEndMinutes - originalStartMinutes; + resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100; + } else { + const newStartMinutes = Math.max( + config.firstVisibleHour * 60, + Math.min(originalEndMinutes - 15, adjustedMinutes) + ); + const newDuration = originalEndMinutes - newStartMinutes; + resizePreviewTop = config.minutesToPercent(newStartMinutes); + resizePreviewHeight = (newDuration / (config.totalVisibleHours * 60)) * 100; + } + } + + async function handleResizeEnd(e: PointerEvent) { + document.removeEventListener('pointermove', handleResizeMove); + document.removeEventListener('pointerup', handleResizeEnd); + + if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) { + cleanupResize(); + return; + } + + const config = getConfig(); + const currentMinutes = minutesFromY(e.clientY); + const adjustedMinutes = currentMinutes - resizeOffsetMinutes; + 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, adjustedMinutes) + ); + 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, adjustedMinutes) + ); + const newHours = Math.floor(newStartMinutes / 60); + const newMins = newStartMinutes % 60; + newStart = setHours(new Date(resizeOriginalStart), newHours); + newStart = setMinutes(newStart, newMins); + } + + 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(), + }); + } + + cleanupResize(); + } + + function cleanupResize() { + isResizing = false; + resizeEvent = null; + resizeOriginalStart = null; + resizeOriginalEnd = null; + resizeOffsetMinutes = 0; + hasMoved = false; + } + + function cancel() { + if (isDragging || isResizing) { + document.removeEventListener('pointermove', handleDragMove); + document.removeEventListener('pointerup', handleDragEnd); + document.removeEventListener('pointermove', handleResizeMove); + document.removeEventListener('pointerup', handleResizeEnd); + cleanupDrag(); + cleanupResize(); + } + } + + function getResizePreviewTime(): string { + if (!resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return ''; + + const config = getConfig(); + const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes(); + const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes(); + + const previewStartMinutes = + (resizePreviewTop / 100) * config.totalVisibleHours * 60 + config.firstVisibleHour * 60; + const previewEndMinutes = + previewStartMinutes + (resizePreviewHeight / 100) * config.totalVisibleHours * 60; + + let startMin: number; + let endMin: number; + + if (resizeEdge === 'top') { + startMin = Math.round(previewStartMinutes); + endMin = origEndMinutes; + } else { + startMin = origStartMinutes; + endMin = Math.round(previewEndMinutes); + } + + return `${formatTime(Math.floor(startMin / 60), startMin % 60)} - ${formatTime(Math.floor(endMin / 60), endMin % 60)}`; + } + + return { + get isDragging() { + return isDragging; + }, + get draggedEvent() { + return draggedEvent; + }, + get dragTargetDay() { + return dragTargetDay; + }, + get dragPreviewTop() { + return dragPreviewTop; + }, + get dragPreviewHeight() { + return dragPreviewHeight; + }, + get isResizing() { + return isResizing; + }, + get resizeEvent() { + return resizeEvent; + }, + get resizePreviewTop() { + return resizePreviewTop; + }, + get resizePreviewHeight() { + return resizePreviewHeight; + }, + get hasMoved() { + return hasMoved; + }, + resetHasMoved() { + hasMoved = false; + }, + startDrag, + startResize, + cancel, + getResizePreviewTime, + }; +} diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/composables/useVisibleHours.svelte.ts b/apps/manacore/apps/web/src/lib/modules/calendar/composables/useVisibleHours.svelte.ts new file mode 100644 index 000000000..8b2fbb4c8 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/composables/useVisibleHours.svelte.ts @@ -0,0 +1,59 @@ +/** + * useVisibleHours + useCurrentTimeIndicator composables + * Provides hour filtering and time-to-position calculations. + */ + +const ALL_HOURS = Array.from({ length: 24 }, (_, i) => i); + +export function useVisibleHours() { + // No hour filtering in manacore yet — show all 24 hours + const firstVisibleHour = 0; + const lastVisibleHour = 24; + const totalVisibleHours = 24; + + function minutesToPercent(minutes: number): number { + const adjustedMinutes = minutes - firstVisibleHour * 60; + return (adjustedMinutes / (totalVisibleHours * 60)) * 100; + } + + function percentToMinutes(percent: number): number { + return (percent / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; + } + + return { + get hours() { + return ALL_HOURS; + }, + get firstVisibleHour() { + return firstVisibleHour; + }, + get lastVisibleHour() { + return lastVisibleHour; + }, + get totalVisibleHours() { + return totalVisibleHours; + }, + minutesToPercent, + percentToMinutes, + }; +} + +export function useCurrentTimeIndicator() { + let now = $state(new Date()); + + $effect(() => { + const interval = setInterval(() => { + now = new Date(); + }, 60000); + return () => clearInterval(interval); + }); + + return { + get now() { + return now; + }, + get currentMinutes() { + return now.getHours() * 60 + now.getMinutes(); + }, + }; +} diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/utils/constants.ts b/apps/manacore/apps/web/src/lib/modules/calendar/utils/constants.ts new file mode 100644 index 000000000..d124eb98e --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/utils/constants.ts @@ -0,0 +1,9 @@ +/** + * Shared calendar constants + */ + +export const HOUR_HEIGHT_PX = 60; +export const SNAP_INTERVAL_MINUTES = 15; +export const DEFAULT_EVENT_DURATION_MINUTES = 60; +export const MIN_EVENT_HEIGHT_PERCENT = 1.5; +export const MAX_MONTH_VIEW_EVENTS = 3; diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/utils/drag-helpers.ts b/apps/manacore/apps/web/src/lib/modules/calendar/utils/drag-helpers.ts new file mode 100644 index 000000000..fcbfb31cd --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/utils/drag-helpers.ts @@ -0,0 +1,56 @@ +/** + * Shared drag/drop utility functions + */ + +import { SNAP_INTERVAL_MINUTES } from './constants'; + +export function formatTime(hours: number, minutes: number): string { + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; +} + +export function getSnapMinutes(snapMinutes?: number): number { + return snapMinutes ?? SNAP_INTERVAL_MINUTES; +} + +export function snapToGrid(minutes: number, snapMinutes?: number): number { + const snap = getSnapMinutes(snapMinutes); + return Math.round(minutes / snap) * snap; +} + +export function getDayFromX( + clientX: number, + containerEl: HTMLElement | null, + days: Date[] +): Date | null { + if (!containerEl) return null; + + const rect = containerEl.getBoundingClientRect(); + const relativeX = clientX - rect.left; + const dayWidth = rect.width / days.length; + const dayIndex = Math.floor(relativeX / dayWidth); + + if (dayIndex >= 0 && dayIndex < days.length) { + return days[dayIndex]; + } + return null; +} + +export function getMinutesFromY( + clientY: number, + containerEl: HTMLElement | null, + totalVisibleHours: number, + hourHeight: number, + firstVisibleHour: number, + snapMinutes?: number +): number { + if (!containerEl) return 0; + + const rect = containerEl.getBoundingClientRect(); + const scrollTop = containerEl.parentElement?.scrollTop || 0; + const relativeY = clientY - rect.top + scrollTop; + + const visibleMinutes = (relativeY / (totalVisibleHours * hourHeight)) * totalVisibleHours * 60; + const totalMinutes = visibleMinutes + firstVisibleHour * 60; + + return snapToGrid(totalMinutes, snapMinutes); +} diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/utils/event-date-helpers.ts b/apps/manacore/apps/web/src/lib/modules/calendar/utils/event-date-helpers.ts new file mode 100644 index 000000000..c98e51086 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/calendar/utils/event-date-helpers.ts @@ -0,0 +1,17 @@ +/** + * Event Date Helpers + */ + +import { parseISO } from 'date-fns'; + +export function toDate(value: string | Date): Date { + return typeof value === 'string' ? parseISO(value) : value; +} + +export function getEventStart(event: { startTime: string | Date }): Date { + return toDate(event.startTime); +} + +export function getEventEnd(event: { endTime: string | Date }): Date { + return toDate(event.endTime); +} diff --git a/apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte index 0fa0d4221..4f28e3898 100644 --- a/apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte @@ -7,24 +7,20 @@ import { eventsStore } from '$lib/modules/calendar/stores/events.svelte'; import { getDefaultCalendar, - getEventsForDay, - getEventsInRange, filterEventsByVisibleCalendars, - sortEventsByTime, getCalendarColor, } from '$lib/modules/calendar/queries'; import type { Calendar, CalendarEvent } from '$lib/modules/calendar/types'; - import { - format, - addMinutes, - eachDayOfInterval, - startOfWeek, - endOfWeek, - isSameDay, - isToday, - } from 'date-fns'; - import { de } from 'date-fns/locale'; - import { CaretLeft, CaretRight, Plus, ShareNetwork } from '@manacore/shared-icons'; + + import CalendarHeader from '$lib/modules/calendar/components/CalendarHeader.svelte'; + import WeekView from '$lib/modules/calendar/components/WeekView.svelte'; + import MonthView from '$lib/modules/calendar/components/MonthView.svelte'; + import AgendaView from '$lib/modules/calendar/components/AgendaView.svelte'; + import MiniCalendar from '$lib/modules/calendar/components/MiniCalendar.svelte'; + import EventDetailModal from '$lib/modules/calendar/components/EventDetailModal.svelte'; + import EventForm from '$lib/modules/calendar/components/EventForm.svelte'; + + import { ShareNetwork } from '@manacore/shared-icons'; import { ShareModal } from '@manacore/shared-uload'; const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars'); @@ -41,21 +37,13 @@ } function handleTagDrop(event: CalendarEvent, payload: DragPayload) { - const tagData = payload.data as TagDragData; + const tagData = payload.data as unknown as TagDragData; const current = event.tagIds ?? []; if (!current.includes(tagData.id)) { eventsStore.updateTagIds(event.id, [...current, tagData.id]); } } - function tagNotAlreadyOnEvent(event: CalendarEvent) { - return (payload: DragPayload) => { - const tagData = payload.data as TagDragData; - return !(event.tagIds ?? []).includes(tagData.id); - }; - } - - // Register passive handler for task→tag direction const tagDropCtx = getContext<{ set: (handler: (tagId: string, payload: DragPayload) => void) => void; clear: () => void; @@ -64,7 +52,6 @@ onMount(() => { tagDropCtx?.set(async (tagId: string, payload: DragPayload) => { const data = payload.data as { id: string }; - // Check if dropped item is an event if (payload.type === 'event') { const event = eventsCtx.value.find((e) => e.id === data.id); if (!event) return; @@ -77,508 +64,129 @@ return () => tagDropCtx?.clear(); }); - // Filtered events based on visible calendars - let visibleEvents = $derived(filterEventsByVisibleCalendars(eventsCtx.value, calendarsCtx.value)); + // ── Event interactions ────────────────────────────────── + let selectedEvent = $state(null); + let showCreateForm = $state(false); + let createStartTime = $state(null); + let createEndTime = $state(null); - // Current view range events - let rangeEvents = $derived( - sortEventsByTime( - getEventsInRange( - visibleEvents, - calendarViewStore.viewRange.start, - calendarViewStore.viewRange.end - ) - ) - ); - - // Week days for the week view - let weekDays = $derived( - eachDayOfInterval({ - start: startOfWeek(calendarViewStore.currentDate, { weekStartsOn: 1 }), - end: endOfWeek(calendarViewStore.currentDate, { weekStartsOn: 1 }), - }) - ); - - // Event form state - let showEventForm = $state(false); - let editingEvent = $state(null); - let newTitle = $state(''); - let newDate = $state(''); - let newStartTime = $state('10:00'); - let newEndTime = $state('11:00'); - let newAllDay = $state(false); - let newLocation = $state(''); - - function openNewEvent(date?: Date) { - const d = date ?? new Date(); - editingEvent = null; - newTitle = ''; - newDate = format(d, 'yyyy-MM-dd'); - newStartTime = '10:00'; - newEndTime = '11:00'; - newAllDay = false; - newLocation = ''; - showEventForm = true; + function handleEventClick(event: CalendarEvent) { + selectedEvent = event; } - function openEditEvent(event: CalendarEvent) { - editingEvent = event; - newTitle = event.title; - newDate = format(new Date(event.startTime), 'yyyy-MM-dd'); - newStartTime = format(new Date(event.startTime), 'HH:mm'); - newEndTime = format(new Date(event.endTime), 'HH:mm'); - newAllDay = event.isAllDay; - newLocation = event.location ?? ''; - showEventForm = true; + function handleNewEvent() { + createStartTime = null; + createEndTime = null; + showCreateForm = true; } - async function handleSaveEvent() { + function handleQuickCreate(startTime: Date, endTime: Date) { + createStartTime = startTime; + createEndTime = endTime; + showCreateForm = true; + } + + async function handleCreateSave(data: Record) { const defaultCal = getDefaultCalendar(calendarsCtx.value); - const startTime = newAllDay ? `${newDate}T00:00:00` : `${newDate}T${newStartTime}:00`; - const endTime = newAllDay ? `${newDate}T23:59:59` : `${newDate}T${newEndTime}:00`; - - if (editingEvent) { - await eventsStore.updateEvent(editingEvent.id, { - title: newTitle, - startTime: new Date(startTime).toISOString(), - endTime: new Date(endTime).toISOString(), - isAllDay: newAllDay, - location: newLocation || null, - }); - } else { - await eventsStore.createEvent({ - calendarId: defaultCal?.id || '', - title: newTitle, - startTime: new Date(startTime).toISOString(), - endTime: new Date(endTime).toISOString(), - isAllDay: newAllDay, - location: newLocation || null, - }); - } - - showEventForm = false; + await eventsStore.createEvent({ + calendarId: (data.calendarId as string) || defaultCal?.id || '', + title: data.title as string, + description: (data.description as string) || null, + startTime: data.startTime as string, + endTime: data.endTime as string, + isAllDay: data.isAllDay as boolean, + location: (data.location as string) || null, + recurrenceRule: (data.recurrenceRule as string) || null, + }); + showCreateForm = false; } - async function handleDeleteEvent() { - if (!editingEvent) return; - await eventsStore.deleteEvent(editingEvent.id); - showEventForm = false; - } - - // Share modal state + // Share modal let shareEvent = $state(null); let shareUrl = $derived( shareEvent ? `${typeof window !== 'undefined' ? window.location.origin : ''}/calendar?event=${shareEvent.id}` : '' ); - - // Hours for the week grid - const hours = Array.from({ length: 24 }, (_, i) => i); - - let headerLabel = $derived.by(() => { - if (calendarViewStore.viewType === 'month') { - return format(calendarViewStore.currentDate, 'MMMM yyyy', { locale: de }); - } - return format(calendarViewStore.currentDate, "'KW' w — MMMM yyyy", { locale: de }); - }); Kalender - ManaCore -
+
-
-
-

{headerLabel}

-
- - - -
-
+ -
- -
- {#each ['week', 'month', 'agenda'] as view} - - {/each} -
+ +
+ +
-
- - -
- {#if calendarViewStore.viewType === 'week'} - -
- -
-
- {#each weekDays as day} - - {/each} -
- - -
- {#each hours as hour} -
-
- {hour.toString().padStart(2, '0')}:00 -
- {#each weekDays as day} - - {/each} -
- {/each} -
-
- {:else if calendarViewStore.viewType === 'month'} - -
-
- - {#each ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as dayName} -
- {dayName} -
- {/each} - - - {#each eachDayOfInterval( { start: startOfWeek( calendarViewStore.viewRange.start, { weekStartsOn: 1 } ), end: endOfWeek( calendarViewStore.viewRange.end, { weekStartsOn: 1 } ) } ) as day} - - {/each} -
-
- {:else} - -
- {#if rangeEvents.length === 0} -
-

Keine Termine in den nächsten 30 Tagen

- + + - {/if} + + + +
+ {#if calendarViewStore.viewType === 'week'} + + {:else if calendarViewStore.viewType === 'month'} + { + calendarViewStore.setDate(day); + calendarViewStore.setViewType('week'); + }} + /> + {:else} + + {/if} +
- -{#if showEventForm} -
-
-

- {editingEvent ? 'Termin bearbeiten' : 'Neuer Termin'} -

+ +{#if selectedEvent} + (selectedEvent = null)} /> +{/if} -
{ - e.preventDefault(); - handleSaveEvent(); - }} - class="space-y-4" - > -
- - -
- -
- - -
- - - - {#if !newAllDay} -
-
- - -
-
- - -
-
- {/if} - -
- - -
- -
- {#if editingEvent} - - - {/if} -
- - -
-
+ +{#if showCreateForm} + {/if} @@ -594,10 +202,151 @@ />