diff --git a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue index 70b32620..133f6ff7 100644 --- a/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue +++ b/resources/js/packages/ui/src/FullCalendar/TimeEntryCalendar.vue @@ -235,8 +235,8 @@ function handleDateSelect(arg: { start: Date; end: Date }) { .utc() .tz(getUserTimezone(), true); const endLocal = getDayJsInstance()(arg.end.toISOString()).utc().tz(getUserTimezone(), true); - const snappedStart = snapToGrid(startLocal, snap); - let snappedEnd = snapToGrid(endLocal, snap); + const snappedStart = snapStartToGrid(startLocal, snap); + let snappedEnd = snapEndToGrid(endLocal, snap); if (!snappedEnd.isAfter(snappedStart)) { snappedEnd = snappedStart.add(snap, 'minute'); } @@ -255,10 +255,17 @@ function handleEventClick(arg: EventClickArg) { showEditTimeEntryModal.value = true; } -// Snap a dayjs time to the nearest snap interval boundary -function snapToGrid(time: Dayjs, snapMinutes: number): Dayjs { +// Snap a dayjs time down to the previous snap boundary (for start times) +function snapStartToGrid(time: Dayjs, snapMinutes: number): Dayjs { const minutes = time.hour() * 60 + time.minute(); - const snapped = Math.round(minutes / snapMinutes) * snapMinutes; + const snapped = Math.floor(minutes / snapMinutes) * snapMinutes; + return time.startOf('day').add(snapped, 'minute'); +} + +// Snap a dayjs time up to the next snap boundary (for end times) +function snapEndToGrid(time: Dayjs, snapMinutes: number): Dayjs { + const minutes = time.hour() * 60 + time.minute(); + const snapped = Math.ceil(minutes / snapMinutes) * snapMinutes; return time.startOf('day').add(snapped, 'minute'); } @@ -291,7 +298,7 @@ async function handleEventDrop(arg: EventDropArg) { .utc() .tz(getUserTimezone(), true) .second(0); - const snappedStart = snapToGrid(startLocal, snap); + const snappedStart = snapStartToGrid(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 @@ -325,8 +332,8 @@ async function handleEventResize(arg: EventChangeArg) { 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; + const snappedStart = startChanged ? snapStartToGrid(newStartLocal, snap) : null; + const snappedEnd = !startChanged && !ext.isRunning ? snapEndToGrid(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. @@ -747,6 +754,10 @@ onUnmounted(() => { border: 1px solid var(--primary); } +.fullcalendar :deep(.fc-event-mirror) { + pointer-events: none; +} + .fullcalendar :deep(.fc-scrollgrid) { border: 1px solid var(--border); border-left: 1px solid transparent; diff --git a/resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts b/resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts index 08886496..68f57101 100644 --- a/resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts +++ b/resources/js/packages/ui/src/FullCalendar/useVisualSnap.ts @@ -32,6 +32,9 @@ export function useVisualSnap({ 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; + if (harness) { + harness.style.pointerEvents = 'none'; + } return { mirror, harness }; } @@ -81,8 +84,8 @@ export function useVisualSnap({ 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 snappedTop = Math.floor(top / snapPx) * snapPx; + const snappedEnd = Math.ceil(endPos / snapPx) * snapPx; const clampedEnd = Math.max(snappedTop + snapPx, snappedEnd); harness.style.top = snappedTop + 'px'; harness.style.bottom = -clampedEnd + 'px'; @@ -99,7 +102,7 @@ export function useVisualSnap({ 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; + const snappedTop = Math.floor(top / snapPx) * snapPx; harness.style.top = snappedTop + 'px'; harness.style.bottom = -(snappedTop + height) + 'px'; }); @@ -135,12 +138,12 @@ export function useVisualSnap({ } if (resizeEdge === 'bottom') { - const snappedEnd = Math.round(endPos / snapPx) * snapPx; + const snappedEnd = Math.ceil(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 snappedTop = Math.floor(top / snapPx) * snapPx; const clampedTop = Math.min(endPos - snapPx, snappedTop); harness.style.top = clampedTop + 'px'; if (mirror) updateMirrorDurationLabel(mirror, clampedTop, endPos, snapPx);