diff --git a/web/src/lib/calendar/__tests__/eventDrag.test.ts b/web/src/lib/calendar/__tests__/eventDrag.test.ts index d8efd5b..9f5e553 100644 --- a/web/src/lib/calendar/__tests__/eventDrag.test.ts +++ b/web/src/lib/calendar/__tests__/eventDrag.test.ts @@ -10,6 +10,8 @@ import { snap, movePatch, moveByDaysPatch, + moveAcrossPatch, + columnsMoved, dayDelta, resizePatch, SNAP_MINUTES, @@ -180,10 +182,22 @@ describe("the patch a drag sends, computed in the event's own frame", () => { expect(resizePatch(3600, -600)).toEqual({ duration: "PT15M" }); }); + it("moves by days and minutes together, as a week-grid drag does", () => { + expect(moveAcrossPatch("2026-09-04T14:00:00", 2, 90)).toEqual({ start: "2026-09-06T15:30:00" }); + expect(moveAcrossPatch("2026-09-04T14:00:00", -1, 0)).toEqual({ start: "2026-09-03T14:00:00" }); + expect(moveAcrossPatch("2026-09-04T14:00:00", 0, -30)).toEqual({ start: "2026-09-04T13:30:00" }); + }); + + it("adds the days as days, so a clock change does not move the hour", () => { + // US clocks go back on 1 November 2026; 14:00 stays 14:00 across it. + expect(moveAcrossPatch("2026-10-31T14:00:00", 2, 0)).toEqual({ start: "2026-11-02T14:00:00" }); + }); + it("says nothing at all about a start it cannot read", () => { expect(movePatch("not a date", 30)).toEqual({}); expect(moveByDaysPatch("", 3)).toEqual({}); expect(moveByDaysPatch("2026-09-04T14:00:00", Number.NaN)).toEqual({}); + expect(moveAcrossPatch("not a date", 1, 30)).toEqual({}); }); }); @@ -228,3 +242,23 @@ describe("pixelsToMinutes", () => { expect(SNAP_MINUTES).toBe(15); }); }); + +describe("columnsMoved", () => { + it("counts whole columns, to the nearest", () => { + expect(columnsMoved(100, 100, 2, 7)).toBe(1); + expect(columnsMoved(140, 100, 2, 7)).toBe(1); + expect(columnsMoved(160, 100, 2, 7)).toBe(2); + expect(columnsMoved(-40, 100, 2, 7)).toBe(0); + expect(columnsMoved(-160, 100, 3, 7)).toBe(-2); + }); + + it("stops at the edges of the week instead of wrapping", () => { + expect(columnsMoved(-900, 100, 2, 7)).toBe(-2); + expect(columnsMoved(900, 100, 2, 7)).toBe(4); + }); + + it("never moves sideways in a one-day grid or before it is measured", () => { + expect(columnsMoved(500, 100, 0, 1)).toBe(0); + expect(columnsMoved(500, 0, 0, 7)).toBe(0); + }); +}); diff --git a/web/src/lib/calendar/eventDrag.ts b/web/src/lib/calendar/eventDrag.ts index 54a77f8..037fac5 100644 --- a/web/src/lib/calendar/eventDrag.ts +++ b/web/src/lib/calendar/eventDrag.ts @@ -142,6 +142,35 @@ export function moveByDaysPatch(storedStart: string, days: number): DragPatch { return { start: formatStored(moved) }; } +/** + * Moved by whole days and by minutes at once -- the week grid, where a drag + * goes sideways to another day and up or down to another hour in the same + * gesture. + * + * The days go first and as days, for the reason moveByDaysPatch gives: a day + * added to a wall clock keeps its time of day across a clock change, where + * 1440 minutes would not. + */ +export function moveAcrossPatch(storedStart: string, days: number, deltaMinutes: number): DragPatch { + const byDays = days ? moveByDaysPatch(storedStart, days).start : storedStart; + if (!byDays) return {}; + return snap(deltaMinutes) ? movePatch(byDays, deltaMinutes) : { start: byDays }; +} + +/** + * How many columns sideways the pointer has gone, kept inside the grid. + * + * Counted from the column the drag began in, so an event that crosses + * midnight moves by the same amount whichever of its two halves was picked + * up. Past the first or last column it stops at the edge rather than + * wrapping: the week on screen is the only week a drag can reach. + */ +export function columnsMoved(deltaPixels: number, columnWidth: number, fromIndex: number, columnCount: number): number { + if (!columnWidth || columnCount < 2) return 0; + const moved = Math.round(deltaPixels / columnWidth) || 0; // never -0 + return Math.max(-fromIndex, Math.min(columnCount - 1 - fromIndex, moved)); +} + /** Whole days between two local dates, ignoring the time of day on each. */ export function dayDelta(from: Date, to: Date): number { const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime(); diff --git a/web/src/views/calendar/CalendarView.tsx b/web/src/views/calendar/CalendarView.tsx index 4be9910..36ebecb 100644 --- a/web/src/views/calendar/CalendarView.tsx +++ b/web/src/views/calendar/CalendarView.tsx @@ -15,7 +15,7 @@ import type { Anchor } from "@/ui/popover"; import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu"; import { toast } from "@/ui/toast"; import { askEditScope, droppedMessage, runScoped } from "./scope"; -import { canDragEvent, dayDelta, moveByDaysPatch, movePatch, pixelsToMinutes, resizePatch, snap, type DragPatch } from "@/lib/calendar/eventDrag"; +import { canDragEvent, columnsMoved, dayDelta, moveAcrossPatch, moveByDaysPatch, pixelsToMinutes, resizePatch, snap, type DragPatch } from "@/lib/calendar/eventDrag"; import { t as translate } from "@/lib/i18n"; type View = "month" | "week" | "day" | "agenda"; @@ -237,16 +237,47 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7)); const dow = weeks[0]!.map((d) => formatWeekday(d)); const maxPer = 4; + const chipDrag = useChipDrag(".month-cell", onDragCommit); + + return ( +
+
{dow.map((d) =>
{d}
)}
+ {weeks.map((days, wi) => ( +
+ {days.map((d) => { + const dayEnd = addDays(d, 1); + const evs = instances.filter((i) => i.start < dayEnd && i.end > d); + const shown = evs.slice(0, maxPer); + return ( +
onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}> + { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()} + {shown.map((i) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} onDragStart={canDragEvent(i.event, i.calendar) ? (e) => chipDrag.begin(i, d, e) : undefined} dragging={chipDrag.draggingKey === i.key} suppressClick={() => chipDrag.draggedRef.current} />)} + {evs.length > maxPer && { e.stopPropagation(); onDay(d); }}>{translate("+{n} more", { n: evs.length - maxPer })}} +
+ ); + })} +
+ ))} +
+ ); +} + +/* + * Dragging a chip to another day: the month grid, and the all-day row above + * the week grid. A cell there is a day and nothing finer, so the only question + * a drag asks is "which day". The cell under the pointer is found by asking + * the document rather than by tracking enter and leave on every cell: one + * question at the end beats bookkeeping throughout. + * + * The move is counted from the day the chip was picked up on, not from the + * event's first day, so a three-day event grabbed on its last day and dropped + * one cell to the right moves one day, not three. + */ +function useChipDrag(cellSelector: string, onDragCommit: (i: EventInstance, patch: DragPatch) => void) { const [draggingKey, setDraggingKey] = useState(null); const draggedRef = useRef(false); - /* - * A month cell is a day and nothing finer, so the only question a drag here - * asks is "which day". The cell under the pointer is found by asking the - * document rather than by tracking enter and leave on forty-two cells: one - * question at the end beats bookkeeping throughout. - */ - const beginChipDrag = (inst: EventInstance, e: React.PointerEvent) => { + const begin = (inst: EventInstance, grabbedOn: Date, e: React.PointerEvent) => { if (e.button !== 0 && e.pointerType === "mouse") return; if (!canDragEvent(inst.event, inst.calendar)) return; e.stopPropagation(); @@ -255,7 +286,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo el.setPointerCapture(e.pointerId); let landedOn: string | null = null; const onPointerMove = (ev: PointerEvent) => { - const cell = document.elementFromPoint(ev.clientX, ev.clientY)?.closest(".month-cell"); + const cell = document.elementFromPoint(ev.clientX, ev.clientY)?.closest(cellSelector); const date = cell?.dataset.date ?? null; if (date) landedOn = date; if (!draggedRef.current) draggedRef.current = true; @@ -277,8 +308,8 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo const target = parts && parts.length === 3 ? new Date(parts[0]!, parts[1]! - 1, parts[2]!) : null; // How far the hand moved it, in local days -- see moveByDaysPatch for // why the target date itself is the wrong thing to write. - if (target && !isSameDay(target, inst.start)) { - onDragCommit(inst, moveByDaysPatch(inst.event.start, dayDelta(inst.start, target))); + if (target && !isSameDay(target, grabbedOn)) { + onDragCommit(inst, moveByDaysPatch(inst.event.start, dayDelta(grabbedOn, target))); } window.setTimeout(() => (draggedRef.current = false), 0); }; @@ -287,27 +318,7 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo el.addEventListener("pointercancel", finish); }; - return ( -
-
{dow.map((d) =>
{d}
)}
- {weeks.map((days, wi) => ( -
- {days.map((d) => { - const dayEnd = addDays(d, 1); - const evs = instances.filter((i) => i.start < dayEnd && i.end > d); - const shown = evs.slice(0, maxPer); - return ( -
onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}> - { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()} - {shown.map((i) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} onDragStart={canDragEvent(i.event, i.calendar) ? (e) => beginChipDrag(i, e) : undefined} dragging={draggingKey === i.key} suppressClick={() => draggedRef.current} />)} - {evs.length > maxPer && { e.stopPropagation(); onDay(d); }}>{translate("+{n} more", { n: evs.length - maxPer })}} -
- ); - })} -
- ))} -
- ); + return { begin, draggingKey, draggedRef }; } function statusClass(i: EventInstance): string { @@ -358,24 +369,36 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa * new time, so the preview is one number and the commit is the same * arithmetic the tests cover. */ - const [moving, setMoving] = useState<{ key: string; deltaMin: number; mode: "move" | "resize" } | null>(null); + const [moving, setMoving] = useState<{ key: string; deltaMin: number; deltaDays: number; shiftPx: number; mode: "move" | "resize" } | null>(null); /* A drag ends with a pointerup, and a pointerup on the same element is also a click. Without this, letting go of a moved event opens its popover. */ const draggedRef = useRef(false); + const allDayDrag = useChipDrag(".ad-cell", onDragCommit); - const beginDrag = (inst: EventInstance, mode: "move" | "resize", e: React.PointerEvent) => { + /* + * A move goes sideways as well as up and down: across the columns to + * another day, keeping whatever hour it was dragged to. The block stays in + * its own column while it moves and is drawn shifted by whole columns, so + * the preview is two numbers and nothing is re-laid-out until it lands. + * A resize only ever changes the end, so it stays vertical. + */ + const beginDrag = (inst: EventInstance, mode: "move" | "resize", column: number, e: React.PointerEvent) => { if (e.button !== 0 && e.pointerType === "mouse") return; if (!canDragEvent(inst.event, inst.calendar)) return; e.stopPropagation(); e.preventDefault(); const el = e.currentTarget as HTMLElement; + const startX = e.clientX; const startY = e.clientY; + const colWidth = mode === "move" ? el.closest(".day-col")?.getBoundingClientRect().width ?? 0 : 0; let delta = 0; + let deltaDays = 0; el.setPointerCapture(e.pointerId); const onPointerMove = (ev: PointerEvent) => { delta = snap(pixelsToMinutes(ev.clientY - startY, HOUR_H)); - if (delta !== 0) draggedRef.current = true; - setMoving({ key: inst.key, deltaMin: delta, mode }); + deltaDays = columnsMoved(ev.clientX - startX, colWidth, column, days.length); + if (delta !== 0 || deltaDays !== 0) draggedRef.current = true; + setMoving({ key: inst.key, deltaMin: delta, deltaDays, shiftPx: deltaDays * colWidth, mode }); }; const finish = () => { el.removeEventListener("pointermove", onPointerMove); @@ -387,9 +410,9 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa /* already released, which is fine */ } setMoving(null); - if (delta !== 0) { + if (delta !== 0 || deltaDays !== 0) { const seconds = (inst.end.getTime() - inst.start.getTime()) / 1000; - onDragCommit(inst, mode === "move" ? movePatch(inst.event.start, delta) : resizePatch(seconds, delta)); + onDragCommit(inst, mode === "move" ? moveAcrossPatch(inst.event.start, deltaDays, delta) : resizePatch(seconds, delta)); } // Cleared after the click that follows this pointerup has been swallowed. window.setTimeout(() => (draggedRef.current = false), 0); @@ -431,8 +454,8 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
{translate("all-day")}
{days.map((d) => ( -
onCreate(d, addDays(d, 1), true)} onContextMenu={(e) => onSlotContext(d, addDays(d, 1), true, e)}> - {allDay(d).map((i) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)} +
onCreate(d, addDays(d, 1), true)} onContextMenu={(e) => onSlotContext(d, addDays(d, 1), true, e)}> + {allDay(d).map((i) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} onDragStart={canDragEvent(i.event, i.calendar) ? (e) => allDayDrag.begin(i, d, e) : undefined} dragging={allDayDrag.draggingKey === i.key} suppressClick={() => allDayDrag.draggedRef.current} />)}
))}
@@ -441,7 +464,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
{[...Array(24)].map((_, h) => h > 0 && {formatHourLabel(h)})}
- {days.map((d) => { + {days.map((d, column) => { const evs = layoutOverlaps(timed(d), d); const today = isToday(d); const nowTop = ((now.getHours() * 60 + now.getMinutes()) / 60) * HOUR_H; @@ -490,9 +513,10 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa height: Math.max(height + (moving?.key === inst.key && moving.mode === "resize" ? (moving.deltaMin / 60) * HOUR_H : 0), 18), left: `${left}%`, width: `calc(${width}% - 3px)`, + transform: moving?.key === inst.key && moving.shiftPx ? `translateX(${moving.shiftPx}px)` : undefined, background: color, }} - onPointerDown={(e) => beginDrag(inst, "move", e)} + onPointerDown={(e) => beginDrag(inst, "move", column, e)} onClick={(e) => { e.stopPropagation(); if (draggedRef.current) return; onEvent(inst, e.currentTarget); }} onContextMenu={(e) => onEventContext(inst, e)} title={inst.event.title ?? ""} @@ -501,7 +525,7 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa /* Its own element rather than an edge zone on the block, so a thumb has something to aim at and the move drag does not have to guess which one was meant. */ -
beginDrag(inst, "resize", e)} aria-hidden="true" /> +
beginDrag(inst, "resize", column, e)} aria-hidden="true" /> )}
{inst.event.title || "(untitled)"}
{height > 30 &&
{formatTime(inst.start)} – {formatTime(inst.end)}
}