mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
refactor(campaign): C150 — simplify (catch/sessão do picker, leitura única, webcal morto) + changelog e débito C156
This commit is contained in:
parent
cc6d2abd04
commit
7990c79b3e
11 changed files with 335 additions and 136 deletions
1
docs/changelog/2026-09-12-c150.md
Normal file
1
docs/changelog/2026-09-12-c150.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
125
docs/plans/escala-dry-pos-c150.md
Normal file
125
docs/plans/escala-dry-pos-c150.md
Normal 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).**
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue