refactor(campaign): C150 — simplify (catch/sessão do picker, leitura única, webcal morto) + changelog e débito C156

This commit is contained in:
Francisco Solla 2026-09-12 20:47:51 -03:00
parent cc6d2abd04
commit 7990c79b3e
11 changed files with 335 additions and 136 deletions

View file

@ -0,0 +1 @@
**Calendário principal da campanha escolhido na própria agenda e inscrição de staff no Google com um clique (2026-09-12):** escolher qual calendário Google espelhava a agenda era operação de admin do Payload; agora o candidato/coordenação lista os calendários da conta conectada (C149) direto em `/campanha/agenda` e escolhe o principal. O `GoogleCalendarClient` ganhou `listCalendars` (`calendarList.list` com `minAccessRole=writer`, paginação por `nextPageToken`, `deleted` filtrado, `summary` caindo para o id), OAuth-only por desenho (a lista do service account não contém os calendários compartilhados). O picker novo (`GoogleCalendarPickerDialog`, Dialog desktop/Drawer mobile, mesmo chrome C94/C148) lista ao vivo a cada abertura com estados loading/erro/vazio/retry, marca o "em uso" e só confirma quando a seleção muda; o chrome da agenda é dono dos dois overlays (um por vez — sem Radix/vaul aninhados). A escrita do `calendarId` virou action manager-only + utility `setCampaignPrimaryCalendarId`: o id é **validado server-side contra a lista viva** da conta conectada antes do system write com bypass (`canSetGoogleCalendarSyncConfigField` segue admin-only como escape hatch do Painel — a superfície REST do campaignUser nunca escreve id arbitrário); trocar o calendário dispara o hook D7 e o motor re-concilia no novo, sem backfill nem limpeza no antigo (design C114). `invalid_grant` na listagem grava `oauthErrorAt/oauthError` e o card deriva `error`/Reconectar (mesma classificação do motor). O one-click "Adicionar ao meu Google Calendar" usa o literal `calendar.google.com/calendar/r?cid=<calendarId URL-encoded>` — âncora no header para staff (rótulo no desktop, ícone com `aria-label` no mobile) e dentro do diálogo; o caminho manual "Por URL"/Apple Calendar/Outlook passou a exibir a URL iCal pública do calendário, e o feed ICS filtrado (`CalendarFeedDialog`) ficou intocado. Advisor vê o one-click mas não o seletor (copy "escolhido por candidato ou coordenação"); leader lockdown intocado. Sem migration. Verificação: unit 290 files/2775 passed, int 81 files/728 passed (spec novo `googleCalendarPrimaryCalendarAction` cobre papel, id fora da lista, record do auth error e o D7 com `listEvents(novoId)`), e2e `campaignAgendaGoogleSync` 11/11 (synced com seletor + one-click + URL iCal, advisor sem seletor, one-click mobile), `campaignActivity`+`campaignAgendaFeed` 10/10, gate:ci completo (lint/format/typecheck/knip/cycles/build). Débito registrado: **C156** (#965, `docs/plans/escala-dry-pos-c150.md`) — o gatilho do C148 disparou (4º modal com o mesmo corpo rolável): extrair o shell responsivo compartilhado dos diálogos; copy única do one-click absorvida como fase cortável e as máquinas de estado picker/sync deferidas com gatilho.

View file

@ -133,6 +133,24 @@ Não se aplica como apresentação de dado do Teqo (nenhuma leitura nova; a inte
**Auto-avaliação: 4/5** — todas as decisões caras (listagem, escrita do `calendarId`, schema, auth error, UI, literal do link, estratégia de teste) têm recomendação e rejeitadas ancoradas em achados concretos (union de auth C149, precedente de system write, hook D7, pins de teste, manifest); o appetite cabe (~1,7–1,9 dia) e o depth check reusa client/utility/action/dialog/hook existentes, criando só o componente de picker (ciclo de estados próprio). Perde 1 ponto porque o caminho feliz da listagem real (shape do Google, `showHidden`, paginação) só é provado por unit/int com stub — sem e2e de rede (D7) —, então a verificação contra o Google real fica no smoke pós-deploy.
## Execução — desvios e decisões emergentes (2026-09-12)
- **`buildGoogleCalendarWebcalUrl` removido:** o plano previa mantê-la para Apple/Outlook, mas o diálogo serve esse caminho com a URL iCal pública (HTTPS); o export ficou morto e saiu no simplify.
- **Validação do id e leitura única:** `resolveGoogleCalendarPickerClient` dobrado em `loadWritableGoogleCalendars`, que recebe o doc já carregado — a escolha faz uma leitura da row, não duas.
- **Picker sem roles ARIA de radio:** a lista usa botões com `aria-pressed` (roving tabindex/owned-children não valem a cerimônia); a seleção fica desabilitada durante a escolha e há guarda de sessão (`sessionRef`) contra corrida de close/reopen.
- **Catch explícito no picker:** listagem/escolha rejeitadas (sessão expirada/serialização) caem em erro com "Tentar de novo" — nunca spinner infinito.
## Débitos (triage do /simplify)
| ID | Achado | Score | Tipo | Destino |
| --- | -------------------------------------------------------------- | ----- | ------------- | ------------------------------------------------------------------------------------------------------------- |
| S1 | 3ª cópia do chrome responsivo Dialog/Drawer (Picker/Sync/Feed) | 3 | cheap_polish | Registrado: **C156** (#965) — `docs/plans/escala-dry-pos-c150.md` (o gatilho do C148 disparou com o 4º modal) |
| S2 | Copy "Adicionar ao meu Google Calendar" duplicada | 2 | cheap_polish | Absorvido como F3 cortável do C156 |
| S3 | Máquinas de estado semelhantes Picker/Sync | 2 | defer_trigger | Defer no plano do C156: 3º diálogo com a mesma máquina busy/erro/ação |
- **Já resolvido no simplify (não reabrir):** guarda de sessão/catch do picker; `CalendarOption`; envelope `applyState` no chrome; `addLink` derivado no diálogo; webcal morto; leitura única da row; flag `changed` removida; roles ARIA.
- **Explicitamente fora:** copy única fora do C156; máquinas de estado unificadas; shell do `ActivityOverlay`.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: candidato/coordenador escolhe o calendário principal na agenda; create/edit espelham nele; staff adiciona a agenda ao Google com um clique; recorte ICS intocado; admin do Payload segue escape hatch.

View file

@ -0,0 +1,125 @@
# Escala e DRY pós-C150 (shell responsivo dos diálogos da agenda)
Status: rascunho
Atualizado em: 2026-09-12
Item do roadmap: fill-in de engenharia (Trilha C, pós-C150) — sem linha nova no roadmap
Issue: C156 (registrada por `agent:register`)
Priority: P3
Impeccable: B — encaixe estrutural sem pixel novo; paridade de DOM/`data-slot` é o aceite (sem critique visual)
Appetite: ~0,5–1 dia eng (fill-in)
Responsável: —
## Contexto
O C150 (#935) entregou o `GoogleCalendarPickerDialog` — a **3ª cópia** do chrome responsivo
Dialog/Drawer da agenda. O C148 (#933, entregue 2026-09-12) considerou e **rejeitou** a extração
(Opção C) para 3 call sites, com gatilho explícito: _"4º modal da agenda com o mesmo corpo
rolável, ou adoção do padrão por outro domínio"_ ([c148-modais-agenda-desktop-impl.md](c148-modais-agenda-desktop-impl.md),
Decisão C). Com o picker, há **4** modais com `data-slot="dialog-scroll-body"` (ActivityOverlay,
Sync, Feed, Picker) — **o gatilho disparou** (mesmo precedente do E10+: _"o gatilho disparou, e é por
isso que este lote existe em vez de mais uma linha adiada"_).
Três deles — `GoogleCalendarSyncDialog`, `CalendarFeedDialog` e `GoogleCalendarPickerDialog` — têm
chrome **idêntico** exceto `max-w` e conteúdo do rodapé: desktop
`DialogContent flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl sm:p-0`
- header `shrink-0 border-b px-6 py-4 pr-12 text-left` + corpo
`min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4` + rodapé `shrink-0 border-t px-6 py-4`;
mobile `DrawerContent max-h-[85vh]` + corpo rolável com as ações dentro + `DrawerFooter` com Fechar.
Evidência (linhas atuais, pós-simplify): picker `src/components/campaign/activity/GoogleCalendarPickerDialog.tsx`,
sync `GoogleCalendarSyncDialog.tsx`, feed `CalendarFeedDialog.tsx:214-260`; ActivityOverlay `:448`.
**Já resolvido no simplify (não reabrir):** picker com guarda de sessão (`sessionRef`/`reloadCount`),
catch de promise rejeitada e status de erro, `CalendarOption` extraído, retry com `useCallback`,
seleção desabilitada durante a escolha e copy da descrição corrigida; chrome com o envelope
`applyState` compartilhado pelos 4 handlers; sync dialog com `runAction` e `addLink` derivado
localmente; `buildGoogleCalendarWebcalUrl` morto removido; `resolveGoogleCalendarPickerClient`
dobrado em `loadWritableGoogleCalendars` e flag `changed` removida.
## Objetivos
- Um `CampaignResponsiveDialog` em `src/components/campaign/shared/`, dono do contrato responsivo
(Dialog desktop com header/body/footer; Drawer mobile com ações no corpo + Fechar no
`DrawerFooter`) — a divergência mobile/desktop deixa de estar triplicada.
- Sync/Feed/Picker compõem o shell com DOM e classes **idênticos** aos de hoje (paridade pinada
pelos units/e2e existentes).
- F3 (cortável): fonte única da copy do one-click em `src` (chrome + dialog); testes seguem com literais.
- Guardrails: sem migration/schema/access/Consent; `ui/dialog.tsx` e os demais consumidores
intocados; ActivityOverlay fora; comportamento e estado inalterados.
## Decisões travadas
- **Escopo: só os 3 homogêneos; ActivityOverlay fora.** O overlay usa `p-0` + cards com `<form>`
envolvendo header/body/footer, `sm:max-w-3xl` e mobile como sheet (não Drawer); incluí-lo exigiria
modos/wrappers que transformariam o shell num config pass-through — exatamente o que o C148
rejeitou. **Rejeitadas:** shell paramétrico para os 4 (YAGNI/config); manter a 4ª cópia.
- **Props de forma, não `variant` genérico:** `{ title, description, children, actions?, size: 'lg' | 'xl', footerClassName? }`;
ações no corpo no mobile e no rodapé fixo no desktop (a regra que o C148 conquistou). Picker = `lg`;
sync/feed = `xl`. **Rejeitadas:** cada dialog montar os dois ramos (mantém a cópia);
`asChild`/render-prop (cerimônia sem volatilidade).
- **Paridade como aceite, não redesign:** classes e `data-slot` preservados; se um unit exigir edição
de asserção estrutural, parar e reavaliar.
- **F3 só se F1/F2 não estourarem** (cortável sem perda do objetivo principal).
## Fases
1. **F1 — shell + picker (tracer, ~50%).** Criar `CampaignResponsiveDialog`; migrar o
`GoogleCalendarPickerDialog` (call site mais novo, com unit próprio). Gate: unit do picker verde +
inspeção visual mobile/desktop.
2. **F2 — sync + feed (~35%).** Mesmo diff mecânico. Gate: units `googleCalendarSyncDialog`/
`calendarFeedDialog` verdes sem edição das asserções estruturais.
3. **F3 — copy única do one-click (cortável, ~5%).** Exportar a label consumida por
`AgendaGoogleSyncChrome` e `GoogleCalendarSyncDialog`; literais permanecem nos testes/e2e.
4. **F4 — gates (~10%, não cortável).** `pnpm gate:fast`; e2e afetados (`campaignAgendaGoogleSync`,
`campaignAgendaFeed`, `campaignActivity`); changelog.
## Rabbit holes / Não escopo
- Não tocar `ui/dialog.tsx` nem os outros consumidores (C148).
- Não incluir `ActivityOverlay` (form/cards/mobile sheet) — gatilho: 2º overlay de form com o mesmo formato.
- Não unificar os breakpoints (`useNarrowMeasured(640)` do overlay vs `useIsMobile()` 768) — C148.
- Não unificar as máquinas de estado picker/sync (S3) — domínios distintos (lista+escolha × ciclo de sync).
- Não mudar copy (fora F3), lógica, estado, nem o `DrawerFooter`/nested sheet.
- Não redesenhar: zero pixel novo; classes idênticas.
## Riscos e mitigação
- **Regressão mobile por recomposição:** paridade de DOM; units/e2e mobile (C103/C94/C114) verdes;
diff por dialog.
- **twMerge/variantes `sm:`** (lição C148): o shell espelha as variantes e preserva `p-0`; asserções
são de `data-slot`/estrutura, não de classe.
- **Rodapé condicional** (sync não renderiza quando vazio; feed sempre; picker condicional): `actions?`
— footer só com `actions`; sync passa `errorNotice` junto das ações.
- **Testes acoplados ao shell:** asserções existentes são estruturais; edição delas = sinal de mudança
de comportamento → parar.
## Explicitamente fora
- **S3 — máquinas de estado picker/sync:** deferido — não é duplicação exata (ciclos de domínio
distintos; D5 do C150 escolheu diálogos separados). Gatilho: 3º diálogo com a mesma máquina busy/erro/ação.
- **S2 standalone:** absorvido como F3 (cortável); se cortada, gatilho: 3ª superfície consumindo a label.
- **ActivityOverlay na extração:** fora (heterogêneo) — gatilho acima.
- **`ui/dialog.tsx` / unificação de breakpoint:** decisões do C148, não reabrir.
- Achados cosméticos dos revisores aplicados no simplify — não reabrir.
## Aceite de engenharia
- [ ] Os 3 diálogos compõem `CampaignResponsiveDialog`; nenhuma cópia do chrome trio resta
(`grep` por `sm:max-w-xl` + `dialog-scroll-body` só no shell).
- [ ] DOM mobile idêntico (ações no corpo + `DrawerFooter` Fechar) e desktop com rodapé fixo;
units/e2e atuais verdes sem edição de asserção estrutural.
- [ ] `pnpm gate:fast` verde; e2e afetados rodados; changelog.
- [ ] Sem migration/access/Consent; `ui/dialog.tsx` intacto.
## Self-score (decision-quality)
| Critério | Nota | Justificativa |
| ---------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------ |
| Decisões caras têm rejeitadas? | 4/5 | Escopo (trio vs 4) e forma do shell com rejeitadas explícitas; demais decisões são baratas. |
| Cabe no appetite? | 5/5 | 1 componente + 3 migrações mecânicas + testes; sem schema. |
| Rabbit holes nomeados? | 5/5 | Overlay, breakpoints, máquinas de estado, base global, copy fora de F3. |
| Depth check: reusa shells/helpers? | 4/5 | O shell encapsula o contrato responsivo conquistado no C148; não é pass-through para o trio (é para o overlay, que fica fora). |
| Intenção permanece satisfeita? | 5/5 | Refactor sem pixel/comportamento novo; paridade pinada por testes. |
**Total: 4,6/5 (gate ≥4).**

View file

@ -87,13 +87,17 @@ export const AgendaGoogleSyncChrome = ({
useBridgedQuickAction('openGoogleCalendarSync', openSync)
// "Re-tenta sem ação manual": a paused mirror tries once when the agenda
// page loads (the staff is operating — the natural retry moment).
const handleSyncNow = useCallback(async () => {
const result = await onSyncNow()
// Shared envelope: every action result that succeeded becomes the rendered
// state; failures are left for the caller's UI to surface.
const applyState = useCallback(async (run: () => Promise<GoogleCalendarSyncActionResult>) => {
const result = await run()
if (result.ok) setState(result)
return result
}, [onSyncNow])
}, [])
// "Re-tenta sem ação manual": a paused mirror tries once when the agenda
// page loads (the staff is operating — the natural retry moment).
const handleSyncNow = useCallback(() => applyState(onSyncNow), [applyState, onSyncNow])
useEffect(() => {
if (state.status === 'paused' && !autoRetriedRef.current) {
@ -103,20 +107,12 @@ export const AgendaGoogleSyncChrome = ({
}, [state.status, handleSyncNow])
const handleSetDisabled = useCallback(
async (disabled: boolean) => {
const result = await onSetDisabled(disabled)
if (result.ok) setState(result)
return result
},
[onSetDisabled],
(disabled: boolean) => applyState(() => onSetDisabled(disabled)),
[applyState, onSetDisabled],
)
// C149 — the connection card follows the disconnect without a page reload.
const handleDisconnect = useCallback(async () => {
const result = await onDisconnect()
if (result.ok) setState(result)
return result
}, [onDisconnect])
const handleDisconnect = useCallback(() => applyState(onDisconnect), [applyState, onDisconnect])
// C150 — one overlay at a time: the picker replaces the mirror dialog while
// open (nested Radix/vaul overlays have fragile focus/scroll on mobile).
@ -131,12 +127,8 @@ export const AgendaGoogleSyncChrome = ({
}, [])
const handleChooseCalendar = useCallback(
async (calendarId: string) => {
const result = await onChooseCalendar(calendarId)
if (result.ok) setState(result)
return result
},
[onChooseCalendar],
(calendarId: string) => applyState(() => onChooseCalendar(calendarId)),
[applyState, onChooseCalendar],
)
const pill = useMemo(() => {

View file

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { RefreshCwIcon } from 'lucide-react'
@ -41,7 +41,50 @@ type GoogleCalendarPickerDialogProps = {
type PickerListStatus = 'loading' | 'error' | 'ready'
const TITLE = 'Calendário principal da campanha'
const DESCRIPTION = 'Criar e editar atividades espelham no calendário escolhido.'
const DESCRIPTION = 'Atividades criadas e editadas no Teqo espelham no calendário escolhido.'
const LIST_FAILED_MESSAGE = 'Não foi possível listar os calendários agora.'
const CalendarOption = ({
calendar,
isSelected,
isCurrent,
disabled,
onSelect,
}: {
calendar: GoogleCalendarPickerOption
isSelected: boolean
isCurrent: boolean
disabled: boolean
onSelect: () => void
}) => (
<button
type="button"
aria-pressed={isSelected}
onClick={onSelect}
disabled={disabled}
className={cn(
'flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60',
isSelected ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted',
)}
>
<span
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full border',
isSelected ? 'border-primary' : 'border-muted-foreground',
)}
aria-hidden
>
{isSelected ? <span className="h-2 w-2 rounded-full bg-primary" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{calendar.summary}</span>
{calendar.primary ? (
<span className="block text-xs text-muted-foreground">Calendário principal da conta</span>
) : null}
</span>
{isCurrent ? <span className="shrink-0 text-xs text-muted-foreground">em uso</span> : null}
</button>
)
/**
* C150 — primary-calendar picker. The list is fetched live from the connected
@ -64,43 +107,68 @@ export const GoogleCalendarPickerDialog = ({
const [selectedId, setSelectedId] = useState<string | null>(null)
const [chooseError, setChooseError] = useState<string | null>(null)
const [isChoosing, setIsChoosing] = useState(false)
const [attempt, setAttempt] = useState(0)
const [reloadCount, setReloadCount] = useState(0)
// A session is one open→close cycle: results from a previous session never
// apply to the next one (a choose in flight when the picker is closed and
// reopened must not close/relabel the new session).
const sessionRef = useRef(0)
useEffect(() => {
if (!open) return
const session = ++sessionRef.current
let cancelled = false
const isStale = () => cancelled || session !== sessionRef.current
setStatus('loading')
setCalendars([])
setSelectedId(null)
setListError(null)
setChooseError(null)
void onListCalendars().then((result) => {
if (cancelled) return
if (result.ok) {
setCalendars(result.calendars)
setSelectedId(currentCalendarId)
setStatus('ready')
} else {
setListError(result.message)
setIsChoosing(false)
void onListCalendars()
.then((result) => {
if (isStale()) return
if (result.ok) {
setCalendars(result.calendars)
setSelectedId(currentCalendarId)
setStatus('ready')
} else {
setListError(result.message)
setStatus('error')
}
})
.catch(() => {
if (isStale()) return
setListError(LIST_FAILED_MESSAGE)
setStatus('error')
}
})
})
return () => {
cancelled = true
}
}, [open, attempt, onListCalendars, currentCalendarId])
}, [open, reloadCount, onListCalendars, currentCalendarId])
const retryList = useCallback(() => setReloadCount((value) => value + 1), [])
const handleChoose = async () => {
if (!selectedId || selectedId === currentCalendarId || isChoosing) return
const session = sessionRef.current
setIsChoosing(true)
setChooseError(null)
try {
const result = await onChooseCalendar(selectedId)
if (session !== sessionRef.current) return
if (result.ok) {
onOpenChange(false)
} else {
setChooseError(result.message ?? 'Não foi possível escolher o calendário.')
}
} catch {
if (session !== sessionRef.current) return
setChooseError('Não foi possível escolher o calendário agora.')
} finally {
setIsChoosing(false)
if (session === sessionRef.current) setIsChoosing(false)
}
}
@ -115,7 +183,7 @@ export const GoogleCalendarPickerDialog = ({
<p role="alert" className="text-sm text-red-600">
{listError}
</p>
<Button type="button" variant="outline" onClick={() => setAttempt((value) => value + 1)}>
<Button type="button" variant="outline" onClick={retryList}>
Tentar de novo
</Button>
</div>
@ -124,55 +192,23 @@ export const GoogleCalendarPickerDialog = ({
<p className="text-sm text-muted-foreground">
Nenhum calendário com permissão de edição nesta conta Google.
</p>
<Button type="button" variant="outline" onClick={() => setAttempt((value) => value + 1)}>
<Button type="button" variant="outline" onClick={retryList}>
Atualizar lista
</Button>
</div>
) : (
<ul
role="radiogroup"
aria-label="Calendários da conta Google conectada"
className="space-y-2"
>
{calendars.map((calendar) => {
const isSelected = selectedId === calendar.id
const isCurrent = currentCalendarId === calendar.id
return (
<li key={calendar.id}>
<button
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => setSelectedId(calendar.id)}
className={cn(
'flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring',
isSelected ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted',
)}
>
<span
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full border',
isSelected ? 'border-primary' : 'border-muted-foreground',
)}
aria-hidden
>
{isSelected ? <span className="h-2 w-2 rounded-full bg-primary" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{calendar.summary}</span>
{calendar.primary ? (
<span className="block text-xs text-muted-foreground">
Calendário principal da conta
</span>
) : null}
</span>
{isCurrent ? (
<span className="shrink-0 text-xs text-muted-foreground">em uso</span>
) : null}
</button>
</li>
)
})}
<ul aria-label="Calendários da conta Google conectada" className="space-y-2">
{calendars.map((calendar) => (
<li key={calendar.id}>
<CalendarOption
calendar={calendar}
isSelected={selectedId === calendar.id}
isCurrent={currentCalendarId === calendar.id}
disabled={isChoosing}
onSelect={() => setSelectedId(calendar.id)}
/>
</li>
))}
</ul>
)

View file

@ -28,7 +28,10 @@ import {
import { Input } from '@/components/ui/input'
import { useIsMobile } from '@/hooks/use-mobile'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { buildGoogleCalendarPublicIcalUrl } from '@/lib/googleCalendarLink'
import {
buildGoogleCalendarAddLink,
buildGoogleCalendarPublicIcalUrl,
} from '@/lib/googleCalendarLink'
import type { GoogleCalendarConnectionStatus } from '@/utilities/googleCalendarSync'
type GoogleCalendarSyncDialogProps = {
@ -100,8 +103,9 @@ export const GoogleCalendarSyncDialog = ({
setActionError(null)
}
// C150 — the manual path ("Por URL", Apple Calendar, Outlook) consumes the
// public iCal URL; the one-click button consumes `state.addLink`.
// C150 — the one-click button and the manual path ("Por URL", Apple
// Calendar, Outlook) are both derived from the configured calendar.
const addLink = state.calendarId ? buildGoogleCalendarAddLink(state.calendarId) : null
const icalUrl = state.calendarId ? buildGoogleCalendarPublicIcalUrl(state.calendarId) : null
const handleCopy = async () => {
@ -143,12 +147,12 @@ export const GoogleCalendarSyncDialog = ({
const handleDisconnect = () => runAction(onDisconnect, 'Não foi possível desconectar.')
const linkBlock = state.calendarId ? (
const linkBlock = addLink ? (
<div className="space-y-3">
<div className="space-y-2">
<p className="text-sm font-medium">Adicionar ao meu Google Calendar</p>
<Button asChild className="w-full sm:w-auto">
<a href={state.addLink ?? undefined} target="_blank" rel="noopener noreferrer">
<a href={addLink} target="_blank" rel="noopener noreferrer">
<ExternalLinkIcon className="mr-2 h-4 w-4" />
Adicionar ao meu Google Calendar
</a>

View file

@ -5,11 +5,9 @@
* publicly by link (ops runbook); these URLs are what the team receives:
*
* - Google Calendar: the `cid` calendarId (C150) — Google opens the calendar
* ready to subscribe, no "add by URL" step. The previous `cid` webcal URL
* was the manual flow; the raw webcal/public iCal URL stays for the other
* apps.
* - Apple Calendar / Outlook: subscribe straight to the webcal URL the Google
* calendar exposes for public calendars.
* ready to subscribe, no "add by URL" step.
* - Google Calendar "add by URL", Apple Calendar and Outlook: the public iCal
* URL of the same calendar (manual path).
*
* Both require the calendar to be public in the owner's sharing settings —
* the link IS the credential, same model as the iCal feed secret.
@ -17,9 +15,6 @@
const GOOGLE_CALENDAR_ADD_BASE = 'https://calendar.google.com/calendar/r'
const GOOGLE_CALENDAR_ICAL_BASE = 'https://calendar.google.com/calendar/ical'
export const buildGoogleCalendarWebcalUrl = (calendarId: string): string =>
`webcal://calendar.google.com/calendar/ical/${encodeURIComponent(calendarId)}/public/basic.ics`
/** C150 — the one-click add link: `cid` carries the calendar id itself. */
export const buildGoogleCalendarAddLink = (calendarId: string): string =>
`${GOOGLE_CALENDAR_ADD_BASE}?cid=${encodeURIComponent(calendarId)}`

View file

@ -905,7 +905,7 @@ export type GoogleCalendarPickerListResult =
| { ok: false; reason: 'not-connected' | 'auth-error' | 'api-error'; message: string }
export type GoogleCalendarPrimaryCalendarResult =
| { ok: true; calendarId: string; changed: boolean }
| { ok: true; calendarId: string }
| {
ok: false
reason: 'not-connected' | 'auth-error' | 'api-error' | 'not-listed'
@ -920,38 +920,32 @@ const GOOGLE_CALENDAR_PICKER_NOT_LISTED_MESSAGE =
'O calendário escolhido não está mais disponível na conta Google conectada. Atualize a lista e escolha de novo.'
/**
* C150 — the picker lists the calendars of the OAuth-connected account. The
* service account is deliberately refused: its own calendar list does not
* contain the calendars shared with it, so it cannot back a picker (the
* connection is the C149 hard dependency of this surface).
* C150 — lists the calendars the connected account can WRITE to. The service
* account is deliberately refused: its own calendar list does not contain the
* calendars shared with it, so it cannot back a picker (the connection is the
* C149 hard dependency of this surface). A dead OAuth connection is recorded
* on the row (same classification as the engine) so the card derives `error`
* and offers Reconnect; every other failure is a transient API error.
*/
const resolveGoogleCalendarPickerClient = (
const loadWritableGoogleCalendars = async (
payload: Payload,
doc: GoogleCalendarSyncDoc | null,
client?: GoogleCalendarClient,
): { client: GoogleCalendarClient } | { reason: 'not-connected'; message: string } => {
): Promise<GoogleCalendarPickerListResult> => {
const auth = readGoogleCalendarAuth(doc)
if (auth?.kind !== 'oauth') {
return { reason: 'not-connected', message: GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE }
return {
ok: false,
reason: 'not-connected',
message: GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE,
}
}
return { client: client ?? createGoogleCalendarClient(auth) }
}
/**
* C150 — lists the calendars the connected account can WRITE to, for the
* primary-calendar picker. A dead OAuth connection is recorded on the row
* (same classification as the engine) so the card derives `error` and offers
* Reconnect; every other failure is a transient API error.
*/
export const listGoogleCalendarPickerOptions = async (
payload: Payload,
options?: { client?: GoogleCalendarClient },
): Promise<GoogleCalendarPickerListResult> => {
const doc = await loadGoogleCalendarSyncConfig(payload)
const resolved = resolveGoogleCalendarPickerClient(doc, options?.client)
if ('reason' in resolved) return { ok: false, ...resolved }
try {
return { ok: true, calendars: await resolved.client.listCalendars() }
return {
ok: true,
calendars: await (client ?? createGoogleCalendarClient(auth)).listCalendars(),
}
} catch (error) {
if (error instanceof GoogleCalendarAuthError) {
await recordGoogleCalendarOAuthError(payload, error.message)
@ -961,6 +955,13 @@ export const listGoogleCalendarPickerOptions = async (
}
}
/** The read model of the picker: the connected account's writable calendars. */
export const listGoogleCalendarPickerOptions = async (
payload: Payload,
options?: { client?: GoogleCalendarClient },
): Promise<GoogleCalendarPickerListResult> =>
loadWritableGoogleCalendars(payload, await loadGoogleCalendarSyncConfig(payload), options?.client)
/**
* C150 — sets the campaign's primary calendar (`calendarId`), the one the
* mirror writes to. The id is validated server-side against the connected
@ -985,9 +986,9 @@ export const setCampaignPrimaryCalendarId = async (
message: GOOGLE_CALENDAR_PICKER_NOT_CONNECTED_MESSAGE,
}
}
if (doc.calendarId === calendarId) return { ok: true, calendarId, changed: false }
if (doc.calendarId === calendarId) return { ok: true, calendarId }
const listed = await listGoogleCalendarPickerOptions(payload, options)
const listed = await loadWritableGoogleCalendars(payload, doc, options?.client)
if (!listed.ok) return listed
if (!listed.calendars.some((calendar) => calendar.id === calendarId)) {
return { ok: false, reason: 'not-listed', message: GOOGLE_CALENDAR_PICKER_NOT_LISTED_MESSAGE }
@ -1002,7 +1003,7 @@ export const setCampaignPrimaryCalendarId = async (
// the campaign path is authorized at the action and validated above.
overrideAccess: true,
})
return { ok: true, calendarId, changed: true }
return { ok: true, calendarId }
}
/** The push-channel webhook address; the URL secret IS the credential. */

View file

@ -230,7 +230,7 @@ describe('AgendaGoogleSyncChrome (S14)', () => {
const picker = await screen.findByRole('dialog', {
name: /Calendário principal da campanha/,
})
fireEvent.click(within(picker).getByRole('radio', { name: /Meu calendário/ }))
fireEvent.click(within(picker).getByRole('button', { name: /Meu calendário/ }))
fireEvent.click(within(picker).getByRole('button', { name: 'Escolher calendário' }))
await waitFor(() => expect(onChooseCalendar).toHaveBeenCalledTimes(1))

View file

@ -3,18 +3,11 @@ import { describe, expect, it } from 'vitest'
import {
buildGoogleCalendarAddLink,
buildGoogleCalendarPublicIcalUrl,
buildGoogleCalendarWebcalUrl,
} from '@/lib/googleCalendarLink'
describe('googleCalendarLink', () => {
const calendarId = 'c_abc123@group.calendar.google.com'
it('builds the webcal subscription URL Apple/Outlook consume', () => {
expect(buildGoogleCalendarWebcalUrl(calendarId)).toBe(
'webcal://calendar.google.com/calendar/ical/c_abc123%40group.calendar.google.com/public/basic.ics',
)
})
it('builds the one-click Google add link with the calendarId as cid (C150)', () => {
const link = buildGoogleCalendarAddLink(calendarId)
expect(link).toBe(

View file

@ -114,8 +114,11 @@ describe('GoogleCalendarPickerDialog (C150)', () => {
const picker = await screen.findByRole('dialog', {
name: /Calendário principal da campanha/,
})
const current = within(picker).getByRole('radio', { name: /Agenda da Campanha/ })
expect(current.getAttribute('aria-checked')).toBe('true')
const current = within(picker).getByRole('button', {
name: /Agenda da Campanha/,
pressed: true,
})
expect(current.getAttribute('aria-pressed')).toBe('true')
expect(within(picker).getByText('em uso')).toBeTruthy()
expect(within(picker).getByText('Calendário principal da conta')).toBeTruthy()
@ -131,7 +134,7 @@ describe('GoogleCalendarPickerDialog (C150)', () => {
const picker = await screen.findByRole('dialog', {
name: /Calendário principal da campanha/,
})
fireEvent.click(within(picker).getByRole('radio', { name: /Meu calendário/ }))
fireEvent.click(within(picker).getByRole('button', { name: /Meu calendário/ }))
fireEvent.click(within(picker).getByRole('button', { name: 'Escolher calendário' }))
await waitFor(() => expect(onChooseCalendar).toHaveBeenCalledWith(CALENDAR_B))
@ -148,7 +151,7 @@ describe('GoogleCalendarPickerDialog (C150)', () => {
const picker = await screen.findByRole('dialog', {
name: /Calendário principal da campanha/,
})
fireEvent.click(within(picker).getByRole('radio', { name: /Meu calendário/ }))
fireEvent.click(within(picker).getByRole('button', { name: /Meu calendário/ }))
fireEvent.click(within(picker).getByRole('button', { name: 'Escolher calendário' }))
expect(await within(picker).findByText(/não está mais disponível/)).toBeTruthy()
@ -181,4 +184,35 @@ describe('GoogleCalendarPickerDialog (C150)', () => {
).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Escolher calendário' })).toBeNull()
})
it('maps a rejected list action to the error state with retry', async () => {
const onListCalendars = vi
.fn<() => Promise<GoogleCalendarListActionResult>>()
.mockRejectedValueOnce(new Error('transport down'))
.mockResolvedValueOnce(listResult)
renderPicker({ onListCalendars })
expect(await screen.findByText('Não foi possível listar os calendários agora.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Tentar de novo' }))
expect(await screen.findByText('Agenda da Campanha')).toBeTruthy()
})
it('maps a rejected choose action to an inline error without closing', async () => {
const onOpenChange = vi.fn()
const onChooseCalendar = vi
.fn<() => Promise<GoogleCalendarSyncActionResult>>()
.mockRejectedValueOnce(new Error('transport down'))
renderPicker({ onOpenChange, onChooseCalendar })
const picker = await screen.findByRole('dialog', {
name: /Calendário principal da campanha/,
})
fireEvent.click(within(picker).getByRole('button', { name: /Meu calendário/ }))
fireEvent.click(within(picker).getByRole('button', { name: 'Escolher calendário' }))
expect(
await within(picker).findByText(/Não foi possível escolher o calendário agora/),
).toBeTruthy()
expect(onOpenChange).not.toHaveBeenCalled()
})
})