# Calendar Integration Settings UI — Implementation Plan

**Spec:** docs/specs/2026-05-31-calendar-integration-settings-ui.md  ·  **Slug:** calendar-integration-settings-ui  ·  **Wave:** 10
**Depends on:** calendar-module, settings-module, foundation-auth-rbac

## Goal
Deliver the full calendar connection management page at `/settings/integrations/calendar` that the `calendar-module` spec only documents in one sentence. Each user manages their own per-provider OAuth connections (Google Calendar, Microsoft Outlook): connect/disconnect, pick which calendar to sync, choose sync direction, and choose what Zync data to push. The page surfaces connection health (active / re-auth needed / sync error) and exposes the OAuth init/callback flow plus a live calendar picker. No new tables — this spec extends the existing `calendar_connections` table (owned by `calendar-module`) with additive sync-preference columns.

## Architecture
- **Consumes upstream `calendar_connections`** (from `calendar-module`, spec 19): existing columns `id`, `tenant_id`, `user_id`, `provider`, `external_user_id`, `access_token BYTEA`, `refresh_token BYTEA`, `token_expires_at`, `selected_calendar_id`, `sync_enabled`, `last_synced_at`, `created_at`. This plan ADDs `selected_calendar_name`, `sync_direction`, `sync_task_due_dates`, `sync_manual_events`, `sync_customer_meetings`, `status`, `last_sync_error`.
- **Token encryption** reuses `encryptCredential` / `decryptCredential` from `@zync/db` (AES-256-GCM, `INTEGRATION_ENCRYPTION_KEY` binding) — the same helpers `calendar-module` uses to store OAuth tokens. We never re-implement crypto.
- **Auth**: every endpoint except OAuth callbacks runs under `authMiddleware`; connection rows are always scoped by `tenantQuery` to `(tenant_id, user_id)`. OAuth callbacks are unauthenticated (Google/Microsoft redirect targets) and recover the user from a signed, single-use OAuth state token.
- **Connection scope is per-user**: `calendar_connections` is keyed by `(tenant_id, user_id, provider)` with one row per provider per user. All reads/writes filter on the authenticated `user_id` from the session — never tenant-wide.
- **Settings shell**: the page renders inside the settings shell owned by `settings-module`. This plan owns the route component, its API endpoints, and the schema delta; it plugs into the existing sidebar manifest entry `/settings/integrations/calendar`.
- **Data flow (connect)**: UI → `GET /api/auth/google-calendar/init` (returns Google consent URL w/ signed state) → browser → Google → `GET /api/auth/google-calendar/callback?code&state` → Worker exchanges code, encrypts + stores tokens, upserts `calendar_connections` row with `status='active'` → redirect `/settings/integrations/calendar?connected=google` → UI shows success toast and fetches live calendar list.
- **Data flow (health)**: on any read of connections, if `token_expires_at` is past and a refresh attempt fails, the row's `status` is set to `'error'` and the card renders the re-authorization banner. `last_sync_error` (set by the sync engine in `calendar-module`) renders inline under the last-sync timestamp.

