Compare commits

...

7 commits

Author SHA1 Message Date
Francisco Solla
449ef72190 fix(S42): corpo do card da home com min-w-0 (paridade com o artefato)
Some checks failed
Deploy / preflight (push) Has been cancelled
Deploy / verify (push) Has been cancelled
Deploy / deploy staging (teqo-staging) (push) Has been cancelled
Deploy / requeue when main advanced (push) Has been cancelled
Deploy / deploy production (teqo-1313) (push) Has been cancelled
Crítica visual (trigger c, tier gpt-5.6-sol): o título longo em 390 px
precisava da coluna do grid sem min-width intrínseco para quebrar como na
referência. Capturas da crítica certificada em
docs/plans/central-conteudos-secao-home-share-filtro-ui-design-critique/.
2026-09-30 01:09:07 -03:00
Francisco Solla
862da74fbe feat(S42): seção da Central compartilha a peça e abre /conteudos já filtrada
A seção S39 era só vitrine: quem via a peça do seu território tinha de ir
até o catálogo para compartilhar ou recortar. O card agora abre a MESMA
folha do S27 (mensagem de voto por tipo, contador C213, sem sair da home) e
as tags com faceta honesta — mais a fileira Tipo | Cidade | Região — levam
a /conteudos já filtrada pelo construtor canônico; nada filtra a amostra
local e as tags sem faceta (Mais recente, origem) continuam estáticas.

As facetas da fileira são derivadas no server (contentPieceCatalogFacets,
mesmo vocabulário do catálogo) e o lean ContentPieceHomeItem ganha só
sourceUrl, com a folha S27 estreitada ao que lê; classes novas são
home-scoped — CONTENT_PIECE_TAG e /conteudos ficam intactos. Sem migration,
Consent ou analytics novos.

Closes #1391
2026-09-30 01:09:07 -03:00
Francisco Solla
3201b7a93f feat(C237): vetor da fala inclui o sumário/keywords oficiais
A Fase 5 pós-deploy do C229 mediu o corpus real e o aceite 'impeachment'
falhava: a 222 (votação do impeachment de Dilma, 17/04/2016) ficava em #841
porque o índice só embutia o ASR — e a fala nunca pronuncia 'impeachment';
a relevância dela mora no sumário/keywords oficiais da Câmara.

O plano do índice passa a carregar metadataText (sumário — ou título no web
— + keywords, incluído no hash de idempotência) e o vetor da fala vira
normalize(média das unidades + 2·metadata), calibrado no corpus real
(w=2: 222 em #8; w=1: #25). A metadata é vetorizada no mesmo lote do
provedor mas não vira unidade armazenada nem evidência; sem migration.

Related #1395
2026-09-29 22:53:41 -03:00
Francisco Solla
5eb8fd75ef feat(C230): lote da Central — publicar rascunhos da janela e retirar pré-campanha
--publish-existing no content:instagram:import (publica rascunhos prontos da
janela, mais antigo primeiro) e CLI novo content:instagram:withdraw (despublica
por corte civil na Bahia, com as datas vindas do feed oficial). Plumbing
compartilhado dos dois em scripts/lib/instagramContentCli.mjs; teto do feed
passa ao da edge (10K) com early-stop por data; guardas C155/C195/C231, testes
unit das guardas e do planejador, runbook e changelog.
2026-09-29 22:51:25 -03:00
Francisco Solla
39933fc004 feat(C237-C239): colunas emparelhadas e motion determinístico no gerador de gráficos
- C237 (CENA 02F): --paired desenha duas colunas zero-based por categoria
  (inicial cinza, recente vermelha, valência positiva compartilhada), com
  legenda de períodos, valor sobre cada barra e rótulo sob o par; até 4
  categorias; design DEGRADED com sign-off humano de 2026-09-29.
- C238/C239 (CENAS 02G/02H): --motion gera MP4 H.264 local (6s @ 30fps,
  frames determinísticos por seek + ffmpeg do repo) para o par emparelhado,
  a coluna de 2 períodos e o número-âncora; C238 com sign-off humano de
  2026-09-30 e C239 aguardando gate.
- Superfície: CLI, SKILL.md, hi-fi e specs novos (chartMotion/
  chartMotionRender; SCRIPTS_SPEC_PINNED sincronizado).

Design tier: DEGRADED (inline, tier 4 — frontier sem quota).
2026-09-29 22:37:02 -03:00
Francisco Solla
10327a7451 feat(C236): publicar, despublicar e apagar em lote na Central de Conteúdos
A lista /campanha/comunicacao/conteudos ganha um modo de seleção opt-in
que envolve os dois corpos (tabela e card list) com um estado client
compartilhado, chaveado pela URL canônica (a seleção morre em
página/filtro e nunca cresce para além da página visível). Com seleção
ativa, a barra flutuante aplica o mesmo veredito unitário à seleção:
Publicar/Despublicar agem direto com recibo honesto; Apagar sempre
confirma no CampaignDeleteDialog reusado (novo caminho onConfirm
aditivo), avisando os links públicos só quando a seleção carrega
publicadas.

O transporte é uma rota única (POST .../conteudos/lote,
campaignJsonMutationRoute, teto de 50 ids) que itera server-side as ações
donas: cada peça mantém a própria transação, o gate fresco e a limpeza
best-effort de mídia; a falha de uma nunca reverte as demais e o recibo
separa afetadas x falhas (2 de 3 peças apagadas. 1 falhou.).

Sem migration, sem Consent novo, sem rota pública (S27) e sem mudar a
semântica do C222. Cobertura: int do lote (papéis, dedupe, mídia, evento,
tag, falha parcial, schema), unit do builder/barra/seleção e e2e HTTP.

Design tier: DEGRADED (artefato de origem sem certificação frontier) —
crítica do designer aplicada no escopo do item; entrega bloqueada para
sign-off humano antes do PR. Related #1388
2026-09-29 22:26:26 -03:00
Francisco Solla
ce055ad3d8 docs(plan): S42 — Home — seção da Central de Conteúdos: compartilhar a peça no card e filtro que abre /conteudos já filtrado (Related #1391) 2026-09-29 22:05:43 -03:00
89 changed files with 7819 additions and 315 deletions

View file

@ -15,10 +15,12 @@ contexto). É a **base visual confiável**, não a peça acabada.
O template visual é o artefato aprovado
`docs/plans/graficos-dados-instagram-ui-design.html` (base C203 aprovada — marca
e paleta oficiais do kit 1313 —, com as revisões C205 da linha de três séries e
C206 do rodapé/marca aguardando gate humano); o renderer
(`scripts/lib/graficosInstagramRender.mjs`) porta esse design classe-a-classe.
**Nunca invente estrutura visual** — o dono é o `designer`. O rodapé usa o
e paleta oficiais do kit 1313 —, com as revisões C205 da linha de três séries,
C206 do rodapé/marca e C207 da projeção **aprovadas no gate de 2026-09-19**, e a
revisão C237 das colunas emparelhadas **DEGRADED — aprovada por sign-off humano
em 2026-09-29, certificação do frontier pendente**); o renderer
(`scripts/lib/graficosInstagramRender.mjs`) porta esse design classe-a-classe. **Nunca invente estrutura visual** — o dono é o `designer`. O
rodapé usa o
ativo oficial `public/campaign-kit/jorge-solla-positivo.png` (nunca recriar
lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência melhor),
`#184e92` (sinal de marca, nunca dado), com amarelo/verde só dentro dos ativos.
@ -62,6 +64,14 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência melhor),
- **duas medidas por categoria → `delta`**: barra de variação com a base
(0 → inicial) num tom e a extensão (inicial → final) noutro, os dois valores
numa gutter de duas colunas e o vermelho fora do plot (sem valência);
- **duas medidas por categoria em pares → `delta --paired`** (revisão C237,
design DEGRADED): duas colunas verticais por categoria — a inicial em cinza
`#d6d3d1`, a mais recente no vermelho oficial `#e4102f` (o dado positivo é o
recente, compartilhado por todos os pares, sem vencedor) —, com legenda de
períodos, valor sobre cada barra e rótulo sob o par; até **4 categorias**
(acima disso os rótulos colidem — pergunte), `initial = 0` sem barra cinza,
`--highlight`/`--no-highlight`/`--dual-positive` recusados; com `--motion`
gera a versão animada (MP4, protótipo C238);
- **uma única medida → `anchor`** (número grande + frase que explica);
- **duas categorias, ambas boas notícias → `column` com `--dual-positive`**:
duas colunas verticais no vermelho oficial `#e4102f` — a valência boa é
@ -116,6 +126,14 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência melhor),
--source="Ministério da Saúde — CNES" \
--note="Sem variação: EMAD, EMAP, ENASF-AB e ECR."
```
Duas colunas por categoria (inicial cinza, recente vermelho; C237 DEGRADED):
```bash
node scripts/build-chart-from-data.mjs --in=<arquivo> --paired --size=feed \
--headline="Ortopedia cresce 4,1 vezes, neurocirurgia quadruplica e oncologia sai do zero" \
--subtitle="Leitos por tipo na rede estadual · cirurgia: 966 → 2.750" \
--source="DATASUS / Ministério da Saúde" \
--note="Oncologia: não havia leitos na rede estadual em 2006."
```
Duas categorias, ambos os resultados bons (dois tons de vermelho, sem hierarquia):
```bash
node scripts/build-chart-from-data.mjs --in=<arquivo> --type=column --size=feed \
@ -127,6 +145,23 @@ lockup tipográfico); a paleta oficial é `#e4102f` (destaque/valência melhor),
(gitignored) + o `chart-spec.json` em `data/graficos-instagram/`. `--size`:
`feed` (1080×1350, default), `square` (1080×1080), `story` (1080×1920, com
faixas seguras de 250px no topo e na base). `--out=` sobrescreve o caminho.
Colunas emparelhadas animadas (protótipo C238 — MP4 local, **6s @ 30fps**,
frames determinísticos + ffmpeg do repo; a peça assenta no estado estático
aprovado e segura ~2,4s até o fim):
```bash
node scripts/build-chart-from-data.mjs --in=<arquivo> --paired --motion --size=story \
--headline="Ortopedia cresce 4,1 vezes, neurocirurgia quadruplica e oncologia sai do zero" \
--subtitle="Leitos por tipo na rede estadual · cirurgia: 966 → 2.750" \
--source="DATASUS / Ministério da Saúde" \
--note="Oncologia: não havia leitos na rede estadual em 2006."
```
O motion cobre as **colunas emparelhadas** (C238), a **coluna de 2 períodos**
e o **número-âncora** (extensão C239): a coluna cresce da esquerda para a
direita (a recente vermelha por último, valor depois da barra) e a âncora
sobe o número e revela a frase — sem contagem dramática. O builder recusa
`--motion` nas demais variantes. Saída `.mp4` no mesmo diretório do PNG;
os frames em `data/graficos-instagram/<slug>-frames-<size>/` são temporários
e apagados no fim.
5. **Confira e ajuste.** A pessoa confere o PNG; ajuste tipo/rótulo/manchete/
tamanho/`--highlight` (ou `--good`/`--bad`/`--projected`/`--crossing`) e
regenere. **Replay** sem reparse: `--spec=<chart-spec.json>`.
@ -218,6 +253,11 @@ períodos da gutter (o vermelho não entra no plot e a variante não aceita
}
```
Nas **colunas emparelhadas** (C237, DEGRADED) o mesmo spec do delta carrega
`pairedColumns: true` e o renderer desenha duas colunas por categoria — inicial
cinza, recente vermelha — com a legenda de períodos; até 4 categorias, sem
`highlight` e sem `noHighlight`/`dualPositive`.
## Guardrails (fail-closed)
O builder **recusa** em vez de desenhar algo enganoso:
@ -235,6 +275,21 @@ O builder **recusa** em vez de desenhar algo enganoso:
retração (final < inicial) é recusada com a linha multi-série no lugar;
`--highlight`/valência não entram (o par de tons é fixo e o vermelho fica fora
do plot).
- **Colunas emparelhadas (`delta --paired`, C237)**: par fixo por categoria —
inicial `#d6d3d1`, recente `#e4102f` (dado positivo compartilhado, nunca
vencedor) —, base zero e até **4 categorias** (o rótulo de 30px sob o par
colide acima disso: pergunte); `initial = 0` sem barra cinza; legenda de
períodos + valor sobre cada barra + rótulo sob o par (**cor nunca sozinha**);
`--highlight`/`--no-highlight`/`--dual-positive` recusados. O design da
variante está **DEGRADED** (tier degradado por quota do frontier): aprovada
por sign-off humano em 2026-09-29, com a certificação do frontier pendente.
- **Motion (`--motion`, C238/C239)**: cobre as colunas emparelhadas, a coluna
de 2 períodos e o número-âncora (as demais variantes são recusadas); MP4
H.264 local (ffmpeg do repo), **6s @ 30fps**, frames determinísticos por seek
(nunca gravação em tempo real) e o estado final é exatamente o PNG aprovado;
sem contagem dramática na âncora. O motion do par (C238) está **DEGRADED**
com sign-off humano de 2026-09-30; a extensão (C239) segue **DEGRADED
aguardando gate humano**.
- **Comparação positiva dupla (`--dual-positive`)** exige `column`, exatamente 2
categorias e `feed`; recusa `--highlight` e `--no-highlight`; os tons seguem a
ordem de entrada (nunca o maior) e ambos leem como resultado bom — cor nunca
@ -278,9 +333,9 @@ O builder **recusa** em vez de desenhar algo enganoso:
valor e posição).
- **Dado de campanha nunca sai da máquina:** render 100% local, sem MCP/serviço
remoto por default.
- **Nada é commitado:** entrada, `chart-spec` e PNG vivem em
`data/graficos-instagram/` e `docs/research/graficos-instagram/`, ambos
gitignored (repo público).
- **Nada é commitado:** entrada, `chart-spec`, PNG/MP4 e os frames temporários
do motion vivem em `data/graficos-instagram/` e
`docs/research/graficos-instagram/`, ambos gitignored (repo público).
## Troubleshooting
@ -288,6 +343,13 @@ O builder **recusa** em vez de desenhar algo enganoso:
rótulo, valor não numérico, rótulo repetido. Pergunte à pessoa; não invente.
- **"N pontos (> 7)"** → agrupe/remova categorias com a pessoa. Na linha
multi-série o teto é 12 por série.
- **Colunas emparelhadas com mais de 4 categorias, `--highlight`,
`--no-highlight` ou `--dual-positive`** → o par de tons é fixo e o rótulo sob o
par colide acima de 4 pares: agrupe/remova categorias e tire o destaque; nunca
corte em top-N.
- **`--motion` recusado** → existe nas colunas emparelhadas, na coluna de 2
períodos e no número-âncora; barra/linha/etc. aguardam design de motion
próprio.
- **Séries desalinhadas / tom ímpar (par ou trio incompletos) / cruzamento na
linha de três séries / três séries fora do feed / cruzamento não confirmado /
projeção fora do último período** → o builder pede a correção com mensagem
@ -301,11 +363,11 @@ O builder **recusa** em vez de desenhar algo enganoso:
## Referências
- Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html` (inclui
a variação certificada de linha de duas séries, com valência bom/ruim; a
extensão C205 de três séries observadas; a revisão C207 da projeção no trio,
nova e aguardando gate humano; e a revisão C206 do rodapé/marca; base C203 —
marca e paleta oficiais do kit 1313).
- Design aprovado: `docs/plans/graficos-dados-instagram-ui-design.html` (base
C203 e revisões C205/C206/C207 aprovadas no gate de 2026-09-19; C237 das
colunas emparelhadas DEGRADED com sign-off humano de 2026-09-29; C238 do
motion do par DEGRADED com sign-off humano de 2026-09-30; C239 da extensão —
coluna de 2 períodos e âncora, CENA 02H — DEGRADED aguardando gate humano).
- Plano de intenção: `docs/plans/graficos-dados-instagram.md`; impl:
`docs/plans/graficos-dados-instagram-impl.md`.
- Marca oficial: `docs/plans/graficos-dados-marca-kit-1313.md` (impl:

View file

@ -0,0 +1 @@
**Central de Conteúdos — publicar, despublicar e apagar em lote (2026-09-29):** o kill switch morava ficha a ficha e apagar era uma confirmação por linha; o uso real chega em blocos. A lista `/campanha/comunicacao/conteudos` ganhou um modo de seleção opt-in (checkbox no topo da lista, coluna `mandatory` fora do seletor de colunas, tint `primary/5` na linha) que envolve os dois corpos da lista — tabela desktop e card list mobile — com **um** estado de seleção client; a seleção é só da página visível, morre ao trocar de página/filtro (o provider é chaveado pela URL canônica) e ids que saem da página deixam de contar. Com seleção ativa a barra flutua no rodapé (`sticky`, 16px acima do FAB no mobile, recuo à direita no desktop) com `N selecionada(s)` e `Publicar`/`Despublicar`/`Apagar`: os reversíveis agem direto, o irreversível sempre confirma no `CampaignDeleteDialog` reusado via um caminho `onConfirm` aditivo — o aviso de links públicos aparece só quando a seleção carrega publicadas (singular de uma peça reusa a copy do C222). O lote é o gesto unitário repetido: `POST .../conteudos/lote` (`campaignJsonMutationRoute`, teto de 50 ids) itera server-side as ações donas, cada item na própria transação e no próprio gate, e devolve `{ affected, failures[] }` — a falha de uma peça nunca reverte as demais e o recibo inline (cena 5 do design) nunca diz "3 apagadas" com falha: `2 de 3 peças apagadas. 1 falhou.` (singular/plural no builder puro, `aria-live` no sucesso e `role="alert"` na parcial). Invariantes intocados: access fail-closed da vertical, sem routine por linha nova, sem `Consent`, sem migration, sem rota pública (S27), `ContentEvent` preservado e a semântica do C222 (slug/`publishedAt`/arquivo preservados) reusada literalmente. Cobertura: int do lote (papéis, dedupe, mídia, evento, tag, falha parcial, schema), unit do builder/barra/seleção (incl. card frame) e e2e HTTP do lote. **Design tier: DEGRADED** (artefato de origem sem certificação frontier) — a crítica do designer contra o app renderizado aplicou a adaptação do FAB/reserva de rolagem/clamp/a11y e a Issue foi flipada para `blocked` aguardando sign-off humano antes do PR (screenshots em `docs/plans/central-conteudos-acoes-em-lote-ui-critique/`).

View file

@ -0,0 +1 @@
**C230-followup — ferramentas de lote da Central do Instagram: publicar rascunhos e retirar pré-campanha (2026-09-30):** o CLI `content:instagram:import` ganha `--publish-existing` (publica os rascunhos prontos já na janela — inclusive o que a rodada criou quando `--publish` não foi usado — do post mais antigo para o mais novo, para o mais recente liderar o listing público ordenado por `-publishedAt`) e o teto do feed deixa de ser 300 mídias para ser o da própria edge (10K, com early-stop por data): uma janela de 60 dias com ~350 mídias não é mais cortada em silêncio. Nasce o CLI irmão `pnpm content:instagram:withdraw --before YYYY-MM-DD [--scan-days 90] [--apply]`: despublica (kill switch — preserva slug, arquivo e `publishedAt`, reversível pela ficha) as peças cujo post original é anterior a uma data civil na Bahia, lida do feed oficial; peça que o feed não consegue datar nunca é tocada e é nomeada no recibo. O corte default não existe de propósito (fronteira legal é explícita): nas Eleições 2026 a propaganda começa em 16/08 (Lei 9.504/97, art. 36; Resolução TSE nº 23.760/2026), então a retirada pré-campanha foi `--before 2026-08-16`. A mecânica compartilhada dos dois CLIs (feed da janela com a credencial do global, token renovado best-effort, lookup chunkado `sourceUrl in` de 2.000 URLs e revalidação da Central pública) vive em `scripts/lib/instagramContentCli.mjs`; o planejamento puro (datas por shortcode, ordenação dos rascunhos, split pelo corte civil, recibos) ficou em `scripts/lib/instagramContentPlan.mjs`, com o boundary vindo do dono das datas civis (`contentPieceProfilePeriodBounds`). Guardas C155/C195/C231 nos dois: `--apply` exige o `TEQO_ENV` casando o nome exato do banco e a flag própria de intenção (`CONTENT_INSTAGRAM_IMPORT_CONFIRM`/`CONTENT_INSTAGRAM_WITHDRAW_CONFIRM`); a importação ainda exige as 4 `S3_*` (a retirada não escreve mídia). Em produção, no mesmo dia: o lote de 60 dias já importado sem publicar teve **325 rascunhos prontos publicados** (`--publish-existing`) e **71 peças com post anterior a 16/08/2026 retiradas** (`withdraw --before 2026-08-16`, posts de 31/07 a 15/08, 0 falhas, sem peças sem data), com revalidação da Central nas duas pontas. Testes: unit do planejador (datas por shortcode, ordem dos rascunhos, boundary 15/08 23:59 × 16/08 00:00 na Bahia, recibo) e das guardas dos dois CLIs em subprocesso (flag/TEQO_ENV/banco/argumentos/corte inválido). Runbook atualizado em `docs/ops/teqo-1313-deploy.md` (lote com `--publish-existing` e seção de retirada com o marco eleitoral). Sem migration, sem UI, sem Consent.

View file

@ -0,0 +1 @@
**C237/C238/C239 — Gráficos do Instagram: colunas emparelhadas e motion determinístico (2026-09-30):** a skill `graficos-dados` (C191/C203/C205/C206/C207) ganha o par de colunas por categoria e o motion das peças. (1) **Colunas emparelhadas (revisão C237, CENA 02F):** `--paired` sobre o dado da barra de variação (`--type=delta`) desenha duas colunas zero-based por categoria — a inicial em cinza `#d6d3d1` e a recente no vermelho oficial `#e4102f` (dado positivo compartilhado por todos os pares, sem vencedor) — com legenda de períodos (swatches 2006/2026), valor sobre cada barra e rótulo sob o par (cor nunca sozinha); até **4 categorias** (o rótulo de 30px sob o par colide acima disso e o builder pergunta), `initial = 0` sem barra cinza, `--highlight`/`--no-highlight`/`--dual-positive` recusados. Design DEGRADED (escrito inline no tier 4 por quota do frontier) aprovado por sign-off humano em 2026-09-29, com certificação do frontier pendente. (2) **Motion (revisões C238/C239, CENAS 02G/02H):** `--motion` gera MP4 H.264 local (**6s @ 30fps**, 180 frames determinísticos por seek das animações CSS + encode com o ffmpeg do repo — nunca gravação em tempo real), com o estado final idêntico ao PNG aprovado e hold estático de ~2,4s; o vocabulário é fixo (sweep/rise/fade/grow, easing único, `fill-mode: backwards`) e cobre o par emparelhado (C238 — pares da esquerda para a direita, barra com rótulo junto e valor depois; sign-off humano em 2026-09-30), a coluna de 2 períodos (C239 — uma coluna por vez, recente vermelha por último) e o número-âncora (C239 — número sobe e frase revela, sem contagem dramática); barra/linha/etc. recusam `--motion`. (3) **Superfície:** CLI (`--paired`, `--motion`), `SKILL.md`/command, hi-fi (CENAS 02F/02G/02H — CENAS 02C/C205/C206/C207 intactas) e specs novos (`chartMotion.mjs`/`chartMotionRender.mjs`; `SCRIPTS_SPEC_PINNED` sincronizado). Peças de validação: leitos por tipo na rede estadual (cirurgia geral 592→1.433, ortopedia 182→755, neurocirurgia 42→170, oncologia 0→134) e os demais gráficos do pronunciamento (leitos totais 3.905→7.343, internações 155.624→384.719, UTI total 179→1.536, leitos privados contratados 1.130) nos 3 tamanhos. Verificação: unit 5.002 verdes (specs do gerador estendidos), lint/typecheck limpos; sem migration.

View file

@ -0,0 +1 @@
**C237 — Acervo por tema: o vetor da fala passa a incluir o sumário/keywords oficiais (2026-09-30):** a Fase 5 pós-deploy do C229 mediu o corpus real e o aceite "impeachment → fala da votação de Dilma" falhava: a 222 (17/04/2016) ficava em #841 e a 216 (14/04/2016) em #82, enquanto 8 das 10 primeiras eram falas que sequer citam impeachment/impedimento (golpe, cassação, PGR/Temer) — o índice só embutia o ASR, e a 222 nunca pronuncia "impeachment"; a relevância dela mora no **sumário/keywords oficiais da Câmara**, que nunca entravam no vetor. Fix: `planSpeechIndex` ganha `metadataText` (sumário — ou título no web — + keywords cruas; `speechMetadataText`) e o **hash de idempotência inclui a metadata** (editar o sumário reindexa); o vetor da fala passa a ser `normalize(média_das_unidades + 2·metadata)` (`composeSpeechVector`, `SPEECH_METADATA_VECTOR_WEIGHT = 2` calibrado no corpus real: w=2 leva a 222 à #8 com o cluster do tema na frente; w=1 deixava em #25); a metadata é vetorizada no mesmo lote do provedor mas **não vira unidade armazenada nem evidência** (o "Trecho mais próximo do tema" continua falado) e não há migration. CLI `acervo:index` seleciona/passa `title`/`keywords` e o relatório ganha `(+N metadados)` ao lado dos trechos (dry-run e write), com testes unit do plano/hash/composição/relatório e int do ranking (fala cujo ASR não cita o tema vence pela metadata). Reindexar produção após o deploy (hash muda para toda fala com metadata; ~US$ 0,007 e ~31 min no corpus atual). Evidência da Fase 5 e aceite na Issue #1395.

View file

@ -0,0 +1 @@
**S42 — Home: a seção da Central compartilha a peça no card e abre `/conteudos` já filtrada (2026-09-30):** a seção S39 deixa de ser só vitrine: o card ganha `Compartilhar` (full width, artefato) que abre a **mesma** folha do S27 — mensagem de voto por tipo, `wa.me` sem destinatário e o contador anônimo C213 por peça, sem sair da home; as tags com faceta honesta (`Foto`/`Vídeo`/…, `Do seu município`, `Da sua região`) e a nova fileira `Tipo | Cidade | Região` viram links via `buildContentPieceCatalogHref` (`?tipo=|?cidade=|?regiao=`) para o catálogo já filtrado, e a tag da seção `⌖ Para seu município` faz o mesmo com o município do visitante; tags sem faceta (`Mais recente`, origem Instagram/YouTube) permanecem estáticas e **nada filtra a amostra localmente** (a fileira só monta URL — sem estado, sem resultado vazio). A fileira (`ContentPieceHomeFilterRow`) é presentacional e recebe as facetas derivadas no server por `contentPieceCatalogFacets` (mesmo vocabulário do catálogo; faceta sem opção não renderiza), com menus `<details>` que não estouram o viewport em 390/1280; o lean `ContentPieceHomeItem` ganha `sourceUrl` (a folha S27 teve a prop estreitada ao subconjunto que lê, sem tocar o catálogo) e as classes novas de tag/tag-link são home-scoped — `CONTENT_PIECE_TAG`/`CONTENT_PIECE_LOCAL_TAG` e `/conteudos` ficam byte-iguais. Sem migration, sem Consent/PII, sem analytics/localização novos; fail-closed (zero peças publicadas ⇒ seção some) intacto. Cobertura: unit novo da fileira, unit do board (folha aberta, hrefs canônicos, estáticas, amostra intacta) e pin da projeção, e2e da seção (share na home, tags, fileira aberta sem overflow e navegação para o catálogo já filtrado).

View file

