Compare commits
7 commits
f1bc634385
...
449ef72190
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
449ef72190 | ||
|
|
862da74fbe | ||
|
|
3201b7a93f | ||
|
|
5eb8fd75ef | ||
|
|
39933fc004 | ||
|
|
10327a7451 | ||
|
|
ce055ad3d8 |
|
|
@ -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:
|
||||
|
|
|
|||
1
docs/changelog/2026-09-29-c236.md
Normal 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/`).
|
||||
1
docs/changelog/2026-09-30-c230-instagram-lote-ops.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
1
docs/changelog/2026-09-30-c237.md
Normal 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.
|
||||
1
docs/changelog/2026-09-30-s42.md
Normal 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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
159
docs/plans/central-conteudos-acoes-em-lote-impl.md
Normal 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.
|
||||
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 91 KiB |
134
docs/plans/central-conteudos-secao-home-share-filtro-impl.md
Normal 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.
|
||||
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 57 KiB |
1877
docs/plans/central-conteudos-secao-home-share-filtro-ui-design.html
Normal file
119
docs/plans/central-conteudos-secao-home-share-filtro.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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; }`
|
||||
107
scripts/lib/chartMotionRender.mjs
Normal 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 }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
154
scripts/lib/instagramContentCli.mjs
Normal 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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
218
scripts/withdraw-instagram-content.mjs
Normal 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.')
|
||||
})
|
||||
|
|
@ -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 })
|
||||
},
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
60
src/components/campaign/content/ContentPieceBatchReceipt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
141
src/components/campaign/content/ContentPieceSelectionBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
100
src/components/conteudos/ContentPieceHomeFilterRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
||||
|
|
|
|||
79
src/lib/contentPieceBatch.ts
Normal 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?`
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
138
tests/unit/chartMotion.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
52
tests/unit/contentPieceBatch.unit.spec.ts
Normal 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?')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
54
tests/unit/contentPieceHomeFilterRow.unit.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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/')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
270
tests/unit/contentPieceSelection.unit.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
|
|
|
|||
90
tests/unit/instagramContentWithdrawCli.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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]))
|
||||
})
|
||||
})
|
||||
|
|
|
|||