## Tech Stack
- **API**: `apps/zync-api` (Hono on Cloudflare Workers). New router file mounted under the existing app router. Zod request validation (`require-zod-validation-in-routes`), data access via repository functions in `@zync/db` (`no-raw-drizzle-from-routes`).
- **DB**: Neon Postgres via Hyperdrive, Drizzle ORM. Schema in `@zync/db`. Migration is a plain `ALTER TABLE` (additive, non-breaking).
- **App UI**: `apps/zync-app` (Vite + React). New route page + provider card components built from `@zync/ui` primitives (`Card`, `Button`, `Radio`, `Checkbox`, `Switch`, `Badge`, `Alert`, `Dialog`, `Spinner`, `toast`/`Toaster`, `EmptyState`). React Query hooks for connection state.
- **Bindings**: `DB` (Hyperdrive/Neon), `INTEGRATION_ENCRYPTION_KEY` (secret, already provisioned by `calendar-module`). OAuth client IDs/secrets as Worker secrets: `GOOGLE_CALENDAR_CLIENT_ID`, `GOOGLE_CALENDAR_CLIENT_SECRET`, `OUTLOOK_CLIENT_ID`, `OUTLOOK_CLIENT_SECRET`, `OAUTH_STATE_SECRET`.
- **i18n / RTL**: all strings via `@zync/i18n` translations; layout uses CSS logical properties (no `left`/`right`); user-generated text (emails, calendar names) wrapped `dir="auto"`.
- **A11y**: radio groups use `role="radiogroup"` with labelled options; status badges have text + non-color cue; disconnect dialog is a focus-trapped `Dialog` with `aria-describedby`; reduced-motion respected on the connect spinner.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 10a | Task 1 (schema delta), Task 2 (repository fns) | `packages/db/**` | Task 2 after Task 1 |
| 10b | Task 3 (OAuth helpers + state), Task 4 (connections API), Task 5 (OAuth init/callback API), Task 6 (live calendar picker API) | `apps/zync-api/**`, `packages/db/**` | Tasks 4/5/6 parallel after Task 3 |
| 10c | Task 7 (React Query hooks), Task 8 (provider card + states), Task 9 (page + disconnect dialog) | `apps/zync-app/**` | Task 8/9 after Task 7 |
| 10d | Task 10 (i18n strings), Task 11 (wiring + acceptance pass) | `packages/i18n/**`, route registration | After 10c |

## Tasks

### Task 1: Schema delta — additive columns on `calendar_connections`
**Blocks:** 2, 4, 5, 6  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/<timestamp>_calendar_connections_sync_prefs.sql`
- Modify: `packages/db/src/schema/calendar.ts` (Drizzle table def for `calendar_connections`)
**Steps:**
- [ ] Add a forward-only migration with the additive `ALTER TABLE` below. All columns are nullable or have defaults so existing rows backfill safely; no data migration needed.
- [ ] Extend the existing Drizzle `calendarConnections` table object with the seven new columns (text/boolean/text) — do not redefine columns already declared by `calendar-module`.
- [ ] Add a partial index to speed the common per-user lookup: `(tenant_id, user_id, provider)`.
- [ ] Confirm the unique constraint `(tenant_id, user_id, provider)` exists (one connection per provider per user); add it in this migration if `calendar-module` did not already create it.
**Schema / Interfaces:**
```sql
ALTER TABLE calendar_connections
  ADD COLUMN selected_calendar_name TEXT,
  ADD COLUMN sync_direction TEXT NOT NULL DEFAULT 'two_way'
    CHECK (sync_direction IN ('two_way', 'read_only', 'push_only')),
  ADD COLUMN sync_task_due_dates BOOLEAN NOT NULL DEFAULT true,
  ADD COLUMN sync_manual_events BOOLEAN NOT NULL DEFAULT true,
  ADD COLUMN sync_customer_meetings BOOLEAN NOT NULL DEFAULT false,
  ADD COLUMN status TEXT NOT NULL DEFAULT 'active'
    CHECK (status IN ('active', 'error', 'disconnected')),
  ADD COLUMN last_sync_error TEXT;

-- Guarantee one connection per provider per user (idempotent if calendar-module already added it).
CREATE UNIQUE INDEX IF NOT EXISTS calendar_connections_tenant_user_provider_uniq
  ON calendar_connections (tenant_id, user_id, provider);
