From a30f96f76b000f41ad6fef6387bd797cff34d54a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 2 Sep 2026 00:40:03 -0700 Subject: [PATCH] Drag an event to move it, and its edge to resize it The calendar could only be edited through the editor, so moving a meeting half an hour meant opening a dialog, changing two fields and saving. Every other surface a finger or a pointer drives already answers to a drag. In the day and week grids an event moves by dragging it and changes length by dragging its bottom edge, snapping to fifteen minutes. In the month grid it moves to another day and keeps the time it had, because a month cell is a day and nothing finer -- changing the hour as well would answer a question nobody asked. It goes through the same path a menu edit takes. A recurring event is asked which dates it means, and the answer runs through runScoped, so a date the server will only change as part of a whole series offers that rather than failing. Three things do not offer a drag, and the reasons are checked in one place so no grid has to remember all three: a read-only calendar, an event with no calendar, and a birthday -- which is derived from a contact and has nothing on the server to move. The reserved classes the swipe gesture was told to keep out of are exactly the ones that are draggable here, which is what that reservation was for. Invitations are not sent. A drag is a scheduling gesture, and mailing every guest on each nudge of a block is not what the hand was asking for; a change that should go out with notice goes through the editor. The new time is computed in the event's own frame rather than through an instant. Working it out from the reader's local hours and then re-expressing it in the event's zone converts twice, and the two do not cancel: caught in the browser, where an event moved two hours the first time it was dragged in the month grid and then sat still, because after that its stored time and the reader's agreed. Parsing the stored string into its parts and adding minutes to those touches no zone at all, and a resize sends only a duration, so the question does not arise there either. --- FEATURES.md | 20 ++- web/src/lib/__tests__/eventDrag.test.ts | 205 ++++++++++++++++++++++++ web/src/lib/eventDrag.ts | 163 +++++++++++++++++++ web/src/styles/app.css | 8 + web/src/views/calendar/CalendarView.tsx | 166 +++++++++++++++++-- 5 files changed, 551 insertions(+), 11 deletions(-) create mode 100644 web/src/lib/__tests__/eventDrag.test.ts create mode 100644 web/src/lib/eventDrag.ts diff --git a/FEATURES.md b/FEATURES.md index 078005a..05608b3 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -638,7 +638,25 @@ work: *This and future* is not offered: the server refuses an occurrence that belongs to such a change, and where it does, ihasmail says so and offers the series. -Events are edited in the editor rather than dragged around the grid. +**Events are dragged.** In the day and week grids an event moves by dragging +it and changes length by dragging its bottom edge, both snapping to fifteen +minutes; in the month grid it moves to another day and keeps the time it had. +The editor is still there and still does everything a drag cannot. + +- **A recurring event asks which dates it means**, the same question the menu + asks, and goes through the same path — so a date the server will only change + as part of a whole series offers that rather than failing. +- **Only where it can be saved.** A read-only calendar offers no drag, and + neither does a birthday: it is derived from a contact and there is nothing on + the server to move. +- **Invitations are not sent.** A drag is a scheduling gesture, and mailing + every guest on each nudge of a block is not what the hand was asking for. A + change that should go out with notice goes through the editor. +- The new time is worked out **in the event's own frame** rather than through + an instant: its stored wall clock is what moves, and its time zone is not + touched. Computing a new time from the reader's local hours and then + re-expressing it in the event's zone converts twice, and the two do not + cancel. --- diff --git a/web/src/lib/__tests__/eventDrag.test.ts b/web/src/lib/__tests__/eventDrag.test.ts new file mode 100644 index 0000000..da34b86 --- /dev/null +++ b/web/src/lib/__tests__/eventDrag.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; +import { + canDragEvent, + formatDuration, + MIN_DURATION_MINUTES, + movedBy, + movedToDay, + pixelsToMinutes, + resizedBy, + snap, + movePatch, + moveToDayPatch, + resizePatch, + SNAP_MINUTES, +} from "@/lib/eventDrag"; +import { BIRTHDAY_ID_PREFIX } from "@/lib/birthdays"; +import type { CalendarEvent } from "@/jmap/types"; + +const at = (h: number, m = 0, d = 4) => new Date(2026, 8, d, h, m, 0, 0); +const span = (from: Date, to: Date) => ({ start: from, end: to }); +const hhmm = (d: Date) => `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +const ymd = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + +describe("snap", () => { + it("rounds to the nearest quarter hour", () => { + expect(snap(0)).toBe(0); + expect(snap(7)).toBe(0); + expect(snap(8)).toBe(15); + expect(snap(22)).toBe(15); + expect(snap(23)).toBe(30); + expect(snap(-8)).toBe(-15); + }); + + it("takes another slot when asked", () => { + expect(snap(20, 30)).toBe(30); + expect(snap(14, 30)).toBe(0); + }); +}); + +describe("movedBy", () => { + it("moves both ends, so the length does not change", () => { + const out = movedBy(span(at(14), at(15)), 30); + expect(hhmm(out.start)).toBe("14:30"); + expect(hhmm(out.end)).toBe("15:30"); + }); + + it("snaps the drag rather than taking it literally", () => { + const out = movedBy(span(at(14), at(15)), 7); + expect(hhmm(out.start)).toBe("14:00"); + }); + + it("moves backwards too", () => { + const out = movedBy(span(at(14), at(15)), -60); + expect(hhmm(out.start)).toBe("13:00"); + expect(hhmm(out.end)).toBe("14:00"); + }); + + it("carries an event across midnight without losing its length", () => { + const out = movedBy(span(at(23, 30), at(23, 45)), 60); + expect(ymd(out.start)).toBe("2026-09-05"); + expect(hhmm(out.start)).toBe("00:30"); + expect(out.end.getTime() - out.start.getTime()).toBe(15 * 60_000); + }); +}); + +describe("movedToDay", () => { + it("keeps the time of day, which is what the month grid is not asking about", () => { + // Dragged from Friday to Monday: still at two o'clock. + const out = movedToDay(span(at(14), at(15, 30)), new Date(2026, 8, 7)); + expect(ymd(out.start)).toBe("2026-09-07"); + expect(hhmm(out.start)).toBe("14:00"); + expect(hhmm(out.end)).toBe("15:30"); + }); + + it("keeps a length that spans days", () => { + const out = movedToDay(span(at(14, 0, 4), at(10, 0, 6)), new Date(2026, 8, 20)); + expect(ymd(out.start)).toBe("2026-09-20"); + expect(ymd(out.end)).toBe("2026-09-22"); + }); + + it("moves across a month boundary", () => { + const out = movedToDay(span(at(9), at(10)), new Date(2026, 9, 1)); + expect(ymd(out.start)).toBe("2026-10-01"); + expect(hhmm(out.start)).toBe("09:00"); + }); +}); + +describe("resizedBy", () => { + it("moves the end and leaves the start alone", () => { + const out = resizedBy(span(at(14), at(15)), 30); + expect(hhmm(out.start)).toBe("14:00"); + expect(hhmm(out.end)).toBe("15:30"); + }); + + it("clamps at one slot rather than refusing the drag", () => { + // A drag that goes too far is still a drag; stopping is what the reader + // sees happening while they do it. + const out = resizedBy(span(at(14), at(15)), -600); + expect(out.end.getTime() - out.start.getTime()).toBe(MIN_DURATION_MINUTES * 60_000); + expect(hhmm(out.end)).toBe("14:15"); + }); + + it("never lets the end cross the start", () => { + for (const delta of [-60, -120, -1000]) { + const out = resizedBy(span(at(9), at(9, 30)), delta); + expect(out.end.getTime()).toBeGreaterThan(out.start.getTime()); + } + }); +}); + +describe("formatDuration", () => { + it("writes the shapes the wire expects", () => { + expect(formatDuration(3600)).toBe("PT1H"); + expect(formatDuration(5400)).toBe("PT1H30M"); + expect(formatDuration(900)).toBe("PT15M"); + expect(formatDuration(86400)).toBe("P1D"); + expect(formatDuration(90000)).toBe("P1DT1H"); + expect(formatDuration(0)).toBe("PT0S"); + expect(formatDuration(45)).toBe("PT45S"); + }); +}); + +describe("the patch a drag sends, computed in the event's own frame", () => { + /* + * The bug this shape exists to prevent: working the new time out from the + * reader's local hours and then re-expressing it in the event's zone + * converts twice, and the two do not cancel. An event two hours from the + * reader jumped two hours the first time it was dragged and then sat still. + * None of these functions touches a zone at all. + */ + it("moves the stored start by the snapped delta", () => { + expect(movePatch("2026-09-04T14:00:00", 30)).toEqual({ start: "2026-09-04T14:30:00" }); + expect(movePatch("2026-09-04T14:00:00", -60)).toEqual({ start: "2026-09-04T13:00:00" }); + expect(movePatch("2026-09-04T14:00:00", 7)).toEqual({ start: "2026-09-04T14:00:00" }); + }); + + it("carries a move across midnight and across a month", () => { + expect(movePatch("2026-09-30T23:30:00", 60)).toEqual({ start: "2026-10-01T00:30:00" }); + }); + + it("never sends a duration for a move, so the length is left alone", () => { + expect(movePatch("2026-09-04T14:00:00", 30).duration).toBeUndefined(); + }); + + it("keeps the time of day when moving to another date", () => { + expect(moveToDayPatch("2026-09-04T14:30:00", new Date(2026, 8, 10))).toEqual({ start: "2026-09-10T14:30:00" }); + }); + + it("never sends a start for a resize, so the zone question does not arise", () => { + const patch = resizePatch(3600, 60); + expect(patch).toEqual({ duration: "PT2H" }); + expect(patch.start).toBeUndefined(); + }); + + it("clamps a resize at one slot", () => { + expect(resizePatch(3600, -600)).toEqual({ duration: "PT15M" }); + }); + + it("says nothing at all about a start it cannot read", () => { + expect(movePatch("not a date", 30)).toEqual({}); + expect(moveToDayPatch("", new Date(2026, 8, 10))).toEqual({}); + }); +}); + +describe("canDragEvent", () => { + const writable = { myRights: { mayWriteAll: true } }; + const readonly = { myRights: { mayWriteAll: false, mayWriteOwn: false } }; + const event = { id: "e1" } as CalendarEvent; + + it("allows a normal event on a calendar you can write to", () => { + expect(canDragEvent(event, writable)).toBe(true); + expect(canDragEvent(event, { myRights: { mayWriteOwn: true } })).toBe(true); + }); + + it("refuses a birthday, which is derived and has nothing to move", () => { + expect(canDragEvent({ id: `${BIRTHDAY_ID_PREFIX}c1:2026` } as CalendarEvent, writable)).toBe(false); + }); + + it("refuses a calendar you cannot write to, and one that is not there", () => { + expect(canDragEvent(event, readonly)).toBe(false); + expect(canDragEvent(event, undefined)).toBe(false); + }); + + it("refuses nothing at all", () => { + expect(canDragEvent(null, writable)).toBe(false); + }); +}); + +describe("pixelsToMinutes", () => { + it("converts against the grid's own scale", () => { + expect(pixelsToMinutes(48, 48)).toBe(60); + expect(pixelsToMinutes(24, 48)).toBe(30); + expect(pixelsToMinutes(-48, 48)).toBe(-60); + }); + + it("says nothing rather than dividing by zero before the grid is measured", () => { + expect(pixelsToMinutes(100, 0)).toBe(0); + }); + + it("round-trips through snap to the slot the pointer is over", () => { + expect(snap(pixelsToMinutes(10, 48))).toBe(15); + expect(snap(pixelsToMinutes(2, 48))).toBe(0); + expect(SNAP_MINUTES).toBe(15); + }); +}); diff --git a/web/src/lib/eventDrag.ts b/web/src/lib/eventDrag.ts new file mode 100644 index 0000000..2cc3aec --- /dev/null +++ b/web/src/lib/eventDrag.ts @@ -0,0 +1,163 @@ +/** + * Moving and resizing an event by dragging it. + * + * The arithmetic lives here, away from the grids and under test, for the same + * reason the swipe thresholds do: the numbers are the whole thing, and a + * mistake in them moves somebody's meeting to the wrong hour rather than + * merely looking wrong. + * + * Nothing here talks to the server or knows what a scope is. It answers one + * question — given an event and a gesture, what are the new start and end — + * and the caller decides whether it is allowed to save that. + */ +import { addMinutes } from "./dates"; +import { isBirthdayEvent } from "./birthdays"; +import type { CalendarEvent } from "@/jmap/types"; + +/** + * Fifteen minutes, which is the smallest slot anybody schedules against and + * the largest that still lands where the pointer looks like it is. + */ +export const SNAP_MINUTES = 15; + +/** An event has to keep some length; dragging its end past its start is not a request. */ +export const MIN_DURATION_MINUTES = 15; + +/** Round a count of minutes to the nearest slot, away from zero on a tie. */ +export function snap(minutes: number, slot: number = SNAP_MINUTES): number { + return Math.round(minutes / slot) * slot; +} + +export interface Span { + start: Date; + end: Date; +} + +/** + * Moved by a number of minutes, keeping its length. + * + * Both ends move together: dragging the middle of an event is asking for it to + * happen at another time, not to become a different length. + */ +export function movedBy(span: Span, deltaMinutes: number): Span { + const delta = snap(deltaMinutes); + return { start: addMinutes(span.start, delta), end: addMinutes(span.end, delta) }; +} + +/** + * Moved to another day, keeping its time of day and its length. + * + * This is the month grid, where a cell is a day and nothing finer. An event + * dragged from Tuesday to Friday should still be at two o'clock; changing the + * hour as well would be answering a question nobody asked. + */ +export function movedToDay(span: Span, day: Date): Span { + const length = span.end.getTime() - span.start.getTime(); + const start = new Date(day.getFullYear(), day.getMonth(), day.getDate(), span.start.getHours(), span.start.getMinutes(), 0, 0); + return { start, end: new Date(start.getTime() + length) }; +} + +/** + * Resized from its end, never shorter than one slot. + * + * The floor is a clamp rather than a refusal: a drag that goes too far is + * still a drag, and stopping at fifteen minutes is what the reader sees + * happening while they do it. + */ +export function resizedBy(span: Span, deltaMinutes: number): Span { + const end = addMinutes(span.end, snap(deltaMinutes)); + const minimum = addMinutes(span.start, MIN_DURATION_MINUTES); + return { start: span.start, end: end.getTime() < minimum.getTime() ? minimum : end }; +} + +/** Seconds, as an ISO 8601 duration — the shape `duration` takes on the wire. */ +export function formatDuration(seconds: number): string { + const total = Math.max(0, Math.round(seconds)); + const days = Math.floor(total / 86400); + const hours = Math.floor((total % 86400) / 3600); + const minutes = Math.floor((total % 3600) / 60); + const secs = total % 60; + if (!total) return "PT0S"; + const time = [hours && `${hours}H`, minutes && `${minutes}M`, secs && `${secs}S`].filter(Boolean).join(""); + return `P${days ? `${days}D` : ""}${time ? `T${time}` : ""}`; +} + +/** + * The patch a move or a resize sends. + * + * **Computed in the event's own frame, never through an instant.** An event + * carries a wall-clock `start` and a `timeZone`, and the grid draws it at the + * reader's local time. Working out a new time from those local hours and then + * re-expressing it in the event's zone converts twice, and the two conversions + * do not cancel: an event in a zone two hours from the reader's moved two + * hours the first time it was dragged, and then sat still, because after that + * its stored time and the reader's happened to agree. + * + * Parsing the stored string into its parts and adding minutes to those parts + * touches no zone at all, so there is nothing to get wrong. The zone itself is + * left exactly as it was: dragging an event is not a claim about where it + * happens. + */ +function parseStored(start: string): Date | null { + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(start ?? ""); + if (!m) return null; + return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6] ?? 0), 0); +} + +function formatStored(d: Date): string { + const p = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +} + +export interface DragPatch { + start?: string; + duration?: string; +} + +/** Moved by a number of minutes, in the event's own frame. */ +export function movePatch(storedStart: string, deltaMinutes: number): DragPatch { + const base = parseStored(storedStart); + if (!base) return {}; + return { start: formatStored(addMinutes(base, snap(deltaMinutes))) }; +} + +/** Moved to another date, keeping the time of day it already had. */ +export function moveToDayPatch(storedStart: string, day: Date): DragPatch { + const base = parseStored(storedStart); + if (!base) return {}; + const moved = new Date(day.getFullYear(), day.getMonth(), day.getDate(), base.getHours(), base.getMinutes(), base.getSeconds(), 0); + return { start: formatStored(moved) }; +} + +/** + * Resized from its end. Only the duration moves, so the start -- and with it + * the whole question of zones -- is not touched at all. + */ +export function resizePatch(currentSeconds: number, deltaMinutes: number): DragPatch { + const seconds = Math.max(MIN_DURATION_MINUTES * 60, currentSeconds + snap(deltaMinutes) * 60); + return { duration: formatDuration(seconds) }; +} + +/** + * Whether this event can be dragged at all. + * + * Three separate reasons it might not be, and they are checked here so no grid + * has to remember all three: + * + * - **A birthday is derived**, not stored. There is nothing on the server to + * move, and the date belongs to a contact rather than to a calendar. + * - **The calendar may be read-only** — someone else's, shared without write + * rights. This is the same question the popover asks before offering Edit. + * - **An event with no calendar** has nowhere to be saved. + */ +export function canDragEvent(event: CalendarEvent | null | undefined, calendar: { myRights?: { mayWriteAll?: boolean; mayWriteOwn?: boolean } } | undefined): boolean { + if (!event || isBirthdayEvent(event.id)) return false; + if (!calendar) return false; + return Boolean(calendar.myRights?.mayWriteAll || calendar.myRights?.mayWriteOwn); +} + +/** How far the pointer moved, in minutes, given a grid's pixels-per-hour. */ +export function pixelsToMinutes(deltaPixels: number, hourHeight: number): number { + if (!hourHeight) return 0; + return (deltaPixels / hourHeight) * 60; +} diff --git a/web/src/styles/app.css b/web/src/styles/app.css index c515abb..c228195 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1771,3 +1771,11 @@ button.dp-open:disabled { cursor: default; opacity: .5; } .mode-switch button { padding: 6px 14px; background: none; border: 0; color: var(--fg-muted); cursor: pointer; font: inherit; } .mode-switch button.active { background: var(--accent-soft); color: var(--accent-soft-fg); } .mode-switch button:disabled { opacity: .45; cursor: not-allowed; } + +/* Dragging an event to another time or day. */ +.ev-block.draggable, .ev-chip.draggable { cursor: grab; touch-action: none; } +.ev-block.dragging, .ev-chip.dragging { cursor: grabbing; opacity: .75; box-shadow: var(--shadow-2); z-index: 5; } +.ev-block { position: absolute; } +.ev-resize { position: absolute; left: 0; right: 0; bottom: 0; height: 8px; cursor: ns-resize; touch-action: none; } +.ev-resize::after { content: ""; position: absolute; left: 50%; bottom: 2px; width: 18px; height: 2px; margin-left: -9px; border-radius: 2px; background: currentColor; opacity: 0; } +.ev-block:hover .ev-resize::after { opacity: .5; } diff --git a/web/src/views/calendar/CalendarView.tsx b/web/src/views/calendar/CalendarView.tsx index c4abcb8..6e83d5a 100644 --- a/web/src/views/calendar/CalendarView.tsx +++ b/web/src/views/calendar/CalendarView.tsx @@ -3,7 +3,7 @@ import { useLocation } from "wouter"; import { ChevronLeft, ChevronRight, Plus, Calendar as CalIcon } from "lucide-react"; import { useCalendar, participantAddresses, type EventInstance } from "@/store/calendar"; import { useSettings } from "@/store/settings"; -import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays } from "@/lib/dates"; +import { addDays, addMonths, DAY_MS, endOfDay, isSameDay, isToday, monthGrid, roundToNext, startOfDay, startOfWeek, toLocalDateOnly, weekDays} from "@/lib/dates"; import { useSwipeNav } from "@/lib/touch"; import { formatMonthYear, formatTime } from "@/lib/format"; import { formatDate, formatDateLong, formatDayMonth, formatHourLabel, formatWeekday, formatWeekdayDate } from "@/lib/datetime"; @@ -13,6 +13,9 @@ import { EventPopover } from "./EventPopover"; import { EventEditor, type EditorInit } from "./EventEditor"; 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, moveToDayPatch, movePatch, pixelsToMinutes, resizePatch, snap, type DragPatch } from "@/lib/eventDrag"; import { t as translate } from "@/lib/i18n"; type View = "month" | "week" | "day" | "agenda"; @@ -110,6 +113,34 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?: ignore: ".cal-toolbar, .ev-chip, .ev-block, .agenda-ev", }); + /* + * Saving a move or a resize. + * + * The same path a menu edit takes: ask which of a series it is meant for, + * then run it through `runScoped` so a date the server will only change as + * part of a whole series offers that rather than failing. Invitations are + * not sent -- a drag is a scheduling gesture, and mailing every guest on + * each nudge of a block is not what the hand was asking for. The editor is + * still where a change goes out with notice. + */ + const commitDrag = useCallback( + async (inst: EventInstance, patch: DragPatch) => { + const ev = inst.event; + if (!patch.start && !patch.duration) return; + const scope = await askEditScope(ev); + if (!scope) return; + try { + const dropped = await runScoped(scope, (sc) => cal.updateEvent(ev, { ...patch }, false, sc)); + if (!dropped) return; + const message = droppedMessage(dropped); + if (message) toast.success(message); + } catch (err) { + toast.error((err as Error).message); + } + }, + [cal], + ); + const openNew = useCallback( (start?: Date, end?: Date, allDay = false) => { const s = start ?? roundToNext(new Date(), 30); @@ -183,8 +214,8 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?: {!isMobile && } {cal.error &&
{cal.error}
} - {effectiveView === "month" && go("day", d)} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(d) => openNew(new Date(d.getTime() + 9 * 3600_000))} />} - {(effectiveView === "week" || effectiveView === "day") && openNew(s, e, allDay)} onDayHeader={(d) => go("day", d)} workStart={settings.workDayStart} workEnd={settings.workDayEnd} />} + {effectiveView === "month" && void commitDrag(i, patch)} anchor={anchor} weekStart={weekStart} onDay={(d) => go("day", d)} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(d) => openNew(new Date(d.getTime() + 9 * 3600_000))} />} + {(effectiveView === "week" || effectiveView === "day") && void commitDrag(i, patch)} days={effectiveView === "week" ? weekDays(anchor, weekStart) : [anchor]} onEvent={onEvent} onEventContext={onEventContext} onSlotContext={onSlotContext} onCreate={(s, e, allDay) => openNew(s, e, allDay)} onDayHeader={(d) => go("day", d)} workStart={settings.workDayStart} workEnd={settings.workDayEnd} />} {effectiveView === "agenda" && } {ctx && setCtx(null)} onOpen={(inst, a) => setPopover({ inst, anchor: a })} onEdit={(inst) => setEditor({ event: inst.event, start: inst.start, end: inst.end, allDay: inst.allDay })} onCreate={(s, e, allDay) => { setCtx(null); openNew(s, e, allDay); }} />} {isMobile && } @@ -199,13 +230,61 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?: type EvCtx = (i: EventInstance, e: React.MouseEvent) => void; type SlotCtx = (start: Date, end: Date, allDay: boolean, e: React.MouseEvent) => void; -function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotContext, onCreate }: { anchor: Date; weekStart: number; onDay: (d: Date) => void; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (d: Date) => void }) { +function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotContext, onCreate, onDragCommit }: { anchor: Date; weekStart: number; onDay: (d: Date) => void; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (d: Date) => void; onDragCommit: (i: EventInstance, patch: DragPatch) => void }) { const cal = useCalendar(); const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]); const instances = cal.instancesIn(grid[0]!, addDays(grid[41]!, 1)); 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 [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) => { + 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; + 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 date = cell?.dataset.date ?? null; + if (date) landedOn = date; + if (!draggedRef.current) draggedRef.current = true; + setDraggingKey(inst.key); + }; + const finish = () => { + el.removeEventListener("pointermove", onPointerMove); + el.removeEventListener("pointerup", finish); + el.removeEventListener("pointercancel", finish); + try { + el.releasePointerCapture(e.pointerId); + } catch { + /* already released */ + } + setDraggingKey(null); + // Built from the parts rather than parsed: `new Date("2026-09-04")` is + // read as UTC and lands on the day before wherever the offset is negative. + const parts = landedOn?.split("-").map(Number); + const target = parts && parts.length === 3 ? new Date(parts[0]!, parts[1]! - 1, parts[2]!) : null; + if (target && !isSameDay(target, inst.start)) { + onDragCommit(inst, moveToDayPatch(inst.event.start, target)); + } + window.setTimeout(() => (draggedRef.current = false), 0); + }; + el.addEventListener("pointermove", onPointerMove); + el.addEventListener("pointerup", finish); + el.addEventListener("pointercancel", finish); + }; + return (
{dow.map((d) =>
{d}
)}
@@ -216,9 +295,9 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo 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)}> +
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)} />)} + {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 })}}
); @@ -248,11 +327,11 @@ function useEventColor() { return (inst: EventInstance) => eventColor(inst.event, inst.calendar?.color, categories); } -function EventChip({ inst, day, onClick, onContext }: { inst: EventInstance; day: Date; onClick: (el: Element) => void; onContext?: (e: React.MouseEvent) => void }) { +function EventChip({ inst, day, onClick, onContext, onDragStart, dragging, suppressClick }: { inst: EventInstance; day: Date; onClick: (el: Element) => void; onContext?: (e: React.MouseEvent) => void; onDragStart?: (e: React.PointerEvent) => void; dragging?: boolean; suppressClick?: () => boolean }) { const color = useEventColor()(inst); const spansDay = inst.allDay || inst.end.getTime() - inst.start.getTime() >= DAY_MS || !isSameDay(inst.start, inst.end) && inst.start < day; return ( -
{ e.stopPropagation(); onClick(e.currentTarget); }} onContextMenu={onContext} title={inst.event.title ?? ""}> +
{ e.stopPropagation(); if (suppressClick?.()) return; onClick(e.currentTarget); }} onContextMenu={onContext} title={inst.event.title ?? ""}> {!spansDay && } {!spansDay && {formatTime(inst.start)}} {inst.event.title || "(untitled)"} @@ -262,7 +341,7 @@ function EventChip({ inst, day, onClick, onContext }: { inst: EventInstance; day /* ---------------- Week / Day ---------------- */ -function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDayHeader, workStart, workEnd }: { days: Date[]; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (s: Date, e: Date, allDay: boolean) => void; onDayHeader: (d: Date) => void; workStart: number; workEnd: number }) { +function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDayHeader, onDragCommit, workStart, workEnd }: { days: Date[]; onEvent: (i: EventInstance, el: Element) => void; onEventContext: EvCtx; onSlotContext: SlotCtx; onCreate: (s: Date, e: Date, allDay: boolean) => void; onDayHeader: (d: Date) => void; onDragCommit: (i: EventInstance, patch: DragPatch) => void; workStart: number; workEnd: number }) { const cal = useCalendar(); const colorOf = useEventColor(); const scrollRef = useRef(null); @@ -271,6 +350,53 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa const instances = cal.instancesIn(start, end); const [now, setNow] = useState(new Date()); const [drag, setDrag] = useState<{ day: Date; startMin: number; endMin: number } | null>(null); + /* + * Dragging an event, as opposed to dragging out a new one on empty grid -- + * which is what `drag` above is. Held as a delta in minutes rather than as a + * 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); + /* 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 beginDrag = (inst: EventInstance, mode: "move" | "resize", 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 startY = e.clientY; + let delta = 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 }); + }; + const finish = () => { + el.removeEventListener("pointermove", onPointerMove); + el.removeEventListener("pointerup", finish); + el.removeEventListener("pointercancel", finish); + try { + el.releasePointerCapture(e.pointerId); + } catch { + /* already released, which is fine */ + } + setMoving(null); + if (delta !== 0) { + const seconds = (inst.end.getTime() - inst.start.getTime()) / 1000; + onDragCommit(inst, mode === "move" ? movePatch(inst.event.start, delta) : resizePatch(seconds, delta)); + } + // Cleared after the click that follows this pointerup has been swallowed. + window.setTimeout(() => (draggedRef.current = false), 0); + }; + el.addEventListener("pointermove", onPointerMove); + el.addEventListener("pointerup", finish); + el.addEventListener("pointercancel", finish); + }; + useEffect(() => { const t = window.setInterval(() => setNow(new Date()), 60_000); return () => window.clearInterval(t); @@ -354,7 +480,27 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa {evs.map(({ inst, top, height, left, width }) => { const color = colorOf(inst); return ( -
{ e.stopPropagation(); onEvent(inst, e.currentTarget); }} onContextMenu={(e) => onEventContext(inst, e)} title={inst.event.title ?? ""}> +
beginDrag(inst, "move", e)} + onClick={(e) => { e.stopPropagation(); if (draggedRef.current) return; onEvent(inst, e.currentTarget); }} + onContextMenu={(e) => onEventContext(inst, e)} + title={inst.event.title ?? ""} + > + {canDragEvent(inst.event, inst.calendar) && ( + /* 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" /> + )}
{inst.event.title || "(untitled)"}
{height > 30 &&
{formatTime(inst.start)} – {formatTime(inst.end)}
}