Skip to content

Commit ae085aa

Browse files
KrisBraunclaude
andcommitted
fix(google-calendar): cancelling an event now actually cancels it
Setting an event link's status to "Cancelled" only updated the local status field — the event stayed live on Google Calendar and kept appearing in the agenda, since Google's next sync would just report it as confirmed again. Add onLinkUpdated to delete the event via the Calendar API when its status changes to "Cancelled" (covers both a standalone event and a single occurrence of a recurring series). The resulting cancellation then flows back through the existing incremental-sync path the same way an externally-cancelled event does. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ab9b4b4 commit ae085aa

3 files changed

Lines changed: 92 additions & 0 deletions

File tree

‎connectors/google-calendar/src/google-calendar.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type Actor,
44
type ActorId,
55
Connector,
6+
type Link,
67
type Thread,
78
type ToolBuilder,
89
} from "@plotday/twister";
@@ -27,6 +28,7 @@ import {
2728
} from "./channels";
2829
import {
2930
SYNC_LOCK_TTL_MS,
31+
cancelEventWithApiFn,
3032
clearBuffersFn,
3133
extractRSVPParamsFn,
3234
getApiFn,
@@ -642,6 +644,38 @@ export class GoogleCalendar extends Connector<GoogleCalendar> {
642644
});
643645
}
644646
}
647+
648+
/**
649+
* Called when a user changes an event link's status in Plot. Only
650+
* "Cancelled" write-backs here: "Confirmed"/"Tentative" just mirror
651+
* Google's own `event.status` and aren't meaningfully user-settable for
652+
* events you organize. Deleting the event (rather than PATCHing
653+
* status=cancelled, which Google only documents as reliable for
654+
* recurring instance exceptions) mirrors how a real cancellation arrives
655+
* from Google, so the next incremental sync's already-tested
656+
* cancellation path archives the schedule and removes it from the
657+
* agenda. Best-effort: a failed write-back is reconciled by the user
658+
* retrying the status change.
659+
*/
660+
async onLinkUpdated(link: Link): Promise<void> {
661+
if (link.status !== "Cancelled") return;
662+
663+
const calendarId = link.channelId;
664+
const eventId = (link.meta as Record<string, unknown> | null)?.id as
665+
| string
666+
| undefined;
667+
if (!calendarId || !eventId) return;
668+
669+
try {
670+
const api = await this.getApi(calendarId);
671+
await cancelEventWithApiFn(api, calendarId, eventId);
672+
} catch (error) {
673+
console.error("[GoogleCalendar] onLinkUpdated write-back failed:", {
674+
event_id: eventId,
675+
error: error instanceof Error ? error.message : String(error),
676+
});
677+
}
678+
}
645679
}
646680

647681
export default GoogleCalendar;

‎connectors/google-calendar/src/sync.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { NewLinkWithNotes } from "@plotday/twister";
1010
import type { Thread } from "@plotday/twister";
1111
import type { CalendarSyncHost } from "./sync";
1212
import {
13+
cancelEventWithApiFn,
1314
cancellationWasSelfInitiatedFn,
1415
extractRSVPParamsFn,
1516
getWatchRenewalScheduleFn,
@@ -780,6 +781,45 @@ describe("updateEventRSVPWithApiFn", () => {
780781
});
781782
});
782783

784+
describe("cancelEventWithApiFn", () => {
785+
afterEach(() => vi.unstubAllGlobals());
786+
787+
it("issues a DELETE against the event's own calendar/eventId", async () => {
788+
const calendarId = "user@example.com";
789+
const eventId = "event-abc";
790+
791+
const fetchSpy = vi.fn(async (url: string, init?: RequestInit) => {
792+
if (
793+
url ===
794+
`https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${eventId}` &&
795+
init?.method === "DELETE"
796+
) {
797+
return new Response(null, { status: 204 });
798+
}
799+
throw new Error(`Unexpected fetch: ${url}`);
800+
});
801+
vi.stubGlobal("fetch", fetchSpy);
802+
803+
const api = new GoogleApi("fake-token");
804+
await cancelEventWithApiFn(api, calendarId, eventId);
805+
806+
expect(fetchSpy).toHaveBeenCalledTimes(1);
807+
});
808+
809+
it("is a no-op (doesn't throw) when the event is already gone", async () => {
810+
const calendarId = "user@example.com";
811+
const eventId = "event-already-deleted";
812+
813+
const fetchSpy = vi.fn(async () => new Response(null, { status: 410 }));
814+
vi.stubGlobal("fetch", fetchSpy);
815+
816+
const api = new GoogleApi("fake-token");
817+
await expect(
818+
cancelEventWithApiFn(api, calendarId, eventId)
819+
).resolves.toBeUndefined();
820+
});
821+
});
822+
783823
describe("setupCalendarWatchFn", () => {
784824
afterEach(() => vi.unstubAllGlobals());
785825

‎connectors/google-calendar/src/sync.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1603,6 +1603,24 @@ export async function updateEventRSVPWithApiFn(
16031603
);
16041604
}
16051605

1606+
/**
1607+
* Cancels a Google Calendar event by deleting it. Works uniformly for a
1608+
* standalone/master event and a single occurrence of a recurring series —
1609+
* both are addressed by their own `eventId`, and Google reports a deleted
1610+
* occurrence as a cancelled exception on the next sync rather than removing
1611+
* the series. `GoogleApi.call` treats 410 (already gone) as a no-op.
1612+
*/
1613+
export async function cancelEventWithApiFn(
1614+
api: GoogleApi,
1615+
calendarId: string,
1616+
eventId: string
1617+
): Promise<void> {
1618+
await api.call(
1619+
"DELETE",
1620+
`https://www.googleapis.com/calendar/v3/calendars/${calendarId}/events/${eventId}`
1621+
);
1622+
}
1623+
16061624
/**
16071625
* Parameters extracted from a thread for an RSVP write-back.
16081626
*

0 commit comments

Comments
 (0)