```
```ts
// packages/db/src/schema/calendar.ts — additive fields on the existing table object
export const calendarConnections = pgTable('calendar_connections', {
  // [existing columns from calendar-module: id, tenantId, userId, provider, externalUserId,
  //  accessToken, refreshToken, tokenExpiresAt, selectedCalendarId, syncEnabled, lastSyncedAt, createdAt]
  selectedCalendarName: text('selected_calendar_name'),
  syncDirection: text('sync_direction').notNull().default('two_way'),       // 'two_way' | 'read_only' | 'push_only'
  syncTaskDueDates: boolean('sync_task_due_dates').notNull().default(true),
  syncManualEvents: boolean('sync_manual_events').notNull().default(true),
  syncCustomerMeetings: boolean('sync_customer_meetings').notNull().default(false),
  status: text('status').notNull().default('active'),                       // 'active' | 'error' | 'disconnected'
  lastSyncError: text('last_sync_error'),
});
```
**Acceptance:**
- [ ] Migration applies cleanly against a Neon branch with existing `calendar_connections` rows; defaults backfill (`sync_direction='two_way'`, `status='active'`, booleans set, `last_sync_error` NULL).
- [ ] CHECK constraints reject any value outside the enumerated sets for `sync_direction` and `status`.
- [ ] Unique index prevents a second row for the same `(tenant_id, user_id, provider)`.

### Task 2: Repository functions for calendar connections
**Blocks:** 4, 5, 6  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/repositories/calendar-connections.ts`
- Modify: `packages/db/src/index.ts` (export new functions + types)
**Steps:**
- [ ] Implement `listCalendarConnections(db, tenantId, userId)` returning all of the user's connection rows (do NOT return token bytes).
- [ ] Implement `getCalendarConnection(db, tenantId, userId, provider)` returning one row (token bytes included for internal OAuth/refresh use only).
- [ ] Implement `upsertCalendarConnection(db, args)` keyed on `(tenant_id, user_id, provider)` — used by the OAuth callback to store encrypted tokens + `external_user_id` + `status='active'`.
- [ ] Implement `updateCalendarConnectionPrefs(db, tenantId, userId, provider, patch)` updating only preference columns (`selected_calendar_id`, `selected_calendar_name`, `sync_direction`, `sync_task_due_dates`, `sync_manual_events`, `sync_customer_meetings`).
- [ ] Implement `setCalendarConnectionStatus(db, tenantId, userId, provider, status, lastSyncError?)` to flip `status` and set/clear `last_sync_error`.
- [ ] Implement `deleteCalendarConnection(db, tenantId, userId, provider)` (hard delete) for disconnect.
- [ ] Every query is `(tenant_id, user_id)`-scoped via `tenantQuery`; token columns are encrypted/decrypted only through `encryptCredential`/`decryptCredential`.
**Schema / Interfaces:**
```ts
export type CalendarProvider = 'google' | 'outlook';
export type CalendarSyncDirection = 'two_way' | 'read_only' | 'push_only';
export type CalendarConnectionStatus = 'active' | 'error' | 'disconnected';

export interface CalendarConnectionPublic {
  provider: CalendarProvider;
  status: CalendarConnectionStatus;
  connectedEmail: string | null;          // derived from stored connected account email
  selectedCalendarId: string | null;
  selectedCalendarName: string | null;
  syncDirection: CalendarSyncDirection;
  syncTaskDueDates: boolean;
  syncManualEvents: boolean;
  syncCustomerMeetings: boolean;
  lastSyncedAt: string | null;            // ISO TIMESTAMPTZ
  lastSyncError: string | null;
}

export interface UpsertCalendarConnectionArgs {
  tenantId: string; userId: string; provider: CalendarProvider;
  externalUserId: string; connectedEmail: string;
  accessTokenEnc: Uint8Array; refreshTokenEnc: Uint8Array;
  tokenExpiresAt: Date | null;
}
export interface CalendarConnectionPrefsPatch {
  selectedCalendarId?: string; selectedCalendarName?: string;
  syncDirection?: CalendarSyncDirection;
  syncTaskDueDates?: boolean; syncManualEvents?: boolean; syncCustomerMeetings?: boolean;
}

export function listCalendarConnections(db: Db, tenantId: string, userId: string): Promise<CalendarConnectionPublic[]>;
export function getCalendarConnection(db: Db, tenantId: string, userId: string, provider: CalendarProvider): Promise<CalendarConnectionRow | null>;
export function upsertCalendarConnection(db: Db, args: UpsertCalendarConnectionArgs): Promise<void>;
export function updateCalendarConnectionPrefs(db: Db, tenantId: string, userId: string, provider: CalendarProvider, patch: CalendarConnectionPrefsPatch): Promise<CalendarConnectionPublic>;
export function setCalendarConnectionStatus(db: Db, tenantId: string, userId: string, provider: CalendarProvider, status: CalendarConnectionStatus, lastSyncError?: string | null): Promise<void>;
export function deleteCalendarConnection(db: Db, tenantId: string, userId: string, provider: CalendarProvider): Promise<void>;
```
> Note: `connected_email` is derived. If `calendar-module` did not persist the OAuth account email, store it inside `selected_calendar_name` semantics is wrong — instead persist the account email by reusing the existing `external_user_id` lookup plus the email returned by the provider userinfo call at connect time; the upsert writes it into a `connected_email`-bearing field. If no such column exists from `calendar-module`, add `ADD COLUMN connected_email TEXT` in Task 1's migration. Implementers MUST add `connected_email TEXT` to the Task 1 ALTER if absent upstream.
**Acceptance:**
- [ ] `listCalendarConnections` never returns `access_token`/`refresh_token` bytes.
- [ ] All functions filter by `user_id` AND `tenant_id`; a connection belonging to another user is never readable or mutable.
- [ ] `updateCalendarConnectionPrefs` touches only preference columns and returns the updated public projection.

