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 &&