From 7b3069e41b6b593d1ab92491ed38256d4fa183ef Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 2 Sep 2026 01:07:07 -0700 Subject: [PATCH] Move an event by the days the hand moved it, not to the date dropped on Dragging an event across the month grid wrote the date of the cell it landed on into the event's stored start. Those are the same date only while the event's time zone is the reader's. An event kept in Asia/Tokyo at 15:00 is drawn to a reader in Phoenix at 23:00 the previous evening. Dropped on the 11th, it was written as the 11th in Tokyo -- which is the 10th on screen. It went where its own calendar said rather than where the pointer did, one day short, every time. Moving by the difference between the two local days instead moves it exactly as far as the hand did, and adding whole days to a stored wall clock leaves the time of day alone without touching the zone -- so the frame the rest of this path is careful about is still not crossed. Found by giving the mock an event in a zone that is not the machine's. Every other fixture used the machine's own, which cannot tell a correct conversion from no conversion at all: the case that works is the one the fixtures were all testing. --- FEATURES.md | 6 ++++- server/src/mock/index.ts | 7 ++++++ web/src/lib/__tests__/eventDrag.test.ts | 33 ++++++++++++++++++++++--- web/src/lib/eventDrag.ts | 28 ++++++++++++++++++--- web/src/views/calendar/CalendarView.tsx | 6 +++-- 5 files changed, 69 insertions(+), 11 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index c36ca83..985eb98 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -682,7 +682,11 @@ The editor is still there and still does everything a drag cannot. 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 + touched. A move in the month grid shifts it by the number of days the hand + moved it, rather than writing the date it was dropped on — those are the same + thing only while the event's zone is the reader's. An event kept in Tokyo and + read from Phoenix is drawn on the previous evening, so writing the dropped-on + date sent it a day earlier than the pointer went. 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/server/src/mock/index.ts b/server/src/mock/index.ts index 98bf239..f69de92 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -248,6 +248,13 @@ const events: Obj[] = []; events.push({ id: "ev1", calendarIds: { c1: true }, "@type": "Event", uid: "ev1", title: "Standup", start: local(d(0, 9)), timeZone: tz, duration: "PT30M", recurrenceRule: { "@type": "RecurrenceRule", frequency: "weekly", byDay: [{ day: "mo" }, { day: "tu" }, { day: "we" }, { day: "th" }, { day: "fr" }] }, showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" }); events.push({ id: "ev2", calendarIds: { c2: true }, "@type": "Event", uid: "ev2", title: "Design review", start: local(d(1, 14)), timeZone: tz, duration: "PT1H30M", showWithoutTime: false, locations: { l: { "@type": "Location", name: "Room 2" } }, participants: { me: { "@type": "Participant", name: "Demo User", calendarAddress: `mailto:${USER}`, roles: { owner: true, attendee: true }, participationStatus: "accepted" }, p2: { "@type": "Participant", name: "Ada Lovelace", calendarAddress: "mailto:ada@example.org", roles: { attendee: true, required: true }, participationStatus: "needs-action", expectReply: true } }, organizerCalendarAddress: `mailto:${USER}` }); events.push({ id: "ev3", calendarIds: { c1: true }, "@type": "Event", uid: "ev3", title: "Conference", start: local(d(3, 0)).slice(0, 10) + "T00:00:00", duration: "P2D", showWithoutTime: true, timeZone: null }); + /* + * One event in a zone that is not the reader's, because every other fixture + * here uses the machine's own and so cannot tell a correct conversion from + * no conversion at all. Dragging this one is what proves a move keeps the + * time the event says it happens at. + */ + events.push({ id: "ev9", calendarIds: { c1: true }, "@type": "Event", uid: "ev9", title: "Tokyo sync", start: local(d(2, 15)), timeZone: "Asia/Tokyo", duration: "PT1H", showWithoutTime: false, color: "#7c3aed" }); events.push({ id: "ev4", calendarIds: { c1: true }, "@type": "Event", uid: "ev4", title: "Lunch with Grace", start: local(d(2, 12)), timeZone: tz, duration: "PT1H", showWithoutTime: false, color: "#db2777" }); // Two in the shared account, so a colleague's calendar has something in it. sharedEvents.push({ id: "sv1", calendarIds: { c9: true }, "@type": "Event", uid: "sv1", title: "Grace: release planning", start: local(d(1, 10)), timeZone: tz, duration: "PT1H", showWithoutTime: false, status: "confirmed", freeBusyStatus: "busy", privacy: "public" }); diff --git a/web/src/lib/__tests__/eventDrag.test.ts b/web/src/lib/__tests__/eventDrag.test.ts index da34b86..af21360 100644 --- a/web/src/lib/__tests__/eventDrag.test.ts +++ b/web/src/lib/__tests__/eventDrag.test.ts @@ -9,7 +9,8 @@ import { resizedBy, snap, movePatch, - moveToDayPatch, + moveByDaysPatch, + dayDelta, resizePatch, SNAP_MINUTES, } from "@/lib/eventDrag"; @@ -142,8 +143,31 @@ describe("the patch a drag sends, computed in the event's own frame", () => { 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("keeps the time of day when moving by whole days", () => { + expect(moveByDaysPatch("2026-09-04T14:30:00", 6)).toEqual({ start: "2026-09-10T14:30:00" }); + expect(moveByDaysPatch("2026-09-04T14:30:00", -3)).toEqual({ start: "2026-09-01T14:30:00" }); + }); + + it("moves by the delta the hand made, not to the date that was dropped on", () => { + /* + * The month grid's cells are local days; the stored date is in the event's + * own zone. Writing the dropped-on date put a Tokyo event dropped on the + * 11th onto the 10th, because 15:00 in Tokyo is the previous evening in + * Phoenix — it went where its own calendar said, not where the pointer did. + */ + const storedTokyo = "2026-09-04T15:00:00"; // shown to a Phoenix reader on the 3rd + const shownOn = new Date(2026, 8, 3); + const droppedOn = new Date(2026, 8, 11); + const patch = moveByDaysPatch(storedTokyo, dayDelta(shownOn, droppedOn)); + // Eight days later in its own frame, so eight days later on screen too. + expect(patch).toEqual({ start: "2026-09-12T15:00:00" }); + }); + + it("counts whole local days, ignoring the time on either side", () => { + expect(dayDelta(new Date(2026, 8, 3, 23, 30), new Date(2026, 8, 4, 0, 30))).toBe(1); + expect(dayDelta(new Date(2026, 8, 4), new Date(2026, 8, 4))).toBe(0); + expect(dayDelta(new Date(2026, 8, 11), new Date(2026, 8, 3))).toBe(-8); + expect(dayDelta(new Date(2026, 8, 30), new Date(2026, 9, 2))).toBe(2); }); it("never sends a start for a resize, so the zone question does not arise", () => { @@ -158,7 +182,8 @@ describe("the patch a drag sends, computed in the event's own frame", () => { 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({}); + expect(moveByDaysPatch("", 3)).toEqual({}); + expect(moveByDaysPatch("2026-09-04T14:00:00", Number.NaN)).toEqual({}); }); }); diff --git a/web/src/lib/eventDrag.ts b/web/src/lib/eventDrag.ts index 2cc3aec..667937f 100644 --- a/web/src/lib/eventDrag.ts +++ b/web/src/lib/eventDrag.ts @@ -121,14 +121,34 @@ export function movePatch(storedStart: string, deltaMinutes: number): DragPatch 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 { +/** + * Moved by a whole number of days, keeping the time of day it already had. + * + * A day *delta*, not a target date, and the difference matters whenever the + * event's zone is not the reader's. The month grid's cells are local days; the + * event's stored date is in its own zone. Rewriting the stored date to the day + * that was dropped on put a Tokyo event dropped on the 11th onto the 10th, + * because 15:00 in Tokyo on the 11th is 23:00 in Phoenix on the 10th — the + * event went where its own calendar said, not where the pointer did. + * + * Shifting by the difference between the two local days moves it exactly as + * far as the hand did, and adding whole days to a wall clock leaves the time + * of day alone without touching the zone. + */ +export function moveByDaysPatch(storedStart: string, days: number): 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); + if (!base || !Number.isFinite(days)) return {}; + const moved = new Date(base.getFullYear(), base.getMonth(), base.getDate() + Math.round(days), base.getHours(), base.getMinutes(), base.getSeconds(), 0); return { start: formatStored(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(); + const b = new Date(to.getFullYear(), to.getMonth(), to.getDate()).getTime(); + return Math.round((b - a) / 86400_000); +} + /** * Resized from its end. Only the duration moves, so the start -- and with it * the whole question of zones -- is not touched at all. diff --git a/web/src/views/calendar/CalendarView.tsx b/web/src/views/calendar/CalendarView.tsx index 6e83d5a..735e6c1 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, moveToDayPatch, movePatch, pixelsToMinutes, resizePatch, snap, type DragPatch } from "@/lib/eventDrag"; +import { canDragEvent, dayDelta, moveByDaysPatch, movePatch, pixelsToMinutes, resizePatch, snap, type DragPatch } from "@/lib/eventDrag"; import { t as translate } from "@/lib/i18n"; type View = "month" | "week" | "day" | "agenda"; @@ -275,8 +275,10 @@ function MonthView({ anchor, weekStart, onDay, onEvent, onEventContext, onSlotCo // 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; + // 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, moveToDayPatch(inst.event.start, target)); + onDragCommit(inst, moveByDaysPatch(inst.event.start, dayDelta(inst.start, target))); } window.setTimeout(() => (draggedRef.current = false), 0); };