docs(plan): C164 + C165 — canal de push do Google Calendar e importação automática de eventos (Related #1009, #1010)
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions

This commit is contained in:
Francisco Solla 2026-09-14 15:14:18 -03:00
parent ba7aa50346
commit 45617356ee
3 changed files with 824 additions and 0 deletions

View file

@ -0,0 +1,135 @@
# Agenda: canal de push do Google Calendar nunca registra (expiration string → Invalid time value)
Status: rascunho
Atualizado em: 2026-09-14
Issue: #1009
Priority: P1
Model: composer-2.5
Impeccable: A — N/A (sem UI)
Rascunho UI: N/A — sem UI
Appetite: ~0,5 dia eng; um outcome verificável — canal de push registra e o vaivém do Google volta a ser imediato
Responsável: —
## Intenção
Quem opera a agenda vive no Google Calendar. O Teqo já espelha o que nasce
aqui (atividades criadas, editadas, canceladas aparecem lá), mas o caminho de
volta está morto: edições e cancelamentos feitos no Gmail **não** voltam para a
atividade em tempo real. Na prática, a coordenação/imprensa mexe no evento pelo
Google e o Teqo continua exibindo a versão antiga até alguém abrir a agenda e
disparar um sync manual.
Descobrimos isso ao vivo: a conexão Google está saudável, o espelho Teqo→Google
funciona, mas o canal de notificação Google→Teqo nunca foi registrado em
produção — `push_channel_id`/`push_channel_expires_at` vazios, `push_channel_error`
preenchido, e o log acusa `Invalid time value` alternando com `fetch failed`.
A causa é determinística: o Google devolve `expiration` como string (int64 em
JSON) e a leitura assume number, quebrando na conversão para data. É um defeito
pequeno com efeito grande: a direção Google→Teqo (C115) fica degradada, e o
usuário percebe como "a agenda não atualiza".
## Persona e fluxo
- **Persona / contexto:** coordenação e imprensa do mandato, na mesa, operando a
agenda compartilhada direto no Google Calendar; esperam que o Teqo reflita o
que fazem lá sem passo extra.
- **Job principal:** editar ou cancelar um evento no Google e ver a atividade
correspondente no Teqo atualizar sozinha, em segundos.
- **Fluxo desejado:** (1) o Teqo registra o canal de push na primeira passada de
sync; (2) o canal se renova sozinho perto de expirar; (3) uma edição feita no
Gmail chega como notificação e a atividade aparece atualizada em segundos-minutos,
sem clique em "Sincronizar agora".
- **Anti-goals de produto:** não criar uma segunda fonte de verdade da agenda,
não pausar nem degradar o espelho Teqo→Google quando o canal falhar, não
introduzir UI nova.
## Objetivo e aceite
- Após uma passada de sync, o canal de push está registrado: `pushChannelId` e
`pushChannelResourceId` preenchidos, `pushChannelExpiresAt` preenchido e
`pushChannelError` nulo.
- O canal se renova sozinho antes de expirar (lead de renovação já existente),
sem intervenção humana.
- Editar ou cancelar no Gmail um evento espelhado reflete na atividade do Teqo
em segundos-minutos, sem sync manual.
- Guardrail: falha ao registrar/renovar o canal **nunca** derruba o espelho
Teqo→Google nem o restante do Teqo (comportamento best-effort atual mantido);
o erro fica registrado para diagnóstico.
- Sem migração de schema, sem Consent, sem UI nova.
## Dados (intenção)
- **Vou apresentar dados?** Não — este item conserta a infraestrutura do espelho
(registro/renovação do canal), sem superfície de dados nova nem métrica de
produto a exibir. O "dado" observável é o estado do canal no banco, usado como
verificação operacional, não como apresentação.
- **Decisões desbloqueadas:** N/A — nenhuma escolha de negócio depende deste
item; o ganho é comportamento (sincronização imediata).
- **Forma:** N/A.
## Dados da decisão (literais)
- Contrato do Google: `events.watch` devolve `expiration` como **int64
serializado em string** no JSON (ex.: `"expiration": "1757900000000"`); o
client hoje tipa `GoogleWatchChannel.expiration: number | null`.
- A normalização deve acontecer **na borda da API** (client), para o resto do
motor continuar recebendo number (ou null) — não espalhar coerção pelo consumidor.
- Fallback de TTL quando `expiration` vier ausente: `PUSH_CHANNEL_TTL_SECONDS`.
- Teste de regressão deve simular a resposta real com `"expiration": "<millis>"`
(string entre aspas) e cobrir também `expiration` ausente/nulo → fallback.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/utilities/googleCalendarClient.ts` (tipo e parsing do
watch) e `src/utilities/googleCalendarSync.ts` (único consumidor, conversão
para ISO e catch que grava `pushChannelError`); teste de regressão em
`tests/unit/googleCalendarClient.unit.spec.ts`, cujo stub hoje não modela
`/watch` — a resposta real precisa ser representada.
- **Precedente a olhar:** C115 (canal de push) e C114 (espelho) para o contrato
best-effort; `tests/int/googleCalendarSync.int.spec.ts` e os stubs int/e2e que
hoje só devolvem number/null.
- **Risco de acoplamento:** não alterar o comportamento do espelho Teqo→Google
nem o fail-open do canal; líder/assessor e demais perfis não são afetados.
## Dependências
- Nenhuma.
## Fora de escopo
- Investigar/retentar o `fetch failed` intermitente no mesmo passo
(transporte) — registrar como observação; só vira trabalho se persistir
depois do fix.
- Mudar o modelo de canal (ex.: renovação por cron dedicado, canal por usuário).
- OAuth / reconexão da conta Google.
- Qualquer UI de status do canal.
## Rabbit holes de produto
- **"Já que mexo no canal, melhoro o sync inteiro".** Se alguém "só completar":
reescrever o loop de sync, adicionar full-sync periódico, mexer em OAuth.
**Corte neste item:** corrigir o parsing do `expiration` e provar com teste;
nada além.
- **"O `fetch failed` também é nosso".** Sem causa determinística, perseguir o
erro de transporte agora vira debug aberto. **Corte:** registrá-lo melhor no
`pushChannelError` e reavaliar se persistir.
- **"Mostrar o canal no admin".** Tentação de UI de diagnóstico. **Corte:** o
aceite é verificação operacional (banco + edição real no Gmail).
## Questões em aberto (produto)
- **O `pushChannelError` deve carregar mais contexto quando a falha é de
transporte?** **Opções:** A) manter só a mensagem; B) registrar a causa do
fetch (`fetch failed` + detalhe/causa raiz) quando existir.
**Recomendação:** B — ajuda o diagnóstico do `fetch failed` sem custo de
produto, e não muda o guardrail. _(assumido — validar com produto)_
## Referências
- GitHub Issue [#1009](https://github.com/fsolla/teqo/issues/1009)
- Planos C115 (canal de push Google→Teqo) e C114 (espelho de agenda)
- `src/utilities/googleCalendarClient.ts` — tipo/retorno do `watchEvents`
- `src/utilities/googleCalendarSync.ts` — registro/renovação do canal e gravação do erro
- `tests/unit/googleCalendarClient.unit.spec.ts`, `tests/int/googleCalendarSync.int.spec.ts`
- `docs/ops/teqo-1313-deploy.md` — verificação em produção pós-deploy
- `AGENTS.md` — convenção de migrations (aqui: nenhuma)

View file

@ -0,0 +1,584 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<title>UI draft — C165 · Eventos do Google viram atividades</title>
</head>
<body class="bg-zinc-950 text-zinc-100 antialiased">
<main class="mx-auto max-w-[1400px] p-6 sm:p-8">
<h1 class="text-lg font-medium text-zinc-100">
UI draft — Agenda: importar eventos do Google como atividades (C165)
</h1>
<p class="mt-1 max-w-3xl text-sm text-zinc-400">
A assessoria cria o compromisso no Google Calendar; ele aparece sozinho em
/campanha/atividades como atividade confirmada, sem município; a coordenação abre o overlay
e atribui o município quando quiser.
</p>
<p class="mt-2 text-sm text-zinc-500">
Cinza = estrutura existente · <span class="text-amber-400">acento</span> = novo neste item ·
barras cinza = conteúdo não especificado. Cenas estáticas: sem interação, sem dados reais.
</p>
<!-- ============================================================ -->
<!-- Cena 1 · desktop (~1280px) — lista de atividades -->
<!-- ============================================================ -->
<section class="mt-8">
<p class="text-xs tracking-wide text-zinc-500 uppercase">
Cena 1 · desktop (~1280px) — lista /campanha/atividades com importados do Google
</p>
<div class="mt-3 overflow-x-auto">
<div
class="w-[1280px] max-w-full overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900"
>
<div class="flex min-h-[560px]">
<aside class="w-52 shrink-0 border-r border-zinc-800 bg-zinc-950/50 p-4">
<p class="px-3 text-xs tracking-wide text-zinc-500 uppercase">Campanha</p>
<nav class="mt-3 flex flex-col gap-1 text-sm">
<span class="rounded-lg px-3 py-2 text-zinc-400">Painel</span>
<span class="rounded-lg bg-zinc-800 px-3 py-2 font-medium text-zinc-100"
>Atividades</span
>
<span class="rounded-lg px-3 py-2 text-zinc-400">Municípios</span>
<span class="rounded-lg px-3 py-2 text-zinc-400">Lideranças</span>
<span class="rounded-lg px-3 py-2 text-zinc-400">Apoiadores</span>
</nav>
</aside>
<div class="min-w-0 flex-1 p-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-base font-medium">Atividades</h2>
<p class="mt-0.5 text-sm text-zinc-400">Agenda da campanha · Próximos</p>
</div>
<button
type="button"
class="h-11 rounded-lg bg-amber-500 px-4 text-sm font-medium text-zinc-950"
>
Nova atividade
</button>
</div>
<div class="mt-4 flex flex-wrap gap-2 text-sm">
<span class="rounded-lg bg-zinc-800 px-3 py-2 font-medium">Próximos</span>
<span class="rounded-lg px-3 py-2 text-zinc-400">Todos</span>
<span class="rounded-lg px-3 py-2 text-zinc-400">Realizados</span>
<span class="rounded-lg px-3 py-2 text-zinc-400">Rascunhos</span>
</div>
<div
class="mt-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950 p-2"
>
<span
class="inline-flex items-center gap-1 rounded-md bg-zinc-800 px-2 py-1 text-xs text-zinc-200"
>
Sem município <span class="text-zinc-500">×</span>
</span>
<span class="px-1 text-sm text-zinc-500"
>Filtrar por janela, tipo, título, município…</span
>
<span class="ml-auto px-1 text-xs text-zinc-500">3 resultados</span>
</div>
<div class="mt-4 grid gap-3 lg:grid-cols-2">
<!-- Card importado, ainda sem município -->
<article class="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="rounded-md border border-zinc-700 px-2 py-0.5 text-xs text-zinc-300"
>Confirmado</span
>
<span
class="rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-400"
>Do Google</span
>
<span
class="rounded-md border border-dashed border-zinc-600 px-2 py-0.5 text-xs text-zinc-400"
>Sem município</span
>
</div>
<h3
class="mt-3 text-sm font-medium text-zinc-100 underline decoration-zinc-700 underline-offset-4"
>
Nome do compromisso (título como está no Google)
</h3>
<div class="mt-2 flex flex-col gap-1.5 text-sm text-zinc-400">
<span class="flex items-center gap-1.5">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4 shrink-0"
>
<path
d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"
/>
</svg>
12/09/2026 · 19:00
</span>
<span class="flex items-center gap-1.5">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4 shrink-0"
>
<path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
Local informado no Google
</span>
</div>
<div
class="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-zinc-800 pt-3 text-sm text-zinc-400"
>
<span class="flex items-center gap-1.5">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4 shrink-0"
>
<path d="M20 21a8 8 0 0 0-16 0M12 3a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z" />
</svg>
Resp: Não definido
</span>
<span class="text-xs text-zinc-500">Importado automaticamente</span>
</div>
</article>
<!-- Card importado já triado (município atribuído) -->
<article class="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="rounded-md border border-zinc-700 px-2 py-0.5 text-xs text-zinc-300"
>Confirmado</span
>
<span
class="rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-400"
>Do Google</span
>
<span
class="rounded-md border border-zinc-700 px-2 py-0.5 text-xs text-zinc-300"
>Município · [nome]</span
>
</div>
<h3
class="mt-3 text-sm font-medium text-zinc-100 underline decoration-zinc-700 underline-offset-4"
>
Outro compromisso (título original do Google)
</h3>
<div class="mt-2 flex flex-col gap-1.5 text-sm text-zinc-400">
<span class="flex items-center gap-1.5">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4 shrink-0"
>
<path
d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"
/>
</svg>
15/09/2026 · 09:30
</span>
<span class="flex items-center gap-1.5">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4 shrink-0"
>
<path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
Local informado no Google
</span>
</div>
<div
class="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-zinc-800 pt-3 text-sm text-zinc-400"
>
<span class="text-xs text-zinc-500">Visível para o assessor da carteira</span>
<span class="text-xs text-zinc-500">Importado automaticamente</span>
</div>
</article>
<!-- Card comum do Teqo (sem Google) -->
<article class="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="rounded-md border border-zinc-700 px-2 py-0.5 text-xs text-zinc-300"
>Confirmado</span
>
</div>
<h3
class="mt-3 text-sm font-medium text-zinc-100 underline decoration-zinc-700 underline-offset-4"
>
Atividade criada no Teqo
</h3>
<div class="mt-2 flex flex-col gap-1.5 text-sm text-zinc-400">
<span class="flex items-center gap-1.5">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4 shrink-0"
>
<path
d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"
/>
</svg>
18/09/2026 · 16:00
</span>
<span class="flex items-center gap-1.5">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4 shrink-0"
>
<path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
Município · [nome]
</span>
</div>
</article>
<!-- Placeholder de conteúdo não especificado -->
<article
class="flex flex-col justify-center gap-3 rounded-lg border border-dashed border-zinc-800 bg-zinc-900/50 p-4"
>
<div class="h-4 w-32 rounded bg-zinc-800"></div>
<div class="h-3 w-56 rounded bg-zinc-800/80"></div>
<div class="h-3 w-40 rounded bg-zinc-800/60"></div>
</article>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- Cena 2 · desktop — omnibox aberto com "Sem município" -->
<!-- ============================================================ -->
<section class="mt-10">
<p class="text-xs tracking-wide text-zinc-500 uppercase">
Cena 2 · desktop — omnibox aberto com a opção "Sem município" (triagem)
</p>
<div class="mt-3 w-[760px] max-w-full rounded-xl border border-zinc-800 bg-zinc-900 p-6">
<div
class="flex flex-wrap items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-950 p-2"
>
<span
class="inline-flex items-center gap-1 rounded-md bg-zinc-800 px-2 py-1 text-xs text-zinc-200"
>
Sem município <span class="text-zinc-500">×</span>
</span>
<span class="px-1 text-sm text-zinc-300">sem<span class="text-zinc-600">|</span></span>
</div>
<div class="mt-2 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950">
<div
class="flex items-center justify-between gap-2 border-l-2 border-amber-500 bg-amber-500/10 px-3 py-2 text-sm"
>
<span class="flex items-center gap-2">
<span
class="rounded-md border border-amber-500/40 px-1.5 py-0.5 text-[10px] font-medium tracking-wide text-amber-400 uppercase"
>novo</span
>
<span class="text-zinc-100">Sem município</span>
</span>
<span class="text-xs text-zinc-500">2 atividades</span>
</div>
<div
class="flex items-center justify-between gap-2 border-t border-zinc-800 px-3 py-2 text-sm text-zinc-400"
>
<span>Município: [nome do município]</span>
<span class="text-xs text-zinc-600">12</span>
</div>
<div
class="flex items-center justify-between gap-2 border-t border-zinc-800 px-3 py-2 text-sm text-zinc-400"
>
<span>Janela: Próximos 30 dias</span>
<span class="text-xs text-zinc-600">—</span>
</div>
<div
class="flex items-center justify-between gap-2 border-t border-zinc-800 px-3 py-2 text-sm text-zinc-400"
>
<span>Título contém "caminhada"</span>
<span class="text-xs text-zinc-600">1</span>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- Cena 3 · desktop — overlay de edição (Município opcional) -->
<!-- ============================================================ -->
<section class="mt-10">
<p class="text-xs tracking-wide text-zinc-500 uppercase">
Cena 3 · desktop — overlay de edição com Município opcional e aviso de visibilidade
</p>
<div
class="mt-3 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-950/70 p-6 sm:p-10"
>
<div class="mx-auto max-w-2xl rounded-xl border border-zinc-800 bg-zinc-900">
<div class="flex items-start justify-between gap-3 border-b border-zinc-800 p-4">
<div>
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-sm font-medium">Editar atividade</h2>
<span
class="rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-400"
>Do Google</span
>
<span
class="rounded-md border border-dashed border-zinc-600 px-2 py-0.5 text-xs text-zinc-400"
>Sem município</span
>
</div>
<p class="mt-1 text-xs text-zinc-500">
Importado do Google Calendar · salvar aqui atualiza o mesmo evento
</p>
</div>
<span class="text-zinc-500">×</span>
</div>
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-300">Título</span>
<div
class="flex h-11 items-center rounded-lg border border-zinc-700 px-3 text-sm text-zinc-200"
>
Nome do compromisso
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-300">Início</span>
<div
class="flex h-11 items-center rounded-lg border border-zinc-700 px-3 text-sm text-zinc-200"
>
12/09/2026 19:00
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-300">Término (opcional)</span>
<div
class="flex h-11 items-center rounded-lg border border-zinc-700 px-3 text-sm text-zinc-200"
>
12/09/2026 21:00
</div>
</div>
</div>
<div class="flex h-11 items-center gap-2 rounded-lg border border-zinc-800 px-3">
<span class="size-4 rounded border border-zinc-600"></span>
<span class="text-sm text-zinc-300">Todo o dia</span>
</div>
<div class="flex flex-col gap-1.5">
<span class="flex items-center gap-2 text-sm text-zinc-300">
Município (opcional)
<span
class="rounded-md border border-amber-500/40 px-1.5 py-0.5 text-[10px] font-medium tracking-wide text-amber-400 uppercase"
>novo</span
>
</span>
<div
class="flex h-11 items-center justify-between rounded-lg border border-amber-500/50 px-3 text-sm"
>
<span class="text-zinc-500">Sem município</span>
<span class="text-zinc-600">⌄</span>
</div>
<p class="text-xs text-amber-400/90">
Sem município, só coordenação e candidato veem. Ao atribuir, o assessor da
carteira passa a ver.
</p>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-300">Local (opcional)</span>
<div
class="flex h-11 items-center rounded-lg border border-zinc-700 px-3 text-sm text-zinc-200"
>
Local informado no Google
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-300">Responsáveis</span>
<div
class="h-11 rounded-lg border border-dashed border-zinc-800 bg-zinc-950/60"
></div>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-300">Organizações apoiadoras</span>
<div
class="h-11 rounded-lg border border-dashed border-zinc-800 bg-zinc-950/60"
></div>
</div>
</div>
<div class="flex items-center justify-end gap-2 border-t border-zinc-800 p-4">
<button
type="button"
class="h-11 rounded-lg border border-zinc-700 px-4 text-sm text-zinc-300"
>
Cancelar
</button>
<button
type="button"
class="h-11 rounded-lg bg-amber-500 px-4 text-sm font-medium text-zinc-950"
>
Salvar atividade
</button>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- Cena 4 · mobile (~390px) — card e sheet -->
<!-- ============================================================ -->
<section class="mt-10">
<p class="text-xs tracking-wide text-zinc-500 uppercase">
Cena 4 · mobile (~390px) — card importado e sheet de edição
</p>
<div class="mt-3 flex flex-wrap items-start gap-6">
<!-- Card mobile -->
<div class="w-[390px] max-w-full rounded-xl border border-zinc-800 bg-zinc-900 p-4">
<div class="flex items-center justify-between gap-2">
<h2 class="text-sm font-medium">Atividades</h2>
<span class="text-xs text-zinc-500">Próximos</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="rounded-md border border-zinc-700 px-2 py-0.5 text-xs text-zinc-300"
>Confirmado</span
>
<span
class="rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-400"
>Do Google</span
>
<span
class="rounded-md border border-dashed border-zinc-600 px-2 py-0.5 text-xs text-zinc-400"
>Sem município</span
>
</div>
<h3 class="mt-3 text-sm font-medium text-zinc-100">
Nome do compromisso (título como está no Google)
</h3>
<div class="mt-2 flex flex-col gap-1.5 text-sm text-zinc-400">
<span class="flex items-center gap-1.5">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4 shrink-0"
>
<path
d="M8 2v4M16 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"
/>
</svg>
12/09/2026 · 19:00
</span>
<span class="flex items-center gap-1.5">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="size-4 shrink-0"
>
<path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
Local informado no Google
</span>
</div>
<div
class="mt-3 flex items-center justify-between border-t border-zinc-800 pt-3 text-xs text-zinc-500"
>
<span>Resp: Não definido</span>
<span>Importado automaticamente</span>
</div>
</div>
<!-- Sheet mobile -->
<div
class="w-[390px] max-w-full overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900"
>
<div class="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
<span class="text-sm font-medium">Editar atividade</span>
<span class="text-zinc-500">×</span>
</div>
<div class="flex flex-col gap-3 p-4">
<div class="flex flex-wrap items-center gap-2">
<span
class="rounded-md border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-400"
>Do Google</span
>
<span
class="rounded-md border border-dashed border-zinc-600 px-2 py-0.5 text-xs text-zinc-400"
>Sem município</span
>
</div>
<div
class="flex h-11 items-center rounded-lg border border-zinc-700 px-3 text-sm text-zinc-200"
>
Nome do compromisso
</div>
<div
class="flex h-11 items-center rounded-lg border border-zinc-700 px-3 text-sm text-zinc-200"
>
12/09/2026 19:00
</div>
<div class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-300">Município (opcional)</span>
<div
class="flex h-11 items-center justify-between rounded-lg border border-amber-500/50 px-3 text-sm"
>
<span class="text-zinc-500">Sem município</span>
<span class="text-zinc-600">⌄</span>
</div>
<p class="text-xs text-amber-400/90">
Sem município, só coordenação e candidato veem.
</p>
</div>
<div
class="flex h-11 items-center rounded-lg border border-zinc-700 px-3 text-sm text-zinc-200"
>
Local informado no Google
</div>
<div class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-300">Responsáveis</span>
<div
class="h-11 rounded-lg border border-dashed border-zinc-800 bg-zinc-950/60"
></div>
</div>
</div>
<div class="border-t border-zinc-800 p-4">
<button
type="button"
class="h-11 w-full rounded-lg bg-amber-500 text-sm font-medium text-zinc-950"
>
Salvar atividade
</button>
</div>
</div>
</div>
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,105 @@
# Agenda: importar automaticamente eventos do Google como atividades (sem município, a partir de 16/08/2026)
Status: rascunho
Atualizado em: 2026-09-14
Issue: #1010
Priority: P2
Model: cursor-grok-4.5-high
Depends: C164
Impeccable: B — encaixe na lista de atividades, no overlay existente e na agenda
Rascunho UI: docs/plans/c165-importar-eventos-google-ui-draft.html
Appetite: ~3–4 dias eng; um outcome verificável — evento criado no Google após o início da campanha vira atividade no Teqo sozinho, e a coordenação atribui o município quando quiser
Responsável: —
## Intenção
A agenda da imprensa vive no Google Calendar, mas o Teqo só enxerga dela o que já nasceu no Teqo: o espelho (C114/C115) empurra atividades para o Google e aceita edições de eventos que o próprio Teqo criou — eventos estrangeiros são ignorados de propósito hoje. Daí o pedido do dono: "o que crio no Google não está aparecendo no Teqo". Este item reabre o corte do C115 (criação pelo Google fora de v1; "sem município não entra") para os eventos da conta conectada com início a partir do começo legal da campanha: eles entram como atividade sozinhos, nascem sem município — coisa que o precedente `supporter` já provou ser possível — e a coordenação tria quando quiser.
## Persona e fluxo
- **Persona / contexto:** assessoria de imprensa criando compromissos no Google Calendar, muitas vezes pelo celular; coordenação/candidato na mesa fazendo a triagem do que chegou.
- **Job principal:** "o que eu crio no Google aparece no Teqo sozinho; eu só atribuo o município depois".
- **Fluxo desejado:** a assessoria cria o evento no calendário da imprensa → em minutos ele aparece em `/campanha/atividades` como atividade confirmada, com badge "Do Google" e "Sem município" → a coordenação abre o overlay e escolhe o município (opcional, pode ficar sem) → a partir daí a atividade segue as regras normais (advisor da carteira vê) → editar/remarcar/cancelar no Google reflete; excluir no Google cancela a atividade.
- **Anti-goals de produto:** não virar sincronizador universal (convidados, descrição, RSVP, Meet); não criar segundo cadastro nem segunda fila de triagem; não abrir acesso novo à liderança; não apagar/alterar evento estrangeiro; não virar um painel de administração do sync.
### Esboço de fluxo (B)
```text
[assessoria cria no Google Calendar] — início ≥ 16/08/2026
→ motor Google (C164) detecta e importa → atividade `confirmado`, sem município
→ lista/agenda mostram o card com "Do Google" + "Sem município"
→ coordenação triagem: overlay → escolhe o município quando quiser
→ advisor da carteira passa a ver; edições/cancelamento no Google seguem refletindo
```
### Rascunho UI (B)
- Rascunho UI (gate): `docs/plans/c165-importar-eventos-google-ui-draft.html`
## Objetivo e aceite
- Evento criado no Google (conta da imprensa já conectada) com início a partir de 16/08/2026 vira atividade no Teqo sozinho, com a imediatez Google→Teqo do C164 — sem ninguém copiar dado.
- O overlay passa a permitir criar/editar atividade sem município; sem município só coordenação/candidato veem; ao atribuir, a atividade entra nas regras normais (advisor por carteira/responsável).
- Vínculo estável e idempotente: re-sync não duplica; editar no Teqo atualiza o MESMO evento do Google — nunca nasce um segundo evento `teqo…`.
- Editar/cancelar no Google reflete na atividade (mesma regra de relógio do C115); excluir no Google cancela a atividade.
- Nada anterior a 16/08/2026 entra (sem backfill); evento estrangeiro nunca é apagado/alterado pelo Teqo; leader lockdown intocado; sem Consent novo; o Teqo segue inteiro se o Google cair.
## Dados (intenção)
- **Vou apresentar dados?** Não — superfície operacional (lista, agenda, overlay); sem leitura agregada nova.
- **Decisões desbloqueadas:** coordenação decide o município de um compromisso importado (triagem), o que muda quem o vê; nenhuma métrica.
- **Forma:** adiada ao plano de implementação.
## Dados da decisão (literais)
- Corte: início do evento (instante na América/Bahia) ≥ `2026-08-16`; reusar `CALENDAR_PHASE_ANCHORS.consolidationStart` (`src/lib/visitPlannerAnchors.ts:22-29`) — não duplicar o literal nem criar outro corte. Anterior fica fora para sempre.
- Horizonte de varredura: a mesma janela que o espelho já usa (90 dias para trás / 365 para a frente), com o piso do corte; evento fora da janela não é importado nem espelhado na v1.
- Importado nasce `status: confirmado`, título original do Google **sem** o prefixo `[Município] ` (com espaço final; o prefixo é só de evento `teqo…` espelhado).
- Atividade sem município é permitida; visível/editável só por `coordinator`/`candidate` (precedente `supporter`); com município, regras normais.
- Vínculo atividade↔evento Google é system-write (invisível aos user paths): garante idempotência e que editar no Teqo atualiza O MESMO evento.
- Google edita/cancela → atividade (relógio do C115); Google exclui → atividade cancelada.
- `location` do evento → `locality`; descrição ignorada na v1 (texto vive no Google).
- Recorrência: `singleEvents=true` expande; cada ocorrência vira atividade própria.
- Sem `Consent` novo (dado interno de staff); leader lockdown intocado; Teqo fonte da verdade; evento estrangeiro nunca apagado/alterado pelo Teqo.
## Direção no codebase (hipótese)
- **Áreas prováveis:** `src/collections/Activity.ts` + `src/lib/schemas/activity.ts` (município opcional), `src/utilities/activityFormData.ts`, `src/utilities/access/activities.ts`, motor `src/utilities/googleCalendarSync.ts` (+ `googleCalendarEventMapping.ts`/`googleCalendarReverseEdit.ts`), `src/components/campaign/activity/ActivityOverlay.tsx`, ação em `src/app/(campaign)/campanha/actions/activity.ts`, omnibox `src/utilities/activityOmnibox.ts` + `ActivityFilters.tsx`.
- **Precedente a olhar:** `supporter` com município opcional (`src/utilities/access/supporters.ts:18-41,43-66`; mensagem própria em `src/lib/schemas/supporter.ts:18-19`) e o C115 (relógio/conflito).
- **Risco de acoplamento:** o vínculo com evento estrangeiro precisa nascer (hoje só o id `teqo…` codifica vínculo; não há `googleEventId` em Activity) — schema/migration é decisão do plano de implementação. Pontos que hoje assumem município: ação de rascunhos de demanda (`actions/activity.ts:293-294` dereferencia `.municipality.id`), `CampaignDemand.ts:242-279` ("Município da demanda inválido"), `activityRelationOptions.ts:21-28`; overlay exige município em `ActivityOverlay.tsx:195-205,375-389,459`. Dossiê/filtros por município não mostrarem sem-município é aceitável (a triagem é na lista).
- **Testes que pinam município obrigatório (mudam com o item):** `tests/int/campaignActivity.int.spec.ts:48-55,99-106`, `tests/unit/activityOverlay.unit.spec.tsx:245-258`, `tests/e2e/campaignActivity.e2e.spec.ts:88`.
## Dependências
- **Dura: C164** — mesmo motor Google e a imediatez Google→Teqo; sem ela o "sozinho" fica capenga.
- Suaves: C114 (espelho), C115 (bidirecional/relógio; este item reabre o corte de criação), C149/C150 (conta e calendário principal conectados).
## Fora de escopo
- Eventos anteriores a 16/08/2026: sem backfill retroativo.
- Descrição, convidados/RSVP, lembretes, Meet e demais metadados do Google não viram campo do Teqo na v1.
- Tela dedicada de triagem/fila de aprovação: a triagem é inline (lista + overlay).
- Importar de calendários secundários/inscritos além do principal conectado.
- Qualquer mudança no leader lockdown ou no feed iCal público.
## Rabbit holes de produto
- **Bidirecional total ("já que está lá, sincroniza convidados/descrição/cor").** Se alguém "só completar": a agenda vira espelho integral da API do Google. **Corte:** só vínculo + título/horário/local/cancelamento (relógio C115); descrição fica no Google.
- **Backfill do calendário histórico.** Se alguém "só completar": anos de eventos pessoais/institucionais viram atividade. **Corte:** só início ≥ 16/08/2026, daqui pra frente.
- **Fila de aprovação com tela nova.** Se alguém "só completar": segundo fluxo de triagem, notificações e estados. **Corte:** lista + filtro "Sem município" + overlay existente.
- **Apagar/alterar o evento estrangeiro para "limpar" o Teqo.** Se alguém "só completar": Teqo escrevendo em evento que não criou. **Corte:** exclusão/edição no Teqo nunca toca evento estrangeiro — no máximo cancela a atividade.
## Questões em aberto (produto)
- **Quem vê e edita atividade sem município?** **Opções:** A) só coordenação/candidato (precedente `supporter`) | B) + assessores (sem carteira para casar; precisaria de claim) | C) todo staff lê, só coordenação edita. **Recomendação: A.** _(assumido — validar)_
- **Descrição do evento: importar ou ignorar na v1?** **Opções:** A) ignorar (texto vive no Google) | B) guardar em campo livre/atualização | C) concatenar no `locality`. **Recomendação: A.** _(assumido — validar)_
- **Cancelar no Teqo remove o evento no Google?** **Opções:** A) sim, cancela o MESMO evento (consistente com "Teqo fonte da verdade") | B) só marca a atividade cancelada e deixa o evento | C) perguntar a cada cancelamento. **Recomendação: A.**
- **Eventos pessoais futuros do calendário da imprensa (aniversário, médico) viram atividade?** **Opções:** A) aceitar e triar (cancelar/descartar na lista) | B) exigir calendário dedicado da campanha | C) allowlist por palavra-chave. **Recomendação: A** — o corte de 16/08 limita o volume e um importado a mais custa pouco; B depende de mudança de hábito externa.
- **Evento importado que ganha município passa a levar `[Município] ` no título do Google?** **Opções:** A) não — mantém o título original (o vínculo system-write basta) | B) sim, vira o padrão dos `teqo…` (confunde quem criou no Google). **Recomendação: A.**
## Referências
- GitHub Issue [#1010](https://github.com/fsolla/teqo/issues/1010)
- Rascunho UI (gate): `docs/plans/c165-importar-eventos-google-ui-draft.html`
- `docs/plans/c115-google-calendar-edicao-bidirecional.md:21,65` (corte reaberto), C114, C149, C150
- `src/lib/visitPlannerAnchors.ts:22-29` (corte canônico), `src/utilities/googleCalendarSync.ts`, `src/utilities/access/supporters.ts`, `AGENTS-campaign.md` ("Campaign activities", "Campaign supporters", "Campaign Municípios model")