Skip to content
29 changes: 29 additions & 0 deletions .agents/handoffs/3232.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
schema_version: 1
task_id: "3232"
from: Implementer
to: GitHub
owner: GitHub
status: verifying
artifact:
- path: packages/web/src/auth/providers/provider-copy.util.ts
- path: packages/web/src/auth/providers/ProviderConnectChooser.tsx
- path: packages/web/src/auth/providers/ConnectProviderAction.tsx
- path: packages/web/src/components/Settings/SettingsModal.tsx
- path: packages/web/src/components/Sidebar/CalendarList/CalendarList.tsx
- path: packages/web/src/components/CalendarConnectionBanner/CalendarConnectionBanner.tsx
evidence:
- command: bun run verify --strict
result: "VERDICT: PASS (test:web, type-check, lint, knip, test:a11y, test:e2e)"
assumptions:
- "Microsoft reconnect copy uses Outlook as the product name so banner text matches the WP-08b example."
- "Default calendar optgroups now include (Provider) even for Google."
open_risks: []
next_deadline: 2026-09-05T12:00:00Z
retry: 0
approval: allow
waiting_on: null
escalation: null
---

P0 WP-08b: migrate Settings, sidebar, banners and toasts to the provider layer.
61 changes: 61 additions & 0 deletions packages/web/src/auth/providers/ConnectProviderAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { type FC } from "react";
import { type ProviderKind } from "@core/types/sync/identity.contracts";
import { useConnectProvider } from "@web/auth/providers/useConnectProvider";
import { OverlayPanelActionButton } from "@web/components/OverlayPanel/OverlayPanel";
import { settingsShortcutAttrs } from "@web/settings/useSettingsShortcuts";

const SIDEBAR_BUTTON_CLASSNAME =
"c-button-compact c-button-primary w-full rounded-xs px-2 py-1.5 text-left text-xs";

interface ConnectProviderActionProps {
connectingLabel: string;
idleLabel: string;
kind: ProviderKind;
newAccount?: boolean;
shortcut?: string;
shortcutAttr?: boolean;
showShortcut?: boolean;
variant: "settings" | "sidebar";
}

export const ConnectProviderAction: FC<ConnectProviderActionProps> = ({
connectingLabel,
idleLabel,
kind,
newAccount,
shortcut,
shortcutAttr = false,
showShortcut,
variant,
}) => {
const { connect, isConnecting } = useConnectProvider(kind, { newAccount });
const label = isConnecting ? connectingLabel : idleLabel;

if (variant === "settings") {
return (
<OverlayPanelActionButton
aria-busy={isConnecting || undefined}
disabled={isConnecting}
onClick={connect}
shortcut={shortcut}
showShortcut={showShortcut}
variant="primary"
{...(shortcutAttr ? settingsShortcutAttrs("add-account") : {})}
>
{label}
</OverlayPanelActionButton>
);
}

return (
<button
aria-busy={isConnecting || undefined}
className={SIDEBAR_BUTTON_CLASSNAME}
disabled={isConnecting}
onClick={connect}
type="button"
>
{label}
</button>
);
};
58 changes: 58 additions & 0 deletions packages/web/src/auth/providers/ProviderConnectChooser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { type FC } from "react";
import { providerDisplayName } from "@core/types/sync/identity.contracts";
import { ConnectProviderAction } from "@web/auth/providers/ConnectProviderAction";
import {
CONNECT_CALENDAR_LABEL,
openingProviderCopy,
} from "@web/auth/providers/provider-copy.util";
import { useConnectableProviders } from "@web/auth/providers/useIsProviderAvailable";
import { OverlayPanelActions } from "@web/components/OverlayPanel/OverlayPanel";

interface ProviderConnectChooserProps {
showShortcuts?: boolean;
variant: "settings" | "sidebar";
}

export const ProviderConnectChooser: FC<ProviderConnectChooserProps> = ({
showShortcuts = false,
variant,
}) => {
const connectable = useConnectableProviders();
if (connectable.length === 0) return null;

if (variant === "settings") {
const single = connectable.length === 1;
return (
<OverlayPanelActions align="start">
{connectable.map((kind, index) => (
<ConnectProviderAction
connectingLabel={openingProviderCopy(kind)}
idleLabel={single ? "Add account" : providerDisplayName(kind)}
key={kind}
kind={kind}
newAccount
shortcut={index === 0 ? "A" : undefined}
shortcutAttr={index === 0}
showShortcut={showShortcuts && index === 0}
variant="settings"
/>
))}
</OverlayPanelActions>
);
}

return (
<div className="mb-2 flex flex-col gap-1.5">
{connectable.map((kind) => (
<ConnectProviderAction
connectingLabel="Connecting…"
idleLabel={CONNECT_CALENDAR_LABEL[kind]}
key={kind}
kind={kind}
newAccount
variant="sidebar"
/>
))}
</div>
);
};
36 changes: 36 additions & 0 deletions packages/web/src/auth/providers/provider-availability.factory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { useEffect, useSyncExternalStore } from "react";
import { type ProviderKind } from "@core/types/sync/identity.contracts";

const PROVIDER_KINDS: readonly ProviderKind[] = [
"google",
"microsoft",
"apple",
];
const NO_CONNECTABLE: ProviderKind[] = [];

