Merge pull request #214 from Coffey-Labs/fix/drag-day-across-timezones
Move an event by the days the hand moved it, not to the date dropped on
This commit is contained in:
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user