### Task 3: OAuth helper module (state token + token exchange + refresh)
**Blocks:** 5, 6  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-api/src/integrations/calendar-oauth.ts`
**Steps:**
- [ ] Implement `signOAuthState({ tenantId, userId, provider, nonce })` → short-lived (10 min) signed token using `OAUTH_STATE_SECRET` (HMAC-SHA256), and `verifyOAuthState(token)` returning the claims or throwing. Compare HMAC with `timingSafeEqual` (`no-string-equality-for-tokens`). State is single-use: store the `nonce` in KV with 10-min TTL and delete on first verify to prevent replay.
- [ ] Implement `buildGoogleConsentUrl(state)` with scopes `calendar.readonly calendar.events` and `access_type=offline&prompt=consent` (to guarantee a refresh token), redirect URI = `…/api/auth/google-calendar/callback`.
- [ ] Implement `buildOutlookConsentUrl(state)` with scopes `Calendars.ReadWrite offline_access`, redirect URI = `…/api/auth/outlook/callback`.
- [ ] Implement `exchangeGoogleCode(code)` / `exchangeOutlookCode(code)` → `{ accessToken, refreshToken, expiresAt, externalUserId, email }` (email + sub/oid via the provider's userinfo / `/me` endpoint).
- [ ] Implement `refreshGoogleToken(refreshToken)` / `refreshOutlookToken(refreshToken)` returning new access token + expiry; used by the picker endpoint and surfaced for the sync engine.
- [ ] Implement `revokeGoogleToken(accessToken)` / `revokeOutlookToken(...)` — best-effort revoke on disconnect; failures are swallowed (logged, not fatal).
- [ ] Implement `ensureFreshAccess(db, conn)` — if `token_expires_at` within 5 minutes (or past), refresh and persist the new encrypted access token; on refresh failure call `setCalendarConnectionStatus(... 'error', 'Invalid grant: token has been revoked')` and throw a typed `CalendarReauthRequiredError`.
**Schema / Interfaces:**
```ts
export class CalendarReauthRequiredError extends Error { provider: CalendarProvider; }
export interface OAuthStateClaims { tenantId: string; userId: string; provider: CalendarProvider; nonce: string; }
export function signOAuthState(env: Env, claims: Omit<OAuthStateClaims,'nonce'>): Promise<string>;
export function verifyOAuthState(env: Env, token: string): Promise<OAuthStateClaims>;   // single-use, timing-safe
export function buildGoogleConsentUrl(env: Env, state: string): string;
export function buildOutlookConsentUrl(env: Env, state: string): string;
export interface ExchangedTokens { accessToken: string; refreshToken: string; expiresAt: Date; externalUserId: string; email: string; }
export function exchangeGoogleCode(env: Env, code: string): Promise<ExchangedTokens>;
export function exchangeOutlookCode(env: Env, code: string): Promise<ExchangedTokens>;
export function ensureFreshAccess(env: Env, db: Db, conn: CalendarConnectionRow): Promise<string /* access token */>;
export function revokeProviderToken(env: Env, provider: CalendarProvider, accessToken: string): Promise<void>;
```
**Acceptance:**
- [ ] State token verification is timing-safe and single-use (replayed state is rejected).
- [ ] Google consent URL requests offline access + forced consent so a refresh token is always returned.
- [ ] `ensureFreshAccess` flips connection `status` to `'error'` and throws `CalendarReauthRequiredError` when refresh fails.

### Task 4: `GET/PATCH/DELETE /api/settings/calendar/connections` endpoints
**Blocks:** 7  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/settings-calendar.ts`
- Modify: `apps/zync-api/src/app.ts` (mount router under `authMiddleware`)
**Steps:**
- [ ] `GET /api/settings/calendar/connections` → `listCalendarConnections(db, session.tenantId, session.userId)`; returns the public projection array. Authenticated only.
- [ ] `PATCH /api/settings/calendar/connections/:provider` → validate `:provider ∈ {google, outlook}` and Zod body, then `updateCalendarConnectionPrefs(...)`. Returns updated connection. Authenticated only.
- [ ] `DELETE /api/settings/calendar/connections/:provider` → load connection, best-effort `revokeProviderToken(...)` using a freshly refreshed access token, then `deleteCalendarConnection(...)`. Idempotent: deleting a non-existent connection returns 204. Authenticated only.
- [ ] All handlers read `tenantId`/`userId` from the session (`SessionPayload`); never from the request body.
**Schema / Interfaces:**
```ts
// Zod body for PATCH
const updateCalendarPrefsSchema = z.object({
  selected_calendar_id: z.string().min(1).optional(),
  sync_direction: z.enum(['two_way','read_only','push_only']).optional(),
  sync_task_due_dates: z.boolean().optional(),
  sync_manual_events: z.boolean().optional(),
  sync_customer_meetings: z.boolean().optional(),
}).strict();
const providerParam = z.enum(['google','outlook']);
// Routes:
//   GET    /api/settings/calendar/connections           -> CalendarConnectionPublic[]
//   PATCH  /api/settings/calendar/connections/:provider -> CalendarConnectionPublic
//   DELETE /api/settings/calendar/connections/:provider -> 204
```
**Acceptance:**
- [ ] PATCH with an unknown `sync_direction` value → 400 (Zod) before any DB write.
- [ ] DELETE revokes provider tokens best-effort and removes the row; a second DELETE returns 204.
- [ ] A user cannot read or mutate another user's connection (scoped by session `user_id`).

