Merge pull request #210 from Coffey-Labs/feat/drag-reschedule

Drag an event to move it, and its edge to resize it
This commit is contained in:
Coffey Labs
2026-09-02 00:42:47 -07:00
committed by GitHub
5 changed files with 551 additions and 11 deletions
+19 -1
View File
@@ -638,7 +638,25 @@ work:
*This and future* is not offered: the server refuses an occurrence that belongs *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. 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.
--- ---
+205
View File
@@ -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);
});
});
+163
View File
@@ -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;
}
+8
View File
@@ -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 { 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.active { background: var(--accent-soft); color: var(--accent-soft-fg); }
.mode-switch button:disabled { opacity: .45; cursor: not-allowed; } .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; }
+155 -9
View File
@@ -13,6 +13,9 @@ import { EventPopover } from "./EventPopover";
import { EventEditor, type EditorInit } from "./EventEditor"; import { EventEditor, type EditorInit } from "./EventEditor";
import type { Anchor } from "@/ui/popover"; import type { Anchor } from "@/ui/popover";
import { CalendarContextMenu, eventColor, type CalendarContext } from "./CalendarContextMenu"; 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"; import { t as translate } from "@/lib/i18n";
type View = "month" | "week" | "day" | "agenda"; 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", 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( const openNew = useCallback(
(start?: Date, end?: Date, allDay = false) => { (start?: Date, end?: Date, allDay = false) => {
const s = start ?? roundToNext(new Date(), 30); const s = start ?? roundToNext(new Date(), 30);
@@ -183,8 +214,8 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
{!isMobile && <button className="btn btn-primary btn-sm" onClick={() => openNew()}><Plus size={16} /> {translate("Event")}</button>} {!isMobile && <button className="btn btn-primary btn-sm" onClick={() => openNew()}><Plus size={16} /> {translate("Event")}</button>}
</div> </div>
{cal.error && <div className="error-box" style={{ margin: 12 }}>{cal.error}</div>} {cal.error && <div className="error-box" style={{ margin: 12 }}>{cal.error}</div>}
{effectiveView === "month" && <MonthView 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 === "month" && <MonthView onDragCommit={(i, patch) => 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") && <TimeGrid 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 === "week" || effectiveView === "day") && <TimeGrid onDragCommit={(i, patch) => 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" && <AgendaView start={anchor} onEvent={onEvent} onEventContext={onEventContext} />} {effectiveView === "agenda" && <AgendaView start={anchor} onEvent={onEvent} onEventContext={onEventContext} />}
{ctx && <CalendarContextMenu ctx={ctx} onClose={() => 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); }} />} {ctx && <CalendarContextMenu ctx={ctx} onClose={() => 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 && <button className="fab" aria-label={translate("New event")} onClick={() => openNew()}><Plus size={24} /></button>} {isMobile && <button className="fab" aria-label={translate("New event")} onClick={() => openNew()}><Plus size={24} /></button>}
@@ -199,13 +230,61 @@ export function CalendarView({ view: viewParam, date }: { view?: string; date?:
type EvCtx = (i: EventInstance, e: React.MouseEvent) => void; type EvCtx = (i: EventInstance, e: React.MouseEvent) => void;
type SlotCtx = (start: Date, end: Date, allDay: boolean, 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 cal = useCalendar();
const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]); const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]);
const instances = cal.instancesIn(grid[0]!, addDays(grid[41]!, 1)); const instances = cal.instancesIn(grid[0]!, addDays(grid[41]!, 1));
const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7)); const weeks = [...Array(6)].map((_, w) => grid.slice(w * 7, w * 7 + 7));
const dow = weeks[0]!.map((d) => formatWeekday(d)); const dow = weeks[0]!.map((d) => formatWeekday(d));
const maxPer = 4; const maxPer = 4;
const [draggingKey, setDraggingKey] = useState<string | null>(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<HTMLElement>(".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 ( return (
<div className="month-grid"> <div className="month-grid">
<div className="dow-row">{dow.map((d) => <div key={d}>{d}</div>)}</div> <div className="dow-row">{dow.map((d) => <div key={d}>{d}</div>)}</div>
@@ -216,9 +295,9 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo
const evs = instances.filter((i) => i.start < dayEnd && i.end > d); const evs = instances.filter((i) => i.start < dayEnd && i.end > d);
const shown = evs.slice(0, maxPer); const shown = evs.slice(0, maxPer);
return ( return (
<div key={d.toISOString()} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}> <div key={d.toISOString()} data-date={toLocalDateOnly(d)} className={`month-cell ${d.getMonth() !== anchor.getMonth() ? "other" : ""} ${isToday(d) ? "today" : ""}`} onClick={() => onCreate(d)} onDoubleClick={() => onDay(d)} onContextMenu={(e) => onSlotContext(new Date(d.getTime() + 9 * 3600_000), new Date(d.getTime() + 10 * 3600_000), false, e)}>
<span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()}</span> <span className="day-num" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{d.getDate() === 1 ? formatDayMonth(d) : d.getDate()}</span>
{shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => onEvent(i, el)} onContext={(e) => onEventContext(i, e)} />)} {shown.map((i) => <EventChip key={i.key} inst={i} day={d} onClick={(el) => 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 && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{translate("+{n} more", { n: evs.length - maxPer })}</span>} {evs.length > maxPer && <span className="more" onClick={(e) => { e.stopPropagation(); onDay(d); }}>{translate("+{n} more", { n: evs.length - maxPer })}</span>}
</div> </div>
); );
@@ -248,11 +327,11 @@ function useEventColor() {
return (inst: EventInstance) => eventColor(inst.event, inst.calendar?.color, categories); 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 color = useEventColor()(inst);
const spansDay = inst.allDay || inst.end.getTime() - inst.start.getTime() >= DAY_MS || !isSameDay(inst.start, inst.end) && inst.start < day; const spansDay = inst.allDay || inst.end.getTime() - inst.start.getTime() >= DAY_MS || !isSameDay(inst.start, inst.end) && inst.start < day;
return ( return (
<div className={`ev-chip ${spansDay ? "" : "timed"} ${statusClass(inst)}`} style={{ background: color, borderColor: color }} onClick={(e) => { e.stopPropagation(); onClick(e.currentTarget); }} onContextMenu={onContext} title={inst.event.title ?? ""}> <div className={`ev-chip ${spansDay ? "" : "timed"} ${statusClass(inst)} ${dragging ? "dragging" : ""} ${onDragStart ? "draggable" : ""}`} style={{ background: color, borderColor: color }} onPointerDown={onDragStart} onClick={(e) => { e.stopPropagation(); if (suppressClick?.()) return; onClick(e.currentTarget); }} onContextMenu={onContext} title={inst.event.title ?? ""}>
{!spansDay && <span className="ev-dot" style={{ background: color }} />} {!spansDay && <span className="ev-dot" style={{ background: color }} />}
{!spansDay && <span className="ev-time">{formatTime(inst.start)}</span>} {!spansDay && <span className="ev-time">{formatTime(inst.start)}</span>}
<span className="truncate">{inst.event.title || "(untitled)"}</span> <span className="truncate">{inst.event.title || "(untitled)"}</span>
@@ -262,7 +341,7 @@ function EventChip({ inst, day, onClick, onContext }: { inst: EventInstance; day
/* ---------------- Week / 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 cal = useCalendar();
const colorOf = useEventColor(); const colorOf = useEventColor();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@@ -271,6 +350,53 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
const instances = cal.instancesIn(start, end); const instances = cal.instancesIn(start, end);
const [now, setNow] = useState(new Date()); const [now, setNow] = useState(new Date());
const [drag, setDrag] = useState<{ day: Date; startMin: number; endMin: number } | null>(null); 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(() => { useEffect(() => {
const t = window.setInterval(() => setNow(new Date()), 60_000); const t = window.setInterval(() => setNow(new Date()), 60_000);
return () => window.clearInterval(t); return () => window.clearInterval(t);
@@ -354,7 +480,27 @@ function TimeGrid({ days, onEvent, onEventContext, onSlotContext, onCreate, onDa
{evs.map(({ inst, top, height, left, width }) => { {evs.map(({ inst, top, height, left, width }) => {
const color = colorOf(inst); const color = colorOf(inst);
return ( return (
<div key={inst.key} className={`ev-block ${statusClass(inst)}`} style={{ top, height: Math.max(height, 18), left: `${left}%`, width: `calc(${width}% - 3px)`, background: color }} onClick={(e) => { e.stopPropagation(); onEvent(inst, e.currentTarget); }} onContextMenu={(e) => onEventContext(inst, e)} title={inst.event.title ?? ""}> <div
key={inst.key}
className={`ev-block ${statusClass(inst)} ${moving?.key === inst.key ? "dragging" : ""} ${canDragEvent(inst.event, inst.calendar) ? "draggable" : ""}`}
style={{
top: top + (moving?.key === inst.key && moving.mode === "move" ? (moving.deltaMin / 60) * HOUR_H : 0),
height: Math.max(height + (moving?.key === inst.key && moving.mode === "resize" ? (moving.deltaMin / 60) * HOUR_H : 0), 18),
left: `${left}%`,
width: `calc(${width}% - 3px)`,
background: color,
}}
onPointerDown={(e) => 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. */
<div className="ev-resize" onPointerDown={(e) => beginDrag(inst, "resize", e)} aria-hidden="true" />
)}
<div className="ev-title">{inst.event.title || "(untitled)"}</div> <div className="ev-title">{inst.event.title || "(untitled)"}</div>
{height > 30 && <div className="ev-time">{formatTime(inst.start)} {formatTime(inst.end)}</div>} {height > 30 && <div className="ev-time">{formatTime(inst.start)} {formatTime(inst.end)}</div>}
</div> </div>