merge: origin/main (C113/OPS38 changelog, wizard chrome)
This commit is contained in:
commit
c81a439d36
21 changed files with 935 additions and 88 deletions
|
|
@ -4,7 +4,13 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
|||
|
||||
---
|
||||
|
||||
**Recently resolved (2026-08-11):** **D10** (Issue #617) — auto-cura de leftovers de consent/supporter + fim do shared-write latente no lease de consent (pós-D9). **F1 (self-heal):** `purgeOrphanedConsentRenames` (3 nomes explícitos de renames temporários — `temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) roda sob lease **exclusivo**, em **autocommit** (delete na transação do lease seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada — observado na primeira implementação), nos 3 pontos que tocam a key canônica (`withMutableConsentFixture` antes do snapshot, setup do `withMissingInviteConsentFixture`, `ensureLeasedConsent`) — uma run abortada no meio de um rename de teste não envenena mais o run seguinte (UNIQUE `consent_key_idx` auto-sustentado). **F1b:** `purgeMunicipalityResidue` também deleta os **contatos** que supporters residue órfãos deixaram (reference-check em leadership/signature/subscription/supporter pós-delete — mesmo critério do `removeSupporterData`, reimplementado no helper). **F2 (refutação por medição + design final):** a hipótese "shared-writer = ABBA latente" caiu sob carga — exclusivo-durante-a-operation → fila de 17 waiters e cascata de 15s (6 workers); operation sem lease → FK `consent_id` (cleanup de fixture apagava linha canônica adotada); o ABBA não é construtível hoje (escritas disjuntas por teste; Onda0 em ordem fixa de 3 keys). As flakes reais do baseline (3/4 runs) eram: consumidores observando a ausência commitada da janela missing-consent (janela D9 era **shared**), down-test da Onda0 deletando as 4 keys **não-atomicamente**, e provision re-textando no meio de asserções de preview. **Fix:** janela do `withMissingInviteConsentFixture` → **exclusiva** (bloqueia consumidores shared durante a ausência; fila limitada ~200–500ms — a medição D9 do "15s tight" era o regime ONDA0-exclusivo, inexistente hoje); down-test da Onda0 **atômico** (delete+count+restore numa transação — FK insert espera o commit via speculative insertion); provision da Onda0 com leases **exclusivos** aninhados (ordem fixa CI→SR→VI); consumidores continuam shared. **Review /simplify (2 reviewers):** P1-1 — os renames vivos de `campaignInvite`/`campaignFixtures` migraram para chaves privadas (`consent-vacated-<id>`/`owned-consent-vacated-<id>`) — nenhum teste vivo produz os 3 nomes do purge; comentários "Onda0 shared" corrigidos; `withExclusiveTestDatabaseLease` colapsado (era cópia do privado); rollback no down-test quando a sessão falta; cleanup no finally nos testes F1 (auto-curáveis — um run falho do próprio teste não polui). Testes novos: contrato da janela exclusiva (waiter SHARED bloqueado), purge em ensure/setup, self-heal do canônico (repro do run abortado, 2 fixtures), F1b. **Contraprova:** 7 arquivos da key × 6 workers → **12/12 verdes** (baseline 1/4; designs intermediários: cascata 15s / FK); suíte int completa 83 arquivos/701 testes; unit 1938; e2e paridade com baseline (10 falhas pré-existentes de chat IA/webauthn/mic reproduzem no código original). Sem migration/access/Consent/`src/`. Intenção [`escala-dry-pos-d9.md`](plans/escala-dry-pos-d9.md); impl [`escala-dry-pos-d9-impl.md`](plans/escala-dry-pos-d9-impl.md).
|
||||
**Recently resolved (2026-08-11):** **D10** (Issue #617) — auto-cura de leftovers de consent/supporter + fim do shared-write latente no lease de consent (pós-D9). **F1 (self-heal):** `purgeOrphanedConsentRenames` (3 nomes explícitos de renames temporários — `temporarily-renamed-consent`, `chave-renomeada`, `chave-temporariamente-ausente`) roda sob lease **exclusivo**, em **autocommit** (delete na transação do lease seguraria row lock e a operation do próprio fixture self-deadlockaria ao reusar a key purgada — observado na primeira implementação), nos 3 pontos que tocam a key canônica (`withMutableConsentFixture` antes do snapshot, setup do `withMissingInviteConsentFixture`, `ensureLeasedConsent`) — uma run abortada no meio de um rename de teste não envenena mais o run seguinte (UNIQUE `consent_key_idx` auto-sustentado). **F1b:** `purgeMunicipalityResidue` também deleta os **contatos** que supporters residue órfãos deixaram (reference-check em leadership/signature/subscription/supporter pós-delete — mesmo critério do `removeSupporterData`, reimplementado no helper). **F2 (refutação por medição + design final):** a hipótese "shared-writer = ABBA latente" caiu sob carga — exclusivo-durante-a-operation → fila de 17 waiters e cascata de 15s (6 workers); operation sem lease → FK `consent_id` (cleanup de fixture apagava linha canônica adotada); o ABBA não é construtível hoje (escritas disjuntas por teste; Onda0 em ordem fixa de 3 keys). As flakes reais do baseline (3/4 runs) eram: consumidores observando a ausência commitada da janela missing-consent (janela D9 era **shared**), down-test da Onda0 deletando as 4 keys **não-atomicamente**, e provision re-textando no meio de asserções de preview. **Fix:** janela do `withMissingInviteConsentFixture` → **exclusiva** (bloqueia consumidores shared durante a ausência; fila limitada ~200–500ms — a medição D9 do "15s tight" era o regime ONDA0-exclusivo, inexistente hoje); down-test da Onda0 **atômico** (delete+count+restore numa transação — FK insert espera o commit via speculative insertion); provision da Onda0 com leases **exclusivos** aninhados (ordem fixa CI→SR→VI); consumidores continuam shared. **Review /simplify (2 reviewers):** P1-1 — os renames vivos de `campaignInvite`/`campaignFixtures` migraram para chaves privadas (`consent-vacated-<id>`/`owned-consent-vacated-<id>`) — nenhum teste vivo produz os 3 nomes do purge; comentários "Onda0 shared" corrigidos; `withExclusiveTestDatabaseLease` colapsado (era cópia do privado); rollback no down-test quando a sessão falta; cleanup no finally nos testes F1 (auto-curáveis — um run falho do próprio teste não polui). Testes novos: contrato da janela exclusiva (waiter SHARED bloqueado), purge em ensure/setup, self-heal do canônico (repro do run abortado, 2 fixtures), F1b. **Contraprova:** 7 arquivos da key × 6 workers → **12/12 verdes** (baseline 1/4; designs intermediários: cascata 15s / FK); suíte int completa 83 arquivos/701 testes; unit 1938; e2e paridade com baseline (falhas pré-existentes de chat IA/webauthn/mic reproduzem no código original). Sem migration/access/Consent/`src/`. Intenção [`escala-dry-pos-d9.md`](plans/escala-dry-pos-d9.md); impl [`escala-dry-pos-d9-impl.md`](plans/escala-dry-pos-d9-impl.md).
|
||||
|
||||
**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 `<th>`) e `AdvisorsTable` (client) passou a receber `columnVisibility` com `<th>`/`<td>` 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):** **Push /campanha — opt-in automático + fix do prompt nativo (ad-hoc).** O `requestPermission` do card de perfil rodava **dentro de `startTransition(async …)`** — qualquer fronteira assíncrona entre o clique e a chamada derruba a user activation e o Chrome **auto-nega silenciosamente** (resolve `'denied'` sem mostrar o prompt): o teste real em device deu exatamente "Permissão de notificação negada". Fix: os guards sync ficam no handler e **`Notification.requestPermission()` passa a ser chamado sincronamente no clique** (antes de qualquer `await`/transição); mensagens distintas para `denied` (permissão bloqueada — reativar nas configurações do site; um `denied` não pode ser re-perguntado por nenhuma API) vs `default` (prompt não exibido). **Opt-in automático** (pedido do usuário): novo `CampaignPushOptInToast` no shell `(app)` (padrão `InstallPwaToast`/`BiometricEnrollmentToast`), montado só para staff (leader não recebe push — sino idêntico), exibido ~2s após o login quando `Notification.permission === 'default'` (nunca `denied`), sem inscrição ativa no device, consentimento `campanha-notificacoes-push` publicado e VAPID configurado; iOS só com app instalado (standalone); "Aceitar e ativar" roda a mesma cerimônia com o requestPermission síncrono no clique do action; dismiss fica salvo em `localStorage` (chave `campaign-push-opt-in-dismissed`); ativação/desativação/leitura de inscrição extraídas para `src/utilities/campaignPushClient.ts` (compartilhado com o card — don't twin; registrado no pin de top-level com razão). **Diagnóstico de prod (read-only, `db:check:push-chain`):** consentimento `campanha-notificacoes-push` **já publicado** (PASS — nada a criar), envs VAPID presentes, SW versionado, 77 eventos no sino — **único elo quebrado: 0 inscrições push** (o erro de permissão impedia o cadastro). Sem migration/access/Consent/schema. Sem issue — entregue via ad-hoc no worktree `work/3`.
|
||||
|
||||
**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 `<th>`) e `AdvisorsTable` (client) passou a receber `columnVisibility` com `<th>`/`<td>` 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).
|
||||
|
||||
|
|
@ -13,6 +19,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:<base>` 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).
|
||||
|
||||
|
|
|
|||
85
docs/plans/c113-feed-ical-agenda-nao-atualiza-impl.md
Normal file
85
docs/plans/c113-feed-ical-agenda-nao-atualiza-impl.md
Normal file
|
|
@ -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: "<sha256 do corpo>"` (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
|
||||
82
docs/plans/municipios-mobile-polimento-omnibox-cards-impl.md
Normal file
82
docs/plans/municipios-mobile-polimento-omnibox-cards-impl.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Impl: Municípios mobile: omnibox colado ao header e card mais denso (pós-B193/B184)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-10
|
||||
Issue: #607
|
||||
Intenção: docs/plans/municipios-mobile-polimento-omnibox-cards.md
|
||||
Appetite restante: herdado (~0,5–1 dia eng) — sem estouro: mudanças de superfície em 2 shared + 1 card + 1 avatar + 5 pages + e2e
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** em `/campanha/municipios` mobile (< md), a barra de filtro cola no header (sem vão, nada passa por cima no scroll), o campo fica ~40px no total, foco sem anel de cor, nome 18px com território colado, chips Classe/Tendência/Nível com label acima, avatares numa linha por grupo com sobreposição dinâmica — padrão do chassis vale para TODAS as listas (gate A); chip de classe ganha label "Classe" (gate B).
|
||||
- **O que NÃO negociar:** desktop (`md+`) intacto; sheets/edit-where-you-see intactos; labels/dados dos chips intactos; nenhum limite de avatares com "…"; área de toque dos controles do card não encolhe.
|
||||
- **O que reavaliar:** a hipótese da intenção ("encurtar o vão no contexto da barra — não o padding global do scrollport") é correta e foi confirmada: o sticky não pode subir acima do containing block (o shell), que começa 16px abaixo do scrollport por causa do `p-4` do `CampaignContentScroll`. Qualquer fix no form isolado falha (clamp do containing block).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[CampaignContentScroll<br/>max-md:has-[marcador]:pt-0] --> B[form sticky ancora em y=0<br/>colado ao header]
|
||||
B --> C[omnibox denso: min-h-8 py-1<br/>X size-8 · ring só md]
|
||||
C --> D[card: nome 18px tight · chips label-acima<br/>avatares overlap row]
|
||||
D --> E[e2e B196 + gates]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C
|
||||
**Recomendação:** A — resolver o vão via `:has()` no scrollport (o scrollport "sabe" quando hospeda a barra sticky; o form carrega um marcador de classe estável). É a única correção que move o containing block do sticky para y=0 sem tocar o padding global nem as 11 páginas de lista.
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B — `-mt-4` no form:** o sticky não sobe acima do containing block; com a borda do shell a 16px, o clamp continua em 16 (e o natural position vira 0 ≠ posição colada no scroll → barra "pula"). Medido na exploração: `formY=72` = header 56 + 16.
|
||||
- **C — `pt-0` global no scrollport / remover `p-4`:** rabbit hole nomeado na intenção; destrói a folga do topo de todas as telas (dashboard, wizard…). O `:has()` escopa o `pt-0` só para páginas com o form de lista.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`campaignListOmniboxFormClassName`** (`src/components/campaign/shared/CampaignListOmnibox.tsx`): adiciona o marcador `campaign-list-omnibox-form` ao bloco de classes existente (contrato do `:has()`). Barra: `py-2` → `py-1` no mobile (`md:py-0` preservado). Nada mais muda aqui — sticky/`-mx-4`/border-b ficam.
|
||||
- **Campo do omnibox** (mesmo arquivo): `min-h-11` → `min-h-8`, `py-1.5` → `py-1` no mobile (`md:min-h-11 md:py-1.5` restauram o desktop); botão "Limpar" X `size-11` → `size-8` (span interno `size-7` → `size-6`, ícone `size-4` → `size-3.5`; `md:hidden` já é mobile-only); anel de foco `focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50` → escopado a `md:` (mobile sem anel/contorno de cor — caret é o indicador; contrato B184 borderless preservado). `text-sm` do input INTOCADO (contrato B183: 16px touch).
|
||||
- Total da barra mobile: 32 (min-h-8) + 8 (py-1×2) + 1 (border-b) = **41px ≈ 40** ✓. Área de toque do X = 32px — aceitável porque o alvo real é o campo (o X vive dentro dele).
|
||||
- **`CampaignContentScroll`** (`src/components/campaign/shell/CampaignQuickActionsHost.tsx`): `p-4` → adiciona `max-md:has-[.campaign-list-omnibox-form]:pt-0` (desktop `md:p-6` intocado). Efeito: só páginas com o form de lista perdem o padding-top no mobile; o shell (containing block do sticky) passa a começar em y=0 → barra cola no header e cards nunca passam por cima. Fallback se o arbitrary variant do Tailwind v4 não compilar: regra CSS pura em `styles.css` sob `@media (width < 48rem)` com o mesmo seletor.
|
||||
- **Compensação das 6 listas com linha de ações antes do form** (`atividades`, `liderancas`, `apoiadores`, `demandas`, `dobradinhas`, `organizacoes` — verificadas na exploração): `pt-4 md:pt-0` no `div.flex.justify-end` que precede o form (respiro no topo que o `pt-0` removeu; desktop inalterado). `municipios`, `territorios`, `atualizacoes`, `pessoas`, `assessores` têm o form como primeiro filho visível — sem compensação.
|
||||
- **`MunicipalityMobileCard`** (`src/components/campaign/municipality/MunicipalityMobileCard.tsx`):
|
||||
- Nome: `<h3>` ganha `text-lg leading-tight` (18px/22.5px vs 24px/32px atuais do `h3` global em `styles.css:371`); `gap-0.5` existente → território a ~2–4px do texto (linha-box enxuta é o que fecha o vão medido de ~13px). Badge "Cidade" e link stretch intactos.
|
||||
- Chips: bloco `flex flex-col gap-0.5` por chip com `ChipLabel` acima do valor (gate B), substituindo o `inline-flex items-center` atual; linha de chips `items-start gap-x-3 gap-y-2` (1–2 linhas aceitas pelo gate); valores com `whitespace-nowrap` (nenhum chip quebra no meio). `chipTriggerClassName` vira o estilo de bloco (sem pílula de hover: `hover:bg-transparent`; `min-h-11` mantido para a área de toque; label+valor são o alvo do tap). A label "Classe" é renderizada no CALL SITE do card (`<ChipBlock label="Classe">`), não dentro de `TerritorialClassCardReadout` — o readout continua compartilhado com a tabela desktop intocado. Caso cidade: Tendência/Nível/Classe como blocos com os mesmos valores ("Não registrada", dash). `MissingAdvisorBadge` ganha wrap (`whitespace-normal h-auto`) para o pill não clipar na célula de ~110px do grupo (desktop segue uma linha).
|
||||
- Densidade: article `gap-3` → `gap-2.5` e `p-4` → `px-4 py-3` (vertical denso; horizontal mantém o edge-to-edge B184; toques `min-h-11` dos controles intactos).
|
||||
- **`MunicipalityRelationAvatarStack`** (`src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx`): o modo `wrap` (B193) vira **overlap row dinâmica** — células `flex-1` com avatar fixo centralizado: poucos avatares → sem sobreposição (célula > 28px), muitos → sobreposição proporcional à contagem, sem wrap, sem cap, sem "…", todos visíveis (a linha ocupa exatamente a largura do grupo; casos patológicos de dezenas de avatares degradam para pilha — sr-only continua com todos os nomes). Renomear a prop `wrap` → `overlapRow` (honestidade da semântica; 2 call sites no card + 1 em `MunicipalityAdvisorAvatarStack`). Adicionar `data-view="relation-avatars"` na linha (hook de teste, precedente `data-view="mobile-cards"`). Desktop (`-space-x-2`, cap 3, tooltip) intocado.
|
||||
- **`MunicipalityAdvisorAvatarStack`** (mesmo arquivo): passa `wrap` → `overlapRow` no modo denso (call site do card não-coordenador).
|
||||
- **Migration:** sem migration (nenhuma mudança de schema/collection/global).
|
||||
- **Access / Consent:** nenhum (zero paths de escrita tocados).
|
||||
- **UI:** Impeccable B — encaixe na tela existente; shape (classes acima) → craft (browser, 390px) → critique (contrastes/vão/overlap) → polish.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
N/A — nenhuma métrica nova; é densidade e alinhamento de superfície existente (intenção: "Vou apresentar dados? Não").
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer — chassis da barra** (maioria do appetite): marcador de classe + `:has()` no scrollport + densidade do campo/X/anel + compensação das 5 páginas. Verificar: `pnpm exec tsc --noEmit` + e2e B184/B183 existentes + novos e2e B196 (barra colada, altura ≤44, sem anel no foco).
|
||||
2. **UI — card denso**: nome/território, chips label-acima, overlap row de avatares, densidade vertical. Verificar: e2e B193 existentes + novos e2e B196 (nome 18px, território colado, label acima do badge, avatares numa linha com overlap e sem overlap quando poucos).
|
||||
3. **Gates**: `pnpm gate:fast` completo (tsc, lint, format, knip, cycles, unit+int, e2e do conjunto afetado) e `pnpm build`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não tocar `p-4`/`pb-*` do scrollport fora do `:has()` — o `pt-0` é escopado e é O fix do containing block.
|
||||
- Não mudar `text-sm` do input (contrato B183), nem o desktop do omnibox (`md:*` restaura tudo).
|
||||
- Não criar sistema de avatares novo — só o modo denso do componente existente.
|
||||
- **Alinhamento dos avatares no grupo (adiado):** com poucos avatares a pilha fica centralizada no grupo (label à esquerda) — ancorar à esquerda mudaria o perfil de overflow para o grupo vizinho com 4+ avatares; revisitável se o product pedir (gatilho: review Impeccable do /simplify).
|
||||
- Não polir o rodapé de atualização nem outras arestas do card (item separado na intenção).
|
||||
- Não mexer em sheets, tooltips de desktop, busca/sugestões do omnibox.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Tailwind v4 não compilar `max-md:has-[.campaign-list-omnibox-form]:pt-0`:** verificar no build da Fase 1; fallback = regra CSS pura em `styles.css` (`@media (width < 48rem)` + `.campaign-content-scroll:has(.campaign-list-omnibox-form)`).
|
||||
- **e2e B193 regressar:** `card.getByText('Nível', { exact: true }).click()` — a label continua DENTRO do trigger (bloco label-acima), então o clique continua abrindo o sheet; `[data-view="mobile-cards"]` e classes de borda intactas.
|
||||
- **Janela estreita-desktop (container < 48rem) vê o card densificado:** aceito — é o MESMO componente mobile (B193 já o estiliza com variantes `md:` no article); o desktop de verdade (tabela) não é tocado.
|
||||
- **Listas com linha de ações antes do form** perdem o respiro no topo com o `pt-0`: mitigado pelo `pt-4 md:pt-0` nas 5 páginas; e2e de regressão no `/campanha/atividades` (barra colada após scroll).
|
||||
- **`has-` casar páginas além das listas:** o marcador só existe em `campaignListOmniboxFormClassName`, usado exclusivamente pelos 11 `*Filters` (verificado por grep); wizard usa forms sem `role="search"`/marcador.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [x] Aceite de produto da intenção ainda coberto (vão zero, barra ~40px, sem anel, nome 18px+território colado, chips label-acima, avatares 1 linha sem cap/wrap/"…"; desktop intacto)
|
||||
- [x] Invariantes AGENTS/engineering-standards (sem schema, sem access, sem Consent; nomes de props em inglês; copy pt-BR preservada)
|
||||
- [x] Testes previstos: e2e B196 (barra colada + altura + anel + nome/território + labels + overlap de avatares) + regressão B184/B183/B193 existentes; sem unit/int novos (mudança puramente de superfície)
|
||||
|
||||
Self-score decision-quality: 5/5 (decisões caras com rejeitadas; cabe no appetite; rabbit holes nomeados; reusa chassis existente — zero módulos novos; intenção preservada)
|
||||
|
|
@ -84,7 +84,7 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col gap-2 pt-4 sm:flex-row sm:justify-end md:pt-0">
|
||||
{isCampaignCoordinator(user) ? (
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/campanha/apoiadores/importar">
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag
|
|||
return (
|
||||
<CampaignPageShell>
|
||||
{canCreate ? (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<div className="flex flex-wrap justify-end gap-2 pt-4 md:pt-0">
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href={TOUR_COMPOSER_PATH}>
|
||||
<MapPinnedIcon data-icon="inline-start" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end pt-4 md:pt-0">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/demandas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end pt-4 md:pt-0">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/dobradinhas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end pt-4 md:pt-0">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/liderancas/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||
|
||||
return (
|
||||
<CampaignPageShell>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end pt-4 md:pt-0">
|
||||
<Button asChild className="min-h-11">
|
||||
<Link href="/campanha/organizacoes/nova">
|
||||
<PlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -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<Response> => {
|
||||
export const GET = async (request: Request, context: RouteContext): Promise<Response> => {
|
||||
const { secret } = await context.params
|
||||
|
||||
if (!secret || secret.length < 32) {
|
||||
|
|
@ -64,13 +77,7 @@ export const GET = async (_request: Request, context: RouteContext): Promise<Res
|
|||
|
||||
const icalContent = generateICalFeed(activities, feed.label, municipalityNames)
|
||||
|
||||
return new Response(icalContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
})
|
||||
return buildICalFeedResponse(icalContent, activities, feed, request)
|
||||
} catch {
|
||||
return notFoundResponse()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,9 +26,16 @@ export const advisorEntriesFromIds = (
|
|||
* overview's coluna da vergonha uses to count them. Non-priority ones keep the
|
||||
* softer pending tone: they are a gap, not a fire. It stands in for the advisor
|
||||
* names, so it only ever states an absence.
|
||||
*
|
||||
* B196: inside the mobile card's ~110px relation group the pill must wrap
|
||||
* (`whitespace-normal`, no fixed height) instead of clipping mid-letter —
|
||||
* on the wide desktop cell it still renders on a single line.
|
||||
*/
|
||||
export const MissingAdvisorBadge = ({ isPriority }: { isPriority: boolean }) => (
|
||||
<Badge variant={isPriority ? 'destructive' : 'estimate-pending'}>
|
||||
<Badge
|
||||
variant={isPriority ? 'destructive' : 'estimate-pending'}
|
||||
className="h-auto min-h-5 whitespace-normal text-center"
|
||||
>
|
||||
<CircleAlertIcon data-icon="inline-start" aria-hidden="true" />
|
||||
{isPriority ? 'Sem responsável' : municipalityListCoverageLabels.sem_assessor}
|
||||
</Badge>
|
||||
|
|
@ -49,14 +56,12 @@ export const formatAdvisorNamesTooltip = (
|
|||
export const MunicipalityAdvisorAvatarStack = ({
|
||||
advisors,
|
||||
isPriority,
|
||||
maxVisible = 3,
|
||||
wrap = false,
|
||||
overlapRow = false,
|
||||
}: {
|
||||
advisors: MunicipalityAdvisorAvatarEntry[]
|
||||
isPriority: boolean
|
||||
maxVisible?: number
|
||||
/** B193 — dense mobile card mode: every advisor gets an avatar, rows wrap. */
|
||||
wrap?: boolean
|
||||
/** B196 — dense mobile card mode: every advisor gets an avatar in one overlapping row. */
|
||||
overlapRow?: boolean
|
||||
}) => {
|
||||
if (!advisors.length) return <MissingAdvisorBadge isPriority={isPriority} />
|
||||
|
||||
|
|
@ -64,8 +69,7 @@ export const MunicipalityAdvisorAvatarStack = ({
|
|||
<MunicipalityRelationAvatarStack
|
||||
entries={advisors.map((advisor) => ({ id: advisor.id, label: advisor.name }))}
|
||||
emptyState={null}
|
||||
maxVisible={maxVisible}
|
||||
wrap={wrap}
|
||||
overlapRow={overlapRow}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,16 +81,35 @@ type MunicipalityMobileCardProps = {
|
|||
signalFormAction: MunicipalityStaffFormAction
|
||||
}
|
||||
|
||||
/** Dense chip trigger styling shared by the Tendência/Nível chips (B193). */
|
||||
const chipTriggerClassName = 'min-h-8 rounded-full px-2.5'
|
||||
/**
|
||||
* B196 — dense chip trigger styling shared by the Tendência/Nível chips:
|
||||
* the whole label+value block is the tap target, label above the pill
|
||||
* (gate B). `hover:bg-transparent` kills the base trigger's hover pill —
|
||||
* the block reads as label+value, not a button. `min-h-11` keeps the touch
|
||||
* target the plan's own `min-h-0` would have cut.
|
||||
*/
|
||||
const chipTriggerClassName =
|
||||
'flex min-h-11 flex-col items-start justify-center gap-0.5 rounded-md px-1 hover:bg-transparent'
|
||||
|
||||
/** Ghost trigger styling for the read-only relation groups (no hover pill). */
|
||||
const relationTriggerClassName = 'min-h-0 rounded-md bg-transparent px-0 hover:bg-transparent'
|
||||
/** Ghost trigger styling for the read-only relation groups (no hover pill).
|
||||
* `w-full` keeps the avatar overlap row constrained to the group's flex-1
|
||||
* box — without it the inline-block button grows with the avatars' min-content
|
||||
* and the stack spills into the neighbouring group (B196). */
|
||||
const relationTriggerClassName =
|
||||
'min-h-0 w-full rounded-md bg-transparent px-0 hover:bg-transparent'
|
||||
|
||||
const ChipLabel = ({ children }: { children: ReactNode }) => (
|
||||
<span className="text-xs font-medium text-muted-foreground">{children}</span>
|
||||
)
|
||||
|
||||
/** B196 (gate B) — one chip block: label above the value pill. */
|
||||
const ChipBlock = ({ label, children }: { label: string; children: ReactNode }) => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<ChipLabel>{label}</ChipLabel>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
/** Read-only city rows render a plain dash everywhere a control would sit. */
|
||||
const CityDash = () => <span className="text-muted-foreground">—</span>
|
||||
|
||||
|
|
@ -105,7 +124,7 @@ const RelationGroupTrigger = ({
|
|||
}) => (
|
||||
<div className="flex w-full min-w-0 flex-col gap-1.5">
|
||||
<ChipLabel>{label}</ChipLabel>
|
||||
<MunicipalityRelationAvatarStack entries={entries} emptyState={emptyState} wrap />
|
||||
<MunicipalityRelationAvatarStack entries={entries} emptyState={emptyState} overlapRow />
|
||||
</div>
|
||||
)
|
||||
|
||||
|
|
@ -180,13 +199,16 @@ export const MunicipalityMobileCard = ({
|
|||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'relative flex flex-col gap-3 rounded-none border-b p-4 last:border-b-0 md:rounded-xl md:border md:last:border-b',
|
||||
// B196 — denser vertical rhythm: gap-2.5 between blocks and py-3
|
||||
// (the horizontal p-4 keeps the B184 edge-to-edge bleed). Touch
|
||||
// targets (min-h-11 controls) are untouched.
|
||||
'relative flex flex-col gap-2.5 rounded-none border-b px-4 py-3 last:border-b-0 md:rounded-xl md:border md:last:border-b',
|
||||
isPriority && !isCity && 'border-r-[6px] border-r-primary',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<h3 className="flex items-center gap-1.5 font-medium">
|
||||
<h3 className="flex items-center gap-1.5 text-lg leading-tight font-medium">
|
||||
<Link
|
||||
href={`/campanha/municipios/${municipality.slug}`}
|
||||
className="rounded-md after:absolute after:inset-0 after:rounded-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:after:rounded-xl"
|
||||
|
|
@ -243,13 +265,16 @@ export const MunicipalityMobileCard = ({
|
|||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<TerritorialClassCardReadout municipality={municipality} />
|
||||
{/* B196 (gate B) — every chip is a label-above-value block; the row
|
||||
accepts 1–2 lines, but no chip ever breaks in the middle. */}
|
||||
<div className="flex flex-wrap items-start gap-x-3 gap-y-2">
|
||||
<ChipBlock label="Classe">
|
||||
<TerritorialClassCardReadout municipality={municipality} />
|
||||
</ChipBlock>
|
||||
{isCity ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ChipLabel>Tendência</ChipLabel>
|
||||
<ChipBlock label="Tendência">
|
||||
<Badge variant="outline">Não registrada</Badge>
|
||||
</span>
|
||||
</ChipBlock>
|
||||
) : (
|
||||
<MunicipalityListTrendControl
|
||||
municipalityID={municipality.id}
|
||||
|
|
@ -259,19 +284,20 @@ export const MunicipalityMobileCard = ({
|
|||
variant="sheet"
|
||||
triggerClassName={chipTriggerClassName}
|
||||
trigger={(trend) => (
|
||||
<>
|
||||
<ChipLabel>Tendência</ChipLabel>
|
||||
<ChipBlock label="Tendência">
|
||||
<Badge
|
||||
variant={trend.status ? politicalTrendBadgeVariant[trend.status] : 'outline'}
|
||||
>
|
||||
{trend.status ? politicalTrendLabels[trend.status] : 'Não registrada'}
|
||||
</Badge>
|
||||
</>
|
||||
</ChipBlock>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{isCity ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<ChipBlock label="Nível">
|
||||
<CityDash />
|
||||
</ChipBlock>
|
||||
) : canMoveEngagementLevel ? (
|
||||
<MunicipalityListLevelControl
|
||||
municipalityID={municipality.id}
|
||||
|
|
@ -282,21 +308,19 @@ export const MunicipalityMobileCard = ({
|
|||
variant="sheet"
|
||||
triggerClassName={chipTriggerClassName}
|
||||
trigger={(level, note) => (
|
||||
<>
|
||||
<ChipLabel>Nível</ChipLabel>
|
||||
<ChipBlock label="Nível">
|
||||
<MunicipalityLevelBadge level={level} note={note} layout="table" />
|
||||
</>
|
||||
</ChipBlock>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ChipLabel>Nível</ChipLabel>
|
||||
<ChipBlock label="Nível">
|
||||
<MunicipalityLevelBadge
|
||||
level={municipality.engagementLevel}
|
||||
note={municipality.levelNote}
|
||||
layout="table"
|
||||
/>
|
||||
</span>
|
||||
</ChipBlock>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -326,7 +350,7 @@ export const MunicipalityMobileCard = ({
|
|||
<MunicipalityAdvisorAvatarStack
|
||||
advisors={advisors}
|
||||
isPriority={isPriority}
|
||||
wrap
|
||||
overlapRow
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,6 @@ export const PeopleFilters = ({
|
|||
id="people-omnibox"
|
||||
label="Filtrar pessoas"
|
||||
placeholder="Buscar pessoa…"
|
||||
edgeToEdge
|
||||
chips={chips}
|
||||
suggestions={suggestions}
|
||||
query={query}
|
||||
|
|
@ -119,9 +118,9 @@ export const PeopleFilters = ({
|
|||
/>
|
||||
</form>
|
||||
{/* C100 mobile spec: "Salvar filtro" lives in the app header as an icon
|
||||
button; the trailing instance is desktop-only (the edge-to-edge
|
||||
omnibox hides its trailing cluster below `md`). Both instances share
|
||||
the same store, and only one is visible per breakpoint. */}
|
||||
button; the trailing cluster is desktop-only below `md` (the omnibox
|
||||
hides its container below the breakpoint). Both instances share the
|
||||
same store, and only one is visible per breakpoint. */}
|
||||
<SetCampaignHeaderAction id="save-people-filter">
|
||||
<SavePeopleFilterControl
|
||||
state={state}
|
||||
|
|
|
|||
|
|
@ -27,13 +27,6 @@ export type CampaignListOmniboxProps = {
|
|||
isPending?: boolean
|
||||
/** Domain controls beside the bar (e.g. save bookmark). */
|
||||
trailing?: ReactNode
|
||||
/**
|
||||
* C100: below `md` the trailing cluster (column picker, save bookmark) is
|
||||
* hidden — B184 already owns the mobile bar chrome (borderless field,
|
||||
* circle-X clear), and the page registers its own header controls instead.
|
||||
* `md:` restores the trailing cluster beside the framed desktop field.
|
||||
*/
|
||||
edgeToEdge?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -47,9 +40,15 @@ export type CampaignListOmniboxProps = {
|
|||
* containing block (its parent), and the form is the only wrapper whose parent
|
||||
* is the full-height `CampaignPageShell` — a sticky input column inside the
|
||||
* short omnibox row would only "stick" for its own height and then scroll away.
|
||||
*
|
||||
* `campaign-list-omnibox-form` is the stable marker `CampaignContentScroll`
|
||||
* matches (`:has()`) to drop its mobile top padding — only a page hosting the
|
||||
* list bar loses the `p-4` top gap, so the sticky's containing block starts at
|
||||
* the scrollport top and the bar glues to the header (B196). Without it the
|
||||
* sticky clamps 16px below the header and cards roll visibly through the gap.
|
||||
*/
|
||||
export const campaignListOmniboxFormClassName =
|
||||
'sticky top-0 z-20 -mx-4 border-b border-border bg-background px-4 py-2 ' +
|
||||
'campaign-list-omnibox-form sticky top-0 z-20 -mx-4 border-b border-border bg-background px-4 py-1 ' +
|
||||
'md:static md:z-auto md:mx-0 md:border-b-0 md:bg-transparent md:px-0 md:py-0'
|
||||
|
||||
/**
|
||||
|
|
@ -83,7 +82,6 @@ export const CampaignListOmnibox = ({
|
|||
onClearAll,
|
||||
isPending = false,
|
||||
trailing,
|
||||
edgeToEdge = false,
|
||||
}: CampaignListOmniboxProps) => {
|
||||
const reactId = useId()
|
||||
const id = idProp ?? `campaign-list-omnibox-${reactId}`
|
||||
|
|
@ -209,11 +207,14 @@ export const CampaignListOmnibox = ({
|
|||
<PopoverAnchor asChild>
|
||||
<div
|
||||
className={cn(
|
||||
// B184: below `md` the field is a borderless sticky bar; the
|
||||
// `md:` variants restore the framed desktop look.
|
||||
'flex min-h-11 w-full flex-wrap items-center gap-1.5 rounded-lg border-0 bg-transparent px-2 py-1.5 shadow-none',
|
||||
'md:border md:border-input md:shadow-xs',
|
||||
'focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50',
|
||||
// B184/B196: below `md` the field is a borderless dense bar
|
||||
// (min-h-8, no inner vertical padding → bar ≈ 40px total)
|
||||
// with NO focus ring — the caret is the focus indicator; the
|
||||
// `md:` variants restore the framed desktop field with its
|
||||
// ring/colored border as the focus affordance.
|
||||
'flex min-h-8 w-full flex-wrap items-center gap-1.5 rounded-lg border-0 bg-transparent px-2 py-0 shadow-none',
|
||||
'md:min-h-11 md:border md:border-input md:py-1.5 md:shadow-xs',
|
||||
'md:focus-within:border-ring md:focus-within:ring-3 md:focus-within:ring-ring/50',
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) inputRef.current?.focus()
|
||||
|
|
@ -280,7 +281,9 @@ export const CampaignListOmnibox = ({
|
|||
type="button"
|
||||
aria-label="Limpar"
|
||||
className={cn(
|
||||
'inline-flex size-11 shrink-0 items-center justify-center rounded-full',
|
||||
// B196 — size-8 keeps the bar at ~40px total; the field
|
||||
// itself is the real touch target (the X lives inside it).
|
||||
'inline-flex size-8 shrink-0 items-center justify-center rounded-full',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'md:hidden',
|
||||
!(hasChips || query.length > 0) && 'invisible pointer-events-none',
|
||||
|
|
@ -298,8 +301,8 @@ export const CampaignListOmnibox = ({
|
|||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="flex size-7 items-center justify-center rounded-full bg-muted text-muted-foreground transition-colors hover:bg-muted-foreground/20 hover:text-foreground">
|
||||
<XIcon className="size-4" aria-hidden />
|
||||
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground transition-colors hover:bg-muted-foreground/20 hover:text-foreground">
|
||||
<XIcon className="size-3.5" aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
|
|
@ -365,12 +368,11 @@ export const CampaignListOmnibox = ({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 flex-wrap items-center gap-2 md:pt-7',
|
||||
edgeToEdge && 'hidden md:flex',
|
||||
)}
|
||||
>
|
||||
{/* C100 — the trailing cluster is desktop-only: below `md` the page
|
||||
registers its own header controls, so the cluster never joins the
|
||||
mobile sticky bar (an empty container would also inflate its height
|
||||
— B196 keeps the bar ≈ 40px). */}
|
||||
<div className="hidden shrink-0 flex-wrap items-center gap-2 md:flex md:pt-7">
|
||||
{trailing}
|
||||
{onClearAll && hasChips ? (
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { campaignUserInitials } from '@/utilities/campaignUserProfile'
|
||||
|
||||
export type MunicipalityRelationEntry = {
|
||||
|
|
@ -17,29 +16,54 @@ export const MunicipalityRelationAvatarStack = ({
|
|||
entries,
|
||||
emptyState,
|
||||
maxVisible = 3,
|
||||
wrap = false,
|
||||
overlapRow = false,
|
||||
}: {
|
||||
entries: MunicipalityRelationEntry[]
|
||||
emptyState: ReactNode
|
||||
maxVisible?: number
|
||||
/**
|
||||
* B193 — dense card mode: every entry gets its own avatar (no cap) and the
|
||||
* row wraps to the available width instead of overlapping in a fixed stack.
|
||||
* B196 — dense card mode: every entry gets its own avatar (no cap), in ONE
|
||||
* row that never wraps. Each avatar sits centered in a flex-1 cell, so few
|
||||
* entries render apart (cell wider than the avatar) and many overlap
|
||||
* progressively — the overlap is proportional to the count, no avatar is
|
||||
* capped or hidden behind a "…", and the row always fills the group width.
|
||||
*/
|
||||
wrap?: boolean
|
||||
overlapRow?: boolean
|
||||
}) => {
|
||||
if (entries.length === 0) return emptyState
|
||||
|
||||
const visible = wrap ? entries : entries.slice(0, maxVisible)
|
||||
const visible = overlapRow ? entries : entries.slice(0, maxVisible)
|
||||
|
||||
if (overlapRow) {
|
||||
return (
|
||||
// `overflow-hidden` clips the pile at the group's box on extreme counts
|
||||
// (the sr-only names below still list everyone) — no slivers into the
|
||||
// neighbouring group.
|
||||
<div
|
||||
className="flex w-full min-w-0 items-center overflow-hidden"
|
||||
data-view="relation-avatars"
|
||||
>
|
||||
{visible.map((entry) => (
|
||||
<div key={entry.id} className="flex min-w-0 flex-1 justify-center">
|
||||
<Avatar className="size-7 shrink-0 border-0">
|
||||
{/* The full names ride in the sr-only span below (or the trigger's
|
||||
aria-label); initials alone would be announced twice. */}
|
||||
<AvatarFallback aria-hidden="true">
|
||||
{campaignUserInitials(entry.initialsLabel ?? entry.label)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
))}
|
||||
<span className="sr-only">{entries.map((entry) => entry.label).join(', ')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', wrap && 'w-full min-w-0 flex-wrap gap-1.5')}>
|
||||
<div className={cn('flex', wrap ? 'flex-wrap gap-1.5' : '-space-x-2')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-2">
|
||||
{visible.map((entry) => (
|
||||
<Avatar
|
||||
key={entry.id}
|
||||
className={cn('size-8 border-2 border-background', wrap && 'size-7 border-0')}
|
||||
>
|
||||
<Avatar key={entry.id} className="size-8 border-2 border-background">
|
||||
{/* The full names ride in the sr-only span below (or the trigger's
|
||||
aria-label); initials alone would be announced twice. */}
|
||||
<AvatarFallback aria-hidden="true">
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
|
|||
export const CampaignContentScroll = ({ children }: { children: ReactNode }) => (
|
||||
<div
|
||||
data-slot="campaign-content-scroll"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-[calc(4rem+env(safe-area-inset-bottom))] md:p-6 md:pb-6 print:h-auto print:overflow-visible print:p-0 [&::-webkit-scrollbar]:hidden [scrollbar-width:none]"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 pb-[calc(4rem+env(safe-area-inset-bottom))] md:p-6 md:pb-6 print:h-auto print:overflow-visible print:p-0 [&::-webkit-scrollbar]:hidden [scrollbar-width:none] max-md:has-[.campaign-list-omnibox-form]:pt-0"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import 'server-only'
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { allDayCivilDateOf, allDayExclusiveEndDate } from '@/lib/activityAllDay'
|
||||
import type { Activity, CalendarFeed } from '@/payload-types'
|
||||
import { advisorMunicipalityScopeWhere } from '@/utilities/access/shared'
|
||||
|
|
@ -9,6 +11,13 @@ const CALENDAR_NAME = 'Agenda Teqo'
|
|||
const FEED_LOOKBACK_DAYS = 90
|
||||
const FEED_LOOKAHEAD_DAYS = 365
|
||||
|
||||
// C113: the feed must never be the link that holds stale content — any shared
|
||||
// cache (CDN, subscriber proxies) may store the response but MUST revalidate
|
||||
// before reuse (`no-cache`). `X-PUBLISHED-TTL` is the iCal freshness hint
|
||||
// (RFC 7986; emitted in the VCALENDAR body — Google publishes it as PT1H).
|
||||
const FEED_CACHE_CONTROL = 'public, no-cache'
|
||||
const FEED_PUBLISHED_TTL = 'PT1H'
|
||||
|
||||
const escapeICalText = (value: string): string =>
|
||||
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<CalendarFeed, 'updatedAt'>,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'
|
|||
|
||||
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '../../src/lib/campaignConsentKeys.js'
|
||||
import { fallbackDemandTitle } from '../../src/lib/demandTitle.js'
|
||||
import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
|
||||
import {
|
||||
ensureLeasedConsent,
|
||||
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
|
||||
|
|
@ -1140,6 +1141,219 @@ test.describe('Municípios — card denso mobile (B193)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* B196 (2026-08-10): the mobile list bar is a chassis standard for every
|
||||
* list — glued to the app top bar with no gap (the sticky's containing block
|
||||
* starts at the scrollport top via `:has()`), dense (~40px total, no focus
|
||||
* ring below `md`), and the mobile card reads denser: 18px name with the
|
||||
* territory glued, label-above chips (Classe/Tendência/Nível — gate B), and
|
||||
* one overlapping avatar row per relation group (no wrap, no cap, no "…").
|
||||
*/
|
||||
test.describe('Municípios — barra colada e card denso (B196)', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
const mobileTopBar = (page: Page) => page.locator('[data-slot="campaign-mobile-top-bar"]')
|
||||
const contentScroll = (page: Page) => page.locator('[data-slot="campaign-content-scroll"]')
|
||||
const headerBottom = async (topBar: ReturnType<typeof mobileTopBar>) =>
|
||||
(await topBar.boundingBox())!.y + (await topBar.boundingBox())!.height
|
||||
// `?q=` matches name PREFIXES, so every card is anchored on the
|
||||
// municipality's own name link.
|
||||
const cardFor = (page: Page, name: string) =>
|
||||
page
|
||||
.locator('[data-view="mobile-cards"] article')
|
||||
.filter({ has: page.getByRole('link', { name, exact: true }) })
|
||||
.first()
|
||||
|
||||
test('bar is glued to the header, ~40px tall, no focus ring, nothing passes above on scroll', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora B196 Barra'),
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios`)
|
||||
// C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell.
|
||||
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
|
||||
|
||||
const topBar = mobileTopBar(page)
|
||||
const omniboxInput = page.getByRole('combobox', { name: 'Filtrar municípios' })
|
||||
const form = omniboxInput.locator('xpath=ancestor::form[1]')
|
||||
const field = omniboxInput.locator('..')
|
||||
await expect(form).toBeVisible()
|
||||
|
||||
// Glued: no gap between the app top bar and the filter bar.
|
||||
await expect.poll(async () => (await form.boundingBox())!.y).toBe(await headerBottom(topBar))
|
||||
|
||||
// Dense: the whole bar stays around 40px (32px field + py + border).
|
||||
await expect.poll(async () => (await form.boundingBox())!.height).toBeLessThanOrEqual(44)
|
||||
|
||||
// No focus ring: the field gains no shadow/border color on focus.
|
||||
const shadowBefore = await field.evaluate((el) => getComputedStyle(el).boxShadow)
|
||||
await omniboxInput.focus()
|
||||
await expect
|
||||
.poll(() => field.evaluate((el) => getComputedStyle(el).boxShadow))
|
||||
.toBe(shadowBefore)
|
||||
|
||||
// Scroll: the bar stays glued to the header — cards roll UNDER the
|
||||
// opaque bar, never above it.
|
||||
await contentScroll(page).evaluate((el) => el.scrollTo(0, el.scrollHeight))
|
||||
await expect.poll(async () => (await form.boundingBox())!.y).toBe(await headerBottom(topBar))
|
||||
await expect.poll(async () => (await form.boundingBox())!.height).toBeLessThanOrEqual(44)
|
||||
})
|
||||
|
||||
test('name is 18px with the territory glued; Classe/Tendência/Nível chips are label-above blocks', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora B196 Card'),
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(
|
||||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||||
)
|
||||
const card = cardFor(page, municipality.name)
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
// Smaller name (18px) with the territory glued right below it.
|
||||
const heading = card.getByRole('heading', { level: 3 })
|
||||
await expect(heading).toHaveCSS('font-size', '18px')
|
||||
const headingBox = (await heading.boundingBox())!
|
||||
const territory = card.locator('h3 + p')
|
||||
const territoryBox = (await territory.boundingBox())!
|
||||
expect(territoryBox.y - headingBox.y - headingBox.height).toBeLessThanOrEqual(6)
|
||||
|
||||
// Every chip is a label-above block: the value renders below the label
|
||||
// (gate B). "Classe" debuts as a label; Tendência/Nível keep theirs.
|
||||
for (const label of ['Classe', 'Tendência', 'Nível']) {
|
||||
const chipLabel = card.getByText(label, { exact: true })
|
||||
await expect(chipLabel).toBeVisible()
|
||||
const labelY = (await chipLabel.boundingBox())!.y
|
||||
const valueY = (await chipLabel.locator('xpath=following-sibling::*[1]').boundingBox())!.y
|
||||
expect(valueY).toBeGreaterThan(labelY)
|
||||
}
|
||||
})
|
||||
|
||||
test('relation groups: one row per group, overlap grows with the count', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora B196 Avatares'),
|
||||
})
|
||||
const fiveAdvisors = await Promise.all(
|
||||
Array.from({ length: 5 }, (_, i) =>
|
||||
fixtures.createCampaignUser('advisor', {
|
||||
name: fixtures.value(`Assessora B196 ${i + 1}`),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const twoAdvisors = await Promise.all(
|
||||
Array.from({ length: 2 }, (_, i) =>
|
||||
fixtures.createCampaignUser('advisor', {
|
||||
name: fixtures.value(`Assessora B196 dois ${i + 1}`),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const crowded = await fixtures.claimMunicipality()
|
||||
const sparse = await fixtures.claimMunicipality()
|
||||
await fixtures.payload.update({
|
||||
collection: 'municipality',
|
||||
id: crowded.id,
|
||||
data: { advisors: fiveAdvisors.map((advisor) => advisor.id) },
|
||||
depth: 0,
|
||||
})
|
||||
await fixtures.payload.update({
|
||||
collection: 'municipality',
|
||||
id: sparse.id,
|
||||
data: { advisors: twoAdvisors.map((advisor) => advisor.id) },
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
|
||||
// Five advisors: all on ONE row, each covering the start of the next —
|
||||
// the overlap is proportional to the count (no wrap, no cap, no "…").
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(crowded.name)}`)
|
||||
const crowdedCard = cardFor(page, crowded.name)
|
||||
await expect(crowdedCard).toBeVisible()
|
||||
const crowdedRow = crowdedCard.locator('[data-view="relation-avatars"]').first()
|
||||
await expect(crowdedRow.locator('.size-7')).toHaveCount(5)
|
||||
const crowdedBoxes = await crowdedRow
|
||||
.locator('.size-7')
|
||||
.evaluateAll((avatars) => avatars.map((a) => a.getBoundingClientRect()))
|
||||
expect(new Set(crowdedBoxes.map((box) => Math.round(box.y))).size).toBe(1)
|
||||
for (let i = 1; i < crowdedBoxes.length; i++) {
|
||||
expect(crowdedBoxes[i]!.x).toBeLessThan(crowdedBoxes[i - 1]!.x + crowdedBoxes[i - 1]!.width)
|
||||
}
|
||||
|
||||
// Two advisors: same single row, no overlap (few entries keep apart).
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(sparse.name)}`)
|
||||
const sparseCard = cardFor(page, sparse.name)
|
||||
await expect(sparseCard).toBeVisible()
|
||||
const sparseRow = sparseCard.locator('[data-view="relation-avatars"]').first()
|
||||
await expect(sparseRow.locator('.size-7')).toHaveCount(2)
|
||||
const sparseBoxes = await sparseRow
|
||||
.locator('.size-7')
|
||||
.evaluateAll((avatars) => avatars.map((a) => a.getBoundingClientRect()))
|
||||
expect(new Set(sparseBoxes.map((box) => Math.round(box.y))).size).toBe(1)
|
||||
expect(sparseBoxes[1]!.x).toBeGreaterThanOrEqual(sparseBoxes[0]!.x + sparseBoxes[0]!.width)
|
||||
})
|
||||
|
||||
test('chassis elsewhere: the actions row keeps its breathing room and the bar glues on scroll (atividades)', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora B196 Ativ'),
|
||||
})
|
||||
const municipality = await fixtures.claimMunicipality()
|
||||
// Enough rows to make the scrollport scrollable once the top bar takes over.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await fixtures.payload.create({
|
||||
collection: 'activity',
|
||||
data: hookFilledCreateData<'activity'>({
|
||||
title: `Atividade B196 ${fixtures.value('x')}`,
|
||||
status: 'confirmado',
|
||||
startAt: '2026-09-01T13:00:00.000Z',
|
||||
municipality: municipality.id,
|
||||
responsible: [{ relationTo: 'campaignUser', value: coordinator.id }],
|
||||
}),
|
||||
depth: 0,
|
||||
})
|
||||
}
|
||||
|
||||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||||
await page.goto(`${campaign.baseURL}/campanha/atividades`)
|
||||
|
||||
const topBar = mobileTopBar(page)
|
||||
const omniboxInput = page.getByRole('combobox', { name: 'Filtrar atividades' })
|
||||
const form = omniboxInput.locator('xpath=ancestor::form[1]')
|
||||
await expect(form).toBeVisible()
|
||||
|
||||
// On load the actions row (criar/planejar) breathes ABOVE the bar — the
|
||||
// bar is not glued yet; it glues once the row scrolls away.
|
||||
const headerBottomValue = await headerBottom(topBar)
|
||||
expect((await form.boundingBox())!.y).toBeGreaterThan(headerBottomValue)
|
||||
|
||||
// The scrollport must actually scroll for the sticky to engage.
|
||||
const scrollport = contentScroll(page)
|
||||
await expect
|
||||
.poll(() => scrollport.evaluate((el) => el.scrollHeight - el.clientHeight))
|
||||
.toBeGreaterThan(0)
|
||||
await scrollport.evaluate((el) => el.scrollTo(0, el.scrollHeight))
|
||||
await expect.poll(async () => (await form.boundingBox())!.y).toBe(headerBottomValue)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* B126: FAB quick-actions overlay — no persistent drawer; opens on demand.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {}) =>
|
||||
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('')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<typeof generateICalFeed>[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<string, string>) =>
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue