refactor(calendar): consolidate code patterns and reduce duplication

- 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 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-14 14:39:27 +01:00
parent c710f43391
commit 6bea47d4da
21 changed files with 291 additions and 352 deletions

View file

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

View file

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

View file

@ -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')}`;
});

View file

@ -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<string, CalendarEvent[]> = 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}

View file

@ -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 @@
></div>
<span class="event-time">
{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')}
</span>
<span class="event-title">{event.title || (isDraft ? '(Neuer Termin)' : '')}</span>
{#if event.location}
@ -893,10 +854,7 @@
<div
class="overflow-line"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
title="{format(
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime,
'HH:mm'
)} {event.title}"
title="{format(toDate(event.startTime), 'HH:mm')} {event.title}"
></div>
{/each}
</div>
@ -910,10 +868,7 @@
<div
class="overflow-line"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
title="{format(
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime,
'HH:mm'
)} {event.title}"
title="{format(toDate(event.startTime), 'HH:mm')} {event.title}"
></div>
{/each}
</div>

View file

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

View file

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

View file

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

View file

@ -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 })}`;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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();
},
};
}

View file

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

View file

@ -7,39 +7,55 @@ interface SearchItem {
[key: string]: unknown;
}
class SearchStore {
// Current search query
query = $state('');
// State
let query = $state('');
let matchingEventIds = $state<Set<string>>(new Set());
let isSearching = $state(false);
// Event IDs that match the search
matchingEventIds = $state<Set<string>>(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,
};

View file

@ -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<Toast[]>([]);
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();

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
import { toast } from '$lib/stores/toast';
import { toast } from '$lib/stores/toast.svelte';
function handleSubscribe(planId: string) {
console.log('Subscribe to plan:', planId);

View file

@ -6,7 +6,7 @@
import { settingsStore } from '$lib/stores/settings.svelte';
import type { TimeFormat, AllDayDisplayMode } from '$lib/stores/settings.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { toast } from '$lib/stores/toast';
import { toast } from '$lib/stores/toast.svelte';
import { GlobalSettingsSection } from '@manacore/shared-ui';
import type { CalendarViewType, Calendar } from '@calendar/shared';

View file

@ -22,6 +22,7 @@
isBefore,
} from 'date-fns';
import { de } from 'date-fns/locale';
import { toDate } from '$lib/utils/eventDateHelpers';
import { CheckSquare, AlertTriangle, Plus } from 'lucide-svelte';
// State
@ -47,8 +48,7 @@
const currentEvents = eventsStore.events ?? [];
if (Array.isArray(currentEvents)) {
for (const event of currentEvents) {
const start =
typeof event.startTime === 'string' ? parseISO(event.startTime) : event.startTime;
const start = toDate(event.startTime);
const dateKey = format(start, 'yyyy-MM-dd');
if (!groups.has(dateKey)) {
@ -100,14 +100,8 @@
// Sort events by time
if (a.type === 'event' && b.type === 'event' && a.event && b.event) {
const aStart =
typeof a.event.startTime === 'string'
? parseISO(a.event.startTime)
: a.event.startTime;
const bStart =
typeof b.event.startTime === 'string'
? parseISO(b.event.startTime)
: b.event.startTime;
const aStart = toDate(a.event.startTime);
const bStart = toDate(b.event.startTime);
return aStart.getTime() - bStart.getTime();
}