From d199c6b00639d011c8651a6cb375d978dfae72d0 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 16 Sep 2026 12:17:20 -0300 Subject: [PATCH] feat(C175): miniatura do trecho na listagem de falas (Closes #1085) --- docs/changelog/2026-09-16-c175.md | 1 + ...ervo-preview-do-trecho-na-listagem-impl.md | 108 ++++++++++++++++++ .../campaign/speech/SpeechResultCard.tsx | 18 ++- .../campaign/speech/SpeechResultThumbnail.tsx | 51 +++++++++ src/lib/speechVod.ts | 8 ++ src/utilities/speech/speechViewModels.ts | 10 +- tests/e2e/campaignSpeechAcervo.e2e.spec.ts | 8 ++ tests/int/speechAcervo.int.spec.ts | 20 ++++ tests/unit/speechViewModels.unit.spec.ts | 39 +++++++ tests/unit/speechVod.unit.spec.ts | 15 ++- 10 files changed, 274 insertions(+), 4 deletions(-) create mode 100644 docs/changelog/2026-09-16-c175.md create mode 100644 docs/plans/acervo-preview-do-trecho-na-listagem-impl.md create mode 100644 src/components/campaign/speech/SpeechResultThumbnail.tsx create mode 100644 tests/unit/speechViewModels.unit.spec.ts diff --git a/docs/changelog/2026-09-16-c175.md b/docs/changelog/2026-09-16-c175.md new file mode 100644 index 00000000..eac0ac54 --- /dev/null +++ b/docs/changelog/2026-09-16-c175.md @@ -0,0 +1 @@ +**C175 — preview do trecho na listagem de falas (2026-09-16):** a listagem do acervo (`/campanha/comunicacao/acervo`) ganha a miniatura do trecho no card quando a fala tem vídeo do YouTube — a assessoria reconhece a cena na varredura em vez de abrir fala por fala. O dado já vinha no `speechListSelect` (`youtubeUrl`), então a entrega é aditiva: nova função pura `youtubeThumbnailUrl(videoId)` em `src/lib/speechVod.ts` (variante `hqdefault`, a mesma do repo; `object-cover` corta as barras 4:3) e campo `thumbnailUrl: string | null` no `SpeechListItemViewModel`, montado com `parseYoutubeVideoId` — sem query, migration, access ou Consent novos. A UI é a ilha client `SpeechResultThumbnail` (novo), port classe-a-classe do design aprovado: slot fixo 128×80 (sem layout shift) sobre `` hotlinkado do CDN do YouTube (precedente raw `` — o otimizador quebraria o e2e, que nunca alcança `i.ytimg.com`), com `Skeleton` pulsando até a imagem pintar (cena 03; `onError` degrada para o slot neutro e `img.complete` no mount cobre imagem de cache) e `Link` para o mesmo `watchHref` do CTA, com o mesmo nome acessível. O `SpeechResultCard` passa a montar o excerto em flex condicional — miniatura à esquerda no desktop e empilhada no mobile (`sm:flex-row`), e **nenhum** espaço de mídia quando não há vídeo. Guardrails preservados: sem player/autoplay/hover-play, sem extração de frame/ffmpeg/mirroring ("meio do trecho" é a melhor miniatura padrão, nunca um seek), busca/excerto/highlight intocados e `leader`/`advisor` seguem fora do acervo. Testes: unit de `youtubeThumbnailUrl` em `tests/unit/speechVod.unit.spec.ts` e novo `tests/unit/speechViewModels.unit.spec.ts` (thumbnail presente/`null`/URL não-YouTube, `sourceUrl` intocado); int em `tests/int/speechAcervo.int.spec.ts` provando a propagação no loader; e2e do acervo com a URL no HTML do card e a ausência dela na fala sem vídeo. Crítica final do `designer` certificada (tier `openai/gpt-5.6-sol`, sem ajustes) contra o artefato `docs/plans/acervo-preview-do-trecho-na-listagem-ui-design.html`; débitos de consolidação do contrato `i.ytimg.com` e da ilha client deferidos com gatilho no `*-impl.md`. Issue #1085. diff --git a/docs/plans/acervo-preview-do-trecho-na-listagem-impl.md b/docs/plans/acervo-preview-do-trecho-na-listagem-impl.md new file mode 100644 index 00000000..4ce5a5b5 --- /dev/null +++ b/docs/plans/acervo-preview-do-trecho-na-listagem-impl.md @@ -0,0 +1,108 @@ +# Impl: Acervo: preview do trecho na listagem de falas + +Status: executado +Atualizado em: 2026-09-16 +Issue: #1085 +Intenção: docs/plans/acervo-preview-do-trecho-na-listagem.md +Design UI: docs/plans/acervo-preview-do-trecho-na-listagem-ui-design.html +Appetite restante: ~0,5–1 dia (herdado, sem corte) + +## Leitura da intenção + +- **Outcome:** quem varre `/campanha/comunicacao/acervo` reconhece o trecho pela imagem e decide qual fala abrir, sem abrir fala por fala. Um outcome verificável: card com miniatura quando há vídeo, card como hoje quando não há. +- **O que NÃO negociar (guardrails):** + - Nada de player, autoplay ou hover-play; a miniatura é estática e `SpeechResultCard.tsx:49` continua server component. + - Nada de extração/render de frame no servidor, ffmpeg ou fila — "meio do trecho" é a melhor miniatura padrão do vídeo, nunca um seek; a UI não promete precisão que o dado não tem. + - Nada de espelhamento/backfill de capas: hotlink declarado. + - Nada novo de Consent/LGPD e nenhuma exposição além do que a fala já expõe na lista (`youtubeUrl` já vai no `speechListSelect`, `speechPageData.ts:41`). + - Não mexer em busca/excerto/highlight (`SpeechHighlightParts`, `matchKind`); sem espaço de mídia reservado quando não há vídeo; `leader`/`advisor` seguem fora do acervo. +- **O que reavaliar:** a variante literal (a intenção recomendou `hqdefault` e o e2e existente já pina `hqdefault` — `campaignSpeechCut.e2e.spec.ts:152`); "meio do trecho" como promessa (resolvido como melhor miniatura disponível); miniatura decorativa vs clicável (o artefato aprovado a envolve em `` → mesmo `watchHref`); mostrar sempre que houver vídeo (não só `matchKind === 'segment'`). + +## Abordagem recomendada + +```mermaid +flowchart LR + A["speech.youtubeUrl
(já no select)"] --> B[parseYoutubeVideoId] + B --> C["youtubeThumbnailUrl(videoId)
lib/speechVod.ts"] + C --> D["SpeechListItemViewModel.thumbnailUrl"] + D --> E["SpeechResultThumbnail
ilha client + Skeleton"] + E --> F["SpeechResultCard
layout condicional"] +``` + +**Opções consideradas:** A) derivar no domínio — campo `thumbnailUrl` no `SpeechListItemViewModel` + ilha client de loading + raw `` (recomendada) | B) expor `youtubeVideoId` no VM e montar a URL no componente, sem estado de loading | C) derivar de `speech.sourceUrl` e/ou usar `next/image`. + +**Recomendação:** A. O VM é o dono dos links do card (header de `speechViewModels.ts:1-5`); o dado já chega (`speechListSelect` já seleciona `youtubeUrl: true`); o estado de loading do artefato (cena 03) exige uma ilha client pequena; o precedente de miniatura no acervo é raw `` (`SpeechCutResultCard.tsx:67-74`). + +**Rejeitadas:** B — espalha o contrato do CDN no componente e perde a cena 03; C — `sourceUrl` prioriza `officialTextUrl` (`speechViewModels.ts:207`), que pode ser a taquigrafia da Câmara, não o YouTube (bug de imagem errada); `next/image` quebra o e2e browserless (o stub só serve `localhost/thumbs/.jpg`; `i.ytimg.com` 404 → console error do guard, `youtube-stub.mjs:6-11`) e não há ganho num asset externo estático de 128×80. + +### Decisões de engenharia + +1. **Onde a URL nasce** — Opções: (A) função pura `youtubeThumbnailUrl(videoId)` em `src/lib/speechVod.ts` + campo `thumbnailUrl: string | null` montado em `toSpeechListItemViewModel`; (B) expor `youtubeVideoId` no VM e montar a URL no card; (C) derivar de `speech.sourceUrl` no card. **Recomendação:** A — o módulo já é puro/client-safe ("No I/O here", `speechVod.ts:6`) e o VM já importa `parseYoutubeVideoId` (`speechViewModels.ts:19`). **Rejeitadas:** B (contrato `i.ytimg.com` no componente); C (fonte errada quando há `officialTextUrl`). +2. **Variante da imagem** — Opções: `hqdefault` (480×360, sempre existe) | `mqdefault` (320×180, 16:9). **Recomendação:** `hqdefault` — consistente com todo o repo (`SpeechCutResultCard.tsx:70`, `SpeechCutPlayer.tsx:17`, `corte/[id]/page.tsx:40`) e pinada pelo e2e; `object-cover` corta as barras. **Rejeitada:** `mqdefault` (não usada em lugar nenhum; a intenção recomendou A). +3. **Elemento de imagem** — Opções: raw `` com dimensões fixas | `next/image`. **Recomendação:** raw `` com `eslint-disable-next-line @next/next/no-img-element` (precedente `SpeechCutResultCard.tsx:68`). **Rejeitada:** `next/image` (e2e nunca alcança `i.ytimg.com`; sem ganho no asset). +4. **Estado de loading (cena 03)** — Opções: ilha client `SpeechResultThumbnail` com `Skeleton` atrás do `` e revelação no `onLoad` | caixa sem JS com `bg-muted` | pulse permanente via CSS. **Recomendação:** ilha client; `onError` também encerra o pulse e degrada para slot neutro (vídeo removido não vira ícone de imagem quebrada nem pulse eterno). **Rejeitadas:** caixa sem JS (perde a affordance aprovada — divergência silenciosa); pulse permanente por CSS (anima todo card da lista — guardrail de performance). +5. **Miniatura clicável** — Opções: decorativa | mesmo `watchHref`. **Recomendação:** mesmo `watchHref` em `Link` com `aria-label="Assistir no trecho"` e `alt=""` decorativo — alvo maior sem ação nova; o artefato a envolve em `
`. **Rejeitada:** decorativa (alvo menor que o aprovado). +6. **Layout** — Opções: linha com `mt-2 flex flex-col items-start gap-3 sm:flex-row sm:gap-4` | `
` de hoje. **Recomendação:** com miniatura usa o flex (mobile empilha — cena 02, desktop em linha — cenas 01/03) com `min-w-0 flex-1` no wrapper do excerto; sem miniatura mantém exatamente o `
` atual (`SpeechResultCard.tsx:83`) — nenhum espaço de mídia reservado. **Rejeitada:** reservar o slot sempre (viola o guardrail do aceite). +7. **Testes** — unit: `youtubeThumbnailUrl` em `tests/unit/speechVod.unit.spec.ts` e novo `tests/unit/speechViewModels.unit.spec.ts` cobrindo `thumbnailUrl` (presente / `null` sem vídeo / URL não-YouTube / `sourceUrl` intocado); int: 1 asserção em `tests/int/speechAcervo.int.spec.ts` de que o loader propaga o campo (o `speechBundleFixture` já aceita `youtubeUrl`, `speechBundleFixture.ts:29`); e2e: asserções no teste de busca existente de `campaignSpeechAcervo.e2e.spec.ts:132-183` (HTML do card contém `i.ytimg.com/vi//hqdefault.jpg`; fala sem YouTube não contém miniatura). **Rejeitadas:** spec e2e novo (o fluxo já é coberto); snapshot de imagem. +8. **E2E local (OPS72)** — Opções: rodar `pnpm test:e2e:affected` | e2e full. **Recomendação:** affected — o diff toca `src/lib/speechVod.ts`, `src/utilities/speech` e `src/components/campaign/speech`, que o manifesto mapeia para `campaignSpeechAcervo` + `campaignSpeechCut` (`e2e-affected-manifest.mjs:305-314`). **Rejeitada:** e2e full (o `verify` do deploy já o roda). + +### Componentes / mudanças + +- **`youtubeThumbnailUrl(videoId: string | null | undefined): string | null`** (`src/lib/speechVod.ts`): nova função pura ao lado de `parseYoutubeVideoId` (`speechVod.ts:135`); retorna `https://i.ytimg.com/vi//hqdefault.jpg` ou `null`. Reusa o contrato de id já testado; não faz I/O. +- **`SpeechListItemViewModel` / `toSpeechListItemViewModel`** (`src/utilities/speech/speechViewModels.ts:44-58`, `:172-209`): novo campo `thumbnailUrl: string | null`, montado como `youtubeThumbnailUrl(parseYoutubeVideoId(speech.youtubeUrl))`; derivado do que `speechListSelect` já traz (`speechPageData.ts:29-42`), sem query nova. +- **`SpeechResultThumbnail`** (`src/components/campaign/speech/SpeechResultThumbnail.tsx`, novo): ilha client com `'use client'`; recebe `href` e `src`; renderiza `Link` (`h-20 w-32 shrink-0`) envolvendo `Skeleton` absoluto (cena 03, `animate-pulse`) + `` raw, encerrando o skeleton em `onLoad`/`onError` (vídeo removido degrada para o slot neutro). Imagem em cache é coberta checando `img.complete` no mount (o `onLoad` não dispara de novo); `prefers-reduced-motion` já zera o pulse. +- **`SpeechResultCard`** (`src/components/campaign/speech/SpeechResultCard.tsx:83-85`): troca o bloco do excerto pelo flex condicional (decisão 6), renderizando `SpeechResultThumbnail` só quando `speech.thumbnailUrl` existe. Nenhuma mudança em CTAs (`:112-125`), chips ou excerto. +- **Migration:** sem migration (nenhum campo de coleção/global novo). +- **Access / Consent:** sem mudança — `youtubeUrl` já é selecionado e já é exposto no detail VM (`speechViewModels.ts:259`) e no fallback de `sourceUrl`. +- **UI:** Impeccable B; port classe-a-classe do artefato aprovado (cenas 01/02/03), sem inventar variante de estilo. + +### Dados → forma + +Não se aplica. A entrega não cria agregado, contagem, gráfico ou tabela: é uma URL de imagem derivada de um campo existente, para reconhecimento visual. Nenhuma decisão numérica nasce aqui (a intenção já fecha "Vou apresentar dados? Não"). + +## Fases verificáveis + +1. **Domínio/VM (unit-first):** adicionar `youtubeThumbnailUrl` e o campo `thumbnailUrl`; escrever os casos unit em `tests/unit/speechVod.unit.spec.ts` e o novo `tests/unit/speechViewModels.unit.spec.ts` junto. Verificação: `pnpm test:unit -- speechVod speechViewModels` verde. +2. **UI (encaixe no card):** `SpeechResultThumbnail` + layout condicional em `SpeechResultCard`, port do artefato. Verificação: `speechDetailPlayer`/`speechCutDialog` seguem verdes. +3. **Integração e entrega:** `pnpm gate:fast`; e2e local afetado via `pnpm test:e2e:affected` (`campaignSpeechAcervo` + `campaignSpeechCut`); entrega com `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- **Refatorar as 3 ocorrências existentes** (`SpeechCutResultCard.tsx:70`, `SpeechCutPlayer.tsx:17`, `corte/[id]/page.tsx:40`) para o helper novo: blast radius em `corte/[id]` e no player, e o e2e pina o literal (`campaignSpeechCut.e2e.spec.ts:152`) — vira débito com gatilho (o próximo toque em capa de corte extrai o helper compartilhado e atualiza o pino). +- Backfill/mirroring de thumbnails no S3, extração de poster do MP4 com ffmpeg, frame exato "do meio do trecho", player/hover-play na lista — todos já cortados na intenção; nenhum reabre aqui. +- Miniatura do VOD da Câmara quando não há YouTube: fica para avaliação pós-uso real; sem YouTube → sem mídia. +- Trocar `next/image`, `blurDataURL`, placeholder local ou `next.config.mjs` `remotePatterns` (já contém `i.ytimg.com`, mas o e2e não o alcança — irrelevante aqui). + +## Riscos e mitigação + +- **CLS / pulo de layout:** dimensões fixas `h-20 w-32` no slot e no Skeleton; `shrink-0` no mobile; o excerto recebe `min-w-0 flex-1`. +- **Imagem externa hotlinkada:** sem mirroring (fora de escopo declarado); `loading="lazy"` + `decoding="async"`; `onError` degrada para slot neutro silencioso. +- **Skeleton preso:** imagem em cache pode não disparar `onLoad` após hidratar — mitigado checando `img.complete` no mount. +- **Duplicação do contrato `i.ytimg.com` (3 lugares):** aceita nesta entrega como débito com gatilho (a próxima ocorrência extrai o helper compartilhado). +- **e2e browserless não busca a imagem:** o teste lê o HTML e assere a URL (`i.ytimg.com/.../hqdefault.jpg`); por isso raw `` é seguro e `next/image` seria instável. +- **Regressão de acessibilidade:** `alt=""` decorativo + `aria-label` no `Link` preservam o nome acessível do CTA já existente. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto: card mostra miniatura quando há vídeo, sem espaço reservado quando não há; a miniatura reflete a fala do resultado; guardrails de performance e LGPD intactos. +- [x] Invariantes AGENTS/engineering-standards: identificadores em inglês, UI da campanha com `data-theme='campaign'`, sem migration/Consent/access novos, precedente de raw `` respeitado. +- [x] Testes de domínio previstos: unit cobre `youtubeThumbnailUrl`/`thumbnailUrl`, int prova a propagação no loader e e2e cobre o HTML do card. +- [x] `pnpm gate:fast` verde; e2e local afetado verde com `campaignSpeechAcervo` + `campaignSpeechCut`; entrega via `pnpm push`. + +## Débitos deferidos (capture-review-debts) + +- **Consolidar o contrato `i.ytimg.com`** (4ª ocorrência criada aqui, via `youtubeThumbnailUrl`): migrar `SpeechCutResultCard.tsx:70`, `SpeechCutPlayer.tsx:17` e `corte/[id]/page.tsx:40` para o helper. **Gatilho:** próximo toque em qualquer superfície de capa de corte ou um 4º consumidor de thumbnail; a extração deve manter a URL idêntica (o e2e `campaignSpeechCut.e2e.spec.ts:152` pina o literal). +- **Reavaliar a ilha client `SpeechResultThumbnail`** (uma fronteira de hidratação por linha da lista) se o custo de hidratação de 25 cards aparecer. **Gatilho:** medição/uso real da lista; o fallback é manter o slot fixo `bg-muted` sem o pulse (perde só a cena 03). + +### Já resolvido no /simplify (não reabrir) + +- `aria-label` da miniatura igual ao label do CTA (não contraditório); `` sem duplicação nos dois ramos; parâmetro de `youtubeThumbnailUrl` restrito a `string | null`; `aria-hidden` redundante removido do `Skeleton`; docblock encurtado. + +### Descartado + +- **Extrair a miniatura de `SpeechCutResultCard` num componente único** (twin apontado pelo revisor): os dois têm contratos distintos (o de cortes não tem skeleton/`Link`); o que é compartilhável — a URL — já tem dono em `youtubeThumbnailUrl`. Fica no débito acima. +- **Inline do helper com 1 call site:** mantido porque nomeia o contrato do CDN (o dono da consolidação do débito) e é unit-testável isolado. + +## Self-score (decision-quality) + +5/5 — (1) decisões caras com rejeitadas explícitas (as 8 nomeiam alternativas e o motivo do descarte); (2) a abordagem cabe no appetite ~0,5–1 dia (mudança aditiva de VM + um componente); (3) rabbit holes nomeados (as 3 ocorrências existentes, backfill, ffmpeg); (4) depth check: reusa `parseYoutubeVideoId`, o `Skeleton` da casa e o precedente de raw ``, sem query/access novos; (5) a intenção permanece satisfeita — a engenharia não reescreveu o outcome. diff --git a/src/components/campaign/speech/SpeechResultCard.tsx b/src/components/campaign/speech/SpeechResultCard.tsx index 12ac6661..2e49172c 100644 --- a/src/components/campaign/speech/SpeechResultCard.tsx +++ b/src/components/campaign/speech/SpeechResultCard.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import type { ReactNode } from 'react' import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts' +import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail' import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight' @@ -55,6 +56,8 @@ export const SpeechResultCard = ({ speech }: { speech: SpeechListItemViewModel } visibleTopics.length + (speech.scopes.length - visibleScopes.length) + (speech.keywords.length - visibleKeywords.length) + const watchLabel = speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala' + const excerptNode = return (
@@ -81,7 +84,18 @@ export const SpeechResultCard = ({ speech }: { speech: SpeechListItemViewModel }
- + {speech.thumbnailUrl ? ( +
+ +
{excerptNode}
+
+ ) : ( + excerptNode + )}
{speech.topics.length || speech.scopes.length || speech.keywords.length ? ( @@ -113,7 +127,7 @@ export const SpeechResultCard = ({ speech }: { speech: SpeechListItemViewModel } {speech.sourceUrl ? ( diff --git a/src/components/campaign/speech/SpeechResultThumbnail.tsx b/src/components/campaign/speech/SpeechResultThumbnail.tsx new file mode 100644 index 00000000..52e6975c --- /dev/null +++ b/src/components/campaign/speech/SpeechResultThumbnail.tsx @@ -0,0 +1,51 @@ +'use client' + +import Link from 'next/link' +import { useEffect, useRef, useState } from 'react' + +import { Skeleton } from '@/components/ui/skeleton' + +/** + * C175 — the trecho thumbnail on the acervo list: a fixed 128×80 slot so the + * lazy image never shifts the card, with the skeleton filling it until the + * cover paints (a missing cover settles into the neutral slot). The link + * reuses the card's watch target, with the same accessible name as the CTA. + */ +export const SpeechResultThumbnail = ({ + href, + src, + label, +}: { + href: string + src: string + label: string +}) => { + const [settled, setSettled] = useState(false) + const imageRef = useRef(null) + + // A cached image can finish before hydration, when `onLoad` never fires. + useEffect(() => { + if (imageRef.current?.complete) setSettled(true) + }, []) + + return ( + + {settled ? null : } + {/* eslint-disable-next-line @next/next/no-img-element -- YouTube cover is an external static image. */} + setSettled(true)} + onError={() => setSettled(true)} + className="h-full w-full object-cover" + /> + + ) +} diff --git a/src/lib/speechVod.ts b/src/lib/speechVod.ts index c621b490..ccba3f10 100644 --- a/src/lib/speechVod.ts +++ b/src/lib/speechVod.ts @@ -160,6 +160,14 @@ export const parseYoutubeVideoId = (raw: unknown): string | null => { return candidate && YOUTUBE_VIDEO_ID.test(candidate) ? candidate : null } +/** + * The YouTube default cover (C175) of an already-parsed video id, or null when + * there is none. `hqdefault` is the variant the whole repo uses; `object-cover` + * crops the 4:3 letterbox bars the YouTube player frame adds to 16:9 video. + */ +export const youtubeThumbnailUrl = (videoId: string | null): string | null => + videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : null + const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo' const NAIVE_DATETIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/ const brtWallClock = new Intl.DateTimeFormat('en-CA', { diff --git a/src/utilities/speech/speechViewModels.ts b/src/utilities/speech/speechViewModels.ts index 5a3546d2..9caed4bd 100644 --- a/src/utilities/speech/speechViewModels.ts +++ b/src/utilities/speech/speechViewModels.ts @@ -16,7 +16,12 @@ import { type SpeechHighlightPart, } from '@/lib/speechHighlight' import { normalizeForSearch } from '@/lib/speechSearch' -import { excerptOffsetSeconds, parseYoutubeVideoId, speechVodCoordinates } from '@/lib/speechVod' +import { + excerptOffsetSeconds, + parseYoutubeVideoId, + speechVodCoordinates, + youtubeThumbnailUrl, +} from '@/lib/speechVod' import type { Municipality } from '@/payload-types' import { speechScopeLabels, speechTopicLabels } from '@/utilities/speech/speechListUrl' @@ -53,6 +58,8 @@ export type SpeechListItemViewModel = { scopes: { value: SpeechScope; label: string }[] keywords: string[] municipalities: { id: number; name: string }[] + /** YouTube cover of the session link (C175); null when the speech has none. */ + thumbnailUrl: string | null watchHref: string sourceUrl: string | null } @@ -203,6 +210,7 @@ export const toSpeechListItemViewModel = ({ scopes: scopeViewModels(speech), keywords: speech.keywords ?? [], municipalities: municipalityViewModels(speech, municipalityLabels), + thumbnailUrl: youtubeThumbnailUrl(parseYoutubeVideoId(speech.youtubeUrl)), watchHref: buildWatchHref(speech.id, matchedSegment, q), sourceUrl: speech.officialTextUrl ?? speech.youtubeUrl ?? null, } diff --git a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts index fc4f8fb6..6df78d13 100644 --- a/tests/e2e/campaignSpeechAcervo.e2e.spec.ts +++ b/tests/e2e/campaignSpeechAcervo.e2e.spec.ts @@ -149,6 +149,9 @@ test.describe('communication vertical (C154/C162)', () => { expect(resultsHtml).toContain(' { const user = await campaign.fixtures.createCampaignUser('communicator') const request = await campaignRequest(user, user.password) + const results = await request.get(`/campanha/comunicacao/acervo?q=${marker}`) + expect(results.status()).toBe(200) + // C175 — no YouTube session link means no thumbnail and no reserved media. + expect(rendered(await results.text())).not.toContain('i.ytimg.com') + const detail = await request.get(`/campanha/comunicacao/acervo/${speech.id}`) expect(detail.status()).toBe(200) const html = rendered(await detail.text()) diff --git a/tests/int/speechAcervo.int.spec.ts b/tests/int/speechAcervo.int.spec.ts index bb7b5c9b..a78b9722 100644 --- a/tests/int/speechAcervo.int.spec.ts +++ b/tests/int/speechAcervo.int.spec.ts @@ -231,6 +231,26 @@ describe('speech acervo (C154)', () => { expect(row).not.toHaveProperty('downloadUrl') }) + it('propagates the YouTube thumbnail to the list view model (C175)', async () => { + const runId = randomUUID().slice(0, 8) + const marker = `miniatura${runId}` + const withVideo = await createSpeech({ + youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A', + segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} no plenário` }], + }) + const withoutVideo = await createSpeech({ + segments: [{ startSeconds: 0, endSeconds: 3, text: `A fala ${marker} sem vídeo` }], + }) + + const { communicator } = await createUsers() + const data = await loadSpeechAcervoPageData(payload, communicator, { q: marker }) + + expect(data.rows.find((row) => row.id === withVideo)?.thumbnailUrl).toBe( + 'https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg', + ) + expect(data.rows.find((row) => row.id === withoutVideo)?.thumbnailUrl).toBeNull() + }) + it('allows communicator/coordinator/candidate and denies advisor/leader', async () => { const id = await createSpeech() const { communicator, coordinator, candidate, advisor, leader } = await createUsers() diff --git a/tests/unit/speechViewModels.unit.spec.ts b/tests/unit/speechViewModels.unit.spec.ts new file mode 100644 index 00000000..1ccf4cb1 --- /dev/null +++ b/tests/unit/speechViewModels.unit.spec.ts @@ -0,0 +1,39 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + toSpeechListItemViewModel, + type SpeechListRecord, +} from '@/utilities/speech/speechViewModels' + +const viewModel = (overrides: Partial = {}) => + toSpeechListItemViewModel({ + speech: { id: 1, speechAt: '2026-08-11T18:48', ...overrides }, + segments: [], + municipalityLabels: new Map(), + }) + +describe('toSpeechListItemViewModel thumbnail (C175)', () => { + it('carries the YouTube cover when the session link parses', () => { + const row = viewModel({ youtubeUrl: 'https://www.youtube.com/watch?v=lLhRDkSPw0A' }) + + expect(row.thumbnailUrl).toBe('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg') + }) + + it('has no thumbnail without a YouTube link', () => { + expect(viewModel().thumbnailUrl).toBeNull() + expect(viewModel({ youtubeUrl: null }).thumbnailUrl).toBeNull() + expect(viewModel({ youtubeUrl: 'https://camara.leg.br/discurso' }).thumbnailUrl).toBeNull() + }) + + it('keeps the source link independent from the thumbnail', () => { + const row = viewModel({ + youtubeUrl: 'https://youtu.be/lLhRDkSPw0A', + officialTextUrl: 'https://camara.leg.br/discurso', + }) + + expect(row.thumbnailUrl).toBe('https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg') + expect(row.sourceUrl).toBe('https://camara.leg.br/discurso') + }) +}) diff --git a/tests/unit/speechVod.unit.spec.ts b/tests/unit/speechVod.unit.spec.ts index b73c56d8..d1e59eaf 100644 --- a/tests/unit/speechVod.unit.spec.ts +++ b/tests/unit/speechVod.unit.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' -import { excerptOffsetSeconds, parseYoutubeVideoId } from '@/lib/speechVod' +import { excerptOffsetSeconds, parseYoutubeVideoId, youtubeThumbnailUrl } from '@/lib/speechVod' /** * The VOD URL/status/duration contracts moved from `scripts/lib/camaraSpeeches.mjs` @@ -82,3 +82,16 @@ describe('parseYoutubeVideoId', () => { expect(parseYoutubeVideoId('https://www.youtube.com/watch?v=../etc/passwd')).toBeNull() }) }) + +describe('youtubeThumbnailUrl', () => { + it('builds the hqdefault cover for a parsed video id (intent literal)', () => { + expect(youtubeThumbnailUrl('lLhRDkSPw0A')).toBe( + 'https://i.ytimg.com/vi/lLhRDkSPw0A/hqdefault.jpg', + ) + }) + + it('returns null when there is no video id', () => { + expect(youtubeThumbnailUrl(null)).toBeNull() + expect(youtubeThumbnailUrl('')).toBeNull() + }) +})