Skip to content

Commit 2317490

Browse files
tyler-daneclaude
andauthored
feat(day): drag events between calendar columns (#2183)
* feat(day): drag events between calendar columns The Day view renders one column per calendar, but the drag engine was fed a single full-width column, so an event could never leave its calendar. Feed the engine one column per displayed calendar (column keys are calendar ids; the columns all share the visible date), map a cross-column drop to a calendarId change, and thread the move through the stack: - core: ReplaceEventInput gains an optional calendarId (a differing value is a move request; supersedes A6 calendar-immutability for single events) - web: useUpdateEvent guards moves (recurring events and read-only destinations toast and revert) and passes calendarId through; optimistic cache, local repository, and undo replay all honor it - backend: replace() re-homes the record (destination must be writable, single events only, Google events may only move to other Google calendars), propagates via Google's events.move + follow-up patch (no attendee emails), and compensates by reverting the calendarId if Google refuses the move; both calendars get the SSE notify Cross-column all-day drags preserve the event's own dates (only the calendar changes), and an event whose calendar has no rendered column falls back to a time-only drag instead of anchoring to the first column. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(events): reuse read-only policy and google-effect failure policy Cross-calendar move follow-ups from the simplify pass: the destination guard in useUpdateEvent now goes through isEventReadOnly instead of an inline capabilities check, propagate()/propagateCalendarMove share one runGoogleEffects failure policy, the shared drag-visual dayDate fields document their Day-view calendar-id semantics, and the move test asserts plain calendar ids instead of narrowing ternaries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(e2e): widen calendar-experience viewport to defuse Saturday date bomb At the default 1280px viewport, bare /week renders a 6-day Sun-Fri window anchored on the week start, which excludes today whenever today is Saturday (UTC on CI). The spec's today-anchored fixtures then never render and all four calendar-experience assertions fail — pre-existing on main, reproducible at the merge base with TZ=UTC. A 1600px viewport fits all 7 columns, making the spec's 'default week always includes today' premise hold on every weekday. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 5d96042 commit 2317490

18 files changed

Lines changed: 761 additions & 52 deletions

File tree

e2e/calendars/calendar-experience.spec.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,22 @@ test.skip(
99
"Mouse/keyboard flows are desktop-only in week view.",
1010
);
1111

12+
// Wide enough for all 7 week columns (track needs GRID_MARGIN_LEFT +
13+
// 7 * DAY_COLUMN_MIN_USABLE_WIDTH beside the sidebar). At the default
14+
// 1280px, bare /week shows a 6-day Sun-Fri window anchored on the week
15+
// start, which EXCLUDES today whenever today is Saturday (UTC) — the
16+
// today-anchored fixtures below then never render and every assertion on
17+
// them fails. Full width keeps "the default week view always includes
18+
// today" true on every weekday.
19+
test.use({ viewport: { width: 1600, height: 900 } });
20+
1221
// Fixtures. CalendarSchema/EventSchema (packages/core/src/types) are zod
1322
// strictObjects - an extra or missing field fails parsing client-side and
1423
// the app renders nothing, so every fixture below is a full, honest member
1524
// of those shapes. IDs are 24-char hex (ObjectId format). Event times anchor
1625
// on "today" so the default week view (dayjs().startOf("week"), always
17-
// including today) renders them without knowing the week-start convention.
26+
// including today at full width - see the viewport note above) renders them
27+
// without knowing the week-start convention.
1828

1929
const objectId = (seed: string) => seed.repeat(24);
2030

packages/backend/src/common/services/gcal/gcal.service.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ describe("gcal.service quotaUser passthrough (packet 07 step 7 pin)", () => {
222222
status: 200,
223223
data: { items: [], nextSyncToken: "sync-token" },
224224
}),
225+
move: jest.fn().mockResolvedValue({ status: 200, data: {} }),
225226
patch: jest.fn().mockResolvedValue({ status: 200, data: {} }),
226227
watch: jest.fn().mockResolvedValue({
227228
status: 200,
@@ -288,6 +289,11 @@ describe("gcal.service quotaUser passthrough (packet 07 step 7 pin)", () => {
288289
call: () => gcalService.deleteEvent(context, "cal-1", "event-1"),
289290
mock: () => context.gcal.events.delete as jest.Mock,
290291
},
292+
{
293+
method: "moveEvent",
294+
call: () => gcalService.moveEvent(context, "cal-1", "event-1", "cal-2"),
295+
mock: () => context.gcal.events.move as jest.Mock,
296+
},
291297
{
292298
method: "getEventInstances",
293299
call: () =>

packages/backend/src/common/services/gcal/gcal.service.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,30 @@ class GCalService {
8080
return response;
8181
}
8282

83+
/**
84+
* Moves an event to another Google calendar. Google preserves the event id
85+
* across a move, so the stored externalReference stays valid. No
86+
* sendUpdates: re-homing an event between the user's own calendars must
87+
* not email attendees (matches createEvent/patchEvent).
88+
*/
89+
async moveEvent(
90+
{ gcal, quotaUser }: GoogleRequestContext,
91+
calendarId: string,
92+
gcalEventId: string,
93+
destinationCalendarId: string,
94+
): Promise<gSchema$Event> {
95+
const response = await withGoogleRetry(() =>
96+
gcal.events.move({
97+
calendarId,
98+
destination: destinationCalendarId,
99+
eventId: gcalEventId,
100+
quotaUser,
101+
}),
102+
);
103+
104+
return this.validateGCalResponse(response).data;
105+
}
106+
83107
private async getEventInstances(
84108
{ gcal, quotaUser }: GoogleRequestContext,
85109
calendarId: string,

packages/backend/src/event/services/event.service.test.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
setupTestDb,
77
} from "@backend/__tests__/helpers/mock.db.setup";
88
import { CalendarRecordSchema } from "@backend/calendar/calendar.record";
9+
import gcalService from "@backend/common/services/gcal/gcal.service";
910
import mongoService from "@backend/common/services/mongo.service";
1011
import eventService from "@backend/event/services/event.service";
1112
import { sseServer } from "@backend/servers/sse/sse.server";
@@ -727,3 +728,256 @@ describe("EventService (visibility filtering for list reads, packet 08)", () =>
727728
).resolves.toBeUndefined();
728729
});
729730
});
731+
732+
/**
733+
* Cross-calendar move (drag between Day-view columns): a replace whose
734+
* calendarId differs from the event's current calendar re-homes a single
735+
* event. Moves supersede A6's calendar-immutability for single events only;
736+
* recurring events are rejected before any write.
737+
*/
738+
describe("EventService (cross-calendar move)", () => {
739+
beforeEach(setupTestDb);
740+
beforeEach(cleanupCollections);
741+
afterEach(() => jest.restoreAllMocks());
742+
afterAll(cleanupTestDb);
743+
744+
const moveInput = (calendarId: string) => ({
745+
calendarId: calendarId as never,
746+
content: { kind: "details" as const, title: "Moved", description: "" },
747+
schedule: {
748+
kind: "timed" as const,
749+
start: "2026-07-14T15:00:00-06:00",
750+
end: "2026-07-14T16:00:00-06:00",
751+
timeZone: "America/Denver" as never,
752+
},
753+
recurrence: { kind: "preserve" as const },
754+
scope: "this" as const,
755+
});
756+
757+
it("moves a single event to the destination calendar", async () => {
758+
const { user } = await UtilDriver.setupTestUser();
759+
const source = await seedLocalCalendar(user._id);
760+
const destination = await seedLocalCalendar(user._id);
761+
const event = buildEventRecord(source._id);
762+
await mongoService.event.insertOne(event);
763+
764+
const replaced = await eventService.replace(
765+
user._id.toString(),
766+
event._id.toHexString(),
767+
moveInput(destination._id.toHexString()) as never,
768+
);
769+
770+
expect(replaced.calendarId).toEqual(destination._id);
771+
772+
const stored = await mongoService.event.findOne({ _id: event._id });
773+
expect(stored?.calendarId).toEqual(destination._id);
774+
});
775+
776+
it("treats a matching calendarId as a plain replace, not a move", async () => {
777+
const { user } = await UtilDriver.setupTestUser();
778+
const calendar = await seedLocalCalendar(user._id);
779+
const event = buildEventRecord(calendar._id);
780+
await mongoService.event.insertOne(event);
781+
782+
const replaced = await eventService.replace(
783+
user._id.toString(),
784+
event._id.toHexString(),
785+
moveInput(calendar._id.toHexString()) as never,
786+
);
787+
788+
expect(replaced.calendarId).toEqual(calendar._id);
789+
expect(replaced.content.kind === "details" && replaced.content.title).toBe(
790+
"Moved",
791+
);
792+
});
793+
794+
it("rejects a move for a recurring event with RECURRENCE_CONFLICT", async () => {
795+
const { user } = await UtilDriver.setupTestUser();
796+
const source = await seedLocalCalendar(user._id);
797+
const destination = await seedLocalCalendar(user._id);
798+
const event = buildEventRecord(source._id, {
799+
recurrence: { kind: "series", rules: ["RRULE:FREQ=WEEKLY;COUNT=3"] },
800+
});
801+
await mongoService.event.insertOne(event);
802+
803+
await expect(
804+
eventService.replace(
805+
user._id.toString(),
806+
event._id.toHexString(),
807+
moveInput(destination._id.toHexString()) as never,
808+
),
809+
).rejects.toMatchObject({ mutationCode: "RECURRENCE_CONFLICT" });
810+
811+
const stored = await mongoService.event.findOne({ _id: event._id });
812+
expect(stored?.calendarId).toEqual(source._id);
813+
});
814+
815+
it("rejects a move that also converts the event to a series", async () => {
816+
const { user } = await UtilDriver.setupTestUser();
817+
const source = await seedLocalCalendar(user._id);
818+
const destination = await seedLocalCalendar(user._id);
819+
const event = buildEventRecord(source._id);
820+
await mongoService.event.insertOne(event);
821+
822+
await expect(
823+
eventService.replace(user._id.toString(), event._id.toHexString(), {
824+
...moveInput(destination._id.toHexString()),
825+
recurrence: { kind: "series", rules: ["RRULE:FREQ=WEEKLY;COUNT=3"] },
826+
} as never),
827+
).rejects.toMatchObject({ mutationCode: "RECURRENCE_CONFLICT" });
828+
829+
const stored = await mongoService.event.findOne({ _id: event._id });
830+
expect(stored?.calendarId).toEqual(source._id);
831+
});
832+
833+
it("rejects moving a Google event onto a non-Google calendar", async () => {
834+
const { user } = await UtilDriver.setupTestUser();
835+
const source = await seedGoogleCalendar(user._id, { access: "owner" });
836+
const destination = await seedLocalCalendar(user._id);
837+
const event = buildEventRecord(source._id, {
838+
externalReference: {
839+
provider: "google",
840+
eventId: "google-event-1",
841+
recurringEventId: null,
842+
},
843+
});
844+
await mongoService.event.insertOne(event);
845+
846+
await expect(
847+
eventService.replace(
848+
user._id.toString(),
849+
event._id.toHexString(),
850+
moveInput(destination._id.toHexString()) as never,
851+
),
852+
).rejects.toMatchObject({ mutationCode: "PROVIDER_FAILURE" });
853+
854+
const stored = await mongoService.event.findOne({ _id: event._id });
855+
expect(stored?.calendarId).toEqual(source._id);
856+
});
857+
858+
it("reverts the calendar when Google refuses the move", async () => {
859+
const { user } = await UtilDriver.setupTestUser();
860+
const source = await seedGoogleCalendar(user._id, { access: "owner" });
861+
const destination = await seedGoogleCalendar(user._id, {
862+
access: "writer",
863+
isPrimary: false,
864+
});
865+
const event = buildEventRecord(source._id, {
866+
externalReference: {
867+
provider: "google",
868+
eventId: "google-event-1",
869+
recurringEventId: null,
870+
},
871+
});
872+
await mongoService.event.insertOne(event);
873+
874+
jest
875+
.spyOn(gcalService, "moveEvent")
876+
.mockRejectedValue(new Error("cannotChangeOrganizer"));
877+
878+
await expect(
879+
eventService.replace(
880+
user._id.toString(),
881+
event._id.toHexString(),
882+
moveInput(destination._id.toHexString()) as never,
883+
),
884+
).rejects.toMatchObject({ mutationCode: "PROVIDER_FAILURE" });
885+
886+
const stored = await mongoService.event.findOne({ _id: event._id });
887+
expect(stored?.calendarId).toEqual(source._id);
888+
});
889+
890+
it("rejects a move to a read-only destination with CALENDAR_READ_ONLY", async () => {
891+
const { user } = await UtilDriver.setupTestUser();
892+
const source = await seedLocalCalendar(user._id);
893+
const destination = await seedGoogleCalendar(user._id, {
894+
access: "reader",
895+
});
896+
const event = buildEventRecord(source._id);
897+
await mongoService.event.insertOne(event);
898+
899+
await expect(
900+
eventService.replace(
901+
user._id.toString(),
902+
event._id.toHexString(),
903+
moveInput(destination._id.toHexString()) as never,
904+
),
905+
).rejects.toMatchObject({ mutationCode: "CALENDAR_READ_ONLY" });
906+
907+
const stored = await mongoService.event.findOne({ _id: event._id });
908+
expect(stored?.calendarId).toEqual(source._id);
909+
});
910+
911+
it("publishes eventsChanged for both the source and destination calendars", async () => {
912+
const { user } = await UtilDriver.setupTestUser();
913+
const source = await seedLocalCalendar(user._id);
914+
const destination = await seedLocalCalendar(user._id);
915+
const event = buildEventRecord(source._id);
916+
await mongoService.event.insertOne(event);
917+
const publishSpy = jest.spyOn(sseServer, "publishEventsChanged");
918+
919+
await eventService.replace(
920+
user._id.toString(),
921+
event._id.toHexString(),
922+
moveInput(destination._id.toHexString()) as never,
923+
);
924+
925+
const notifiedCalendarIds = publishSpy.mock.calls.map(
926+
([, payload]) => payload.calendarId,
927+
);
928+
expect(notifiedCalendarIds).toEqual(
929+
expect.arrayContaining([
930+
source._id.toHexString(),
931+
destination._id.toHexString(),
932+
]),
933+
);
934+
});
935+
936+
it("moves the Google copy via events.move (then patches) when both calendars are Google", async () => {
937+
const { user } = await UtilDriver.setupTestUser();
938+
const source = await seedGoogleCalendar(user._id, { access: "owner" });
939+
const destination = await seedGoogleCalendar(user._id, {
940+
access: "writer",
941+
isPrimary: false,
942+
});
943+
const event = buildEventRecord(source._id, {
944+
externalReference: {
945+
provider: "google",
946+
eventId: "google-event-1",
947+
recurringEventId: null,
948+
},
949+
});
950+
await mongoService.event.insertOne(event);
951+
952+
const moveSpy = jest
953+
.spyOn(gcalService, "moveEvent")
954+
.mockResolvedValue({} as never);
955+
const patchSpy = jest
956+
.spyOn(gcalService, "patchEvent")
957+
.mockResolvedValue({} as never);
958+
959+
await eventService.replace(
960+
user._id.toString(),
961+
event._id.toHexString(),
962+
moveInput(destination._id.toHexString()) as never,
963+
);
964+
965+
expect(moveSpy).toHaveBeenCalledWith(
966+
expect.anything(),
967+
source.source.calendarId,
968+
"google-event-1",
969+
destination.source.calendarId,
970+
);
971+
expect(patchSpy).toHaveBeenCalledWith(
972+
expect.anything(),
973+
destination.source.calendarId,
974+
"google-event-1",
975+
expect.anything(),
976+
);
977+
978+
const stored = await mongoService.event.findOne({ _id: event._id });
979+
expect(stored?.externalReference).toEqual(
980+
expect.objectContaining({ eventId: "google-event-1" }),
981+
);
982+
});
983+
});

0 commit comments

Comments
 (0)