From 6bea47d4da03bb2d80b6534c6ed3b067e5aed9df Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Sun, 14 Dec 2025 14:39:27 +0100 Subject: [PATCH] refactor(calendar): consolidate code patterns and reduce duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useVisibleHours composable for shared hour filtering logic - Replace 60+ parseISO patterns with centralized toDate() utility - Remove legacy toast.ts store (keep only Svelte 5 runes version) - Standardize searchStore from class to object pattern - Remove debug console.logs from API clients Affected views: WeekView, MultiDayView, DayView, AgendaView, YearView Affected components: EventForm, EventDetailModal, QuickEventOverlay, AgendaItem Affected stores: events, search, toast 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../apps/web/src/lib/api/base-client.ts | 1 - apps/calendar/apps/web/src/lib/api/events.ts | 10 -- .../lib/components/agenda/AgendaItem.svelte | 7 +- .../lib/components/calendar/AgendaView.svelte | 17 +-- .../lib/components/calendar/DayView.svelte | 109 +++++------------- .../components/calendar/MultiDayView.svelte | 83 +++++-------- .../lib/components/calendar/WeekView.svelte | 84 +++++--------- .../lib/components/calendar/YearView.svelte | 5 +- .../components/event/EventDetailModal.svelte | 9 +- .../src/lib/components/event/EventForm.svelte | 8 +- .../components/event/QuickEventOverlay.svelte | 14 +-- .../components/todo/TodoDetailModal.svelte | 2 +- .../src/lib/composables/useDragDrop.svelte.ts | 17 +-- .../src/lib/composables/useResize.svelte.ts | 7 +- .../lib/composables/useVisibleHours.svelte.ts | 102 ++++++++++++++++ .../apps/web/src/lib/stores/events.svelte.ts | 18 ++- .../apps/web/src/lib/stores/search.svelte.ts | 82 +++++++------ .../calendar/apps/web/src/lib/stores/toast.ts | 50 -------- .../web/src/routes/(app)/mana/+page.svelte | 2 +- .../src/routes/(app)/settings/+page.svelte | 2 +- .../web/src/routes/(app)/tasks/+page.svelte | 14 +-- 21 files changed, 291 insertions(+), 352 deletions(-) create mode 100644 apps/calendar/apps/web/src/lib/composables/useVisibleHours.svelte.ts delete mode 100644 apps/calendar/apps/web/src/lib/stores/toast.ts diff --git a/apps/calendar/apps/web/src/lib/api/base-client.ts b/apps/calendar/apps/web/src/lib/api/base-client.ts index 383a33a06..0b53439e3 100644 --- a/apps/calendar/apps/web/src/lib/api/base-client.ts +++ b/apps/calendar/apps/web/src/lib/api/base-client.ts @@ -59,7 +59,6 @@ export function createApiClient(config: ApiClientConfig) { } const url = `${baseUrl}${apiPrefix}${endpoint}`; - console.log(`[API Client] ${method} ${url}`, { hasToken: !!authToken }); const response = await fetch(url, { method, diff --git a/apps/calendar/apps/web/src/lib/api/events.ts b/apps/calendar/apps/web/src/lib/api/events.ts index 893cf5f3d..1ce53b469 100644 --- a/apps/calendar/apps/web/src/lib/api/events.ts +++ b/apps/calendar/apps/web/src/lib/api/events.ts @@ -23,14 +23,7 @@ export async function getEvents(params: QueryEventsParams) { if (params.search) { searchParams.set('search', params.search); } - console.log('[Calendar API] Fetching events:', params); const result = await fetchApi<{ events: CalendarEvent[] }>(`/events?${searchParams.toString()}`); - console.log( - '[Calendar API] Fetch events result:', - result.data?.events?.length, - 'events', - result.error - ); if (result.error || !result.data) { return { data: null, error: result.error }; } @@ -64,14 +57,11 @@ export async function getEventsByCalendar(calendarId: string) { } export async function createEvent(data: CreateEventInput) { - console.log('[Calendar API] Creating event:', data); const result = await fetchApi<{ event: CalendarEvent }>('/events', { method: 'POST', body: data, }); - console.log('[Calendar API] Create event result:', result); if (result.error || !result.data) { - console.error('[Calendar API] Create event failed:', result.error); return { data: null, error: result.error }; } return { data: result.data.event, error: null }; diff --git a/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte b/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte index ce0620e0f..54549afa9 100644 --- a/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte +++ b/apps/calendar/apps/web/src/lib/components/agenda/AgendaItem.svelte @@ -7,8 +7,9 @@ import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte'; import PriorityBadge from '$lib/components/todo/PriorityBadge.svelte'; import { Calendar, MapPin, Clock } from 'lucide-svelte'; - import { format, parseISO } from 'date-fns'; + import { format } from 'date-fns'; import { de } from 'date-fns/locale'; + import { toDate } from '$lib/utils/eventDateHelpers'; type ItemType = 'event' | 'todo'; @@ -29,8 +30,8 @@ if (!event) return ''; if (event.isAllDay) return 'Ganztägig'; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); return `${format(start, 'HH:mm')} - ${format(end, 'HH:mm')}`; }); diff --git a/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte index a8202c9bf..982a0ece3 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/AgendaView.svelte @@ -6,6 +6,7 @@ import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; import { format, parseISO, isToday, isTomorrow, startOfDay } from 'date-fns'; import { de } from 'date-fns/locale'; + import { toDate } from '$lib/utils/eventDateHelpers'; import type { CalendarEvent } from '@calendar/shared'; interface Props { @@ -25,8 +26,7 @@ const groups: Map = new Map(); for (const event of currentEvents) { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const start = toDate(event.startTime); // Skip events before the start date if (start < startDate) continue; @@ -45,8 +45,8 @@ .map(([dateKey, events]) => ({ date: parseISO(dateKey), events: events.sort((a, b) => { - const aStart = typeof a.startTime === 'string' ? parseISO(a.startTime) : a.startTime; - const bStart = typeof b.startTime === 'string' ? parseISO(b.startTime) : b.startTime; + const aStart = toDate(a.startTime); + const bStart = toDate(b.startTime); return aStart.getTime() - bStart.getTime(); }), })); @@ -118,13 +118,8 @@ {#if event.isAllDay} Ganztägig {:else} - {format( - typeof event.startTime === 'string' - ? parseISO(event.startTime) - : event.startTime, - 'HH:mm' - )} - {format( - typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, + {format(toDate(event.startTime), 'HH:mm')} - {format( + toDate(event.endTime), 'HH:mm' )} {/if} diff --git a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte index d7bba3b48..dfbf0dde2 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -6,18 +6,15 @@ import { searchStore } from '$lib/stores/search.svelte'; import { todosStore, type Task } from '$lib/stores/todos.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; + import { + useVisibleHours, + useCurrentTimeIndicator, + } from '$lib/composables/useVisibleHours.svelte'; + import { toDate } from '$lib/utils/eventDateHelpers'; import TaskBlock from './TaskBlock.svelte'; import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; import { goto } from '$app/navigation'; - import { - format, - isToday, - parseISO, - differenceInMinutes, - addMinutes, - setHours, - setMinutes, - } from 'date-fns'; + import { format, isToday, differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns'; import { de } from 'date-fns/locale'; import type { CalendarEvent } from '@calendar/shared'; @@ -34,41 +31,19 @@ const HOUR_HEIGHT = 60; // pixels per hour const SNAP_MINUTES = 15; // snap to 15-minute intervals - // Generate hours (filtered based on settings) - let allHours = Array.from({ length: 24 }, (_, i) => i); - let hours = $derived( - settingsStore.filterHoursEnabled - ? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour) - : allHours - ); + // Use composables for hour filtering and time indicator + const visibleHours = useVisibleHours(); + const timeIndicator = useCurrentTimeIndicator(); - // Calculate visible hours range for positioning - let firstVisibleHour = $derived( - settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0 - ); - let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24); - let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour); - - // Helper to convert minutes to percentage position (accounting for hidden hours) - function minutesToPercent(minutes: number): number { - const adjustedMinutes = minutes - firstVisibleHour * 60; - return (adjustedMinutes / (totalVisibleHours * 60)) * 100; - } + // Destructure for convenience (these are reactive getters) + let hours = $derived(visibleHours.hours); + let firstVisibleHour = $derived(visibleHours.firstVisibleHour); + let lastVisibleHour = $derived(visibleHours.lastVisibleHour); + let totalVisibleHours = $derived(visibleHours.totalVisibleHours); + const minutesToPercent = visibleHours.minutesToPercent; // Current time indicator position - let now = $state(new Date()); - let currentTimePosition = $derived.by(() => { - const minutes = now.getHours() * 60 + now.getMinutes(); - return minutesToPercent(minutes); - }); - - // Update current time every minute - $effect(() => { - const interval = setInterval(() => { - now = new Date(); - }, 60000); - return () => clearInterval(interval); - }); + let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes)); // Get timed events, filtering out those outside visible range when hour filter is enabled let timedEvents = $derived.by(() => { @@ -79,9 +54,8 @@ const visibleEndMinutes = settingsStore.dayEndHour * 60; return allEvents.filter((event) => { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); @@ -108,9 +82,8 @@ const visibleEndMinutes = settingsStore.dayEndHour * 60; for (const event of allEvents) { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); @@ -212,8 +185,8 @@ e.preventDefault(); e.stopPropagation(); - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const startMinutes = start.getHours() * 60 + start.getMinutes(); const duration = differenceInMinutes(end, start); @@ -257,14 +230,8 @@ Math.min(newStartMinutes, lastVisibleHour * 60 - 30) ); - const start = - typeof draggedEvent.startTime === 'string' - ? parseISO(draggedEvent.startTime) - : draggedEvent.startTime; - const end = - typeof draggedEvent.endTime === 'string' - ? parseISO(draggedEvent.endTime) - : draggedEvent.endTime; + const start = toDate(draggedEvent.startTime); + const end = toDate(draggedEvent.endTime); const duration = differenceInMinutes(end, start); // Create new start time on same day @@ -303,8 +270,8 @@ resizeEdge = edge; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); resizeOriginalStart = start; resizeOriginalEnd = end; @@ -647,8 +614,8 @@ // Event Styling // ============================================================================ function getEventStyle(event: CalendarEvent) { - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const startMinutes = start.getHours() * 60 + start.getMinutes(); const duration = differenceInMinutes(end, start); @@ -840,14 +807,8 @@ > - {format( - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime, - 'HH:mm' - )} - - {format( - typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime, - 'HH:mm' - )} + {format(toDate(event.startTime), 'HH:mm')} - + {format(toDate(event.endTime), 'HH:mm')} {event.title || (isDraft ? '(Neuer Termin)' : '')} {#if event.location} @@ -893,10 +854,7 @@
{/each} @@ -910,10 +868,7 @@
{/each} diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte index 4bcd05bfc..1a8910595 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte @@ -6,6 +6,11 @@ import { searchStore } from '$lib/stores/search.svelte'; import { todosStore, type Task } from '$lib/stores/todos.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; + import { + useVisibleHours, + useCurrentTimeIndicator, + } from '$lib/composables/useVisibleHours.svelte'; + import { toDate } from '$lib/utils/eventDateHelpers'; import TaskBlock from './TaskBlock.svelte'; import { goto } from '$app/navigation'; import { @@ -13,7 +18,6 @@ eachDayOfInterval, isToday, isSameDay, - parseISO, differenceInMinutes, isWeekend, addMinutes, @@ -56,41 +60,19 @@ settingsStore.showOnlyWeekdays ? allDays.filter((day) => !isWeekend(day)) : allDays ); - // Generate hours (filtered based on settings) - let allHours = Array.from({ length: 24 }, (_, i) => i); - let hours = $derived( - settingsStore.filterHoursEnabled - ? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour) - : allHours - ); + // Use composables for hour filtering and time indicator + const visibleHours = useVisibleHours(); + const timeIndicator = useCurrentTimeIndicator(); - // Calculate visible hours range for positioning - let firstVisibleHour = $derived( - settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0 - ); - let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24); - let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour); - - // Helper to convert minutes to percentage position (accounting for hidden hours) - function minutesToPercent(minutes: number): number { - const adjustedMinutes = minutes - firstVisibleHour * 60; - return (adjustedMinutes / (totalVisibleHours * 60)) * 100; - } + // Destructure for convenience (these are reactive getters) + let hours = $derived(visibleHours.hours); + let firstVisibleHour = $derived(visibleHours.firstVisibleHour); + let lastVisibleHour = $derived(visibleHours.lastVisibleHour); + let totalVisibleHours = $derived(visibleHours.totalVisibleHours); + const minutesToPercent = visibleHours.minutesToPercent; // Current time indicator position - let now = $state(new Date()); - let currentTimePosition = $derived.by(() => { - const minutes = now.getHours() * 60 + now.getMinutes(); - return minutesToPercent(minutes); - }); - - // Update current time every minute - $effect(() => { - const interval = setInterval(() => { - now = new Date(); - }, 60000); - return () => clearInterval(interval); - }); + let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes)); // Determine column width based on day count let columnClass = $derived.by(() => { @@ -145,9 +127,8 @@ const visibleEndMinutes = settingsStore.dayEndHour * 60; return allEvents.filter((event) => { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); @@ -174,9 +155,8 @@ const visibleEndMinutes = settingsStore.dayEndHour * 60; for (const event of allEvents) { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); @@ -221,8 +201,8 @@ ); function getEventStyle(event: CalendarEvent) { - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const startMinutes = start.getHours() * 60 + start.getMinutes(); const duration = differenceInMinutes(end, start); @@ -265,8 +245,7 @@ } function formatEventTime(date: Date | string): string { - const d = typeof date === 'string' ? parseISO(date) : date; - return settingsStore.formatTime(d); + return settingsStore.formatTime(toDate(date)); } function handleEventClick(event: CalendarEvent, e: MouseEvent) { @@ -346,8 +325,8 @@ draggedEvent = event; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const duration = differenceInMinutes(end, start); // Calculate initial preview position @@ -397,14 +376,8 @@ return; } - const start = - typeof draggedEvent.startTime === 'string' - ? parseISO(draggedEvent.startTime) - : draggedEvent.startTime; - const end = - typeof draggedEvent.endTime === 'string' - ? parseISO(draggedEvent.endTime) - : draggedEvent.endTime; + const start = toDate(draggedEvent.startTime); + const end = toDate(draggedEvent.endTime); const duration = differenceInMinutes(end, start); // Calculate new start time @@ -450,8 +423,8 @@ resizeEdge = edge; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); resizeOriginalStart = start; resizeOriginalEnd = end; diff --git a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte index 60c484ea6..9e2f3c9f5 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -6,17 +6,20 @@ import { searchStore } from '$lib/stores/search.svelte'; import { todosStore, type Task } from '$lib/stores/todos.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; + import { + useVisibleHours, + useCurrentTimeIndicator, + } from '$lib/composables/useVisibleHours.svelte'; + import { toDate } from '$lib/utils/eventDateHelpers'; import TaskBlock from './TaskBlock.svelte'; import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; import { goto } from '$app/navigation'; import { format, eachDayOfInterval, - startOfDay, isToday, isWeekend, isSameDay, - parseISO, differenceInMinutes, addMinutes, setHours, @@ -63,41 +66,19 @@ getWeek(viewStore.viewRange.start, { weekStartsOn: settingsStore.weekStartsOn }) ); - // Generate hours (filtered based on settings) - let allHours = Array.from({ length: 24 }, (_, i) => i); - let hours = $derived( - settingsStore.filterHoursEnabled - ? allHours.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour) - : allHours - ); + // Use composables for hour filtering and time indicator + const visibleHours = useVisibleHours(); + const timeIndicator = useCurrentTimeIndicator(); - // Calculate visible hours range for positioning - let firstVisibleHour = $derived( - settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0 - ); - let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24); - let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour); - - // Helper to convert minutes to percentage position (accounting for hidden hours) - function minutesToPercent(minutes: number): number { - const adjustedMinutes = minutes - firstVisibleHour * 60; - return (adjustedMinutes / (totalVisibleHours * 60)) * 100; - } + // Destructure for convenience (these are reactive getters) + let hours = $derived(visibleHours.hours); + let firstVisibleHour = $derived(visibleHours.firstVisibleHour); + let lastVisibleHour = $derived(visibleHours.lastVisibleHour); + let totalVisibleHours = $derived(visibleHours.totalVisibleHours); + const minutesToPercent = visibleHours.minutesToPercent; // Current time indicator position - let now = $state(new Date()); - let currentTimePosition = $derived.by(() => { - const minutes = now.getHours() * 60 + now.getMinutes(); - return minutesToPercent(minutes); - }); - - // Update current time every minute - $effect(() => { - const interval = setInterval(() => { - now = new Date(); - }, 60000); - return () => clearInterval(interval); - }); + let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes)); // Drag & Drop State let isDragging = $state(false); @@ -145,9 +126,8 @@ const visibleEndMinutes = settingsStore.dayEndHour * 60; return allEvents.filter((event) => { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); @@ -174,9 +154,8 @@ const visibleEndMinutes = settingsStore.dayEndHour * 60; for (const event of allEvents) { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); @@ -221,8 +200,8 @@ ); function getEventStyle(event: CalendarEvent) { - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const startMinutes = start.getHours() * 60 + start.getMinutes(); const duration = differenceInMinutes(end, start); @@ -267,8 +246,7 @@ } function formatEventTime(date: Date | string): string { - const d = typeof date === 'string' ? parseISO(date) : date; - return settingsStore.formatTime(d); + return settingsStore.formatTime(toDate(date)); } function handleEventClick(event: CalendarEvent, e: MouseEvent) { @@ -354,8 +332,8 @@ draggedEvent = event; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const duration = differenceInMinutes(end, start); // Calculate initial preview position @@ -405,14 +383,8 @@ return; } - const start = - typeof draggedEvent.startTime === 'string' - ? parseISO(draggedEvent.startTime) - : draggedEvent.startTime; - const end = - typeof draggedEvent.endTime === 'string' - ? parseISO(draggedEvent.endTime) - : draggedEvent.endTime; + const start = toDate(draggedEvent.startTime); + const end = toDate(draggedEvent.endTime); const duration = differenceInMinutes(end, start); // Calculate new start time @@ -458,8 +430,8 @@ resizeEdge = edge; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); resizeOriginalStart = start; resizeOriginalEnd = end; diff --git a/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte index 72ac9254a..e0c0d5950 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/YearView.svelte @@ -11,11 +11,11 @@ eachDayOfInterval, isSameMonth, isToday, - parseISO, setHours, setMinutes, } from 'date-fns'; import { de } from 'date-fns/locale'; + import { toDate } from '$lib/utils/eventDateHelpers'; import type { CalendarViewType, CalendarEvent } from '@calendar/shared'; interface Props { @@ -58,8 +58,7 @@ const events = eventsStore.events ?? []; for (const event of events) { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + const start = toDate(event.startTime); const key = format(start, 'yyyy-MM-dd'); counts.set(key, (counts.get(key) || 0) + 1); } diff --git a/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte index 3f802dc68..2b6c4df49 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventDetailModal.svelte @@ -2,13 +2,14 @@ import { goto } from '$app/navigation'; import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; - import { toast } from '$lib/stores/toast'; + import { toast } from '$lib/stores/toast.svelte'; import EventForm from './EventForm.svelte'; import { TagBadge } from '@manacore/shared-ui'; import type { CalendarEvent, UpdateEventInput } from '@calendar/shared'; import * as api from '$lib/api/events'; - import { format, parseISO } from 'date-fns'; + import { format } from 'date-fns'; import { de } from 'date-fns/locale'; + import { toDate } from '$lib/utils/eventDateHelpers'; import { EventDetailSkeleton } from '$lib/components/skeletons'; interface Props { @@ -99,8 +100,8 @@ if (event.isAllDay) { return 'Ganztägig'; } - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); return `${format(start, 'PPPp', { locale: de })} - ${format(end, 'p', { locale: de })}`; } diff --git a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte index 4e81fcb49..2cc24b0fe 100644 --- a/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/EventForm.svelte @@ -15,7 +15,8 @@ EventAttendee, ResponsiblePerson, } from '@calendar/shared'; - import { format, addMinutes, parseISO } from 'date-fns'; + import { format, addMinutes } from 'date-fns'; + import { toDate } from '$lib/utils/eventDateHelpers'; interface Props { mode: 'create' | 'edit'; @@ -104,9 +105,8 @@ // Initialize date/time fields using settings for default duration $effect(() => { if (event) { - const start = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); startDate = format(start, 'yyyy-MM-dd'); startTime = format(start, 'HH:mm'); endDate = format(end, 'yyyy-MM-dd'); diff --git a/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte b/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte index e26c84fb0..e71671d04 100644 --- a/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte +++ b/apps/calendar/apps/web/src/lib/components/event/QuickEventOverlay.svelte @@ -3,7 +3,7 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; import { contactsStore } from '$lib/stores/contacts.svelte'; - import { toast } from '$lib/stores/toast'; + import { toast } from '$lib/stores/toast.svelte'; import type { LocationDetails, CalendarEvent, @@ -13,8 +13,9 @@ import type { ContactSummary, ContactOrManual, ManualContactEntry } from '@manacore/shared-types'; import { ContactSelector, ContactAvatar } from '@manacore/shared-ui'; import { Users } from 'lucide-svelte'; - import { format, addMinutes, parseISO } from 'date-fns'; + import { format, addMinutes } from 'date-fns'; import { de } from 'date-fns/locale'; + import { toDate } from '$lib/utils/eventDateHelpers'; import { tick, onMount, onDestroy } from 'svelte'; // Portal action - moves element to body to escape stacking contexts @@ -246,9 +247,8 @@ attendees = event.metadata?.attendees || []; // Initialize time fields - const eventStart = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const eventStart = toDate(event.startTime); + const eventEnd = toDate(event.endTime); startDateStr = format(eventStart, 'yyyy-MM-dd'); startTimeStr = format(eventStart, 'HH:mm'); endDateStr = format(eventEnd, 'yyyy-MM-dd'); @@ -259,7 +259,7 @@ // Date/time fields - derive from draft event (create mode) or event (edit mode) let draftStart = $derived(() => { if (isEditMode && event) { - return typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; + return toDate(event.startTime); } const draft = eventsStore.draftEvent; if (draft) { @@ -270,7 +270,7 @@ let draftEnd = $derived(() => { if (isEditMode && event) { - return typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + return toDate(event.endTime); } const draft = eventsStore.draftEvent; if (draft) { diff --git a/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte b/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte index b9424d733..9c33ff370 100644 --- a/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte +++ b/apps/calendar/apps/web/src/lib/components/todo/TodoDetailModal.svelte @@ -2,7 +2,7 @@ import { todosStore } from '$lib/stores/todos.svelte'; import type { Task, UpdateTaskInput, TaskPriority } from '$lib/api/todos'; import { PRIORITY_LABELS, PRIORITY_COLORS } from '$lib/api/todos'; - import { toast } from '$lib/stores/toast'; + import { toast } from '$lib/stores/toast.svelte'; import TodoCheckbox from './TodoCheckbox.svelte'; import PriorityBadge from './PriorityBadge.svelte'; import { diff --git a/apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts index 7af9a938a..a6e66fa36 100644 --- a/apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts +++ b/apps/calendar/apps/web/src/lib/composables/useDragDrop.svelte.ts @@ -4,7 +4,8 @@ */ import type { CalendarEvent } from '@calendar/shared'; -import { parseISO, differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns'; +import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns'; +import { toDate } from '$lib/utils/eventDateHelpers'; import { eventsStore } from '$lib/stores/events.svelte'; export interface DragDropConfig { @@ -107,8 +108,8 @@ export function useDragDrop(getConfig: () => DragDropConfig) { draggedEvent = event; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); const duration = differenceInMinutes(end, start); // Calculate initial preview position @@ -158,14 +159,8 @@ export function useDragDrop(getConfig: () => DragDropConfig) { } const config = getConfig(); - const start = - typeof draggedEvent.startTime === 'string' - ? parseISO(draggedEvent.startTime) - : draggedEvent.startTime; - const end = - typeof draggedEvent.endTime === 'string' - ? parseISO(draggedEvent.endTime) - : draggedEvent.endTime; + const start = toDate(draggedEvent.startTime); + const end = toDate(draggedEvent.endTime); const duration = differenceInMinutes(end, start); // Calculate new start time diff --git a/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts index 44e8c511a..04d43e592 100644 --- a/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts +++ b/apps/calendar/apps/web/src/lib/composables/useResize.svelte.ts @@ -4,7 +4,8 @@ */ import type { CalendarEvent } from '@calendar/shared'; -import { parseISO, differenceInMinutes, setHours, setMinutes } from 'date-fns'; +import { differenceInMinutes, setHours, setMinutes } from 'date-fns'; +import { toDate } from '$lib/utils/eventDateHelpers'; import { eventsStore } from '$lib/stores/events.svelte'; export interface ResizeConfig { @@ -86,8 +87,8 @@ export function useResize(getConfig: () => ResizeConfig) { resizeEdge = edge; hasMoved = false; - const start = typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const end = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const start = toDate(event.startTime); + const end = toDate(event.endTime); resizeOriginalStart = start; resizeOriginalEnd = end; diff --git a/apps/calendar/apps/web/src/lib/composables/useVisibleHours.svelte.ts b/apps/calendar/apps/web/src/lib/composables/useVisibleHours.svelte.ts new file mode 100644 index 000000000..d823418cb --- /dev/null +++ b/apps/calendar/apps/web/src/lib/composables/useVisibleHours.svelte.ts @@ -0,0 +1,102 @@ +/** + * useVisibleHours Composable + * + * Provides hour filtering and time-to-position calculations for calendar views. + * Extracts common logic from WeekView, MultiDayView, and DayView. + */ + +import { settingsStore } from '$lib/stores/settings.svelte'; + +const ALL_HOURS = Array.from({ length: 24 }, (_, i) => i); + +/** + * Creates reactive hour visibility state and helper functions + */ +export function useVisibleHours() { + // Filtered hours based on settings + let hours = $derived( + settingsStore.filterHoursEnabled + ? ALL_HOURS.filter((h) => h >= settingsStore.dayStartHour && h < settingsStore.dayEndHour) + : ALL_HOURS + ); + + // Calculate visible hours range for positioning + let firstVisibleHour = $derived( + settingsStore.filterHoursEnabled ? settingsStore.dayStartHour : 0 + ); + + let lastVisibleHour = $derived(settingsStore.filterHoursEnabled ? settingsStore.dayEndHour : 24); + + let totalVisibleHours = $derived(lastVisibleHour - firstVisibleHour); + + /** + * Convert minutes (from midnight) to percentage position + * accounting for hidden hours when filtering is enabled + */ + function minutesToPercent(minutes: number): number { + const adjustedMinutes = minutes - firstVisibleHour * 60; + return (adjustedMinutes / (totalVisibleHours * 60)) * 100; + } + + /** + * Convert percentage position back to minutes (from midnight) + */ + function percentToMinutes(percent: number): number { + return (percent / 100) * (totalVisibleHours * 60) + firstVisibleHour * 60; + } + + /** + * Check if a time range overlaps with the visible hours range + */ + function isTimeRangeVisible(startMinutes: number, endMinutes: number): boolean { + const visibleStartMinutes = firstVisibleHour * 60; + const visibleEndMinutes = lastVisibleHour * 60; + return startMinutes < visibleEndMinutes && endMinutes > visibleStartMinutes; + } + + return { + get hours() { + return hours; + }, + get firstVisibleHour() { + return firstVisibleHour; + }, + get lastVisibleHour() { + return lastVisibleHour; + }, + get totalVisibleHours() { + return totalVisibleHours; + }, + minutesToPercent, + percentToMinutes, + isTimeRangeVisible, + }; +} + +/** + * Creates a reactive current time indicator + * Updates every minute and provides position calculation + */ +export function useCurrentTimeIndicator() { + let now = $state(new Date()); + + // Update current time every minute + $effect(() => { + const interval = setInterval(() => { + now = new Date(); + }, 60000); + return () => clearInterval(interval); + }); + + return { + get now() { + return now; + }, + /** + * Get current time as minutes from midnight + */ + get currentMinutes() { + return now.getHours() * 60 + now.getMinutes(); + }, + }; +} diff --git a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts index 48798e117..2619ea3db 100644 --- a/apps/calendar/apps/web/src/lib/stores/events.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/events.svelte.ts @@ -4,7 +4,8 @@ import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared'; import * as api from '$lib/api/events'; -import { format, isWithinInterval, parseISO, isSameDay } from 'date-fns'; +import { format, isWithinInterval, isSameDay } from 'date-fns'; +import { toDate } from '$lib/utils/eventDateHelpers'; import { toastStore } from './toast.svelte'; // State @@ -68,9 +69,8 @@ export const eventsStore = { if (!Array.isArray(currentEvents)) return []; const result = currentEvents.filter((event) => { - const eventStart = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const eventStart = toDate(event.startTime); + const eventEnd = toDate(event.endTime); // For all-day events, check if day falls within event range if (event.isAllDay) { @@ -86,10 +86,7 @@ export const eventsStore = { // Include draft event if it exists and is on this day if (includeDraft && draftEvent) { - const draftStart = - typeof draftEvent.startTime === 'string' - ? parseISO(draftEvent.startTime) - : draftEvent.startTime; + const draftStart = toDate(draftEvent.startTime); if (isSameDay(date, draftStart)) { result.push(draftEvent); } @@ -107,9 +104,8 @@ export const eventsStore = { if (!Array.isArray(currentEvents)) return []; return currentEvents.filter((event) => { - const eventStart = - typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime; - const eventEnd = typeof event.endTime === 'string' ? parseISO(event.endTime) : event.endTime; + const eventStart = toDate(event.startTime); + const eventEnd = toDate(event.endTime); // Check if event overlaps with the range return eventStart <= end && eventEnd >= start; diff --git a/apps/calendar/apps/web/src/lib/stores/search.svelte.ts b/apps/calendar/apps/web/src/lib/stores/search.svelte.ts index 8859c8aed..64c93eddb 100644 --- a/apps/calendar/apps/web/src/lib/stores/search.svelte.ts +++ b/apps/calendar/apps/web/src/lib/stores/search.svelte.ts @@ -7,39 +7,55 @@ interface SearchItem { [key: string]: unknown; } -class SearchStore { - // Current search query - query = $state(''); +// State +let query = $state(''); +let matchingEventIds = $state>(new Set()); +let isSearching = $state(false); - // Event IDs that match the search - matchingEventIds = $state>(new Set()); - - // Whether search is active (user is typing in InputBar) - isSearching = $state(false); - - // Set search query and matching items (events or any items with an id) - setSearch(query: string, matchingItems: SearchItem[]) { - this.query = query; - this.matchingEventIds = new Set(matchingItems.map((item) => item.id)); - this.isSearching = query.trim().length > 0; - } - - // Clear search - clear() { - this.query = ''; - this.matchingEventIds = new Set(); - this.isSearching = false; - } - - // Check if an event matches the search - isEventHighlighted(eventId: string): boolean { - return this.isSearching && this.matchingEventIds.has(eventId); - } - - // Check if an event should be dimmed (search active but event doesn't match) - isEventDimmed(eventId: string): boolean { - return this.isSearching && !this.matchingEventIds.has(eventId); - } +/** + * Set search query and matching items (events or any items with an id) + */ +function setSearch(newQuery: string, matchingItems: SearchItem[]) { + query = newQuery; + matchingEventIds = new Set(matchingItems.map((item) => item.id)); + isSearching = newQuery.trim().length > 0; } -export const searchStore = new SearchStore(); +/** + * Clear search + */ +function clear() { + query = ''; + matchingEventIds = new Set(); + isSearching = false; +} + +/** + * Check if an event matches the search + */ +function isEventHighlighted(eventId: string): boolean { + return isSearching && matchingEventIds.has(eventId); +} + +/** + * Check if an event should be dimmed (search active but event doesn't match) + */ +function isEventDimmed(eventId: string): boolean { + return isSearching && !matchingEventIds.has(eventId); +} + +export const searchStore = { + get query() { + return query; + }, + get matchingEventIds() { + return matchingEventIds; + }, + get isSearching() { + return isSearching; + }, + setSearch, + clear, + isEventHighlighted, + isEventDimmed, +}; diff --git a/apps/calendar/apps/web/src/lib/stores/toast.ts b/apps/calendar/apps/web/src/lib/stores/toast.ts deleted file mode 100644 index 0655b3212..000000000 --- a/apps/calendar/apps/web/src/lib/stores/toast.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { writable } from 'svelte/store'; - -export type ToastType = 'success' | 'error' | 'warning' | 'info'; - -export interface Toast { - id: string; - type: ToastType; - message: string; - duration?: number; -} - -function createToastStore() { - const { subscribe, update } = writable([]); - - function add(message: string, type: ToastType = 'info', duration: number = 4000) { - const id = crypto.randomUUID(); - const toast: Toast = { id, type, message, duration }; - - update((toasts) => [...toasts, toast]); - - if (duration > 0) { - setTimeout(() => { - remove(id); - }, duration); - } - - return id; - } - - function remove(id: string) { - update((toasts) => toasts.filter((t) => t.id !== id)); - } - - function clear() { - update(() => []); - } - - return { - subscribe, - add, - remove, - clear, - success: (message: string, duration?: number) => add(message, 'success', duration), - error: (message: string, duration?: number) => add(message, 'error', duration), - warning: (message: string, duration?: number) => add(message, 'warning', duration), - info: (message: string, duration?: number) => add(message, 'info', duration), - }; -} - -export const toast = createToastStore(); diff --git a/apps/calendar/apps/web/src/routes/(app)/mana/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/mana/+page.svelte index 279d6f48f..fe14b49ab 100644 --- a/apps/calendar/apps/web/src/routes/(app)/mana/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/mana/+page.svelte @@ -1,6 +1,6 @@