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, 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);