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 @@
+
+
+
+
+
+
+
+
+
+
Calendar Settings
+
+
+ Snap Interval
+
+
+
+
+ Start Time
+
+
+
+
+ End Time
+
+
+
+
+ Grid Scale
+
+
+
+
+
+
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,