diff --git a/FEATURES.md b/FEATURES.md index 28ef6ba..a311a0f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -689,7 +689,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); };