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 765b26413..e8dc910f3 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/DayView.svelte @@ -13,6 +13,13 @@ useCurrentTimeIndicator, } from '$lib/composables/useVisibleHours.svelte'; import { toDate } from '$lib/utils/eventDateHelpers'; + import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; + import { + getVisibleTimedEvents, + getVisibleAllDayEvents, + getVisibleOverflowEvents, + type OverflowEvents, + } from '$lib/utils/eventFiltering'; import TaskBlock from './TaskBlock.svelte'; import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; import { goto } from '$app/navigation'; @@ -29,9 +36,9 @@ let { onQuickCreate, onEventClick, onTaskClick }: Props = $props(); - // Constants - const HOUR_HEIGHT = 60; // pixels per hour - const SNAP_MINUTES = 15; // snap to 15-minute intervals + // Use shared constants + const HOUR_HEIGHT = HOUR_HEIGHT_PX; + const SNAP_MINUTES = SNAP_INTERVAL_MINUTES; // Use composables for hour filtering and time indicator const visibleHours = useVisibleHours(); @@ -48,75 +55,37 @@ let currentTimePosition = $derived(minutesToPercent(timeIndicator.currentMinutes)); // Get timed events, filtering out those outside visible range when hour filter is enabled - let timedEvents = $derived.by(() => { - // Filter by visible calendars first - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - const allEvents = eventsStore - .getEventsForDay(viewStore.currentDate) - .filter((e) => !e.isAllDay && visibleCalendarIds.has(e.calendarId)); - - if (settingsStore.filterHoursEnabled) { - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - return allEvents.filter((event) => { - const start = toDate(event.startTime); - const end = toDate(event.endTime); - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event overlaps with visible range - return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes; - }); - } - - return allEvents; - }); + let timedEvents = $derived( + getVisibleTimedEvents( + eventsStore.getEventsForDay(viewStore.currentDate), + calendarsStore.visibleCalendars, + { + filterHoursEnabled: settingsStore.filterHoursEnabled, + dayStartHour: settingsStore.dayStartHour, + dayEndHour: settingsStore.dayEndHour, + } + ) + ); // Get events that are completely outside the visible time range - let overflowEvents = $derived.by(() => { + let overflowEvents = $derived.by((): OverflowEvents => { if (!settingsStore.filterHoursEnabled) { - return { before: [] as CalendarEvent[], after: [] as CalendarEvent[] }; + return { before: [], after: [] }; } - - // Filter by visible calendars - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - const allEvents = eventsStore - .getEventsForDay(viewStore.currentDate) - .filter((e) => !e.isAllDay && visibleCalendarIds.has(e.calendarId)); - const before: CalendarEvent[] = []; - const after: CalendarEvent[] = []; - - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - for (const event of allEvents) { - const start = toDate(event.startTime); - const end = toDate(event.endTime); - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event ends before visible range starts - if (eventEndMinutes <= visibleStartMinutes) { - before.push(event); - } - // Event starts after visible range ends - else if (eventStartMinutes >= visibleEndMinutes) { - after.push(event); - } - } - - return { before, after }; + return getVisibleOverflowEvents( + eventsStore.getEventsForDay(viewStore.currentDate), + calendarsStore.visibleCalendars, + settingsStore.dayStartHour, + settingsStore.dayEndHour + ); }); - let allDayEvents = $derived.by(() => { - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - return eventsStore - .getEventsForDay(viewStore.currentDate) - .filter((e) => e.isAllDay && visibleCalendarIds.has(e.calendarId)); - }); + let allDayEvents = $derived( + getVisibleAllDayEvents( + eventsStore.getEventsForDay(viewStore.currentDate), + calendarsStore.visibleCalendars + ) + ); // Get display mode for an event (per-event override takes precedence over global setting) function getEventDisplayMode(event: CalendarEvent): 'header' | 'block' { diff --git a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte index 22a3cc3ae..5b205e283 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MonthView.svelte @@ -30,6 +30,7 @@ } from 'date-fns'; import { de } from 'date-fns/locale'; import { _ } from 'svelte-i18n'; + import { filterByVisibleCalendars } from '$lib/utils/eventFiltering'; import type { CalendarEvent } from '@calendar/shared'; @@ -194,17 +195,18 @@ // ============================================================================ // Event Handlers // ============================================================================ - function getEventsForDay(day: Date) { - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - return eventsStore - .getEventsForDay(day) - .filter((e) => visibleCalendarIds.has(e.calendarId)) - .slice(0, 3); // Max 3 events shown + function getEventsForDay(day: Date): CalendarEvent[] { + return filterByVisibleCalendars( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars + ).slice(0, 3); // Max 3 events shown } - function getAllEventsForDay(day: Date) { - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - return eventsStore.getEventsForDay(day).filter((e) => visibleCalendarIds.has(e.calendarId)); + function getAllEventsForDay(day: Date): CalendarEvent[] { + return filterByVisibleCalendars( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars + ); } function handleDayClick(day: Date, e: MouseEvent) { 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 ee0a54037..c033a8a0a 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/MultiDayView.svelte @@ -11,6 +11,13 @@ useCurrentTimeIndicator, } from '$lib/composables/useVisibleHours.svelte'; import { toDate } from '$lib/utils/eventDateHelpers'; + import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; + import { + getVisibleTimedEvents, + getVisibleAllDayEvents, + getVisibleOverflowEvents, + type OverflowEvents, + } from '$lib/utils/eventFiltering'; import TaskBlock from './TaskBlock.svelte'; import { goto } from '$app/navigation'; import { @@ -27,9 +34,9 @@ import { de, enUS, fr, es, it } from 'date-fns/locale'; import { locale } from 'svelte-i18n'; - // Constants - const HOUR_HEIGHT = 60; // px - should match CSS --hour-height - const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals + // Use shared constants + const HOUR_HEIGHT = HOUR_HEIGHT_PX; + const MINUTES_PER_SLOT = SNAP_INTERVAL_MINUTES; import type { CalendarEvent } from '@calendar/shared'; @@ -119,75 +126,35 @@ // Reference to the days container for position calculations let daysContainerEl: HTMLDivElement; - function getEventsForDay(day: Date) { - // Filter by visible calendars first - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - const allEvents = eventsStore - .getEventsForDay(day) - .filter((e) => !e.isAllDay && visibleCalendarIds.has(e.calendarId)); - - // If hour filtering is enabled, only show events that overlap with visible range - if (settingsStore.filterHoursEnabled) { - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - return allEvents.filter((event) => { - const start = toDate(event.startTime); - const end = toDate(event.endTime); - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event overlaps with visible range - return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes; - }); - } - - return allEvents; + function getEventsForDay(day: Date): CalendarEvent[] { + return getVisibleTimedEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars, + { + filterHoursEnabled: settingsStore.filterHoursEnabled, + dayStartHour: settingsStore.dayStartHour, + dayEndHour: settingsStore.dayEndHour, + } + ); } - // Get events that are completely outside the visible time range - function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } { + function getOverflowEventsForDay(day: Date): OverflowEvents { if (!settingsStore.filterHoursEnabled) { return { before: [], after: [] }; } - - // Filter by visible calendars - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - const allEvents = eventsStore - .getEventsForDay(day) - .filter((e) => !e.isAllDay && visibleCalendarIds.has(e.calendarId)); - const before: CalendarEvent[] = []; - const after: CalendarEvent[] = []; - - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - for (const event of allEvents) { - const start = toDate(event.startTime); - const end = toDate(event.endTime); - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event ends before visible range starts - if (eventEndMinutes <= visibleStartMinutes) { - before.push(event); - } - // Event starts after visible range ends - else if (eventStartMinutes >= visibleEndMinutes) { - after.push(event); - } - } - - return { before, after }; + return getVisibleOverflowEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars, + settingsStore.dayStartHour, + settingsStore.dayEndHour + ); } - function getAllDayEventsForDay(day: Date) { - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - return eventsStore - .getEventsForDay(day) - .filter((e) => e.isAllDay && visibleCalendarIds.has(e.calendarId)); + function getAllDayEventsForDay(day: Date): CalendarEvent[] { + return getVisibleAllDayEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars + ); } // Get display mode for an event (per-event override takes precedence over global setting) 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 0ee5a07cb..b25c9765a 100644 --- a/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte +++ b/apps/calendar/apps/web/src/lib/components/calendar/WeekView.svelte @@ -13,6 +13,13 @@ useCurrentTimeIndicator, } from '$lib/composables/useVisibleHours.svelte'; import { toDate } from '$lib/utils/eventDateHelpers'; + import { HOUR_HEIGHT_PX, SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants'; + import { + getVisibleTimedEvents, + getVisibleAllDayEvents, + getVisibleOverflowEvents, + type OverflowEvents, + } from '$lib/utils/eventFiltering'; import TaskBlock from './TaskBlock.svelte'; import EventContextMenu from '$lib/components/event/EventContextMenu.svelte'; import { goto } from '$app/navigation'; @@ -41,9 +48,9 @@ let { onQuickCreate, onEventClick, onTaskClick }: Props = $props(); - // Constants - const HOUR_HEIGHT = 60; // px - should match CSS --hour-height - const MINUTES_PER_SLOT = 15; // Snap to 15-minute intervals + // Use shared constants + const HOUR_HEIGHT = HOUR_HEIGHT_PX; + const MINUTES_PER_SLOT = SNAP_INTERVAL_MINUTES; // Get date-fns locale based on current app locale const dateLocales = { de, en: enUS, fr, es, it }; @@ -147,75 +154,35 @@ selectedBirthday = null; } - function getEventsForDay(day: Date) { - // Filter by visible calendars first - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - const allEvents = eventsStore - .getEventsForDay(day) - .filter((e) => !e.isAllDay && visibleCalendarIds.has(e.calendarId)); - - // If hour filtering is enabled, only show events that overlap with visible range - if (settingsStore.filterHoursEnabled) { - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - return allEvents.filter((event) => { - const start = toDate(event.startTime); - const end = toDate(event.endTime); - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event overlaps with visible range - return eventStartMinutes < visibleEndMinutes && eventEndMinutes > visibleStartMinutes; - }); - } - - return allEvents; + function getEventsForDay(day: Date): CalendarEvent[] { + return getVisibleTimedEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars, + { + filterHoursEnabled: settingsStore.filterHoursEnabled, + dayStartHour: settingsStore.dayStartHour, + dayEndHour: settingsStore.dayEndHour, + } + ); } - // Get events that are completely outside the visible time range - function getOverflowEventsForDay(day: Date): { before: CalendarEvent[]; after: CalendarEvent[] } { + function getOverflowEventsForDay(day: Date): OverflowEvents { if (!settingsStore.filterHoursEnabled) { return { before: [], after: [] }; } - - // Filter by visible calendars - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - const allEvents = eventsStore - .getEventsForDay(day) - .filter((e) => !e.isAllDay && visibleCalendarIds.has(e.calendarId)); - const before: CalendarEvent[] = []; - const after: CalendarEvent[] = []; - - const visibleStartMinutes = settingsStore.dayStartHour * 60; - const visibleEndMinutes = settingsStore.dayEndHour * 60; - - for (const event of allEvents) { - const start = toDate(event.startTime); - const end = toDate(event.endTime); - - const eventStartMinutes = start.getHours() * 60 + start.getMinutes(); - const eventEndMinutes = end.getHours() * 60 + end.getMinutes(); - - // Event ends before visible range starts - if (eventEndMinutes <= visibleStartMinutes) { - before.push(event); - } - // Event starts after visible range ends - else if (eventStartMinutes >= visibleEndMinutes) { - after.push(event); - } - } - - return { before, after }; + return getVisibleOverflowEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars, + settingsStore.dayStartHour, + settingsStore.dayEndHour + ); } - function getAllDayEventsForDay(day: Date) { - const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id)); - return eventsStore - .getEventsForDay(day) - .filter((e) => e.isAllDay && visibleCalendarIds.has(e.calendarId)); + function getAllDayEventsForDay(day: Date): CalendarEvent[] { + return getVisibleAllDayEvents( + eventsStore.getEventsForDay(day), + calendarsStore.visibleCalendars + ); } // Get display mode for an event (per-event override takes precedence over global setting) diff --git a/apps/calendar/apps/web/src/lib/utils/calendarConstants.ts b/apps/calendar/apps/web/src/lib/utils/calendarConstants.ts new file mode 100644 index 000000000..7d91e2dfa --- /dev/null +++ b/apps/calendar/apps/web/src/lib/utils/calendarConstants.ts @@ -0,0 +1,60 @@ +/** + * Shared calendar constants + * Single source of truth for magic numbers used across calendar views + */ + +/** + * Height of one hour in pixels (should match CSS --hour-height variable) + */ +export const HOUR_HEIGHT_PX = 60; + +/** + * Snap interval for drag/drop and resize operations in minutes + */ +export const SNAP_INTERVAL_MINUTES = 15; + +/** + * Default event duration in minutes when creating quick events + */ +export const DEFAULT_EVENT_DURATION_MINUTES = 60; + +/** + * Minimum event height as percentage of visible hours + */ +export const MIN_EVENT_HEIGHT_PERCENT = 1.5; + +/** + * Maximum number of event dots to show in month view cells + */ +export const MAX_EVENT_DOTS = 5; + +/** + * Days buffer for infinite scroll in date strip + */ +export const DATE_STRIP_BUFFER_DAYS = 60; + +/** + * Default visible hours range + */ +export const DEFAULT_DAY_START_HOUR = 0; +export const DEFAULT_DAY_END_HOUR = 24; + +/** + * Week starts on (0 = Sunday, 1 = Monday) + */ +export const DEFAULT_WEEK_STARTS_ON = 1; + +/** + * All constants as a single object for convenient destructuring + */ +export const CALENDAR_CONSTANTS = { + HOUR_HEIGHT_PX, + SNAP_INTERVAL_MINUTES, + DEFAULT_EVENT_DURATION_MINUTES, + MIN_EVENT_HEIGHT_PERCENT, + MAX_EVENT_DOTS, + DATE_STRIP_BUFFER_DAYS, + DEFAULT_DAY_START_HOUR, + DEFAULT_DAY_END_HOUR, + DEFAULT_WEEK_STARTS_ON, +} as const; diff --git a/apps/calendar/apps/web/src/lib/utils/eventFiltering.ts b/apps/calendar/apps/web/src/lib/utils/eventFiltering.ts new file mode 100644 index 000000000..5b0e4dcad --- /dev/null +++ b/apps/calendar/apps/web/src/lib/utils/eventFiltering.ts @@ -0,0 +1,165 @@ +/** + * Event Filtering Utilities + * Reusable functions for filtering calendar events by visibility, time range, etc. + */ + +import type { CalendarEvent } from '@calendar/shared'; +import type { Calendar } from '@calendar/shared'; +import { toDate } from './eventDateHelpers'; + +/** + * Create a Set of visible calendar IDs for efficient lookup + */ +export function getVisibleCalendarIds(visibleCalendars: Calendar[]): Set { + return new Set(visibleCalendars.map((c) => c.id)); +} + +/** + * Filter events to only include those from visible calendars + */ +export function filterByVisibleCalendars( + events: CalendarEvent[], + visibleCalendars: Calendar[] +): CalendarEvent[] { + const visibleIds = getVisibleCalendarIds(visibleCalendars); + return events.filter((e) => visibleIds.has(e.calendarId)); +} + +/** + * Filter events to only include timed (non-all-day) events + */ +export function filterTimedEvents(events: CalendarEvent[]): CalendarEvent[] { + return events.filter((e) => !e.isAllDay); +} + +/** + * Filter events to only include all-day events + */ +export function filterAllDayEvents(events: CalendarEvent[]): CalendarEvent[] { + return events.filter((e) => e.isAllDay); +} + +/** + * Get event time in minutes from midnight + */ +export function getEventMinutes(event: CalendarEvent): { start: number; end: number } { + const start = toDate(event.startTime); + const end = toDate(event.endTime); + return { + start: start.getHours() * 60 + start.getMinutes(), + end: end.getHours() * 60 + end.getMinutes(), + }; +} + +/** + * Check if an event overlaps with a given time range (in minutes from midnight) + */ +export function eventOverlapsTimeRange( + event: CalendarEvent, + startMinutes: number, + endMinutes: number +): boolean { + const { start: eventStart, end: eventEnd } = getEventMinutes(event); + return eventStart < endMinutes && eventEnd > startMinutes; +} + +/** + * Filter timed events that overlap with a visible hour range + */ +export function filterByHourRange( + events: CalendarEvent[], + dayStartHour: number, + dayEndHour: number +): CalendarEvent[] { + const startMinutes = dayStartHour * 60; + const endMinutes = dayEndHour * 60; + return events.filter((event) => eventOverlapsTimeRange(event, startMinutes, endMinutes)); +} + +/** + * Result type for overflow events + */ +export interface OverflowEvents { + before: CalendarEvent[]; + after: CalendarEvent[]; +} + +/** + * Get events that are outside the visible hour range + * Returns events that end before the visible range starts (before) + * and events that start after the visible range ends (after) + */ +export function getOverflowEvents( + events: CalendarEvent[], + dayStartHour: number, + dayEndHour: number +): OverflowEvents { + const startMinutes = dayStartHour * 60; + const endMinutes = dayEndHour * 60; + + const before: CalendarEvent[] = []; + const after: CalendarEvent[] = []; + + for (const event of events) { + const { start: eventStart, end: eventEnd } = getEventMinutes(event); + + if (eventEnd <= startMinutes) { + before.push(event); + } else if (eventStart >= endMinutes) { + after.push(event); + } + } + + return { before, after }; +} + +/** + * Combined filter: Get visible timed events for a day with optional hour filtering + */ +export function getVisibleTimedEvents( + events: CalendarEvent[], + visibleCalendars: Calendar[], + options?: { + filterHoursEnabled?: boolean; + dayStartHour?: number; + dayEndHour?: number; + } +): CalendarEvent[] { + let filtered = filterByVisibleCalendars(events, visibleCalendars); + filtered = filterTimedEvents(filtered); + + if ( + options?.filterHoursEnabled && + options.dayStartHour !== undefined && + options.dayEndHour !== undefined + ) { + filtered = filterByHourRange(filtered, options.dayStartHour, options.dayEndHour); + } + + return filtered; +} + +/** + * Combined filter: Get visible all-day events for a day + */ +export function getVisibleAllDayEvents( + events: CalendarEvent[], + visibleCalendars: Calendar[] +): CalendarEvent[] { + let filtered = filterByVisibleCalendars(events, visibleCalendars); + return filterAllDayEvents(filtered); +} + +/** + * Combined filter: Get overflow events for visible calendars + */ +export function getVisibleOverflowEvents( + events: CalendarEvent[], + visibleCalendars: Calendar[], + dayStartHour: number, + dayEndHour: number +): OverflowEvents { + let filtered = filterByVisibleCalendars(events, visibleCalendars); + filtered = filterTimedEvents(filtered); + return getOverflowEvents(filtered, dayStartHour, dayEndHour); +}