From 5577d31c38828b98ae7c083c9078073606204720 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 08:27:45 -0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(C113):=20feed=20iCal=20da=20agenda=20vi?= =?UTF-8?q?vo=20=E2=80=94=20contrato=20de=20frescor=20no=20lugar=20do=20sn?= =?UTF-8?q?apshot=20congelado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spike no build de produção provou que o Next 15 não congela route handlers GET (rota dinâmica por default); o freeze era o contrato de resposta (public, max-age=3600 sem validadores) permitindo caches compartilhados reterem o snapshot do primeiro fetch. Fix: force-dynamic explícito + buildICalFeedResponse com public no-cache, ETag forte sha256, Last-Modified, 304 só por If-None-Match (nunca por data — blind spot de exclusão), dica X-PUBLISHED-TTL no corpo do VCALENDAR (RFC 7986) e 404 no-store para revogação fail-closed no CDN. Testes unit/int/e2e (dev + prod build). --- docs/CHANGELOG-AGENTS.md | 3 + ...c113-feed-ical-agenda-nao-atualiza-impl.md | 85 ++++++++++++ .../campanha/agenda/ical/[secret]/route.ts | 25 ++-- src/utilities/calendarFeed.ts | 68 ++++++++++ tests/e2e/campaignAgendaFeed.e2e.spec.ts | 123 ++++++++++++++++++ tests/int/calendarFeed.int.spec.ts | 97 ++++++++++++++ tests/unit/calendarFeed.unit.spec.ts | 113 +++++++++++++++- 7 files changed, 504 insertions(+), 10 deletions(-) create mode 100644 docs/plans/c113-feed-ical-agenda-nao-atualiza-impl.md diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 40ec1e83..48dca964 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-11):** **C113** (Issue #631) — feed iCal da agenda **vivo**: eventos criados depois da criação do link aparecem no mesmo link. **Spike diagnóstico primeiro** (e2e novo com o aceite de dois `GET`s via fixture — roda em dev E no build de produção): o teste **passou no modo prod ANTES do fix**, provando que o Next 15 **não** congela route handlers GET (docs: "not cached by default" desde 15.0; build compila a rota como `route.js` dinâmica, sem `.body`) — **o freeze é o contrato de resposta**: `Cache-Control: public, max-age=3600` sem validadores permitia a caches compartilhados (CDN da Vercel, infra do Google, proxies) reter o snapshot do primeiro fetch. **Fix (mínimo):** `export const dynamic = 'force-dynamic'` na rota `ical/[secret]` (explícito, convenção do repo) + novo `buildICalFeedResponse` em `src/utilities/calendarFeed.ts` (helper puro): `Cache-Control: public, no-cache` (armazenar só com revalidação obrigatória — fecha também a janela de 1h servindo feed revogado), `ETag` forte (sha256 do corpo, hash sempre muda com conteúdo), `Last-Modified` informacional, `X-PUBLISHED-TTL: PT1H` (RFC 7986) e **304 só por `If-None-Match`** — nunca por `If-Modified-Since`, porque exclusão de atividade não move `Last-Modified` (linha some) e um 304 por data esconderia "evento fantasma". **Testes:** unit do helper (12/12 — headers, ETag estável/mutável, 304 sem corpo, `*`/lista/weak, ETag velho → 200, IMS nunca 304); int na rota real com payload (`route.dynamic === 'force-dynamic'` + A → GET → B → GET ambos → edição de horário → DTSTART novo → cancelamento → some → GET condicional → 304, 18/18); e2e do aceite completo no spec existente `campaignAgendaFeed` (dev 5/5, prod build 1/1). Aceite: edição/cancelamento refletem, revogação/escopo do criador fail-closed intactos, sem migration/schema/access/UI/Consent. Verificação pós-deploy pendente no elo CDN externo (link real de produção). Intenção [`c113-feed-ical-agenda-nao-atualiza.md`](plans/c113-feed-ical-agenda-nao-atualiza.md); impl [`c113-feed-ical-agenda-nao-atualiza-impl.md`](plans/c113-feed-ical-agenda-nao-atualiza-impl.md). + **Recently resolved (2026-08-10):** **B197** (Issue #627) — coluna de e-mail oculta por padrão nas cinco listas de pessoas do `/campanha` (lideranças, dobradinhas, pessoas, apoiadores, assessores), religável pelo seletor de colunas B17 (cookie `campaign_columns`, default vale só para quem nunca tocou no seletor — escolha salva vence). Defaults novos em `DEFAULT_HIDDEN_COLUMN_IDS` (`email` nas 5) e `assessores` entrou em `CAMPAIGN_LIST_IDS` (primeira lista sem `CampaignTable` no mecanismo). **Pessoas (C100 reaberto):** o e-mail saiu da célula "Contato" (fica só telefone, `—` sem contato) e virou coluna própria oculta — cards mobile intocados (anti-goal). **Apoiadores:** coluna "E-mail" nova no `SupporterList` (dado já existia via `contact`; `SupporterListItemViewModel` ganhou `email`), nascendo oculta. **Assessores:** o encaixe — picker B17 na lista (trailing no omnibox, `advisorPickerColumns` espelhando os ``) e `AdvisorsTable` (client) passou a receber `columnVisibility` com ``/`` condicionais; **exceção única: a linha-draft de criação mantém o input de e-mail sempre** (campo obrigatório no create). Sem migration/access/Consent/cache. Testes: unit do mecanismo (parser passa a guardar `assessores`; defaults + stored-wins) e e2e novo no `campaignColumnPicker` (default oculto, religar, persistência no reload, draft com input). Intenção [`ocultar-email-por-padrao-listas.md`](plans/ocultar-email-por-padrao-listas.md); impl [`ocultar-email-por-padrao-listas-impl.md`](plans/ocultar-email-por-padrao-listas-impl.md). **Recently resolved (2026-08-10):** **OPS35+** (Issue #616) — restaurado o pin de browser que o OPS35 documentou como perdido: a **visibilidade CSS real** das colunas de rede (Assessor / Liderança / Dobradinha) e dos sortables de `/campanha/territorios` a 2200px. Spec novo mínimo `tests/e2e/campaignTerritoriesColumns.e2e.spec.ts` (projeto `campaign`, corpo exato do teste 3 do spec deletado — `getByRole('columnheader')` visível por rung container-query `@min-[Nrem]/territory-list`, Cobertura `count 0`); divisão de trabalho com o HTTP preservada (presença + classes de rung continuam no `campaignTerritoriesHttp`). Manifesto `e2e-affected`: entrada territórios agora mapeia os 2 specs **e** o arquivo dono das rungs `TerritoryListColumns.tsx` (uma mudança de rung acorda o pin; o seletor faz união, specs de municípios continuam acordando). Prova de não-vazio por mutação (rung `advisor` → `@min-[200rem]` fez o spec falhar em "Assessor"; revertido). Sem migration/access/Consent/UI/CSS. Intenção [`restaurar-pin-visibilidade-colunas-territorios.md`](plans/restaurar-pin-visibilidade-colunas-territorios.md); impl [`restaurar-pin-visibilidade-colunas-territorios-impl.md`](plans/restaurar-pin-visibilidade-colunas-territorios-impl.md). @@ -13,6 +15,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente **Recently resolved (2026-08-10):** **OPS37** (Issue #605) — o container Postgres compartilhado **não é mais recriado** por um worktree vizinho: `pnpm db:start` virou `scripts/db-start.mjs` (CLI) sobre `scripts/lib/db-start.mjs` — preflight "container `teqo-postgres-1` Up + `healthy`/`starting` → **no-op** (compose nem roda); senão, `compose -p teqo up -d --wait --wait-timeout 120 postgres` como antes. **Causa raiz provada por medição:** o bind mount **relativo** `./docker/postgres/init` do compose resolve um path absoluto por worktree → `config_hash` diferente por worktree (OPS37 `39e89956…` / B195 `cf3cb522…` / C101 `917e11bb…`; diff do `config` = só o `source:` do bind) → qualquer `compose up` cru de outro worktree fazia `replace` e matava dev+e2e de todos (`terminating connection due to administrator command`) — o incidente se repetiu **ao vivo durante o gate**: o worktree `work/4` recriou o container no meio da suíte int (17 testes mortos; os 3 arquivos re-rodaram verdes e a suíte completa 691/691 passou em seguida). **Verificação do aceite:** `StartedAt` inalterado após `db:start` repetido, inclusive rodado de **outro worktree** (D9, hash diferente) — o caso que antes recriava. Recriação legítima (bump de imagem/config) segue por `pnpm db:start --force-recreate` (explícito; não testado live para não matar a run e2e do D9 em voo — flag é pass-through de 1 arg). `worktree.mjs` provisionamento delega para a mesma lib (mesmo risco no `worktree next`; `dockerComposeUp`/`SHARED_COMPOSE_PROJECT` órfãos removidos). Skip deliberado em `starting` (container que um peer acabou de criar): **espera** o boot ficar `healthy` (60s, polling 2s) em vez de recriar — e cai no compose up se travou; **não** skip em `unhealthy`/sem healthcheck (volta à config atual exatamente uma vez); `db:stop`/`db:doctor`/compose file/CI intactos. Unit novos (`dbStart.unit.spec.ts`, 17: matrix running×health + parse do inspect + wrapper mockado — skip não chama compose, argv do up/force-recreate, espera de `starting`). Sem migration/access/Consent/UI. Intenção [`container-postgres-compartilhado-compose-replace.md`](plans/container-postgres-compartilhado-compose-replace.md); impl [`container-postgres-compartilhado-compose-replace-impl.md`](plans/container-postgres-compartilhado-compose-replace-impl.md). **Recently resolved (2026-08-10):** **D9** (Issue #596) — flake do consent restore do `testDatabaseLease` sob carga eliminado. **Caracterização com reprodução** (3 falhas em 8 runs da suíte int completa; depois isolada a 2 arquivos): o assertion "restores the exact configured consent after deletion and recreation" via leituras voláteis de uma linha canônica que escritores legítimos de outras specs deletavam/recriavam no meio do teste. **Causa raiz em três camadas:** (1) a janela de operação de `withMissingInviteConsentFixture` era a única transição de estado sem lease da suíte (outras specs criavam linha efêmera que o restore deletava sob elas); (2) `removeOnda0ConsentAndPrivacyDb` rodava sob leases **shared** e deletava as 4 keys estáveis no meio de testes alheios (o id restaurado sumia irreversivelmente); (3) asserções de restore comparavam contra leituras `before` que o provision Onda0 podia re-textar legitimamente. **Fix (só testes, zero `src/`):** janela de operação sob lease shared (`serializeWindow`, default true; o teste de ensures concorrentes migrou para key/lease privada — único caso que deliberadamente mantém janela sem lease, com re-verificação/re-delete do gap setup→janela; exclusivo descartado: alargava o ABBA com os escritores de invite sob carga); spec Onda0 com leases **shared** + down-migration **restaura as linhas com os ids originais** dentro da janela (`restoreConsentRows` compartilhado; no-op para o resto da suíte); asserções de restore referenciam o **snapshot do fixture** (contrato real), rollback asserta id+key; `ORDER BY id`/`sort: 'id'` nas leituras limit-1; core `withDatabaseLease`; `ensureInviteConsent` órfão removido (knip). Contraprova: suíte completa ×5 + par mínimo ×8 + 6 arquivos da key ×13/14 verdes (runs com restart externo do Postgres descartados por uptime). Intenção [`test-database-lease-consent-flake.md`](plans/test-database-lease-consent-flake.md); impl [`test-database-lease-consent-flake-impl.md`](plans/test-database-lease-consent-flake-impl.md). +(fix(C113): feed iCal da agenda vivo — contrato de frescor no lugar do snapshot congelado) **Recently resolved (2026-08-10):** **B195** (Issue #591) — wizard "Registrar pedido" completo (A5, adiado no B85): o passo final saiu do stub e virou formulário funcional no shell do wizard — município no header do app (chrome `municipalityLabel`), **sem título grande de passo** (`stepTitle={null}` + `contentFocus="none"`), Tipo → Atividade (opcional) → **campo único** "O que você precisa?"; salvar ("Abrir demanda") → toast + retorno à origem (`returnPath`). **Mudança de modelo da demanda:** o cadastro (wizard e `/demandas/nova`, sem divergência) tem UM campo de texto (`description` required, min 2, max 4000 — constante `CAMPAIGN_DEMAND_BODY_MAX_LENGTH`); o **título virou derivado por IA** (`src/utilities/ai/campaignDemandTitle.ts` — `generateText` DeepSeek `deepseek-v4-flash` como o Sollinha, timeout 4s, sem key → `null`, prompt com rótulo do tipo) com **fallback = texto truncado** (`src/lib/demandTitle.ts`, puro: colapsa whitespace, ≤160) no create e **keep-previous na edição** (novo `updateCampaignDemandRecord` + editor inline no detalhe — recalc só quando a descrição muda; slug preservado pela invariante canônica); `campaignDemandCreateSchema` perdeu `title` (só os drafts de demanda da atividade mantêm título digitado — vertical fora de escopo, schema desacoplado). **Unicidade de slug defensiva só no create das forms** (`ensureUniqueDemandSlug`, opt-in `context.campaignDemandUniqueSlug` fail-closed em transação ativa — precedente `skipContactPhoneInvariant`): probe único da janela `-2…-20` + advisory lock `campaign-demand-slug:` contra race; activity drafts/admin mantêm a constraint estrita (contrato de rollback C90 pinado em teste). Page: branch `register-demand` no router + **staff gate ANTES do load do escopo** (`gate: 'staff'` → líder vai a `/campanha/contatos`, B43 — o 403 do scope load pré-existente virava erro não determinístico; stub `WizardMunicipalitySelectedStub` removido, placeholder "em breve" apagado). **Bug latente corrigido no hook compartilhado** `useCampaignFormSuccessToast` (dependência `[state.message, state.status]` → `[state]`: segundo save com a mesma mensagem — caso do editor — não disparava toast/refresh). Copy compartilhada em `schemas/campaignDemand.ts`; parser único `parseCampaignDemandCreateFormData` (`utilities/demand/`). **Sem migration** (título continua required, preenchido pelo record). Testes: unit do fallback/limites exatos; int (create deriva, colisão `-2`/`-3`, min 2, update keep-previous/skip, leader edit bloqueado, pin "sem `DEEPSEEK_API_KEY`" no `beforeAll`); e2e novo `campaignRegisterDemand` (fluxo completo com título na lista, back-nav à busca, lockdown de líder). Intenção [`registrar-pedido-wizard.md`](plans/registrar-pedido-wizard.md); impl [`registrar-pedido-wizard-impl.md`](plans/registrar-pedido-wizard-impl.md). diff --git a/docs/plans/c113-feed-ical-agenda-nao-atualiza-impl.md b/docs/plans/c113-feed-ical-agenda-nao-atualiza-impl.md new file mode 100644 index 00000000..f81ca365 --- /dev/null +++ b/docs/plans/c113-feed-ical-agenda-nao-atualiza-impl.md @@ -0,0 +1,85 @@ +# Impl: C113 — Feed iCal da agenda congela na criação do link: eventos novos não aparecem + +Status: aprovado +Atualizado em: 2026-08-11 +Issue: #631 +Intenção: docs/plans/c113-feed-ical-agenda-nao-atualiza.md +Appetite restante: ~0,5 dia eng (herdado; fix pequeno + verificação) + +## Desfecho da verificação (registrado em 2026-08-11) + +**O nível Next NÃO congela o feed — o spike no build de produção passou ANTES do fix** (e2e novo "serves a live feed", modo prod `E2E_PROD=1`/`NEXT_DIST_DIR=.next/e2e`: 1,7 s verde com a rota sem `force-dynamic` e com `public, max-age=3600`; o build também compila a rota como `route.js` dinâmica, sem `.body`). Isso confirma a leitura das docs do Next 15 ("GET Route Handlers are no longer cached by default") e refina o diagnóstico da intenção: **o freeze vem do contrato de resposta** — `Cache-Control: public, max-age=3600` **sem validadores** permite a qualquer cache compartilhado (CDN da Vercel, infra do Google, proxies do assinante) armazenar o snapshot do primeiro fetch e reusá-lo sem revalidação condicional, apresentando "congela na criação do link" para o assinante (cadência de re-busca do Google + janela de 1h sem validadores). O fix entregue ataca exatamente esse elo; a verificação pós-deploy com link real (item 5 das fases) confirma o efeito no elo CDN externo, que não existe em teste local. + +## Leitura da intenção + +- **Outcome:** o MESMO link de import serve a agenda viva — um compromisso criado depois da criação do link aparece no iCal (prova: `GET` → criar atividade → `GET` → ambos presentes); o feed responde com sinais de frescor (`no-cache`/curto + `ETag`/`Last-Modified` para revalidação em 304 + `X-PUBLISHED-TTL`); edição/cancelamento refletem; revogação e escopo do criador seguem fail-closed. +- **O que NÃO negociar:** fail-closed (criador desativado/sem acesso → feed para de servir), escopo do criador (advisor intersectado por municípios atuais), leader lockdown, revogação; sem migration, sem UI, sem mudança de schema. +- **O que reavaliar (diagnóstico):** a hipótese da intenção — "App Router cacheia respostas `GET` de route handlers por padrão" — **está errada para este repo**: o app roda **Next.js 15.4.11**, e desde o Next 15 `GET` Route Handlers **não são cacheados por padrão** (breaking change 15.0: "GET Route Handlers are no longer cached by default"; docs: "Route Handlers are not cached by default"). A rota `ical/[secret]` já é dinâmica por padrão no nível Next. **O elo congelado é o contrato de resposta:** `Cache-Control: public, max-age=3600` **sem validadores** — qualquer cache compartilhado (CDN da Vercel, infra do Google, proxies do assinante) pode armazenar o snapshot do primeiro fetch e reusá-lo por 1h **sem nenhuma forma de revalidação condicional** (não há `ETag` nem `Last-Modified`); combinado com a cadência lenta de re-busca do Google Calendar para feeds públicos, isso apresenta o sintoma "congela na criação do link". Secundariamente, o `public, max-age=3600` também é uma janela de vazamento pós-revogação: um feed revogado continua servível por até 1h a partir de caches fora do controle do servidor. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[Spike diagnóstico: e2e two-GET roda em modo prod ANTES do fix] --> B{Next congela no build prod?} + B -- sim --> C[force-dynamic é o mecanismo + contrato de frescor] + B -- não --> D[freeze é CDN/cliente externo: contrato de resposta é o mecanismo] + C --> E[Fix: dynamic explícito + ETag/304 + no-cache + X-PUBLISHED-TTL] + D --> E + E --> F[Testes: unit helper + int rota + e2e aceite completo] + F --> G[Gates + changelog + PR Closes #631 + verificação pós-deploy] +``` + +**Opções consideradas:** A | B | C + +- **A. Fix do contrato de frescor + dinamismo explícito, com spike diagnóstico primeiro (recomendada):** (1) spike: novo e2e com o aceite de dois `GET`s criado via fixture (roda em dev E no build de produção), rodado em modo prod **antes** de qualquer fix para registrar empiricamente se o nível Next congela ou não; (2) `export const dynamic = 'force-dynamic'` na rota (explicitude + convenção do repo — todas as rotas dinâmicas de `/campanha` têm; protege contra flips futuros de default, mesmo não sendo o mecanismo no Next 15); (3) contrato de resposta em helper puro de `calendarFeed.ts`: `Cache-Control: public, no-cache` (armazenar só com revalidação obrigatória; elimina a janela de freeze e a janela pós-revogação), `ETag` forte (hash do corpo), `Last-Modified` informacional, `X-PUBLISHED-TTL: PT1H`, e 304 quando `If-None-Match` bate. +- **B. Só `force-dynamic` + `no-store`:** mais agressivo e mais simples; mas não entrega os sinais de frescor que a intenção prescreve (`ETag`/304 para revalidação barata, `X-PUBLISHED-TTL`), e `no-store` descarta a eficiência de revalidação condicional para um feed que o Google re-busca a cada poucas horas. +- **C. Sem spike, só headers:** arriscaria corrigir um mecanismo não confirmado — se o Next 15 (ou um futuro upgrade) de fato cachear a rota, o fix de headers sozinho não resolve; o spike é barato (roda o mesmo teste em modo dev e prod, ambos suportados pelo repo) e torna o diagnóstico prova, não opinião. + +**Recomendação: A** — porque ataca o elo comprovado (contrato de resposta sem validadores + cache compartilhado), registra o diagnóstico empírico do nível Next antes de mexer (a intenção explicitamente pede "quem executa confirma"), entrega todos os sinais de frescor do aceite, e mantém o escopo mínimo (sem migration, sem schema, sem access). +**Rejeitadas:** B porque perde os validadores/304 e a dica `X-PUBLISHED-TTL` que o próprio aceite lista; C porque fixar sem diagnóstico viola a observação da intenção ("direção, não diagnóstico fechado") e pode corrigir um mecanismo inexistente deixando o real intocado. + +### Componentes / mudanças + +- **`src/utilities/calendarFeed.ts`** (helper puro novo): `buildICalFeedResponse(icalContent, activities, feed, request)` → monta `Response` com `Content-Type: text/calendar; charset=utf-8`, `Cache-Control: public, no-cache`, `ETag: ""` (forte, byte-exact), `Last-Modified` (máx. de `updatedAt`/`createdAt` das atividades e `feed.updatedAt`, UTC), `X-PUBLISHED-TTL: PT1H`; retorna **304 sem corpo** quando `If-None-Match` bate. **304 é só por ETag** — `If-Modified-Since` nunca responde 304: exclusão de atividade não move `Last-Modified` (a linha some), e um 304 por data poderia esconder um "evento fantasma" removido; o ETag (hash do corpo) sempre muda. Constantes `FEED_CACHE_CONTROL`, `FEED_PUBLISHED_TTL`. +- **`src/app/(campaign)/campanha/agenda/ical/[secret]/route.ts`**: `export const dynamic = 'force-dynamic'` (explícito, convenção do repo); 404/revogação/access inalterados; a resposta 200/304 passa a vir de `buildICalFeedResponse`. +- **Migration:** sem migration. +- **Access / Consent:** sem mudança — `resolveFeedCreatorAccess`, intersecção de escopo (C96) e fail-closed intactos; o novo header `no-cache` **fecha** a janela CDN pós-revogação que o `public, max-age=3600` abria. +- **UI:** Impeccable A — sem superfície. + +### Dados → forma + +- N/A (sem dados novos; a forma é o iCal existente — muda a frescura, não o formato). + +## Fases verificáveis + +1. **Spike diagnóstico (primeiro, antes do fix):** novo teste em `tests/e2e/campaignAgendaFeed.e2e.spec.ts` — feed criado **via fixture** (sem diálogo: funciona em dev E em modo prod, sem o skip do fluxo de geração): coordinator + município + atividade A + `calendarFeed` com `secretSlug` próprio → `GET` no link (contém A) → criar atividade B via `fixtures.payload.create` → `GET` de novo → **ambos presentes**. Rodar em modo prod local (padrão CI: `NEXT_DIST_DIR=.next/e2e pnpm build` + `E2E_PROD=1 CI=1 NEXT_DIST_DIR=.next/e2e pnpm test:e2e -- tests/e2e/campaignAgendaFeed.e2e.spec.ts`). Registrar o desfecho: se o teste falha no modo prod sem fix → Next congela (mecanismo A); se passa → freeze é CDN/cliente (mecanismo D). +2. **Fix:** `force-dynamic` na rota + `buildICalFeedResponse` em `calendarFeed.ts` + rota consumindo o helper. +3. **Testes:** + - **Unit** (novo, node env, sem payload): `tests/unit/calendarFeedResponse.unit.spec.ts` — helper puro: headers esperados (`no-cache`, ETag presente e estável para corpo igual, `X-PUBLISHED-TTL: PT1H`, `Last-Modified`); corpo igual + `If-None-Match` correto → 304 sem corpo; `If-None-Match` errado → 200; `If-Modified-Since` sozinho → **sempre 200** (blind spot da exclusão documentado). + - **Int** (ancorado em `tests/int/calendarFeed.int.spec.ts` existente): import da rota real (`route.dynamic === 'force-dynamic'`, precedente `campaignInviteUi.int.spec.ts:429`) + `GET` real com payload: atividade A → criar B → dois `GET` com corpos contendo ambos; editar B → `GET` com título novo; cancelar B → `GET` sem B; `If-None-Match` do último ETag → 304. + - **E2E** (extensão do spec): aceite completo — A → GET → B → GET (ambos) → edita B → GET (título novo) → cancela B → GET (B some, A fica); asserts de headers (`Cache-Control` com `no-cache`, `ETag` presente, `X-PUBLISHED-TTL`) e do 304 final. Roda em dev e prod (CI já roda modo prod via gate-ci). +4. **Gates + fechamento:** `pnpm gate:fast`, `pnpm test`, `pnpm test:e2e` (dev), modo prod local (spike + spec completo), `pnpm build`; 1 entrada em `docs/CHANGELOG-AGENTS.md`; impl plan com o desfecho do spike; `pnpm push` → PR Ready `--base main` `Closes #631` → auto-merge → checks. +5. **Verificação pós-deploy (manual, com o usuário):** com um link real de produção, `GET` → criar atividade em `/campanha/agenda` → `GET` de novo (ou aguardar a re-busca do Google) → evento aparece; conferir `x-vercel-cache` ausente/MISS e `Cache-Control: no-cache` no `curl -sI`. O CDN da Vercel não existe no teste local — só o contrato de resposta; o contrato novo (`no-cache` + 304) é o que garante que nenhum elo intermediário segure o snapshot. + +## Rabbit holes / Não escopo (engenharia) + +- **Controlar a cadência do Google Calendar:** anti-goal explícito; `X-PUBLISHED-TTL` é a melhor tentativa dentro do padrão (Google ignora para feeds públicos) — o aceite do servidor é o que controlamos. +- **Caçar cache do CDN da Vercel sem evidência:** o CDN não existe em teste local; o spike decide o nível Next, e o fix de headers vale para qualquer cache compartilhado (Vercel, Google, proxies). Verificação do `x-vercel-cache` é pós-deploy, não bloqueante. +- **Mudar access/escopo/revogação/geração do iCal:** fora — o gerador já usa `updatedAt` no `DTSTAMP`, já pula `cancelado`; não tocar. +- **ETag por timestamp:** rejeitado — `updatedAt` não captura exclusão (linha some); hash do corpo é o único validator que sempre muda com o conteúdo. + +## Riscos e mitigação + +- **Spike não reproduz o freeze no Next em modo prod (provável):** confirma que o mecanismo é CDN/cliente externo; mitigação = o fix de headers remove o contrato que permite o cache + verificação pós-deploy com link real. +- **304 malformado (com corpo):** teste unit pina 304 com corpo vazio. +- **`no-cache` + CDN que não suporta revalidação:** caches modernos suportam; o pior caso é um 200 completo a cada poll (payload minúsculo, carga irrelevante). +- **Regressão do e2e em modo prod (CI):** teste pequeno (2–3 GETs no mesmo feed); roda com o padrão `gate-ci` já existente. +- **Import da rota em teste int puxa payload config:** precedente já existe (`campaignInviteUi.int.spec.ts` importa página do convite com payload). + +## Aceite de engenharia + +- [x] Aceite de produto da intenção coberto: dois `GET`s com criação entre eles mostram o evento novo; edição/cancelamento refletem; headers com `no-cache` + `ETag`/`Last-Modified` + 304 + `X-PUBLISHED-TTL`; revogação/escopo fail-closed intactos +- [x] Invariantes AGENTS/engineering-standards: identificadores EN, copy pt-BR, sem migration/schema/access/Consent, sem tocar outras Issues +- [x] Testes: unit do helper (headers/ETag/304/IMS nunca 304), int da rota real, e2e do aceite completo (dev + prod) +- [x] Spike registrado no impl plan (desfecho do nível Next em modo prod) +- [x] Gates: `pnpm gate:fast` + `pnpm test` + e2e dev/prod + `pnpm build`; changelog; PR `Closes #631` com auto-merge diff --git a/src/app/(campaign)/campanha/agenda/ical/[secret]/route.ts b/src/app/(campaign)/campanha/agenda/ical/[secret]/route.ts index 69ea1217..dffde6a6 100644 --- a/src/app/(campaign)/campanha/agenda/ical/[secret]/route.ts +++ b/src/app/(campaign)/campanha/agenda/ical/[secret]/route.ts @@ -2,6 +2,7 @@ import config from '@payload-config' import { getPayload } from 'payload' import { + buildICalFeedResponse, generateICalFeed, loadFeedActivities, loadMunicipalityNames, @@ -12,13 +13,25 @@ type RouteContext = { params: Promise<{ secret: string }> } +// C113: the feed must always render against the live database (a feed whose +// value is "new commitments appear without re-subscribing"). Explicit marker +// per repo convention — every dynamic campaign route declares it, and it pins +// the intent against any future default flip in Next's route caching. +export const dynamic = 'force-dynamic' + const notFoundResponse = () => new Response('Feed não encontrado.', { status: 404, - headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + // C113: a revoked/unknown feed must never become a heuristically cacheable + // 404 in a shared cache — `no-store` keeps the fail-closed revocation + // contract from eroding at the CDN layer. + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }, }) -export const GET = async (_request: Request, context: RouteContext): Promise => { +export const GET = async (request: Request, context: RouteContext): Promise => { const { secret } = await context.params if (!secret || secret.length < 32) { @@ -64,13 +77,7 @@ export const GET = async (_request: Request, context: RouteContext): Promise value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;') @@ -37,6 +46,7 @@ export const generateICalFeed = ( `X-WR-CALNAME:${escapeICalText(feedLabel || CALENDAR_NAME)}`, 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', + `X-PUBLISHED-TTL:${FEED_PUBLISHED_TTL}`, ] for (const activity of activities) { @@ -76,6 +86,64 @@ export const generateICalFeed = ( return lines.join('\r\n') } +const computeFeedLastModified = (activities: Activity[], feedUpdatedAt?: string): string => { + const timestamps = [ + ...activities.flatMap((activity) => + [activity.updatedAt, activity.createdAt].filter((value): value is string => Boolean(value)), + ), + ...(feedUpdatedAt ? [feedUpdatedAt] : []), + ] + const latest = timestamps.length + ? timestamps.reduce((max, value) => (value > max ? value : max)) + : new Date().toISOString() + return new Date(latest).toUTCString() +} + +// C113: the response contract of the live feed. The ETag (strong, from the +// body hash) is the authoritative validator and the ONLY 304 trigger — +// `If-Modified-Since` alone never answers 304, because deleting an activity +// removes its row and would leave `Last-Modified` stale (a date-based 304 +// could hide the removed event). The body hash always changes with content. +export const buildICalFeedResponse = ( + icalContent: string, + activities: Activity[], + feed: Pick, + request: Request, +): Response => { + const etag = `"${createHash('sha256').update(icalContent).digest('hex')}"` + const lastModified = computeFeedLastModified(activities, feed.updatedAt) + + const ifNoneMatch = request.headers.get('if-none-match') + const matchesEtag = + ifNoneMatch === '*' || + (ifNoneMatch + ?.split(',') + .map((value) => value.trim().replace(/^W\//, '')) + .includes(etag) ?? + false) + + if (matchesEtag) { + return new Response(null, { + status: 304, + headers: { + 'Cache-Control': FEED_CACHE_CONTROL, + ETag: etag, + 'Last-Modified': lastModified, + }, + }) + } + + return new Response(icalContent, { + status: 200, + headers: { + 'Content-Type': 'text/calendar; charset=utf-8', + 'Cache-Control': FEED_CACHE_CONTROL, + ETag: etag, + 'Last-Modified': lastModified, + }, + }) +} + const buildFeedDateRange = (): { rangeStart: string; rangeEnd: string } => { const now = new Date() const rangeStart = new Date(now) diff --git a/tests/e2e/campaignAgendaFeed.e2e.spec.ts b/tests/e2e/campaignAgendaFeed.e2e.spec.ts index 71752bce..6c6a8573 100644 --- a/tests/e2e/campaignAgendaFeed.e2e.spec.ts +++ b/tests/e2e/campaignAgendaFeed.e2e.spec.ts @@ -1,3 +1,5 @@ +import { randomUUID } from 'node:crypto' + import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js' import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js' import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js' @@ -113,4 +115,125 @@ test.describe('Agenda — link de import (C98)', () => { await expect(linkInput).toBeVisible({ timeout: 15_000 }) expect(await linkInput.inputValue()).toMatch(/\/campanha\/agenda\/ical\/[0-9a-f-]{36}$/) }) + + test('serves a live feed: a second GET shows activities created after the first (C113)', async ({ + campaign, + request, + }) => { + // C113 acceptance: the SAME link must stay live — a commitment created + // after the first fetch appears on the next GET. The feed is created via + // fixture (no dialog), so this runs on the dev server AND on the + // production build (E2E_PROD=1), where route-level caching would freeze + // the first response. + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator') + const municipality = await fixtures.claimMunicipality() + const civilDate = formatBahiaCivilDate(new Date()) + const startAtA = parseBahiaDateTimeInput(`${civilDate}T10:00`) + const startAtB = parseBahiaDateTimeInput(`${civilDate}T11:00`) + if (!startAtA || !startAtB) throw new Error('Falha ao montar horário da fixture de agenda.') + const icalDateTimeOf = (iso: string) => iso.replace(/[-:]/g, '').replace(/\.\d{3}/, '') + + const titleA = fixtures.value('Comício do feed vivo') + const titleB = fixtures.value('Caminhada do feed vivo') + + await fixtures.payload.create({ + collection: 'activity', + data: hookFilledCreateData<'activity'>({ + title: titleA, + tags: ['Comício'], + status: 'confirmado', + startAt: startAtA, + municipality: municipality.id, + }), + depth: 0, + }) + + const secretSlug = randomUUID() + await fixtures.payload.create({ + collection: 'calendarFeed', + data: { secretSlug, label: fixtures.value('Feed vivo'), createdBy: coordinator.id }, + depth: 0, + draft: false, + user: coordinator, + overrideAccess: false, + }) + + const feedUrl = `${campaign.baseURL}/campanha/agenda/ical/${secretSlug}` + + const first = await request.get(feedUrl) + expect(first.status()).toBe(200) + expect(first.headers()['cache-control']).toBe('public, no-cache') + expect(first.headers()['etag']).toMatch(/^"[0-9a-f]{64}"$/) + const firstBody = await first.text() + expect(firstBody).toContain(titleA) + expect(firstBody).not.toContain(titleB) + expect(firstBody).toContain('X-PUBLISHED-TTL:PT1H') + const firstEtag = first.headers()['etag']! + + const activityB = await fixtures.payload.create({ + collection: 'activity', + data: hookFilledCreateData<'activity'>({ + title: titleB, + tags: ['Caminhada'], + status: 'confirmado', + startAt: startAtB, + municipality: municipality.id, + }), + depth: 0, + }) + + // The C113 acceptance: the same link is alive — the second GET sees the + // commitment created after the first, with a changed validator. + const second = await request.get(feedUrl) + expect(second.status()).toBe(200) + const secondBody = await second.text() + expect(secondBody).toContain(titleA) + expect(secondBody).toContain(titleB) + expect(second.headers()['etag']).not.toBe(firstEtag) + + // Schedule edits reflect on the next GET (the title is immutable by + // design — the canonical slug — so the proof is the DTSTART change). + const editedStart = parseBahiaDateTimeInput(`${civilDate}T12:00`) + if (!editedStart) throw new Error('Falha ao montar horário editado da fixture.') + await fixtures.payload.update({ + collection: 'activity', + id: activityB.id, + data: { startAt: editedStart }, + depth: 0, + }) + const edited = await request.get(feedUrl) + expect(edited.status()).toBe(200) + const editedBody = await edited.text() + expect(editedBody).toContain(`DTSTART:${icalDateTimeOf(editedStart)}`) + expect(editedBody).not.toContain(`DTSTART:${icalDateTimeOf(startAtB)}`) + + // A subscriber holding the FIRST validator gets a full fresh 200, never a + // stale 304 — the freeze story of C113. + const staleRevalidation = await request.get(feedUrl, { + headers: { 'if-none-match': firstEtag }, + }) + expect(staleRevalidation.status()).toBe(200) + expect(await staleRevalidation.text()).toContain(`DTSTART:${icalDateTimeOf(editedStart)}`) + + // Cancellation removes the event without leaving a ghost. + await fixtures.payload.update({ + collection: 'activity', + id: activityB.id, + data: { status: 'cancelado' }, + depth: 0, + }) + const cancelled = await request.get(feedUrl) + expect(cancelled.status()).toBe(200) + const cancelledBody = await cancelled.text() + expect(cancelledBody).not.toContain(titleB) + expect(cancelledBody).toContain(titleA) + + // A conditional GET with the current validator revalidates cheaply. + const revalidated = await request.get(feedUrl, { + headers: { 'if-none-match': cancelled.headers()['etag']! }, + }) + expect(revalidated.status()).toBe(304) + expect(await revalidated.text()).toBe('') + }) }) diff --git a/tests/int/calendarFeed.int.spec.ts b/tests/int/calendarFeed.int.spec.ts index 10ca0399..cf766117 100644 --- a/tests/int/calendarFeed.int.spec.ts +++ b/tests/int/calendarFeed.int.spec.ts @@ -488,4 +488,101 @@ describe('campaign calendar feed domain', () => { const activities = await loadFeedActivities(payload, feed, access.municipalityIds) expect(activities).toHaveLength(0) }) + + it('serves a live feed over two GETs with fresh validators (C113)', async () => { + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + const municipality = await campaignFixtures().getMunicipality() + + const route = await import('@/app/(campaign)/campanha/agenda/ical/[secret]/route') + expect(route.dynamic).toBe('force-dynamic') + + const titleA = campaignFixtures().value('Comício vivo') + const titleB = campaignFixtures().value('Caminhada viva') + const activityA = await createActivityRecord( + payload, + coordinator, + validActivityInput(relationId(municipality), titleA), + ) + campaignFixtures().own('activity', activityA.id) + + const ok = requireOk( + await createCalendarFeedLinkRecord(payload, coordinator, { label: 'Feed vivo' }), + ) + campaignFixtures().own('calendarFeed', ok.feedId) + const secret = ok.feedUrl.match(/\/campanha\/agenda\/ical\/([0-9a-f-]{36})$/)![1] + + const feedUrl = `http://localhost/campanha/agenda/ical/${secret}` + const getFeed = (headers: Record = {}) => + route.GET(new Request(feedUrl, { headers }), { params: Promise.resolve({ secret }) }) + + const first = await getFeed() + expect(first.status).toBe(200) + expect(first.headers.get('Cache-Control')).toBe('public, no-cache') + expect(first.headers.get('ETag')).toMatch(/^"[0-9a-f]{64}"$/) + expect(first.headers.get('Last-Modified')).toMatch(/ GMT$/) + const firstBody = await first.text() + expect(firstBody).toContain(titleA) + expect(firstBody).not.toContain(titleB) + expect(firstBody).toContain('X-PUBLISHED-TTL:PT1H') + const firstEtag = first.headers.get('ETag')! + + const activityB = await createActivityRecord(payload, coordinator, { + ...validActivityInput(relationId(municipality), titleB), + // An hour after activity A, so the DTSTART proof is unique to B. + startAt: new Date(Date.now() + 86_400_000 + 3_600_000).toISOString(), + }) + campaignFixtures().own('activity', activityB.id) + + // A commitment created after the first fetch shows up on the second GET, + // with a changed validator — the C113 acceptance. + const second = await getFeed() + expect(second.status).toBe(200) + const secondBody = await second.text() + expect(secondBody).toContain(titleA) + expect(secondBody).toContain(titleB) + expect(second.headers.get('ETag')).not.toBe(firstEtag) + + // Schedule edits reflect on the next GET (the title is immutable by + // design — the canonical slug — so the proof is the DTSTART change). + const icalDateTimeOf = (iso: string) => iso.replace(/[-:]/g, '').replace(/\.\d{3}/, '') + const originalStart = activityB.startAt! + const editedStart = new Date(Date.now() + 86_400_000 + 7_200_000).toISOString() + await payload.update({ + collection: 'activity', + id: activityB.id, + data: { startAt: editedStart }, + depth: 0, + user: coordinator, + overrideAccess: false, + }) + const edited = await getFeed() + const editedBody = await edited.text() + expect(editedBody).toContain(`DTSTART:${icalDateTimeOf(editedStart)}`) + expect(editedBody).not.toContain(`DTSTART:${icalDateTimeOf(originalStart)}`) + + // A subscriber holding the FIRST validator gets a full fresh 200, never a + // stale 304 — the freeze story of C113. + const staleRevalidation = await getFeed({ 'if-none-match': firstEtag }) + expect(staleRevalidation.status).toBe(200) + expect(await staleRevalidation.text()).toContain(`DTSTART:${icalDateTimeOf(editedStart)}`) + + // Cancellation removes the event without leaving a ghost. + await payload.update({ + collection: 'activity', + id: activityB.id, + data: { status: 'cancelado' }, + depth: 0, + user: coordinator, + overrideAccess: false, + }) + const cancelled = await getFeed() + const cancelledBody = await cancelled.text() + expect(cancelledBody).not.toContain(titleB) + expect(cancelledBody).toContain(titleA) + + // A conditional GET with the current validator revalidates cheaply. + const revalidated = await getFeed({ 'if-none-match': cancelled.headers.get('ETag')! }) + expect(revalidated.status).toBe(304) + expect(await revalidated.text()).toBe('') + }) }) diff --git a/tests/unit/calendarFeed.unit.spec.ts b/tests/unit/calendarFeed.unit.spec.ts index 1d4e664a..fbb53426 100644 --- a/tests/unit/calendarFeed.unit.spec.ts +++ b/tests/unit/calendarFeed.unit.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { generateICalFeed } from '@/utilities/calendarFeed' +import { buildICalFeedResponse, generateICalFeed } from '@/utilities/calendarFeed' describe('generateICalFeed', () => { const municipalityNames = new Map([ @@ -37,6 +37,7 @@ describe('generateICalFeed', () => { expect(result).toContain('DTSTART:20260815T190000Z') expect(result).toContain('DTEND:20260815T220000Z') expect(result).toContain('X-WR-CALNAME:Agenda do Candidato') + expect(result).toContain('X-PUBLISHED-TTL:PT1H') }) it('excludes cancelled activities', () => { @@ -157,3 +158,113 @@ describe('generateICalFeed', () => { expect(result).toContain('DTEND:20260815T190000Z') }) }) + +describe('buildICalFeedResponse (C113)', () => { + const activities = [ + { + id: 1, + slug: 'comicio-salvador', + title: 'Comício em Salvador', + status: 'confirmado' as const, + startAt: '2026-08-15T19:00:00.000Z', + endAt: '2026-08-15T22:00:00.000Z', + municipality: 1, + locality: 'Centro', + tags: ['comício', 'campanha'], + deputyPresent: true, + updatedAt: '2026-08-01T10:00:00.000Z', + createdAt: '2026-08-01T09:00:00.000Z', + }, + ] as unknown as Parameters[0] + + const icalContent = generateICalFeed( + activities, + 'Agenda do Candidato', + new Map([[1, 'Salvador']]), + ) + const feed = { updatedAt: '2026-08-02T08:00:00.000Z' } + + const requestWith = (headers: Record) => + new Request('https://example.test/campanha/agenda/ical/secret', { headers }) + + it('serves the feed with a freshness contract: no-cache, ETag and Last-Modified', async () => { + const response = buildICalFeedResponse(icalContent, activities, feed, requestWith({})) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toContain('text/calendar') + expect(response.headers.get('Cache-Control')).toBe('public, no-cache') + expect(response.headers.get('ETag')).toMatch(/^"[0-9a-f]{64}"$/) + expect(response.headers.get('Last-Modified')).toBe('Sun, 02 Aug 2026 08:00:00 GMT') + expect(await response.text()).toBe(icalContent) + }) + + it('keeps the ETag stable for identical content and changes it with the body', () => { + const first = buildICalFeedResponse(icalContent, activities, feed, requestWith({})) + const second = buildICalFeedResponse(icalContent, activities, feed, requestWith({})) + + expect(first.headers.get('ETag')).toBe(second.headers.get('ETag')) + + const edited = buildICalFeedResponse( + icalContent.replace('Comício em Salvador', 'Comício em Salvador (novo local)'), + activities, + feed, + requestWith({}), + ) + expect(edited.headers.get('ETag')).not.toBe(first.headers.get('ETag')) + }) + + it('answers 304 without a body when If-None-Match matches the current ETag', async () => { + const fresh = buildICalFeedResponse(icalContent, activities, feed, requestWith({})) + const etag = fresh.headers.get('ETag')! + + const revalidated = buildICalFeedResponse( + icalContent, + activities, + feed, + requestWith({ 'if-none-match': etag }), + ) + + expect(revalidated.status).toBe(304) + expect(revalidated.headers.get('ETag')).toBe(etag) + expect(revalidated.headers.get('Cache-Control')).toBe('public, no-cache') + expect(await revalidated.text()).toBe('') + }) + + it('answers 304 for wildcard and multi-value If-None-Match lists, weak or strong', () => { + const etag = buildICalFeedResponse(icalContent, activities, feed, requestWith({})).headers.get( + 'ETag', + )! + + for (const header of ['*', `"stale-1", ${etag}`, `W/${etag}`]) { + const revalidated = buildICalFeedResponse( + icalContent, + activities, + feed, + requestWith({ 'if-none-match': header }), + ) + expect(revalidated.status, header).toBe(304) + } + }) + + it('answers 200 when If-None-Match carries a stale ETag', () => { + const revalidated = buildICalFeedResponse( + icalContent, + activities, + feed, + requestWith({ 'if-none-match': '"stale-etag"' }), + ) + + expect(revalidated.status).toBe(200) + }) + + it('never answers 304 to If-Modified-Since alone (deletion blind spot)', () => { + const response = buildICalFeedResponse( + icalContent, + activities, + feed, + requestWith({ 'if-modified-since': 'Mon, 03 Aug 2026 08:00:00 GMT' }), + ) + + expect(response.status).toBe(200) + }) +}) From 3af97e960f1ad410089dc467e1359400190f3c46 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 11 Aug 2026 10:22:20 -0300 Subject: [PATCH 2/2] =?UTF-8?q?plans(C116,C117,C118):=20lista=20de=20pesso?= =?UTF-8?q?as=20=E2=80=94=20edi=C3=A7=C3=A3o=20onde=20voc=C3=AA=20v=C3=AA,?= =?UTF-8?q?=20ordena=C3=A7=C3=A3o/filtros=20de=20aus=C3=AAncia=20e=20detal?= =?UTF-8?q?he=20por=20capacidades?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/pessoas-detalhe-por-capacidades.md | 73 ++++++++++++++ docs/plans/pessoas-edicao-inplace-lista.md | 97 +++++++++++++++++++ .../pessoas-ordenacao-filtros-ausencia.md | 80 +++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 docs/plans/pessoas-detalhe-por-capacidades.md create mode 100644 docs/plans/pessoas-edicao-inplace-lista.md create mode 100644 docs/plans/pessoas-ordenacao-filtros-ausencia.md diff --git a/docs/plans/pessoas-detalhe-por-capacidades.md b/docs/plans/pessoas-detalhe-por-capacidades.md new file mode 100644 index 00000000..f867d334 --- /dev/null +++ b/docs/plans/pessoas-detalhe-por-capacidades.md @@ -0,0 +1,73 @@ +# Pessoas — página de detalhe da pessoa (customizada por capacidades) + +Status: rascunho +Atualizado em: 2026-08-11 +Issue: #657 +Priority: P2 +Model: composer-2.5 +Impeccable: C — fluxo novo: rota de detalhe + composição de seções por capacidade +Canvas UI: /home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c118-ui-draft.canvas.tsx +Appetite: ~2 dias eng; um outcome verificável — toda pessoa da lista tem um detalhe que mostra só o que ela é +Responsável: — + +## Intenção + +A lista de pessoas ganhou profundidade de uso (edição in-place na C116, ordenação na C117) e o Nome vira link — mas hoje não existe uma página de detalhe de pessoa: só `/campanha/liderancas/[id]`, que só cobre quem é liderança. Uma dobradinha, um assessor ou um apoiador não têm detalhe nenhum. A mesa precisa de uma ficha única da pessoa que se monta sozinha conforme as capacidades dela: a mesma rota serve para qualquer pessoa e mostra **só as seções que ela tem** (liderança, dobradinha, assessora, assessorado, apoiador). + +## Persona e fluxo + +- **Persona / contexto:** coordenador e assessores (staff) abrindo a ficha de uma pessoa a partir da lista; leader não acessa. +- **Job principal:** ver a ficha inteira da pessoa num lugar só — dados, vínculos territoriais e capacidades — sem juntar pedaços de várias telas. +- **Fluxo desejado:** + - Na lista de pessoas, clicar no Nome abre `/campanha/pessoas/` (ou rota equivalente). + - A página monta as seções pela capacidade: ficha (nome, partido, contato, base), **Liderança** (municípios, status de apoio, acesso ao app), **Dobradinha** (partido, municípios), **Assessora** (carteira), **Assessorado** (quem assessora), **Apoiador** (se houver registro), **Ações** (WhatsApp, convidar, apagar). + - Seções ausentes não aparecem — uma pessoa só-dobradinha não vê blocos vazios de liderança. +- **Anti-goals de produto:** não vira tela de admin do Payload (sem edição de schema); não duplica o detalhe de liderança existente para sempre — o detalhe de liderança pode ser absorvido ou linkado; não vira dashboard com KPIs da pessoa. + +## Objetivo e aceite + +- Rota nova de detalhe de pessoa acessível a staff; aberta pelo Nome na lista de pessoas (C116 troca o link para cá quando esta existir). +- Seções por capacidade: cada capacidade presente da pessoa renderiza seu bloco; a página de uma pessoa sem liderança não mostra o bloco de liderança. +- Escopo preservado: assessor só vê/abre pessoas da carteira; leader não acessa a rota; sem migration; `Contact` continua a fonte da ficha. +- O detalhe de liderança existente (`/campanha/liderancas/[id]`) não regride: este item decide convivência (absorver no novo detalhe ou linkar). + +## Dados (intenção) + +- **Vou apresentar dados?** Não — a página mostra vínculos e ficha (mesmos dados da lista, em profundidade), não métricas. Sem KPI de pessoa (votos/estimativas só nas superfícies que já os exibem, com a assimetria atual intacta — liderança vê `declaredVotes`, nunca estimativas). + +## Direção no codebase (hipótese) + +- **Áreas prováveis:** rota nova em `src/app/(campaign)/campanha/(app)/pessoas/[id]/`; loader por `contactID` reusando o merge de `src/utilities/people/peopleData.ts` (ou um irmão `loadPersonDetail`); seções como componentes em `src/components/campaign/people/`; dados por capacidade vêm dos utilitários de domínio já existentes (leadership/stateDeputy/advisor/supporter). +- **Precedente a olhar:** `/campanha/liderancas/[id]` (detalhe existente), `peopleData.ts` (merge por Contact), C100 (lista), `municipalityPortfolio`/catálogo para nomes de municípios. +- **Risco de acoplamento:** o escopo do assessor no detalhe deve espelhar o da lista (merge por capacidade, nunca alargado); leader lockdown; assimetria votos declarados × estimados preservada se qualquer bloco mostrar votos. + +## Dependências + +- **C99/C100** (prontas). **Suaves:** C116 (é quem passa a linkar o Nome para cá quando existir; sem dependência de ordem — o link da C116 nasce apontando para a liderança e troca quando C118 entrar); C117 (mesma lista, sem dependência). + +## Fora de escopo + +- Edição na página de detalhe (a edição é in-place na lista, C116; editar aqui pode ser item futuro). +- Migração do detalhe de liderança para esta rota (decisão de convivência no item, sem derrubar a rota atual). +- Página de apoiador dedicada; histórico/atividades da pessoa; timeline. + +## Rabbit holes de produto + +- **"Detalhe vira mini-admin do Payload."** Formulários de edição de todos os campos, histórico de versões, relação de tudo com tudo. **Corte:** página de leitura + navegação; escrita fica na lista (C116) e nas superfícies atuais. +- **"Seções vazias com placeholder 'Nada aqui ainda'."** Pessoa sem liderança não precisa do bloco. **Corte:** seção só quando a capacidade existe (o pedido explícito). +- **"Absorver o detalhe de liderança de uma vez."** Migrar todo o fluxo (incluindo ações/votos) para a rota nova num item só. **Corte:** v1 é leitura + convivência; a migração do detalhe de liderança é avaliada com uso. +- **KPIs da pessoa.** "Engajamento", "score", contagens decorativas no topo. **Corte:** sem métrica nova; a ficha mostra vínculos. + +## Questões em aberto (produto) + +- **Convivência com o detalhe de liderança:** manter `/campanha/liderancas/[id]` intacto e o novo detalhe linkar para ele na seção de liderança, ou migrar a rota de liderança para o detalhe novo? **Recomendação:** v1 mantém as duas — a seção Liderança do detalhe novo mostra o resumo e linka para a página rica existente (que tem votos/ações). Migração total fica avaliada com uso. _(assumido — validar no gate)_ +- **Ações no detalhe:** repetir WhatsApp/convidar/apagar da lista? **Recomendação:** sim, as mesmas ações (padrão da lista) — é o destino natural delas quando a mesa está na ficha. _(assumido — validar no gate)_ +- **Apoiador:** a seção de apoiador mostra o registro (fonte, município, intenção de voto)? **Recomendação:** sim, resumo do registro de apoiador quando houver — mesmo consent guardado das superfícies atuais. _(assumido — validar no gate)_ + +## Referências + +- `src/app/(campaign)/campanha/(app)/liderancas/[id]/` — detalhe existente (molde de seções/leitura) +- `src/utilities/people/peopleData.ts` — merge por `Contact` e view model (capacidades da pessoa) +- `src/components/campaign/people/` — componentes de lista (base para seções do detalhe) +- `src/app/(campaign)/campanha/(app)/pessoas/page.tsx` — colunas da lista que viram seções do detalhe +- Canvas UI (gate): `/home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c118-ui-draft.canvas.tsx` diff --git a/docs/plans/pessoas-edicao-inplace-lista.md b/docs/plans/pessoas-edicao-inplace-lista.md new file mode 100644 index 00000000..0f0bf50d --- /dev/null +++ b/docs/plans/pessoas-edicao-inplace-lista.md @@ -0,0 +1,97 @@ +# Pessoas — edição onde você vê (edit-where-you-see) na lista desktop + +Status: rascunho +Atualizado em: 2026-08-11 +Issue: #655 +Priority: P2 +Model: composer-2.5 +Impeccable: B — encaixe em tela existente (`/campanha/pessoas`, tabela desktop); sem rota nova +Canvas UI: /home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c116-ui-draft.canvas.tsx +Appetite: ~1,5–2 dias eng; um outcome verificável — a linha da lista é editável sem sair da tabela +Responsável: — + +## Intenção + +A mesa corrige dados de pessoas o dia inteiro (telefone errado, município que saiu da rede, assessor que mudou) e hoje precisa abrir a ficha, o detalhe ou o admin para cada ajuste — mesmo quando a correção é uma célula da lista. O pedido: na tabela desktop de `/campanha/pessoas`, toda célula (exceto a coluna Ações) vira um editor in-place — clicou, editou, salvou. Quem lidera/é aliada em várias cidades ainda precisa enxergar o recorte por território sem perder a capacidade de ajustar cidade a cidade. + +## Persona e fluxo + +- **Persona / contexto:** coordenador e assessores na mesa (staff), trabalhando a lista de pessoas com a base nominal real. +- **Job principal:** corrigir o dado de uma pessoa — um campo de texto ou um vínculo territorial — sem sair da linha em que está. +- **Paradigma da superfície (travado com o humano 2026-08-11):** a célula **é** um input permanente. Sem estado de "edição" com label, borda ou fundo diferente: a célula parece texto e se comporta como input. **Chips são internos ao input** (tags dentro do box, transparente como o resto). +- **Fluxo desejado:** + - Células de texto (Contato, E-mail, Base): input sempre, sem destaque; salva no blur ou Enter (Esc descarta). Vazio mostra placeholder discreto ("—" atual vira placeholder apagado). + - Nome: input sempre cujo valor é estilizado como **link** para a página da pessoa; navegação convive com a edição sem trocar de estado (mecanismo em "Questões em aberto"). + - Células de municípios (Assessora, Lidera, Aliada em) e Assessorado: input com **chips internos** — municípios agrupados em chip de território quando a pessoa tem **todos** os municípios do território ("Sertão do São Francisco (5)"), as 19 zonas de Salvador num chip "Salvador (19)"; excedente "+X" expande. + - Digitar + autocomplete: selecionar uma sugestão **ou** teclar espaço/Enter com o texto batendo (match) no catálogo vira chip imediatamente. Sem match, não vira chip. + - Clicar num chip de território expande as cidades dele dentro do input; ao sair da célula com o mouse, se a pessoa ainda tiver todas as cidades do território, colapsa de novo. + - Hover num chip mostra o **X** no canto; clicar no X (não no chip) apaga a cidade daquela linha. + - Hover nos botões de Ações mostra a **label** da ação em tooltip. +- **Anti-goals de produto:** não vira planilha/data-grid (sem edição em lote, sem seleção múltipla); não é segundo cadastro de pessoa (a ficha continua `Contact`); mobile não muda; "Assessorado" não ganha autocomplete de criar pessoa (só vínculo); nada é criado fora do catálogo (texto sem match não vira chip). + +## Objetivo e aceite + +- Toda célula da tabela desktop é um **input permanente sem destaque** (sem label de edição, sem borda, sem fundo diferente) — o conteúdo parece texto e edita no lugar; salva no blur/Enter (Esc descarta), escrevendo no campo dono da ficha `Contact` (nome, telefone, e-mail, cidade) — sem migration. +- O Nome mantém o valor como **link** para a página de detalhe da pessoa (quando existir), com a edição convivendo no mesmo input. +- As colunas de municípios (Assessora, Lidera, Aliada em) e Assessorado usam **chips internos ao input** com colapso por território: grupo completo → chip "Território (N)"; 19 zonas de Salvador completas → chip "Salvador (19)"; excedente → "+X" que expande. +- Digitar e selecionar no autocomplete — ou teclar espaço/Enter com o texto batendo no catálogo — transforma o valor em chip; texto sem match não vira chip. +- Remover uma cidade pelo X do chip persiste no servidor; expansão/recolapso por hover não altera dados. +- Hover nas ações mostra a label (WhatsApp, Convidar, Apagar) em tooltip. +- Guardrails: foco por teclado mantém indicador discreto (acessibilidade) mesmo sem caixa de edição; escopo preservado (assessor só edita a carteira dele; leader fora da rota); nenhuma coluna nova; mobile intacto. + +## Dados (intenção) + +- **Vou apresentar dados?** Não — as colunas expressam vínculos (território/município), não métricas. O chip de território mostra contagem de cidades do grupo, não é dado analítico. +- **Decisões desbloqueadas:** nenhuma leitura decisória nova; a superfície muda de "ler e navegar" para "ler e corrigir". + +## Direção no codebase (hipótese) + +- **Áreas prováveis:** `src/app/(campaign)/campanha/(app)/pessoas/page.tsx` (colunas + `PeopleMunicipalityCell`); `src/components/campaign/people/` (filters/chrome); componentes compartilhados já existentes — `CampaignInlineEditableCell` (B163: célula de texto com clique→input→blur/Enter, já suporta `href` no Nome com edição por clique fora do valor), `RelationChipCell` (B157/B159/B169: chips de relação com X no hover, input de autocomplete inline; hoje colapsa por linhas "Ver mais…" — o colapso **por território** com expandir/recolapsar por hover é o trabalho novo), `useCampaignCellAutosave`; escrita via as rotas/actions de célula já usadas pelas listas vizinhas. +- **Precedente a olhar:** [editar-campos-lista-dobradinhas.md](editar-campos-lista-dobradinhas.md) (B163 — mesmo padrão de célula de texto), [generalizar-colunas-relacao-municipios.md](generalizar-colunas-relacao-municipios.md) e [chips-municipios-lista-dobradinhas.md](chips-municipios-lista-dobradinhas.md) (B159/B157 — chips de relação), [estabilizar-lista-dobradinhas-followup.md](estabilizar-lista-dobradinhas-followup.md) (B170 — contrato de colapso de chips), `src/lib/municipalityCatalog.ts` (TI por município, `region`; zonas de Salvador como grupo próprio). +- **Risco de acoplamento:** leader lockdown e escopo do assessor (rota staff; a escrita deve respeitar o access da coleção dona — ex. carteira de outro assessor); **B155+ (#368, in-progress)** migra o padrão de escrita de célula de form action para rota — se entrar antes, seguir o padrão novo; **C112 (#626, in-progress)** muda o shape de telefone (múltiplos) — o editor de Contato deve conviver com a transição. + +## Dependências + +- **C99/C100** (prontas — ficha `Contact` e lista unificada). +- **Suaves:** C112 (telefones múltiplos — editor de Contato acompanha o shape), B155+ (padrão de escrita de célula), C117 (mesma tabela; sem dependência de ordem entre elas), **C118** (detalhe de pessoa — quando entrar, o link do Nome aponta para ela). + +## Fora de escopo + +- Mobile (cards): os fluxos próprios já existem; este item é a tabela desktop. +- Edição em lote / spreadsheet mode / reorder de colunas. +- Página de detalhe de pessoa (customizada por capacidades) → **C118** ([pessoas-detalhe-por-capacidades.md](pessoas-detalhe-por-capacidades.md)); este item usa o detalhe existente (liderança) para o link do Nome. +- Ordenação e filtros de ausência na lista → **C117** ([pessoas-ordenacao-filtros-ausencia.md](pessoas-ordenacao-filtros-ausencia.md)). +- Criar pessoa nova na lista (cadastro segue nas rotas atuais). + +## Rabbit holes de produto + +- **"Já que editamos, vamos editar tudo em lote."** Seleção múltipla, "aplicar a todos", CSV de volta — explode para spreadsheet mode. **Corte neste item:** uma célula por vez, autosave por célula (padrão B163), nada de lote. +- **"Chips internos ao input = contenteditable de verdade."** `contenteditable` com formatação livre, colar HTML, estado de cursor imprevisível — vira um editor de texto. **Corte:** a implementação deve manter o comportamento de input (valor simples, chips como dados, teclado previsível), mesmo que o mecanismo seja um wrapper com chips + input; o executor decide a mecânica. +- **"Expansão de território com animações e estado global."** Guardar qual célula está expandida, coordenar entre linhas, animar transições — vira motor de UI. **Corte:** expansão é local à célula, dirigida por hover/foco, sem estado persistido. +- **Colapso por território querendo virar tree-view.** Chip de território com sub-chips aninhados persistentes (acordeão) é outra interação. **Corte:** grupo colapsa/expande in loco; cidades do grupo aparecem como os chips normais (removíveis), nada de hierarquia persistente. +- **Editar "Assessorado" exigindo criar assessor.** O autocomplete da coluna inversa só vincula pessoas existentes (staff). **Corte:** sem "Criar…" aqui; criação de conta/staff fica nas rotas atuais. + +## Questões em aberto (produto) + +Resolvidas no gate 2026-08-11: + +- **Link do Nome:** `/campanha/liderancas/[id]` quando a pessoa é liderança (sem link senão); a **página de detalhe de pessoa** (customizada por capacidades) entra como Issue própria **C118** ([pessoas-detalhe-por-capacidades.md](pessoas-detalhe-por-capacidades.md)) — quando existir, o link do Nome passa a apontar para ela. +- **Assessorado editável:** sim — autocomplete de assessores existentes (escreve nos vínculos de assessor da liderança/dobradinha; sem "Criar…" aqui). +- **Paradigma:** células são **inputs permanentes sem destaque**; chips internos ao input; nome mantém o valor como link. +- **Navegação do Nome (mecanismo travado com o humano):** o valor é **exibido como link**; cada tecla digitada no input atualiza o texto do link de acordo com o valor (draft), **mantendo o valor real do input em vazio** — o draft vive no display; clique no link navega; salva no blur/Enter. A mecânica exata de sobreposição/display é do plano de implementação, com o requisito de link clicável + digitação no mesmo lugar. +- **Sem match no catálogo:** ao teclar Enter/espaço sem o texto bater em município/assessor, o texto **permanece** no input (não vira chip); nada é criado fora do catálogo. + +Ainda em aberto (uma rodada no gate): + +- **Quem pode editar a carteira de um assessor ("Assessora")?** **Recomendação:** manter a regra de acesso da coleção dona (coordenação/candidato; assessor só a própria) — sem alargar permissão na lista. _(assumido — validar no gate)_ +- **Ordenação do excedente e do autocomplete:** manter a ordem do catálogo (alfabética pt-BR) e sugerir apenas municípios ainda não vinculados. **Recomendação:** sim — mesma regra dos chips das outras listas. _(assumido — validar no craft)_ + +## Referências + +- `src/app/(campaign)/campanha/(app)/pessoas/page.tsx` — tabela, colunas, `PeopleMunicipalityCell` (chips atuais "+2") +- `src/components/campaign/shared/CampaignInlineEditableCell.tsx` — célula de texto B163 (clique→input→blur/Enter, `href` no Nome) +- `src/components/campaign/shared/RelationChipCell.tsx` — chips de relação B157/B169 (X no hover, input inline, colapso por linhas) +- `src/components/campaign/shared/CampaignCellEditOverlay.tsx` / `useCampaignCellAutosave.ts` — overlay e autosave de célula +- `src/utilities/people/peopleData.ts` — view model (`assessoraMunicipalityIDs`, `leadershipMunicipalityIDs`, `deputyMunicipalityIDs`, …) +- `src/lib/municipalityCatalog.ts` — TI por município (`region`), zonas de Salvador (grupo próprio de 19) +- Canvas UI (gate): `/home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c116-ui-draft.canvas.tsx` diff --git a/docs/plans/pessoas-ordenacao-filtros-ausencia.md b/docs/plans/pessoas-ordenacao-filtros-ausencia.md new file mode 100644 index 00000000..f4cbd09f --- /dev/null +++ b/docs/plans/pessoas-ordenacao-filtros-ausencia.md @@ -0,0 +1,80 @@ +# Pessoas — ordenação (omnibox + header) e filtros de ausência + +Status: rascunho +Atualizado em: 2026-08-11 +Issue: #656 +Priority: P2 +Model: composer-2.5 +Impeccable: B — encaixe em tela existente (`/campanha/pessoas`); sem rota nova +Canvas UI: /home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c117-ui-draft.canvas.tsx +Appetite: ~1–1,5 dia eng; um outcome verificável — recortar e ordenar a lista pelo critério do dia +Responsável: — + +## Intenção + +A lista de pessoas hoje só ordena por nome (fixo) e só filtra por capacidade/município/status. A mesa precisa de dois movimentos: **ordenar por qualquer coluna** (quem tem mais rede? quem está sem base?) e **filtrar ausência** ("Sem assessor", "Sem base", "Sem contato" — para atacar exatamente quem não tem o dado). Ordenação entra em dois lugares com o mesmo estado: o header das colunas (desktop) e o omnibox (onde já existe o padrão de ordenação nas outras listas). + +## Persona e fluxo + +- **Persona / contexto:** coordenador e assessores (staff) na lista de pessoas; leader não acessa. +- **Job principal:** recortar a lista pelo critério do dia e ordená-la pela coluna que importa, sem sair da tela nem decorar quem é quem. +- **Fluxo desejado:** + - Clicar no header de uma coluna ordena por ela e mostra a **seta de direção** ao lado do header; clicar de novo inverte; clicar em outra coluna move o indicador. Ordenação padrão: alfabética pelo nome. + - Na omnibox, opção **Ordenação** com cada coluna × crescente/decrescente (padrão já usado na lista de Territórios) — **em todos os breakpoints**, inclusive no mobile (a omnibox é edge-to-edge e o mobile tem as mesmas capacidades de ordenação). + - Na omnibox, filtros de ausência: **Sem assessor** (quem não tem assessorado), **Sem base** (sem cidade no cadastro), **Sem contato** (sem telefone registrado). +- **Anti-goals de produto:** não vira spreadsheet mode; nada de ordenar por colunas ocultas (toda chave de ordenação pousa numa coluna visível); filtro de ausência não expõe o valor do dado (só a presença/ausência — telefone nunca é listado em facet). + +## Objetivo e aceite + +- Header de cada coluna sortável ordena a lista global (conjunto filtrado, não só a página), com `aria-sort` e seta de direção; 2º clique inverte; a coluna ativa é única (mudou de coluna, o indicador move). +- Omnibox ganha o recorte "Ordenação" (padrão de Territórios) com as mesmas chaves/direções do header. +- Omnibox ganha os filtros de ausência "Sem assessor", "Sem base", "Sem contato" — combináveis entre si e com os filtros atuais, estado na URL como os demais. +- Ordenação default inalterada (nome A–Z) — a lista abre como hoje. +- Guardrails: sem migration, sem collection nova; escopo do assessor preservado (ordena/filtra sobre o que ele enxerga); leader segue fora da rota. + +## Dados (intenção) + +- **Vou apresentar dados?** Não — nenhuma métrica nova: a entrega é recorte e ordenação sobre a tabela existente (mesma natureza do B29 em lideranças). +- **Decisões desbloqueadas:** staff: "quem da minha carteira está sem telefone para disparar a ação?"; "quais pessoas lideram mais municípios?"; "quem está sem assessor na região X?". Ausência é recorte operacional, não dado exibido. + +## Direção no codebase (hipótese) + +- **Áreas prováveis:** `src/utilities/people/peopleListUrl.ts` (estado `sort`/`dir` + filtros de ausência no contrato de URL, espelhando `territoryListUrl.ts`); `src/utilities/people/peopleListFilters.ts` e `peopleOmnibox.ts` (chips/sugestões de ordenação e ausência, molde `territoryOmnibox.ts`); `src/utilities/people/peopleData.ts` (aplica sort sobre o conjunto filtrado — a lista já é merge em memória); `src/components/campaign/people/PeopleFilters.tsx` (omnibox); página `pessoas/page.tsx` (headers sortáveis via `CampaignSortableHead`/`CampaignTransitionAnchor`, padrão B15). +- **Precedente a olhar:** [ordenacao-colunas-lista-municipios.md](ordenacao-colunas-lista-municipios.md) (B15 — header sort com `?sort=`/`?dir=` na URL), [ordenacao-filtros-lista-liderancas.md](ordenacao-filtros-lista-liderancas.md) (B29 — wrappers de domínio sobre chrome compartilhado), `src/utilities/territory/territoryOmnibox.ts` (chip "Ordenação" no omnibox com chaves asc/desc). +- **Risco de acoplamento:** a lista é merge de três fontes (leadership/dobradinha/staff) com filtros in-memory — o sort deve operar sobre o conjunto filtrado **antes** da paginação (precedente B15 branch in-memory), respeitando o escopo do assessor no merge; chaves de ordenação só sobre colunas visíveis; nulls ("Sem…") sempre no fim (precedente B15). + +## Dependências + +- **C99/C100** (prontas). **Suaves:** C116 (mesma tabela; sem dependência de ordem), B155+ (padrão de escrita de célula — só afeta se o sort interagir com edição). + +## Fora de escopo + +- Edição in-place / chips de território → **C116** ([pessoas-edicao-inplace-lista.md](pessoas-edicao-inplace-lista.md)). +- Ordenar por colunas ocultas; filtros salvos novos (o padrão B18 já cobre a lista se a mesa pedir). +- "Sem e-mail" como filtro (o pedido cobre assessor/base/contato; e-mail oculto por padrão desde B197). +- Export CSV / view alternativas. + +## Rabbit holes de produto + +- **"Ordenar por coluna oculta ou por campo sem coluna."** Chave de ordenação que não é coluna visível é estado que mente. **Corte:** toda chave do omnibox = uma coluna da tabela; nada extra. +- **"Filtro de ausência vira campo de busca."** Digitar "sem telefone" na busca e esperar que interprete. **Corte:** facet explícito "Sem…" na omnibox, combinável, mesmo contrato dos demais. +- **Sort client-side da página de 25.** Ordena a página, não a lista — ranking falso. **Corte:** sort sobre o conjunto filtrado completo (a lista já carrega o filtrado para merge; o custo é o mesmo do B15 in-memory). +- **Nova métrica de "cobertura" para justificar o sort.** Contagem de vazios agregada no topo da página vira dashboard de vaidade. **Corte:** ausência é filtro e ordenação, não KPI. + +## Questões em aberto (produto) + +Resolvidas no gate 2026-08-11: + +- Colunas de municípios ordenam **por contagem** (mais municípios primeiro em `desc`); vazios ("Sem…") sempre no fim. +- "Sem contato" = **sem telefone** registrado (coluna Contato = telefone desde B197). +- Coluna Ações não é sortável (inerte, padrão B15). +- Mobile: o recorte "Ordenação" na omnibox vale em todos os breakpoints — o mobile tem as mesmas capacidades de ordenação e filtro. + +## Referências + +- `src/app/(campaign)/campanha/(app)/pessoas/page.tsx` — tabela + `PeopleFilters` (omnibox) +- `src/utilities/people/peopleListUrl.ts` / `peopleListFilters.ts` / `peopleOmnibox.ts` — contrato de URL e omnibox atuais ("no sort in v1") +- `src/utilities/territory/territoryOmnibox.ts` + `territoryListUrl.ts` — molde do recorte "Ordenação" no omnibox +- `src/components/campaign/shared/CampaignTable.tsx`, `CampaignSortableHead` (B15/B29) — headers sortáveis com `aria-sort` + seta +- `src/utilities/people/peopleData.ts` — merge in-memory; ponto onde o sort global se aplica +- Canvas UI (gate): `/home/fsolla/.cursor/projects/home-fsolla-Code-teqo/canvases/plan-c117-ui-draft.canvas.tsx`