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