diff --git a/docs/changelog/2026-09-12-c150.md b/docs/changelog/2026-09-12-c150.md new file mode 100644 index 00000000..7c80a545 --- /dev/null +++ b/docs/changelog/2026-09-12-c150.md @@ -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=` — â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. diff --git a/docs/plans/c150-calendario-principal-e-inscricao-staff-impl.md b/docs/plans/c150-calendario-principal-e-inscricao-staff-impl.md index f6bd753c..138cd14d 100644 --- a/docs/plans/c150-calendario-principal-e-inscricao-staff-impl.md +++ b/docs/plans/c150-calendario-principal-e-inscricao-staff-impl.md @@ -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. diff --git a/docs/plans/escala-dry-pos-c150.md b/docs/plans/escala-dry-pos-c150.md new file mode 100644 index 00000000..27421334 --- /dev/null +++ b/docs/plans/escala-dry-pos-c150.md @@ -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 `
` + 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).** diff --git a/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx b/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx index 7da8c1e2..84aede95 100644 --- a/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx +++ b/src/components/campaign/activity/AgendaGoogleSyncChrome.tsx @@ -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) => { + 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(() => { diff --git a/src/components/campaign/activity/GoogleCalendarPickerDialog.tsx b/src/components/campaign/activity/GoogleCalendarPickerDialog.tsx index 89fae89f..7a53932a 100644 --- a/src/components/campaign/activity/GoogleCalendarPickerDialog.tsx +++ b/src/components/campaign/activity/GoogleCalendarPickerDialog.tsx @@ -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 +}) => ( + +) /** * C150 — primary-calendar picker. The list is fetched live from the connected @@ -64,43 +107,68 @@ export const GoogleCalendarPickerDialog = ({ const [selectedId, setSelectedId] = useState(null) const [chooseError, setChooseError] = useState(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 = ({

{listError}

- @@ -124,55 +192,23 @@ export const GoogleCalendarPickerDialog = ({

Nenhum calendário com permissão de edição nesta conta Google.

- ) : ( -
    - {calendars.map((calendar) => { - const isSelected = selectedId === calendar.id - const isCurrent = currentCalendarId === calendar.id - return ( -
  • - -
  • - ) - })} +
      + {calendars.map((calendar) => ( +
    • + setSelectedId(calendar.id)} + /> +
    • + ))}
    ) diff --git a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx index 07813c40..78d43721 100644 --- a/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx +++ b/src/components/campaign/activity/GoogleCalendarSyncDialog.tsx @@ -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 ? (

    Adicionar ao meu Google Calendar