@ -1517,11 +1517,16 @@ O comando `pnpm content:instagram:import` traz as mídias recentes do perfil
oficial `@depjorgesolla` para a Central pelo caminho oficial (Graph API da
própria conta; a credencial é a do global `Social Feed`): cada novidade vira
uma peça no pipeline C220 (download do arquivo do próprio perfil, transcrição e
catalogação) e, com `--publish`, é publicada ao fim do lote o que terminou
`pronto` — falha fica rascunho e é nomeada no recibo. Reexecutar converge
catalogação); com `--publish`, é publicada ao fim do lote o que esta rodada
terminou `pronto`, e com `--publish-existing` também entram os **rascunhos
prontos já na janela** (lote importado sem publicar), do post mais antigo para
o mais novo — o listing público ordena por `-publishedAt`, então o mais recente
lidera. Falha fica rascunho e é nomeada no recibo. Reexecutar converge
("novas / já estavam / falharam com motivo") e nunca duplica: a identidade é o
post, não a grafia da URL. Nada de scraping, terceiro ou stories; a mídia de
terceiro nunca é baixada (o match é dentro do feed da própria conta).
terceiro nunca é baixada (o match é dentro do feed da própria conta). A
varredura lê até o teto da própria edge (10K mídias) com early-stop por data —
a janela nunca é cortada por um teto arbitrário de páginas.
O dry-run é o padrão e não escreve (lê o feed e o banco):
@ -1531,21 +1536,47 @@ ssh homeserver
cd ~/stack
# 1) plano (dry-run; mostra janela, candidatos e peça-link previstos):
docker compose --profile maintenance run --rm \
-v /srv/content-instagram:/app/data/content-instagram \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:import --days 60
# 2) canário no alvo (exige TEQO_ENV + confirmação; as 4 S3_* são obrigatórias):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_IMPORT_CONFIRM=1 \
-v /srv/content-instagram:/app/data/content-instagram \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:import --apply --publish --limit 2
# 3) lote completo — rode em tmux: o processamento baixa, transcreve e cataloga
# cada peça em sequência (docker compose run sem -d segura a sessão SSH):
# --publish-existing fecha a lacuna de um lote que já entrou como rascunho.
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_IMPORT_CONFIRM=1 \
-v /srv/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:import --apply --publish --days 60
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:import --apply --publish --publish-existing --days 60
```
**Retirar peças anteriores a um corte** (ex.: antes do início da campanha
eleitoral). O comando despublica (kill switch: preserva slug, arquivo e
`publishedAt`; reversível pela ficha) as peças cujo **post original** é
anterior à data de corte — uma data civil na Bahia, a mesma do calendário
eleitoral. Nas Eleições 2026 a propaganda é permitida a partir de **16/08**
(Lei 9.504/97, art. 36; Resolução TSE nº 23.760/2026), então o corte é
`--before 2026-08-16`. Peça publicada que o feed não consegue datar nunca é
tocada: aparece no recibo como "sem data no feed".
```bash
# 1) plano (dry-run; lista o que sairia, o período e o que não tem data):
docker compose --profile maintenance run --rm \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:withdraw --before 2026-08-16
# 2) retirada (exige TEQO_ENV + confirmação):
docker compose --profile maintenance run --rm \
-e TEQO_ENV=production -e CONTENT_INSTAGRAM_WITHDRAW_CONFIRM=1 \
-v ~/content-instagram:/app/data/content-instagram \
teqo-1313-migrate pnpm content:instagram:withdraw --before 2026-08-16 --apply
```
`--scan-days <n>` (default 90) é só a profundidade da varredura do feed usada
para datar as peças publicadas; reexecutar converge (o que já é rascunho sai do
conjunto). O recibo nomeia cada peça retirada, o período e as falhas.
Os recibos JSON ficam em `data/content-instagram/` do container — monte o
volume acima para preservá-los. Com `--publish`, o CLI revalida a Central
pública ao final pela rota oficial de revalidação

View file

@ -0,0 +1,159 @@
# Impl: C236 — Central de Conteúdos — publicar, despublicar e apagar em lote
Status: aprovado (executado; entrega bloqueada aguardando sign-off do design DEGRADED)
Atualizado em: 2026-09-29
Issue: #1388
Intenção: docs/plans/central-conteudos-acoes-em-lote.md
Appetite restante: herdado (~1–1,5 dia eng) — o corte de produto (seleção só da página visível, sem job, sem agendamento, sem edição de campos) mantém o escopo dentro do appetite.
## Leitura da intenção
- **Outcome:** a assessoria seleciona várias peças **da página visível** na lista da Central (`/campanha/comunicacao/conteudos`) e aplica o **mesmo veredito unitário** — `Publicar`, `Despublicar` ou `Apagar` — num gesto, com recibo honesto (afetadas × falhas) e a lista refletindo o desfecho; a seleção morre ao trocar de página/filtro e pós-ação.
- **O que NÃO negociar:** o lote é o gesto unitário repetido, sem regra nova — elegibilidade `processando`/`falhou` incluída; `Publicar`/`Despublicar` agem sem confirmação (reversíveis) e `Apagar` sempre confirma avisando links públicos quando há publicadas; falha parcial é honesta e não reverte as demais; audiência `communicator|coordinator|candidate` (`advisor`/`leader` negados, fail-closed), sem papel novo; sem rota pública nova (S27), sem mudar a semântica do C222 (slug canônico/`publishedAt`/arquivo preservados no despublicar, mídia privada best-effort, `ContentEvent` nunca limpo); nenhum `Consent` novo.
- **O que reavaliar (hipóteses da intenção confirmadas no código):** o `CampaignTable` é server e não tem API de seleção (`CampaignTable.tsx:27-63,155-224`) — a seleção entra por ilha client, não por mudança de shell; a lista é renderizada 2× por breakpoint (card/tabela) — um único estado compartilhado via provider; a ação unitária já concentra semântica e transação (reuso literal, não reinterpretação).
## Abordagem recomendada
```mermaid
flowchart LR
Page["page.tsx (RSC)<br/>conteudos"] --> Provider["ContentPieceSelectionProvider<br/>(client: modo, ids, pending, receipt)"]
Provider --> Mode["ContentPieceSelectionModeControl"]
Provider --> Table["ContentPieceTable (RSC)<br/>coluna seleção oculta fora do modo"]
Provider --> Cards["ContentPieceCardList (RSC)"]
Table --> Boxes["ContentPieceRowCheckbox /<br/>ContentPieceSelectAllCheckbox"]
Cards --> Frame["ContentPieceCardSelectionFrame"]
Provider --> Bar["ContentPieceSelectionBar"]
Bar -->|Publicar / Despublicar| BatchRoute["POST .../conteudos/lote<br/>campaignJsonMutationRoute"]
Bar --> DeleteDialog["CampaignDeleteDialog<br/>(confirm = batch)"]
DeleteDialog -->|Apagar| BatchRoute
BatchRoute --> BatchAction["applyContentPieceBatchActionForActor<br/>loop por item, transação por item"]
BatchAction --> PublishAction["setContentPiecePublishedForActor"]
BatchAction --> DeleteAction["deleteContentPieceForActor"]
BatchAction --> Outcome["outcome { action, affected, failures }"]
Outcome --> Receipt["ContentPieceBatchReceipt<br/>(inline, status/alert)"]
Outcome --> Refresh["clear() + router.refresh()"]
```
**Opções consideradas:** A | B | C
**Recomendação:** **B** — uma rota única `POST .../conteudos/lote` que itera server-side reusando as ações unitárias, cada item na própria transação, e devolve um recibo estruturado. Reuso literal da semântica (slug/`publishedAt`/arquivo/mídia/tag), teto de lote fixado em schema (max 50, precedente `contentPieceStatusRequestSchema`), recibo e falha parcial testáveis no int, transporte único pelo wrapper `campaignJsonMutationRoute`.
**Rejeitadas:** **A** (cliente dispara N fetches unitários) porque espalha no cliente o laço, o recibo e a agregação de falhas, faz N round-trips e não fixa o teto do conjunto em nenhum contrato; **C** (transação única do lote) porque a intenção fixa falha parcial honesta — uma falha não pode reverter as demais —, prolongaria a transação e mudaria a semântica pós-commit da mídia privada.
### Decisões de engenharia (caras de reverter)
#### 1. Transporte do lote
**Opções:** A) o cliente dispara N fetches às rotas unitárias existentes | B) rota nova única `POST .../conteudos/lote` com `{ action, contentPieceIds }` iterando server-side o reuso das ações unitárias | C) transação única para o lote.
**Recomendação:** **B** — a rota entra por `campaignJsonMutationRoute` (guard de convenção em `tests/unit/codebaseConventions.unit.spec.ts:251`), o schema fixa o teto (`.min(1).max(50)`), e a action `applyContentPieceBatchActionForActor` chama `setContentPiecePublishedForActor` / `deleteContentPieceForActor` **uma vez por id** — cada chamada mantém sua própria transação (`withPayloadTransaction`), o gate fresco e a limpeza de mídia pós-commit (`contentPieces.ts:163-189,239-278`). O recibo (`affected` + `failures[]`) nasce no domínio e é testável no int; o cliente só traduz para os literais do design.
**Alternativas rejeitadas:** A porque duplica no cliente o laço/agregação, multiplica round-trips (até 25) e não tem contrato de teto nem recibo de domínio; C porque contraria a decisão de produto de falha parcial honesta, cria transação longa e conflita com o `onPayloadTransactionCommit` da mídia por item.
**Detalhe de execução:** o loop é sequencial (determinístico, gentil com o banco; 25 itens máx. da página). Se a latência do lote doer (medida no e2e), o gatilho de revisitação é paralelizar com `Promise.allSettled` mantendo o mesmo outcome — barato e local.
#### 2. Estado de seleção compartilhado (tabela + card list)
**Opções:** A) um client provider via `children` (RSC preservado), com estado de ids, modo, pending e recibo, resetado por `key` da URL canônica da lista | B) Context API com row registry (cada linha registra/desregistra no provider) | C) estado no servidor via URL (`?sel=1,2,3`).
**Recomendação:** **A** — `ContentPieceSelectionProvider` (client) recebe uma projeção serializável das rows (`{ id, status, publicPath }`) e envolve os dois corpos renderizados pela RSC (`ContentPieceCardList` e `ContentPieceTable`), que continuam server; as ilhas de checkbox consomem o contexto. O provider é chaveado pela URL canônica da lista (`buildContentPieceListHref(data.state, data.state.page)`), então trocar página/filtro **remonta** e zera seleção, modo e recibo. Pós-ação, o provider chama `clear()` e `router.refresh()`. O poll de status (`ContentPieceStatusRefresher.tsx:48`) refresca sem mudar a key — a seleção sobrevive a uma renovação de status, que é o comportamento correto (os ids seguem válidos).
**Alternativas rejeitadas:** B porque é maquinaria de sincronização sem necessidade (as rows já chegam como prop) e dificulta "selecionar todas" e o aviso de publicadas; C porque contraria "a seleção morre ao navegar", polui o contrato da URL canônica com ordenação/limites e transforma cada checkbox numa navegação com re-render do servidor.
#### 3. Coluna de seleção no `CampaignTable` server
**Opções:** A) coluna definida sempre em `columns` com `mandatory: true`, head/cell como ilhas client que leem o contexto, e a coluna nascendo oculta por CSS (`data-selection-mode` no root do provider + variantes `group-data-[selection-mode=true]:table-cell`); a lista do column picker **não** ganha a coluna | B) checkbox permanente, sem modo | C) dar API de seleção ao `CampaignTable` (ou torná-lo client).
**Recomendação:** **A** — o shell da tabela não muda (`CampaignTable.tsx` segue server e data-driven); `mandatory: true` protege contra cookie antigo (`campaignColumnVisibility.ts:184-192`) e a coluna não entra no picker em `page.tsx:60-66` porque é chrome do modo de seleção, não dado. O tint da linha selecionada usa `rowClassName: 'has-[[data-selection-state=selected]]:bg-primary/5'` (o `has-[...]` já é usado em `ui/Table.tsx:57,67` e `ui/field.tsx`), e a ilha marca `data-selection-state` no seu root.
**Alternativas rejeitadas:** B porque contraria o design aprovado (repouso sem ruído) e a questão de produto A da intenção; C porque acopla um concern de uma superfície ao sistema de tabela compartilhado — a seleção é local da Central, não do shell (depth errado).
#### 4. Barra de ações e confirmação de apagar
**Opções:** A) barra client dentro do provider (`sticky bottom-4 z-20`, classes do artefato) e confirmação reusando `CampaignDeleteDialog` estendido de forma **aditiva** com um caminho `onConfirm`/`onSuccess` (union: `endpoint` XOR `onConfirm`) | B) criar um `ContentPieceBatchDeleteDialog` irmão, duplicando o chrome | C) montar o `AlertDialog` inline na barra.
**Recomendação:** **A** — a barra é a única dona do gesto (contagem, botões, pending, recibo) e o diálogo continua com **um só dono de chrome/estado** (`CampaignDeleteDialog.tsx:54-127`: ícone circular destrutivo, rodapé `muted/50`, `Cancelar` outline, `Apagar` vermelho cheio, erro dentro do diálogo, spinner/disabled). A extensão não toca os 3 call sites atuais (cut, recording, peça): `onConfirm: () => Promise<{ ok: true } | { ok: false; message: string }>` e `onSuccess?: () => void`; no caminho novo, o machine chama `onConfirm()` em vez do DELETE por endpoint e, no sucesso, mantém o `router.refresh()` e chama `onSuccess`. A barra computa título/descrição: `1` selecionada reusa o C222 (`Apagar esta peça?` + link nomeado quando publicado); `N > 1` usa `Apagar N peças selecionadas?` com o aviso de publicadas × só "não pode ser desfeita".
**Alternativas rejeitadas:** B porque duplica um machine extraído justamente para não ter a 4ª cópia (C183→C199→C222); C porque recopia chrome, submitting/erro/disabled que o machine já owns.
#### 5. Recibo e estado de erro
**Opções:** A) banner inline no topo da lista (ilha client no provider; `role="status"`/`aria-live` no sucesso, `role="alert"` na falha parcial) | B) toast sonner (`useCampaignFormSuccessToast`, layout `(campaign)/layout.tsx:52`) | C) mensagem dentro da barra.
**Recomendação:** **A** — o design do gate (cena 5) fixa o recibo inline no topo da lista, nunca sobre ela; o precedente sonner é de form action, não de lista, e um `role="alert"` de falha parcial precisa ficar no contexto da lista, não num canto transitório. Sucesso some sozinho (~6 s) ou no X; falha parcial/erro fica até fechar ou até a próxima ação. Os literais saem de um builder puro (singular/plural seguindo `TourComposerForm.tsx:180` e o literal do plano): `3 peças publicadas`, `1 peça despublicada`, `2 de 3 peças apagadas. 1 falhou.`; a UI não renderiza mensagem por peça (fora do design), mas o outcome carrega `failures[]` para teste e diagnóstico. Erro de rota no `Publicar`/`Despublicar` vira banner de erro e **mantém** a seleção (nada mudou); no `Apagar` o erro fica dentro do diálogo (como no C222).
**Alternativas rejeitadas:** B porque o design fixa o recibo inline e o toast some sozinho — a falha parcial não pode ser silenciosa; C porque a barra sai de cena quando a seleção zera e o recibo precisa sobreviver à limpeza da seleção.
#### 6. Migração, access e Consent
**Opções:** A) sem migration, reusando access existente e o gate fresco por ação | B) migration nova | C) mecanismo de consentimento/auditoria novo.
**Recomendação:** **A** — nenhuma coleção/campo muda (o lote só chama ações existentes), nenhuma `Consent` nova, nenhuma role nova. `applyContentPieceBatchActionForActor` repete o gate fresco (`canReadCommunicationCatalog`) **antes** do loop — role/auth negados falham a requisição inteira (400/401), não viram falha parcial — e cada item reusa a ação que re-checa o gate e termina na collection access (`canUpdateContentPiece`/`canDeleteContentPiece`, `utilities/access/contentPieces.ts:40-58`) como barreira final.
**Alternativas rejeitadas:** B por desnecessária (nenhum schema); C por sair do escopo e do invariante "nada de Consent novo".
### Componentes / mudanças
- **`ContentPieceSelectionProvider`** (`src/components/campaign/content/ContentPieceSelectionProvider.tsx`, novo): contexto client com `selectionMode`, `selectedIds` (`number[]` + `Set` memoizado, precedente `TourComposerForm.tsx:137-141`), `pendingAction`, `receipt`; ações `enter/exit/toggle/toggleAll/clear/applyBatch`; `resetKey` por `key`; transporta o lote com `postCampaignJson` (`lib/campaignJsonRequest.ts:12-26`) e orquestra `clear()` + `router.refresh()`.
- **`ContentPieceSelectionControls`** (`.../ContentPieceSelectionControls.tsx`, novo): ilhas `ContentPieceSelectionModeControl` (botão `Selecionar` × badge `Modo de seleção` + `Cancelar seleção`), `ContentPieceRowCheckbox` (tabela, com `data-selection-state`), `ContentPieceSelectAllCheckbox` (`indeterminate`), `ContentPieceCardSelectionFrame` (envelope client do card mobile com checkbox 44 px + `ring-1 ring-primary/25`; corpo do card continua RSC via `children`).
- **`ContentPieceSelectionBar`** (`.../ContentPieceSelectionBar.tsx`, novo): barra com `N selecionada(s)`, `Publicar`/`Despublicar`/`Apagar`, pending (`aria-busy`), consumindo o provider; monta o `CampaignDeleteDialog` do batch.
- **`ContentPieceBatchReceipt`** (`.../ContentPieceBatchReceipt.tsx`, novo): banner inline do recibo (cena 5 do artefato).
- **`CampaignDeleteDialog`** (`src/components/campaign/shared/CampaignDeleteDialog.tsx`): extensão aditiva do union `endpoint` XOR `onConfirm` + `onSuccess` (decisão 4); call sites atuais intocados.
- **`ContentPieceTable`** (`src/components/campaign/content/ContentPieceTable.tsx`): coluna `selection` `mandatory` com head/cell ilhas, CSS de modo, `rowClassName` de tint; nenhuma mudança no `CampaignTable`.
- **`ContentPieceCardList`** (`src/components/campaign/content/ContentPieceCardList.tsx`): card envolvido pelo frame client de seleção (sem mudar o conteúdo RSC).
- **`page.tsx`** (`src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx`): provider em volta dos dois corpos + mode control + recibo + barra; projeção `selectionRows` e `resetKey` calculados da `data.state`; coluna `selection` fora do `toCampaignColumnPickerColumns`.
- **`contentPieceBatch`** (`src/lib/contentPieceBatch.ts`, novo): vocabulário `publicar|despublicar|apagar`, labels e builder puro do recibo (singular/plural/parcial).
- **Schema** (`src/lib/schemas/contentPiece.ts`): `contentPieceBatchRequestSchema` (`action` enum + `contentPieceIds` `.min(1).max(50)`, precedente `:133-135`).
- **Action** (`src/app/(campaign)/campanha/actions/contentPieces.ts`): `applyContentPieceBatchActionForActor` com gate fresco, loop sequencial, `mapCampaignFormActionError` + `CONTENT_PIECE_SAFE_MESSAGES` para mascarar falha por item.
- **Rota** (`src/app/(campaign)/campanha/(app)/comunicacao/conteudos/lote/route.ts`, novo): `POST = campaignJsonMutationRoute(...)` devolvendo `{ status: 'success', outcome }`; convive com `[id]` como `enviar`/`status`/`link` já convivem. Sem rota DELETE nova.
- **Contratos/wire** (`.../conteudos/types.ts`): `ContentPieceBatchResponse`/`ContentPieceBatchOutcome`/`ContentPieceBatchFailure`; **paths** (`src/lib/campaignPaths.ts`): `CAMPAIGN_CONTENT_PIECE_BATCH_HREF`.
- **Migration:** N/A — nenhuma coleção/campo/índice muda; nenhuma migration criada (skill `payload-migrations` não é acionada).
- **Access / Consent:** sem mudança de access; sem `Consent` novo; fail-closed preservado.
- **UI:** Impeccable **B** — encaixe na lista existente. Port **classe-a-classe** do artefato DEGRADED `docs/plans/central-conteudos-acoes-em-lote-ui-design.html`; shells reusados: `CampaignPageShell`, `CampaignListPendingBoundary/Results`, `CampaignListFooter`, `CampaignTable`/`CampaignTableHead`, `AlertDialog`, `Button`, `Checkbox`, `Spinner`, badges. Classes-chave a preservar: barra `bar-shadow flex items-center justify-between gap-4 rounded-xl border border-border bg-card px-3 py-2.5` (mobile `p-3` com `grid grid-cols-3 gap-2`); contador `text-sm font-medium tabular-nums`; `Publicar` `bg-primary text-primary-foreground`, `Despublicar` outline `hover:bg-muted`, `Apagar` `bg-destructive/10 text-destructive hover:bg-destructive/20`; checkbox `size-4 rounded-[4px]` com hit-area e alvo `size-9` (tabela)/`size-11` (card); linha `bg-primary/5`; card `ring-1 ring-primary/25`; badge do modo `bg-primary/10 text-primary`; recibos `rounded-lg border` com check verde só no ícone e `border-destructive/40` na falha; `min-h-11` em todo alvo; `motion-reduce:animate-none` no spinner. **Sign-off humano obrigatório no PR** (design DEGRADED).
- **Testes:** int em `tests/int/contentPiece.int.spec.ts` (novo describe de lote), unit novos `tests/unit/contentPieceBatchReceipt.unit.spec.ts` e `tests/unit/contentPieceSelection.unit.spec.tsx` (template `contentPieceDeleteDialog.unit.spec.tsx`: mock de `next/navigation` + fetch), e2e no describe de content pieces de `tests/e2e/campaignSpeechAcervo.e2e.spec.ts`.
### Dados → forma (se aplicável)
- **Não se aplica** — a intenção fixa "não apresento dados": nenhum painel, métrica, dashboard ou telemetria. A forma do recibo (banner inline com contagens do outcome) é a da cena 5 do design; o número é o resultado da própria ação, não leitura agregada nova.
## Fases verificáveis
1. **Tracer / server (schema → action → rota)** — quota ~0,5 dia. `contentPieceBatchRequestSchema` + `contentPieceBatch` (builder do recibo) + `applyContentPieceBatchActionForActor` + `lote/route.ts` + `CAMPAIGN_CONTENT_PIECE_BATCH_HREF` + tipos do wire; int cobrindo matriz de papéis (communicator/coordinator/candidate/admin × advisor/leader), publicar/despublicar em lote preservando slug/`publishedAt`/arquivo, apagar em lote derrubando mídia (best-effort) e mantendo `ContentEvent`, falha parcial honesta (`affected` + `failures` com `NOT_FOUND`), teto/limite do schema e bust da tag `contentPieces`; e2e de rota (HTTP) com sucesso, falha parcial e negado.
2. **UI** — quota ~0,5–0,75 dia. Provider + mode control + ilhas de checkbox (tabela e card) + coluna `mandatory` com CSS de modo + tint + barra + extensão aditiva do `CampaignDeleteDialog` + recibo; unit de seleção (modo, contagem `1 selecionada`/`N selecionadas`, POST com `{ action, contentPieceIds }`, limpeza pós-ação, recibo de sucesso e parcial, diálogo plural/singular com aviso de publicadas, erro de rota mantendo seleção) e unit do builder do recibo; port classe-a-classe revisado contra o artefato.
3. **Gates** — `pnpm gate:fast`; push via `pnpm push` (o CI PR roda o cascade curado; o deploy roda a suíte full). Sign-off humano do design DEGRADED no PR.
## Rabbit holes / Não escopo (engenharia)
- Seleção cross-page/"todos os filtrados", dedupe, contagem global — corte explícito da intenção (só a página visível; 25).
- Seleção persistente em `localStorage`/cookie ou modo de seleção sobrevivendo à navegação — a key da URL zera tudo de propósito.
- Marcar as peças que falharam como "retry da seleção", progresso por item, fila/retomada/cancelamento — o recibo é uma frase, não um painel.
- Toast e banner ao mesmo tempo; auditoria/telemetria de quem agiu; undo/lixeira.
- Otimismo nas rows (atualizar a linha antes do refresh) — o refresh do RSC é a verdade; o estado de envio cobre o feedback.
- "Selecionar todas" também no card list mobile (o design não mostra; a barra mobile não ganha header de seleção).
- Adicionar API de seleção ao `CampaignTable` ou converter a tabela em client — o shell fica intocado.
- Migrations ("já que mexe na lista, ajusta X"), novos campos em `contentPiece`, `Consent` novo, mudar o gesto unitário (slug/`publishedAt`/arquivo/mídia/`ContentEvent`) ou as rotas públicas S27.
- Editar `docs/plans/central-conteudos-acoes-em-lote.md` (intenção) ou o design HTML.
- **Adiado com gatilho (triage /simplify):** a frase singular de apagar ("O link público `<path>` deixa de funcionar… Esta ação não pode ser desfeita.") é paralela entre a barra de lote e o `ContentPieceDeleteDialog` (C222) — o dono único da copy sai quando houver um 3º call site ou quando a copy/design do aviso mudar (DRY de 2 sites não vira item).
- **Explicitamente fora (triage /simplify):** sombra da barra como valor arbitrário (port classe-a-classe do `bar-shadow` do artefato; tokenização só se a regra do DESIGN.md §7 for aprovada no sign-off do PR); ids obsoletos no estado do provider (filtrados por `visibleSelectedIds` em todo consumidor); esconder o entry "Selecionar" com lista vazia já resolvido (`page.tsx` condiciona a `data.rows.length > 0`).
## Riscos e mitigação
- **Estado client sobrevive a refresh/navegação** — a key do provider é a URL canônica da lista (`page`+filtros): navegação remonta e zera; o refresh pós-ação/poll não muda a key (seleção pós-ação é limpa por `clear()`; renovação de status preserva os ids válidos). Risco residual: o poll renovar rows durante a seleção — aceitável e coberto por testar que a seleção sobrevive ao refresh de mesma key.
- **Dois corpos (tabela + card) com um estado** — provider único envolvendo os dois; cada corpo renderiza sua ilha; teste unitário garante que marcar no card reflete na barra e no select-all (e vice-versa) — na prática o mesmo contexto.
- **`CampaignTable` server sem API de seleção** — coluna estática + CSS `data-selection-mode` + ilhas; `mandatory` contra cookie; `ui/Table` já remove `pr-0` quando há `role=checkbox`. Se um dia o shell ganhar seleção nativa, revisitar (barato).
- **Falha parcial silenciosa** — o outcome sempre separa `affected` de `failures` e o recibo nunca diz "N apagadas" com falha; o literal parcial é o do plano (`2 de 3 peças apagadas. 1 falhou.`); `role="alert"` na parcial.
- **Mídia privada best-effort / `ContentEvent` intocado** — o lote não toca nesses caminhos: reusa `deleteContentPieceForActor` (transação + `onPayloadTransactionCommit` + `overrideAccess` intencional só da mídia) e não faz nenhuma escrita em `contentEvent`.
- **Latência de até 25 itens em loop sequencial** — aceitável para um gesto de mesa com teto da página; gatilho de revisitação: se o e2e/int acusar latência, paralelizar com `Promise.allSettled` mantendo o outcome.
- **Convenção de rotas** — POST novo usa `campaignJsonMutationRoute` (guard `codebaseConventions:251`); nenhuma rota DELETE nova (guard `:271` fica satisfeito por não existir).
- **Sessão/role expirada no meio do lote** — gate e auth são checados antes do loop; negado vira 400/401 inteiro, não falha parcial disfarçada.
- **Dupla renderização a11y** — o corpo oculto por breakpoint sai da árvore de acessibilidade (`display:none`); labels dos checkboxes incluem o título da peça (`Selecionar <título>`), select-all `Selecionar todas as peças desta página`.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: seleção por página nos dois corpos; barra só com seleção; `Publicar`/`Despublicar` sem confirmação e `Apagar` sempre confirmando (com aviso de publicadas); recibo com afetadas × falhas e sem sucesso parcial silencioso; elegibilidade idêntica ao unitário; seleção morre em página/filtro e pós-ação; audiência `communicator|coordinator|candidate` fail-closed; nada de rota pública.
- [ ] Invariantes AGENTS/engineering-standards: Local API sempre com `user` + `overrideAccess: false` (herdado das ações reusadas); escritas multi-collection transacionais com `req` (herdado); copy pt-BR / identificadores em inglês; sem `Consent` novo; sem tocar S27; sem editar migration existente nem criar migration (N/A); "edite o dono, não crie o gêmeo" (diálogo e shells reusados).
- [ ] Testes de domínio previstos (unit/int) onde access/write paths mudam: int do lote (papéis, transações por item, mídia, `ContentEvent`, tag, falha parcial, schema), unit da barra/seleção (transporte, limpeza, recibos, diálogo) e unit do builder puro; e2e de rota no arquivo existente.
- [ ] Gates: `pnpm gate:fast` verde; push via `pnpm push`; sign-off humano do design DEGRADED registrado no PR.
## Self-score (decision-quality, gate ≥4)
**5/5** — (1) decisões caras com Opções A|B|C e rejeitadas explícitas (transporte, estado, coluna, barra/diálogo, recibo, migration/access); (2) cabe no appetite herdado (~1–1,5 dia; server ~0,5, UI ~0,5–0,75, sem rabbit hole); (3) rabbit holes de engenharia nomeados (cross-page, persistência, progresso, toast duplo, API de seleção no shell); (4) depth check: reusa `CampaignDeleteDialog`, `CampaignTable`, `Checkbox`, `postCampaignJson`, `campaignJsonMutationRoute`, `withPayloadTransaction`/ações donas, `CampaignListResults` e o builder puro centraliza o recibo; (5) outcome da intenção intocado — nenhuma semântica unitária, access ou rota pública mudou.
## Crítica de design (trigger c, 2026-09-29)
**Veredito: DEGRADED — não certificado.** O designer frontier (`openai/gpt-5.6-sol`) rodou a crítica contra o app renderizado (screenshots 390/1280 em `docs/plans/central-conteudos-acoes-em-lote-ui-critique/`), mas não certifica: o artefato de origem é DEGRADED e exige sign-off humano. Ajustes do designer:
1. **FAB × barra (trigger b, aplicado):** `ContentPieceSelectionBar` → `sticky bottom-32 z-20 mt-4 md:bottom-4 md:mr-16` (no mobile a barra sobe acima do `CampaignQuickActionsFab`; no desktop recua à direita).
2. **Reserva de rolagem (aplicado):** `CampaignListResults` da lista ganha `group-data-[selection-mode=true]:pb-64 md:group-data-[selection-mode=true]:pb-28` — durante a seleção a barra não oculta o foco/último item.
3. **Diálogo translúcido/overlay (deferido ao sign-off):** tocar `AlertDialog` (overlay `bg-black/10` + blur, `max-w-xs` mobile, `leading` da descrição) muda o chrome de **todas** as superfícies do app (corte, gravação, C222) — decisão humana, registrada no comentário da Issue.
4. **Largura do diálogo de lote (aplicado, escopado):** novo `contentClassName` aditivo no `CampaignDeleteDialog`; a barra passa `max-w-sm` (358px em 390) sem tocar os call sites existentes.
5. **Clamp de títulos (aplicado):** `line-clamp-2 break-words` no título da tabela e do card.
6. **Recibo a11y (aplicado):** sucesso com `aria-live="polite" aria-atomic="true"`; fechar com `focus-visible:ring-3 focus-visible:ring-ring/50`.
7. **Semântica aprovada preservada:** rascunhos sem aviso de links; seleção com publicadas com o aviso — inalterado.
**Estado da entrega:** implementação, testes (int/unit/e2e) e `pnpm gate:fast` verdes. Com `--auto` não há humano para o sign-off, então o fluxo **não abre PR**: Issue comentada e flipada para `blocked`. A proposta de evolução do `DESIGN.md` §7 (modo de seleção de lista) continua proposta, dependente do sign-off.

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View file

@ -0,0 +1,134 @@
# Impl: S42 — Home — seção da Central de Conteúdos: compartilhar a peça no card e filtro que abre /conteudos já filtrado
Status: aprovado
Atualizado em: 2026-09-30
Issue: #1391
Intenção: docs/plans/central-conteudos-secao-home-share-filtro.md
Appetite restante: herdado — ~1–1,5 dia eng (S42), sem corte
## Leitura da intenção
- **Outcome:** o card da seção passa a compartilhar a peça pela **mesma** folha do S27 sem sair da home, e a seção ganha tags-atalho + fileira (`Tipo | Cidade | Região`) que abrem `/conteudos` já filtrado pelo construtor canônico — a seção vira atalho de circulação, nunca uma mini-Central.
- **O que NÃO negociar:** folha única do S27 (mensagem de voto por tipo, contador C213, sem mecanismo paralelo); URL canônica de `buildContentPieceCatalogHref` (`?tipo=|?cidade=|?regiao=`); tags sem faceta honesta (`Mais recente`, `Seleção recente`, origem Instagram/YouTube) estáticas; fileira **não** filtra a amostra local (sem estado de filtro, sem resultado vazio local); fail-closed (zero peças publicadas ⇒ seção some) intacto; `Ver esta peça →`/`Ver todas as peças →` seguem; sem download/analytics/localização/Consent/PII novos; `/conteudos` e demais seções intocadas; sem overflow horizontal novo em 390/1280.
- **O que reavaliar (hipóteses confirmadas por leitura direta):**
- O lean `ContentPieceHomeItem` **não carrega `sourceUrl`** (`src/lib/contentPieceHomeSelection.ts:25-43`) e a folha precisa dele para peça-link (`src/lib/contentPieceShare.ts:16-50`) — D2.
- `contentPieceCatalogFacets` lê `topics`/`institution` (`src/lib/contentPieceCatalog.ts:215-226`), então **não** roda sobre o item lean: calcula na section server, antes da projeção — D1.
- O board é o dono natural do estado de share (precedente `ContentPieceCatalog.tsx:36-37,86-88`); o card é folha e ganha só `onShare` — D5.
- `ContentPieceCatalogFacetOption` não é exportado, mas `ContentPieceCatalogFacets` é — o recorte `Pick<…, 'tipo'|'cidade'|'regiao'>` resolve sem exportar tipo novo.
## Abordagem recomendada
```mermaid
flowchart LR
PAGE["page.tsx (home)<br/>contentPieces.length > 0"] --> SEC["ContentPieceHomeSection (server)<br/>contentPieceCatalogFacets<br/>→ Pick tipo|cidade|regiao<br/>+ projeção lean (sourceUrl)"]
SEC -->|"items + facets"| BOARD["ContentPieceHomeBoard (client)<br/>playing · sharingItem · visitor"]
BOARD --> CARD["ContentPieceHomeCard<br/>tag tipo→?tipo, match→?cidade|?regiao<br/>Compartilhar + Ver esta peça"]
BOARD --> ROW["ContentPieceHomeFilterRow<br/>details Tipo|Cidade|Região"]
BOARD --> SHEET["ContentPieceShareSheet (S27)<br/>mesmo mecanismo · prop estreitada"]
CARD -->|"buildContentPieceCatalogHref"| CAT["/conteudos já filtrada"]
ROW -->|"buildContentPieceCatalogHref"| CAT
SHEET -.->|"C213 (já existente)"| EV["contentEvents"]
```
**Opções consideradas:** A) estender os donos existentes (section calcula facetas no server; board guarda `sharingItem` e renderiza a folha S27; card renderiza tags-link/Compartilhar; fileira é componente novo sem estado) | B) ilha própria da seção com filtro local sobre a amostra | C) rota intermediária com estado de filtro próprio da seção.
**Recomendação:** A — reusa o dono da URL (`buildContentPieceCatalogHref`), o dono das facetas (`contentPieceCatalogFacets`), o dono do mecanismo de share (`ContentPieceShareSheet`) e o dono das classes (`contentPieceClasses.ts`); o delta fica em 1 campo de projeção + markup portado do artefato. Cabe no appetite e mantém a amostra intocada.
**Rejeitadas:** B porque é o rabbit hole "já que tem atalho, filtra a amostra aqui mesmo" (estado local, cards escondidos, vazio local — cortado na intenção) e engordaria o board com busca/filtro que não existem no aceite; C porque cria um segundo contrato de URL/estado para o mesmo vocabulário, furando o construtor canônico.
### Decisões de engenharia (caras de reverter)
**D1 — Onde computar as facetas da fileira.**
Opções: A) na `ContentPieceHomeSection` (server), com `contentPieceCatalogFacets` canônico, passando ao board só `Pick<ContentPieceCatalogFacets, 'tipo' | 'cidade' | 'regiao'>` | B) no board (client), derivando de `ContentPieceHomeItem` | C) utility nova `contentPieceHomeFacets`.
Recomendação: A — o dono das facetas roda uma vez no server, perto da leitura cacheada, com o branch de card incluso de graça; o board recebe dados serializáveis já prontos (lista curta valor/label) e o client não carrega a derivação.
Rejeitadas: B porque o item lean não tem `topics`/`institution` — exigiria engordar o `Pick` com campos que o card não renderiza (quebrando o guardrail do S39) ou reimplementar o dono; C porque é pass-through raso de 3 facetas e vira gêmeo de `contentPieceCatalogFacets`.
**D2 — Reuso da folha S27 e resolução do `sourceUrl`.**
Opções: A) adicionar `sourceUrl` ao `Pick`/projeção de `ContentPieceHomeItem` e estreitar a prop de `ContentPieceShareSheet` ao subconjunto que ela consome (`slug`, `title`, `type`, `file`, `isLink`, `sourceUrl`) | B) manter a folha exigindo `ContentPiecePublicItem` e o board guardar o item público completo | C) folha da home própria/wrapper.
Recomendação: A — segue o precedente do módulo (`ContentPieceMedia.tsx:9-21`, `ContentPieceCardParts.tsx:15,37`): a folha declara só o que lê, o catálogo continua passando o item inteiro por assignabilidade estrutural e a home passa o lean + `sourceUrl` (URL pública de peça-link, já exposta no catálogo/página da peça; sem PII e sem o haystack `searchText`/`description`). Zero mudança visual na folha; um mecanismo só.
Rejeitadas: B porque obrigaria o board a receber/manter o `ContentPiecePublicItem` full (o haystack que a projeção S39 existe para manter fora do HTML estático e do client); C é o rabbit hole "faz um botão de share próprio do card" (dois mecanismos com mensagens divergentes).
**D3 — Componente da fileira vs inline vs reuso do catálogo.**
Opções: A) `ContentPieceHomeFilterRow` novo em `src/components/conteudos/`, presentacional e sem estado, recebendo as facetas | B) inline no `ContentPieceHomeBoard` | C) reusar `ContentPieceFilters` do catálogo.
Recomendação: A — a fileira é uma unidade com contrato próprio (caption + 3 `<details>` + menus) montada pelo board em dois contextos (multi e single, cena 01/04/05); sem estado (`<details>` nativo), reusa `contentPieceCatalogFacetLabels`, `buildContentPieceCatalogHref` e o padrão `ChevronDown` (`ContentPieceFilters.tsx:33-39,141-190`).
Rejeitadas: B porque o board já carrega geolocalização/seleção/playing/sharing e ganharia ~80 linhas de markup de facetas; C porque `ContentPieceFilters` é o form GET do catálogo (busca, modo tema, chips ativos, hidden inputs) — outro contrato, outra rota.
**D4 — Classes novas home-scoped vs alterar as compartilhadas.**
Opções: A) classes novas home-scoped em `contentPieceClasses.ts`, mantendo `CONTENT_PIECE_TAG`/`CONTENT_PIECE_LOCAL_TAG` intactas | B) alterar as compartilhadas para o spec do design | C) inline no card/fileira.
Recomendação: A — o S42 muda forma e altura (min-h-10 mobile / 36px de `sm`, contorno + chevron nas tags-link) e as classes antigas são consumidas pelo catálogo e pela página da peça, que o aceite manda não tocar; as novas ficam no dono das classes (mesmo arquivo), com prefixo home, e atendem tags do card, tag da seção e estáticas.
Rejeitadas: B porque vaza para `/conteudos`/detalhe sem intenção; C porque repetiria a mesma string em 3–4 lugares sem contrato único.
**D5 — Onde derivar o href das tags.**
Opções: A) no card, com `buildContentPieceCatalogHref`/`slugify` sobre o próprio `match`/`item` | B) no board, passando um `href` por tag ao card | C) helper local por tipo de tag.
Recomendação: A — o mapa tag→faceta é propriedade da tag que o card já possui (`MATCH_LABELS`, `item.isLink`, `item.type`) e o builder canônico é puro/client-safe (`src/lib/contentPieceCatalog.ts:131-151`); o card segue folha (ganha só `onShare`) e o board deriva apenas o href da tag da seção (`⌖`), que depende do `visitor` que ele mesmo resolve.
Rejeitadas: B porque espalha o vocabulário de tags do card para o board; C porque recria o builder (gêmeo do dono). Guarda barata: label nulo (não ocorre nos matches por construção) ⇒ tag fica estática, nunca `?cidade=undefined`.
### Componentes / mudanças
- **`ContentPieceHomeSection`** (`src/components/conteudos/ContentPieceHomeSection.tsx`): server; calcula `contentPieceCatalogFacets(items)` antes de `items.map(toContentPieceHomeItem)` e passa `items` + `Pick<ContentPieceCatalogFacets, 'tipo'|'cidade'|'regiao'>` ao board. `page.tsx` intocado (já entrega `ContentPiecePublicItem[]` e mantém o fail-closed `:257`).
- **`ContentPieceHomeItem` / `toContentPieceHomeItem`** (`src/lib/contentPieceHomeSelection.ts`): `sourceUrl: string | null` entra no `Pick` e no mapa campo-a-campo (um campo novo; o guardrail "nunca por spread" permanece).
- **`ContentPieceHomeBoard`** (`src/components/conteudos/ContentPieceHomeBoard.tsx`): ganha `facets` (props), estado `sharingItem: ContentPieceHomeItem | null` (espelha o playing), `onShare` nos cards, render de `ContentPieceShareSheet` quando setado, e a tag da seção `⌖ Para seu município` vira link `?cidade=slugify(visitor.municipalityName)` (mantendo `data-sample-tag`); `Seleção recente` continua estática.
- **`ContentPieceHomeCard`** (`src/components/conteudos/ContentPieceHomeCard.tsx`): tags — tipo link `?tipo=<enum>` quando peça não-link, `Do seu município`/`Da sua região` link `?cidade|?regiao` (D5), origem/`Mais recente` estáticas; `Compartilhar` com `CONTENT_PIECE_PRIMARY_BUTTON` e `aria-label={\`Compartilhar ${item.title}\`}` (`mt-3 sm:mt-4`, full width) antes de `Ver esta peça →`, que assume `.piece-link` (`mt-2 w-full justify-center text-xs font-extrabold`).
- **`ContentPieceHomeFilterRow`** (novo, `src/components/conteudos/ContentPieceHomeFilterRow.tsx`): presentacional sem estado; caption `Explore na Central · abre o catálogo já filtrado` (desktop) / `… abre já filtrada` (mobile); `grid grid-cols-3 gap-2`; 3 `<details name="content-home-explore">` exclusivos com `<summary>` chip e menus absolutos conforme o artefato; opções via `buildContentPieceCatalogHref({ [facet]: option.value })` (home não tem outros filtros a preservar); faceta sem opção **não renderiza** o chip.
- **`ContentPieceShareSheet`** (`src/components/conteudos/ContentPieceShareSheet.tsx`): prop estreitada para `Pick<ContentPiecePublicItem, 'slug'|'title'|'type'|'file'|'isLink'|'sourceUrl'>`; **zero mudança visual/comportamental** (mensagem, C213, portal, foco, Escape intactos). `ContentPieceCatalog` continua passando o item inteiro.
- **`contentPieceClasses.ts`** (`src/components/conteudos/contentPieceClasses.ts`): classes home-scoped novas — estáticas (`min-h-10 sm:min-h-9`, fundo `#eef4fb`/local `#fff3c4`) e link (contorno `rgb(24 78 146/30%)`, bg `#f8fbff`, hover borda currentColor/bg `#eef4fb`/underline/`translateY(-1px)`, variante local `rgb(107 81 0/28%)`/`#fff8dc`/`#fff3c4`) + `.piece-link`; `CONTENT_PIECE_TAG`/`CONTENT_PIECE_LOCAL_TAG`/`CONTENT_PIECE_FOCUS`/`CONTENT_PIECE_PRIMARY_BUTTON` intocadas/reusadas.
- **Migration:** sem migration — nenhuma collection, global, campo Payload ou índice muda.
- **Access / Consent:** sem mudança e sem chave nova — nenhuma leitura/write de PII, nenhum `Consent`; a folha segue o C213 existente.
- **UI:** Impeccable B — port classe-a-classe do artefato aprovado no gate (`…-ui-design.html`, cenas 01–06; card, fileira fechada/aberta, folha, foco, peça única); sem shape novo; shells reusados: `ContentPieceShareSheet`, `CONTENT_PIECE_PRIMARY_BUTTON`, `CONTENT_PIECE_FOCUS`, `ChevronDown`/`ChevronRight` (lucide) e o padrão `<details>/<summary>` do catálogo.
### Dados → forma (se aplicável)
N/A — nenhuma contagem/ranking/série nova; a circulação continua no contador anônimo por peça do C213, sem dado novo do visitante.
## Fases verificáveis
1. **Tracer — server + folha (~0,4 dia).** `sourceUrl` no `Pick`/projeção; facetas na section; board com `sharingItem` + `ContentPieceShareSheet` (prop estreitada) e `Compartilhar` no card. Atualizar fixture de `contentPieceHomeBoard.unit.spec.tsx` e o pin de `Object.keys` de `contentPieceHomeSelection.unit.spec.ts:84-108`. **Prova:** unit do board — clicar `Compartilhar <título>` abre o `dialog` com a mensagem do tipo e a URL da home não muda.
2. **Card tags-link + classes home (~0,3 dia).** Classes novas; tags do card (tipo/município/região link; origem/`Mais recente` estáticas); tag da seção `⌖` vira link do visitor; `Ver esta peça →` no spec `.piece-link`. **Prova:** unit com hrefs exatos (`/conteudos?tipo=foto`, `/conteudos?cidade=…`, `/conteudos?regiao=…`) e estáticas sem `<a>`.
3. **Fileira (~0,3 dia).** `ContentPieceHomeFilterRow` + posicionamento (multi: entre intro/tags e os cards, `mx-auto max-w-xl`; single: coluna esquerda, `max-w-md`, caption alinhada à esquerda no desktop, antes do CTA — cenas 01/04/05). **Prova:** unit da fileira (details/opções com href canônico; chip ausente quando a faceta é vazia).
4. **Testes da seção (~0,3 dia).** Unit do board para fileira/tags/estáticas e e2e em `tests/e2e/frontendConteudos.e2e.spec.ts` (estender o teste `:732` ou novo): abrir `Compartilhar` na home → folha visível, home atrás; tag → `/conteudos?cidade=…`; abrir a fileira → link de opção com href canônico; overflow 390 (com menu aberto) ≤1 e 1280 sem overflow.
5. **Gates.** `pnpm gate:fast` (lint/format/typecheck/knip/cycles/unit/int/build) + e2e afetado (`frontendConteudos`, seleção pelo manifesto); push via `pnpm push` → PR. Sem migration a aplicar.
## Rabbit holes / Não escopo (engenharia)
- **Filtro local na seção** (estado que esconde cards, busca, resultado vazio local) — cortado na intenção; a fileira só monta URL.
- **Segundo mecanismo de share** (hook/contexto/botão próprio do card com mensagem própria) — a folha S27 é o único; o estado é local do board (1 call site, sem `useContentPieceShare` novo).
- **Download/analytics/localização/Consent novos** — nada disso; C213 existente é o único evento.
- **Tocar `/conteudos`, catálogo, página da peça ou S3** (`ContentPieceFilters`, `ContentPieceCatalog`, `ContentPieceCard`, `CampaignContentSection`) — fora do aceite.
- **Alterar `CONTENT_PIECE_TAG`/`LOCAL_TAG`** — classes novas home-scoped; as compartilhadas ficam byte-iguais.
- **Engordar o lean item além de `sourceUrl`** (trazer `topics`/`description`/`searchText`) — proibido pelo guardrail S39.
- **Editar `scripts/lib/e2e-affected-manifest.mjs`** — `(home)`, `components/conteudos` e `contentPieceHomeSelection` já mapeiam para `frontendConteudos`.
- **Facetas Tema/Instituição/Liderança na fileira v1** — vocabulário v1 é `Tipo|Cidade|Região`; tema fica no catálogo (decisão de gate).
- **Mexer na geolocalização/visitor ou persistir qualquer coisa** — a seção segue efêmera.
- **Refatorar o board para server slots / extrair componentes "por pureza"** — sem volatilidade real.
### Débitos triados (pós-simplify)
- **Fileira estática viajando no bundle client** (poderia ser slot do server) — defer; gatilho: a fileira ganhar dado dinâmico/estado, ou perf audit acusar peso do bundle da home.
- **Esqueleto `<details>/summary/menu` reimplementado** em `ContentPieceHomeFilterRow` vs `ContentPieceFilters` — defer; gatilho: 3º consumidor do esqueleto ou convergência dos contratos (links puros vs form GET).
- **`buildContentPieceCatalogHref({ cidade: slugify(...) })` em board e card** (2 call sites) — defer; gatilho: 3º call site.
- Registrar/discartar: nenhum achado caro (sem access/LGPD/schema/hot path); nenhuma Issue nova.
## Riscos e mitigação
- **SSR/hidratação da folha a partir da home:** `ContentPieceShareSheet` lê `window.location.origin` no render (`:41`), mas só monta quando `sharingItem !== null`; o estado nasce null e só um clique o seta — o HTML estático e o primeiro render do client não incluem a folha (mesmo padrão do catálogo). Não mover a leitura para o corpo do board.
- **Overflow 390 com menus absolutos:** `w-[min(220px,calc(100vw-72px))]`, 2º chip centralizado (`left-1/2 -translate-x-1/2`), último alinhado à direita; o teste de overflow da seção (hoje `:763-767`) deve rodar também com um menu aberto; 1280 sem novo overflow.
- **Alvo de toque:** chips/opções `min-h-10` (40px) no mobile; tags `min-h-10` mobile / 36px de `sm` — conforme o artefato; não encolher por densidade.
- **Alinhamento estáticas×links:** mesmas alturas/padding/box nas tags para a fileira do card não pular entre combinações (tipo-link + recente estática) — classes home-scoped garantem o mesmo contrato.
- **Faceta vazia:** chip sem opções não renderiza (mesma regra do catálogo `ContentPieceFilters.tsx:142`); com amostra pequena a fileira pode ficar com menos chips, nunca com menu vazio; a caption permanece.
- **`sourceUrl` novo no HTML estático:** URL pública de peça-link já exposta no catálogo/página; sem PII e sem haystack — o pin de campos do unit de seleção é atualizado no mesmo commit para registrar a intenção.
- **Tipo `card`:** peça editorial `card` linka `?tipo=card` (valor canônico do vocabulário), sem rota especial de modelos — não confundir com os itens sintéticos do S38.
- **Unit do board com portal:** a folha monta portal e importa `contentEvents`/`CopyLinkButton`; manter os mocks existentes (`next/link`) e mockar só se o ambiente reclamar — sem tocar a folha por testabilidade.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: `Compartilhar` no card abre a folha S27 (mensagem por tipo, C213) sem sair da home; tags com faceta + fileira `Tipo|Cidade|Região` abrem `/conteudos` via `buildContentPieceCatalogHref`; estáticas permanecem; `Ver esta peça →`/`Ver todas as peças →` intactos; fail-closed; sem overflow 390/1280.
- [ ] Invariantes AGENTS/engineering-standards: Dependency Rule (lib pura → components), `import type` client←server, copy pt-BR/identificadores em inglês, sem Consent/PII/migration, classes compartilhadas intocadas, "edit the owner, don't twin" (nenhum gêmeo de facetas/URL/share).
- [ ] Testes de domínio previstos: unit `contentPieceHomeBoard` (folha, hrefs, estáticas, fileira) + pin de `contentPieceHomeSelection` (`sourceUrl`) + unit novo da fileira; e2e da seção no `frontendConteudos`; nenhum write path/access muda (sem int).
## Self-score (decision-quality)
1. Decisões caras com rejeitadas explícitas — 5/5 (D1–D5 no formato Opções/Recomendação/Rejeitadas).
2. Abordagem cabe no appetite da intenção — 5/5 (tracer 0,4 + card 0,3 + fileira 0,3 + testes 0,3 ≈ 1,3 dia, dentro de ~1–1,5).
3. Rabbit holes nomeados — 5/5 (filtro local, share paralelo, download/analytics, tocar catálogo, engordar o lean item, manifesto e2e).
4. Depth check: reusa shells/helpers existentes — 5/5 (`buildContentPieceCatalogHref`, `contentPieceCatalogFacets`, folha S27, classes no dono, `<details>` do catálogo); nenhum pass-through novo além da fileira, que tem contrato próprio.
5. Intenção (aceite de produto) permanece satisfeita — 5/5 (nada reescreveu o outcome; a engenharia só escolheu onde computar/renderizar).
Média: 5/5 — ≥4/5.

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,119 @@
# S42 — Home — seção da Central de Conteúdos: compartilhar a peça no card e filtro que abre /conteudos já filtrado
Status: rascunho
Atualizado em: 2026-09-29
Issue: #1391
Priority: P2
Impeccable: B — encaixe na seção S39 da home (ações no card existente)
Design UI: docs/plans/central-conteudos-secao-home-share-filtro-ui-design.html
Appetite: ~1–1,5 dias eng; um outcome verificável (a seção compartilha a peça e leva à Central já filtrada)
Responsável: —
## Intenção
A seção da Central de Conteúdos na home (S39) está no ar, mas hoje é só vitrine: o eleitor vê a peça do território dele, toca e cai em `/conteudos` para então compartilhar ou filtrar. Na reta final até 04/10, o dono pediu o atalho direto: compartilhar a peça **do card da seção**, tocar numa tag ou na **fileira de filtros da seção** e abrir a Central **já filtrada** naquele recorte. A seção deixa de ser só vitrine e vira atalho de circulação — ações no card existente mais uma fileira de filtros, sem virar uma segunda Central (nada filtra a amostra localmente).
Este item **reabre parcialmente** o corte do S39 "A seção vira uma mini-Central" (`docs/plans/central-conteudos-secao-home.md:98`): compartilhar no card e tags-atalho para o catálogo filtrado passam a valer por pedido explícito do dono; filtros, busca e download locais na seção continuam cortados. O restante do S39 (localização sem rastro, seleção município→região→recentes, fail-closed, 3 peças) fica intocado — a reabertura é registrada no mesmo molde do S39 sobre o D7 do S27.
## Persona e fluxo
- **Persona / contexto:** eleitor no celular que rola a home, reconhece a peça do município/região dele na seção da Central e está com o grupo de WhatsApp aberto — quer repassar sem sair da home e, se quiser mais, ver o recorte inteiro.
- **Job principal:** compartilhar a peça que acabou de ver em um toque e, quando quiser o resto, chegar em `/conteudos` já filtrado pelo mesmo recorte.
- **Fluxo desejado:** rola a home → vê a peça do território → toca em `Compartilhar` no card → a mesma folha de compartilhamento do catálogo abre com a mensagem de voto do tipo da peça → compartilha e continua na home. Ou: toca numa tag com faceta equivalente (`Do seu município`, `Da sua região`, `Vídeo`/`Áudio`/`Foto`/`Texto`, `⌖ Para seu município`) ou numa opção da **fileira de filtros da seção** (`Tipo`/`Cidade`/`Região`) → cai em `/conteudos` **já filtrada** naquele recorte → segue do catálogo (fluxo do S27).
- **Anti-goals de produto:** não é mini-Central — sem busca e sem nenhum filtro que filtre as 3 peças exibidas: tags e fileira apenas abrem `/conteudos` já filtrada; sem player próprio, sem download novo no card; sem segundo mecanismo de compartilhar (a folha é a do S27); sem rastreio novo — o compartilhar da home conta na mesma circulação por peça (C213); nada de localização nova, login, captura ou PII; `Ver esta peça →` e `Ver todas as peças →` intactos.
### Esboço de fluxo (B)
```text
[visitante na home] → seção da Central (S39, intocada)
→ card da peça do território
├─ toca em "Compartilhar" → abre a folha do S27 com a mensagem de voto do tipo
│ → compartilha → continua na home
└─ toca numa tag com faceta equivalente → /conteudos já filtrado (?tipo | ?cidade | ?regiao)
(tags sem faceta honesta: "Mais recente"/"Seleção recente"/origem de peça-link — estáticas)
→ fileira de filtros da seção (Tipo | Cidade | Região) → /conteudos já filtrado
(a fileira NÃO filtra a amostra: nenhum estado local, nenhum resultado vazio na seção)
→ "Ver esta peça →" e "Ver todas as peças →" seguem como estão
[zero peça publicada] → a seção inteira some (fail-closed)
```
### Design UI (B)
- Design UI (gate): `docs/plans/central-conteudos-secao-home-share-filtro-ui-design.html` — cobre o card com `Compartilhar` + tags-link, a **fileira de filtros** (e o menu aberto), a folha de compartilhamento aberta a partir da home, foco/estado das affordances e a cena de peça única; cenas ~390 e ~1280.
## Objetivo e aceite
- **Compartilhar no card:** `Compartilhar` no card da seção abre a **mesma** folha do catálogo S27, com a mensagem de voto por tipo já contratada; o visitante não sai da home e o evento conta na mesma circulação por peça (C213).
- **Filtro direto (tags):** as affordances com faceta equivalente abrem `/conteudos` com o parâmetro canônico da URL (`tipo|cidade|regiao`) pelo construtor canônico (`buildContentPieceCatalogHref`), no recorte da tag; `Ver todas as peças →` continua abrindo a Central sem filtro.
- **Fileira de filtros da seção:** a seção ganha uma fileira com as facetas canônicas `Tipo` | `Cidade` | `Região` (mesmo vocabulário do catálogo) e cada opção abre `/conteudos` já filtrada; a fileira **não** filtra a amostra localmente (sem estado de filtro, sem resultado vazio na seção).
- Tags sem faceta honesta (`Mais recente`, `Seleção recente`, origem de peça-link) permanecem **estáticas**.
- Sem peça publicada, a seção inteira some (fail-closed) — kill switch intacto.
- A seção continua sem login, sem PII e sem localização persistida; nenhuma solicitação de localização nova.
- Demais seções da home e a própria `/conteudos` intocadas; sem overflow horizontal novo nos dois breakpoints.
## Dados (intenção)
- **Dados: N/A** — nenhum número, contagem, ranking ou série nova na superfície; a contagem de compartilhamento é o contador anônimo já existente do C213 por peça, alimentado pela própria folha. Nenhum dado novo do visitante é coletado, guardado ou exibido.
## Dados da decisão (literais)
- Item `S42`; slug `central-conteudos-secao-home-share-filtro`; tipo `feature`; Priority `P2`; Impeccable `B`.
- **Supersessão parcial:** rabbit hole "A seção vira uma mini-Central" do S39 (`docs/plans/central-conteudos-secao-home.md:98`) — reabre só compartilhar-no-card e tags-atalho para `/conteudos` filtrado; filtros/busca/download locais seguem cortados; restante do S39 intocado.
- Mapeamento affordance → faceta canônica:
- `Do seu município` → `?cidade=<slug do label>`
- `Da sua região` → `?regiao=<slug>`
- tag de tipo (`Vídeo`/`Áudio`/`Foto`/`Texto`) → `?tipo=<enum>`
- tag da seção `⌖ Para seu município` → `?cidade=<slug do município do visitante>`
- fileira de filtros da seção: facetas `Tipo` | `Cidade` | `Região` (rótulos do catálogo), cada opção → `/conteudos` já filtrada; sem filtro local
- `Mais recente` / `Seleção recente` / origem de peça-link (`Instagram`/`YouTube`) → estáticas
- Copy: `Compartilhar` (mesma do catálogo S27), com `aria-label` `Compartilhar <título>`.
- Intocados: `Ver esta peça →` e `Ver todas as peças →`; sem download no card; sem busca local; sem solicitação de localização nova.
## Direção no codebase (hipótese)
- **Áreas prováveis:** wrapper server `src/components/conteudos/ContentPieceHomeSection.tsx:12-34`; board client `ContentPieceHomeBoard.tsx` (estado `:66-71`, card `:217-226`, CTAs `:202-207` e `:229-242`); card `ContentPieceHomeCard.tsx` (tags `:58-63`, link `:66-74`); montagem em `src/app/(frontend)/(home)/page.tsx:257`.
- **Precedente a olhar:** folha do S27 (`ContentPieceShareSheet.tsx:31-193`) com estado de share no catálogo (`ContentPieceCatalog.tsx:36-37,86-88`); construtor canônico de URL e facetas (`src/lib/contentPieceCatalog.ts:55-73,131-151`, cidade/região `slugify(label)` `:222-224`); o S4 (`ContentShareButton.tsx:44` em `CampaignContentCard.tsx:86`) é precedente visual de OUTRO dono (seção S3) — não duplicar.
- **Risco de acoplamento:** o item lean da home (`ContentPieceHomeItem`, `src/lib/contentPieceHomeSelection.ts:25-43`) não carrega `sourceUrl` (usado por `src/lib/contentPieceShare.ts`) — projetar/estreitar a prop é decisão do plano de implementação; a intenção exige reusar o MESMO mecanismo. Não editar a home além da seção nem o manifesto de e2e.
## Dependências
- S39 entregue (#1305) — sem a seção não há o que estender.
- Design hi-fi aprovado no gate.
## Fora de escopo
- Busca/filtro que filtrem a própria amostra (mini-Central local) — item próprio, se houver pedido.
- Novos canais de compartilhamento além da folha S27; download no card.
- Tema/liderança/instituição como affordance na seção; mudar `/conteudos` ou o catálogo; tocar a seção S3/`CampaignContentSection`; analytics novo; qualquer mudança de localização/Consent.
## Rabbit holes de produto
- **"Já que tem atalho, filtra a amostra aqui mesmo".** Se alguém "só completar": a seção guarda estado de filtro, esconde cards e inventa um resultado vazio local. **Corte neste item:** tags e fileira só montam a URL da Central; os 3 cards da amostra nunca são filtrados na seção.
- **"Faz um botão de share próprio do card".** Se alguém "só completar": nascem dois mecanismos de compartilhar com mensagens divergentes. **Corte neste item:** a folha do S27 é o único mecanismo; o card só a abre.
- **"Toda tag vira link".** Se alguém "só completar": tags sem faceta (origem, "Mais recente") viram link para lugar nenhum ou busca improvisada. **Corte:** só tag com faceta canônica vira filtro; o resto fica estático.
- **"Aproveita e baixa também".** Se alguém "só completar": download no card puxa mídia pesada para a home. **Corte:** download só no catálogo/página da peça (S27).
## Questões em aberto (produto)
- **Quais affordances viram filtro na v1?** **Decidido no gate:** **B** — tags com faceta honesta **e** fileira de filtros própria da seção (`Tipo` | `Cidade` | `Região`), ambas abrindo `/conteudos` já filtrada; nenhum filtro local. _(decidido — humano, gate 2026-09-29)_
- **Incluir chip de tema no card?** **Decidido no gate:** **A** — não nesta v1 (o card fica com 2 tags + `Compartilhar` + CTA; tema vive no catálogo). _(decidido — humano, gate 2026-09-29)_
- **O compartilhar da home entra na contagem C213 existente?** **Decidido no gate:** **A** — sim, mesmo mecanismo/contador por peça; sem rastreio novo. _(decidido — humano, gate 2026-09-29)_
## Referências
- GitHub Issue: #1391.
- Design UI (gate): `docs/plans/central-conteudos-secao-home-share-filtro-ui-design.html` (+ assets em `central-conteudos-secao-home-share-filtro-ui-design-assets/`).
- `docs/plans/central-conteudos-secao-home.md` (S39 — supersessão na linha 98) e `central-conteudos-secao-home-ui-design.html`; `docs/plans/central-conteudos-publica.md` (S27).
- `src/components/conteudos/ContentPieceHomeSection.tsx`, `ContentPieceHomeBoard.tsx`, `ContentPieceHomeCard.tsx`, `ContentPieceShareSheet.tsx`, `ContentPieceCatalog.tsx`; `src/lib/contentPieceCatalog.ts`, `src/lib/contentPieceHomeSelection.ts`; `src/app/(frontend)/(home)/page.tsx`.
- Testes: `tests/unit/contentPieceHomeBoard.unit.spec.tsx`, `tests/unit/contentPieceHomeSelection.unit.spec.ts`, `tests/e2e/frontendConteudos.e2e.spec.ts`, `scripts/lib/e2e-affected-manifest.mjs`.
- `AGENTS-public.md` — convenções do site público (cache, kill switch, mídia).
## Self-score (shaping)
1. Fatia = um outcome verificável? 5/5 — a seção compartilha a peça e leva à Central já filtrada (tags + fileira), ambos observáveis no card/seção.
2. Appetite declarado e a intenção cabe nele? 4/5 — ~1–1,5 dias cabe porque reusa folha e URL canônica existentes; a fileira de filtros é o delta.
3. Persona + job + aceite claros (sem jargão de stack)? 5/5 — fluxo só em linguagem de eleitor.
4. Direção no codebase é hipótese (não contrato técnico)? 5/5 — arquivos/linhas são pista do estado atual, não prescrição.
5. Zero decisões duras de engenharia no plano? 4/5 — projeção de `sourceUrl` para a folha e formato das affordances ficam para o plano de implementação.
Média: 4,6/5 — ≥4/5.

File diff suppressed because it is too large Load diff

View file

@ -36,6 +36,7 @@
"reels:ingest": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/reels-ingest.mjs",
"flickr:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/ingest-flickr-archive.mjs",
"content:instagram:import": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/import-instagram-content.mjs",
"content:instagram:withdraw": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/withdraw-instagram-content.mjs",
"archive:catalog": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/catalog-archive-photos.mjs",
"faces:enroll": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/enroll-face-subject.mjs",
"faces:index": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/index-archive-faces.mjs",

View file

@ -11,8 +11,8 @@
* node scripts/build-chart-from-data.mjs --in=data/graficos-instagram/dados.csv \
* --headline="Um território concentra o maior resultado" --source="TSE 2022" \
* [--subtitle=...] [--note=...] [--type=bar|column|line|anchor|delta] [--size=feed|square|story] \
* [--unit="%"] [--highlight="Território A"|--no-highlight|--dual-positive] \
* [--out=docs/research/graficos-instagram/x.png]
* [--unit="%"] [--highlight="Território A"|--no-highlight|--dual-positive|--paired] \
* [--motion] [--out=docs/research/graficos-instagram/x.png]
* node scripts/build-chart-from-data.mjs --in=dados.csv --inspect
*/
@ -33,6 +33,8 @@ import {
parseInput,
validateSpec,
} from './lib/chartData.mjs'
import { MOTION_DURATION_S, MOTION_FPS, validateMotion } from './lib/chartMotion.mjs'
import { renderChartMotion } from './lib/chartMotionRender.mjs'
import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs'
import { renderChartHtml } from './lib/graficosInstagramRender.mjs'
@ -128,6 +130,7 @@ const readInput = async (flags) => {
* argv?: string[],
* launchBrowser?: () => Promise<{ close: () => Promise<void> }>,
* screenshot?: typeof screenshotHtmlPng,
* renderMotion?: typeof renderChartMotion,
* die?: (message: string) => never,
* repoRoot?: string,
* }} [options]
@ -136,6 +139,7 @@ export const main = async ({
argv = process.argv.slice(2),
launchBrowser = launchPdfBrowser,
screenshot = screenshotHtmlPng,
renderMotion = renderChartMotion,
die = dieWithLabel(LABEL),
repoRoot = process.cwd(),
} = {}) => {
@ -150,7 +154,7 @@ export const main = async ({
}
} else {
if (!flags.in) die('informe --in=<arquivo|-> com os dados (ou --spec=<json> para replay).')
const forcedType = flags.type ? String(flags.type) : null
const forcedType = flags.type ? String(flags.type) : flags.paired ? 'delta' : null
let input
try {
input = await readInput(flags)
@ -212,6 +216,7 @@ export const main = async ({
...(flags.highlight ? { highlight: String(flags.highlight) } : {}),
...(flags['no-highlight'] ? { noHighlight: true } : {}),
...(flags['dual-positive'] ? { dualPositive: true } : {}),
...(flags.paired ? { pairedColumns: true } : {}),
rows: dataset.rows,
...(chartType === 'delta'
? { startLabel: dataset.startLabel, endLabel: dataset.endLabel }
@ -220,8 +225,11 @@ export const main = async ({
}
}
if (flags.motion) spec = { ...spec, motion: true }
if (!spec.source) die('informe --source (a fonte viaja dentro da imagem).')
validateSpec(spec)
if (spec.motion) validateMotion(spec)
const slug = slugify(spec.headline)
const specPath = join(repoRoot, 'data/graficos-instagram', `${slug || 'grafico'}.chart-spec.json`)
@ -230,7 +238,7 @@ export const main = async ({
: join(
repoRoot,
'docs/research/graficos-instagram',
`${slug || 'grafico'}-${today()}-${spec.size ?? 'feed'}.png`,
`${slug || 'grafico'}-${today()}-${spec.size ?? 'feed'}.${spec.motion ? 'mp4' : 'png'}`,
)
let brandLogo = ''
@ -258,6 +266,24 @@ export const main = async ({
await mkdir(dirname(outPath), { recursive: true })
await mkdir(dirname(specPath), { recursive: true })
await writeFile(specPath, `${JSON.stringify(spec, null, 2)}\n`)
if (spec.motion) {
const { size, source } = await renderMotion({
browser,
html,
width: canvas.width,
height: canvas.height,
outPath,
workDir: join(
repoRoot,
'data/graficos-instagram',
`${slug || 'grafico'}-frames-${spec.size ?? 'feed'}`,
),
})
console.log(
`[${LABEL}] MP4 ${outPath} (${canvas.width}×${canvas.height}, ${Math.round(size / 1024)} KB) · motion ${MOTION_DURATION_S}s @ ${MOTION_FPS}fps · ${pointCount} · ffmpeg ${source}`,
)
return
}
const { size } = await screenshot(browser, {
html,
width: canvas.width,

View file

@ -18,7 +18,14 @@
* on a non-local target and the
* complete S3_* set
* pnpm content:instagram:import --apply --publish
* also publishes what is `pronto`
* also publishes what this run
* processed to `pronto`
* pnpm content:instagram:import --apply --publish-existing
* also publishes the READY
* DRAFTS already in the window
* (e.g. a batch imported without
* `--publish`), oldest post
* first so the newest leads
*
* Options: --days <n> (default 60), --limit <n> (canary), --out <dir> (receipts,
* default `data/content-instagram`), --help.
@ -47,11 +54,17 @@ import {
loadCliEnv,
mirroredMediaRequired,
} from './lib/cli.mjs'
import {
findContentPieceSourceRows,
loadInstagramContentWindowFeed,
revalidateInstagramContentCentral,
} from './lib/instagramContentCli.mjs'
import {
formatInstagramContentReport,
instagramContentFeedIdentityUrls,
instagramContentReportStamp,
parseInstagramContentCliArgs,
planInstagramContentDraftsToPublish,
planInstagramContentWindow,
shouldPublishImportedPiece,
summarizeInstagramContentResults,
@ -62,14 +75,6 @@ loadCliEnv()
const die = dieWithLabel('content:instagram:import')
const WRITE_CONFIRM_FLAG = 'CONTENT_INSTAGRAM_IMPORT_CONFIRM'
/** A hung Graph API must not hold the run forever. */
const FEED_TIMEOUT_MS = 60_000
/**
* Feed window of one run: one page serves 50, and the date early-stop ends the
* walk at the page that carries the first post older than `--days`, so the cap
* is only a defensive ceiling (6 pages), never the real window.
*/
const FEED_MAX_RESULTS = 300
const HELP = `Uso: pnpm content:instagram:import [opções]
@ -81,8 +86,12 @@ catalogação). Sem --apply, é um plano read-only (não baixa, não escreve).
Opções:
--apply processa o lote (exige ${WRITE_CONFIRM_FLAG}=1 em alvo
não-local e as 4 S3_*; o job grava a mídia no bucket privado)
--publish publica o que terminar "pronto" (exige --apply); falhas ficam
como rascunho e são nomeadas no recibo
--publish publica o que ESTA rodada terminar "pronto" (exige --apply);
falhas ficam como rascunho e são nomeadas no recibo
--publish-existing
publica os rascunhos PRONTOS já na janela (inclui o que esta
rodada criou se --publish não foi usado), do post mais antigo
para o mais novo (exige --apply)
--days <n> janela em dias (default 60; 1..365)
--limit <n> canário: processa no máximo n candidatos (o resto fica no lote)
--out <dir> recibos JSON (default data/content-instagram)
@ -92,40 +101,6 @@ Flags: TEQO_ENV=staging|production (obrigatória no --apply fora de teste; o nom
do banco tem de casar) e ${WRITE_CONFIRM_FLAG}=1 para a escrita.
`
/**
* The CLI writes straight to the DB, so the collection hook's `revalidateTag`
* is a no-op in this process (the seed loader stubs `next/cache`): the public
* Central is busted over HTTP, best-effort — a failure never fails the run and
* is named in the receipt. Uses the same tag the hook busts.
*/
const revalidatePublicCentral = async () => {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/+$/, '')
const secret = process.env.REVALIDATE_SECRET?.trim()
if (!baseUrl || !secret) {
return { attempted: true, ok: false, reason: 'NEXT_PUBLIC_SITE_URL/REVALIDATE_SECRET ausentes' }
}
try {
const { REVALIDATE_CONTENT_PIECES_TAG } = await import('../src/utilities/revalidateRequest.ts')
const response = await fetch(
`${baseUrl}/api/revalidate?tag=${encodeURIComponent(REVALIDATE_CONTENT_PIECES_TAG)}`,
{
method: 'POST',
headers: { 'x-revalidate-secret': secret },
signal: AbortSignal.timeout(15_000),
},
)
return response.ok
? { attempted: true, ok: true, reason: null }
: { attempted: true, ok: false, reason: `HTTP ${response.status}` }
} catch (error) {
return {
attempted: true,
ok: false,
reason: error instanceof Error && error.message !== '' ? error.message : 'falha de rede',
}
}
}
const writeReport = async (options, report, suffix = '') => {
const stamp = instagramContentReportStamp(report.runAt)
const reportPath = join(options.out, `${stamp}${suffix}.json`)
@ -173,65 +148,30 @@ async function main() {
const { createContentPieceFromProfilePost } =
await import('../src/utilities/content/contentPieceProfileImport.ts')
const { runContentPieceJob } = await import('../src/utilities/content/contentPieceJob.ts')
const { isInstagramFeedConfigured, loadInstagramFeed, persistInstagramAccessToken } =
await import('../src/utilities/socialFeed/instagramFeed.ts')
const { DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD } =
await import('../src/utilities/ai/deepInfraTranscribe.ts')
const payload = await getPayload({ config })
// Intentional admin bypass: the global is admin-only and its credential is
// read solely to call the official API; the token never leaves this process.
const settings = await payload.findGlobal({ slug: 'social-feed-settings', depth: 0 })
if (!isInstagramFeedConfigured(settings)) {
const window = await loadInstagramContentWindowFeed({ payload, days: options.days })
if ('error' in window) {
die(
'Instagram ainda não configurado (token/ID ausentes ou feed desligado no global Social Feed) — a importação só roda pelo caminho oficial.',
window.error === 'unavailable'
? 'Instagram ainda não configurado (token/ID ausentes ou feed desligado no global Social Feed) — a importação só roda pelo caminho oficial.'
: 'não foi possível ler o feed oficial do Instagram agora (API ou credencial) — tente novamente.',
)
}
const { feed, from, to } = window
const to = new Date()
const from = new Date(to.getTime() - options.days * 86_400_000)
let feed
try {
feed = await loadInstagramFeed({
accessToken: settings.instagramAccessToken,
userId: settings.instagramUserId,
maxResults: FEED_MAX_RESULTS,
// The feed is newest-first: the first page carrying a post older than the
// window ends the walk, so the typical run costs one page per 50 medias.
shouldStopAt: (post) => {
const timestamp = Date.parse(post.timestamp)
return Number.isFinite(timestamp) && timestamp < from.getTime()
},
signal: AbortSignal.timeout(FEED_TIMEOUT_MS),
})
} catch {
die(
'não foi possível ler o feed oficial do Instagram agora (API ou credencial) — tente novamente.',
)
}
if (feed.refreshedAccessToken) {
await persistInstagramAccessToken(payload, feed.refreshedAccessToken).catch(() => undefined)
}
// One `sourceUrl in` lookup covers every spelling of every feed post.
// One chunked `sourceUrl in` lookup covers every spelling of every feed post.
const identityUrls = instagramContentFeedIdentityUrls(feed.posts)
let existingSourceUrls = []
if (identityUrls.length > 0) {
const existing = await payload.find({
collection: 'contentPiece',
where: { sourceUrl: { in: identityUrls } },
depth: 0,
limit: 0,
pagination: false,
select: { sourceUrl: true },
// Intentional admin bypass: the ops import must see every catalogue row,
// including drafts no actor could read.
overrideAccess: true,
})
existingSourceUrls = existing.docs
.map((doc) => doc.sourceUrl)
.filter((sourceUrl) => typeof sourceUrl === 'string' && sourceUrl !== '')
}
const catalogued = await findContentPieceSourceRows({
payload,
sourceUrls: identityUrls,
select: { sourceUrl: true },
})
const existingSourceUrls = catalogued
.map((doc) => doc.sourceUrl)
.filter((sourceUrl) => typeof sourceUrl === 'string' && sourceUrl !== '')
const plan = planInstagramContentWindow({
posts: feed.posts,
@ -260,7 +200,7 @@ async function main() {
console.log(line)
}
console.log(
`\n[content:instagram:import] plano sem escrita — para importar: --apply [--publish]`,
`\n[content:instagram:import] plano sem escrita — para importar: --apply [--publish] [--publish-existing]`,
)
console.log(`[content:instagram:import] recibo JSON: ${reportPath}`)
process.exit(0)
@ -344,11 +284,64 @@ async function main() {
}
}
const publishedInWindow = new Set()
let publishedExisting = 0
let publishFailures = 0
const extraFailures = []
if (options.publishExisting && identityUrls.length > 0) {
const drafts = await findContentPieceSourceRows({
payload,
sourceUrls: identityUrls,
where: [{ status: { equals: 'rascunho' } }, { processingStatus: { equals: 'pronto' } }],
select: { title: true, sourceUrl: true, linkFailureReason: true },
})
const planned = planInstagramContentDraftsToPublish({ pieces: drafts, posts: feed.posts })
// Oldest post first so the newest carries the latest `publishedAt` (the
// public listing sorts by `-publishedAt`). This phase runs BEFORE the
// run's own `--publish` so a combined run keeps that order.
for (const piece of planned.ordered) {
try {
await payload.update({
collection: 'contentPiece',
id: piece.id,
data: { status: 'publicado' },
depth: 0,
// Intentional admin bypass: the ops run publishes what it listed.
overrideAccess: true,
})
publishedExisting += 1
publishedInWindow.add(piece.id)
if (publishedExisting % 50 === 0) {
console.log(
`[content:instagram:import] publicando rascunhos da janela: ${publishedExisting}…`,
)
}
} catch (error) {
publishFailures += 1
const message =
error instanceof Error && error.message !== '' ? error.message : 'falha inesperada'
extraFailures.push({ shortcode: `#${piece.id}`, error: `publicação: ${message}` })
}
}
for (const piece of planned.undateable) {
console.log(
`[content:instagram:import] sem data no feed — não publicada: #${piece.id} ${piece.title ?? ''}`.trim(),
)
}
console.log(
`[content:instagram:import] rascunhos prontos da janela: ${planned.ordered.length} · publicadas: ${publishedExisting}${
planned.linkOnly > 0 ? ` (peça-link: ${planned.linkOnly})` : ''
}`,
)
}
if (options.publish) {
// Oldest first so the newest post carries the latest `publishedAt` — the
// public listing sorts by `-publishedAt`, so the newest leads the Central.
for (const entry of [...created].reverse()) {
if (publishedInWindow.has(entry.contentPieceId)) continue
if (!shouldPublishImportedPiece({ ...entry, publish: true })) continue
try {
await payload.update({
@ -377,8 +370,8 @@ async function main() {
}
let revalidation = { attempted: false, ok: false, reason: null }
if (results.some((result) => result.published === true)) {
revalidation = await revalidatePublicCentral()
if (publishedExisting > 0 || results.some((result) => result.published === true)) {
revalidation = await revalidateInstagramContentCentral()
console.log(
`[content:instagram:import] Central pública: ${
revalidation.ok ? 'revalidada' : `revalidação falhou (${revalidation.reason})`
@ -389,9 +382,12 @@ async function main() {
const summary = summarizeInstagramContentResults(results, {
costPerMinuteUsd: DEEPINFRA_TRANSCRIBE_COST_PER_MINUTE_USD,
})
const failures = results
.filter((result) => result.error !== null)
.map((result) => ({ shortcode: result.shortcode, error: result.error }))
const failures = [
...results
.filter((result) => result.error !== null)
.map((result) => ({ shortcode: result.shortcode, error: result.error })),
...extraFailures,
]
const candidatesRemaining = plan.candidates.length - selected.length
const runAt = new Date().toISOString()
const report = {
@ -407,6 +403,7 @@ async function main() {
failures,
candidatesRemaining,
publishFailures,
publishedExisting,
revalidation,
}
const reportPath = await writeReport(options, report, '-apply')
@ -417,6 +414,7 @@ async function main() {
feedCount: report.feed.count,
plan,
summary,
publishedExisting,
failures,
candidatesRemaining,
reportPath,

View file

@ -80,7 +80,14 @@ const loadSources = async (payload, { source, limit }) => {
depth: 0,
...(limit ? { limit, page: 1 } : { limit: 0, pagination: false }),
sort: 'speechAt',
select: { sourceKey: true, officialTranscript: true, summary: true, searchText: true },
select: {
sourceKey: true,
officialTranscript: true,
summary: true,
title: true,
keywords: true,
searchText: true,
},
// Intentional bypass: the index CLI is a trusted actor with no session.
overrideAccess: true,
})
@ -116,6 +123,8 @@ const loadSources = async (payload, { source, limit }) => {
sourceKey: speech.sourceKey,
officialTranscript: speech.officialTranscript,
summary: speech.summary,
title: speech.title,
keywords: speech.keywords,
searchText: speech.searchText,
segments: segmentsBySpeech.get(speech.id) ?? [],
}))
@ -204,6 +213,7 @@ async function main() {
indexed: result.indexed,
failed: result.failed,
units: result.units,
metadataTexts: result.metadataTexts,
estimatedBytes: speechIndexEstimatedBytes(result.units, DEEPINFRA_EMBED_DIMENSIONS),
promptTokens: result.promptTokens,
costUsd: speechIndexCostUsd(result.promptTokens, DEEPINFRA_EMBED_COST_PER_MILLION_TOKENS_USD),

View file

@ -11,6 +11,8 @@
import { parse as parseCsv } from 'csv-parse/sync'
import XLSX from 'xlsx'
import { motionKindFor } from './chartMotion.mjs'
export const CHART_TYPES = ['bar', 'column', 'line', 'anchor', 'delta']
/** Max data points per chart (honest density; above this the person must summarize). */
@ -23,6 +25,16 @@ export const MAX_POINTS = 7
*/
export const MAX_POINTS_STORY = 5
/**
* Paired columns (C237, degraded design): the two-measure-per-category dataset
* of the delta drawn as two zero-based columns per category — the initial in
* the neutral tone, the recent one in the official red (the positive datum is
* shared by every pair, never a winner). The 30px category label under a pair
* caps the variant at four categories on the 1080 canvas; above that the labels
* collide and the builder asks instead of drawing.
*/
export const MAX_PAIRED = 4
/**
* Two-series and three-series time comparison (approved variants): an annual
* series carries more points than a ranking, per series. One tone per series
@ -643,6 +655,42 @@ export const validateSpec = (spec) => {
}
throw new Error(`tipo de gráfico inválido: ${JSON.stringify(chartType)}`)
}
if (spec.pairedColumns !== undefined && typeof spec.pairedColumns !== 'boolean') {
throw new Error('pairedColumns inválido — use booleano.')
}
if (spec.pairedColumns) {
if (chartType !== 'delta') {
throw new Error(
'as colunas emparelhadas exigem o tipo delta (--type=delta): o dado é rótulo + inicial + final.',
)
}
if (spec.highlight) {
throw new Error(
'as colunas emparelhadas não usam destaque — o par de tons é fixo (inicial cinza, recente vermelho).',
)
}
if (spec.noHighlight || spec.dualPositive) {
throw new Error(
'as colunas emparelhadas não combinam com --no-highlight/--dual-positive — o par de tons é fixo.',
)
}
if (!Array.isArray(spec.rows) || spec.rows.length === 0) {
throw new Error('sem pontos de dado para desenhar.')
}
if (spec.rows.length > MAX_PAIRED) {
throw new Error(
`${spec.rows.length} categorias (> ${MAX_PAIRED}): os rótulos colidem sob os pares — agrupe/remova categorias antes de gerar o gráfico.`,
)
}
}
if (spec.motion !== undefined && typeof spec.motion !== 'boolean') {
throw new Error('motion inválido — use booleano.')
}
if (spec.motion && !motionKindFor(spec)) {
throw new Error(
'o motion cobre as colunas emparelhadas (--paired), a coluna de 2 períodos e o número-âncora.',
)
}
if (chartType === 'delta') {
validateDelta(spec)
} else if (Array.isArray(series) && series.length > 0) {

172
scripts/lib/chartMotion.mjs Normal file
View file

@ -0,0 +1,172 @@
/**
* Chart motion (C238/C239): the deterministic timeline that animates the
* pieces without touching the approved static design. The schedule is data,
* not CSS — the renderer serializes each stage into `--md` (delay) and `--mud`
* (duration) custom properties on the element and the stylesheet carries only
* the keyframes and the easing, so the clock is pinned here and tested
* directly.
*
* Variants: the paired columns (C238 prototype) and, in the C239 extension,
* the two-period column and the anchor number. The capture never records in
* real time: every CSS animation is seeked to the frame time (30fps grid, 6s),
* screenshotted, and the MP4 is encoded offline from those exact timestamps
* (see `chartMotionRender.mjs`).
*/
export const MOTION_FPS = 30
export const MOTION_DURATION_S = 6
export const MOTION_FRAMES = MOTION_DURATION_S * MOTION_FPS
export const MOTION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'
/**
* Shell stages (seconds). The card is already composed for reading: the rule,
* kicker, headline and subtitle enter first, then the data marks grow, and the
* footer (source + brand) lands last — right before the static hold. `legend`
* belongs to the paired variant; `number`/`copy` belong to the anchor.
*/
export const MOTION_STAGES = {
topRule: { kind: 'sweep', delay: 0, duration: 0.45 },
kicker: { kind: 'rise', delay: 0.2, duration: 0.45 },
headline: { kind: 'rise', delay: 0.35, duration: 0.55 },
subtitle: { kind: 'rise', delay: 0.65, duration: 0.45 },
legend: { kind: 'fade', delay: 0.8, duration: 0.4 },
number: { kind: 'rise', delay: 1.05, duration: 0.6 },
copy: { kind: 'fade', delay: 1.7, duration: 0.45 },
footer: { kind: 'fade', delay: 3.1, duration: 0.5 },
}
/**
* Per-mark stages of the bar variants: the mark grows, the label enters with
* the growth and the value lands after the bar settles. The paired columns
* stagger by pair (the two columns of a pair grow together); the two-period
* column staggers per column, left to right (the recent red one last).
*/
export const PAIR_MOTION = {
firstAt: 1.1,
stagger: 0.3,
barDuration: 0.75,
labelOffset: 0.15,
labelDuration: 0.3,
valueOffset: 0.8,
valueDuration: 0.3,
}
export const COLUMN_MOTION = {
firstAt: 1.1,
stagger: 0.35,
barDuration: 0.75,
labelOffset: 0.15,
labelDuration: 0.3,
valueOffset: 0.8,
valueDuration: 0.3,
}
/** Milliseconds-rounded seconds: the inline CSS vars must read `1.4s`, not `1.4000000000000001s`. */
const round3 = (value) => Math.round(value * 1000) / 1000
const markMotion = (config, index) => {
const barAt = round3(config.firstAt + index * config.stagger)
return {
bar: { kind: 'grow', delay: barAt, duration: config.barDuration },
label: {
kind: 'fade',
delay: round3(barAt + config.labelOffset),
duration: config.labelDuration,
},
value: {
kind: 'rise',
delay: round3(barAt + config.valueOffset),
duration: config.valueDuration,
},
}
}
/** @param {number} index pair position, left to right (0-based) */
export const pairMotion = (index) => markMotion(PAIR_MOTION, index)
/** @param {number} index column position, left to right (0-based) */
export const columnMotion = (index) => markMotion(COLUMN_MOTION, index)
/**
* Motion variant of a spec — `null` means the shape has no certified motion
* yet (the builder refuses instead of animating something undesigned).
*/
export const motionKindFor = (spec) => {
if (spec.pairedColumns) return 'paired'
if (spec.chartType === 'anchor') return 'anchor'
if (spec.chartType === 'column') return 'column'
return null
}
const SHELL_STAGES = ['topRule', 'kicker', 'headline', 'subtitle', 'footer']
/** Every stage a spec animates — the clock guard reads this list. */
export const motionStages = (spec) => {
const kind = motionKindFor(spec)
const shell = SHELL_STAGES.map((name) => MOTION_STAGES[name])
if (kind === 'anchor') return [...shell, MOTION_STAGES.number, MOTION_STAGES.copy]
const rows = Array.isArray(spec.rows) ? spec.rows : []
const marks = rows.flatMap((_row, index) =>
Object.values(kind === 'column' ? columnMotion(index) : pairMotion(index)),
)
return kind === 'paired' ? [...shell, MOTION_STAGES.legend, ...marks] : [...shell, ...marks]
}
/** Last stage end (seconds) of a stage list. */
export const motionEnd = (stages) =>
Math.max(...stages.map((stage) => stage.delay + stage.duration))
/**
* Fail-closed: the shape must have a certified motion, the column motion is
* certified for the two-period pair (C239) and the piece must be fully settled
* inside the exported duration (the tail is a static hold the editor can cut
* into).
*/
export const validateMotion = (spec) => {
const kind = motionKindFor(spec)
if (!kind) {
throw new Error(
'motion indisponível para esta variante: o protótipo cobre as colunas emparelhadas, a coluna de 2 períodos e o número-âncora.',
)
}
if (kind === 'column' && (!Array.isArray(spec.rows) || spec.rows.length !== 2)) {
throw new Error(
`o motion da coluna está certificado para 2 períodos (${spec.rows?.length ?? 0} recebidos): o protótipo C239 cobre o par de períodos.`,
)
}
const end = motionEnd(motionStages(spec))
if (end > MOTION_DURATION_S) {
throw new Error(
`motion termina em ${end}s (> ${MOTION_DURATION_S}s): reduza os pontos antes de gerar.`,
)
}
return { kind, end, duration: MOTION_DURATION_S }
}
/** Deterministic 30fps grid (ms): the capture seeks every animation to these. */
export const motionFrameTimes = () =>
Array.from({ length: MOTION_FRAMES }, (_value, index) => (index * 1000) / MOTION_FPS)
/**
* Keyframes and the generic animated classes. Timings never live here: each
* element carries `--md`/`--mud` from the schedule above. `backwards` fill
* means the from-state holds through the delay and the element ends on its
* natural (static) styles — the last frame is exactly the approved PNG.
*/
export const MOTION_CSS = `@keyframes motion-sweep { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes motion-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } }
@keyframes motion-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes motion-grow { from { transform: scaleY(0); } to { transform: scaleY(1); } }
.motion .motion-sweep,
.motion .motion-rise,
.motion .motion-fade,
.motion .motion-grow {
animation-fill-mode: backwards;
animation-timing-function: ${MOTION_EASING};
animation-duration: var(--mud, 0.5s);
animation-delay: var(--md, 0s);
}
.motion .motion-sweep { animation-name: motion-sweep; transform-origin: left center; }
.motion .motion-rise { animation-name: motion-rise; }
.motion .motion-fade { animation-name: motion-fade; }
.motion .motion-grow { animation-name: motion-grow; transform-origin: bottom center; }`

View file

@ -0,0 +1,107 @@
/**
* C238 chart motion render: deterministic frame capture (one page, every CSS
* animation seeked to the frame time and paused) plus the offline H.264 encode
* with the packaged ffmpeg. Frames live in the gitignored `data/` workdir and
* are removed after the encode — the MP4 is the only artifact. Real-time
* recording is never used: the same reasoning of the reel capture applies (a
* fixed-rate recorder drifts from the clock; here the clock is exact anyway).
*/
import { mkdir, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { MOTION_FPS, MOTION_FRAMES } from './chartMotion.mjs'
import { encodeFrames, REEL_CRF, REEL_PRESET, resolveFfmpeg } from './reelFfmpeg.mjs'
/**
* Numbered PNG frames of the motion document: after `load` every CSS animation
* is seeked to the frame timestamp and paused, so the frame is exact no matter
* how fast Chromium renders it.
*
* @param {any} browser Chromium from `launchPdfBrowser`
* @param {{ html: string, width: number, height: number, dir: string, frames?: number, fps?: number }} options
*/
const captureMotionFrames = async (
browser,
{ html, width, height, dir, frames = MOTION_FRAMES, fps = MOTION_FPS },
) => {
const page = await browser.newPage()
try {
await page.setViewportSize({ width, height })
await page.setContent(html, { waitUntil: 'load' })
// Freeze the timeline before anything else: Chromium drops finished CSS
// animations, and a slow font load would make the first stages unseekable.
await page.evaluate(() => {
for (const animation of document.getAnimations()) {
animation.currentTime = 0
animation.pause()
}
})
await page.evaluate(() => document.fonts.ready)
const captured = []
for (let index = 0; index < frames; index += 1) {
const captureMs = (index * 1000) / fps
await page.evaluate((time) => {
for (const animation of document.getAnimations()) {
animation.currentTime = time
animation.pause()
}
}, captureMs)
const file = join(dir, `frame-${String(index).padStart(4, '0')}.png`)
await page.screenshot({ path: file, type: 'png', clip: { x: 0, y: 0, width, height } })
captured.push({ file, captureMs })
}
return captured
} finally {
await page.close()
}
}
/**
* Full local render of one animated chart: resolve ffmpeg (`FFMPEG_PATH` →
* PATH → packaged), capture the frames into `workDir`, encode and clean up.
* `resolveFfmpegImpl`/`encodeImpl` are injectable so the CLI spec runs without
* a real binary.
*
* @returns {Promise<{ size: number, source: string }>}
*/
export const renderChartMotion = async ({
browser,
html,
width,
height,
outPath,
workDir,
frames = MOTION_FRAMES,
fps = MOTION_FPS,
resolveFfmpegImpl = resolveFfmpeg,
encodeImpl = encodeFrames,
}) => {
await mkdir(workDir, { recursive: true })
let source = ''
try {
const ffmpeg = await resolveFfmpegImpl()
source = ffmpeg.source
const captured = await captureMotionFrames(browser, {
html,
width,
height,
dir: workDir,
frames,
fps,
})
await encodeImpl({
ffmpegBin: ffmpeg.bin,
frames: captured,
listPath: join(workDir, 'frames.txt'),
output: outPath,
fps,
preset: REEL_PRESET,
crf: REEL_CRF,
})
} finally {
await rm(workDir, { recursive: true, force: true })
}
const { size } = await stat(outPath)
return { size, source }
}

View file

@ -10,6 +10,7 @@
*/
import { RELATION_LABEL, SERIES_RELATION_LABEL, SIZES } from './chartData.mjs'
import { MOTION_CSS, MOTION_STAGES, columnMotion, pairMotion } from './chartMotion.mjs'
import {
dualLineChart,
lineChart,
@ -65,6 +66,7 @@ const rankingBody = (spec) => {
}
const columnBody = (spec) => {
const motion = spec.motion === true
const rows = spec.rows
const max = Math.max(...rows.map((row) => row.value))
const winner = highlightLabel(spec)
@ -78,10 +80,13 @@ const columnBody = (spec) => {
.map((row, index) => {
const isHighlight = !spec.dualPositive && row.label === winner
const tone = spec.dualPositive ? ` positive-${index === 0 ? 'a' : 'b'}` : ''
// C239: the two-period motion grows each column left to right (the
// recent red one last), with the value landing after the bar settles.
const timing = columnMotion(index)
return `<div class="column-cell">
<span class="column-value">${formatValue(row.value, spec.unit)}</span>
<div class="column${isHighlight ? ' highlight' : ''}${tone}" style="height:${proportionalPercent(row.value, max)}%"></div>
<span class="column-label">${htmlEscape(row.label)}</span>
<span class="column-value${motionClass(motion, timing.value)}"${styleAttr(motionVars(motion, timing.value))}>${formatValue(row.value, spec.unit)}</span>
<div class="column${isHighlight ? ' highlight' : ''}${tone}${motionClass(motion, timing.bar)}"${styleAttr(styleJoin(`height:${proportionalPercent(row.value, max)}%`, motionVars(motion, timing.bar)))}></div>
<span class="column-label${motionClass(motion, timing.label)}"${styleAttr(motionVars(motion, timing.label))}>${htmlEscape(row.label)}</span>
</div>`
})
.join('')}
@ -93,6 +98,13 @@ const formatPercent = (value) => {
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
}
/** Motion plumbing: the schedule travels as `--md`/`--mud` inline vars (C238). */
const styleAttr = (value) => (value ? ` style="${value}"` : '')
const styleJoin = (...parts) => parts.filter(Boolean).join(';')
const motionClass = (motion, timing) => (motion && timing ? ` motion-${timing.kind}` : '')
const motionVars = (motion, timing) =>
motion && timing ? `--md:${timing.delay}s;--mud:${timing.duration}s` : ''
const deltaOrder = (rows) =>
[...rows].sort(
(a, b) => b.final - a.final || a.initial - b.initial || a.label.localeCompare(b.label, 'pt-BR'),
@ -145,12 +157,69 @@ const deltaBody = (spec) => {
</div>`
}
/**
* Paired columns (C237 degraded design): the same two-measure dataset of the
* delta, drawn as two zero-based columns per category — the initial in the
* neutral tone and the recent one in the official red (the positive datum is
* shared by every pair, never a winner). The period legend carries the color
* cue, the value travels over each bar and the category label under the pair,
* so color never carries the reading alone. An initial of zero simply has no
* gray column.
*
* C238 motion: under `spec.motion` the pairs grow left to right (both columns
* together, labels with the growth, values after the bar lands) and the legend
* fades in with the shell staging — the timing comes from `chartMotion.mjs`.
*/
const pairedBody = (spec) => {
const motion = spec.motion === true
const rows = deltaOrder(spec.rows)
const max = Math.max(1, ...rows.map((row) => row.final))
const aria = `Colunas emparelhadas por categoria, ${spec.startLabel} e ${spec.endLabel}: ${rows
.map(
(row) =>
`${row.label} ${formatValue(row.initial, spec.unit)} e ${formatValue(row.final, spec.unit)}`,
)
.join('; ')}. A coluna cinza é o início do período; a vermelha é o dado mais recente.`
return `<div class="paired-grade" role="img" aria-label="${htmlEscape(aria)}">
<div class="paired-legend${motionClass(motion, MOTION_STAGES.legend)}"${styleAttr(motionVars(motion, MOTION_STAGES.legend))}>
<span><i class="paired-swatch initial"></i>${htmlEscape(spec.startLabel)}</span>
<span><i class="paired-swatch final"></i>${htmlEscape(spec.endLabel)}</span>
</div>
<div class="paired-columns" style="grid-template-columns:repeat(${rows.length}, minmax(0, 1fr))">
${rows
.map((row, index) => {
const timing = pairMotion(index)
const initialHeight = formatPercent((row.initial / max) * 100)
const finalHeight = formatPercent((row.final / max) * 100)
return `<div class="paired-pair">
<div class="paired-bar-cell">
<span class="paired-value${motionClass(motion, timing.value)}"${styleAttr(motionVars(motion, timing.value))}>${formatValue(row.initial, spec.unit)}</span>${
row.initial > 0
? `<span class="paired-bar${motionClass(motion, timing.bar)}"${styleAttr(styleJoin(`height:${initialHeight}%`, motionVars(motion, timing.bar)))}></span>`
: ''
}
</div>
<div class="paired-bar-cell">
<span class="paired-value${motionClass(motion, timing.value)}"${styleAttr(motionVars(motion, timing.value))}>${formatValue(row.final, spec.unit)}</span>
<span class="paired-bar recent${motionClass(motion, timing.bar)}"${styleAttr(styleJoin(`height:${finalHeight}%`, motionVars(motion, timing.bar)))}></span>
</div>
<span class="paired-label${motionClass(motion, timing.label)}"${styleAttr(motionVars(motion, timing.label))}>${deltaLabelHtml(row.label)}</span>
</div>`
})
.join('')}
</div>
</div>`
}
const anchorBody = (spec) => {
// C239: the anchor motion rises the hero number and fades the copy after it —
// same vocabulary, never a dramatic count-up.
const motion = spec.motion === true
const row = spec.rows[0]
const copy = row.label || spec.subtitle || ''
return `<div class="anchor">
<div class="anchor-number">${formatValue(row.value, spec.unit)}</div>
${copy ? `<p class="anchor-copy">${htmlEscape(copy)}</p>` : ''}
<div class="anchor-number${motionClass(motion, MOTION_STAGES.number)}"${styleAttr(motionVars(motion, MOTION_STAGES.number))}>${formatValue(row.value, spec.unit)}</div>
${copy ? `<p class="anchor-copy${motionClass(motion, MOTION_STAGES.copy)}"${styleAttr(motionVars(motion, MOTION_STAGES.copy))}>${htmlEscape(copy)}</p>` : ''}
</div>`
}
@ -215,7 +284,7 @@ const tripleBody = (spec) =>
})}</div>`
const plotBody = (spec) => {
if (spec.chartType === 'delta') return deltaBody(spec)
if (spec.chartType === 'delta') return spec.pairedColumns ? pairedBody(spec) : deltaBody(spec)
const seriesCount = Array.isArray(spec.series) ? spec.series.length : 0
if (seriesCount === 3) return tripleBody(spec)
if (seriesCount > 0) return dualBody(spec)
@ -241,7 +310,8 @@ const brandMark = (brandMarkDataUri) => `<div class="brand-logo-frame">
<img src="${brandMarkDataUri}" alt="Jorge Solla — Deputado Federal" />
</div>`
const footer = (spec, brandMarkDataUri) => `<footer class="footer">
const footer = (spec, brandMarkDataUri, motion = false) =>
`<footer class="footer${motionClass(motion, MOTION_STAGES.footer)}"${styleAttr(motionVars(motion, MOTION_STAGES.footer))}>
<p class="source"><strong>Fonte:</strong> ${htmlEscape(spec.source)}${spec.note ? `<br />Nota: ${htmlEscape(spec.note)}` : ''}</p>
${brandMark(brandMarkDataUri)}
</footer>`
@ -325,6 +395,39 @@ html, body { margin: 0; background: ${SOLLA_PALETTE.paper}; }
text-align: right;
white-space: nowrap;
}
.paired-grade { display: flex; flex-direction: column; height: 100%; }
.paired-legend {
flex: none; display: flex; justify-content: flex-end; align-items: center;
gap: 26px; margin-bottom: 16px; color: ${SOLLA_PALETTE.label};
font-size: 28px; line-height: 1; font-weight: 700;
}
.paired-legend span { display: inline-flex; align-items: center; gap: 10px; }
.paired-swatch { width: 18px; height: 18px; flex: 0 0 18px; }
.paired-swatch.initial { background: ${SOLLA_PALETTE.bar}; }
.paired-swatch.final { background: ${SOLLA_PALETTE.highlight}; }
.paired-columns {
flex: 1; min-height: 0; display: grid; align-items: end; gap: 30px;
padding: 0 5px 46px; border-bottom: 3px solid ${SOLLA_PALETTE.barStrong};
}
.paired-pair {
position: relative; display: flex; align-items: flex-end; justify-content: center;
gap: 12px; height: 100%;
}
.paired-bar-cell {
position: relative; display: flex; flex-direction: column; justify-content: flex-end;
width: 50%; max-width: 86px; height: 100%;
}
.paired-value {
margin-bottom: 10px; color: ${SOLLA_PALETTE.ink}; text-align: center;
font-size: 31px; line-height: 1; font-weight: 800; font-variant-numeric: tabular-nums;
}
.paired-bar { background: ${SOLLA_PALETTE.bar}; }
.paired-bar.recent { background: ${SOLLA_PALETTE.highlight}; }
.paired-label {
position: absolute; bottom: -40px; left: 50%; transform: translateX(-50%);
color: ${SOLLA_PALETTE.label}; text-align: center; white-space: nowrap;
font-size: 30px; line-height: 1; font-weight: 650;
}
.columns { display: grid; justify-content: center; align-items: end; gap: 26px; height: 100%; padding-bottom: 46px; border-bottom: 3px solid ${SOLLA_PALETTE.barStrong}; }
.plot.dual-positive-plot { flex: none; height: 500px; margin-top: 34px; }
.column.positive-a { background: ${SOLLA_PALETTE.highlight}; }
@ -457,7 +560,11 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
: sizeKey === 'story'
? '.footer { min-height: 190px; padding-top: 28px; gap: 28px; } .brand-logo-frame { width: 279px; height: 160px; flex-basis: 279px; }'
: ''
const delta = spec.chartType === 'delta'
const paired = spec.chartType === 'delta' && Boolean(spec.pairedColumns)
const delta = spec.chartType === 'delta' && !paired
// C238: motion is the paired-columns prototype; the schedule lives in
// chartMotion.mjs and reaches each element as inline --md/--mud vars.
const motion = spec.motion === true
const deltaStyles = !delta
? ''
: sizeKey === 'feed'
@ -465,6 +572,13 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
: sizeKey === 'square'
? '.inner { padding: 56px 84px 48px; } .plot { margin-top: 0; } .headline { font-size: 54px; } .subtitle { font-size: 28px; } .delta-grade { margin-top: 24px; --delta-bar-h: 42px; --delta-gap: 12px; --delta-v1-w: 84px; --delta-v2-w: 104px; } .delta-value { font-size: 30px; }'
: '.plot { margin-top: 0; } .headline { font-size: 64px; } .delta-grade { margin-top: 40px; --delta-gap: 22px; --delta-bar-h: 52px; }'
const pairedStyles = !paired
? ''
: sizeKey === 'feed'
? '.plot { margin-top: 26px; } .headline { font-size: 60px; max-width: 900px; } .subtitle { margin-top: 18px; font-size: 28px; }'
: sizeKey === 'square'
? '.inner { padding: 56px 84px 48px; } .plot { margin-top: 20px; } .headline { font-size: 54px; } .subtitle { font-size: 28px; } .paired-legend { font-size: 26px; } .paired-value { font-size: 30px; } .paired-label { bottom: -36px; }'
: '.plot { margin-top: 40px; } .headline { font-size: 60px; } .subtitle { font-size: 30px; }'
const kicker =
spec.kicker ??
(dual || triple ? SERIES_RELATION_LABEL : (RELATION_LABEL[spec.chartType] ?? 'Gráfico'))
@ -475,6 +589,7 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
<head>
<meta charset="utf-8" />
<style>${CSS}</style>
${motion ? `<style>${MOTION_CSS}</style>` : ''}
<style>
.canvas { width: ${canvas.width}px; height: ${canvas.height}px; }
.inner { padding: ${layout.padding}; }
@ -486,18 +601,19 @@ export const renderChartHtml = (spec, { brandLogo: brandLogoDataUri } = {}) => {
${footerStyles}
${dualStyles}
${deltaStyles}
${pairedStyles}
${dualPositiveStyles}
</style>
</head>
<body>
<article class="canvas${dual ? ' dual-series' : ''}${triple ? ' triple-series' : ''}${tripleProjected ? ' triple-projected' : ''}" role="img" aria-label="${htmlEscape(spec.headline)}">
<article class="canvas${dual ? ' dual-series' : ''}${triple ? ' triple-series' : ''}${tripleProjected ? ' triple-projected' : ''}${motion ? ' motion' : ''}" role="img" aria-label="${htmlEscape(spec.headline)}">
<div class="inner">
<div class="top-rule"></div>
<p class="context">${htmlEscape(kicker)}</p>
<h1 class="headline">${htmlEscape(spec.headline)}</h1>
${showSubtitle ? `<p class="subtitle">${htmlEscape(spec.subtitle)}</p>` : ''}
<div class="top-rule${motionClass(motion, MOTION_STAGES.topRule)}"${styleAttr(motionVars(motion, MOTION_STAGES.topRule))}></div>
<p class="context${motionClass(motion, MOTION_STAGES.kicker)}"${styleAttr(motionVars(motion, MOTION_STAGES.kicker))}>${htmlEscape(kicker)}</p>
<h1 class="headline${motionClass(motion, MOTION_STAGES.headline)}"${styleAttr(motionVars(motion, MOTION_STAGES.headline))}>${htmlEscape(spec.headline)}</h1>
${showSubtitle ? `<p class="subtitle${motionClass(motion, MOTION_STAGES.subtitle)}"${styleAttr(motionVars(motion, MOTION_STAGES.subtitle))}>${htmlEscape(spec.subtitle)}</p>` : ''}
<div class="plot${dualPositive ? ' dual-positive-plot' : ''}">${plotBody(spec)}</div>
${footer(spec, brandLogoDataUri)}
${footer(spec, brandLogoDataUri, motion)}
</div>
</article>
</body>

View file

@ -0,0 +1,154 @@
/**
* C230-followup — shared impure plumbing of the Instagram content ops CLIs
* (`content:instagram:import` and `content:instagram:withdraw`): the window of
* the own profile's official feed read with the global's credential (fail-
* closed, refreshed token persisted best-effort), the chunked `sourceUrl in`
* lookups over the Central and the best-effort revalidation of the public
* Central after a direct-DB write. The planning/reporting stays pure in
* `instagramContentPlan.mjs`; the token never leaves this module.
*/
import { REVALIDATE_CONTENT_PIECES_TAG } from '../../src/utilities/revalidateRequest.ts'
import {
isInstagramFeedConfigured,
loadInstagramFeed,
persistInstagramAccessToken,
} from '../../src/utilities/socialFeed/instagramFeed.ts'
/** A hung Graph API must not hold the run forever. */
const INSTAGRAM_CONTENT_FEED_TIMEOUT_MS = 60_000
/**
* The edge serves up to 10K media; the date early-stop bounds the walk, so the
* cap is the platform's own ceiling — a deep window is never silently cut at
* an arbitrary page count.
*/
const INSTAGRAM_CONTENT_FEED_MAX_RESULTS = 10_000
/**
* Chunk of one `sourceUrl in` lookup: a deep window (up to 10K media × 3 URL
* spellings) would blow the query/payload size in a single `in`.
*/
const CONTENT_PIECE_SOURCE_URL_CHUNK = 2_000
/**
* Reads the own-profile feed window with the global's credential, fail-closed:
* an unconfigured credential never reaches the API, and a feed failure is told
* apart from the missing credential so each CLI prints its own honest message.
* A refreshed token is persisted best-effort, exactly like the link path.
*
* @param {{
* payload: import('payload').Payload,
* days: number,
* maxResults?: number,
* timeoutMs?: number,
* }} input
* @returns {Promise<
* | { feed: Awaited<ReturnType<typeof loadInstagramFeed>>, from: Date, to: Date }
* | { error: 'unavailable' | 'feed' }
* >}
*/
export const loadInstagramContentWindowFeed = async ({
payload,
days,
maxResults = INSTAGRAM_CONTENT_FEED_MAX_RESULTS,
timeoutMs = INSTAGRAM_CONTENT_FEED_TIMEOUT_MS,
}) => {
// Intentional admin bypass: the global is admin-only and its credential is
// read solely to call the official API; the token never leaves this call.
const settings = await payload.findGlobal({ slug: 'social-feed-settings', depth: 0 })
if (!isInstagramFeedConfigured(settings)) return { error: 'unavailable' }
const to = new Date()
const from = new Date(to.getTime() - days * 86_400_000)
let feed
try {
feed = await loadInstagramFeed({
accessToken: settings.instagramAccessToken,
userId: settings.instagramUserId,
maxResults,
// The feed is newest-first: the first page carrying a post older than the
// window ends the walk, so the typical run costs one page per 50 medias.
shouldStopAt: (post) => {
const timestamp = Date.parse(post.timestamp)
return Number.isFinite(timestamp) && timestamp < from.getTime()
},
signal: AbortSignal.timeout(timeoutMs),
})
} catch {
return { error: 'feed' }
}
if (feed.refreshedAccessToken) {
await persistInstagramAccessToken(payload, feed.refreshedAccessToken).catch(() => undefined)
}
return { feed, from, to }
}
/**
* One chunked lookup of the Central by `sourceUrl`, with optional extra `and`
* conditions (status/processing filters). `select` keeps the rows lean.
*
* @param {{
* payload: import('payload').Payload,
* sourceUrls: readonly string[],
* where?: readonly Record<string, unknown>[],
* select: Record<string, true>,
* }} input
*/
export const findContentPieceSourceRows = async ({ payload, sourceUrls, where = [], select }) => {
const unique = [...new Set(sourceUrls)]
const docs = []
for (let index = 0; index < unique.length; index += CONTENT_PIECE_SOURCE_URL_CHUNK) {
const chunk = unique.slice(index, index + CONTENT_PIECE_SOURCE_URL_CHUNK)
const found = await payload.find({
collection: 'contentPiece',
where: { and: [{ sourceUrl: { in: chunk } }, ...where] },
depth: 0,
limit: 0,
pagination: false,
select,
// Intentional admin bypass: the ops run must see every catalogue row,
// including drafts no actor could read.
overrideAccess: true,
})
docs.push(...found.docs)
}
return docs
}
/**
* The CLIs write straight to the DB, so the collection hook's `revalidateTag`
* is a no-op in their process (the seed loader stubs `next/cache`): the public
* Central is busted over HTTP, best-effort — a failure never fails the run and
* is named in the receipt. Uses the same tag the hook busts.
*
* @returns {Promise<{ attempted: boolean, ok: boolean, reason: string | null }>}
*/
export const revalidateInstagramContentCentral = async () => {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/+$/, '')
const secret = process.env.REVALIDATE_SECRET?.trim()
if (!baseUrl || !secret) {
return { attempted: true, ok: false, reason: 'NEXT_PUBLIC_SITE_URL/REVALIDATE_SECRET ausentes' }
}
try {
const response = await fetch(
`${baseUrl}/api/revalidate?tag=${encodeURIComponent(REVALIDATE_CONTENT_PIECES_TAG)}`,
{
method: 'POST',
headers: { 'x-revalidate-secret': secret },
signal: AbortSignal.timeout(15_000),
},
)
return response.ok
? { attempted: true, ok: true, reason: null }
: { attempted: true, ok: false, reason: `HTTP ${response.status}` }
} catch (error) {
return {
attempted: true,
ok: false,
reason: error instanceof Error && error.message !== '' ? error.message : 'falha de rede',
}
}
}

View file

@ -1,14 +1,18 @@
/**
* C230-followup — pure planning/reporting of the Instagram content import CLI
* (`import-instagram-content.mjs`): filter the official feed to the declared
* recency window, classify each post into an import candidate, dedupe by the
* post identity (what the Central already carries, in any URL spelling) and
* aggregate the honest run report. No I/O and no `server-only`: the unit specs
* read this module and the CLI is the impure shell.
* C230-followup — pure planning/reporting of the Instagram content ops CLIs
* (`import-instagram-content.mjs` and `withdraw-instagram-content.mjs`):
* filter the official feed to the declared recency window, classify each post
* into an import candidate, dedupe by the post identity (what the Central
* already carries, in any URL spelling), order the window's ready drafts for
* publication, split the published set by a civil cutoff and aggregate the
* honest run reports. No I/O and no `server-only`: the unit specs read this
* module and the CLIs are the impure shells.
*/
import { parseContentPieceLink } from '../../src/lib/contentPiece.ts'
import {
contentPieceProfileFeedIdentityUrls,
contentPieceProfilePeriodBounds,
planContentPieceProfileWindow,
} from '../../src/lib/contentPieceProfileWindow.ts'
@ -27,7 +31,10 @@ const INSTAGRAM_CONTENT_MAX_DAYS = 365
const INSTAGRAM_CONTENT_DEFAULT_OUT_DIR = 'data/content-instagram'
const INSTAGRAM_CONTENT_USAGE =
'pnpm content:instagram:import [--apply] [--publish] [--days 60] [--limit n] [--out data/content-instagram]'
'pnpm content:instagram:import [--apply] [--publish] [--publish-existing] [--days 60] [--limit n] [--out data/content-instagram]'
const INSTAGRAM_CONTENT_WITHDRAW_USAGE =
'pnpm content:instagram:withdraw --before YYYY-MM-DD [--scan-days 90] [--apply] [--out data/content-instagram]'
/** Report file stamp: the run's ISO instant with `:`/`.` swapped, like C215. */
export const instagramContentReportStamp = (runAt) => runAt.replace(/[:.]/g, '-')
@ -42,6 +49,7 @@ export const parseInstagramContentCliArgs = (argv = process.argv.slice(2)) => {
const options = {
apply: false,
publish: false,
publishExisting: false,
days: INSTAGRAM_CONTENT_DEFAULT_DAYS,
limit: null,
out: INSTAGRAM_CONTENT_DEFAULT_OUT_DIR,
@ -60,6 +68,7 @@ export const parseInstagramContentCliArgs = (argv = process.argv.slice(2)) => {
if (arg === '--help' || arg === '-h') options.help = true
else if (arg === '--apply') options.apply = true
else if (arg === '--publish') options.publish = true
else if (arg === '--publish-existing') options.publishExisting = true
else if (arg === '--days') options.days = Number(value())
else if (arg === '--limit') options.limit = Number(value())
else if (arg === '--out') options.out = value()
@ -70,6 +79,9 @@ export const parseInstagramContentCliArgs = (argv = process.argv.slice(2)) => {
if (options.publish && !options.apply) {
throw new Error(`--publish exige --apply — ${INSTAGRAM_CONTENT_USAGE}.`)
}
if (options.publishExisting && !options.apply) {
throw new Error(`--publish-existing exige --apply — ${INSTAGRAM_CONTENT_USAGE}.`)
}
if (
!Number.isInteger(options.days) ||
options.days < 1 ||
@ -86,6 +98,61 @@ export const parseInstagramContentCliArgs = (argv = process.argv.slice(2)) => {
return options
}
/**
* Parses the withdraw CLI arguments. `--before` is a REQUIRED Bahia civil date
* (the campaign cutoff is a legal boundary, never a hidden default) and the
* `--scan-days` walk depth only bounds how far back the feed is read to date
* the published pieces.
*
* @param {string[]} [argv]
*/
export const parseInstagramContentWithdrawCliArgs = (argv = process.argv.slice(2)) => {
const options = {
apply: false,
before: null,
scanDays: 90,
out: INSTAGRAM_CONTENT_DEFAULT_OUT_DIR,
help: false,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = () => {
const next = argv[++index]
if (next === undefined || next.startsWith('--')) {
throw new Error(`faltou valor para ${arg} — ${INSTAGRAM_CONTENT_WITHDRAW_USAGE}.`)
}
return next
}
if (arg === '--help' || arg === '-h') options.help = true
else if (arg === '--apply') options.apply = true
else if (arg === '--before') options.before = value()
else if (arg === '--scan-days') options.scanDays = Number(value())
else if (arg === '--out') options.out = value()
else throw new Error(`argumento desconhecido: ${arg} — ${INSTAGRAM_CONTENT_WITHDRAW_USAGE}.`)
}
if (options.help) return options
if (options.before === null) {
throw new Error(`--before é obrigatório — ${INSTAGRAM_CONTENT_WITHDRAW_USAGE}.`)
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(options.before))) {
throw new Error('--before deve ser uma data civil YYYY-MM-DD.')
}
try {
contentPieceProfilePeriodBounds({ since: options.before })
} catch {
throw new Error('--before deve ser uma data civil válida.')
}
if (!Number.isInteger(options.scanDays) || options.scanDays < 1 || options.scanDays > 365) {
throw new Error('--scan-days deve ser um inteiro entre 1 e 365.')
}
if (options.out.split(/[\\/]/).includes('..')) {
throw new Error('--out não pode escapar do diretório do repo (sem "..").')
}
return options
}
/**
* The publish rule of the import: only a piece the pipeline finished (`pronto`)
* goes public, and only when the run asked for it. A `falhou` piece stays a
@ -94,6 +161,103 @@ export const parseInstagramContentCliArgs = (argv = process.argv.slice(2)) => {
export const shouldPublishImportedPiece = ({ processingStatus, publish }) =>
publish === true && processingStatus === 'pronto'
/**
* shortcode → earliest post instant (ms) of the official feed. A link the
* catalogue cannot parse contributes nothing; a repeated post keeps the
* earliest instant (the original publication).
*
* @param {readonly { permalink: string, mediaType: string, mediaUrl?: string | null, timestamp: string }[]} posts
* @returns {Map<string, number>}
*/
export const instagramContentPostDates = (posts) => {
const dates = new Map()
for (const post of posts) {
const link = parseContentPieceLink(post.permalink)
const timestamp = Date.parse(post.timestamp)
if (!link || link.origin !== 'instagram' || !Number.isFinite(timestamp)) continue
const current = dates.get(link.shortcode)
if (current === undefined || timestamp < current) dates.set(link.shortcode, timestamp)
}
return dates
}
/**
* Orders the window's ready drafts for publication: oldest post first, so the
* newest post carries the latest `publishedAt` (the public listing sorts by
* `-publishedAt`). A piece whose post is not in the feed is named, never
* dropped silently.
*
* @param {{
* pieces: Array<{ id: number, title?: string | null, sourceUrl?: string | null, linkFailureReason?: string | null }>,
* posts: Parameters<typeof instagramContentPostDates>[0],
* }} input
*/
export const planInstagramContentDraftsToPublish = ({ pieces, posts }) => {
const dates = instagramContentPostDates(posts)
const ordered = []
const undateable = []
for (const piece of pieces) {
const link = parseContentPieceLink(piece.sourceUrl)
const timestamp = link?.origin === 'instagram' ? dates.get(link.shortcode) : undefined
if (timestamp === undefined) {
undateable.push(piece)
continue
}
ordered.push({ ...piece, shortcode: link.shortcode, postTimestamp: timestamp })
}
ordered.sort((left, right) => left.postTimestamp - right.postTimestamp)
return {
ordered,
undateable,
linkOnly: ordered.filter(
(piece) => typeof piece.linkFailureReason === 'string' && piece.linkFailureReason !== '',
).length,
}
}
/**
* Splits the published set by the civil cutoff: a post strictly BEFORE the
* `before` civil day (Bahia time, the electoral calendar's timezone) is
* withdrawn; the boundary instant comes from the domain owner of the civil
* dates, never a re-spelled offset. A piece whose post is not in the feed is
* named and left untouched.
*
* @param {{
* pieces: Array<{ id: number, title?: string | null, sourceUrl?: string | null }>,
* posts: Parameters<typeof instagramContentPostDates>[0],
* before: string,
* }} input
*/
export const planInstagramContentWithdraw = ({ pieces, posts, before }) => {
const { fromIso } = contentPieceProfilePeriodBounds({ since: before })
const cutoffMs = Date.parse(fromIso)
const dates = instagramContentPostDates(posts)
const toWithdraw = []
const undateable = []
for (const piece of pieces) {
const link = parseContentPieceLink(piece.sourceUrl)
const timestamp = link?.origin === 'instagram' ? dates.get(link.shortcode) : undefined
if (timestamp === undefined) {
undateable.push(piece)
continue
}
if (timestamp < cutoffMs) {
toWithdraw.push({
...piece,
shortcode: link.shortcode,
postTimestamp: timestamp,
postDate: new Date(timestamp).toISOString(),
})
}
}
toWithdraw.sort((left, right) => left.postTimestamp - right.postTimestamp)
return { cutoffIso: fromIso, toWithdraw, undateable }
}
/**
* Aggregates the per-candidate results of an `--apply` run. `asrCostUsd` uses
* the provider's per-audio-minute price passed by the shell (the app-side
@ -135,6 +299,7 @@ export const summarizeInstagramContentResults = (results, { costPerMinuteUsd = 0
* feedCount: number,
* plan: ReturnType<typeof planInstagramContentWindow>,
* summary?: ReturnType<typeof summarizeInstagramContentResults>,
* publishedExisting?: number,
* failures?: Array<{ shortcode: string, error: string }>,
* candidatesRemaining?: number,
* reportPath?: string | null,
@ -160,6 +325,11 @@ export const formatInstagramContentReport = (report) => {
`novas: ${report.summary.created} · já estavam: ${report.summary.existing} · prontas: ${report.summary.ready} · falharam: ${report.summary.failed} · publicadas: ${report.summary.published} · peça-link: ${report.summary.linkOnly}`,
)
lines.push(`ASR: ${report.summary.asrSeconds}s (~US$ ${report.summary.asrCostUsd})`)
if ((report.publishedExisting ?? 0) > 0) {
lines.push(
`publicadas da janela (já importadas, rascunhos prontos): ${report.publishedExisting}`,
)
}
for (const failure of report.failures ?? []) {
lines.push(` - falha ${failure.shortcode}: ${failure.error}`)
}
@ -178,3 +348,68 @@ export const formatInstagramContentReport = (report) => {
if (report.reportPath) lines.push(`recibo: ${report.reportPath}`)
return lines
}
const WITHDRAW_SAMPLE_LIMIT = 10
/**
* The withdraw receipt lines the operator reads (and the JSON mirrors): the
* cutoff, what the feed could date, the withdrawn set with its period and the
* pieces the feed could not date (never touched, always named).
*
* @param {{
* target: string,
* mode: 'plan' | 'apply',
* before: string,
* scanDays: number,
* feedCount: number,
* publishedCount: number,
* plan: ReturnType<typeof planInstagramContentWithdraw>,
* withdrawn?: number,
* failures?: Array<{ id: number, reason: string }>,
* revalidation?: { attempted: boolean, ok: boolean, reason: string | null },
* reportPath?: string | null,
* }} report
*/
export const formatInstagramContentWithdrawReport = (report) => {
const lines = []
const { toWithdraw, undateable } = report.plan
const dates = toWithdraw.map((entry) => entry.postTimestamp)
lines.push(`[content:instagram:withdraw] alvo: ${report.target} | modo: ${report.mode}`)
lines.push(
`corte: ${report.before} (Bahia; posts anteriores) · varredura: ${report.scanDays} dias · feed: ${report.feedCount} mídias`,
)
lines.push(
`publicadas: ${report.publishedCount} · anteriores ao corte: ${toWithdraw.length} · sem data no feed: ${undateable.length}`,
)
if (toWithdraw.length > 0) {
lines.push(
`período das retiradas: ${new Date(Math.min(...dates)).toISOString()} → ${new Date(Math.max(...dates)).toISOString()}`,
)
for (const entry of toWithdraw.slice(0, WITHDRAW_SAMPLE_LIMIT)) {
lines.push(
` - ${entry.shortcode} (${entry.postDate.slice(0, 10)}) ${entry.title ?? ''}`.trim(),
)
}
if (toWithdraw.length > WITHDRAW_SAMPLE_LIMIT) {
lines.push(` … +${toWithdraw.length - WITHDRAW_SAMPLE_LIMIT}`)
}
}
for (const entry of undateable) {
lines.push(
` ? sem data: #${entry.id} ${entry.title ?? ''} — ${entry.sourceUrl ?? 'sem URL'}`.trim(),
)
}
if (report.mode === 'apply') {
lines.push(
`retiradas: ${report.withdrawn ?? 0} · falhas: ${report.failures?.length ?? 0} · revalidação: ${
report.revalidation?.ok ? 'ok' : (report.revalidation?.reason ?? 'não tentada')
}`,
)
for (const failure of report.failures ?? []) {
lines.push(` - falha #${failure.id}: ${failure.reason}`)
}
}
if (report.reportPath) lines.push(`recibo: ${report.reportPath}`)
return lines
}

View file

@ -71,11 +71,16 @@ export const speechIndexCostUsd = (promptTokens, pricePerMillionTokensUsd) =>
/** Rough JSON cost of the vectors: units × dims × ~8 bytes per number. */
export const speechIndexEstimatedBytes = (units, dimensions) => units * dimensions * 8
/** C237 — the official metadata rides the same provider call but is not a stored unit. */
const metadataSuffix = (metadataTexts) =>
typeof metadataTexts === 'number' && metadataTexts > 0 ? ` (+${metadataTexts} metadados)` : ''
/**
* @param {{
* target: string, mode: 'index' | 'dry-run', source: string,
* speeches: number, upToDate: number, skippedNoText: number, toIndex: number,
* units: number, estimatedBytes?: number, indexed?: number, failed?: number,
* units: number, metadataTexts?: number, estimatedBytes?: number,
* indexed?: number, failed?: number,
* promptTokens?: number, costUsd?: number, durationMs: number,
* }} report
* @returns {string[]}
@ -85,6 +90,7 @@ export const formatSpeechIndexReport = (report) => {
typeof report.estimatedBytes === 'number'
? ` | bytes estimados: ${(report.estimatedBytes / 1_000_000).toFixed(1)} MB`
: ''
const metadata = metadataSuffix(report.metadataTexts)
const lines = [
`[acervo:index] alvo: ${report.target} | modo: ${report.mode} | fonte: ${report.source}`,
`falas: ${report.speeches} | atualizadas: ${report.upToDate} | sem texto: ${report.skippedNoText} | a indexar: ${report.toIndex}`,
@ -92,10 +98,10 @@ export const formatSpeechIndexReport = (report) => {
if (report.mode === 'index') {
lines.push(
`indexadas: ${report.indexed ?? 0} | falhas: ${report.failed ?? 0} | trechos vetorizados: ${report.units}${bytes} | tokens: ${report.promptTokens ?? 0} | custo estimado: US$ ${(report.costUsd ?? 0).toFixed(4)}`,
`indexadas: ${report.indexed ?? 0} | falhas: ${report.failed ?? 0} | trechos vetorizados: ${report.units}${metadata}${bytes} | tokens: ${report.promptTokens ?? 0} | custo estimado: US$ ${(report.costUsd ?? 0).toFixed(4)}`,
)
} else {
lines.push(`trechos a vetorizar: ${report.units}${bytes}`)
lines.push(`trechos a vetorizar: ${report.units}${metadata}${bytes}`)
}
lines.push(`duração: ${(report.durationMs / 1000).toFixed(1)}s`)

View file

@ -54,6 +54,8 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/campaignKitAssets.mjs',
'scripts/lib/changelog.mjs',
'scripts/lib/chartData.mjs',
'scripts/lib/chartMotion.mjs',
'scripts/lib/chartMotionRender.mjs',
'scripts/lib/chartPrimitives.mjs',
'scripts/lib/cityReportBlocks.mjs',
'scripts/lib/cityReportDatabase.mjs',

View file

@ -0,0 +1,218 @@
/**
* C230-followup — withdraws from the public Central the pieces whose original
* Instagram post is BEFORE a civil cutoff (the electoral campaign boundary in
* 2026 is 16/08). The cutoff (`--before`, required) is a Bahia civil date, the
* post dates come from the official feed and the action is the kill switch
* (unpublish): slug, file and `publishedAt` are preserved and the receipt names
* every piece. A published piece the feed cannot date is never touched, only
* named. Re-executing converges — what is already a draft leaves the listing.
*
* Modes:
* pnpm content:instagram:withdraw --before 2026-08-16
* plan/dry-run (default): reads the feed and the DB, lists and writes nothing
* pnpm content:instagram:withdraw --before 2026-08-16 --apply
* withdraws; needs CONTENT_INSTAGRAM_WITHDRAW_CONFIRM=1 on a non-local
* target and the declared TEQO_ENV matching the exact database name
*
* Options: --scan-days <n> (feed walk depth to date the published pieces,
* default 90), --out <dir> (receipts, default `data/content-instagram`), --help.
*
* Runbook: on the homeserver, inside the compose maintenance service with the
* stack env file — see docs/ops/teqo-1313-deploy.md.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { getPayload } from 'payload'
import {
assertEnvironmentDatabaseTarget,
assertWriteConfirm,
databaseTarget,
dieWithLabel,
loadCliEnv,
} from './lib/cli.mjs'
import {
loadInstagramContentWindowFeed,
revalidateInstagramContentCentral,
} from './lib/instagramContentCli.mjs'
import {
formatInstagramContentWithdrawReport,
instagramContentReportStamp,
parseInstagramContentWithdrawCliArgs,
planInstagramContentWithdraw,
} from './lib/instagramContentPlan.mjs'
loadCliEnv()
const die = dieWithLabel('content:instagram:withdraw')
const WRITE_CONFIRM_FLAG = 'CONTENT_INSTAGRAM_WITHDRAW_CONFIRM'
const HELP = `Uso: pnpm content:instagram:withdraw --before YYYY-MM-DD [opções]
Retira da Central pública (despublica) as peças cujo post original no Instagram
é ANTERIOR à data de corte — o kill switch preserva slug, arquivo e publishedAt,
então a operação é reversível pela ficha. Sem --apply, é um plano read-only.
Opções:
--before <data> corte civil na Bahia (obrigatório; posts anteriores saem)
--scan-days <n> profundidade da varredura do feed para datar as peças
(default 90; 1..365)
--apply executa a retirada (exige ${WRITE_CONFIRM_FLAG}=1 em alvo
não-local)
--out <dir> recibos JSON (default data/content-instagram)
--help esta ajuda
Flags: TEQO_ENV=staging|production (obrigatória no --apply fora de teste; o nome
do banco tem de casar) e ${WRITE_CONFIRM_FLAG}=1 para a escrita.
`
const writeReport = async (options, report, suffix = '') => {
const stamp = instagramContentReportStamp(report.runAt)
const reportPath = join(options.out, `${stamp}${suffix}.json`)
await mkdir(dirname(reportPath), { recursive: true })
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
return reportPath
}
const reportLines = (report) =>
formatInstagramContentWithdrawReport({
target: report.target,
mode: report.mode,
before: report.before,
scanDays: report.scanDays,
feedCount: report.feed.count,
publishedCount: report.publishedCount,
plan: report.plan,
withdrawn: report.withdrawn,
failures: report.failures,
revalidation: report.revalidation,
reportPath: report.reportPath,
})
async function main() {
let options
try {
options = parseInstagramContentWithdrawCliArgs()
} catch (error) {
die(error instanceof Error ? error.message : String(error))
}
if (options.help) {
console.log(HELP)
process.exit(0)
}
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
const mode = options.apply ? 'apply' : 'plan'
if (mode === 'apply') {
assertWriteConfirm({
label: 'content:instagram:withdraw',
flag: WRITE_CONFIRM_FLAG,
command: 'pnpm content:instagram:withdraw --before <data> --apply',
})
assertEnvironmentDatabaseTarget()
}
console.log(
`[content:instagram:withdraw] alvo: ${databaseTarget()} | modo: ${mode} | corte: ${options.before} (Bahia)`,
)
const config = (await import('../src/payload.config.ts')).default
const payload = await getPayload({ config })
const window = await loadInstagramContentWindowFeed({ payload, days: options.scanDays })
if ('error' in window) {
die(
window.error === 'unavailable'
? 'Instagram ainda não configurado (token/ID ausentes ou feed desligado no global Social Feed) — a retirada precisa das datas oficiais do feed.'
: 'não foi possível ler o feed oficial do Instagram agora (API ou credencial) — tente novamente.',
)
}
const { feed } = window
const published = await payload.find({
collection: 'contentPiece',
where: { status: { equals: 'publicado' } },
depth: 0,
limit: 0,
pagination: false,
select: { title: true, sourceUrl: true },
// Intentional admin bypass: the ops run must see every published row.
overrideAccess: true,
})
const plan = planInstagramContentWithdraw({
pieces: published.docs,
posts: feed.posts,
before: options.before,
})
const runAt = new Date().toISOString()
const report = {
runAt,
mode,
target: databaseTarget(),
before: options.before,
scanDays: options.scanDays,
cutoffIso: plan.cutoffIso,
feed: { username: feed.username, count: feed.posts.length },
publishedCount: published.docs.length,
plan,
reportPath: null,
}
if (mode === 'plan') {
report.reportPath = await writeReport(options, report)
for (const line of reportLines(report)) console.log(line)
console.log(
`\n[content:instagram:withdraw] plano sem escrita — para retirar: --apply com ${WRITE_CONFIRM_FLAG}=1 (e TEQO_ENV).`,
)
process.exit(0)
}
const startedAt = Date.now()
let withdrawn = 0
const failures = []
for (const entry of plan.toWithdraw) {
try {
await payload.update({
collection: 'contentPiece',
id: entry.id,
data: { status: 'rascunho' },
depth: 0,
// Intentional admin bypass: the ops run retires what it listed.
overrideAccess: true,
})
withdrawn += 1
if (withdrawn % 25 === 0) console.log(`[content:instagram:withdraw] ${withdrawn}…`)
} catch (error) {
failures.push({
id: entry.id,
reason: error instanceof Error && error.message !== '' ? error.message : 'falha',
})
}
}
let revalidation = { attempted: false, ok: false, reason: null }
if (withdrawn > 0) revalidation = await revalidateInstagramContentCentral()
report.withdrawn = withdrawn
report.failures = failures
report.revalidation = revalidation
report.durationMs = Date.now() - startedAt
report.reportPath = await writeReport(options, report, '-withdraw')
for (const line of reportLines(report)) console.log(line)
console.log(`\n[content:instagram:withdraw] recibo JSON: ${report.reportPath}`)
if (withdrawn === 0 && plan.toWithdraw.length > 0) {
die('nenhuma peça foi retirada — veja o recibo.')
}
process.exit(0)
}
main().catch((error) => {
die(error instanceof Error && error.message !== '' ? error.message : 'falha inesperada.')
})

View file

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import { applyContentPieceBatchActionForActor } from '@/app/(campaign)/campanha/actions/contentPieces'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
contentPieceBatchRequestSchema,
} from '@/lib/schemas/contentPiece'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import type { ContentPieceBatchResponse } from '../types'
export const dynamic = 'force-dynamic'
/**
* C236 — the batch gateway of the Central list: one request carries the verb
* and the ids of the visible page; the action repeats the unit gesture per
* piece and answers the honest outcome (affected × failures). The loop belongs
* to the server, next to the semantics it reuses — the client only renders the
* receipt.
*/
export const POST = campaignJsonMutationRoute(
{
bodySchema: contentPieceBatchRequestSchema,
safeMessages: [CONTENT_PIECE_FORBIDDEN_MESSAGE],
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
},
async (body) => {
const outcome = await applyContentPieceBatchActionForActor(body)
return NextResponse.json<ContentPieceBatchResponse>({ status: 'success', outcome })
},
)

View file

@ -4,8 +4,12 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { AddContentPieceLinkDialog } from '@/components/campaign/content/AddContentPieceLinkDialog'
import { ContentPieceBatchReceipt } from '@/components/campaign/content/ContentPieceBatchReceipt'
import { ContentPieceCardList } from '@/components/campaign/content/ContentPieceCardList'
import { ContentPieceFilters } from '@/components/campaign/content/ContentPieceFilters'
import { ContentPieceSelectionBar } from '@/components/campaign/content/ContentPieceSelectionBar'
import { ContentPieceSelectionModeControl } from '@/components/campaign/content/ContentPieceSelectionControls'
import { ContentPieceSelectionProvider } from '@/components/campaign/content/ContentPieceSelectionProvider'
import { ContentPieceStatusRefresher } from '@/components/campaign/content/ContentPieceStatusRefresher'
import { ContentPieceTable } from '@/components/campaign/content/ContentPieceTable'
import { ContentPieceUploadDialog } from '@/components/campaign/content/ContentPieceUploadDialog'
@ -20,6 +24,7 @@ import {
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import type { ContentPieceSelectionRow } from '@/lib/contentPieceBatch'
import { CONTENT_PIECE_PROFILE_IMPORT_UNAVAILABLE_MESSAGE } from '@/lib/schemas/contentPiece'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
@ -57,6 +62,19 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP
processingStatus: row.processingStatus,
}))
// C236 — the serializable projection the client selection provider needs:
// the delete warning (status + public path) and the ids of "selecionar
// todas" (the visible page). The checkbox labels come from the row islands,
// which already render the piece title.
const selectionRows: ContentPieceSelectionRow[] = data.rows.map((row) => ({
id: row.id,
status: row.status,
publicPath: row.publicPath,
}))
// The selection is page-scoped: the canonical list URL is the provider's key,
// so navigating to another page/filter remounts it and forgets the selection.
const selectionResetKey = buildContentPieceListHref(data.state, data.state.page)
const columns = toCampaignColumnPickerColumns([
{ id: 'piece', label: 'Peça', mandatory: true },
{ id: 'processing', label: 'Processamento' },
@ -142,24 +160,31 @@ export default async function ContentPiecesPage({ searchParams }: ContentPiecesP
</div>
) : null}
<CampaignListResults>
<ContentPieceCardList rows={data.rows} empty={emptyState} />
<ContentPieceTable
rows={data.rows}
columnVisibility={columnVisibility}
empty={emptyState}
/>
{data.rows.length > 0 ? (
<CampaignListFooter
totalDocs={data.totalDocs}
singular="peça"
plural="peças"
page={data.state.page}
totalPages={data.totalPages}
hrefForPage={(page) => buildContentPieceListHref(data.state, page)}
<ContentPieceSelectionProvider key={selectionResetKey} rows={selectionRows}>
{data.rows.length > 0 ? <ContentPieceSelectionModeControl /> : null}
<ContentPieceBatchReceipt />
<CampaignListResults className="group-data-[selection-mode=true]:pb-64 md:group-data-[selection-mode=true]:pb-28">
<ContentPieceCardList rows={data.rows} empty={emptyState} />
<ContentPieceTable
rows={data.rows}
columnVisibility={columnVisibility}
empty={emptyState}
/>
) : null}
</CampaignListResults>
{data.rows.length > 0 ? (
<CampaignListFooter
totalDocs={data.totalDocs}
singular="peça"
plural="peças"
page={data.state.page}
totalPages={data.totalPages}
hrefForPage={(page) => buildContentPieceListHref(data.state, page)}
/>
) : null}
</CampaignListResults>
<ContentPieceSelectionBar />
</ContentPieceSelectionProvider>
</CampaignListPendingBoundary>
</CampaignPageShell>
)

View file

@ -1,4 +1,5 @@
import type { ContentPieceProfileCandidate, ContentPieceViewModel } from '@/lib/contentPiece'
import type { ContentPieceBatchOutcome } from '@/lib/contentPieceBatch'
/** Wire contract of `POST .../conteudos/enviar` (C211) — raw-body upload. */
export type ContentPieceUploadResponse =
@ -51,3 +52,8 @@ export type ContentPieceAttachResponse =
export type ContentPieceDeleteResponse =
| { status: 'success'; deleted: true }
| { status: 'error'; message: string }
/** Wire contract of `POST .../conteudos/lote` (C236) — the batch verdict. */
export type ContentPieceBatchResponse =
| { status: 'success'; outcome: ContentPieceBatchOutcome }
| { status: 'error'; message: string }

View file

@ -12,13 +12,17 @@ import {
type ContentPieceCuratedField,
type ContentPieceViewModel,
} from '@/lib/contentPiece'
import type { ContentPieceBatchOutcome } from '@/lib/contentPieceBatch'
import { normalizeContentPiecePublicFigures } from '@/lib/publicFigureCatalog'
import {
CONTENT_PIECE_FORBIDDEN_MESSAGE,
CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
CONTENT_PIECE_LINK_DUPLICATE_MESSAGE,
CONTENT_PIECE_LINK_INVALID_MESSAGE,
CONTENT_PIECE_NOT_FOUND_MESSAGE,
CONTENT_PIECE_RETRY_NOT_FAILED_MESSAGE,
CONTENT_PIECE_SAFE_MESSAGES,
contentPieceBatchRequestSchema,
contentPieceDeleteRequestSchema,
contentPieceLinkRequestSchema,
contentPieceProfileImportRequestSchema,
@ -26,12 +30,14 @@ import {
contentPieceRetryRequestSchema,
contentPieceStatusRequestSchema,
contentPieceUpdateRequestSchema,
type ContentPieceBatchRequest,
type ContentPieceStatusRequest,
type ContentPieceUpdateRequest,
} from '@/lib/schemas/contentPiece'
import type { SpeechTopic } from '@/lib/speechFacets'
import type { CampaignUser } from '@/payload-types'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { mapCampaignFormActionError } from '@/utilities/campaignFormActionError'
import { reapStaleContentPiece } from '@/utilities/content/contentPieceJob'
import {
searchContentPieceLeaderOptions,
@ -278,6 +284,58 @@ export const deleteContentPieceForActor = async (input: {
return { deleted: true }
}
/**
* C236 — applies the same verdict to a bounded set of pieces. The batch is the
* unit gesture repeated, so every id goes through the unit action: its own
* transaction, its own fresh gate, its own post-commit media cleanup, its own
* collection access as the final barrier. A failure is named in the outcome and
* never reverts the pieces that succeeded (the product's honest partial
* failure); the communication gate is checked once before the loop, so a denied
* actor fails the whole request instead of turning into per-item noise. The
* loop is sequential on purpose: the page ceiling is 25 ids and determinism
* beats latency for a desk gesture.
*/
export const applyContentPieceBatchActionForActor = async (
input: ContentPieceBatchRequest,
): Promise<ContentPieceBatchOutcome> => {
const parsed = contentPieceBatchRequestSchema.parse(input)
const { actor } = await getCampaignActionContext()
if (!canReadCommunicationCatalog(actor.role)) throw new Error(CONTENT_PIECE_FORBIDDEN_MESSAGE)
// A repeated id would act twice on the same row (the second one failing as
// not found), so the batch normalizes the selection before looping.
const contentPieceIds = [...new Set(parsed.contentPieceIds)]
const failures: ContentPieceBatchOutcome['failures'] = []
let affected = 0
for (const contentPieceId of contentPieceIds) {
try {
if (parsed.action === 'apagar') {
await deleteContentPieceForActor({ contentPieceId })
} else {
await setContentPiecePublishedForActor({
contentPieceId,
published: parsed.action === 'publicar',
})
}
affected += 1
} catch (error) {
failures.push({
contentPieceId,
message:
mapCampaignFormActionError({
error,
safeMessages: CONTENT_PIECE_SAFE_MESSAGES,
genericMessage: CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
}).message ?? CONTENT_PIECE_GENERIC_ERROR_MESSAGE,
})
}
}
return { action: parsed.action, affected, failures }
}
/**
* Polls the statuses of the visible pieces; a stale `processando` row is reaped
* to `falhou` on the way (there is no queue to ask), then the page is read

View file

@ -0,0 +1,60 @@
'use client'
import { CircleAlertIcon, CircleCheckIcon, XIcon } from 'lucide-react'
import { useEffect } from 'react'
import { useContentPieceSelection } from '@/components/campaign/content/ContentPieceSelectionProvider'
import { cn } from '@/lib/utils'
/** How long an all-success receipt stays before it fades on its own. */
const RECEIPT_AUTO_DISMISS_MS = 6_000
/**
* C236 — the honest receipt of the batch (approved design scene 5): a single
* inline banner at the top of the list, never over it. Success is one neutral
* phrase with a green icon and dismisses itself; a partial failure is an
* assertive `role="alert"` that always separates affected from failures and
* stays until it is closed.
*/
export const ContentPieceBatchReceipt = () => {
const { receipt, dismissReceipt } = useContentPieceSelection()
useEffect(() => {
if (receipt?.kind !== 'success') return
const timer = window.setTimeout(dismissReceipt, RECEIPT_AUTO_DISMISS_MS)
return () => window.clearTimeout(timer)
}, [receipt, dismissReceipt])
if (!receipt) return null
const success = receipt.kind === 'success'
return (
<div
role={success ? 'status' : 'alert'}
aria-live={success ? 'polite' : undefined}
aria-atomic="true"
className={cn(
'mt-4 flex items-start gap-2 rounded-lg border bg-card px-3 py-2.5',
success ? 'border-border' : 'border-destructive/40',
)}
>
{success ? (
<CircleCheckIcon className="mt-0.5 size-4 shrink-0 text-green-700" aria-hidden="true" />
) : (
<CircleAlertIcon className="mt-0.5 size-4 shrink-0 text-destructive" aria-hidden="true" />
)}
<p className={cn('flex-1 text-sm font-medium', !success && 'text-destructive')}>
{receipt.message}
</p>
<button
type="button"
aria-label="Fechar aviso"
className="grid size-9 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50"
onClick={dismissReceipt}
>
<XIcon className="size-4" aria-hidden="true" />
</button>
</div>
)
}

View file

@ -3,6 +3,7 @@ import Link from 'next/link'
import { ContentPieceCirculationCounters } from '@/components/campaign/content/ContentPieceCirculationCounters'
import { ContentPieceDeleteDialog } from '@/components/campaign/content/ContentPieceDeleteDialog'
import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton'
import { ContentPieceCardSelectionFrame } from '@/components/campaign/content/ContentPieceSelectionControls'
import {
ContentPieceProcessingBadge,
ContentPiecePublicationBadge,
@ -18,7 +19,9 @@ import type { ContentPieceRowViewModel } from '@/lib/contentPieceCirculation'
* carries the neutral "Peça-link" label — the honest reason lives on the ficha,
* never on the list (C220 D7). C213 adds the piece's circulation counters (2×2
* grid) right above the action. C222 adds "Apagar" beside the routine action
* (below it when the piece can be reprocessed), per the approved scene B.
* (below it when the piece can be reprocessed), per the approved scene B. C236
* hands the article box to the selection frame, which adds the 44px checkbox
* and the primary ring in selection mode without touching the body.
*/
export const ContentPieceCardList = ({
rows,
@ -40,21 +43,31 @@ export const ContentPieceCardList = ({
...(isLinkOnly ? [] : [piece.typeLabel, piece.durationLabel]),
]
return (
<article key={piece.id} className="rounded-xl border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<Link href={piece.detailHref} className="min-w-0 text-sm font-semibold hover:underline">
{piece.title}
</Link>
<ContentPieceProcessingBadge status={piece.processingStatus} className="shrink-0" />
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ContentPiecePublicationBadge status={piece.status} />
<span>
{meta.filter(Boolean).join(' · ')}
{piece.cityLabel ? ` · ${piece.cityLabel}` : ''}
</span>
</div>
<ContentPieceCardSelectionFrame
key={piece.id}
contentPieceId={piece.id}
title={piece.title}
header={
<>
<div className="flex items-start justify-between gap-3">
<Link
href={piece.detailHref}
className="block min-w-0 text-sm font-semibold break-words line-clamp-2 hover:underline"
>
{piece.title}
</Link>
<ContentPieceProcessingBadge status={piece.processingStatus} className="shrink-0" />
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ContentPiecePublicationBadge status={piece.status} />
<span>
{meta.filter(Boolean).join(' · ')}
{piece.cityLabel ? ` · ${piece.cityLabel}` : ''}
</span>
</div>
</>
}
>
{piece.processingStatus === 'processando' ? (
<>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-muted">
@ -96,7 +109,7 @@ export const ContentPieceCardList = ({
/>
</div>
)}
</article>
</ContentPieceCardSelectionFrame>
)
})}
</div>

View file

@ -0,0 +1,141 @@
'use client'
import { BookmarkIcon, CheckIcon, Trash2Icon, XIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useContentPieceSelection } from '@/components/campaign/content/ContentPieceSelectionProvider'
import { CampaignDeleteDialog } from '@/components/campaign/shared/CampaignDeleteDialog'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import {
CONTENT_PIECE_BATCH_DELETE_IRREVERSIBLE_MESSAGE,
CONTENT_PIECE_BATCH_DELETE_LINKS_MESSAGE,
CONTENT_PIECE_BATCH_ERROR_MESSAGE,
contentPieceBatchDeleteTitle,
contentPieceSelectionLabel,
type ContentPieceBatchAction,
} from '@/lib/contentPieceBatch'
/**
* C236 — the batch action bar (approved design scenes 2, 3b and 6): it exists
* only while something is selected, floats at the footer of the content and
* owns the three verbs. `Publicar`/`Despublicar` are the reversible kill switch
* and act on the spot, with the receipt in the list; `Apagar` always opens the
* one confirmation machine (the C222 chrome) with the link warning when the
* selection carries published pieces. While the loop runs every target blocks:
* `aria-busy` on the bar, spinner on the clicked verb, the rest disabled.
*/
export const ContentPieceSelectionBar = () => {
const router = useRouter()
const { selectedCount, selectedPublishedPaths, pendingAction, exitSelection, runBatch } =
useContentPieceSelection()
if (selectedCount === 0) return null
const pending = pendingAction !== null
const barButtonClassName = 'min-h-11 px-1 text-xs sm:px-3 sm:text-sm'
const run = async (action: ContentPieceBatchAction) => {
const result = await runBatch(action)
if (result.ok) router.refresh()
}
const description = (() => {
if (selectedPublishedPaths.length === 0) return CONTENT_PIECE_BATCH_DELETE_IRREVERSIBLE_MESSAGE
if (selectedCount > 1) return CONTENT_PIECE_BATCH_DELETE_LINKS_MESSAGE
return (
<>
O link público{' '}
<span className="font-medium text-foreground">{selectedPublishedPaths[0]}</span> deixa de
funcionar para quem já recebeu. Esta ação não pode ser desfeita.
</>
)
})()
const deleteTrigger = (
<Button type="button" variant="destructive" className={barButtonClassName} disabled={pending}>
{pendingAction === 'apagar' ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
)}
Apagar
</Button>
)
return (
<div className="sticky bottom-32 z-20 mt-4 md:bottom-4 md:mr-16">
<div
aria-busy={pending}
className="flex flex-col rounded-xl border border-border bg-card p-3 shadow-[0_10px_34px_rgb(28_25_23_/_0.16)] sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:px-3 sm:py-2.5"
>
<div className="flex items-center justify-between gap-2 sm:justify-start">
<p
role="status"
aria-live="polite"
className={
pending
? 'text-sm font-medium tabular-nums text-muted-foreground'
: 'text-sm font-medium tabular-nums'
}
>
{contentPieceSelectionLabel(selectedCount)}
</p>
<Button
type="button"
variant="ghost"
className="min-h-11 sm:hidden"
aria-label="Sair da seleção"
disabled={pending}
onClick={exitSelection}
>
<XIcon aria-hidden="true" />
</Button>
</div>
<div className="mt-2 grid grid-cols-3 gap-2 sm:mt-0 sm:flex sm:items-center">
<Button
type="button"
className={barButtonClassName}
disabled={pending}
onClick={() => void run('publicar')}
>
{pendingAction === 'publicar' ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<CheckIcon data-icon="inline-start" aria-hidden="true" />
)}
Publicar
</Button>
<Button
type="button"
variant="outline"
className={barButtonClassName}
disabled={pending}
onClick={() => void run('despublicar')}
>
{pendingAction === 'despublicar' ? (
<Spinner data-icon="inline-start" aria-hidden="true" />
) : (
<BookmarkIcon data-icon="inline-start" aria-hidden="true" />
)}
Despublicar
</Button>
<CampaignDeleteDialog
errorMessage={CONTENT_PIECE_BATCH_ERROR_MESSAGE}
title={contentPieceBatchDeleteTitle(selectedCount)}
titleClassName="border-b-0 pb-0"
contentClassName="max-w-sm"
description={description}
confirmLabel="Apagar"
trigger={deleteTrigger}
onConfirm={async () => {
const result = await runBatch('apagar')
return result.ok ? { ok: true } : { ok: false, message: result.message }
}}
/>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,156 @@
'use client'
import { SquareCheckIcon, XIcon } from 'lucide-react'
import type { ReactNode } from 'react'
import { useContentPieceSelection } from '@/components/campaign/content/ContentPieceSelectionProvider'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/Checkbox'
import { cn } from '@/lib/utils'
/**
* C236 — the affordances of the selection mode (approved design scenes 1–3):
* the single entry at the top of the list, the mode badge with its exit, the
* per-row checkboxes of the table and the mobile card frame. All of them are
* client islands reading the one selection context; the table shell and the
* card bodies stay server-rendered.
*/
/** The opt-in entry ("Selecionar") and the active-mode chrome of the top of the list. */
export const ContentPieceSelectionModeControl = () => {
const { selectionMode, enterSelection, exitSelection, pendingAction } = useContentPieceSelection()
if (!selectionMode) {
return (
<div className="mt-4 flex items-center justify-end">
<Button type="button" variant="outline" className="min-h-11" onClick={enterSelection}>
<SquareCheckIcon data-icon="inline-start" aria-hidden="true" />
Selecionar
</Button>
</div>
)
}
return (
<div className="mt-4 flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
<SquareCheckIcon className="size-3.5" aria-hidden="true" />
Modo de seleção
</span>
<Button
type="button"
variant="ghost"
className="min-h-11 text-muted-foreground hover:text-foreground"
aria-label="Cancelar seleção"
disabled={pendingAction !== null}
onClick={exitSelection}
>
<XIcon data-icon="inline-start" aria-hidden="true" />
<span aria-hidden="true" className="max-sm:hidden">
Cancelar seleção
</span>
<span aria-hidden="true" className="sm:hidden">
Cancelar
</span>
</Button>
</div>
)
}
/** The header checkbox: all of the visible page, or none. */
export const ContentPieceSelectAllCheckbox = () => {
const { allSelected, selectedCount, toggleAll, pendingAction } = useContentPieceSelection()
return (
<span className="grid size-9 place-items-center">
<Checkbox
checked={allSelected ? true : selectedCount > 0 ? 'indeterminate' : false}
onCheckedChange={() => toggleAll()}
disabled={pendingAction !== null}
aria-label="Selecionar todas as peças desta página"
/>
</span>
)
}
/**
* One table-row checkbox. The wrapper carries `data-selection-state`, which the
* row's `has-[…]:bg-primary/5` class uses to tint the selected line.
*/
export const ContentPieceRowCheckbox = ({
contentPieceId,
title,
}: {
contentPieceId: number
title: string
}) => {
const { isSelected, toggle, pendingAction } = useContentPieceSelection()
const selected = isSelected(contentPieceId)
return (
<span
className="grid size-9 place-items-center"
data-selection-state={selected ? 'selected' : undefined}
>
<Checkbox
checked={selected}
onCheckedChange={() => toggle(contentPieceId)}
disabled={pendingAction !== null}
aria-label={`Selecionar ${title}`}
/>
</span>
)
}
/**
* The mobile card frame (design scene 3b): in selection mode the article gains
* the 44px touch target with the checkbox and the primary ring; outside it the
* frame renders the exact resting article, so the card look at rest does not
* depend on this island. `header` is the card's title/meta block (which sits
* beside the checkbox); children are the rest of the card body.
*/
export const ContentPieceCardSelectionFrame = ({
contentPieceId,
title,
header,
children,
}: {
contentPieceId: number
title: string
header: ReactNode
children: ReactNode
}) => {
const { selectionMode, isSelected, toggle, pendingAction } = useContentPieceSelection()
const selected = selectionMode && isSelected(contentPieceId)
if (!selectionMode) {
return (
<article className="rounded-xl border bg-card p-4">
{header}
{children}
</article>
)
}
return (
<article
className={cn(
'rounded-xl border border-border bg-card p-4',
selected && 'ring-1 ring-primary/25',
)}
>
<div className="flex items-start gap-2">
<span className="grid size-11 shrink-0 place-items-center">
<Checkbox
checked={selected}
onCheckedChange={() => toggle(contentPieceId)}
disabled={pendingAction !== null}
aria-label={`Selecionar ${title}`}
/>
</span>
<div className="min-w-0 flex-1">{header}</div>
</div>
{children}
</article>
)
}

View file

@ -0,0 +1,201 @@
'use client'
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
import type { ContentPieceBatchResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/types'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import { CAMPAIGN_CONTENT_PIECE_BATCH_HREF } from '@/lib/campaignPaths'
import {
CONTENT_PIECE_BATCH_ERROR_MESSAGE,
contentPieceBatchReceiptMessage,
type ContentPieceBatchAction,
type ContentPieceBatchOutcome,
type ContentPieceSelectionRow,
} from '@/lib/contentPieceBatch'
/** What the receipt banner renders: success is `status`, a failure is `alert`. */
export type ContentPieceReceipt = {
kind: 'success' | 'failure'
message: string
}
export type ContentPieceBatchResult =
| { ok: true; outcome: ContentPieceBatchOutcome }
| { ok: false; message: string }
type ContentPieceSelectionValue = {
selectionMode: boolean
selectedCount: number
allSelected: boolean
isSelected: (id: number) => boolean
pendingAction: ContentPieceBatchAction | null
receipt: ContentPieceReceipt | null
/** `/conteudos/<slug>` of the selected published pieces (the delete warning). */
selectedPublishedPaths: readonly string[]
enterSelection: () => void
exitSelection: () => void
toggle: (id: number) => void
toggleAll: () => void
dismissReceipt: () => void
runBatch: (action: ContentPieceBatchAction) => Promise<ContentPieceBatchResult>
}
const ContentPieceSelectionContext = createContext<ContentPieceSelectionValue | null>(null)
export const useContentPieceSelection = (): ContentPieceSelectionValue => {
const value = useContext(ContentPieceSelectionContext)
if (!value) {
throw new Error('useContentPieceSelection precisa de um ContentPieceSelectionProvider.')
}
return value
}
/**
* C236 — the one selection state shared by the two bodies of the list (desktop
* table + mobile cards) and by the action bar. Client-only by design: the two
* RSC bodies and the shell stay untouched; the provider receives a serializable
* projection of the visible page and wraps them via `children`.
*
* The selection is page-scoped and dies on navigation: the page keys this
* provider by the canonical list URL, so a new page/filter remounts it. A
* refresh of the same key (post-action, status poll) preserves the state, and
* `runBatch` clears the ids on success so the receipt is about a finished
* gesture. A whole-request failure keeps the selection — nothing changed.
*/
export const ContentPieceSelectionProvider = ({
rows,
children,
}: {
rows: readonly ContentPieceSelectionRow[]
children: ReactNode
}) => {
const [selectionMode, setSelectionMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [pendingAction, setPendingAction] = useState<ContentPieceBatchAction | null>(null)
const [receipt, setReceipt] = useState<ContentPieceReceipt | null>(null)
const pageIds = useMemo(() => rows.map((row) => row.id), [rows])
const pageIdSet = useMemo(() => new Set(pageIds), [pageIds])
// The selection never outgrows the visible page: an id that left the page
// (the status poll can change the filtered rows under the same URL key) stops
// counting and never reaches the batch.
const visibleSelectedIds = useMemo(
() => selectedIds.filter((id) => pageIdSet.has(id)),
[selectedIds, pageIdSet],
)
const selectedSet = useMemo(() => new Set(visibleSelectedIds), [visibleSelectedIds])
const isSelected = useCallback((id: number) => selectedSet.has(id), [selectedSet])
const selectedPublishedPaths = useMemo(
() =>
rows.flatMap((row) =>
selectedSet.has(row.id) && row.status === 'publicado' && row.publicPath !== null
? [row.publicPath]
: [],
),
[rows, selectedSet],
)
const enterSelection = useCallback(() => {
setSelectionMode(true)
setReceipt(null)
}, [])
const exitSelection = useCallback(() => {
setSelectionMode(false)
setSelectedIds([])
}, [])
const toggle = useCallback((id: number) => {
setSelectedIds((current) =>
current.includes(id) ? current.filter((value) => value !== id) : [...current, id],
)
}, [])
const toggleAll = useCallback(() => {
setSelectedIds((current) =>
current.filter((id) => pageIdSet.has(id)).length === pageIds.length ? [] : [...pageIds],
)
}, [pageIds, pageIdSet])
const dismissReceipt = useCallback(() => setReceipt(null), [])
const runBatch = useCallback(
async (action: ContentPieceBatchAction): Promise<ContentPieceBatchResult> => {
setPendingAction(action)
try {
const { ok, payload } = await postCampaignJson<ContentPieceBatchResponse>(
CAMPAIGN_CONTENT_PIECE_BATCH_HREF,
{ action, contentPieceIds: visibleSelectedIds },
)
if (!ok || payload.status !== 'success') {
const message =
payload.status === 'error' ? payload.message : CONTENT_PIECE_BATCH_ERROR_MESSAGE
// The delete failure belongs to the confirmation dialog; the
// reversible verbs answer in the list, right where the selection is.
if (action !== 'apagar') setReceipt({ kind: 'failure', message })
return { ok: false, message }
}
setReceipt({
kind: payload.outcome.failures.length === 0 ? 'success' : 'failure',
message: contentPieceBatchReceiptMessage(payload.outcome),
})
setSelectedIds([])
return { ok: true, outcome: payload.outcome }
} catch {
if (action !== 'apagar') {
setReceipt({ kind: 'failure', message: CONTENT_PIECE_BATCH_ERROR_MESSAGE })
}
return { ok: false, message: CONTENT_PIECE_BATCH_ERROR_MESSAGE }
} finally {
setPendingAction(null)
}
},
[visibleSelectedIds],
)
const value = useMemo<ContentPieceSelectionValue>(
() => ({
selectionMode,
selectedCount: visibleSelectedIds.length,
allSelected: pageIds.length > 0 && visibleSelectedIds.length === pageIds.length,
isSelected,
pendingAction,
receipt,
selectedPublishedPaths,
enterSelection,
exitSelection,
toggle,
toggleAll,
dismissReceipt,
runBatch,
}),
[
selectionMode,
visibleSelectedIds,
pageIds,
isSelected,
pendingAction,
receipt,
selectedPublishedPaths,
enterSelection,
exitSelection,
toggle,
toggleAll,
dismissReceipt,
runBatch,
],
)
return (
<ContentPieceSelectionContext.Provider value={value}>
{/* `contents` keeps the wrapper out of the layout; `group` + the data
attribute are what the table column CSS reads to show/hide itself. */}
<div className="group contents" data-selection-mode={selectionMode ? 'true' : undefined}>
{children}
</div>
</ContentPieceSelectionContext.Provider>
)
}

View file

@ -4,6 +4,10 @@ import Link from 'next/link'
import { ContentPieceCirculationCounters } from '@/components/campaign/content/ContentPieceCirculationCounters'
import { ContentPieceDeleteDialog } from '@/components/campaign/content/ContentPieceDeleteDialog'
import { ContentPieceRetryButton } from '@/components/campaign/content/ContentPieceRetryButton'
import {
ContentPieceRowCheckbox,
ContentPieceSelectAllCheckbox,
} from '@/components/campaign/content/ContentPieceSelectionControls'
import {
ContentPieceProcessingBadge,
ContentPiecePublicationBadge,
@ -60,7 +64,24 @@ export const ContentPieceTable = ({
rows={rows}
rowKey={(row) => row.id}
empty={empty}
rowClassName="has-[[data-selection-state=selected]]:bg-primary/5"
columns={[
{
// C236 — the selection column is chrome of the selection mode, not a
// data column: it is mandatory (a stale cookie can never hide it) and
// the CSS shows it only while the provider is in selection mode. It is
// deliberately absent from the column picker of the page.
id: 'selection',
label: 'Seleção',
mandatory: true,
head: (
<CampaignTableHead className="hidden w-12 group-data-[selection-mode=true]:table-cell">
<ContentPieceSelectAllCheckbox />
</CampaignTableHead>
),
cellClassName: 'hidden group-data-[selection-mode=true]:table-cell',
cell: (piece) => <ContentPieceRowCheckbox contentPieceId={piece.id} title={piece.title} />,
},
{
id: 'piece',
label: 'Peça',
@ -75,7 +96,10 @@ export const ContentPieceTable = ({
<Icon className="size-5" aria-hidden="true" />
</span>
<div className="min-w-0">
<Link href={piece.detailHref} className="font-semibold hover:underline">
<Link
href={piece.detailHref}
className="block min-w-0 font-semibold break-words line-clamp-2 hover:underline"
>
{piece.title}
</Link>
<p className="mt-1 truncate text-xs text-muted-foreground">{metaLine(piece)}</p>

View file

@ -25,9 +25,7 @@ import { deleteCampaignJson } from '@/lib/campaignJsonRequest'
*/
type CampaignDeleteDialogResponse = { status: 'success' } | { status: 'error'; message: string }
type CampaignDeleteDialogProps = {
/** The DELETE endpoint of the row (the id lives in the path). */
endpoint: string
type CampaignDeleteDialogBaseProps = {
/** Fallback copy for a transport/unknown failure; domain errors travel from the route. */
errorMessage: string
/** The styled trigger button of the caller (the machine owns the confirm flow). */
@ -41,27 +39,50 @@ type CampaignDeleteDialogProps = {
titleClassName?: string
/** Callers whose footer chrome differs from the default pass their own. */
footerClassName?: string
/** Callers whose panel width differs from the default pass their own (C236 batch). */
contentClassName?: string
}
type CampaignDeleteDialogProps = CampaignDeleteDialogBaseProps &
(
| {
/** The DELETE endpoint of the row (the id lives in the path). */
endpoint: string
}
| {
/**
* C236 — a caller that owns the whole request (the batch loop) passes
* the mutation instead of a DELETE endpoint: the machine calls it on
* confirm and keeps owning submitting/error/success.
*/
onConfirm: () => Promise<{ ok: true } | { ok: false; message: string }>
/** Runs after a successful confirm, before the refresh/redirect. */
onSuccess?: () => void
}
)
/**
* The one delete-confirmation machine of the campaign vertical (C183 built it
* for the cut, C199 reused it for the recording, C222 is the third call site —
* extracted here instead of copied again). Owns the submitting/error state, the
* DELETE call, the redirect/refresh decision and the dialog chrome; each caller
* passes policy only (endpoint, copy, trigger, classes). The error always stays
* in the dialog context, never a toast.
* in the dialog context, never a toast. C236 adds the `onConfirm` path: a
* caller that owns the whole request (the batch loop) passes the mutation
* itself and the machine keeps the same chrome and flow.
*/
export const CampaignDeleteDialog = ({
endpoint,
errorMessage,
trigger,
title,
description,
confirmLabel,
redirectTo,
titleClassName,
footerClassName,
}: CampaignDeleteDialogProps) => {
export const CampaignDeleteDialog = (props: CampaignDeleteDialogProps) => {
const {
errorMessage,
trigger,
title,
description,
confirmLabel,
redirectTo,
titleClassName,
footerClassName,
contentClassName,
} = props
const router = useRouter()
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -71,7 +92,19 @@ export const CampaignDeleteDialog = ({
setError(null)
try {
const { ok, payload } = await deleteCampaignJson<CampaignDeleteDialogResponse>(endpoint)
if ('onConfirm' in props) {
const result = await props.onConfirm()
if (!result.ok) {
setError(result.message)
return
}
props.onSuccess?.()
if (redirectTo) router.push(redirectTo)
else router.refresh()
return
}
const { ok, payload } = await deleteCampaignJson<CampaignDeleteDialogResponse>(props.endpoint)
if (!ok || payload.status !== 'success') {
setError(payload.status === 'error' ? payload.message : errorMessage)
return
@ -88,7 +121,7 @@ export const CampaignDeleteDialog = ({
return (
<AlertDialog>
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogContent className={contentClassName}>
<AlertDialogHeader>
<div className="flex items-start gap-3">
<div className="grid size-9 shrink-0 place-items-center rounded-full bg-red-50 text-destructive">

View file

@ -27,13 +27,13 @@ export const ContentPieceDownloadLink = ({
</a>
) : null
export const ContentPieceShareButton = ({
export const ContentPieceShareButton = <Item extends { title: string }>({
item,
onShare,
className,
}: {
item: ContentPiecePublicItem
onShare: (item: ContentPiecePublicItem) => void
item: Item
onShare: (item: Item) => void
className?: string
}) => (
<button

View file

@ -1,3 +1,4 @@
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
@ -12,6 +13,11 @@ import { CONTENT_PIECE_FOCUS } from './contentPieceClasses'
* link, the focus ring and the meta contract).
*/
/** S42 — the chevron of a filtrable tag (home card and section tag). */
export const ContentPieceTagChevron = () => (
<ChevronRight aria-hidden="true" strokeWidth={2.5} className="size-3 shrink-0" />
)
type ContentPieceCardTitleItem = Pick<ContentPiecePublicItem, 'title' | 'publicPath'>
export const ContentPieceCardTitle = ({

View file

@ -8,12 +8,14 @@ import {
loadMunicipalityZoneGeometryModule,
loadTerritoryGeometryModule,
} from '@/lib/bahiaGeometries'
import { buildContentPieceCatalogHref } from '@/lib/contentPieceCatalog'
import {
resolveContentPieceHomeVisitor,
selectContentPieceHomeItems,
type ContentPieceHomeItem,
type ContentPieceHomeVisitor,
} from '@/lib/contentPieceHomeSelection'
import { slugify } from '@/lib/slug'
import { cn } from '@/lib/utils'
import {
COARSE_ACCURACY_M,
@ -22,12 +24,15 @@ import {
type GeolocationPermissionState,
} from '@/utilities/campaignGeolocation'
import { ContentPieceTagChevron } from './ContentPieceCardParts'
import { ContentPieceHomeCard } from './ContentPieceHomeCard'
import { ContentPieceHomeFilterRow, type ContentPieceHomeFacets } from './ContentPieceHomeFilterRow'
import { ContentPieceShareSheet } from './ContentPieceShareSheet'
import {
CONTENT_PIECE_FOCUS,
CONTENT_PIECE_LOCAL_TAG,
CONTENT_PIECE_HOME_TAG,
CONTENT_PIECE_LOCAL_TAG_LINK,
CONTENT_PIECE_PRIMARY_BUTTON,
CONTENT_PIECE_TAG,
} from './contentPieceClasses'
/**
@ -40,6 +45,10 @@ import {
*
* No auto-prompt (unlike the staff B14 card): a surprise dialog on the home
* burns trust, and the recent selection is already useful without it.
*
* S42 — the board also owns the one share sheet of the section (same S27
* mechanism as the catalogue) and the explore row that opens `/conteudos`
* already filtered; the row never filters the sample.
*/
const COPY = {
@ -62,12 +71,19 @@ const LOCATE_BUTTON = cn(
CONTENT_PIECE_FOCUS,
)
export const ContentPieceHomeBoard = ({ items }: { items: readonly ContentPieceHomeItem[] }) => {
export const ContentPieceHomeBoard = ({
items,
facets,
}: {
items: readonly ContentPieceHomeItem[]
facets: ContentPieceHomeFacets
}) => {
const [permission, setPermission] = useState<GeolocationPermissionState | null>(null)
const [visitor, setVisitor] = useState<ContentPieceHomeVisitor | null>(null)
const [locating, setLocating] = useState(false)
const [attempted, setAttempted] = useState(false)
const [playingId, setPlayingId] = useState<number | null>(null)
const [sharingItem, setSharingItem] = useState<ContentPieceHomeItem | null>(null)
const autoStartedRef = useRef(false)
const locate = useCallback(async () => {
@ -139,6 +155,9 @@ export const ContentPieceHomeBoard = ({ items }: { items: readonly ContentPieceH
// tap does not drop the keyboard focus; after the attempt it is gone.
(!attempted || locating)
const copy = isSingle ? COPY.single : hasMunicipalityMatch ? COPY.local : COPY.recent
const municipalityHref = visitor?.municipalityName
? buildContentPieceCatalogHref({ cidade: slugify(visitor.municipalityName) })
: null
return (
<>
@ -180,24 +199,35 @@ export const ContentPieceHomeBoard = ({ items }: { items: readonly ContentPieceH
isSingle ? 'justify-center lg:justify-start' : 'justify-center',
)}
>
<span
data-sample-tag={hasMunicipalityMatch ? 'municipality' : 'recent'}
className={hasMunicipalityMatch ? CONTENT_PIECE_LOCAL_TAG : CONTENT_PIECE_TAG}
>
{hasMunicipalityMatch ? (
<>
<span aria-hidden="true">⌖</span> Para seu município
</>
) : (
'Seleção recente'
)}
</span>
{hasMunicipalityMatch && municipalityHref ? (
// S42 — the sampled territory tag is itself a shortcut to the
// catalogue filtered by the visitor's município (artefato cena 01).
<Link
href={municipalityHref}
data-sample-tag="municipality"
className={CONTENT_PIECE_LOCAL_TAG_LINK}
>
<span aria-hidden="true">⌖</span> Para seu município
<ContentPieceTagChevron />
</Link>
) : (
<span data-sample-tag="recent" className={CONTENT_PIECE_HOME_TAG}>
Seleção recente
</span>
)}
{hasMunicipalityMatch ? (
<span className="hidden text-[11px] text-(--campaign-muted) sm:inline">
Localização usada só nesta visita
</span>
) : null}
</div>
<ContentPieceHomeFilterRow
facets={facets}
className={cn(
'mt-5 w-full',
isSingle ? 'mx-auto max-w-md lg:mx-0' : 'mx-auto max-w-xl sm:mt-6',
)}
/>
{isSingle ? (
<Link
href="/conteudos"
@ -222,6 +252,7 @@ export const ContentPieceHomeBoard = ({ items }: { items: readonly ContentPieceH
playing={playingId === item.id}
onToggle={() => setPlayingId((current) => (current === item.id ? null : item.id))}
onEnded={() => setPlayingId((current) => (current === item.id ? null : current))}
onShare={setSharingItem}
/>
))}
</div>
@ -274,6 +305,10 @@ export const ContentPieceHomeBoard = ({ items }: { items: readonly ContentPieceH
)}
</div>
)}
{sharingItem ? (
<ContentPieceShareSheet item={sharingItem} onClose={() => setSharingItem(null)} />
) : null}
</>
)
}

View file

@ -1,15 +1,24 @@
import Link from 'next/link'
import { buildContentPieceCatalogHref } from '@/lib/contentPieceCatalog'
import type { ContentPieceHomeItem, ContentPieceHomeMatch } from '@/lib/contentPieceHomeSelection'
import { slugify } from '@/lib/slug'
import { cn } from '@/lib/utils'
import { ContentPieceCardMeta, ContentPieceCardTitle } from './ContentPieceCardParts'
import { ContentPieceShareButton } from './ContentPieceActions'
import {
ContentPieceCardMeta,
ContentPieceCardTitle,
ContentPieceTagChevron,
} from './ContentPieceCardParts'
import { ContentPieceMedia } from './ContentPieceMedia'
import {
CONTENT_PIECE_CARD,
CONTENT_PIECE_FOCUS,
CONTENT_PIECE_LOCAL_TAG,
CONTENT_PIECE_TAG,
CONTENT_PIECE_HOME_LOCAL_TAG,
CONTENT_PIECE_HOME_TAG,
CONTENT_PIECE_LOCAL_TAG_LINK,
CONTENT_PIECE_PIECE_LINK,
CONTENT_PIECE_TAG_LINK,
} from './contentPieceClasses'
const MATCH_LABELS: Record<ContentPieceHomeMatch, string> = {
@ -19,11 +28,27 @@ const MATCH_LABELS: Record<ContentPieceHomeMatch, string> = {
}
/**
* S39 — the home sample card (artefato: cenas 01/02/05): a lighter sibling of
* the catalogue card — no download/share actions, the piece page is the
* handoff. Horizontal on the phone (`[124px | 1fr]`, cena 02) and vertical from
* `sm` on; the asset is the shared `ContentPieceMedia`, so video/audio only
* mount the media after the tap.
* S42 — the canonical facet of a tag, or null when the tag has no honest one
* (origin and "Mais recente" stay static). The match only exists with its
* label, so the null guard is a contract, never a rendered case.
*/
const matchHref = (item: ContentPieceHomeItem, match: ContentPieceHomeMatch): string | null => {
if (match === 'municipality' && item.cityLabel) {
return buildContentPieceCatalogHref({ cidade: slugify(item.cityLabel) })
}
if (match === 'region' && item.regionLabel) {
return buildContentPieceCatalogHref({ regiao: slugify(item.regionLabel) })
}
return null
}
/**
* S39/S42 — the home sample card (artefato: cenas 01/02/05): a lighter sibling
* of the catalogue card. Horizontal on the phone (`[124px | 1fr]`, cena 02) and
* vertical from `sm` on; the asset is the shared `ContentPieceMedia`, so
* video/audio only mount the media after the tap. S42 gives it the direct
* shortcut: `Compartilhar` opens the section's own share sheet (the S27 one)
* and the honest tags open `/conteudos` already filtered.
*/
export const ContentPieceHomeCard = ({
item,
@ -31,47 +56,67 @@ export const ContentPieceHomeCard = ({
playing,
onToggle,
onEnded,
onShare,
}: {
item: ContentPieceHomeItem
match: ContentPieceHomeMatch
playing: boolean
onToggle: () => void
onEnded: () => void
}) => (
<article
data-content-piece={item.slug}
className={cn(CONTENT_PIECE_CARD, 'grid grid-cols-[124px_1fr] sm:block')}
>
<ContentPieceMedia
item={item}
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
// C226 — below `sm` this card's media slot is the 124px thumb of the
// design's cena 05 (the compact play/duration, no marker); from `sm` up
// the card becomes a full block and the media slot goes back to the
// catalogue treatment.
variant="home-thumb"
className="aspect-video self-start"
/>
<div className="p-4">
<div className="flex flex-wrap gap-1">
<span className={CONTENT_PIECE_TAG}>{item.isLink ? item.originLabel : item.typeLabel}</span>
<span className={match === 'recent' ? CONTENT_PIECE_TAG : CONTENT_PIECE_LOCAL_TAG}>
{MATCH_LABELS[match]}
</span>
onShare: (item: ContentPieceHomeItem) => void
}) => {
const typeHref = item.isLink ? null : buildContentPieceCatalogHref({ tipo: item.type })
const matchFacetHref = matchHref(item, match)
return (
<article
data-content-piece={item.slug}
className={cn(CONTENT_PIECE_CARD, 'grid grid-cols-[124px_1fr] sm:block')}
>
<ContentPieceMedia
item={item}
playing={playing}
onToggle={onToggle}
onEnded={onEnded}
// C226 — below `sm` this card's media slot is the 124px thumb of the
// design's cena 05 (the compact play/duration, no marker); from `sm` up
// the card becomes a full block and the media slot goes back to the
// catalogue treatment.
variant="home-thumb"
className="aspect-video self-start"
/>
<div className="min-w-0 p-4">
<div className="flex flex-wrap gap-1.5">
{typeHref ? (
<Link href={typeHref} className={CONTENT_PIECE_TAG_LINK}>
{item.typeLabel}
<ContentPieceTagChevron />
</Link>
) : (
<span className={CONTENT_PIECE_HOME_TAG}>
{item.isLink ? item.originLabel : item.typeLabel}
</span>
)}
{matchFacetHref ? (
<Link href={matchFacetHref} className={CONTENT_PIECE_LOCAL_TAG_LINK}>
<span className="truncate">{MATCH_LABELS[match]}</span>
<ContentPieceTagChevron />
</Link>
) : (
<span
className={match === 'recent' ? CONTENT_PIECE_HOME_TAG : CONTENT_PIECE_HOME_LOCAL_TAG}
>
{MATCH_LABELS[match]}
</span>
)}
</div>
<ContentPieceCardTitle item={item} className="text-sm sm:text-base" />
<ContentPieceCardMeta item={item} className="text-[11px] sm:text-xs" />
<ContentPieceShareButton item={item} onShare={onShare} className="mt-3 w-full sm:mt-4" />
<Link href={item.publicPath} className={CONTENT_PIECE_PIECE_LINK}>
Ver esta peça →
</Link>
</div>
<ContentPieceCardTitle item={item} className="text-sm sm:text-base" />
<ContentPieceCardMeta item={item} className="text-[11px] sm:text-xs" />
<Link
href={item.publicPath}
className={cn(
'mt-3 inline-block text-xs font-bold text-(--pt-red) underline-offset-4 hover:underline',
CONTENT_PIECE_FOCUS,
)}
>
Ver esta peça →
</Link>
</div>
</article>
)
</article>
)
}

View file

@ -0,0 +1,100 @@
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { isContentPieceType } from '@/lib/contentPiece'
import {
buildContentPieceCatalogHref,
contentPieceCatalogFacetLabels,
type ContentPieceCatalogFacets,
} from '@/lib/contentPieceCatalog'
import { cn } from '@/lib/utils'
import { CONTENT_PIECE_FOCUS } from './contentPieceClasses'
/**
* S42 — the facets of the home section's explore row (artefato: cenas 01/04/06).
* The server derives them from the published pieces with the catalogue owner
* (`contentPieceCatalogFacets`), so the vocabulary is the same and a facet with
* no option never renders. The row is pure navigation: no local state, nothing
* filters the sample.
*/
const HOME_FACETS = ['tipo', 'cidade', 'regiao'] as const
type ContentPieceHomeFacet = (typeof HOME_FACETS)[number]
export type ContentPieceHomeFacets = Pick<ContentPieceCatalogFacets, ContentPieceHomeFacet>
const CHIP = cn(
'flex min-h-10 w-full cursor-pointer list-none items-center justify-between gap-1.5 rounded-full border border-(--campaign-line) bg-white px-3 py-[7px] text-xs font-extrabold text-[#171000]',
'transition-[border-color,background-color,color] duration-150 ease-out hover:border-[rgb(24_78_146/35%)] hover:bg-[#eef4fb] hover:text-[#184e92] group-open:border-[rgb(24_78_146/35%)] group-open:bg-[#eef4fb] group-open:text-[#184e92] motion-reduce:transition-none [&::-webkit-details-marker]:hidden',
CONTENT_PIECE_FOCUS,
)
const MENU =
'absolute top-[calc(100%+8px)] left-0 z-20 max-h-72 w-[min(220px,calc(100vw-72px))] list-none overflow-y-auto rounded-xl border border-(--campaign-line) bg-white p-1.5 shadow-[0_16px_40px_rgb(39_25_22/16%)]'
const OPTION = cn(
'flex min-h-10 items-center justify-between gap-2 rounded-lg px-2.5 py-2 text-xs font-bold text-[#171000] no-underline hover:bg-[#eef4fb] hover:text-[#184e92]',
CONTENT_PIECE_FOCUS,
)
/** The canonical href of one option — each facet maps to its own URL parameter. */
const optionHref = (facet: ContentPieceHomeFacet, value: string): string => {
if (facet === 'tipo') {
return buildContentPieceCatalogHref({ tipo: isContentPieceType(value) ? value : null })
}
if (facet === 'cidade') return buildContentPieceCatalogHref({ cidade: value })
return buildContentPieceCatalogHref({ regiao: value })
}
export const ContentPieceHomeFilterRow = ({
facets,
className,
}: {
facets: ContentPieceHomeFacets
className?: string
}) => {
const available = HOME_FACETS.filter((facet) => facets[facet].length > 0)
if (available.length === 0) return null
return (
<div className={className}>
<p className="mb-2 text-[11px] font-bold text-(--campaign-muted)">
<span className="sm:hidden">Explore na Central · abre já filtrada</span>
<span className="hidden sm:inline">Explore na Central · abre o catálogo já filtrado</span>
</p>
<div className="grid grid-cols-3 gap-2">
{available.map((facet, index) => (
<details key={facet} name="content-piece-home-facet" className="group relative min-w-0">
<summary className={CHIP}>
<span className="truncate">{contentPieceCatalogFacetLabels[facet]}</span>
<ChevronDown
aria-hidden="true"
strokeWidth={2.5}
className="size-3.5 shrink-0 transition-transform group-open:rotate-180 motion-reduce:transition-none"
/>
</summary>
<ul
className={cn(
MENU,
// Artefato: the middle chip centers its menu, the last one
// anchors right so no menu bleeds past the section.
index === 1 && available.length > 2 && 'left-1/2 -translate-x-1/2',
index > 0 && index === available.length - 1 && 'right-0 left-auto',
)}
>
{facets[facet].map((option) => (
<li key={option.value}>
<Link href={optionHref(facet, option.value)} className={OPTION}>
<span className="truncate">{option.label}</span>
<span aria-hidden="true">→</span>
</Link>
</li>
))}
</ul>
</details>
))}
</div>
</div>
)
}

View file

@ -1,4 +1,4 @@
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { contentPieceCatalogFacets, type ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import { toContentPieceHomeItem } from '@/lib/contentPieceHomeSelection'
import { ContentPieceHomeBoard } from './ContentPieceHomeBoard'
@ -8,27 +8,40 @@ import { ContentPieceHomeBoard } from './ContentPieceHomeBoard'
* 01–06). The page resolves the cached listing once and only renders this when
* something is published (fail-closed, same kill switch as the footer link);
* the lean projection keeps the search haystack out of the static HTML.
*
* S42 — the explore row's facets are derived here, before the projection, from
* the same catalogue owner the catalogue page uses (the lean item keeps only
* what the card renders).
*/
export const ContentPieceHomeSection = ({
items,
}: {
items: readonly ContentPiecePublicItem[]
}) => (
<section
aria-labelledby="content-pieces-title"
data-home-section="content-pieces"
className="relative overflow-hidden border-b border-(--campaign-line) bg-white"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 -right-20 size-64 rounded-full bg-(--pt-yellow)/35"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-28 -left-24 size-64 rounded-full bg-[#e4102f]/8"
/>
<div className="relative mx-auto w-full max-w-[1160px] px-5 py-10 sm:px-8 lg:px-10 lg:py-16">
<ContentPieceHomeBoard items={items.map(toContentPieceHomeItem)} />
</div>
</section>
)
}) => {
const facets = contentPieceCatalogFacets(items)
return (
<section
aria-labelledby="content-pieces-title"
data-home-section="content-pieces"
className="relative overflow-hidden border-b border-(--campaign-line) bg-white"
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 -right-20 size-64 rounded-full bg-(--pt-yellow)/35"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-28 -left-24 size-64 rounded-full bg-[#e4102f]/8"
/>
<div className="relative mx-auto w-full max-w-[1160px] px-5 py-10 sm:px-8 lg:px-10 lg:py-16">
<ContentPieceHomeBoard
items={items.map(toContentPieceHomeItem)}
// Only the row's three facets cross to the client island; the other
// option lists (tema/instituição/liderança) stay on the server.
facets={{ tipo: facets.tipo, cidade: facets.cidade, regiao: facets.regiao }}
/>
</div>
</section>
)
}

View file

@ -20,6 +20,17 @@ import {
CONTENT_PIECE_PRIMARY_BUTTON,
} from './contentPieceClasses'
/**
* S42 — what the sheet actually reads: the catalogue passes the full public
* item and the home section passes its lean projection (the same structural
* precedent as `ContentPieceMedia`), so the one share mechanism serves both
* surfaces without the home carrying the search haystack.
*/
type ContentPieceShareSheetItem = Pick<
ContentPiecePublicItem,
'slug' | 'title' | 'type' | 'file' | 'isLink' | 'sourceUrl'
>
/**
* S27 — the share sheet (artefato: cenas 03/08): the vote message prefilled and
* EDITABLE, then `wa.me` of the sender's own WhatsApp — no recipient, nothing
@ -32,7 +43,7 @@ export const ContentPieceShareSheet = ({
item,
onClose,
}: {
item: ContentPiecePublicItem
item: ContentPieceShareSheetItem
onClose: () => void
}) => {
const [mounted, setMounted] = useState(false)

View file

@ -19,7 +19,7 @@ export const CONTENT_PIECE_TAG =
'inline-flex rounded-full bg-[#eef4fb] px-2 py-1 text-[11px] font-extrabold text-[#184e92]'
/** S39 — the provenance tag of a piece sampled from the visitor's territory. */
export const CONTENT_PIECE_LOCAL_TAG = cn(CONTENT_PIECE_TAG, 'bg-[#fff3c4] text-[#6b5100]')
const CONTENT_PIECE_LOCAL_TAG = cn(CONTENT_PIECE_TAG, 'bg-[#fff3c4] text-[#6b5100]')
/** S28 — the neutral badge of a theme-mode result that only matched the query. */
export const CONTENT_PIECE_EXACT_TAG =
@ -40,3 +40,38 @@ export const CONTENT_PIECE_OUTLINE_BUTTON = cn(
export const CONTENT_PIECE_CARD = cn(
'overflow-hidden rounded-[14px] border border-(--campaign-line) bg-white shadow-[0_8px_24px_rgb(71_19_14/7%)]',
)
/**
* S42 — the home section's tags (artefato: cenas 01–06): 40px targets on
* touch and 36px from `sm` up, in the static (filled) and link (outline +
* chevron) variants. The catalogue and the piece page keep the S27 tags
* (`CONTENT_PIECE_TAG`/`CONTENT_PIECE_LOCAL_TAG`) untouched.
*/
export const CONTENT_PIECE_HOME_TAG = cn(
CONTENT_PIECE_TAG,
'min-h-10 items-center px-2.5 py-1.5 sm:min-h-9',
)
export const CONTENT_PIECE_HOME_LOCAL_TAG = cn(
CONTENT_PIECE_LOCAL_TAG,
'min-h-10 items-center px-2.5 py-1.5 sm:min-h-9',
)
export const CONTENT_PIECE_TAG_LINK = cn(
CONTENT_PIECE_TAG,
'min-h-10 max-w-full items-center gap-1 border border-[rgb(24_78_146/30%)] bg-[#f8fbff] px-2.5 py-1.5 no-underline sm:min-h-9',
'transition-[border-color,background-color,transform] duration-150 ease-out hover:-translate-y-px hover:border-current hover:bg-[#eef4fb] hover:underline hover:underline-offset-[3px] motion-reduce:transition-none',
CONTENT_PIECE_FOCUS,
)
/** S42 — the local (territory) variant of the filtrable tag. */
export const CONTENT_PIECE_LOCAL_TAG_LINK = cn(
CONTENT_PIECE_TAG_LINK,
'border-[rgb(107_81_0/28%)] bg-[#fff8dc] text-[#6b5100] hover:bg-[#fff3c4]',
)
/** S42 — the card's `Ver esta peça →` under the share button (artefato `.piece-link`). */
export const CONTENT_PIECE_PIECE_LINK = cn(
'mt-2 flex min-h-9 w-full items-center justify-center rounded-sm text-xs font-extrabold text-(--pt-red) underline-offset-4 hover:underline',
CONTENT_PIECE_FOCUS,
)

View file

@ -6,7 +6,7 @@ export const Spinner = ({ className, ...props }: React.ComponentProps<'svg'>) =>
data-slot="spinner"
role="status"
aria-label="Carregando"
className={cn('size-4 animate-spin', className)}
className={cn('size-4 animate-spin motion-reduce:animate-none', className)}
{...props}
/>
)

View file

@ -103,6 +103,9 @@ export const campaignContentPiecePublicationHref = (id: number): string =>
export const campaignContentPieceDeleteHref = (id: number): string =>
`${campaignContentPieceDetailHref(id)}/apagar`
/** C236 — batch gateway of the Central list (publish/unpublish/delete a selection). */
export const CAMPAIGN_CONTENT_PIECE_BATCH_HREF = `${CAMPAIGN_COMMUNICATION_CONTEUDOS}/lote` as const
const campaignRecordingDetailHref = (id: number): string =>
`${CAMPAIGN_COMMUNICATION_ACERVO_GRAVACOES}/${id}`

View file

@ -0,0 +1,79 @@
/**
* C236 — pure vocabulary and copy of the batch actions of the Central de
* Conteúdos: the three verbs, the selection counter, the delete confirmation
* and the honest receipt (affected × failures). Client-safe: the selection
* provider, the action bar, the confirmation dialog and the JSON route share
* this module.
*
* The batch is the unit gesture repeated — it never invents a rule of its own,
* so everything here is presentation of an outcome the unit actions produced.
*/
import type { ContentPieceStatus } from '@/lib/contentPiece'
export const CONTENT_PIECE_BATCH_ACTIONS = ['publicar', 'despublicar', 'apagar'] as const
export type ContentPieceBatchAction = (typeof CONTENT_PIECE_BATCH_ACTIONS)[number]
/** One item of the selection: the only fields the list projection needs. */
export type ContentPieceSelectionRow = {
id: number
status: ContentPieceStatus
/** The public `/conteudos/<slug>` when the piece has a slug; null otherwise. */
publicPath: string | null
}
type ContentPieceBatchFailure = {
contentPieceId: number
message: string
}
/** What the loop produced: how many pieces changed and which ones did not. */
export type ContentPieceBatchOutcome = {
action: ContentPieceBatchAction
affected: number
failures: ContentPieceBatchFailure[]
}
/** Transport failure of the whole request — never a per-piece failure. */
export const CONTENT_PIECE_BATCH_ERROR_MESSAGE =
'Não foi possível concluir a ação em lote. Tente novamente.'
/** The irreversible-only body of the mass delete (no published piece in the selection). */
export const CONTENT_PIECE_BATCH_DELETE_IRREVERSIBLE_MESSAGE = 'Esta ação não pode ser desfeita.'
/** The body that warns about the public links when the selection has published pieces. */
export const CONTENT_PIECE_BATCH_DELETE_LINKS_MESSAGE =
'Os links públicos das peças publicadas da seleção deixam de funcionar para quem já recebeu. Esta ação não pode ser desfeita.'
/** `3 selecionadas` / `1 selecionada` (the TourComposerForm precedent). */
export const contentPieceSelectionLabel = (count: number): string =>
`${count} ${count === 1 ? 'selecionada' : 'selecionadas'}`
const PARTICIPLES: Record<ContentPieceBatchAction, readonly [string, string]> = {
publicar: ['publicada', 'publicadas'],
despublicar: ['despublicada', 'despublicadas'],
apagar: ['apagada', 'apagadas'],
}
/**
* The honest receipt: an all-success outcome is one phrase; a partial one
* always separates affected from failures and never says "3 peças apagadas"
* when one failed.
*/
export const contentPieceBatchReceiptMessage = (outcome: ContentPieceBatchOutcome): string => {
const [singular, plural] = PARTICIPLES[outcome.action]
if (outcome.failures.length === 0) {
return `${outcome.affected} ${outcome.affected === 1 ? `peça ${singular}` : `peças ${plural}`}`
}
const total = outcome.affected + outcome.failures.length
const failed =
outcome.failures.length === 1 ? '1 falhou.' : `${outcome.failures.length} falharam.`
const noun = total === 1 ? 'peça' : 'peças'
return `${outcome.affected} de ${total} ${noun} ${total === 1 ? singular : plural}. ${failed}`
}
/** `Apagar 3 peças selecionadas?` — the singular reuses the C222 literal. */
export const contentPieceBatchDeleteTitle = (count: number): string =>
count === 1 ? 'Apagar esta peça?' : `Apagar ${count} peças selecionadas?`

View file

@ -32,6 +32,7 @@ export type ContentPieceHomeItem = Pick<
| 'origin'
| 'originLabel'
| 'isLink'
| 'sourceUrl'
| 'cityLabel'
| 'regionLabel'
| 'excerpt'
@ -52,6 +53,7 @@ export const toContentPieceHomeItem = (item: ContentPiecePublicItem): ContentPie
origin: item.origin,
originLabel: item.originLabel,
isLink: item.isLink,
sourceUrl: item.sourceUrl,
cityLabel: item.cityLabel,
regionLabel: item.regionLabel,
excerpt: item.excerpt,

View file

@ -11,6 +11,7 @@ import {
CONTENT_PIECE_TITLE_MAX_LENGTH,
CONTENT_PIECE_TYPES,
} from '@/lib/contentPiece'
import { CONTENT_PIECE_BATCH_ACTIONS } from '@/lib/contentPieceBatch'
import { contentPieceProfilePeriodError } from '@/lib/contentPieceProfileWindow'
import { trimmedNullableText } from '@/lib/schemas/primitives'
import { TRANSCRIPT_TEXT_MAX_LENGTH } from '@/lib/speechSearch'
@ -169,6 +170,18 @@ export const contentPiecePublicationRequestSchema = z.object({
published: z.boolean(),
})
/**
* C236 — apply the same verdict to a bounded set of pieces: the selection is
* one page of the list, so the ceiling mirrors the status poll (50 ids) and the
* action dedupes whatever arrives.
*/
export const contentPieceBatchRequestSchema = z.object({
action: z.enum(CONTENT_PIECE_BATCH_ACTIONS),
contentPieceIds: z.array(z.number().int().positive()).min(1).max(50),
})
export type ContentPieceBatchRequest = z.infer<typeof contentPieceBatchRequestSchema>
/** Attach the original file to a link piece. */
export const contentPieceAttachRequestSchema = z.object({
contentPieceId: z.number().int().positive(),

View file

@ -11,6 +11,16 @@
/** The provisional cut-off of the theme mode — calibrated by `acervo:index --probe`. */
export const SPEECH_SEMANTIC_MIN_COSINE = 0.4
/**
* C237 — weight of the official metadata (summary/title + keywords) against the
* mean of the spoken units when composing the speech-level vector. Calibrated
* on the production corpus (2026-09-30): with weight 1 the Dilma impeachment
* vote speech (222) sat at #25 for the theme "impeachment"; with 2 it reaches
* the first page (#8) and the topic cluster (2016 process + Bolsonaro requests)
* leads, without dropping the other acceptance themes out of their cluster.
*/
export const SPEECH_METADATA_VECTOR_WEIGHT = 2
/** Text windows of speeches without ASR segments: ~1200 chars, ~200 of overlap. */
const SPEECH_WINDOW_SIZE_CHARS = 1200
const SPEECH_WINDOW_OVERLAP_CHARS = 200
@ -239,10 +249,50 @@ type SpeechIndexUnit = {
export type SpeechIndexPlan = {
units: SpeechIndexUnit[]
/** Hash of every unit text + model: the CLI's skip key. */
/**
* C237 — official metadata text folded into the speech-level vector (never
* stored as a unit, never evidence). `null` when the speech carries none.
*/
metadataText: string | null
/** Hash of every unit text + metadata + model: the CLI's skip key. */
speechHash: string
}
/**
* C237 — the official metadata of one speech: the Câmara summary (the web title
* when there is no summary) plus the raw official keywords. This is the text
* that states the *topic* even when the spoken passage never names it (the
* impeachment vote speech never says "impeachment").
*/
export const speechMetadataText = (speech: {
summary?: string | null
title?: string | null
keywords?: readonly string[] | null
}): string | null => {
const head = [speech.summary, speech.title].find(
(candidate) => typeof candidate === 'string' && candidate.trim() !== '',
)
const keywords = (speech.keywords ?? []).map((keyword) => keyword.trim()).filter(Boolean)
const text = [head?.trim() ?? '', keywords.join(', ')].filter(Boolean).join('\n')
return text === '' ? null : text
}
/**
* C237 — the speech-level vector of the index: the normalized mean of the unit
* vectors plus the weighted official metadata. A speech without metadata keeps
* the pre-C237 mean exactly.
*/
export const composeSpeechVector = (
unitVectors: readonly SpeechSemanticVector[],
metadataVector: SpeechSemanticVector | null,
): number[] => {
const mean = meanPoolVectors(unitVectors)
if (!metadataVector || metadataVector.length !== mean.length || mean.length === 0) return mean
return normalizeVector(
mean.map((value, index) => value + SPEECH_METADATA_VECTOR_WEIGHT * metadataVector[index]),
)
}
export type SpeechIndexSegment = {
order: number
startSeconds: number
@ -259,6 +309,8 @@ export const planSpeechIndex = (
speech: {
officialTranscript?: string | null
summary?: string | null
title?: string | null
keywords?: readonly string[] | null
searchText?: string | null
segments?: readonly SpeechIndexSegment[]
},
@ -289,9 +341,13 @@ export const planSpeechIndex = (
if (units.length === 0) return null
const metadataText = speechMetadataText(speech)
const speechHash = speechEmbeddingHash(
units.map((unit) => `${unit.kind}:${unit.order}:${unit.text}`).join('\n'),
[
...units.map((unit) => `${unit.kind}:${unit.order}:${unit.text}`),
...(metadataText ? [`metadata:${metadataText}`] : []),
].join('\n'),
model,
)
return { units, speechHash }
return { units, metadataText, speechHash }
}

View file

@ -3,8 +3,8 @@ import 'server-only'
import type { Payload } from 'payload'
import {
composeSpeechVector,
cosineSimilarity,
meanPoolVectors,
planSpeechIndex,
type SpeechIndexPlan,
type SpeechIndexSegment,
@ -24,6 +24,10 @@ export type SpeechIndexSource = {
id: number
officialTranscript?: string | null
summary?: string | null
/** C237 — web title, the metadata head when there is no summary. */
title?: string | null
/** C237 — raw official keywords, folded into the metadata text. */
keywords?: readonly string[] | null
searchText?: string | null
segments: readonly SpeechIndexSegment[]
}
@ -41,6 +45,8 @@ export type SpeechIndexPlanBatch = {
/** No usable text at all. */
skippedNoText: number
units: number
/** C237 — one extra provider input per stale speech that has metadata. */
metadataTexts: number
}
export type SpeechIndexBatchResult = {
@ -48,7 +54,10 @@ export type SpeechIndexBatchResult = {
upToDate: number
skippedNoText: number
failed: number
/** Stored retrieval units (segments/windows). */
units: number
/** C237 — metadata texts embedded on top of the units (not stored). */
metadataTexts: number
promptTokens: number
}
@ -120,6 +129,7 @@ export const planSpeechIndexBatch = async (
upToDate: planned.length - stale.length,
skippedNoText,
units: stale.reduce((total, { plan }) => total + plan.units.length, 0),
metadataTexts: stale.reduce((total, { plan }) => total + (plan.metadataText ? 1 : 0), 0),
}
}
@ -141,29 +151,41 @@ export const indexSpeechSources = async (
upToDate: batch.upToDate,
skippedNoText: batch.skippedNoText,
units: 0,
metadataTexts: 0,
promptTokens: 0,
}
if (batch.stale.length === 0) return { ...base, indexed: 0, failed: 0 }
const texts = batch.stale.flatMap(({ plan }) => plan.units.map((unit) => unit.text))
// C237 — the official metadata rides the same ordered provider call, right
// after the units of each speech; it feeds the speech vector only.
const texts = batch.stale.flatMap(({ plan }) => [
...plan.units.map((unit) => unit.text),
...(plan.metadataText ? [plan.metadataText] : []),
])
const embedded = await embedTexts(texts)
if (!embedded) {
return { ...base, indexed: 0, failed: batch.stale.length }
}
// The whole batch was embedded already: the tokens were spent even if a
// later write fails.
const spent = { units: texts.length, promptTokens: embedded.promptTokens }
const spent = {
units: batch.units,
metadataTexts: batch.metadataTexts,
promptTokens: embedded.promptTokens,
}
let cursor = 0
let indexed = 0
for (const { source, plan } of batch.stale) {
const vectors = embedded.vectors.slice(cursor, cursor + plan.units.length)
cursor += plan.units.length
const metadataVector = plan.metadataText ? (embedded.vectors[cursor] ?? null) : null
if (plan.metadataText) cursor += 1
if (vectors.length !== plan.units.length) {
return { ...base, ...spent, indexed, failed: batch.stale.length - indexed }
}
const speechVector = meanPoolVectors(vectors)
const speechVector = composeSpeechVector(vectors, metadataVector)
await withPayloadTransaction(payload, async ({ req }) => {
await payload.delete({
collection: 'speechEmbedding',
@ -299,6 +321,7 @@ export type SpeechIndexPreview = {
skippedNoText: number
toIndex: number
units: number
metadataTexts: number
}
export const previewSpeechIndex = (
@ -310,4 +333,5 @@ export const previewSpeechIndex = (
skippedNoText: batch.skippedNoText,
toIndex: batch.stale.length,
units: batch.units,
metadataTexts: batch.metadataTexts,
})

View file

@ -1892,5 +1892,68 @@ test.describe('communication vertical (C154/C162)', () => {
)
.toBe(true)
})
test('publishes and deletes a selection over HTTP, naming the partial failure (C236)', async ({
campaign,
campaignRequest,
}) => {
const marker = campaign.fixtures.value('pecalote')
const published = await createPiece(campaign, { marker: `${marker}a`, status: 'publicado' })
const draft = await createPiece(campaign, { marker: `${marker}b` })
const loteUrl = '/campanha/comunicacao/conteudos/lote'
const communicator = await campaign.fixtures.createCampaignUser('communicator')
const request = await campaignRequest(communicator, communicator.password)
// Fail-closed: an advisor is denied before any piece changes.
const advisor = await campaign.fixtures.createCampaignUser('advisor')
const deniedRequest = await campaignRequest(advisor, advisor.password)
const denied = await deniedRequest.post(loteUrl, {
data: { action: 'apagar', contentPieceIds: [published.piece.id] },
})
expect(denied.status()).toBe(400)
expect(((await denied.json()) as { message: string }).message).toContain(
'não tem acesso à Central de Conteúdos',
)
// Publish the draft through the batch gateway.
const publishBatch = await request.post(loteUrl, {
data: { action: 'publicar', contentPieceIds: [draft.piece.id] },
})
expect(publishBatch.status()).toBe(200)
expect(await publishBatch.json()).toEqual({
status: 'success',
outcome: { action: 'publicar', affected: 1, failures: [] },
})
const slug = published.piece.slug!
const missingId = published.piece.id + 1_000_000
const deleteBatch = await request.post(loteUrl, {
data: { action: 'apagar', contentPieceIds: [published.piece.id, missingId] },
})
expect(deleteBatch.status()).toBe(200)
expect(await deleteBatch.json()).toEqual({
status: 'success',
outcome: {
action: 'apagar',
affected: 1,
failures: [{ contentPieceId: missingId, message: 'Peça não encontrada.' }],
},
})
// The deleted row leaves the list; the draft published above stays.
const afterList = rendered(
await (await request.get(`/campanha/comunicacao/conteudos?q=${marker}`)).text(),
)
expect(afterList).not.toContain(`Peça ${marker}a`)
expect(afterList).toContain(`Peça ${marker}b`)
const anonymous = await playwrightRequest.newContext({ baseURL: campaign.baseURL })
try {
expect((await anonymous.get(`/conteudos/${slug}`)).status()).toBe(404)
} finally {
await anonymous.dispose()
}
})
})
})

View file

@ -735,8 +735,10 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
}) => {
const headers = await adminHeaders(request, BASE_URL)
await unpublishEveryPiece(request, headers)
await createPiece(request, headers, { title: `Peça da home ${uniqueMarker()}`, type: 'foto' })
await createPiece(request, headers, { title: `Vídeo da home ${uniqueMarker()}`, type: 'video' })
const photoTitle = `Peça da home ${uniqueMarker()}`
const videoTitle = `Vídeo da home ${uniqueMarker()}`
await createPiece(request, headers, { title: photoTitle, type: 'foto' })
await createPiece(request, headers, { title: videoTitle, type: 'video' })
await waitForHomeSection(request, 'present')
@ -759,7 +761,43 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
await expect(section.locator('article[data-content-piece]')).toHaveCount(2)
// Nothing plays on its own: the video mounts no media element before the tap.
await expect(section.locator('video, audio')).toHaveCount(0)
// Cena 02 — the sample never overflows the phone.
// S42 — the honest tags open the catalogue already filtered and the others
// stay static; nothing filters the sample locally.
const photoCard = section.locator(`article[data-content-piece]`, { hasText: photoTitle })
const videoCard = section.locator(`article[data-content-piece]`, { hasText: videoTitle })
await expect(photoCard.getByRole('link', { name: 'Foto', exact: true })).toHaveAttribute(
'href',
'/conteudos?tipo=foto',
)
await expect(videoCard.getByRole('link', { name: 'Vídeo', exact: true })).toHaveAttribute(
'href',
'/conteudos?tipo=video',
)
await expect(section.getByText('Mais recente', { exact: true })).toHaveCount(2)
await expect(section.getByRole('link', { name: 'Mais recente' })).toHaveCount(0)
// S42 — the card shares through the same S27 sheet, without leaving the home.
await section.getByRole('button', { name: `Compartilhar ${photoTitle}` }).click()
const homeSheet = page.getByRole('dialog', { name: 'Compartilhar peça' })
await expect(homeSheet).toBeVisible()
await expect(homeSheet.getByLabel('Mensagem para compartilhar')).toHaveValue(
new RegExp(photoTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
)
await page.keyboard.press('Escape')
await expect(homeSheet).toHaveCount(0)
await expect(section.getByRole('heading', { name: 'Peça voto pra Solla 1313' })).toBeVisible()
// S42 — the explore row only carries the facets the sample has (no
// município in these pieces) and its options point at the filtered URL.
const tipoSummary = section.locator('summary').filter({ hasText: 'Tipo' })
await expect(section.locator('summary')).toHaveCount(1)
await tipoSummary.click()
await expect(
section.locator('details[open]').getByRole('link', { name: 'Vídeo' }),
).toHaveAttribute('href', '/conteudos?tipo=video')
// Cena 02 — the sample (with the open menu) never overflows the phone.
const overflow = await page.evaluate(() => {
const container = document.querySelector<HTMLElement>('[data-theme="campaign-site"]')
return container ? container.scrollWidth - container.clientWidth : 0
@ -811,6 +849,24 @@ test.describe('Frontend Central de Conteúdos (S27)', () => {
// Exact: the sr-only live region announces "Peças do seu município".
await expect(section.getByText('Do seu município', { exact: true })).toBeVisible()
await expect(section.getByRole('link', { name: title })).toBeVisible()
// S42 — the territory tags are shortcuts to the catalogue on the same facet.
const card = section.locator('article[data-content-piece]', { hasText: title })
await expect(card.getByRole('link', { name: 'Do seu município', exact: true })).toHaveAttribute(
'href',
'/conteudos?cidade=feira-de-santana',
)
await expect(card.getByRole('link', { name: 'Foto', exact: true })).toHaveAttribute(
'href',
'/conteudos?tipo=foto',
)
await section.getByRole('link', { name: /Para seu município/ }).click()
await page.waitForURL(/\/conteudos\?cidade=feira-de-santana$/)
await waitForSettledPage(page)
// The catalogue opens already filtered by the same canonical URL.
await expect(page.getByRole('link', { name: /Remover filtro Cidade/ })).toBeVisible()
await expect(page.locator('article[data-content-piece]')).toHaveCount(1)
await expect(page.getByRole('link', { name: title, exact: true })).toBeVisible()
})
test('filters by who appears in the piece and removes a name when unpublished (S37)', async ({
page,

View file

@ -34,8 +34,10 @@ vi.mock('@/utilities/campaignAuth', () => ({
import { GET } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/arquivo/route'
import { POST as postProfileImportCreate } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/criar/route'
import { POST as postProfileImport } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/importar/route'
import { POST as postBatch } from '@/app/(campaign)/campanha/(app)/comunicacao/conteudos/lote/route'
import {
addContentPieceByLinkForActor,
applyContentPieceBatchActionForActor,
createContentPieceFromProfilePostForActor,
deleteContentPieceForActor,
getContentPieceStatusesForActor,
@ -850,6 +852,208 @@ describe('content pieces (C211)', () => {
})
})
describe('batch actions (C236)', () => {
const mediaGone = async (mediaId: number): Promise<boolean> => {
for (let attempt = 0; attempt < 20; attempt += 1) {
const gone = await payload
.findByID({ collection: 'contentMedia', id: mediaId, overrideAccess: true })
.then(() => false)
.catch(() => true)
if (gone) return true
await new Promise((resolve) => setTimeout(resolve, 50))
}
return false
}
it('publishes and unpublishes a selection with the unit semantics preserved', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
const tag = getCollectionListingTag('contentPiece')
const first = await createPiece({ title: `Lote publica ${Date.now()}a` })
const second = await createPiece({ title: `Lote publica ${Date.now()}b` })
vi.mocked(revalidateTag).mockClear()
await expect(
applyContentPieceBatchActionForActor({
action: 'publicar',
contentPieceIds: [first.piece.id, second.piece.id],
}),
).resolves.toEqual({ action: 'publicar', affected: 2, failures: [] })
expect(revalidateTag).toHaveBeenCalledWith(tag)
const publishedFirst = await payload.findByID({
collection: 'contentPiece',
id: first.piece.id,
depth: 0,
overrideAccess: true,
})
expect(publishedFirst.status).toBe('publicado')
expect(publishedFirst.slug).toBeTruthy()
await expect(
applyContentPieceBatchActionForActor({
action: 'despublicar',
contentPieceIds: [first.piece.id, second.piece.id],
}),
).resolves.toEqual({ action: 'despublicar', affected: 2, failures: [] })
const unpublished = await payload.findByID({
collection: 'contentPiece',
id: first.piece.id,
depth: 0,
overrideAccess: true,
})
// The kill switch preserves slug, publication date and the file.
expect(unpublished.status).toBe('rascunho')
expect(unpublished.slug).toBe(publishedFirst.slug)
expect(unpublished.publishedAt).toBe(publishedFirst.publishedAt)
expect(unpublished.media).toBe(first.media!.id)
})
it('deletes a selection with its media, leaves the events and dedupes repeated ids', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
const first = await createPiece({ title: `Lote apaga ${Date.now()}a`, status: 'publicado' })
const second = await createPiece({ title: `Lote apaga ${Date.now()}b` })
const event = await payload.create({
collection: 'contentEvent',
data: { type: 'abertura', subjectType: 'peca', subjectId: String(first.piece.id) },
overrideAccess: true,
})
await expect(
applyContentPieceBatchActionForActor({
action: 'apagar',
contentPieceIds: [first.piece.id, first.piece.id, second.piece.id],
}),
).resolves.toEqual({ action: 'apagar', affected: 2, failures: [] })
createdPieceIds.delete(first.piece.id)
createdPieceIds.delete(second.piece.id)
await expect(
payload.findByID({ collection: 'contentPiece', id: second.piece.id, overrideAccess: true }),
).rejects.toThrow()
for (const media of [first.media, second.media]) {
if (!media) continue
expect(await mediaGone(media.id)).toBe(true)
createdMediaIds.delete(media.id)
}
const survivor = await payload.findByID({
collection: 'contentEvent',
id: event.id,
overrideAccess: true,
})
expect(survivor.subjectId).toBe(String(first.piece.id))
await payload.delete({ collection: 'contentEvent', id: event.id, overrideAccess: true })
})
it('names the failing piece and never reverts the ones that succeeded', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
const existing = await createPiece({ title: `Lote parcial ${Date.now()}` })
const missingId = existing.piece.id + 100_000
await expect(
applyContentPieceBatchActionForActor({
action: 'apagar',
contentPieceIds: [existing.piece.id, missingId],
}),
).resolves.toEqual({
action: 'apagar',
affected: 1,
failures: [{ contentPieceId: missingId, message: CONTENT_PIECE_NOT_FOUND_MESSAGE }],
})
createdPieceIds.delete(existing.piece.id)
await expect(
payload.findByID({
collection: 'contentPiece',
id: existing.piece.id,
overrideAccess: true,
}),
).rejects.toThrow()
})
it('gates the batch by role: denied roles fail whole, the vertical acts', async () => {
const first = await createPiece({ title: `Lote negado ${Date.now()}a` })
const second = await createPiece({ title: `Lote negado ${Date.now()}b` })
for (const denied of [advisor, leader]) {
getCampaignUserMock.mockResolvedValue(denied)
await expect(
applyContentPieceBatchActionForActor({
action: 'apagar',
contentPieceIds: [first.piece.id, second.piece.id],
}),
).rejects.toThrow(CONTENT_PIECE_FORBIDDEN_MESSAGE)
}
for (const piece of [first.piece, second.piece]) {
await expect(
payload.findByID({ collection: 'contentPiece', id: piece.id, overrideAccess: true }),
).resolves.toMatchObject({ id: piece.id })
}
// The vertical that owns the gesture acts through the same gate.
for (const allowed of [coordinator, candidate]) {
const { piece } = await createPiece({ title: `Lote permitido ${Date.now()}` })
getCampaignUserMock.mockResolvedValue(allowed)
await expect(
applyContentPieceBatchActionForActor({
action: 'publicar',
contentPieceIds: [piece.id],
}),
).resolves.toEqual({ action: 'publicar', affected: 1, failures: [] })
}
})
it('bounds the batch with the request schema', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
await expect(
applyContentPieceBatchActionForActor({ action: 'publicar', contentPieceIds: [] }),
).rejects.toThrow()
await expect(
applyContentPieceBatchActionForActor({
action: 'publicar',
contentPieceIds: Array.from({ length: 51 }, (_, index) => index + 1),
}),
).rejects.toThrow()
})
it('answers the JSON envelope of the batch route', async () => {
getCampaignUserMock.mockResolvedValue(communicator)
const { piece } = await createPiece({ title: `Lote rota ${Date.now()}` })
const call = (body: unknown, origin = 'http://localhost') =>
postBatch(
new Request('http://localhost/campanha/comunicacao/conteudos/lote', {
method: 'POST',
headers: { 'content-type': 'application/json', origin },
body: JSON.stringify(body),
}),
)
const published = await call({ action: 'publicar', contentPieceIds: [piece.id] })
expect(published.status).toBe(200)
await expect(published.json()).resolves.toEqual({
status: 'success',
outcome: { action: 'publicar', affected: 1, failures: [] },
})
const crossOrigin = await call(
{ action: 'publicar', contentPieceIds: [piece.id] },
'https://evil.example',
)
expect(crossOrigin.status).toBe(403)
getCampaignUserMock.mockResolvedValue(advisor)
const denied = await call({ action: 'apagar', contentPieceIds: [piece.id] })
expect(denied.status).toBe(400)
await expect(denied.json()).resolves.toEqual({
status: 'error',
message: CONTENT_PIECE_FORBIDDEN_MESSAGE,
})
})
})
it('reaps a stale processing row to failed and reports the repaired status', async () => {
const { piece } = await createPiece({ processingStatus: 'processando' })
await payload.update({

View file

@ -613,6 +613,70 @@ describe('speech acervo (C154)', () => {
).toContain(`Primeiro trecho ${runId}`)
})
it('folds the official metadata into the speech vector (C237)', async () => {
const runId = randomUUID().slice(0, 8)
const offTopic = [0.5, Math.sqrt(3) / 2]
const summary = `Processo de impeachment da Presidenta Dilma Rousseff ${runId}`
const idWithMetadata = await createSpeech({
speechAt: '1988-01-01T10:00',
year: 1988,
summary,
keywords: ['IMPEACHMENT'],
segments: [{ startSeconds: 0, endSeconds: 3, text: `Trecho sem o tema ${runId}` }],
})
const idWithout = await createSpeech({
speechAt: '1988-01-02T10:00',
year: 1988,
segments: [{ startSeconds: 0, endSeconds: 3, text: `Trecho sem o tema ${runId}` }],
})
const embedCalls: string[][] = []
const embedTexts = async (texts: readonly string[]) => {
embedCalls.push([...texts])
return {
vectors: texts.map((text) =>
text.includes(runId) && text.startsWith('Processo') ? [1, 0] : offTopic,
),
promptTokens: 5,
}
}
const result = await indexSpeechSources(
payload,
[
{
id: idWithMetadata,
summary,
keywords: ['IMPEACHMENT'],
segments: [{ order: 1, startSeconds: 0, text: `Trecho sem o tema ${runId}` }],
},
{
id: idWithout,
segments: [{ order: 1, startSeconds: 0, text: `Trecho sem o tema ${runId}` }],
},
],
{ embedTexts },
)
expect(result).toMatchObject({ indexed: 2, failed: 0, units: 2, metadataTexts: 1 })
expect(embedCalls[0]).toEqual([
`Trecho sem o tema ${runId}`,
`${summary}\nIMPEACHMENT`,
`Trecho sem o tema ${runId}`,
])
const { coordinator } = await createUsers()
const data = await loadSpeechAcervoPageData(
payload,
coordinator,
{ q: `tema ${runId}`, mode: 'tema', year: '1988' },
async () => [1, 0],
)
// Both passages never state the theme; the official metadata is what ranks
// the speech that carries it above the identical spoken-only neighbour.
expect(data.rows.map((row) => row.id)).toEqual([idWithMetadata, idWithout])
expect(data.rows[0]?.semanticMatch).toBe(true)
})
it('cascades the index rows when the speech is deleted (C229)', async () => {
const id = await createSpeech()
await seedSpeechEmbedding(id, 'speech', null, [1, 0])

View file

@ -252,6 +252,138 @@ describe('main — spec assembly, replay and the per-size log', () => {
expect(stdout.join('')).toContain('tipo=delta')
})
it('assembles the paired columns from the delta table with --paired', async () => {
const input = await writeInput(
dir,
'Tipo\t2006\t2026\nCirurgia geral\t592\t1433\nOncologia\t0\t134\n',
)
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
await main({
argv: [
`--in=${input}`,
'--paired',
'--headline=Ortopedia cresce 4,1 vezes',
'--source=DATASUS / Ministério da Saúde',
`--out=${join(dir, 'par.png')}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
})
expect(close).toHaveBeenCalledOnce()
const spec = JSON.parse(
await readFile(
join(dir, 'data/graficos-instagram/ortopedia-cresce-4-1-vezes.chart-spec.json'),
'utf8',
),
)
expect(spec).toMatchObject({
chartType: 'delta',
pairedColumns: true,
startLabel: '2006',
endLabel: '2026',
})
expect(stdout.join('')).toContain('tipo=delta')
})
it('renders the paired columns as an MP4 with --motion and skips the still screenshot', async () => {
const input = await writeInput(
dir,
'Tipo\t2006\t2026\nCirurgia geral\t592\t1433\nOncologia\t0\t134\n',
)
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
const renderMotion = vi.fn(async (_options: { outPath: string }) => ({
size: 4096,
source: 'empacotado',
}))
const outPath = join(dir, 'motion.mp4')
await main({
argv: [
`--in=${input}`,
'--paired',
'--motion',
'--headline=Ortopedia cresce 4,1 vezes',
'--source=DATASUS / Ministério da Saúde',
`--out=${outPath}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
renderMotion,
})
expect(screenshot).not.toHaveBeenCalled()
expect(close).toHaveBeenCalledOnce()
expect(renderMotion).toHaveBeenCalledWith(
expect.objectContaining({ outPath, width: 1080, height: 1350 }),
)
const spec = JSON.parse(
await readFile(
join(dir, 'data/graficos-instagram/ortopedia-cresce-4-1-vezes.chart-spec.json'),
'utf8',
),
)
expect(spec).toMatchObject({ motion: true, pairedColumns: true, chartType: 'delta' })
expect(stdout.join('')).toContain('MP4')
expect(stdout.join('')).toContain('motion 6s @ 30fps')
})
it('refuses --motion outside the paired variant without launching the browser', async () => {
const input = await writeInput(dir, 'Ilhéus 84\nItabuna 68\n')
const { launchBrowser } = launchTracker()
await expect(
main({
argv: [`--in=${input}`, '--motion', '--headline=X', '--source=TSE'],
repoRoot: dir,
launchBrowser,
die: throwingDie,
}),
).rejects.toThrow(/colunas emparelhadas/)
expect(launchBrowser).not.toHaveBeenCalled()
})
it('renders the two-period column as an MP4 with --motion (C239)', async () => {
const input = await writeInput(dir, 'Ano\tLeitos de UTI\n2005\t179\n2026\t1536\n')
const { launchBrowser, close } = launchTracker()
const screenshot = screenshotStub()
const renderMotion = vi.fn(async (_options: { outPath: string }) => ({
size: 4096,
source: 'empacotado',
}))
const outPath = join(dir, 'uti.mp4')
await main({
argv: [
`--in=${input}`,
'--motion',
'--headline=UTIs crescem quase 9 vezes',
'--source=DATASUS / Ministério da Saúde',
`--out=${outPath}`,
],
repoRoot: dir,
launchBrowser,
screenshot,
renderMotion,
})
expect(screenshot).not.toHaveBeenCalled()
expect(close).toHaveBeenCalledOnce()
const spec = JSON.parse(
await readFile(
join(dir, 'data/graficos-instagram/utis-crescem-quase-9-vezes.chart-spec.json'),
'utf8',
),
)
expect(spec).toMatchObject({ motion: true, chartType: 'column' })
expect(stdout.join('')).toContain('MP4')
})
it('assembles a vertical category comparison with the relation kicker and the unit', async () => {
const input = await writeInput(
dir,

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
CHART_TYPES,
MAX_PAIRED,
MAX_POINTS,
MAX_POINTS_LINE,
MAX_POINTS_STORY,
@ -631,6 +632,67 @@ describe('validateSpec — the variation bar (delta)', () => {
})
})
// C237 degraded variant: the same delta dataset drawn as two columns per
// category (initial gray, recent red). The category label under a pair caps it
// at four categories; the two-tone pair is fixed, so no highlight.
describe('validateSpec — paired columns (C237)', () => {
const pairedSpec = (overrides = {}) => ({
chartType: 'delta',
headline: 'Ortopedia cresce 4,1 vezes, neurocirurgia quadruplica e oncologia sai do zero',
startLabel: '2006',
endLabel: '2026',
pairedColumns: true,
rows: [
{ label: 'Cirurgia geral', initial: 592, final: 1433 },
{ label: 'Ortopedia', initial: 182, final: 755 },
{ label: 'Oncologia', initial: 0, final: 134 },
],
...overrides,
})
it('accepts the two-measure table drawn as paired columns', () => {
expect(validateSpec(pairedSpec())).toBeTruthy()
})
it('requires the delta shape and type', () => {
expect(() =>
validateSpec(pairedSpec({ chartType: 'bar', rows: [{ label: 'Ortopedia', value: 755 }] })),
).toThrow(/exigem o tipo delta/)
})
it('refuses highlight (the two-tone pair is fixed)', () => {
expect(() => validateSpec(pairedSpec({ highlight: 'Ortopedia' }))).toThrow(/destaque/)
})
it('refuses --no-highlight and --dual-positive', () => {
expect(() => validateSpec(pairedSpec({ noHighlight: true }))).toThrow(/não combinam/)
expect(() => validateSpec(pairedSpec({ dualPositive: true }))).toThrow(/exige o tipo coluna/)
})
it(`caps the pairs at ${MAX_PAIRED} (the category label collides above that)`, () => {
const rows = Array.from({ length: MAX_PAIRED + 1 }, (_value, index) => ({
label: `Tipo ${index}`,
initial: 1,
final: 2,
}))
expect(validateSpec(pairedSpec({ rows: rows.slice(0, MAX_PAIRED) }))).toBeTruthy()
expect(() => validateSpec(pairedSpec({ rows }))).toThrow(/colidem/)
})
it('keeps the delta guardrails (zero base, no retraction)', () => {
expect(() =>
validateSpec(pairedSpec({ rows: [{ label: 'Ortopedia', initial: 755, final: 182 }] })),
).toThrow(/linha de duas séries/)
expect(() =>
validateSpec(pairedSpec({ rows: [{ label: 'Ortopedia', initial: -1, final: 2 }] })),
).toThrow(/negativo/)
})
it('rejects a non-boolean pairedColumns', () => {
expect(() => validateSpec(pairedSpec({ pairedColumns: 'sim' }))).toThrow(/booleano/)
})
})
describe('contracts', () => {
it('declares the v1 types plus the delta variant, labels and sizes', () => {
expect(CHART_TYPES).toEqual(['bar', 'column', 'line', 'anchor', 'delta'])

View file

@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest'
import {
COLUMN_MOTION,
columnMotion,
MOTION_CSS,
MOTION_DURATION_S,
MOTION_EASING,
MOTION_FPS,
MOTION_FRAMES,
MOTION_STAGES,
motionEnd,
motionFrameTimes,
motionKindFor,
motionStages,
PAIR_MOTION,
pairMotion,
validateMotion,
} from '../../scripts/lib/chartMotion.mjs'
// C238/C239: the motion clock is data — the renderer serializes the schedule
// into inline vars and the capture seeks the animations to the grid, so the
// timing is pinned here instead of living inside the stylesheet.
const pairedSpec = (pairCount = 4) => ({
chartType: 'delta',
pairedColumns: true,
startLabel: '2006',
endLabel: '2026',
rows: Array.from({ length: pairCount }, (_value, index) => ({
label: `Tipo ${index}`,
initial: 1,
final: 2,
})),
})
const columnSpec = (count = 2) => ({
chartType: 'column',
rows: Array.from({ length: count }, (_value, index) => ({
label: String(2005 + index),
value: 100 + index,
})),
})
const anchorSpec = () => ({
chartType: 'anchor',
rows: [{ label: 'leitos privados contratados para o atendimento pelo SUS', value: 1130 }],
})
describe('chartMotion — the deterministic clock', () => {
it('runs 6s at 30fps on an exact 180-frame grid', () => {
expect(MOTION_FPS).toBe(30)
expect(MOTION_DURATION_S).toBe(6)
expect(MOTION_FRAMES).toBe(180)
const times = motionFrameTimes()
expect(times).toHaveLength(MOTION_FRAMES)
expect(times[0]).toBe(0)
expect(times[times.length - 1]).toBeCloseTo(((MOTION_FRAMES - 1) * 1000) / MOTION_FPS, 6)
})
it('staggers the pairs left to right and lands the values after the bars', () => {
const first = pairMotion(0)
const second = pairMotion(1)
expect(first.bar.delay).toBe(PAIR_MOTION.firstAt)
expect(second.bar.delay - first.bar.delay).toBeCloseTo(PAIR_MOTION.stagger, 6)
expect(first.label.delay).toBeCloseTo(first.bar.delay + PAIR_MOTION.labelOffset, 6)
expect(first.value.delay).toBeCloseTo(first.bar.delay + PAIR_MOTION.valueOffset, 6)
expect(first.value.delay).toBeGreaterThan(first.bar.delay + first.bar.duration)
})
it('grows the two-period columns left to right, values after the bars', () => {
const first = columnMotion(0)
const second = columnMotion(1)
expect(first.bar.delay).toBe(COLUMN_MOTION.firstAt)
expect(second.bar.delay - first.bar.delay).toBeCloseTo(COLUMN_MOTION.stagger, 6)
expect(second.label.delay).toBeCloseTo(second.bar.delay + COLUMN_MOTION.labelOffset, 6)
expect(second.value.delay).toBeGreaterThan(second.bar.delay + second.bar.duration)
})
it('maps each motion shape to its variant and refuses the undesigned ones', () => {
expect(motionKindFor(pairedSpec())).toBe('paired')
expect(motionKindFor(columnSpec())).toBe('column')
expect(motionKindFor(anchorSpec())).toBe('anchor')
expect(motionKindFor({ chartType: 'bar', rows: [] })).toBeNull()
expect(motionKindFor({ chartType: 'line', rows: [] })).toBeNull()
})
it('settles every stage inside the duration, leaving a static tail to cut into', () => {
const { end, kind } = validateMotion(pairedSpec())
expect(kind).toBe('paired')
expect(end).toBeLessThanOrEqual(MOTION_DURATION_S)
expect(MOTION_DURATION_S - end).toBeGreaterThanOrEqual(2)
expect(validateMotion(columnSpec())).toMatchObject({ kind: 'column' })
expect(validateMotion(anchorSpec())).toMatchObject({ kind: 'anchor' })
})
it('lists the stages of each shape for the clock guard', () => {
// Shell (rule, kicker, headline, subtitle, footer) + the marks.
expect(motionStages(columnSpec())).toHaveLength(5 + 2 * 3)
expect(motionStages(pairedSpec())).toHaveLength(5 + 1 + 4 * 3) // + legend
expect(motionStages(anchorSpec())).toHaveLength(5 + 2) // + number + copy
expect(motionEnd(motionStages(anchorSpec()))).toBeGreaterThan(0)
})
it('fails closed when the shape is undesigned, the column is not a pair or the clock overflows', () => {
expect(() => validateMotion({ chartType: 'bar', rows: [{ label: 'x', value: 1 }] })).toThrow(
/indisponível/,
)
expect(() => validateMotion(columnSpec(3))).toThrow(/2 períodos/)
expect(() => validateMotion(pairedSpec(20))).toThrow(/reduza os pontos/)
})
it('gives the anchor the hero number and the copy, never a counting rule', () => {
expect(MOTION_STAGES.number.kind).toBe('rise')
expect(MOTION_STAGES.copy.kind).toBe('fade')
expect(MOTION_STAGES.copy.delay).toBeGreaterThan(MOTION_STAGES.number.delay)
})
it('carries only keyframes and easing; timings travel as inline vars', () => {
for (const kind of ['sweep', 'rise', 'fade', 'grow']) {
expect(MOTION_CSS).toContain(`@keyframes motion-${kind}`)
expect(MOTION_CSS).toContain(`.motion .motion-${kind}`)
}
expect(MOTION_CSS).toContain(MOTION_EASING)
expect(MOTION_CSS).toContain('var(--md, 0s)')
expect(MOTION_CSS).toContain('var(--mud, 0.5s)')
expect(MOTION_CSS).toContain('animation-fill-mode: backwards')
})
it('uses the same motion vocabulary in the shell and the pairs', () => {
const kinds = new Set([
...Object.values(MOTION_STAGES).map((stage) => stage.kind),
...Object.values(pairMotion(3)).map((stage) => stage.kind),
...Object.values(columnMotion(1)).map((stage) => stage.kind),
])
for (const kind of kinds) expect(['sweep', 'rise', 'fade', 'grow']).toContain(kind)
})
})

View file

@ -171,6 +171,13 @@ describe('campaign formActions convention', () => {
'src/app/(campaign)/campanha/actions/password.ts',
'anti-enumeration + bespoke password field errors outside the shared ladder',
],
// C236 — the batch loop masks one failure per piece into the outcome's
// `failures[]` (no form state, no redirect): it is not a form-action
// ladder, and the mapping must stay the shared one, not a local copy.
[
'src/app/(campaign)/campanha/actions/contentPieces.ts',
'batch loop maps per-piece failures into the outcome, not a form-action state',
],
])
it('routes every hand-rolled mapCampaignFormActionError ladder through a shared wrapper', () => {

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
contentPieceBatchDeleteTitle,
contentPieceBatchReceiptMessage,
contentPieceSelectionLabel,
type ContentPieceBatchOutcome,
} from '@/lib/contentPieceBatch'
const outcome = (
action: ContentPieceBatchOutcome['action'],
affected: number,
failed = 0,
): ContentPieceBatchOutcome => ({
action,
affected,
failures: Array.from({ length: failed }, (_, index) => ({
contentPieceId: index + 1,
message: 'Peça não encontrada.',
})),
})
describe('contentPieceBatch (C236)', () => {
it('writes the all-success receipt in the plan literals, singular included', () => {
expect(contentPieceBatchReceiptMessage(outcome('publicar', 3))).toBe('3 peças publicadas')
expect(contentPieceBatchReceiptMessage(outcome('despublicar', 1))).toBe('1 peça despublicada')
expect(contentPieceBatchReceiptMessage(outcome('apagar', 3))).toBe('3 peças apagadas')
expect(contentPieceBatchReceiptMessage(outcome('apagar', 1))).toBe('1 peça apagada')
})
it('never calls a partial outcome a success: it separates affected from failures', () => {
expect(contentPieceBatchReceiptMessage(outcome('apagar', 2, 1))).toBe(
'2 de 3 peças apagadas. 1 falhou.',
)
expect(contentPieceBatchReceiptMessage(outcome('publicar', 1, 2))).toBe(
'1 de 3 peças publicadas. 2 falharam.',
)
expect(contentPieceBatchReceiptMessage(outcome('despublicar', 0, 1))).toBe(
'0 de 1 peça despublicada. 1 falhou.',
)
})
it('counts the selection with the TourComposerForm singular', () => {
expect(contentPieceSelectionLabel(1)).toBe('1 selecionada')
expect(contentPieceSelectionLabel(3)).toBe('3 selecionadas')
})
it('titles the mass delete and reuses the C222 singular', () => {
expect(contentPieceBatchDeleteTitle(1)).toBe('Apagar esta peça?')
expect(contentPieceBatchDeleteTitle(3)).toBe('Apagar 3 peças selecionadas?')
})
})

View file

@ -1,7 +1,8 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ContentPieceHomeBoard } from '@/components/conteudos/ContentPieceHomeBoard'
import type { ContentPieceHomeFacets } from '@/components/conteudos/ContentPieceHomeFilterRow'
import {
loadMunicipalityGeometryModule,
loadMunicipalityZoneGeometryModule,
@ -56,6 +57,7 @@ const item = (id: number, patch: Partial<ContentPieceHomeItem> = {}): ContentPie
origin: 'arquivo',
originLabel: 'Arquivo',
isLink: false,
sourceUrl: null,
cityLabel: null,
regionLabel: null,
excerpt: null,
@ -138,6 +140,19 @@ const localPiece = item(1, { cityLabel: 'Feira de Santana', regionLabel: 'Portal
const otherPiece = item(2, { cityLabel: 'Ilhéus', regionLabel: 'Litoral Sul' })
const items = [localPiece, otherPiece]
/** S42 — the server hands the explore row the catalogue vocabulary of the sample. */
const facets: ContentPieceHomeFacets = {
tipo: [
{ value: 'video', label: 'Vídeo' },
{ value: 'foto', label: 'Foto' },
],
cidade: [{ value: 'feira-de-santana', label: 'Feira de Santana' }],
regiao: [{ value: 'portal-do-sertao', label: 'Portal do Sertão' }],
}
const renderBoard = (boardItems: ContentPieceHomeItem[] = items, boardFacets = facets) =>
render(<ContentPieceHomeBoard items={boardItems} facets={boardFacets} />)
const locateButton = () => screen.queryByRole('button', { name: /Usar minha localização/ })
/** The section tag is split by the decorative glyph, so the hook is the attribute. */
@ -158,7 +173,7 @@ describe('ContentPieceHomeBoard', () => {
it('renders the recent selection and the affordance while permission is undecided', async () => {
readPermission.mockResolvedValue('prompt')
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
await waitFor(() => expect(locateButton()).not.toBeNull())
expect(screen.getByText('Seleção recente')).toBeDefined()
@ -175,7 +190,7 @@ describe('ContentPieceHomeBoard', () => {
it('hides the affordance when the browser already refused the permission', async () => {
readPermission.mockResolvedValue('denied')
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
await waitFor(() => expect(readPermission).toHaveBeenCalled())
expect(locateButton()).toBeNull()
@ -185,7 +200,7 @@ describe('ContentPieceHomeBoard', () => {
it('upgrades to the visitor territory without a dialog when permission is granted', async () => {
readPermission.mockResolvedValue('granted')
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
await waitFor(() => expect(sampleTag()).toBe('municipality'))
expect(screen.getByText('Do seu município')).toBeDefined()
@ -196,7 +211,7 @@ describe('ContentPieceHomeBoard', () => {
it('upgrades to the visitor territory when the affordance is tapped', async () => {
readPermission.mockResolvedValue('prompt')
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
await waitFor(() => expect(locateButton()).not.toBeNull())
fireEvent.click(locateButton()!)
@ -208,7 +223,7 @@ describe('ContentPieceHomeBoard', () => {
it('offers the affordance when the browser cannot tell the permission (Safari)', async () => {
readPermission.mockResolvedValue('unknown')
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
await waitFor(() => expect(locateButton()).not.toBeNull())
expect(readPosition).not.toHaveBeenCalled()
@ -228,7 +243,7 @@ describe('ContentPieceHomeBoard', () => {
},
})
render(<ContentPieceHomeBoard items={[video]} />)
renderBoard([video])
await waitFor(() => expect(readPermission).toHaveBeenCalled())
expect(document.querySelector('video')).toBeNull()
@ -245,7 +260,7 @@ describe('ContentPieceHomeBoard', () => {
readPermission.mockResolvedValue('granted')
readPosition.mockResolvedValue({ ok: false, reason: 'timeout' })
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
await waitFor(() => expect(readPosition).toHaveBeenCalledTimes(1))
expect(screen.getByText('Seleção recente')).toBeDefined()
@ -257,7 +272,7 @@ describe('ContentPieceHomeBoard', () => {
readPermission.mockResolvedValue('granted')
readPosition.mockResolvedValue({ ok: true, fix: { ...feiraPoint, accuracyM: 50_000 } })
render(<ContentPieceHomeBoard items={items} />)
renderBoard()
// The coarse fix keeps the region sample, never the município claim.
await waitFor(() => expect(screen.getByText('Da sua região')).toBeDefined())
@ -269,7 +284,7 @@ describe('ContentPieceHomeBoard', () => {
it('does not offer the affordance for a single published piece', async () => {
readPermission.mockResolvedValue('prompt')
render(<ContentPieceHomeBoard items={[localPiece]} />)
renderBoard([localPiece])
await waitFor(() => expect(readPermission).toHaveBeenCalled())
expect(locateButton()).toBeNull()
@ -280,4 +295,66 @@ describe('ContentPieceHomeBoard', () => {
expect(screen.queryByText(/Nenhum card personalizável/)).toBeNull()
expect(screen.queryByText(/A localização não é guardada/)).toBeNull()
})
it('opens the S27 share sheet from the card without leaving the home (S42)', async () => {
readPermission.mockResolvedValue('denied')
renderBoard()
await waitFor(() => expect(readPermission).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Compartilhar Peça 1' }))
const sheet = screen.getByRole('dialog', { name: 'Compartilhar peça' })
expect(sheet).toBeDefined()
const message = within(sheet).getByLabelText(
'Mensagem para compartilhar',
) as HTMLTextAreaElement
expect(message.value).toContain('Peça 1')
// The section stays mounted behind the sheet — the visitor never leaves the home.
expect(screen.getByText('Seleção recente')).toBeDefined()
})
it('links the honest tags to the catalogue and keeps "Mais recente" static (S42)', async () => {
readPermission.mockResolvedValue('granted')
renderBoard()
await waitFor(() => expect(sampleTag()).toBe('municipality'))
const localCard = document.querySelector('article[data-content-piece="peca-1"]') as HTMLElement
const otherCard = document.querySelector('article[data-content-piece="peca-2"]') as HTMLElement
expect(within(localCard).getByRole('link', { name: 'Foto' }).getAttribute('href')).toBe(
'/conteudos?tipo=foto',
)
expect(
within(localCard).getByRole('link', { name: 'Do seu município' }).getAttribute('href'),
).toBe('/conteudos?cidade=feira-de-santana')
expect(within(otherCard).queryByRole('link', { name: 'Mais recente' })).toBeNull()
// The section tag is the shortcut of the visitor's município.
expect(screen.getByRole('link', { name: /Para seu município/ }).getAttribute('href')).toBe(
'/conteudos?cidade=feira-de-santana',
)
})
it('renders the explore row with canonical facet links and never filters the sample (S42)', async () => {
readPermission.mockResolvedValue('denied')
renderBoard()
await waitFor(() => expect(readPermission).toHaveBeenCalled())
// Both breakpoint captions render (one hidden by CSS); the row itself has
// one chip per non-empty facet.
expect(screen.getAllByText(/Explore na Central/)).toHaveLength(2)
// The board hands the server facets to the row; every option href is the
// row's own contract (pinned in `contentPieceHomeFilterRow.unit.spec.tsx`).
const tipoDetails = screen.getByText('Tipo').closest('details') as HTMLElement
fireEvent.click(within(tipoDetails).getByText('Tipo'))
expect(within(tipoDetails).getByRole('link', { name: 'Vídeo' }).getAttribute('href')).toBe(
'/conteudos?tipo=video',
)
// The row is pure navigation: the sample stays intact.
expect(document.querySelectorAll('article[data-content-piece]')).toHaveLength(2)
})
})

View file

@ -0,0 +1,54 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { ContentPieceHomeFilterRow } from '@/components/conteudos/ContentPieceHomeFilterRow'
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
<a href={href} {...props}>
{children}
</a>
),
}))
const facets = {
tipo: [
{ value: 'video', label: 'Vídeo' },
{ value: 'foto', label: 'Foto' },
],
cidade: [{ value: 'salvador', label: 'Salvador' }],
regiao: [],
}
describe('ContentPieceHomeFilterRow', () => {
it('renders only the facets with options and links each option to the canonical filtered URL', () => {
render(<ContentPieceHomeFilterRow facets={facets} />)
expect(screen.getByText('Tipo')).toBeDefined()
expect(screen.getByText('Cidade')).toBeDefined()
// A facet no published piece carries never renders — no empty menu.
expect(screen.queryByText('Região')).toBeNull()
const tipo = screen.getByText('Tipo').closest('details') as HTMLElement
fireEvent.click(within(tipo).getByText('Tipo'))
expect(within(tipo).getByRole('link', { name: 'Vídeo' }).getAttribute('href')).toBe(
'/conteudos?tipo=video',
)
const cidade = screen.getByText('Cidade').closest('details') as HTMLElement
fireEvent.click(within(cidade).getByText('Cidade'))
expect(
within(cidade)
.getByRole('link', { name: /Salvador/ })
.getAttribute('href'),
).toBe('/conteudos?cidade=salvador')
})
it('renders nothing without any option', () => {
const { container } = render(
<ContentPieceHomeFilterRow facets={{ tipo: [], cidade: [], regiao: [] }} />,
)
expect(container.firstChild).toBeNull()
})
})

View file

@ -98,6 +98,7 @@ describe('content piece home selection', () => {
'publicPath',
'regionLabel',
'slug',
'sourceUrl',
'title',
'type',
'typeLabel',
@ -139,6 +140,9 @@ describe('content piece home selection', () => {
expect(item.isLink).toBe(true)
expect(item.file).toBeNull()
// S42 — the share sheet reads it from the lean item, so the projection
// must carry the platform URL of a link piece.
expect(item.sourceUrl).toBe('https://www.instagram.com/p/abc/')
})
})

View file

@ -0,0 +1,270 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { routerMock } = vi.hoisted(() => ({
routerMock: { push: vi.fn(), refresh: vi.fn() },
}))
vi.mock('next/navigation', () => ({ useRouter: () => routerMock }))
import { ContentPieceBatchReceipt } from '@/components/campaign/content/ContentPieceBatchReceipt'
import { ContentPieceSelectionBar } from '@/components/campaign/content/ContentPieceSelectionBar'
import {
ContentPieceCardSelectionFrame,
ContentPieceRowCheckbox,
ContentPieceSelectAllCheckbox,
ContentPieceSelectionModeControl,
} from '@/components/campaign/content/ContentPieceSelectionControls'
import { ContentPieceSelectionProvider } from '@/components/campaign/content/ContentPieceSelectionProvider'
import { CAMPAIGN_CONTENT_PIECE_BATCH_HREF } from '@/lib/campaignPaths'
import type { ContentPieceSelectionRow } from '@/lib/contentPieceBatch'
const harnessRows = [
{ id: 1, title: 'Peça publicada', status: 'publicado', publicPath: '/conteudos/peca-publicada' },
{ id: 2, title: 'Peça em rascunho', status: 'rascunho', publicPath: null },
{ id: 3, title: 'Peça com falha', status: 'rascunho', publicPath: null },
] as const
const rows: ContentPieceSelectionRow[] = harnessRows.map(({ id, status, publicPath }) => ({
id,
status,
publicPath,
}))
const fetchMock = vi.fn()
const jsonResponse = (payload: unknown, ok = true) => ({ ok, json: async () => payload })
const renderHarness = () => {
render(
<ContentPieceSelectionProvider rows={rows}>
<ContentPieceSelectionModeControl />
<ContentPieceBatchReceipt />
<ContentPieceSelectAllCheckbox />
{harnessRows.map((row) => (
<ContentPieceRowCheckbox key={row.id} contentPieceId={row.id} title={row.title} />
))}
<ContentPieceSelectionBar />
</ContentPieceSelectionProvider>,
)
}
const enterSelection = () => fireEvent.click(screen.getByRole('button', { name: 'Selecionar' }))
const check = (name: string) => fireEvent.click(screen.getByRole('checkbox', { name }))
const press = (name: string) => fireEvent.click(screen.getByRole('button', { name }))
const openDeleteDialog = async () => {
press('Apagar')
return screen.findByRole('alertdialog')
}
const selectionStatus = () => screen.getByRole('status').textContent
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
vi.stubGlobal('fetch', fetchMock)
routerMock.push.mockReset()
routerMock.refresh.mockReset()
fetchMock.mockReset()
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('ContentPieceSelection (C236)', () => {
it('enters selection mode opt-in, counts the selection and opens the bar only with one', () => {
renderHarness()
expect(screen.queryByText('Modo de seleção')).toBeNull()
enterSelection()
expect(screen.getByText('Modo de seleção')).toBeDefined()
expect(screen.queryByRole('status')).toBeNull()
check('Selecionar Peça publicada')
expect(selectionStatus()).toContain('1 selecionada')
check('Selecionar Peça em rascunho')
expect(selectionStatus()).toContain('2 selecionadas')
})
it('selects and clears the whole visible page from the header checkbox', () => {
renderHarness()
enterSelection()
fireEvent.click(
screen.getByRole('checkbox', { name: 'Selecionar todas as peças desta página' }),
)
expect(selectionStatus()).toContain('3 selecionadas')
fireEvent.click(
screen.getByRole('checkbox', { name: 'Selecionar todas as peças desta página' }),
)
expect(screen.queryByRole('status')).toBeNull()
})
it('publishes the selection in one request, shows the receipt and clears the selection', async () => {
fetchMock.mockResolvedValue(
jsonResponse({
status: 'success',
outcome: { action: 'publicar', affected: 2, failures: [] },
}),
)
renderHarness()
enterSelection()
check('Selecionar Peça em rascunho')
check('Selecionar Peça com falha')
press('Publicar')
expect(await screen.findByText('2 peças publicadas')).toBeDefined()
expect(fetchMock).toHaveBeenCalledWith(
CAMPAIGN_CONTENT_PIECE_BATCH_HREF,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ action: 'publicar', contentPieceIds: [2, 3] }),
}),
)
await waitFor(() => expect(routerMock.refresh).toHaveBeenCalled())
expect(screen.queryByText('2 selecionadas')).toBeNull()
expect(screen.getByText('Modo de seleção')).toBeDefined()
})
it('keeps the selection and answers in the list when the whole request fails', async () => {
fetchMock.mockResolvedValue({
ok: false,
json: async () => ({ status: 'error', message: 'Sessão expirada. Entre novamente.' }),
})
renderHarness()
enterSelection()
check('Selecionar Peça publicada')
press('Publicar')
expect(await screen.findByRole('alert')).toHaveProperty(
'textContent',
expect.stringContaining('Sessão expirada. Entre novamente.'),
)
expect(selectionStatus()).toContain('1 selecionada')
expect(routerMock.refresh).not.toHaveBeenCalled()
})
it('warns about the public links only when the selection carries published pieces', async () => {
renderHarness()
enterSelection()
check('Selecionar Peça publicada')
let dialog = await openDeleteDialog()
expect(within(dialog).getByText('Apagar esta peça?')).toBeDefined()
expect(within(dialog).getByText('/conteudos/peca-publicada')).toBeDefined()
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancelar' }))
check('Selecionar Peça em rascunho')
dialog = await openDeleteDialog()
expect(within(dialog).getByText('Apagar 2 peças selecionadas?')).toBeDefined()
expect(within(dialog).getByText(/deixam de funcionar para quem já recebeu/)).toBeDefined()
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancelar' }))
check('Selecionar Peça publicada')
check('Selecionar Peça com falha')
dialog = await openDeleteDialog()
expect(within(dialog).getByText('Apagar 2 peças selecionadas?')).toBeDefined()
expect(within(dialog).getByText('Esta ação não pode ser desfeita.')).toBeDefined()
})
it('deletes the selection after the confirmation and names the partial failure', async () => {
fetchMock.mockResolvedValue(
jsonResponse({
status: 'success',
outcome: {
action: 'apagar',
affected: 2,
failures: [{ contentPieceId: 3, message: 'Peça não encontrada.' }],
},
}),
)
renderHarness()
enterSelection()
check('Selecionar Peça em rascunho')
check('Selecionar Peça com falha')
const dialog = await openDeleteDialog()
fireEvent.click(within(dialog).getByRole('button', { name: 'Apagar' }))
expect(await screen.findByRole('alert')).toHaveProperty(
'textContent',
expect.stringContaining('2 de 3 peças apagadas. 1 falhou.'),
)
expect(fetchMock).toHaveBeenCalledWith(
CAMPAIGN_CONTENT_PIECE_BATCH_HREF,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ action: 'apagar', contentPieceIds: [2, 3] }),
}),
)
await waitFor(() => expect(routerMock.refresh).toHaveBeenCalled())
})
it('keeps the delete failure inside the dialog, with the selection intact', async () => {
fetchMock.mockResolvedValue(
jsonResponse({ status: 'error', message: 'Peça não encontrada.' }, false),
)
renderHarness()
enterSelection()
check('Selecionar Peça publicada')
check('Selecionar Peça em rascunho')
const dialog = await openDeleteDialog()
fireEvent.click(within(dialog).getByRole('button', { name: 'Apagar' }))
expect(await within(dialog).findByText('Peça não encontrada.')).toBeDefined()
expect(screen.queryByText('2 de 3 peças apagadas. 1 falhou.')).toBeNull()
expect(within(dialog).getByText('Apagar 2 peças selecionadas?')).toBeDefined()
expect(routerMock.refresh).not.toHaveBeenCalled()
})
it('leaves the selection mode from the top control', () => {
renderHarness()
enterSelection()
check('Selecionar Peça publicada')
fireEvent.click(screen.getByRole('button', { name: 'Cancelar seleção' }))
expect(screen.queryByText('Modo de seleção')).toBeNull()
expect(screen.queryByRole('status')).toBeNull()
expect(screen.getByRole('button', { name: 'Selecionar' })).toBeDefined()
})
it('selects from the mobile card frame with the one shared state', () => {
render(
<ContentPieceSelectionProvider rows={rows}>
<ContentPieceSelectionModeControl />
<ContentPieceCardSelectionFrame
contentPieceId={1}
title="Peça publicada"
header={<p>Peça publicada</p>}
>
<p>Corpo do card</p>
</ContentPieceCardSelectionFrame>
<ContentPieceSelectionBar />
</ContentPieceSelectionProvider>,
)
// At rest the frame is the exact card, with no checkbox to scan past.
expect(screen.queryByRole('checkbox')).toBeNull()
expect(screen.getByText('Corpo do card')).toBeDefined()
enterSelection()
check('Selecionar Peça publicada')
expect(selectionStatus()).toContain('1 selecionada')
})
})

View file

@ -64,6 +64,25 @@ describe('skill /graficos-dados documents the flow', () => {
expect(skill).toContain('192×110')
expect(skill).toContain('279×160')
})
it('pins the paired columns of the C237 degraded revision', () => {
expect(skill).toContain('--paired')
expect(skill).toMatch(/colunas emparelhadas/i)
expect(skill).toMatch(/DEGRADED/)
expect(skill).toContain('4 categorias')
})
it('pins the C238/C239 motion of the paired columns, the column and the anchor', () => {
expect(skill).toContain('--motion')
expect(skill).toMatch(/MP4/)
expect(skill).toMatch(/6s @ 30fps/)
expect(skill).toMatch(/frames determin/)
expect(skill).toContain('C237')
expect(skill).toContain('C238')
expect(skill).toContain('C239')
expect(skill).toMatch(/2 períodos/)
expect(skill).toMatch(/número-âncora/)
})
})
describe('command /graficos-dados couples to the skill', () => {

View file

@ -339,6 +339,143 @@ describe('renderChartHtml — the variation bar (delta)', () => {
})
})
// C237 degraded variant: the same delta dataset drawn as paired columns — the
// initial in the neutral tone, the recent one in the official red (the positive
// datum is shared by every pair), with the period legend and the value over
// each bar. An initial of zero has no gray column at all.
const pairedRows = [
{ label: 'Cirurgia geral', initial: 592, final: 1433 },
{ label: 'Ortopedia', initial: 182, final: 755 },
{ label: 'Oncologia', initial: 0, final: 134 },
]
const pairedSpec = (overrides = {}) => ({
chartType: 'delta',
size: 'feed',
pairedColumns: true,
headline: 'Ortopedia cresce 4,1 vezes, neurocirurgia quadruplica e oncologia sai do zero',
subtitle: 'Leitos por tipo na rede estadual · cirurgia: 966 → 2.750',
source: 'DATASUS / Ministério da Saúde',
startLabel: '2006',
endLabel: '2026',
rows: pairedRows,
...overrides,
})
describe('renderChartHtml — paired columns (C237)', () => {
it('renders the period legend, the pair labels and the recent red column', () => {
const html = render(pairedSpec())
expect(html).toContain('Variação no período')
expect(html).toContain('paired-swatch initial')
expect(html).toContain('paired-swatch final')
expect(html).toContain('>2006</span>')
expect(html).toContain('>2026</span>')
expect(html).toContain('paired-bar recent')
expect(html).toContain('class="paired-label">Cirurgia geral')
})
it('proportions both bars of a pair against the biggest final value', () => {
const html = render(pairedSpec())
expect(html).toContain('height:41.3%') // 592 / 1433
expect(html).toContain('height:52.7%') // 755 / 1433
expect(html).toContain('height:100%') // 1433
expect(html).toContain('height:9.4%') // 134 / 1433
})
it('leaves the zero initial without the gray column and keeps the value', () => {
const html = render(pairedSpec())
const labelAt = html.indexOf('paired-label">Oncologia')
const start = html.lastIndexOf('class="paired-pair"', labelAt)
const pair = html.slice(start, labelAt)
expect(pair).toContain('paired-value">0')
expect(pair).not.toContain('<span class="paired-bar" style')
expect(pair).toContain('paired-bar recent')
})
it('sorts the pairs by the final value', () => {
const html = render(pairedSpec())
const body = html.slice(html.indexOf('<div class="paired-grade'))
expect(body.indexOf('Cirurgia geral')).toBeLessThan(body.indexOf('Ortopedia'))
expect(body.indexOf('Ortopedia')).toBeLessThan(body.indexOf('Oncologia'))
})
it('adapts the rhythm and keeps every pair on the Stories canvas', () => {
const story = render(pairedSpec({ size: 'story' }))
expect(story).toContain('padding: 306px 84px 310px')
expect(story).toMatch(/\.headline \{ font-size: 60px/)
expect((story.match(/class="paired-pair"/g) ?? []).length).toBe(pairedRows.length)
})
})
// C238 prototype: under `spec.motion` the same paired piece carries the motion
// stylesheet and the schedule as inline `--md`/`--mud` vars; without it the
// markup stays byte-identical to the approved still.
describe('renderChartHtml — motion (C238)', () => {
it('injects the motion stylesheet and the animated classes only under spec.motion', () => {
const still = render(pairedSpec())
expect(still).not.toContain('motion-')
expect(still).not.toContain('--md:')
const html = render(pairedSpec({ motion: true }))
expect(html).toContain('class="canvas motion"')
expect(html).toContain('@keyframes motion-grow')
expect(html).toContain('.motion .motion-sweep')
expect(html).toContain('animation-fill-mode: backwards')
})
it('serializes the pair schedule into inline --md/--mud vars', () => {
const html = render(pairedSpec({ motion: true }))
expect(html).toContain('--md:1.1s;--mud:0.75s') // first pair bars
expect(html).toContain('--md:1.4s') // second pair
expect(html).toContain('--md:1.7s') // third pair
expect(html).toContain('--md:1.9s;--mud:0.3s') // first pair values, after the bar
expect(html).toContain('--md:1.25s;--mud:0.3s') // first pair label, with the growth
})
it('keeps the animated markup in the paired body of every size', () => {
const story = render(pairedSpec({ size: 'story', motion: true }))
expect((story.match(/class="paired-bar(?!-)/g) ?? []).length).toBe(5) // 3 pairs × 2 bars − the zero gray
expect((story.match(/ motion-grow"/g) ?? []).length).toBe(5)
expect(story).toContain('@keyframes motion-rise')
})
it('grows the two-period columns left to right and lands their values (C239)', () => {
const columnSpec = {
chartType: 'column',
size: 'feed',
motion: true,
headline: 'UTIs na rede estadual crescem quase 9 vezes desde 2005',
subtitle: 'Leitos de UTI nos hospitais estaduais · Bahia',
source: 'DATASUS / Ministério da Saúde',
rows: [
{ label: '2005', value: 179 },
{ label: '2026', value: 1536 },
],
}
const html = render(columnSpec)
expect(html).toContain('--md:1.1s;--mud:0.75s') // first column
expect(html).toContain('--md:1.45s;--mud:0.75s') // recent column, red
expect(html).toContain('--md:2.25s;--mud:0.3s') // recent value, after the bar
expect(html).toContain('column highlight motion-grow')
})
it('rises the anchor number and fades the copy, never counting (C239)', () => {
const anchorSpec = {
chartType: 'anchor',
size: 'feed',
motion: true,
headline: 'Bahia contrata mais de mil leitos privados para o SUS',
subtitle: 'Rede complementar do SUS · Bahia · 2026',
source: 'DATASUS / Ministério da Saúde',
rows: [{ label: 'leitos privados contratados para atendimento pelo SUS', value: 1130 }],
}
const html = render(anchorSpec)
expect(html).toContain('anchor-number motion-rise')
expect(html).toContain('--md:1.05s;--mud:0.6s')
expect(html).toContain('anchor-copy motion-fade')
expect(html).toContain('--md:1.7s;--mud:0.45s')
})
})
// C205 approved extension: the three-series observed line runs on the feed
// canvas only, with the triad good/neutral/neutral-dark (red circle "↑ amplia",
// gray diamond "↗ cresce", ink triangle "↘ diminui") and no projection.

View file

@ -14,7 +14,7 @@ const run = (args: string[], env: Record<string, string> = {}) =>
spawnSync(process.execPath, [scriptPath, ...args], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 30_000,
timeout: 60_000,
env: {
...process.env,
NODE_OPTIONS: '--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs',

View file

@ -9,10 +9,15 @@ import {
import {
formatInstagramContentReport,
formatInstagramContentWithdrawReport,
instagramContentFeedIdentityUrls,
instagramContentPostDates,
instagramContentReportStamp,
parseInstagramContentCliArgs,
parseInstagramContentWithdrawCliArgs,
planInstagramContentDraftsToPublish,
planInstagramContentWindow,
planInstagramContentWithdraw,
shouldPublishImportedPiece,
summarizeInstagramContentResults,
} from '../../scripts/lib/instagramContentPlan.mjs'
@ -48,6 +53,7 @@ describe('content:instagram:import arguments', () => {
expect(parseInstagramContentCliArgs([])).toEqual({
apply: false,
publish: false,
publishExisting: false,
days: 60,
limit: null,
out: 'data/content-instagram',
@ -55,6 +61,14 @@ describe('content:instagram:import arguments', () => {
})
})
it('accepts --publish-existing only with --apply', () => {
expect(parseInstagramContentCliArgs(['--apply', '--publish-existing'])).toMatchObject({
apply: true,
publishExisting: true,
})
expect(() => parseInstagramContentCliArgs(['--publish-existing'])).toThrow(/exige --apply/)
})
it('accepts the apply/publish pair and the window options', () => {
expect(
parseInstagramContentCliArgs([
@ -99,6 +113,148 @@ describe('planner re-exports (C235)', () => {
})
})
describe('window draft publication (C230-followup)', () => {
it('dates the feed by shortcode, keeping the earliest publication', () => {
const dates = instagramContentPostDates([
post('A', { timestamp: '2026-09-02T12:00:00.000Z' }),
post('A', {
timestamp: '2026-09-01T12:00:00.000Z',
permalink: 'https://www.instagram.com/p/A/',
}),
post('X', { permalink: 'https://example.com/x/' }),
])
expect(dates.get('A')).toBe(Date.parse('2026-09-01T12:00:00.000Z'))
expect(dates.size).toBe(1)
})
it('orders the ready drafts oldest post first and names the undateable', () => {
const planned = planInstagramContentDraftsToPublish({
pieces: [
{ id: 1, title: 'A', sourceUrl: 'https://www.instagram.com/reel/A/' },
{ id: 2, title: 'B', sourceUrl: 'https://www.instagram.com/p/B/' },
{
id: 3,
title: 'D',
sourceUrl: 'https://www.instagram.com/reel/D/',
linkFailureReason: 'carrossel',
},
{ id: 4, title: 'C', sourceUrl: 'https://www.instagram.com/reel/NAO-ESTA/' },
],
posts: [
post('A', { timestamp: '2026-09-01T12:00:00.000Z' }),
post('B', { timestamp: '2026-08-20T12:00:00.000Z' }),
post('D', { timestamp: '2026-08-25T12:00:00.000Z' }),
],
})
expect(planned.ordered.map((piece) => piece.id)).toEqual([2, 3, 1])
expect(planned.ordered.map((piece) => piece.shortcode)).toEqual(['B', 'D', 'A'])
expect(planned.undateable.map((piece) => piece.id)).toEqual([4])
expect(planned.linkOnly).toBe(1)
})
})
describe('pre-campaign withdrawal (C230-followup)', () => {
const publishedPosts = [
post('KEEP', { timestamp: '2026-08-16T00:00:00-03:00' }),
post('DROP-LATE', { timestamp: '2026-08-15T23:59:59-03:00' }),
post('DROP-EARLY', { timestamp: '2026-07-31T09:00:00-03:00' }),
]
it('splits by the Bahia civil cutoff, oldest withdrawn first', () => {
const plan = planInstagramContentWithdraw({
pieces: [
{ id: 1, title: 'KEEP', sourceUrl: 'https://www.instagram.com/reel/KEEP/' },
{ id: 2, title: 'DROP-LATE', sourceUrl: 'https://www.instagram.com/p/DROP-LATE/' },
{ id: 3, title: 'DROP-EARLY', sourceUrl: 'https://www.instagram.com/reel/DROP-EARLY/' },
{ id: 4, title: 'SEM-DATA', sourceUrl: 'https://www.instagram.com/reel/FORA-DO-FEED/' },
{ id: 5, title: 'SEM-URL', sourceUrl: null },
],
posts: publishedPosts,
before: '2026-08-16',
})
// 16/08 00:00 na Bahia = 03:00Z: o que veio antes sai, inclusive a noite do dia 15.
expect(plan.cutoffIso).toBe('2026-08-16T03:00:00.000Z')
expect(plan.toWithdraw.map((piece) => piece.id)).toEqual([3, 2])
expect(plan.toWithdraw.map((piece) => piece.postDate.slice(0, 10))).toEqual([
'2026-07-31',
'2026-08-16',
])
expect(plan.undateable.map((piece) => piece.id)).toEqual([4, 5])
})
it('refuses an impossible civil date', () => {
expect(() =>
planInstagramContentWithdraw({ pieces: [], posts: [], before: '2026-02-30' }),
).toThrow(/data inicial válida/)
})
it('parses the withdraw arguments with a required cutoff', () => {
expect(parseInstagramContentWithdrawCliArgs(['--before', '2026-08-16'])).toEqual({
apply: false,
before: '2026-08-16',
scanDays: 90,
out: 'data/content-instagram',
help: false,
})
expect(
parseInstagramContentWithdrawCliArgs([
'--before',
'2026-08-16',
'--apply',
'--scan-days',
'60',
]),
).toMatchObject({ apply: true, before: '2026-08-16', scanDays: 60 })
expect(() => parseInstagramContentWithdrawCliArgs([])).toThrow(/--before é obrigatório/)
expect(() => parseInstagramContentWithdrawCliArgs(['--before', '16/08/2026'])).toThrow(
/YYYY-MM-DD/,
)
expect(() => parseInstagramContentWithdrawCliArgs(['--before', '2026-02-30'])).toThrow(
/data civil válida/,
)
expect(() => parseInstagramContentWithdrawCliArgs(['--force'])).toThrow(
/argumento desconhecido/,
)
expect(parseInstagramContentWithdrawCliArgs(['--help'])).toMatchObject({ help: true })
})
it('reports the cutoff, the period and the undateable pieces', () => {
const plan = planInstagramContentWithdraw({
pieces: [
{ id: 3, title: 'DROP-EARLY', sourceUrl: 'https://www.instagram.com/reel/DROP-EARLY/' },
{ id: 4, title: 'SEM-DATA', sourceUrl: null },
],
posts: publishedPosts,
before: '2026-08-16',
})
const lines = formatInstagramContentWithdrawReport({
target: '127.0.0.1/teqo_1313',
mode: 'apply',
before: '2026-08-16',
scanDays: 90,
feedCount: 3,
publishedCount: 2,
plan,
withdrawn: 1,
failures: [],
revalidation: { attempted: true, ok: true, reason: null },
reportPath: 'data/content-instagram/x-withdraw.json',
})
const text = lines.join('\n')
expect(text).toContain('alvo: 127.0.0.1/teqo_1313 | modo: apply')
expect(text).toContain('corte: 2026-08-16 (Bahia; posts anteriores)')
expect(text).toContain('anteriores ao corte: 1 · sem data no feed: 1')
expect(text).toContain('DROP-EARLY (2026-07-31)')
expect(text).toContain('? sem data: #4 SEM-DATA')
expect(text).toContain('retiradas: 1 · falhas: 0 · revalidação: ok')
expect(text).toContain('recibo: data/content-instagram/x-withdraw.json')
})
})
describe('summaries and receipt', () => {
it('publishes only a ready piece when the run asked for it', () => {
expect(shouldPublishImportedPiece({ processingStatus: 'pronto', publish: true })).toBe(true)
@ -158,6 +314,7 @@ describe('summaries and receipt', () => {
summary: summarizeInstagramContentResults([
{ outcome: 'created', processingStatus: 'falhou', error: 'ffmpeg ausente' },
]),
publishedExisting: 3,
failures: [{ shortcode: 'A', error: 'ffmpeg ausente' }],
candidatesRemaining: 1,
reportPath: 'data/content-instagram/x.json',
@ -169,6 +326,7 @@ describe('summaries and receipt', () => {
expect(text).toContain('candidatos: 2 (peça-link previstos: 1)')
expect(text).toContain('falha A: ffmpeg ausente')
expect(text).toContain('peça-link B: carrossel')
expect(text).toContain('publicadas da janela (já importadas, rascunhos prontos): 3')
expect(text).toContain('restam 1 candidatos')
expect(text).toContain('recibo: data/content-instagram/x.json')
})

View file

@ -0,0 +1,90 @@
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
// C230-followup — the withdraw CLI's fail-closed write guards, exercised as a
// subprocess: each refusal must happen before any Payload import, feed call or
// DB connection.
const repoRoot = process.cwd()
const scriptPath = join(repoRoot, 'scripts', 'withdraw-instagram-content.mjs')
const run = (args: string[], env: Record<string, string> = {}) =>
spawnSync(process.execPath, [scriptPath, ...args], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 60_000,
env: {
...process.env,
NODE_OPTIONS: '--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs',
NODE_ENV: 'production',
VITEST: '',
DATABASE_URL: 'postgresql://teqo:teqo@127.0.0.1:5432/teqo_1313',
PAYLOAD_SECRET: 'test-secret',
CONTENT_INSTAGRAM_WITHDRAW_CONFIRM: '',
TEQO_ENV: '',
ALLOW_REMOTE_DB: '',
...env,
},
})
const output = (result: ReturnType<typeof run>) => `${result.stdout}${result.stderr}`
describe('content:instagram:withdraw write guards (C230-followup)', () => {
it('prints the help even without a database', () => {
const result = run(['--help'], { DATABASE_URL: '' })
expect(result.status).toBe(0)
expect(result.stdout).toContain('CONTENT_INSTAGRAM_WITHDRAW_CONFIRM')
expect(result.stdout).toContain('pnpm content:instagram:withdraw')
})
it('requires --before before touching anything', () => {
const result = run(['--apply'])
expect(result.status).toBe(1)
expect(output(result)).toContain('--before é obrigatório')
})
it('refuses an invalid cutoff before touching anything', () => {
const result = run(['--before', '16/08/2026'])
expect(result.status).toBe(1)
expect(output(result)).toContain('YYYY-MM-DD')
})
it('refuses --apply on a production target without the intent flag, before any DB import', () => {
const result = run(['--before', '2026-08-16', '--apply'])
expect(result.status).toBe(1)
expect(output(result)).toContain('CONTENT_INSTAGRAM_WITHDRAW_CONFIRM=1')
expect(output(result)).not.toContain('| modo:')
})
it('requires the declared TEQO_ENV once the intent flag is set', () => {
const result = run(['--before', '2026-08-16', '--apply'], {
CONTENT_INSTAGRAM_WITHDRAW_CONFIRM: '1',
})
expect(result.status).toBe(1)
expect(output(result)).toContain('TEQO_ENV')
})
it('refuses a target database that does not match the declared environment', () => {
const result = run(['--before', '2026-08-16', '--apply'], {
CONTENT_INSTAGRAM_WITHDRAW_CONFIRM: '1',
TEQO_ENV: 'staging',
})
expect(result.status).toBe(1)
expect(output(result)).toContain('≠ "teqo_staging"')
})
it('refuses an unknown argument before touching anything', () => {
const result = run(['--force'])
expect(result.status).toBe(1)
expect(output(result)).toContain('argumento desconhecido')
})
})

View file

@ -112,6 +112,27 @@ describe('formatSpeechIndexReport', () => {
expect(lines[2]).toContain('tokens: 123456')
expect(lines[2]).toContain('custo estimado: US$ 0.0012')
})
it('reports the metadata inputs on top of the stored units (C237)', () => {
const dryRun = formatSpeechIndexReport({ ...base, mode: 'dry-run', metadataTexts: 42 })
expect(dryRun[2]).toContain('trechos a vetorizar: 300 (+42 metadados)')
const write = formatSpeechIndexReport({
...base,
mode: 'index',
metadataTexts: 42,
indexed: 15,
failed: 0,
promptTokens: 10,
costUsd: 0,
})
expect(write[2]).toContain('trechos vetorizados: 300 (+42 metadados)')
// No metadata (speech without summary/title/keywords) keeps the old shape.
expect(formatSpeechIndexReport({ ...base, mode: 'dry-run' })[2]).toBe(
'trechos a vetorizar: 300',
)
})
})
describe('formatSpeechIndexProbe', () => {

View file

@ -3,6 +3,7 @@
import { describe, expect, it } from 'vitest'
import {
composeSpeechVector,
cosineSimilarity,
dotProduct,
meanPoolVectors,
@ -11,8 +12,10 @@ import {
planSpeechIndex,
rankSemanticHits,
sortSemanticHits,
SPEECH_METADATA_VECTOR_WEIGHT,
SPEECH_SEMANTIC_MIN_COSINE,
speechEmbeddingHash,
speechMetadataText,
speechSemanticSourceText,
speechTextWindows,
type SpeechSemanticCandidate,
@ -246,4 +249,65 @@ describe('planSpeechIndex', () => {
planSpeechIndex(speech, { model: 'b' })?.speechHash,
)
})
it('plans the official metadata and pins it in the hash (C237)', () => {
const speech = {
summary: 'Processo de impeachment da Presidenta Dilma Rousseff.',
keywords: ['IMPEACHMENT', 'DILMA ROUSSEFF'],
segments: [{ order: 1, startSeconds: 0, text: 'Sr. Presidente...' }],
}
const plan = planSpeechIndex(speech, { model })
expect(plan?.metadataText).toBe(
'Processo de impeachment da Presidenta Dilma Rousseff.\nIMPEACHMENT, DILMA ROUSSEFF',
)
expect(plan?.speechHash).toBe(
speechEmbeddingHash(
'segment:1:Sr. Presidente...\nmetadata:Processo de impeachment da Presidenta Dilma Rousseff.\nIMPEACHMENT, DILMA ROUSSEFF',
model,
),
)
// Editing the summary or the keywords must reindex the speech.
expect(
planSpeechIndex({ ...speech, summary: 'Outro sumário' }, { model })?.speechHash,
).not.toBe(plan?.speechHash)
expect(planSpeechIndex({ ...speech, keywords: ['OUTRA'] }, { model })?.speechHash).not.toBe(
plan?.speechHash,
)
// A speech without metadata keeps the pre-C237 hash bytes.
expect(planSpeechIndex({ segments: speech.segments }, { model })?.metadataText).toBeNull()
expect(planSpeechIndex({ segments: speech.segments }, { model })?.speechHash).toBe(
speechEmbeddingHash('segment:1:Sr. Presidente...', model),
)
})
})
describe('speechMetadataText (C237)', () => {
it('prefers the summary over the title and joins the raw keywords', () => {
expect(
speechMetadataText({ summary: 'Resumo', title: 'Título', keywords: ['SUS', ' Saúde '] }),
).toBe('Resumo\nSUS, Saúde')
expect(speechMetadataText({ title: 'Título', keywords: [] })).toBe('Título')
expect(speechMetadataText({ summary: ' ', keywords: [' '] })).toBeNull()
expect(speechMetadataText({})).toBeNull()
})
})
describe('composeSpeechVector (C237)', () => {
const unit = [0.6, 0.8]
it('keeps the pure mean without metadata', () => {
expect(composeSpeechVector([unit], null)).toEqual(meanPoolVectors([unit]))
// A metadata vector of another dimension never poisons the mean.
expect(composeSpeechVector([unit], [1, 0, 0])).toEqual(meanPoolVectors([unit]))
})
it('pulls the speech vector toward the weighted metadata', () => {
const composed = composeSpeechVector([unit], [1, 0])
expect(composed).toEqual(
normalizeVector([unit[0] + SPEECH_METADATA_VECTOR_WEIGHT * 1, unit[1]]),
)
expect(cosineSimilarity(composed, [1, 0])).toBeGreaterThan(cosineSimilarity(unit, [1, 0]))
})
})