### Task 5: OAuth init + callback endpoints (Google and Outlook)
**Blocks:** 7  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/calendar-oauth-routes.ts`
- Modify: `apps/zync-api/src/app.ts` (mount; callbacks are OUTSIDE `authMiddleware`)
**Steps:**
- [ ] `GET /api/auth/google-calendar/init` (auth required) → `signOAuthState` for `{tenantId,userId,'google'}`, return `{ url: buildGoogleConsentUrl(state) }`.
- [ ] `GET /api/auth/outlook/init` (auth required) → same for `'outlook'`.
- [ ] `GET /api/auth/google-calendar/callback?code&state` (NO auth) → `verifyOAuthState(state)`; on failure redirect to `/settings/integrations/calendar?error=oauth_state`. On success: `exchangeGoogleCode(code)`, `encryptCredential` access+refresh tokens, `upsertCalendarConnection(...)` with `status='active'`, `last_sync_error=NULL`, then `302` redirect to `/settings/integrations/calendar?connected=google`.
- [ ] `GET /api/auth/outlook/callback?code&state` (NO auth) → mirror flow, redirect `?connected=outlook`.
- [ ] On token-exchange failure: redirect `/settings/integrations/calendar?error=oauth_exchange`.
- [ ] Set a strict CSP-compatible redirect (relative path only; never reflect attacker-controlled `state`/`code` into the redirect). Use `safeRedirect` for the final location.
**Schema / Interfaces:**
```ts
// Routes (callbacks unauthenticated — identity recovered from signed single-use state):
//   GET /api/auth/google-calendar/init      (auth)  -> { url: string }
//   GET /api/auth/google-calendar/callback   (open) -> 302 /settings/integrations/calendar?connected=google
//   GET /api/auth/outlook/init               (auth)  -> { url: string }
//   GET /api/auth/outlook/callback           (open)  -> 302 /settings/integrations/calendar?connected=outlook
```
**Acceptance:**
- [ ] Init endpoints require auth; calling them unauthenticated → 401.
- [ ] Callback with a tampered/expired/replayed `state` redirects to `?error=oauth_state` and writes nothing.
- [ ] Successful Google callback stores AES-256-GCM-encrypted access + refresh tokens (never plaintext) and lands the user on the settings page with `?connected=google`.

### Task 6: Live calendar picker endpoint
**Blocks:** 7  ·  **Blocked by:** 2, 3
**Files:**
- Modify: `apps/zync-api/src/routes/settings-calendar.ts`
**Steps:**
- [ ] `GET /api/settings/calendar/calendars?provider=google|outlook` (auth required) → load the user's active connection; `ensureFreshAccess(...)`; call the provider list-calendars API (Google `calendarList.list`, Microsoft Graph `/me/calendars`); normalize to `{ id, name, primary }[]`.
- [ ] If no active connection → 409 `{ error: 'not_connected' }`.
- [ ] If `ensureFreshAccess` throws `CalendarReauthRequiredError` → 409 `{ error: 'reauth_required' }` (UI renders the reconnect banner).
- [ ] Cache nothing server-side beyond the request (calendar list must be live, per spec architecture decision).
**Schema / Interfaces:**
```ts
const calendarsQuerySchema = z.object({ provider: z.enum(['google','outlook']) });
// GET /api/settings/calendar/calendars?provider=google
//   200 -> Array<{ id: string; name: string; primary: boolean }>
//   409 -> { error: 'not_connected' | 'reauth_required' }
```
**Acceptance:**
- [ ] Returns a live, normalized calendar list with `primary` flagged correctly for the connected provider.
- [ ] Returns 409 `reauth_required` (not 500) when the stored refresh token is revoked.

### Task 7: React Query hooks for connection state
**Blocks:** 8, 9  ·  **Blocked by:** 4, 5, 6
**Files:**
- Create: `apps/zync-app/src/features/settings/calendar/useCalendarConnections.ts`
**Steps:**
- [ ] `useCalendarConnections()` → `GET /api/settings/calendar/connections`; returns connections keyed by provider with derived booleans (`isConnected`, `needsReauth = status==='error'`).
- [ ] `useCalendarList(provider, enabled)` → `GET /api/settings/calendar/calendars?provider=` ; only fires when a connection is active; surfaces the `reauth_required` 409 distinctly so the card can show the banner.
- [ ] `useUpdateCalendarPrefs(provider)` → `PATCH …/connections/:provider`; optimistic update + invalidate connections query.
- [ ] `useDisconnectCalendar(provider)` → `DELETE …/connections/:provider`; invalidate connections query; show toast.
- [ ] `useConnectCalendar(provider)` → calls the matching `…/init` endpoint and performs `window.location.assign(url)`.
**Schema / Interfaces:**
```ts
export function useCalendarConnections(): { google?: CalendarConnectionPublic; outlook?: CalendarConnectionPublic; isLoading: boolean };
export function useCalendarList(provider: CalendarProvider, enabled: boolean): { data?: Array<{id:string;name:string;primary:boolean}>; reauthRequired: boolean; isLoading: boolean };
export function useUpdateCalendarPrefs(provider: CalendarProvider): UseMutationResult<CalendarConnectionPublic, Error, CalendarConnectionPrefsPatch>;
export function useDisconnectCalendar(provider: CalendarProvider): UseMutationResult<void, Error, void>;
export function useConnectCalendar(provider: CalendarProvider): () => Promise<void>;
```
**Acceptance:**
- [ ] On `?connected=google` query param present at mount, the connections query refetches and a success toast fires once.
- [ ] `useCalendarList` does not fire for a provider with no active connection.

### Task 8: Provider connection card component (all states)
**Blocks:** 9  ·  **Blocked by:** 7
**Files:**
- Create: `apps/zync-app/src/features/settings/calendar/CalendarProviderCard.tsx`
**Steps:**
- [ ] Render the **not-connected** state: provider name, grey "Not connected" status `Badge`, `[Connect …]` `Button` calling `useConnectCalendar`.
- [ ] Render the **connected** state: green "Connected as: {email}" (email `dir="auto"`), `Last sync: {relative time}`, `[Disconnect]` button, and the preference form.
- [ ] Preference form: "Which calendar to sync" → `Radio` group (`role="radiogroup"`) populated from `useCalendarList`; "Sync direction" → radio group (`two_way` / `read_only` / `push_only`, two_way labelled "recommended"); "What to sync from Zync" → three `Checkbox`es bound to `sync_task_due_dates`, `sync_manual_events`, `sync_customer_meetings`; `[Save preferences]` button → `useUpdateCalendarPrefs`.
- [ ] Render the **error / re-auth** state: amber `Alert` "Connection requires re-authorization" with `[Reconnect →]` (re-runs connect flow). Triggered when `status==='error'` or list call returns `reauth_required`.
- [ ] Render the **sync-error** detail: if `last_sync_error` non-null, show it inline under the last-sync timestamp with a warning icon (icon + text, not color alone).
- [ ] Informational note for Google/Outlook: "External {provider} events appear read-only in Zync."
- [ ] All copy via translation keys; spinner on the calendar-list fetch respects `prefers-reduced-motion`.
**Schema / Interfaces:**
```tsx
interface CalendarProviderCardProps {
  provider: CalendarProvider;
  label: string;                 // 'Google Calendar' | 'Microsoft Outlook' (i18n)
  connection?: CalendarConnectionPublic;
}
export function CalendarProviderCard(props: CalendarProviderCardProps): JSX.Element;
```
**Acceptance:**
- [ ] Card renders the correct one of {not-connected, connected, re-auth, sync-error} states from connection `status` + list result.
- [ ] Radio groups expose `role="radiogroup"` and each option is keyboard-focusable and labelled.
- [ ] Save preferences persists and the card reflects the saved values after refetch.

### Task 9: Settings page + disconnect confirmation dialog
**Blocks:** 11  ·  **Blocked by:** 8
**Files:**
- Create: `apps/zync-app/src/features/settings/calendar/CalendarIntegrationSettingsPage.tsx`
- Create: `apps/zync-app/src/features/settings/calendar/DisconnectCalendarDialog.tsx`
**Steps:**
- [ ] Page header "Settings > Integrations > Calendar Sync" + intro copy; renders one `CalendarProviderCard` for `google` and one for `outlook` stacked vertically.
- [ ] Read `?connected=<provider>` / `?error=<code>` query params on mount → fire success toast ("{Provider} connected") or error toast ("Couldn't connect — please try again"); strip the params from the URL after handling (history replace).
- [ ] `DisconnectCalendarDialog`: focus-trapped `Dialog` titled "Disconnect {Provider}?" with the spec body copy ("Zync events pushed … will remain … Google events in Zync will become read-only and eventually expire."), `[Cancel]` and destructive `[Disconnect]`. On confirm → `useDisconnectCalendar`.
- [ ] Page accessible to all roles (each user manages own connections) — no permission gate beyond `authMiddleware`; do NOT require `settings:write`.
**Schema / Interfaces:**
```tsx
export function CalendarIntegrationSettingsPage(): JSX.Element;       // route: /settings/integrations/calendar
interface DisconnectCalendarDialogProps { provider: CalendarProvider; open: boolean; onOpenChange(open: boolean): void; onConfirm(): void; }
export function DisconnectCalendarDialog(props: DisconnectCalendarDialogProps): JSX.Element;
```
**Acceptance:**
- [ ] Page reachable at `/settings/integrations/calendar` for any authenticated role.
- [ ] Disconnect dialog is focus-trapped, `Escape`/`Cancel` closes without disconnecting, `[Disconnect]` confirms and removes the connection.
- [ ] `?connected=` and `?error=` are consumed once and cleared from the URL.

### Task 10: i18n strings (Hebrew + English) and RTL verification
**Blocks:** 11  ·  **Blocked by:** 8, 9
**Files:**
- Modify: `packages/i18n/src/locales/en/settings.json`
- Modify: `packages/i18n/src/locales/he/settings.json`
**Steps:**
- [ ] Add keys for: page title/intro, provider labels, connect/disconnect/reconnect buttons, status labels (not connected / connected as / last sync / requires re-authorization), the three sync-direction options + "recommended", the three checkbox labels, the read-only info note, the sync-error prefix, and the disconnect dialog title/body/actions.
- [ ] Provide Hebrew translations for every key.
- [ ] Verify the page under `dir="rtl"`: cards, radio/checkbox alignment, and the disconnect dialog mirror correctly via CSS logical properties (no hardcoded `left`/`right`); user-generated emails/calendar names keep `dir="auto"`.
**Acceptance:**
- [ ] No hardcoded user-facing strings remain in the components (all via translation keys).
- [ ] Page renders correctly mirrored in Hebrew/RTL with no clipped or misaligned controls.

### Task 11: Route registration, navigation wiring, and end-to-end acceptance
**Blocks:** —  ·  **Blocked by:** 5, 9, 10
**Files:**
- Modify: `apps/zync-app/src/routes.tsx` (register `/settings/integrations/calendar` → `CalendarIntegrationSettingsPage`)
- Modify: `apps/zync-api/src/app.ts` (confirm all six routers mounted; callbacks excluded from auth)
**Steps:**
- [ ] Register the route inside the settings shell (owned by `settings-module`); confirm the existing sidebar manifest entry `/settings/integrations/calendar` resolves to this page.
- [ ] Confirm `GET /api/settings/calendar/connections`, `GET /api/settings/calendar/calendars`, `PATCH`/`DELETE …/connections/:provider`, `…/init`, and `…/callback` are all reachable; callbacks are NOT behind `authMiddleware`.
- [ ] Run the full connect → pick calendar → set direction → save → disconnect loop against a Neon branch + sandbox OAuth client.
**Acceptance:**
- [ ] End-to-end: a user connects Google, the live calendar list loads, selecting a calendar + direction + toggles and saving persists to `calendar_connections`, the card shows "Connected as", and disconnecting removes the row and reverts the card to "Not connected".
- [ ] Token-revoked path: forcing a refresh failure flips `status='error'` and the card shows the re-authorization banner instead of crashing.

## Implementation Notes (endpoint reconciliation)
- The `calendar-module` spec named OAuth routes `…/google-calendar/start` and `…/outlook-calendar/callback`. THIS spec (the owner of the settings UI) names them `…/google-calendar/init`, `…/google-calendar/callback`, `…/outlook/init`, `…/outlook/callback`. This plan uses THIS spec's names as authoritative for the settings flow. If `calendar-module` already shipped `…/start` routes, alias them to the `…/init` paths rather than duplicating logic.
- Token encryption uses the existing `encryptCredential`/`decryptCredential` helpers and the `INTEGRATION_ENCRYPTION_KEY` binding already provisioned by `calendar-module`; do not introduce a second key.