export type BackendProviderAvailability =
| "available"
| "unavailable"
Expand Down Expand Up @@ -58,6 +65,7 @@ export function createProviderAvailability({
apple: unavailableFlags,
};
let loadPromise: Promise<void> | undefined;
let connectableCache: ProviderKind[] = NO_CONNECTABLE;

const emit = () => {
for (const listener of listeners) {
Expand All @@ -83,6 +91,18 @@ export function createProviderAvailability({
return ready;
};

const connectableSnapshot = (): ProviderKind[] => {
const next = PROVIDER_KINDS.filter((kind) => snapshotFor(kind, "connect"));
if (
next.length === connectableCache.length &&
next.every((kind, index) => kind === connectableCache[index])
) {
return connectableCache;
}
connectableCache = next.length === 0 ? NO_CONNECTABLE : next;
return connectableCache;
};

const load = async (): Promise<void> => {
if (!loadPromise) {
loadPromise = getConfig()
Expand Down Expand Up @@ -120,6 +140,20 @@ export function createProviderAvailability({
return available;
};

const useConnectableProviders = (): ProviderKind[] => {
const connectable = useSyncExternalStore(
subscribe,
connectableSnapshot,
connectableSnapshot,
);

useEffect(() => {
void load();
}, []);

return connectable;
};

const useIsGoogleAvailable = (): boolean =>
useIsProviderAvailable("google", "signIn");

Expand All @@ -133,6 +167,7 @@ export function createProviderAvailability({
apple: unavailableFlags,
};
loadPromise = undefined;
connectableCache = NO_CONNECTABLE;
emit();
};

Expand Down Expand Up @@ -171,5 +206,6 @@ export function createProviderAvailability({
useIsGoogleAvailable,
useIsConnectGoogleAvailable,
useIsProviderAvailable,
useConnectableProviders,
};
}
104 changes: 104 additions & 0 deletions packages/web/src/auth/providers/provider-copy.util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { ArrowsClockwiseIcon } from "@phosphor-icons/react";
import {
CONNECT_CALENDAR_LABEL,
calendarProductName,
connectionProvider,
defaultCalendarGroupLabel,
emptyCalendarsCopy,
openingProviderCopy,
RECONNECT_BANNER_MESSAGE,
RECONNECT_CALENDAR_LABEL,
reconnectPointerHint,
reconnectToastBody,
reconnectToastTitle,
relabelConnectCommand,
} from "./provider-copy.util";
import { describe, expect, it } from "bun:test";

describe("provider copy", () => {
it("defaults a missing connection provider to google", () => {
expect(connectionProvider(undefined)).toBe("google");
expect(connectionProvider({ provider: "microsoft" })).toBe("microsoft");
});

it("keeps Google strings byte-identical", () => {
expect(calendarProductName("google")).toBe("Google Calendar");
expect(CONNECT_CALENDAR_LABEL.google).toBe("Connect Google Calendar");
expect(RECONNECT_CALENDAR_LABEL.google).toBe("Reconnect Google Calendar");
expect(RECONNECT_BANNER_MESSAGE.google).toBe(
"Google Calendar needs reconnecting.",
);
expect(openingProviderCopy("google")).toBe("Opening Google…");
expect(emptyCalendarsCopy(["google"])).toBe(
"Connect Google to see your calendars.",
);
expect(defaultCalendarGroupLabel("ahab@pequod.com", "google")).toBe(
"ahab@pequod.com (Google)",
);
expect(reconnectToastTitle("google", "lance@example.com")).toBe(
"Google Calendar disconnected (lance@example.com)",
);
expect(reconnectToastTitle("google")).toBe("Google Calendar disconnected");
expect(reconnectToastBody("google", "lance@example.com")).toBe(
"Access for lance@example.com expired or was revoked. Your events are still safe in Google. Reconnect and Compass will re-import them.",
);
expect(reconnectToastBody("google")).toBe(
"This happens when access expires or is revoked. Your events are still safe in Google. Reconnect and Compass will re-import them.",
);
expect(reconnectPointerHint("google")).toBe(
"Press G to reconnect Google Calendar.",
);
});

it("names Microsoft as Outlook in reconnect copy", () => {
expect(RECONNECT_BANNER_MESSAGE.microsoft).toBe(
"Outlook needs reconnecting.",
);
expect(reconnectToastTitle("microsoft", "ada@outlook.com")).toBe(
"Outlook disconnected (ada@outlook.com)",
);
expect(reconnectToastBody("microsoft", "ada@outlook.com")).toBe(
"Access for ada@outlook.com expired or was revoked. Your events are still safe in Outlook. Reconnect and Compass will re-import them.",
);
expect(RECONNECT_CALENDAR_LABEL.microsoft).toBe("Reconnect Outlook");
expect(CONNECT_CALENDAR_LABEL.microsoft).toBe("Connect Outlook");
expect(openingProviderCopy("microsoft")).toBe("Opening Microsoft…");
expect(emptyCalendarsCopy(["microsoft"])).toBe(
"Connect Microsoft to see your calendars.",
);
expect(defaultCalendarGroupLabel("ada@outlook.com", "microsoft")).toBe(
"ada@outlook.com (Microsoft)",
);
expect(reconnectPointerHint("microsoft")).toBe(
"Press G to reconnect Outlook.",
);
});

it("uses provider-neutral empty copy when more than one provider can connect", () => {
expect(emptyCalendarsCopy(["google", "microsoft"])).toBe(
"Connect a calendar to see your calendars.",
);
});

it("relabels Google connect commands for another provider", () => {
const connect = relabelConnectCommand(
{
label: "Connect Google Calendar",
icon: ArrowsClockwiseIcon,
onSelect: () => {},
},
"microsoft",
);
expect(connect?.label).toBe("Connect Outlook");

const reconnect = relabelConnectCommand(
{
label: "Reconnect Google Calendar",
icon: ArrowsClockwiseIcon,
onSelect: () => {},
},
"microsoft",
);
expect(reconnect?.label).toBe("Reconnect Outlook");
});
});
Loading
Loading