From 6503482b5593442d6d742bdf925f9c30c6ab0984 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:14:28 +0200 Subject: [PATCH] feat: support resizing events from the top edge --- components/calendar/calendar-day-view.tsx | 18 +++++- components/calendar/calendar-week-view.tsx | 18 +++++- hooks/use-time-grid-interactions.ts | 71 ++++++++++++++++------ 3 files changed, 82 insertions(+), 25 deletions(-) diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 3dd516d9..cec29b8d 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -247,9 +247,11 @@ export function CalendarDayView({ {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { const durMin = Math.max(15, endMinutes - startMinutes); - const top = (startMinutes / 60) * HOUR_HEIGHT; + const baseTop = (startMinutes / 60) * HOUR_HEIGHT; const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT); - const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; + const isResizing = resizeVisual?.eventId === ev.id; + const top = isResizing ? resizeVisual!.topPx : baseTop; + const height = isResizing ? resizeVisual!.heightPx : baseHeight; const calId = getPrimaryCalendarId(ev); const leftPct = (column / totalColumns) * 100; const widthPct = (1 / totalColumns) * 100; @@ -271,11 +273,21 @@ export function CalendarDayView({ onContextMenu={onContextMenuEvent} draggable /> +
handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)} + onPointerMove={handleResizePointerMove} + onPointerUp={handleResizePointerUp} + > +
+
handleResizePointerDown(ev.id, durMin, e)} + onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)} onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerUp} > diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 369e5fe4..6dc8ac61 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -385,9 +385,11 @@ export function CalendarWeekView({ {layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => { const durMin = Math.max(15, endMinutes - startMinutes); - const top = (startMinutes / 60) * HOUR_HEIGHT; + const baseTop = (startMinutes / 60) * HOUR_HEIGHT; const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT); - const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight; + const isResizing = resizeVisual?.eventId === ev.id; + const top = isResizing ? resizeVisual!.topPx : baseTop; + const height = isResizing ? resizeVisual!.heightPx : baseHeight; const calId = getPrimaryCalendarId(ev); const leftPct = (column / totalColumns) * 100; const widthPct = (1 / totalColumns) * 100; @@ -409,11 +411,21 @@ export function CalendarWeekView({ onContextMenu={onContextMenuEvent} draggable /> +
handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)} + onPointerMove={handleResizePointerMove} + onPointerUp={handleResizePointerUp} + > +
+
handleResizePointerDown(ev.id, durMin, e)} + onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)} onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerUp} > diff --git a/hooks/use-time-grid-interactions.ts b/hooks/use-time-grid-interactions.ts index e4d992ba..6a4f32ce 100644 --- a/hooks/use-time-grid-interactions.ts +++ b/hooks/use-time-grid-interactions.ts @@ -1,5 +1,5 @@ import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react"; -import { format } from "date-fns"; +import { format, parseISO } from "date-fns"; import { useAuthStore } from "@/stores/auth-store"; import { useCalendarStore } from "@/stores/calendar-store"; import { toast } from "@/stores/toast-store"; @@ -13,9 +13,13 @@ interface DragCreateState { endMinutes: number; } +export type ResizeEdge = "top" | "bottom"; + interface ResizeState { eventId: string; + topPx: number; heightPx: number; + startMinutes: number; durationMinutes: number; } @@ -134,43 +138,65 @@ export function useTimeGridInteractions({ // --- Resize --- const resizeRef = useRef<{ eventId: string; + edge: ResizeEdge; startY: number; + originalStartMinutes: number; originalDurationMinutes: number; - originalHeightPx: number; pointerId: number; } | null>(null); const [resizeVisual, setResizeVisual] = useState(null); + const computeResize = useCallback(( + ref: NonNullable, + clientY: number, + ): { startMinutes: number; durationMinutes: number } => { + const deltaY = clientY - ref.startY; + const deltaMinutes = Math.round((deltaY / hourHeight) * 60 / 15) * 15; + const originalEnd = ref.originalStartMinutes + ref.originalDurationMinutes; + + if (ref.edge === "bottom") { + const newDuration = Math.max(15, ref.originalDurationMinutes + deltaMinutes); + return { startMinutes: ref.originalStartMinutes, durationMinutes: newDuration }; + } + // Top edge: move start, keep end fixed. Clamp so duration stays >= 15 and start >= 0. + let newStart = ref.originalStartMinutes + deltaMinutes; + newStart = Math.max(0, Math.min(originalEnd - 15, newStart)); + return { startMinutes: newStart, durationMinutes: originalEnd - newStart }; + }, [hourHeight]); + const handleResizePointerDown = useCallback(( eventId: string, + edge: ResizeEdge, + originalStartMinutes: number, originalDurationMinutes: number, e: PointerEvent, ) => { e.stopPropagation(); e.preventDefault(); - const originalHeightPx = Math.max(20, (originalDurationMinutes / 60) * hourHeight); resizeRef.current = { eventId, + edge, startY: e.clientY, + originalStartMinutes, originalDurationMinutes, - originalHeightPx, pointerId: e.pointerId, }; (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, [hourHeight]); + }, []); const handleResizePointerMove = useCallback((e: PointerEvent) => { if (!resizeRef.current) return; - - const deltaY = e.clientY - resizeRef.current.startY; - const newHeightPx = Math.max(hourHeight / 4, resizeRef.current.originalHeightPx + deltaY); - const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15); - const snappedHeight = (newDurationMinutes / 60) * hourHeight; - - setResizeVisual({ eventId: resizeRef.current.eventId, heightPx: snappedHeight, durationMinutes: newDurationMinutes }); - }, [hourHeight]); + const { startMinutes, durationMinutes } = computeResize(resizeRef.current, e.clientY); + setResizeVisual({ + eventId: resizeRef.current.eventId, + topPx: (startMinutes / 60) * hourHeight, + heightPx: (durationMinutes / 60) * hourHeight, + startMinutes, + durationMinutes, + }); + }, [hourHeight, computeResize]); const handleResizePointerUp = useCallback(async (e: PointerEvent) => { const resize = resizeRef.current; @@ -183,11 +209,11 @@ export function useTimeGridInteractions({ try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ } - const deltaY = e.clientY - resize.startY; - const newHeightPx = Math.max(hourHeight / 4, resize.originalHeightPx + deltaY); - const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15); + const { startMinutes: newStartMinutes, durationMinutes: newDurationMinutes } = computeResize(resize, e.clientY); - if (newDurationMinutes === resize.originalDurationMinutes) { + const startChanged = newStartMinutes !== resize.originalStartMinutes; + const durationChanged = newDurationMinutes !== resize.originalDurationMinutes; + if (!startChanged && !durationChanged) { setResizeVisual(null); return; } @@ -209,14 +235,21 @@ export function useTimeGridInteractions({ try { const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId); const hasParticipants = event?.participants && Object.keys(event.participants).length > 0; - await useCalendarStore.getState().updateEvent(client, resize.eventId, { duration: dur }, hasParticipants || undefined); + const updates: { start?: string; duration: string } = { duration: dur }; + if (startChanged && event?.start) { + // Shift the event's floating `start` wall-clock by the delta (preserves event.timeZone). + const deltaMinutes = newStartMinutes - resize.originalStartMinutes; + const shifted = new Date(parseISO(event.start).getTime() + deltaMinutes * 60000); + updates.start = format(shifted, "yyyy-MM-dd'T'HH:mm:ss"); + } + await useCalendarStore.getState().updateEvent(client, resize.eventId, updates, hasParticipants || undefined); } catch (error) { debug.error("Failed to resize event:", resize.eventId, error); toast.error(errorMessages.resize); } finally { setResizeVisual(null); } - }, [hourHeight, errorMessages.resize]); + }, [computeResize, errorMessages.resize]); // --- Click / Double-click / Quick-create --- const clickTimerRef = useRef | null>(null);