From 5b5128daa80cb99191f7783ad6098ca1035fa3b4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 02:15:26 +0000 Subject: [PATCH 1/2] feat(web): replace provider gates with capabilities and neutral copy EventForm and useSaveEventForm gate attendee editing on canInviteAttendees instead of provider === google. Default target calendar selection uses any writable connected provider calendar. User-facing strings name the calendar host generically or via providerDisplayName when known. Fixes #3234 Co-authored-by: Tyler Dane --- .agents/handoffs/3234.md | 35 +++++++++++++++ docs/features/google-sync-and-sse-flow.md | 5 +++ .../booking/services/booking-page.service.ts | 4 +- packages/web/src/calendars/calendar.util.ts | 6 +-- .../migrations/external/demo-data-seed.ts | 4 +- .../export-user-data.util.test.ts | 6 +-- .../offline-data/export-user-data.util.ts | 4 +- .../src/common/utils/event/event.util.test.ts | 2 +- .../web/src/common/utils/event/event.util.ts | 4 +- .../DeleteAccountConfirmationDialog.test.tsx | 4 +- .../DeleteAccountConfirmationDialog.tsx | 2 +- .../EventForm/EventForm.attendees.test.tsx | 39 +++++++++++++++- .../src/views/Forms/EventForm/EventForm.tsx | 6 +-- .../Forms/EventForm/SendInvitationsDialog.tsx | 14 ++++-- .../hooks/useSaveEventForm.attendees.test.tsx | 44 ++++++++++++++++++- .../src/views/Forms/hooks/useSaveEventForm.ts | 23 ++++++++-- 16 files changed, 172 insertions(+), 30 deletions(-) create mode 100644 .agents/handoffs/3234.md diff --git a/.agents/handoffs/3234.md b/.agents/handoffs/3234.md new file mode 100644 index 0000000000..97a8ea8f18 --- /dev/null +++ b/.agents/handoffs/3234.md @@ -0,0 +1,35 @@ +--- +schema_version: 1 +task_id: "3234" +from: Implementer +to: GitHub +owner: GitHub +status: verifying +artifact: + - path: packages/web/src/views/Forms/EventForm/EventForm.tsx + - path: packages/web/src/views/Forms/hooks/useSaveEventForm.ts + - path: packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx + - path: packages/web/src/calendars/calendar.util.ts + - path: packages/web/src/common/utils/event/event.util.ts + - path: packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.tsx + - path: packages/web/src/common/storage/offline-data/export-user-data.util.ts + - path: packages/web/src/common/storage/migrations/external/demo-data-seed.ts + - path: packages/backend/src/booking/services/booking-page.service.ts + - path: docs/features/google-sync-and-sse-flow.md +evidence: + - command: bun run verify --strict + result: pending + - command: bun test:web EventForm.attendees useSaveEventForm.attendees + result: pass +assumptions: + - "RSVP account email uses any non-local provider calendar with accountEmail." + - "SendInvitationsDialog host label comes from providerDisplayName when known." +open_risks: [] +next_deadline: 2026-09-05T12:00:00Z +retry: 0 +approval: allow +waiting_on: null +escalation: null +--- + +P0 WP-09: capability gates replace provider checks; provider-neutral copy pass. diff --git a/docs/features/google-sync-and-sse-flow.md b/docs/features/google-sync-and-sse-flow.md index 9d54fafed2..fdbab6cd9b 100644 --- a/docs/features/google-sync-and-sse-flow.md +++ b/docs/features/google-sync-and-sse-flow.md @@ -1,5 +1,10 @@ # Google Sync And Server-Sent Events (SSE) +> **Multi-provider note:** Google is the first connected calendar host in +> Compass. Product-wide provider behavior, capability gates, and connect flows +> are specified in [`calendar-providers.md`](./calendar-providers.md). This +> document covers the Google sync path and SSE wiring only. + Google Calendar sync is owned entirely by the standalone **Sync service** (`packages/sync`) — the backend has no Google API calls or sync logic of its own. The backend's role is: proxy sync-related reads/writes to Sync, poll diff --git a/packages/backend/src/booking/services/booking-page.service.ts b/packages/backend/src/booking/services/booking-page.service.ts index faf7c11a96..9648d9edeb 100644 --- a/packages/backend/src/booking/services/booking-page.service.ts +++ b/packages/backend/src/booking/services/booking-page.service.ts @@ -67,7 +67,7 @@ const assertHealthyGoogleForEnable = async (userId: string): Promise => { if (connection.connectionState !== "HEALTHY") { throw bookingError( "GOOGLE_NOT_CONNECTED", - "Connect a healthy Google account before enabling booking", + "Connect a healthy calendar account before enabling booking", ); } }; @@ -102,7 +102,7 @@ const assertCalendarsForEnable = async ( if (!destination) { throw bookingError( "DESTINATION_NOT_WRITABLE", - "Destination calendar must be a writable Google calendar", + "Destination calendar must be a writable calendar", ); } diff --git a/packages/web/src/calendars/calendar.util.ts b/packages/web/src/calendars/calendar.util.ts index 7e0113f4ee..66faeba358 100644 --- a/packages/web/src/calendars/calendar.util.ts +++ b/packages/web/src/calendars/calendar.util.ts @@ -217,11 +217,11 @@ export interface DefaultTargetCalendarOptions { reconnectRequiredEmails?: ReadonlySet | readonly string[]; } -const isWritableGoogleCalendar = ( +const isWritableProviderCalendar = ( calendar: Calendar, reconnectRequiredEmails: ReadonlySet | null, ): boolean => - calendar.provider === "google" && + calendar.provider !== "local" && calendar.capabilities.canWrite && !calendarNeedsReconnect(calendar, reconnectRequiredEmails); @@ -260,7 +260,7 @@ export function getDefaultTargetCalendar( const primaries = calendars.filter( (calendar) => calendar.isPrimary && - isWritableGoogleCalendar(calendar, reconnectRequiredEmails), + isWritableProviderCalendar(calendar, reconnectRequiredEmails), ); const byConnectionOrder = accountEmailOrder .map((email) => diff --git a/packages/web/src/common/storage/migrations/external/demo-data-seed.ts b/packages/web/src/common/storage/migrations/external/demo-data-seed.ts index 6ad73c99ed..9181631165 100644 --- a/packages/web/src/common/storage/migrations/external/demo-data-seed.ts +++ b/packages/web/src/common/storage/migrations/external/demo-data-seed.ts @@ -143,7 +143,7 @@ function generateDemoData() { // connected Google yet. conference: { url: "https://meet.google.com/abc-defg-hij", - label: "Google Meet", + label: "Video call", }, organizer: { email: "avery@example.com", displayName: "Avery" }, attendees: [ @@ -172,7 +172,7 @@ function generateDemoData() { createEventRecord({ title: "Try Compass", description: - "Welcome! Click any empty time slot to create an event, or press C. When you're ready to sync Google Calendar, use the Connect Google Calendar button in the sidebar.", + "Welcome! Click any empty time slot to create an event, or press C. When you're ready to sync your calendar, use Connect calendar in the sidebar.", schedule: { kind: "timed", start: todayAt(10, 0), diff --git a/packages/web/src/common/storage/offline-data/export-user-data.util.test.ts b/packages/web/src/common/storage/offline-data/export-user-data.util.test.ts index f26f6d848a..70d43049fc 100644 --- a/packages/web/src/common/storage/offline-data/export-user-data.util.test.ts +++ b/packages/web/src/common/storage/offline-data/export-user-data.util.test.ts @@ -49,13 +49,13 @@ describe("collectExportData", () => { expect(typeof result.exportedAt).toBe("string"); }); - it("explains what the export contains and why Google-synced events are absent", async () => { + it("explains what the export contains and why synced events are absent", async () => { const result = await collectExportData(); expect(result.about.whatThisIs.toLowerCase()).toContain("indexeddb"); - expect(result.about.whatThisIs.toLowerCase()).toContain("google calendar"); + expect(result.about.whatThisIs.toLowerCase()).not.toContain("google"); expect(result.about.events.toLowerCase()).toContain("locally"); - expect(result.about.events.toLowerCase()).toContain("google calendar"); + expect(result.about.events.toLowerCase()).toContain("calendar host"); expect(result.about.tasks.toLowerCase()).toContain("tasks"); }); diff --git a/packages/web/src/common/storage/offline-data/export-user-data.util.ts b/packages/web/src/common/storage/offline-data/export-user-data.util.ts index 64811cf8bc..af72e56c1a 100644 --- a/packages/web/src/common/storage/offline-data/export-user-data.util.ts +++ b/packages/web/src/common/storage/offline-data/export-user-data.util.ts @@ -8,9 +8,9 @@ import { const EXPORT_ABOUT = { whatThisIs: - "Snapshot of data Compass stores in this browser (IndexedDB). It is not a full account or Google Calendar dump.", + "Snapshot of data Compass stores in this browser (IndexedDB). It is not a full account or calendar dump.", events: - "Only calendar events still stored locally in this browser. If you connected Google Calendar, those events live in Google Calendar (and on Compass's servers when signed in), so they will not appear here.", + "Only calendar events still stored locally in this browser. If you connected a calendar, those events live with your calendar host (and on Compass's servers when signed in), so they will not appear here.", tasks: "Legacy to-do items from a Tasks feature we removed. Any still retained in this browser are listed below; they are cleared after a successful export.", someday: diff --git a/packages/web/src/common/utils/event/event.util.test.ts b/packages/web/src/common/utils/event/event.util.test.ts index 23f375bd62..78b3d31794 100644 --- a/packages/web/src/common/utils/event/event.util.test.ts +++ b/packages/web/src/common/utils/event/event.util.test.ts @@ -208,7 +208,7 @@ describe("handleError", () => { expect(mockCaptureException).not.toHaveBeenCalled(); expect(mocks.error).toHaveBeenCalledTimes(1); const [message] = mocks.error.mock.calls[0] ?? []; - expect(message).toContain("Google doesn't allow this change"); + expect(message).toContain("Your calendar doesn't allow this change"); expect(message).not.toBe( "Something went wrong behind the scenes. Please try again later.", ); diff --git a/packages/web/src/common/utils/event/event.util.ts b/packages/web/src/common/utils/event/event.util.ts index 2701fd75e0..f4455280f8 100644 --- a/packages/web/src/common/utils/event/event.util.ts +++ b/packages/web/src/common/utils/event/event.util.ts @@ -224,13 +224,13 @@ const MUTATION_ERROR_TOAST_MESSAGES: Partial< Record > = { UNSUPPORTED_OPERATION: - "Google doesn't allow this change for this event (like birthdays or holidays). Try deleting the entire series, or manage it in Google Calendar.", + "Your calendar doesn't allow this change for this event (like birthdays or holidays). Try deleting the entire series, or manage it in your calendar app.", CALENDAR_READ_ONLY: "This calendar is read-only, so its events can't be changed from Compass.", RECURRENCE_CONFLICT: "This event was changed somewhere else. Refresh to load the latest version, then try again.", GOOGLE_REVOKED: - "Google Calendar access expired or was revoked. Reconnect Google Calendar in Compass to resume syncing.", + "Calendar access expired or was revoked. Reconnect your calendar in Compass to resume syncing.", }; const showCatchallToast = (message: string) => diff --git a/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.test.tsx b/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.test.tsx index 6d90bcc322..ce0a7b65e1 100644 --- a/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.test.tsx +++ b/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.test.tsx @@ -41,11 +41,11 @@ describe("DeleteAccountConfirmationDialog", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - it("tells the user their Google Calendar data is left alone", () => { + it("tells the user their connected calendar data is left alone", () => { setup(); expect(screen.getByRole("dialog")).toHaveTextContent( - /Your Google Calendar is not affected/i, + /Your calendar is not affected/i, ); }); diff --git a/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.tsx b/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.tsx index 29c8012685..2ad8852b6e 100644 --- a/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.tsx +++ b/packages/web/src/components/DeleteAccountConfirmation/DeleteAccountConfirmationDialog.tsx @@ -10,7 +10,7 @@ export const DELETE_ACCOUNT_PHRASE = "Delete my Compass account"; const INTRO_TEXT = [ "This deletes your Compass account and data: your calendars, events, and settings. It can't be undone.", "If you have a Compass trial or subscription, it is canceled immediately and your saved payment details are removed. Previous payments aren't refunded automatically.", - "Your Google Calendar is not affected. Nothing there gets deleted, Compass just loses its access to it.", + "Your calendar is not affected. Nothing there gets deleted, Compass just loses its access to it.", ].join("\n\n"); interface DeleteAccountConfirmationDialogProps { diff --git a/packages/web/src/views/Forms/EventForm/EventForm.attendees.test.tsx b/packages/web/src/views/Forms/EventForm/EventForm.attendees.test.tsx index 19c7bd6c3e..38c2e3b14f 100644 --- a/packages/web/src/views/Forms/EventForm/EventForm.attendees.test.tsx +++ b/packages/web/src/views/Forms/EventForm/EventForm.attendees.test.tsx @@ -249,10 +249,47 @@ describe("EventForm attendee editor gating", () => { ).toBeInTheDocument(); }); - it("shows no editor on a non-Google (local) calendar", () => { + it("keeps the read-only guest list on a Google read-only calendar", () => { + const calendar = makeCalendar({ + provider: "google", + access: "reader", + capabilities: { + ...getCalendarCapabilities("reader"), + canInviteAttendees: false, + }, + }); + const draft = editDraftOrThrow(makeMeetingEvent(calendar.id)); + + renderEventForm(draft, [calendar]); + + expect( + screen.queryByRole("combobox", { name: "Guests" }), + ).not.toBeInTheDocument(); + }); + + it("renders the guest combobox for a Microsoft calendar with canInviteAttendees", () => { + const calendar = makeCalendar({ + provider: "microsoft", + capabilities: { + ...getCalendarCapabilities("owner"), + canInviteAttendees: true, + }, + }); + const draft = editDraftOrThrow(makeMeetingEvent(calendar.id)); + + renderEventForm(draft, [calendar]); + + expect(screen.getByRole("combobox", { name: "Guests" })).toBeEnabled(); + }); + + it("shows no editor on a non-provider (local) calendar", () => { const calendar = makeCalendar({ provider: "local", accountEmail: undefined, + capabilities: { + ...getCalendarCapabilities("owner"), + canInviteAttendees: false, + }, }); const event = createMockEvent({ calendarId: calendar.id, diff --git a/packages/web/src/views/Forms/EventForm/EventForm.tsx b/packages/web/src/views/Forms/EventForm/EventForm.tsx index 59393ee2cd..667bf336d9 100644 --- a/packages/web/src/views/Forms/EventForm/EventForm.tsx +++ b/packages/web/src/views/Forms/EventForm/EventForm.tsx @@ -319,7 +319,7 @@ export const EventForm: React.FC = memo( attendeeCalendar.accountEmail.toLowerCase()); const showAttendeeEditor = !isReadOnly && - attendeeCalendar?.provider === "google" && + attendeeCalendar?.capabilities.canInviteAttendees === true && attendeeCalendar.capabilities.canWrite && organizesEvent; const guestEditIsSeriesWide = @@ -331,8 +331,8 @@ export const EventForm: React.FC = memo( // Deliberately NOT gated on writability: answering an invitation is // allowed on viewer-access calendars. const rsvpAccountEmail = - attendeeCalendar?.provider === "google" - ? attendeeCalendar.accountEmail + attendeeCalendar?.provider !== "local" + ? attendeeCalendar?.accountEmail : undefined; const showRsvpControl = rsvpSource !== null && diff --git a/packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx b/packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx index 657f17c0d8..25fc0ee8d4 100644 --- a/packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx +++ b/packages/web/src/views/Forms/EventForm/SendInvitationsDialog.tsx @@ -12,20 +12,26 @@ type SendInvitationsDialogProps = { /** * Save-time "Send invitation emails?" choice, shown only when a save changed - * the guest set. Send (the default, focused on open) has Google email the - * affected guests (`invitation: "all"`); Don't send saves silently + * the guest set. Send (the default, focused on open) has the calendar host + * email the affected guests (`invitation: "all"`); Don't send saves silently * (`"none"`). Dismissing (Escape / backdrop) cancels the save and returns to - * the form. Compass never sends email itself — Google does, via sendUpdates. + * the form. Compass never sends email itself — the connected calendar host + * does, via sendUpdates or equivalent provider semantics. */ export function SendInvitationsDialog({ prompt }: SendInvitationsDialogProps) { const sendButtonRef = useRef(null); if (!prompt) return null; + const host = + prompt.invitationEmailHost === "your calendar" + ? "Your calendar" + : prompt.invitationEmailHost; + return ( = {}): Calendar => ({ +const providerCalendar = (overrides: Partial = {}): Calendar => ({ id: calendarId, name: "Work", description: "", @@ -48,6 +48,8 @@ const googleCalendar = (overrides: Partial = {}): Calendar => ({ ...overrides, }); +const googleCalendar = providerCalendar; + const meetingEvent = (overrides: Partial = {}): Event => createMockEvent({ calendarId, @@ -245,6 +247,46 @@ describe("useSaveEventForm guest edits", () => { expect(variables?.input.invitation).toBe("all"); }); + it("keeps attendee edits for any provider with canInviteAttendees", () => { + const { queryClient, Wrapper } = createWrapper(); + queryClient.setQueryData(calendarQueryKeys.all, [ + providerCalendar({ + provider: "microsoft", + capabilities: { + ...getCalendarCapabilities("owner"), + canInviteAttendees: true, + }, + }), + ]); + const { result } = renderHook(() => useSaveEventForm(), { + wrapper: Wrapper, + }); + const draft = createGridEventDraft( + timedGridSchedule( + new Date("2026-05-20T10:00:00.000Z"), + new Date("2026-05-20T11:00:00.000Z"), + ), + undefined, + calendarId, + ); + draft.values.attendees = [ + { email: "new-guest@example.com", displayName: null }, + ]; + + act(() => { + result.current.saveEventForm(draft); + }); + act(() => { + result.current.invitationPrompt?.onSend(); + }); + + const variables = createVariables(queryClient); + expect(variables?.input.content.attendees).toEqual([ + { email: "new-guest@example.com", displayName: null }, + ]); + expect(variables?.input.invitation).toBe("all"); + }); + it("drops a guest edit on a recurring event saved at a non-'all' scope (belt behind the UI gates)", () => { const { queryClient, Wrapper } = createWrapper(); const { result } = renderHook(() => useSaveEventForm(), { diff --git a/packages/web/src/views/Forms/hooks/useSaveEventForm.ts b/packages/web/src/views/Forms/hooks/useSaveEventForm.ts index 837a6b3164..2238bf95e9 100644 --- a/packages/web/src/views/Forms/hooks/useSaveEventForm.ts +++ b/packages/web/src/views/Forms/hooks/useSaveEventForm.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState } from "react"; import { EventIdSchema } from "@core/types/domain-primitives"; import { type CreateEventInput } from "@core/types/event-command.contracts"; +import { providerDisplayName } from "@core/types/sync/identity.contracts"; import { useCalendarsQuery } from "@web/calendars/calendar.query"; import { useDefaultTargetCalendar } from "@web/calendars/useDefaultTargetCalendar"; import { RecurringEventUpdateScope } from "@web/common/types/web.event.types"; @@ -24,6 +25,7 @@ type InvitationIntentValue = NonNullable; * waiting on the choice. */ export type EventInvitationPrompt = { + invitationEmailHost: string; onSend: () => void; onDontSend: () => void; onCancel: () => void; @@ -76,11 +78,14 @@ export function useSaveEventForm() { return draft; } - // Create: guests only deliver to a writable Google calendar + // Create: guests only deliver to a calendar that can invite attendees // (ATTENDEES_UNSUPPORTED backstop server-side). const calendarId = draft.values.calendarId ?? defaultTargetCalendarId; const calendar = calendars?.find((entry) => entry.id === calendarId); - if (calendar?.provider !== "google" || !calendar.capabilities.canWrite) { + if ( + !calendar?.capabilities.canInviteAttendees || + !calendar.capabilities.canWrite + ) { console.warn( "[useSaveEventForm] dropped guest edit: target calendar cannot deliver a guest list", ); @@ -201,16 +206,28 @@ export function useSaveEventForm() { const invitationPrompt: EventInvitationPrompt = useMemo(() => { if (!pendingInvitationSave) return null; const { draft, applyTo } = pendingInvitationSave; + const calendarId = + draft.values.calendarId ?? + (draft.kind === "edit" + ? draft.source.calendarId + : defaultTargetCalendarId); + const calendar = calendars?.find((entry) => entry.id === calendarId); + const provider = calendar?.provider; + const invitationEmailHost = + provider === "google" || provider === "microsoft" || provider === "apple" + ? providerDisplayName(provider) + : "your calendar"; const resolve = (invitation: InvitationIntentValue) => { setPendingInvitationSave(null); commitSave(draft, applyTo, invitation); }; return { + invitationEmailHost, onSend: () => resolve("all"), onDontSend: () => resolve("none"), onCancel: () => setPendingInvitationSave(null), }; - }, [commitSave, pendingInvitationSave]); + }, [calendars, commitSave, defaultTargetCalendarId, pendingInvitationSave]); return { saveEventForm, fieldErrors, clearFieldErrors, invitationPrompt }; } From 148a30ba37add37aab8cff0ca20a64366e650f9e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 02:15:45 +0000 Subject: [PATCH 2/2] docs(handoff): record verify pass for #3234 Co-authored-by: Tyler Dane --- .agents/handoffs/3234.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/handoffs/3234.md b/.agents/handoffs/3234.md index 97a8ea8f18..a34a51d35e 100644 --- a/.agents/handoffs/3234.md +++ b/.agents/handoffs/3234.md @@ -18,7 +18,7 @@ artifact: - path: docs/features/google-sync-and-sse-flow.md evidence: - command: bun run verify --strict - result: pending + result: "VERDICT: PASS" - command: bun test:web EventForm.attendees useSaveEventForm.attendees result: pass assumptions: