diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.test.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.test.ts index 6cfb10310..afa2be691 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.test.ts +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.test.ts @@ -16,19 +16,19 @@ describe("formatLastSyncedLabel", () => { it("formats recent relative ages", () => { expect(formatLastSyncedLabel("2026-07-24T11:59:30.000Z", nowMs)).toBe( - "Last synced just now", + "Updated just now", ); expect(formatLastSyncedLabel("2026-07-24T11:59:00.000Z", nowMs)).toBe( - "Last synced 1 minute ago", + "Updated 1 minute ago", ); expect(formatLastSyncedLabel("2026-07-24T11:45:00.000Z", nowMs)).toBe( - "Last synced 15 minutes ago", + "Updated 15 minutes ago", ); expect(formatLastSyncedLabel("2026-07-24T10:00:00.000Z", nowMs)).toBe( - "Last synced 2 hours ago", + "Updated 2 hours ago", ); expect(formatLastSyncedLabel("2026-07-22T12:00:00.000Z", nowMs)).toBe( - "Last synced 2 days ago", + "Updated 2 days ago", ); }); }); @@ -41,25 +41,22 @@ describe("getGoogleSyncStatus", () => { it("returns healthy copy for connected Google", () => { expect(getGoogleSyncStatus("HEALTHY")).toEqual({ variant: "healthy", - text: "Calendar up-to-date", + text: "Calendar connected", }); }); - it("returns checking copy while metadata loads", () => { - expect(getGoogleSyncStatus("checking")).toEqual({ - variant: "syncing", - text: "Checking calendar status…", - }); + it("hides transient metadata loading without a connection summary", () => { + expect(getGoogleSyncStatus("checking")).toBeNull(); }); - it("returns syncing copy while importing", () => { + it("uses setup copy while importing", () => { expect(getGoogleSyncStatus("IMPORTING")).toEqual({ variant: "syncing", - text: "Syncing your calendar…", + text: "Adding your calendar…", }); }); - it("shows checking progress over a cached healthy connection", () => { + it("keeps a cached healthy connection calm while metadata loads", () => { expect( getGoogleSyncStatus("checking", { id: "c1", @@ -70,12 +67,12 @@ describe("getGoogleSyncStatus", () => { accountEmail: "a@example.com", }), ).toEqual({ - variant: "syncing", - text: "Checking calendar status…", + variant: "healthy", + text: "Calendar connected", }); }); - it("shows import progress over a cached healthy connection", () => { + it("keeps a cached healthy connection calm during a routine refresh", () => { expect( getGoogleSyncStatus("IMPORTING", { id: "c1", @@ -86,8 +83,8 @@ describe("getGoogleSyncStatus", () => { accountEmail: "a@example.com", }), ).toEqual({ - variant: "syncing", - text: "Syncing your calendar…", + variant: "healthy", + text: "Calendar connected", }); }); @@ -95,15 +92,16 @@ describe("getGoogleSyncStatus", () => { const status = getGoogleSyncStatus("ATTENTION"); expect(status?.variant).toBe("warning"); - expect(status?.text.toLowerCase()).not.toContain("repair"); - expect(status?.text.toLowerCase()).toContain("refresh"); + expect(status?.text).toBe( + "Calendar updates are taking longer than usual. We'll keep trying.", + ); }); it("returns error copy for RECONNECT_REQUIRED", () => { expect(getGoogleSyncStatus("RECONNECT_REQUIRED")?.variant).toBe("error"); }); - it("uses the same syncing copy for every in-progress Sync state", () => { + it("shows setup copy for a connection that has never been healthy", () => { expect( getGoogleSyncStatus("IMPORTING", { id: "c1", @@ -115,7 +113,7 @@ describe("getGoogleSyncStatus", () => { }), ).toEqual({ variant: "syncing", - text: "Syncing your calendar…", + text: "Adding your calendar…", }); }); @@ -131,7 +129,7 @@ describe("getGoogleSyncStatus", () => { }), ).toEqual({ variant: "warning", - text: "Calendar sync is delayed — try Refresh", + text: "Calendar updates are taking longer than usual. We'll keep trying.", }); }); @@ -147,7 +145,7 @@ describe("getGoogleSyncStatus", () => { }), ).toEqual({ variant: "healthy", - text: "Calendar up-to-date", + text: "Calendar connected", }); }); }); diff --git a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts index 26526f751..b692ad253 100644 --- a/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts +++ b/packages/web/src/auth/google/hooks/useConnectGoogle/useConnectGoogle.util.ts @@ -9,6 +9,14 @@ import { const CONNECT_ICON: CommandActionIcon = CloudArrowUpIcon; const REFRESH_ICON: CommandActionIcon = ArrowsClockwiseIcon; +const CONNECTED_STATUS: SyncStatus = { + variant: "healthy", + text: "Calendar connected", +}; +const DELAYED_STATUS: SyncStatus = { + variant: "warning", + text: "Calendar updates are taking longer than usual. We'll keep trying.", +}; /** Short relative label for Sync connection `lastSyncedAt` (ISO). */ export const formatLastSyncedLabel = ( @@ -26,31 +34,31 @@ export const formatLastSyncedLabel = ( const deltaSec = Math.max(0, Math.floor((nowMs - syncedMs) / 1000)); if (deltaSec < 60) { - return "Last synced just now"; + return "Updated just now"; } const deltaMin = Math.floor(deltaSec / 60); if (deltaMin < 60) { return deltaMin === 1 - ? "Last synced 1 minute ago" - : `Last synced ${deltaMin} minutes ago`; + ? "Updated 1 minute ago" + : `Updated ${deltaMin} minutes ago`; } const deltaHr = Math.floor(deltaMin / 60); if (deltaHr < 24) { return deltaHr === 1 - ? "Last synced 1 hour ago" - : `Last synced ${deltaHr} hours ago`; + ? "Updated 1 hour ago" + : `Updated ${deltaHr} hours ago`; } const deltaDay = Math.floor(deltaHr / 24); if (deltaDay < 7) { return deltaDay === 1 - ? "Last synced 1 day ago" - : `Last synced ${deltaDay} days ago`; + ? "Updated 1 day ago" + : `Updated ${deltaDay} days ago`; } - return `Last synced ${new Date(syncedMs).toLocaleDateString()}`; + return `Updated ${new Date(syncedMs).toLocaleDateString()}`; }; export type GoogleConnectionHandlers = { @@ -102,29 +110,21 @@ export const getGoogleSyncStatus = ( state: GoogleUiState, connection?: GoogleSyncConnectionSummary | null, ): SyncStatus => { - // Local transient states are newer than a cached server summary, which can - // still report healthy while a fresh check or import is underway. - if (state === "checking") { - return { variant: "syncing", text: "Checking calendar status…" }; - } - - if (state === "IMPORTING") { - return { variant: "syncing", text: "Syncing your calendar…" }; - } - + // A connection summary describes durable provider work. Local metadata + // loading and a routine incremental pull must not replace a calm, usable + // calendar with transient "checking" or "syncing" copy. if (connection) { switch (connection.state) { case "healthy": - return { variant: "healthy", text: "Calendar up-to-date" }; + return CONNECTED_STATUS; case "connecting": case "importing": case "catchingUp": - return { variant: "syncing", text: "Syncing your calendar…" }; + return connection.lastHealthyAt + ? CONNECTED_STATUS + : { variant: "syncing", text: "Adding your calendar…" }; case "delayed": - return { - variant: "warning", - text: "Calendar sync is delayed — try Refresh", - }; + return DELAYED_STATUS; case "actionRequired": case "disconnected": // Product enum already distinguishes reconnect vs soft attention. @@ -133,13 +133,14 @@ export const getGoogleSyncStatus = ( } switch (state) { + case "checking": + return null; + case "IMPORTING": + return { variant: "syncing", text: "Adding your calendar…" }; case "HEALTHY": - return { variant: "healthy", text: "Calendar up-to-date" }; + return CONNECTED_STATUS; case "ATTENTION": - return { - variant: "warning", - text: "Calendar needs a refresh", - }; + return DELAYED_STATUS; case "RECONNECT_REQUIRED": return { variant: "error", text: "Calendar needs reconnecting" }; case "NOT_CONNECTED": diff --git a/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx b/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx index c0f90e6f7..358cb09c3 100644 --- a/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx +++ b/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.test.tsx @@ -194,8 +194,8 @@ describe("CalendarListHeader", () => { expect(email.tagName).toBe("SPAN"); expect(email).toHaveClass("text-text"); expect(email).not.toHaveClass("c-sync-text-wave"); - expect(screen.getByRole("status")).toHaveTextContent("Calendar up-to-date"); - expect(screen.queryByText(/Last synced/)).toBeNull(); + expect(screen.getByRole("status")).toHaveTextContent("Calendar connected"); + expect(screen.queryByText(/Updated/)).toBeNull(); await user.hover(email); expect(screen.queryByRole("tooltip")).toBeNull(); @@ -220,26 +220,25 @@ describe("CalendarListHeader", () => { renderHeader(); - expect(screen.getByRole("status")).toHaveTextContent("Calendar up-to-date"); - expect(screen.getByText("Last synced just now")).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Calendar connected"); + expect(screen.getByText("Updated just now")).toBeInTheDocument(); expect( screen.queryByRole("button", { name: /Google Calendar/ }), ).toBeNull(); }); - it.each([ - ["IMPORTING", "Syncing your calendar…"], - ["checking", "Checking calendar status…"], - ] as const)("shows the visible %s status for %s", async (state, status) => { + it("shows setup status only while the first calendar import is in progress", async () => { const user = userEvent.setup(); mockEmail = "ahab@pequod.com"; - mockGoogleState = state; + mockGoogleState = "IMPORTING"; renderHeader(); const email = screen.getByText("ahab@pequod.com"); expect(email).toHaveClass("c-sync-text-wave"); - expect(screen.getByRole("status")).toHaveTextContent(status); + expect(screen.getByRole("status")).toHaveTextContent( + "Adding your calendar…", + ); await user.hover(email); expect(screen.queryByRole("tooltip")).toBeNull(); @@ -257,7 +256,7 @@ describe("CalendarListHeader", () => { expect(email).toHaveClass("text-text"); expect(email).not.toHaveClass("text-warning"); expect(screen.getByRole("status")).toHaveTextContent( - "Calendar needs a refresh", + "Calendar updates are taking longer than usual. We'll keep trying.", ); expect(screen.getByRole("status")).toHaveClass("text-warning"); @@ -340,7 +339,7 @@ describe("CalendarListHeader", () => { name: "Refreshing…", }); expect(screen.getByRole("status")).toHaveTextContent( - "Requesting a calendar refresh…", + "Calendar updates are taking longer than usual. We'll keep trying.", ); expect(refreshButton).toBeDisabled(); expect(refreshButton).toHaveAttribute("aria-busy", "true"); @@ -379,7 +378,7 @@ describe("CalendarListHeader", () => { ); }); - it("uses the same syncing copy for an in-progress reconciliation", () => { + it("keeps an established calendar calm during reconciliation", () => { mockEmail = "ahab@pequod.com"; mockGoogleState = "HEALTHY"; userMetadataActions.set({ @@ -398,10 +397,8 @@ describe("CalendarListHeader", () => { renderHeader(); - expect(screen.getByRole("status")).toHaveTextContent( - "Syncing your calendar…", - ); - expect(screen.queryByText("Catching up your calendar…")).toBeNull(); - expect(screen.queryByText(/Last synced/)).toBeNull(); + expect(screen.getByRole("status")).toHaveTextContent("Calendar connected"); + expect(screen.queryByText("Adding your calendar…")).toBeNull(); + expect(screen.getByText("Updated just now")).toBeInTheDocument(); }); }); diff --git a/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.tsx b/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.tsx index c1beb4a7e..03356c1a4 100644 --- a/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.tsx +++ b/packages/web/src/components/Sidebar/CalendarList/CalendarListHeader.tsx @@ -52,13 +52,11 @@ const getSidebarSyncStatus = ({ googleStatus, hasPendingEventMutations, isConnecting, - isRefreshing, state, }: { googleStatus: SyncStatus; hasPendingEventMutations: boolean; isConnecting: boolean; - isRefreshing: boolean; state: GoogleUiState; }): SyncStatus => { if (isConnecting) { @@ -71,10 +69,6 @@ const getSidebarSyncStatus = ({ }; } - if (isRefreshing) { - return { variant: "syncing", text: "Requesting a calendar refresh…" }; - } - if (googleStatus && googleStatus.variant !== "healthy") { return googleStatus; } @@ -154,7 +148,6 @@ const AuthenticatedAccountHeader: FC<{ email: string }> = ({ email }) => { googleStatus: getGoogleSyncStatus(state, syncConnection), hasPendingEventMutations, isConnecting, - isRefreshing, state, }); const isSyncing = diff --git a/packages/web/src/sse/hooks/useSyncFocusRefresh.test.ts b/packages/web/src/sse/hooks/useSyncFocusRefresh.test.ts index df3ad42f7..706ae9a79 100644 --- a/packages/web/src/sse/hooks/useSyncFocusRefresh.test.ts +++ b/packages/web/src/sse/hooks/useSyncFocusRefresh.test.ts @@ -94,6 +94,21 @@ describe("useSyncFocusRefresh", () => { expect(refresh).toHaveBeenCalledTimes(1); }); + it("does not retrigger after the refresh changes connection state", () => { + const refresh = mock(); + let state: UseConnectGoogleResult["state"] = "HEALTHY"; + const hook = renderHook(() => + useSyncFocusRefresh(() => fakeConnectGoogle({ refresh, state })), + ); + + state = "IMPORTING"; + hook.rerender(); + state = "HEALTHY"; + hook.rerender(); + + expect(refresh).toHaveBeenCalledTimes(1); + }); + it("refreshes again when the tab regains focus after being hidden long enough", () => { setSystemTime(new Date("2026-02-05T00:00:00.000Z")); const refresh = mock(); diff --git a/packages/web/src/sse/hooks/useSyncFocusRefresh.ts b/packages/web/src/sse/hooks/useSyncFocusRefresh.ts index 95a323bd2..f728add66 100644 --- a/packages/web/src/sse/hooks/useSyncFocusRefresh.ts +++ b/packages/web/src/sse/hooks/useSyncFocusRefresh.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useConnectGoogle } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle"; import { type UseConnectGoogleResult } from "@web/auth/google/hooks/useConnectGoogle/useConnectGoogle.types"; import { useVisibleAfterHidden } from "@web/common/hooks/useVisibleAfterHidden"; @@ -30,12 +30,17 @@ export const useSyncFocusRefresh = ( useConnectGoogleImpl: () => UseConnectGoogleResult = useConnectGoogle, ) => { const { isAvailable, refresh, state } = useConnectGoogleImpl(); + const didRefreshOnMount = useRef(false); const canRefresh = isAvailable && (state === "HEALTHY" || state === "ATTENTION"); const silentRefresh = useCallback(() => refresh({ silent: true }), [refresh]); useEffect(() => { - if (!canRefresh) return; + if (!canRefresh || didRefreshOnMount.current) return; + // Metadata briefly changes the connection state while a refresh is + // requested. A mount refresh must not run again when that state settles, + // or every completion enqueues another pull forever. + didRefreshOnMount.current = true; silentRefresh(); }, [canRefresh, silentRefresh]);