From 2a0b8d31e6131b123377b9f50b1e9877ebbd28bd Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Mon, 23 Feb 2026 18:56:47 +0100 Subject: [PATCH] add calendar settings + custom visual snapping --- e2e/calendar-settings.spec.ts | 172 +++++++++++ .../FullCalendar/CalendarSettingsPopover.vue | 198 ++++++++++++ .../ui/src/FullCalendar/TimeEntryCalendar.vue | 287 +++++++++++++----- .../ui/src/FullCalendar/calendarSettings.ts | 6 + .../ui/src/FullCalendar/useVisualSnap.ts | 186 ++++++++++++ resources/js/packages/ui/src/index.ts | 3 + 6 files changed, 770 insertions(+), 82 deletions(-) create mode 100644 e2e/calendar-settings.spec.ts create mode 100644 resources/js/packages/ui/src/FullCalendar/CalendarSettingsPopover.vue create mode 100644 resources/js/packages/ui/src/FullCalendar/calendarSettings.ts create mode 100644 resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts diff --git a/e2e/calendar-settings.spec.ts b/e2e/calendar-settings.spec.ts new file mode 100644 index 00000000..e70db659 --- /dev/null +++ b/e2e/calendar-settings.spec.ts @@ -0,0 +1,172 @@ +import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { test } from '../playwright/fixtures'; +import { expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +async function goToCalendar(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); + await expect(page.locator('.fc')).toBeVisible(); +} + +async function openSettingsPopover(page: Page) { + await page.getByRole('button', { name: 'Calendar settings' }).click(); + await expect(page.getByText('Calendar Settings')).toBeVisible(); +} + +async function clearCalendarSettings(page: Page) { + await page.evaluate(() => localStorage.removeItem('solidtime:calendar-settings')); +} + +test.describe('Calendar Settings', () => { + test.beforeEach(async ({ page }) => { + await clearCalendarSettings(page); + }); + + test('settings popover shows all fields with correct defaults', async ({ page }) => { + await goToCalendar(page); + await openSettingsPopover(page); + + await expect(page.getByLabel('Snap Interval')).toContainText('15 min'); + await expect(page.getByLabel('Start Time')).toContainText('12:00 AM'); + await expect(page.getByLabel('End Time')).toContainText('12:00 AM (next)'); + await expect(page.getByLabel('Grid Scale')).toContainText('15 min'); + }); + + test('snap interval can be changed and persists across reload', async ({ page }) => { + await goToCalendar(page); + await openSettingsPopover(page); + + // Change snap interval to 30 min + await page.getByLabel('Snap Interval').click(); + await page.getByRole('option', { name: '30 min' }).click(); + await page.locator('.fc-toolbar-title').click(); + + // Verify localStorage was updated + const stored = await page.evaluate(() => + JSON.parse(localStorage.getItem('solidtime:calendar-settings') || '{}') + ); + expect(stored.snapMinutes).toBe(30); + + // Reload and verify persistence + await page.reload(); + await expect(page.locator('.fc')).toBeVisible(); + await openSettingsPopover(page); + await expect(page.getByLabel('Snap Interval')).toContainText('30 min'); + }); + + test('start time change is applied to calendar and rejects values >= end time', async ({ + page, + }) => { + await goToCalendar(page); + + // Verify 7 AM slot exists with default start (00:00) + await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).not.toHaveCount(0); + + await openSettingsPopover(page); + + // Set end time to 6 PM first + await page.getByLabel('End Time').click(); + await page.getByRole('option', { name: '6:00 PM' }).click(); + + // Change start time to 8 AM (valid) + await page.getByLabel('Start Time').click(); + await page.getByRole('option', { name: '8:00 AM' }).click(); + await page.locator('.fc-toolbar-title').click(); + + // Calendar should no longer show hours before 8 AM + await expect(page.locator('.fc-timegrid-slot[data-time="07:00:00"]')).toHaveCount(0); + await expect(page.locator('.fc-timegrid-slot[data-time="08:00:00"]')).not.toHaveCount(0); + + // Try to set start time to 6 PM (invalid: equals end time) + await openSettingsPopover(page); + await page.getByLabel('Start Time').click(); + await page.getByRole('option', { name: '6:00 PM' }).click(); + + // Should be rejected — start time stays at 8 AM + await expect(page.getByLabel('Start Time')).toContainText('8:00 AM'); + }); + + test('end time change is applied to calendar and rejects values <= start time', async ({ + page, + }) => { + await goToCalendar(page); + + // Verify 19:00 slot exists with default end (24:00) + await expect(page.locator('.fc-timegrid-slot[data-time="19:00:00"]')).not.toHaveCount(0); + + await openSettingsPopover(page); + + // Set start time to 8 AM first + await page.getByLabel('Start Time').click(); + await page.getByRole('option', { name: '8:00 AM' }).click(); + + // Change end time to 6 PM (valid) + await page.getByLabel('End Time').click(); + await page.getByRole('option', { name: '6:00 PM' }).click(); + await page.locator('.fc-toolbar-title').click(); + + // Calendar should no longer show hours at or after 6 PM + await expect(page.locator('.fc-timegrid-slot[data-time="18:00:00"]')).toHaveCount(0); + await expect(page.locator('.fc-timegrid-slot[data-time="17:00:00"]')).not.toHaveCount(0); + + // Try to set end time to 8 AM (invalid: equals start time) + await openSettingsPopover(page); + await page.getByLabel('End Time').click(); + await page.getByRole('option', { name: '8:00 AM' }).click(); + + // Should be rejected — end time stays at 6 PM + await expect(page.getByLabel('End Time')).toContainText('6:00 PM'); + }); + + test('grid scale affects number of calendar slots', async ({ page }) => { + await goToCalendar(page); + + // Count slots with default 15-min scale + const defaultSlotCount = await page.locator('.fc-timegrid-slot').count(); + + // Change to 30 min scale (should halve the slots) + await openSettingsPopover(page); + await page.getByLabel('Grid Scale').click(); + await page.getByRole('option', { name: '30 min' }).click(); + await page.locator('.fc-toolbar-title').click(); + + const largerSlotCount = await page.locator('.fc-timegrid-slot').count(); + expect(largerSlotCount).toBeLessThan(defaultSlotCount); + + // Change to 5 min scale (should have many more slots) + await openSettingsPopover(page); + await page.getByLabel('Grid Scale').click(); + await page.getByRole('option', { name: '5 min', exact: true }).click(); + await page.locator('.fc-toolbar-title').click(); + + const smallerSlotCount = await page.locator('.fc-timegrid-slot').count(); + expect(smallerSlotCount).toBeGreaterThan(defaultSlotCount); + }); + + test('all settings persist across navigation', async ({ page }) => { + await goToCalendar(page); + await openSettingsPopover(page); + + // Change every setting + await page.getByLabel('Snap Interval').click(); + await page.getByRole('option', { name: '5 min', exact: true }).click(); + await page.getByLabel('Start Time').click(); + await page.getByRole('option', { name: '6:00 AM' }).click(); + await page.getByLabel('End Time').click(); + await page.getByRole('option', { name: '10:00 PM' }).click(); + await page.getByLabel('Grid Scale').click(); + await page.getByRole('option', { name: '30 min' }).click(); + await page.locator('.fc-toolbar-title').click(); + + // Navigate away and back + await page.goto(PLAYWRIGHT_BASE_URL + '/time'); + await goToCalendar(page); + + // Verify all settings persisted + await openSettingsPopover(page); + await expect(page.getByLabel('Snap Interval')).toContainText('5 min'); + await expect(page.getByLabel('Start Time')).toContainText('6:00 AM'); + await expect(page.getByLabel('End Time')).toContainText('10:00 PM'); + await expect(page.getByLabel('Grid Scale')).toContainText('30 min'); + }); +}); diff --git a/resources/js/packages/ui/src/FullCalendar/CalendarSettingsPopover.vue b/resources/js/packages/ui/src/FullCalendar/CalendarSettingsPopover.vue new file mode 100644 index 00000000..7557a14b --- /dev/null +++ b/resources/js/packages/ui/src/FullCalendar/CalendarSettingsPopover.vue @@ -0,0 +1,198 @@ + + + diff --git a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue index 77d8004f..70b32620 100644 --- a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue +++ b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue @@ -15,13 +15,22 @@ import { onActivated, onUnmounted, } from 'vue'; +import { useLocalStorage } from '@vueuse/core'; import chroma from 'chroma-js'; import { useCssVariable } from '@/utils/useCssVariable'; -import { getDayJsInstance, getLocalizedDayJs } from '../utils/time'; +import { + getDayJsInstance, + getLocalizedDayJs, + formatHumanReadableDuration, + formatDuration, +} from '../utils/time'; import { getUserTimezone, getWeekStart } from '../utils/settings'; import { LoadingSpinner, TimeEntryCreateModal, TimeEntryEditModal } from '..'; import FullCalendarEventContent from './FullCalendarEventContent.vue'; import FullCalendarDayHeader from './FullCalendarDayHeader.vue'; +import CalendarSettingsPopover from './CalendarSettingsPopover.vue'; +import type { CalendarSettings } from './calendarSettings'; +import { useVisualSnap } from './useVisualSnap'; import activityStatusPlugin, { type ActivityPeriod, renderActivityStatusBoxes, @@ -81,6 +90,22 @@ const selectedTimeEntry = ref(null); const calendarRef = ref | null>(null); +// Calendar settings with localStorage persistence via VueUse +const calendarSettings = useLocalStorage( + 'solidtime:calendar-settings', + { + snapMinutes: 15, + startHour: 0, + endHour: 24, + slotMinutes: 15, + }, + { mergeDefaults: true } +); + +function onSettingsUpdate(newSettings: CalendarSettings) { + calendarSettings.value = newSettings; +} + // Reactive "now" for running time entry - updates every minute const currentTime = ref(getDayJsInstance()()); let currentTimeInterval: ReturnType | null = null; @@ -204,16 +229,19 @@ function emitDatesChange(arg: DatesSetArg) { } function handleDateSelect(arg: { start: Date; end: Date }) { - const startTime = getDayJsInstance()(arg.start.toISOString()) + stopVisualSnap(); + const snap = calendarSettings.value.snapMinutes; + const startLocal = getDayJsInstance()(arg.start.toISOString()) .utc() - .tz(getUserTimezone(), true) - .utc(); - const endTime = getDayJsInstance()(arg.end.toISOString()) - .utc() - .tz(getUserTimezone(), true) - .utc(); - newEventStart.value = startTime; - newEventEnd.value = endTime; + .tz(getUserTimezone(), true); + const endLocal = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true); + const snappedStart = snapToGrid(startLocal, snap); + let snappedEnd = snapToGrid(endLocal, snap); + if (!snappedEnd.isAfter(snappedStart)) { + snappedEnd = snappedStart.add(snap, 'minute'); + } + newEventStart.value = snappedStart.utc(); + newEventEnd.value = snappedEnd.utc(); showCreateTimeEntryModal.value = true; } @@ -227,90 +255,136 @@ function handleEventClick(arg: EventClickArg) { showEditTimeEntryModal.value = true; } +// Snap a dayjs time to the nearest snap interval boundary +function snapToGrid(time: Dayjs, snapMinutes: number): Dayjs { + const minutes = time.hour() * 60 + time.minute(); + const snapped = Math.round(minutes / snapMinutes) * snapMinutes; + return time.startOf('day').add(snapped, 'minute'); +} + +// --- Visual snap (composable) --- +const { + startDragSnap: startVisualDragSnap, + startResizeSnap: startVisualResizeSnap, + stop: stopVisualSnap, +} = useVisualSnap({ + calendarRef, + snapMinutes: () => calendarSettings.value.snapMinutes, + slotMinutes: () => calendarSettings.value.slotMinutes, + formatDuration: (seconds) => + formatHumanReadableDuration( + seconds, + organization?.value?.interval_format, + organization?.value?.number_format + ), +}); + async function handleEventDrop(arg: EventDropArg) { + stopVisualSnap(); const ext = arg.event.extendedProps as CalendarExtendedProps; const timeEntry = ext.timeEntry; if (!arg.event.start || !arg.event.end) return; + // Running entries have no end time — can't compute duration for drop + if (!timeEntry.end) return; + const snap = calendarSettings.value.snapMinutes; + const startLocal = getDayJsInstance()(arg.event.start.toISOString()) + .utc() + .tz(getUserTimezone(), true) + .second(0); + const snappedStart = snapToGrid(startLocal, snap); + const durationMs = getLocalizedDayJs(timeEntry.end).diff(getLocalizedDayJs(timeEntry.start)); + const snappedEnd = snappedStart.add(durationMs, 'millisecond'); + // Set FC event to snapped position immediately to avoid flash + arg.event.setDates(snappedStart.utc(true).toDate(), snappedEnd.utc(true).toDate()); const updatedTimeEntry = { ...timeEntry, - start: getDayJsInstance()(arg.event.start.toISOString()) - .utc() - .tz(getUserTimezone(), true) - .second(0) - .utc() - .format(), - end: getDayJsInstance()(arg.event.end.toISOString()) - .utc() - .tz(getUserTimezone(), true) - .second(0) - .utc() - .format(), + start: snappedStart.utc().format(), + end: snappedEnd.utc().format(), } as TimeEntry; await props.updateTimeEntry(updatedTimeEntry); emit('refresh'); } async function handleEventResize(arg: EventChangeArg) { + stopVisualSnap(); const ext = arg.event.extendedProps as CalendarExtendedProps; const timeEntry = ext.timeEntry; if (!arg.event.start || !arg.event.end) return; + const snap = calendarSettings.value.snapMinutes; + + const newStartLocal = getDayJsInstance()(arg.event.start.toISOString()) + .utc() + .tz(getUserTimezone(), true) + .second(0); + const newEndLocal = getDayJsInstance()(arg.event.end.toISOString()) + .utc() + .tz(getUserTimezone(), true) + .second(0); + const origStartLocal = getLocalizedDayJs(timeEntry.start).second(0); + + const startChanged = !newStartLocal.isSame(origStartLocal, 'minute'); + + // Snap only the changed edge once, reuse for both setDates and API update + const snappedStart = startChanged ? snapToGrid(newStartLocal, snap) : null; + const snappedEnd = !startChanged && !ext.isRunning ? snapToGrid(newEndLocal, snap) : null; + + // Set FC event to snapped position immediately to avoid flash. + // Use the original event date for the edge that wasn't resized. + if (snappedStart) { + arg.event.setDates(snappedStart.utc(true).toDate(), arg.oldEvent.end!); + } else if (snappedEnd) { + arg.event.setDates(arg.oldEvent.start!, snappedEnd.utc(true).toDate()); + } const updatedTimeEntry = { ...timeEntry, - start: getDayJsInstance()(arg.event.start.toISOString()) - .utc() - .tz(getUserTimezone(), true) - .second(0) - .utc() - .format(), - // Preserve null end for running entries - end: ext.isRunning - ? null - : getDayJsInstance()(arg.event.end.toISOString()) - .utc() - .tz(getUserTimezone(), true) - .second(0) - .utc() - .format(), + start: snappedStart ? snappedStart.utc().format() : timeEntry.start, + end: ext.isRunning ? null : snappedEnd ? snappedEnd.utc().format() : timeEntry.end, } as TimeEntry; await props.updateTimeEntry(updatedTimeEntry); emit('refresh'); } -const calendarOptions = computed(() => ({ - plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin], - initialView: 'timeGridWeek', - headerToolbar: { - left: 'prev,next today', - center: 'title', - right: 'timeGridWeek,timeGridDay', - }, - height: 'parent', - slotMinTime: '00:00:00', - slotMaxTime: '24:00:00', - slotDuration: '00:15:00', - slotLabelInterval: '01:00:00', - slotLabelFormat: getSlotLabelFormat(), - snapDuration: '00:01:00', - firstDay: getFirstDay(), - allDaySlot: false, - nowIndicator: true, - eventMinHeight: 1, - selectable: true, - selectMirror: true, - editable: true, - eventResizableFromStart: true, - eventDurationEditable: true, - timeZone: getUserTimezone(), - eventStartEditable: true, - select: handleDateSelect, - eventClick: handleEventClick, - eventDrop: handleEventDrop, - eventResize: handleEventResize, - datesSet: emitDatesChange, +const calendarOptions = computed(() => { + const s = calendarSettings.value; - events: events.value, - activityPeriods: props.activityPeriods || [], -})); + return { + plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin, activityStatusPlugin], + initialView: 'timeGridWeek', + headerToolbar: { + left: 'prev,next today', + center: 'title', + right: 'timeGridWeek,timeGridDay', + }, + height: 'parent', + slotMinTime: formatDuration(s.startHour * 3600), + slotMaxTime: formatDuration(s.endHour * 3600), + slotDuration: formatDuration(s.slotMinutes * 60), + slotLabelInterval: '01:00:00', + slotLabelFormat: getSlotLabelFormat(), + snapDuration: '00:01:00', + firstDay: getFirstDay(), + allDaySlot: false, + nowIndicator: true, + eventMinHeight: 1, + selectable: true, + selectMirror: true, + editable: true, + eventResizableFromStart: true, + eventDurationEditable: true, + timeZone: getUserTimezone(), + eventStartEditable: true, + select: handleDateSelect, + eventClick: handleEventClick, + eventDragStart: startVisualDragSnap, + eventDrop: handleEventDrop, + eventResizeStart: startVisualResizeSnap, + eventResize: handleEventResize, + datesSet: emitDatesChange, + + events: events.value, + activityPeriods: props.activityPeriods || [], + }; +}); watch(showCreateTimeEntryModal, (value) => { if (!value) { @@ -376,7 +450,6 @@ onActivated(() => { }); onUnmounted(() => { - // Clean up interval if (currentTimeInterval) { clearInterval(currentTimeInterval); currentTimeInterval = null; @@ -424,6 +497,11 @@ onUnmounted(() => { :clients="clients" :currency="currency" :can-create-project="canCreateProject" /> +
+ +
+ + diff --git a/resources/js/packages/ui/src/FullCalendar/calendarSettings.ts b/resources/js/packages/ui/src/FullCalendar/calendarSettings.ts new file mode 100644 index 00000000..6230d093 --- /dev/null +++ b/resources/js/packages/ui/src/FullCalendar/calendarSettings.ts @@ -0,0 +1,6 @@ +export interface CalendarSettings { + snapMinutes: number; + startHour: number; + endHour: number; + slotMinutes: number; +} diff --git a/resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts b/resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts new file mode 100644 index 00000000..08886496 --- /dev/null +++ b/resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts @@ -0,0 +1,186 @@ +import { onActivated, onDeactivated, onMounted, onUnmounted, type Ref } from 'vue'; +import type FullCalendar from '@fullcalendar/vue3'; + +interface VisualSnapOptions { + calendarRef: Ref | null>; + snapMinutes: () => number; + slotMinutes: () => number; + formatDuration: (durationSeconds: number) => string; +} + +export function useVisualSnap({ + calendarRef, + snapMinutes, + slotMinutes, + formatDuration, +}: VisualSnapOptions) { + let rafId: number | null = null; + + function getCalendarEl(): HTMLElement | null { + return (calendarRef.value?.$el as HTMLElement) ?? null; + } + + function getSnapPixels(): number { + const calendarEl = getCalendarEl(); + if (!calendarEl) return 25; + const slot = calendarEl.querySelector('.fc-timegrid-slot-lane') as HTMLElement; + if (!slot) return 25; + const slotHeightPx = slot.getBoundingClientRect().height; + return (snapMinutes() / slotMinutes()) * slotHeightPx; + } + + function findMirrorHarness(calendarEl: HTMLElement) { + const mirror = calendarEl.querySelector('.fc-event-mirror') as HTMLElement | null; + const harness = mirror?.closest('.fc-timegrid-event-harness') as HTMLElement | null; + return { mirror, harness }; + } + + function updateMirrorDurationLabel( + mirror: HTMLElement, + snappedTop: number, + snappedEnd: number, + snapPx: number + ) { + const snappedDurationMin = Math.round((snappedEnd - snappedTop) / snapPx) * snapMinutes(); + const durationText = formatDuration(snappedDurationMin * 60); + const durationEl = mirror.querySelector('.fc-event-main')?.querySelector('div:last-child'); + if (durationEl) { + durationEl.textContent = durationText; + } + } + + function startLoop(onFrame: (calendarEl: HTMLElement, snapPx: number) => void) { + const calendarEl = getCalendarEl(); + if (!calendarEl) return; + const snapPx = getSnapPixels(); + if (snapPx <= 0) return; + + const loop = () => { + onFrame(calendarEl, snapPx); + rafId = requestAnimationFrame(loop); + }; + rafId = requestAnimationFrame(loop); + } + + function stop() { + document.body.classList.remove('fc-resizing-active'); + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + } + + // --- Public snap starters --- + + function startSelectSnap() { + // Don't start if another snap loop is already running + if (rafId !== null) return; + startLoop((calendarEl, snapPx) => { + const { mirror, harness } = findMirrorHarness(calendarEl); + if (!harness || !mirror) return; + + const top = parseFloat(harness.style.top) || 0; + const endPos = -(parseFloat(harness.style.bottom) || 0); + const snappedTop = Math.round(top / snapPx) * snapPx; + const snappedEnd = Math.round(endPos / snapPx) * snapPx; + const clampedEnd = Math.max(snappedTop + snapPx, snappedEnd); + harness.style.top = snappedTop + 'px'; + harness.style.bottom = -clampedEnd + 'px'; + updateMirrorDurationLabel(mirror, snappedTop, clampedEnd, snapPx); + }); + } + + function startDragSnap() { + stop(); + startLoop((calendarEl, snapPx) => { + const { harness } = findMirrorHarness(calendarEl); + if (!harness) return; + + const top = parseFloat(harness.style.top) || 0; + const endPos = -(parseFloat(harness.style.bottom) || 0); + const height = endPos - top; + const snappedTop = Math.round(top / snapPx) * snapPx; + harness.style.top = snappedTop + 'px'; + harness.style.bottom = -(snappedTop + height) + 'px'; + }); + } + + function startResizeSnap() { + stop(); + document.body.classList.add('fc-resizing-active'); + + let initialTop: number | null = null; + let initialEnd: number | null = null; + let resizeEdge: 'top' | 'bottom' | null = null; + + startLoop((calendarEl, snapPx) => { + const { mirror, harness } = findMirrorHarness(calendarEl); + if (!harness) return; + + const top = parseFloat(harness.style.top) || 0; + const endPos = -(parseFloat(harness.style.bottom) || 0); + + // Detect which edge is being resized + if (initialTop === null) { + initialTop = top; + initialEnd = endPos; + } else if (resizeEdge === null) { + const topDelta = Math.abs(top - initialTop); + const endDelta = Math.abs(endPos - initialEnd!); + if (topDelta > 0.5) { + resizeEdge = 'top'; + } else if (endDelta > 0.5) { + resizeEdge = 'bottom'; + } + } + + if (resizeEdge === 'bottom') { + const snappedEnd = Math.round(endPos / snapPx) * snapPx; + const clampedEnd = Math.max(top + snapPx, snappedEnd); + harness.style.bottom = -clampedEnd + 'px'; + if (mirror) updateMirrorDurationLabel(mirror, top, clampedEnd, snapPx); + } else if (resizeEdge === 'top') { + const snappedTop = Math.round(top / snapPx) * snapPx; + const clampedTop = Math.min(endPos - snapPx, snappedTop); + harness.style.top = clampedTop + 'px'; + if (mirror) updateMirrorDurationLabel(mirror, clampedTop, endPos, snapPx); + } + }); + } + + // Pointerdown handler for starting select snap on timegrid background + function handleTimegridPointerDown(e: PointerEvent) { + const target = e.target as HTMLElement; + if (target.closest('.fc-event')) return; + startSelectSnap(); + } + + // Lifecycle: attach/detach pointerdown listener + function attachListener() { + const calendarEl = getCalendarEl(); + calendarEl?.addEventListener('pointerdown', handleTimegridPointerDown); + } + + function detachListener() { + const calendarEl = getCalendarEl(); + calendarEl?.removeEventListener('pointerdown', handleTimegridPointerDown); + } + + onMounted(attachListener); + onActivated(attachListener); + onDeactivated(() => { + stop(); + detachListener(); + }); + onUnmounted(() => { + stop(); + detachListener(); + }); + + return { + startSelectSnap, + startDragSnap, + startResizeSnap, + stop, + }; +} diff --git a/resources/js/packages/ui/src/index.ts b/resources/js/packages/ui/src/index.ts index c465887b..ed6712cc 100644 --- a/resources/js/packages/ui/src/index.ts +++ b/resources/js/packages/ui/src/index.ts @@ -36,6 +36,7 @@ import TimeEntryEditModal from './TimeEntry/TimeEntryEditModal.vue'; import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue'; import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue'; import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue'; +import CalendarSettingsPopover from './FullCalendar/CalendarSettingsPopover.vue'; import DateRangePicker from './Input/DateRangePicker.vue'; import TimezoneMismatchModal from './TimezoneMismatchModal.vue'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index'; @@ -59,6 +60,7 @@ import { } from './field/index'; export type { FieldVariants } from './field/index'; export type { ActivityPeriod } from './FullCalendar/idleStatusPlugin'; +export type { CalendarSettings } from './FullCalendar/calendarSettings'; export type { CommandPaletteCommand, CommandPaletteGroup, @@ -92,6 +94,7 @@ export { FullCalendarEventContent, FullCalendarDayHeader, TimeEntryCalendar, + CalendarSettingsPopover, DateRangePicker, TimezoneMismatchModal, Tooltip,