feat(C174): encontrar cortes — navegação, na fala e na busca (Closes #1084)
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions

This commit is contained in:
Francisco Solla 2026-09-16 12:29:13 -03:00
parent 01be1de7be
commit f066bee92b
28 changed files with 1339 additions and 86 deletions

View file

@ -0,0 +1 @@
**C174 — Acervo: encontrar cortes na navegação, na fala e na busca (2026-09-16):** a assessoria passa a reencontrar um corte pelos três caminhos, sem recortar de novo. (1) **Navegação:** `nav.ts` ganha `subItems` aditivo no item "Comunicação" — "Acervo de falas" e "Biblioteca de cortes" — renderizado na sidebar (`SidebarMenuSub*`, filho ativo com cartão branco + ring) e no drawer "Mais" do mobile; era a causa do "não encontrei a edição" (o C168 só tinha o CTA dentro do acervo). (2) **Na fala:** nova seção **"Cortes desta fala"** no detalhe (`loadSpeechCutsForSpeech`, `speechCut` por `speech` index, depth 0, mais recentes primeiro), com tabela no desktop e cards no mobile, badges de status do C168 e vazio honesto ("Nenhum corte desta fala ainda" + "Selecione um trecho no player para criar o primeiro corte." + CTA "Selecionar trecho"). (3) **Na busca:** `loadSpeechCutsForSpeeches` pré-carrega os cortes dos ids da página e o `SpeechResultCard` aninha "N corte(s) desta fala"; a opção B assunida também entra — `loadSpeechCutOriginSpeechIds` OR-eia no `where` textual as falas de origem de cortes que casam por título/descrição, então a fala aparece como "Fala de origem" com a nota honesta "A fala não contém o termo exato…" e "Correspondência no corte". A ponte CTA↔player é um evento nomeado (`SPEECH_EXCERPT_REQUEST_EVENT`): o CTA só anuncia a intenção, o `SpeechDetailPlayer` (dono do estado) liga o picker sem duplicar a seleção. Sem collection, Consent ou migration; gate `speechCatalog` e lockdown advisor/leader intocados (o gate de página segue fail-closed; a leitura nova usa `user` + `overrideAccess:false`); `/corte/<id>` público intocado. Testes: units de nav/chrome/`buildSpeechListWhereIncludingCutOrigins`/CTA; int do loader por fala, do batch, do acesso por papel e da origem opção B; e2e `campaignSpeechCut` cobre a seção, o aninhamento e a origem.

View file

@ -0,0 +1,40 @@
# Acervo cortes — dívida pós-C174: select enxuto dos loaders, teto do `id in` da origem e paridade busca↔VM
Status: rascunho
Atualizado em: 2026-09-16
Issue: (C180 — registrada por `agent:register`)
Priority: P3
Impeccable: A (só backend/utilities; nenhuma superfície visual nova)
Appetite: ~0,5–1 dia eng fill-in
Depends: #1084 (C174 — destrava no flip `done` do pai)
## Contexto
O `/simplify` do C174 (Issue #1084) deixou três achados de performance/paridade que **não** foram absorvidos no cleanup da sessão. Nenhum bloqueia a entrega: são custo de query e risco de drift silencioso, todos em `src/utilities/speech/`.
## Fases
1. **F1 — `select` enxuto nos loaders de corte e DTO estreito.** `loadSpeechCutsForSpeech`, `loadSpeechCutsForSpeeches` e `loadSpeechCutOriginSpeechIds` (`src/utilities/speech/speechCutPageData.ts`) hoje trazem a linha inteira (`limit: 0`, sem `select`) e o VM entrega `SpeechCutViewModel` completo (`description`/`mediaUrl`/`failureMessage`/`youtubeVideoId`) para superfícies que só renderizam `id`/`title`/`status`/`durationLabel`. Adicionar `select` aos três e um `SpeechCutSummaryViewModel` (ou um `toSpeechCutSummaryViewModel`) consumido pela seção "Cortes desta fala" e pelo aninhamento da busca. Prova: int existente de `speechCut`/`speechAcervo` verde + um caso de loader que confirme a ausência dos campos não usados.
2. **F2 — predicado único busca↔VM.** `speechMatchesText` (`src/utilities/speech/speechViewModels.ts`) espelha por comentário o ramo textual do `where` (`buildSpeechTextBranches`, `src/utilities/speech/speechListFilters.ts`). Se os dois divergirem, a fala aparece sem o aviso "Fala de origem" (ou com ele indevido). Extrair um predicado puro compartilhado (ou manter o `where` como única fonte e comparar por id já resolvido) e cobrir com unit o par `searchText`/`keywords`.
3. **F3 — teto/estratégia do `id in` da origem.** `loadSpeechCutOriginSpeechIds` devolve todos os ids de fala de origem; com termo comum o `id: { in: [...] }` do acervo cresce sem limite. Definir estratégia (cap + degradação honesta, ou subquery) e uma constante com gatilho de volume, como o precedente do `searchText` do C168 (~5–10 mil cortes).
## Explicitamente fora (skips do simplify + descartes deste triage)
- **Sub-lista de subitens duplicada** (`CampaignSidebar` × `CampaignBottomNav`) — defer; gatilho: um 3º consumidor real de `subItems` (hoje os dois consomem primitivos distintos: `SidebarMenuSub*` × `div`/`Link`).
- **`[data-slot="speech-player"]` literal no CTA** — defer; gatilho: um 2º scroller externo do player ou rename do slot (a falha só degrada o scroll, não quebra).
- **Rename `matchedTextSearch`** — descartado (pureza de nome, score 1).
- **`OpenCutButton` / `countLabel` / `campaignSpeechCutDetailHref` com <3 call sites** — descartados; `campaignSpeechCutDetailHref` é decisão explícita do plano do C174 (D1) para 2 call sites.
- **Seletor `button[data-start-seconds="43"]`** — descartado; é do player (C166/C167), fora do escopo do C174.
## Riscos
- **`select` enxuto quebrar o VM**: o `toSpeechCutViewModel` lê `error`/`step` para o `failureMessage`; o `select` da summary precisa manter o que a superfície renderiza e nada além. Coberto pelos int do C174.
- **Predicado único divergir do SQL**: o `where` usa operadores do Payload (`like`/`contains`) que o JS não replica 1:1; a fase deve documentar a semântica assumida (texto normalizado e keyword crua) e testar o par.
## Self-score (decision-quality ≥4)
1. **Decisões caras com rejeitadas — 4/5:** F1/F3 mexem em shape de DTO e em limites de query do acervo (barato de reverter, mas com custo de coordenação); F2 tem opções (predicado compartilhado vs comparar por id) explicitadas.
2. **Cabe no appetite — 5/5:** 0,5–1 dia; reusa os int/unit existentes.
3. **Rabbit holes nomeados — 5/5:** a lista de "Explicitamente fora" fecha o escopo.
4. **Depth check — 4/5:** mantém `utilities/speech/` como dono único; nenhum módulo gêmeo.
5. **Intenção de produto — 5/5:** nenhuma mudança de comportamento visível; é custo de query e robustez.

View file

@ -0,0 +1,223 @@
# Impl: Acervo: encontrar cortes — na navegação, na fala e na busca
Status: aprovado
Atualizado em: 2026-09-16
Issue: #1084
Intenção: docs/plans/acervo-encontrar-cortes.md
Appetite restante: herdado (~1,5–2 dias eng)
## Leitura da intenção
- **Outcome:** a assessoria **reencontra um corte existente e o reaproveita** pelos três caminhos — subitem de nav (biblioteca a um clique), seção "Cortes desta fala" no detalhe da fala, e cortes aninhados sob a fala na busca do acervo — sem recortar de novo. É descoberta e reuso; **nenhum modelo de dados novo**.
- **O que NÃO negociar:** gate `speechCatalog` (`communicator`/`coordinator`/`candidate` passam; `advisor`/`leader` fail-closed); corte sempre pertence a uma fala (FK `SET NULL`, UI degrada); corte **não** vira grupo/lista separada na busca; nenhum internals de corte (media/status cru/`error`) vaza; `/corte/<id>` público **intocado**; sem `Consent`, sem collection, sem migration.
- **O que reavaliar:** (a) o C168 **não** deixou item de nav nem breadcrumb — o dono não achou a edição (D9 do C168: só CTA na lista + chrome); a árvore `CampaignNavItem` é **plana** e não tem subitens; (b) não existe seção de cortes no detalhe da fala nem query cortes-por-fala; (c) a busca só cobre `speech.searchText`/`keywords` — corte não casa; (d) o "Selecionar trecho" é **interno** ao player (`SpeechDetailPlayer.tsx:449-461`), sem trigger externo.
## Abordagem recomendada
```mermaid
flowchart TD
subgraph nav["D1/D5/D7 — descoberta pela navegação"]
NAV[nav.ts: CampaignNavItem.subItems em Comunicação] --> SIDEBAR[CampaignSidebar: SidebarMenuSub]
NAV --> OVERFLOW[CampaignBottomNav: drawer mobile flatten]
end
subgraph speech["D2/D4 — descoberta pela fala de origem"]
SPEECH[acervo/id/page.tsx] --> GATE[requireCampaignPageActor gate speechCatalog]
GATE --> LOADSPEECHCUTS[loadSpeechCutsForSpeech: speechCut where speech=id depth 0 sort -createdAt]
LOADSPEECHCUTS --> VM[toSpeechCutViewModel]
VM --> SECTION["Cortes desta fala" populado/empty]
SECTION -->|empty CTA| CTA[SpeechExcerptCta → evento → SpeechDetailPlayer.requestSelection]
end
subgraph search["D3 — descoberta pela busca"]
ACERVO[acervo/page.tsx] --> LOADACERVO[loadSpeechAcervoPageData]
LOADACERVO --> BATCH[loadSpeechCutsForSpeeches: where speech in ids]
BATCH --> NESTED[SpeechResultList/SpeechResultCard: cutsBySpeech aninhado]
LOADACERVO -->|opção B, GATE| CUTQUERY[speechCut title/description like q depth 1]
CUTQUERY --> MERGE[merge dedupe por speech id → card de origem mínimo]
end
```
**Opções consideradas:**
- **A — Estender a árvore de nav com subitens estáticos + aninhar cortes na fala/busca (dono único de cada concern).**
- **B — Tipo dedicado `CommunicationNavItem` para a vertical + renderer paralelo.**
- **C — Abas Falas|Cortes na home da Comunicação + grupo "Cortes" separado na busca.**
**Recomendação: A** — a navegação vira dado aditivo (`subItems`), então o **mesmo** `nav.ts` alimenta os dois consumidores (sidebar desktop/sheet e drawer mobile) sem duplicar verdade; a seção da fala e a busca reusam os loaders/VM do C168/C167 (`toSpeechCutViewModel`, `SpeechCutStatusBadge`, `loadSpeechCutsForSpeeches` sobre `speech` index). A árvore plana de hoje não expressa hierarquia e o pin `getCampaignNav('communicator')` é sobre os **hrefs de topo** (`campaignNav.unit.spec.ts:68`), preservado por uma extensão aditiva.
**Rejeitadas:** **B** porque um tipo/renderer dedicado à vertical fura a fonte única do `nav.ts` e obriga a manter dois caminhos de render (o vazamento que queremos evitar); **C** já rejeitada em produto — o acervo é facetado (`speechListFilters.ts:34-57`) e a biblioteca é só `q`+paginação (`speechCutListUrl.ts`), e o grupo "Cortes" recria a lista paralela que motivou o pedido.
### Decisões de engenharia (caras de reverter)
**D1 — Forma da árvore de nav (subitens estáticos).**
Opções: A) adicionar `subItems?: readonly CampaignNavSubItem[]` a `CampaignNavItem`, com `CampaignNavSubItem = { title; href }` (sem ícone), e renderizar em `CampaignSidebar` (via `SidebarMenuSub*`) + `CampaignBottomNav` (drawer) | B) tipo dedicado `CommunicationNavItem` + renderer próprio | C) "grupo" separado, como `staffSecondaryNav` (Conceitos).
**Recomendação: A** — a extensão é **aditiva e rasa**: `icon` continua obrigatório no item de topo (nenhum `<item.icon />` precisou de narrowing), e o sub-item segue sem ícone como no design (CENA 01: dois links indentados, só texto). Nomear o campo `subItems` (não `children`) evita colidir com a prop JSX `children` que o `CampaignSidebarLink` já usa para os saved-filters (`CampaignSidebar.tsx:45-66`). O pin `expect(getCampaignNav('communicator').map(i => i.href)).toEqual([CAMPAIGN_COMMUNICATION_HOME])` (`campaignNav.unit.spec.ts:65-72`) **permanece verde** — ele só lê o nível de topo. Os dois consumidores leem a mesma estrutura: a sidebar renderiza `SidebarMenuSub/SidebarMenuSubItem/SidebarMenuSubButton` (`Sidebar.tsx:450-514`) sob o item; o drawer mobile "achata" o pai + `subItems` indentados. **Sem estado de expansão** (os dois sub-links são sempre visíveis nas cenas 1–4; o caret é decorativo): não há disclosure controlado, sem client state novo.
**Alternativas rejeitadas:** B porque duplica o render da nav e fura a fonte única; C porque o design pede filhos **abaixo do pai** com caret, não um grupo no pé (esse é o padrão do Conceitos) — e "grupo" não dá o ativo por prefixo do pai.
**Ativo do pai:** `isCampaignNavActive` já cobre `/campanha/comunicacao/*` por prefixo (`nav.ts:126`); o filho ativo usa o mesmo predicado no `href` do sub-item. Nada muda no helper.
**D2 — Loader cortes-por-fala + VM.**
Opções: A) `loadSpeechCutsForSpeech(payload, user, speechId)` em `speechCutPageData.ts`, `where: { speech: { equals: speechId } }`, `depth: 0`, `sort: '-createdAt'`, `user`+`overrideAccess: false`, mapeando por `toSpeechCutViewModel` | B) query direto no componente da página | C) novo domínio `utilities/speechCut/`.
**Recomendação: A** — o `relationship speech` é `index` (`SpeechCut.ts:71-81`), então o filtro é barato; `depth: 0` basta porque na página da fala a **origem é a própria página** (não precisamos do `speech` expandido) e as ações só precisam de `id`/`title`/`status`/`durationLabel`. Por isso o VM é o `toSpeechCutViewModel` (`speechCut.ts:277-304`) e **não** o `toSpeechCutLibraryItemViewModel` (`:342`), que assume `speech` expandida e cairia em `origin: null` — usar o library VM aqui seria carregar um contrato que a seção não renderiza. **Limite:** `limit: 0, pagination: false` (todos os cortes daquela fala, mais recentes primeiro) — o número de cortes por fala é limitado pelo esforço humano; **sem teto/paginação no v1**, com gatilho de revisão se uma fala passar de ~50 cortes. `select` enxuto (sem `media`/`error`/`createdBy`).
**Alternativas rejeitadas:** B porque query no componente sem `user`+`overrideAccess:false` fura o access e é intestável; C porque o C167/C168 já são donos (`utilities/speech/`) — módulo gêmeo é exatamente o anti-padrão "edite o dono, não gema um irmão".
**D3 — Busca: aninhamento sem grupo separado + a questão B (corte casa, fala não casa).**
Opções: **A (núcleo, obrigatório)** — cortes aninhados sob a fala **que já casou** na busca de falas: batch loader `loadSpeechCutsForSpeeches(payload, user, ids)` com `where: { speech: { in: ids } }` (precedente `loadSegmentsForSpeeches`, `speechPageData.ts:110-141`), agrupado num `Map<number, SpeechCutViewModel[]>` consumido por `SpeechResultList/SpeechResultCard` | **B (aberta, design CENA 05)** — 2ª query `speechCut` casando `title`/`description` `like q`, `depth: 1` para trazer a fala de origem, fundida na **mesma lista** | **C** grupo "Cortes" separado. **Recomendação:** **A entra com certeza** (é o aceite literal da intenção). **B fica como decisão de produto a confirmar no GATE** — a intenção e o design (CENA 05) recomendam incluir; o plano deixa B isolado numa etapa própria que pode ser dropada sem tocar o núcleo. **Se confirmada, B implementa-se assim:** `loadSpeechCutsMatchingQuery(payload, user, q)` (`where: { or: [{ title: { like: q } }, { description: { like: q } }] }`, `depth: 1`, `overrideAccess: false`) → agrupa por `speech.id` → **dedupe por id**: se a fala já está na página de resultados, **não** adiciona card novo e apenas mescla o corte no `cutsBySpeech`; se não está, acrescenta um **card de origem mínimo** (CENA 05: "Fala de origem · data · tipo", a nota honesta "A fala não contém o termo exato; ela aparece porque um corte vinculado corresponde à busca.", "Ver fala" e o corte aninhado). O card mínimo é um VM próprio (`SpeechCutOriginResultViewModel`) e **não** um `SpeechListItemViewModel` fabricado — o `speech` a `depth 1` no corte não traz `searchText`/segmentos/`excerpt` para montar o card padrão honestamente. O rodapé "N falas encontradas" continua contando as falas do resultado canônico; quando B adiciona origens, o rótulo acompanha (a confirmar com produto junto com B).
**Alternativas rejeitadas:** C porque recria a lista paralela (anti-goal explícito); em B, reusar `SpeechResultCard` para a origem porque exigiria inventar `excerpt`/segmentos a partir de dados parciais — dado fabricado é pior que um card mínimo honesto.
**D4 — CTA "Selecionar trecho" do vazio.**
Opções: A) `SpeechExcerptCta` (client) que **emite uma intenção** (evento) e rola o player para a vista; o `SpeechDetailPlayer` (dono do estado `selection`) escuta e chama `requestSelection()` (liga a seleção, idempotente) | B) anchor `href="#speech-player"` que só rola; a pessoa ainda clica no botão do player | C) mover o estado de seleção para o servidor / renderizar o toggle no lado server.
**Recomendação: A** — o estado de seleção mora no player (`SpeechDetailPlayer.tsx:137`, `toggleSelection` em `:255`); a ponte não duplica lógica, só **dispara a ação que o player já tem**. Contrato nomeado (`SPEECH_EXCERPT_REQUEST_EVENT`, junto de `src/lib/speechExcerptSelection.ts`), `useEffect` no player adiciona/remove o listener e chama um `requestSelection()` que liga a seleção quando `null` (distinto do toggle, que alterna); o CTA renderiza o `Button` com a copy literal e faz `scrollIntoView` do `[data-slot="speech-player"]`. É testável por unit (o contrato/reducer do CTA) sem browser.
**Alternativas rejeitadas:** B porque entrega só metade do design (o botão grande do vazio seria só um scroll, exigindo dois cliques); C porque sobe estado de um player autônomo para a página sem necessidade e acopla RSC a uma interação local.
**D5 — Chrome/breadcrumb e o "não encontrei a edição".**
Opções: A) nav com subitens (D1) + confirmar o chrome existente + manter o "Voltar aos cortes" do detalhe | B) redesenhar breadcrumbs na vertical | C) criar rota nova de biblioteca.
**Recomendação: A** — a causa do "não encontrei" era **ausência de item de nav** (D9 do C168) e nenhuma pista na fala; o chrome da biblioteca **já está correto** desde o C168: a regra exata `pathname === CAMPAIGN_COMMUNICATION_CORTES` (`campaignPageChrome.ts:293`) vem **antes** da regex genérica do detalhe do acervo (`:297`), e a regex `^/campanha/comunicacao/acervo/[^/]+$` não captura `cortes/[id]` (dois segmentos), então o detalhe do corte resolve `null` e seta o próprio título via `SetCampaignPageChrome` (`cortes/[id]/page.tsx:65`) — igual ao detalhe da fala. O detalhe do corte já tem "Voltar aos cortes" (`:68-73`). O que entra é a **subárvore de nav** (e o subitem "Biblioteca de cortes" ativo). Ordem das regras **inalterada**.
**Alternativas rejeitadas:** B porque o produto não pediu breadcrumb completo e a vertical não tem trilha hoje; C porque a rota já existe e o problema é descobribilidade, não a rota.
**D6 — Testes e manifest.**
Opções: A) unit de nav/chrome + int do loader cortes-por-fala/nested + e2e do gate e da seção | B) só int | C) e2e de browser para o CTA e o aninhamento.
**Recomendação: A** — unit: `campaignNav.unit.spec.ts` (communicator tem `subItems` com os dois caminhos; pai ativo quando o filho `/acervo/cortes` está ativo; overflow de staff inclui os subitens de Comunicação) e `campaignPageChrome.unit.spec.ts` (lista `cortes` resolve título "Cortes"; `/acervo/cortes/42` resolve `null`). int: `speechCut.int.spec.ts` ganha `loadSpeechCutsForSpeech` (todos os cortes da fala, `-createdAt`, VM depth-0 sem vazar `error`; vazio quando não há corte; fail-closed para advisor/leader) e o nested da busca (mapa por fala; em B, merge/dedupe por id). e2e HTTP (`campaignSpeechCut.e2e.spec.ts`): a página da fala renderiza "Cortes desta fala" com o título do corte aninhado; a biblioteca segue 200 para communicator e fail-closed para advisor/leader (já coberto hoje — o manifesto dispara pelo mesmo prefixo). **O manifest NÃO muda**: nav → `src/components/campaign/shell` já mapeia para `campaignPermissionProfileHttp` (`e2e-affected-manifest.mjs:290`); seção/lista/busca ficam em `(campaign)/.../comunicacao`, `src/components/campaign/speech`, `src/utilities/speech`, `src/lib/speech` (`:305-313`).
**Alternativas rejeitadas:** B porque nav/chrome são contratos puros baratos e a regressão do "não achei" é exatamente de navegação; C porque o CTA é um evento testável em unit e o e2e de browser exigiria player real sem ganho sobre o HTTP+unit.
**D7 — Mobile.**
Opções: A) subitens no sheet da sidebar para communicator + subitens no drawer "Mais" para staff, sem tocar nos 5 slots | B) novo slot no bottom nav | C) esconder a biblioteca no mobile.
**Recomendação: A** — o bottom nav é fixo em 5 itens (`nav.ts:138-146`; pin `campaignNav.unit.spec.ts:97-112`) e não muda. `communicator` **não é staff**, então `CampaignSidebar` monta no mobile (`CampaignSidebar.tsx:77`) e o sheet offcanvas mostra "Comunicação" com os `subItems` (D1). `coordinator`/`candidate` são staff: no mobile a sidebar não monta e `getCampaignOverflowNav` inclui "Comunicação" no drawer (`nav.ts:156-160`) → o drawer passa a renderizar o pai **e** os `subItems` indentados a partir da mesma estrutura. A seção da fala e o resultado aninhado já são responsivos (CENA 06 empilha; classes existentes).
**Alternativas rejeitadas:** B porque fura o pin dos 5 slots e o bottom nav é navegação primária, não atalho de vertical; C porque o pedido é justamente o contrário (tornar achável).
### Componentes / mudanças
**Constantes, nav e chrome:**
- `src/lib/campaignPaths.ts` — sem constante nova de rota; adicionar `campaignSpeechCutDetailHref(id)` (`${CAMPAIGN_COMMUNICATION_CORTES}/${id}`) e usá-lo em `SpeechCutLibraryCard.tsx:47` e no bloco aninhado — hoje o path do detalhe é hardcoded e passaria a ter 2+ call sites.
- `src/components/campaign/shell/nav.ts` — `CampaignNavSubItem = { title; href }`; `CampaignNavItem.subItems?`; `subItems` no item Comunicação de `staffNav:70` e `communicatorNav:98` → `[{ 'Acervo de falas', CAMPAIGN_COMMUNICATION_ACERVO }, { 'Biblioteca de cortes', CAMPAIGN_COMMUNICATION_CORTES }]`. Nenhum outro item ganha subitem.
- `src/components/campaign/shell/CampaignSidebar.tsx` — renderizar `item.subItems` com `SidebarMenuSub/SidebarMenuSubItem/SidebarMenuSubButton` (filho ativo com fundo branco + ring como no design), preservando os `children` de saved-filters (`:109-114`).
- `src/components/campaign/shell/CampaignBottomNav.tsx` — no drawer de overflow, renderizar pai + `subItems` indentados (mesma estrutura de `nav.ts`), fechando o drawer ao navegar.
- `src/lib/campaignPageChrome.ts` — **sem mudança** (catálogo `cortes:124-127` e regra `:293` corretos; ordem inalterada).
- `src/app/(campaign)/campanha/(app)/comunicacao/acervo/page.tsx` — mantém o CTA "Biblioteca de cortes" (produto recomendou manter como atalho redundante); a busca passa a renderizar cortes aninhados (D3).
**Domínio (utilities/server) — dono único, sem twinning:**
- `src/utilities/speech/speechCutPageData.ts` — `loadSpeechCutsForSpeech(payload, user, speechId): Promise<SpeechCutViewModel[]>` (D2); `loadSpeechCutsForSpeeches(payload, user, ids): Promise<Map<number, SpeechCutViewModel[]>>` (D3-A, precedente `loadSegmentsForSpeeches`); em B, `loadSpeechCutsMatchingQuery(payload, user, q)` com os cortes + `speech` a `depth: 1`.
- `src/utilities/speech/speechPageData.ts` — `loadSpeechAcervoPageData` pré-carrega o mapa de cortes dos ids da página e devolve `cutsBySpeech`; em B, funde as origens dos cortes que casaram (dedupe por `speech.id`).
**Componentes de UI (em `src/components/campaign/speech/`):**
- `SpeechCutsForSpeechSection.tsx` — cabeçalho "Cortes desta fala"; populado = lista (Título · Status · Duração · ação "Abrir corte") reusando `SpeechCutStatusBadge`; vazio = título "Nenhum corte desta fala ainda", descrição "Selecione um trecho no player para criar o primeiro corte." e CTA "Selecionar trecho".
- `SpeechExcerptCta.tsx` (client) — dispara `SPEECH_EXCERPT_REQUEST_EVENT` (contrato em `src/lib/speechExcerptSelection.ts`) e rola até o player (D4).
- `SpeechDetailPlayer.tsx` — `useEffect` de listener → `requestSelection()` (liga a seleção quando `null`); o `toggleSelection` interno e o `data-slot="speech-excerpt-toggle"` permanecem.
- Bloco compacto do corte aninhado (status + duração + título + "Abrir corte →"), reusado na seção da fala e no resultado da busca; a **fala** só lista e linka (edição segue no C168).
- `SpeechResultList.tsx`/`SpeechResultCard.tsx` — nova prop `cutsBySpeech: ReadonlyMap<number, readonly SpeechCutViewModel[]>`; o card renderiza "N corte(s) desta fala" + o bloco aninhado quando a fala tem ≥1 corte. Em B, card de origem mínimo (`SpeechCutOriginResultCard.tsx`) para a origem que aparece só por corte.
- `src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx` — nova seção `<SpeechCutsForSpeechSection>` **full-width após o grid** (`:208`), recebendo os cortes de `loadSpeechCutsForSpeech`.
**Migration / Access / Consent:**
- **Sem migration**, **sem collection**, **sem `Consent`** — nenhum schema muda.
- **Access:** inalterado. Gate `requireCampaignPageActor({ gate: 'speechCatalog' })` (`campaignPageActor.ts:74`) na página da fala e na busca (já existente); toda leitura nova roda `payload.find` com `user` + `overrideAccess: false` (a matriz do `canReadSpeechCut`, `access/speeches.ts:40-44`, é a fronteira).
- **UI:** Impeccable C (shape→craft→critique→polish) sobre as cenas novas/alteradas (nav + seção da fala + resultado aninhado); shells existentes (`CampaignPageShell`, `CampaignList*`) reusados, sem shell novo.
### Mapeamento das cenas do design
| Cena | Conteúdo | Componente |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| CENA 01 — nav subitems desktop | Comunicação com caret; "Acervo de falas" e "Biblioteca de cortes"; filho ativo com fundo branco + ring | `nav.ts` (`subItems`) + `CampaignSidebar` (`SidebarMenuSub*`) |
| CENA 02 — fala, "Cortes desta fala" populado | Título · Status · Duração · "Abrir corte" (4 estados) | `SpeechCutsForSpeechSection` + `SpeechCutStatusBadge` + `campaignSpeechCutDetailHref` |
| CENA 03 — fala, vazio | título + descrição + CTA "Selecionar trecho" | `SpeechCutsForSpeechSection` (empty) + `SpeechExcerptCta` |
| CENA 04 — busca, fala com corte aninhado | card da fala + "1 corte desta fala" + bloco do corte com "Abrir corte →" | `SpeechResultList`/`SpeechResultCard` (`cutsBySpeech`) + bloco aninhado |
| CENA 05 — busca, corte casa e fala não (opção B) | card de origem mínimo + nota honesta + corte correspondente | `SpeechCutOriginResultCard` + `loadSpeechCutsMatchingQuery` (**produto no GATE**) |
| CENA 06 — mobile | resultado aninhado + seção da fala empilhados | mesmos componentes (classes responsivas) + subitens no sheet/drawer (D7) |
| CENA 07 — biblioteca (destino) | glimpse do destino já existente (C168) | sem mudança de tela; alcançável pela nav (D1/D5) |
### Dados → forma
- **Fonte:** collection `speechCut` filtrada por `speech` (index); `depth: 0` na seção da fala (a origem é a página) e no aninhamento da busca (a origem é o card da fala); `depth: 1` **apenas** na query da opção B (para o card de origem mínimo). Sem agregação, sem KPI, sem gráfico.
- **DTO da seção:** `SpeechCutViewModel` (id, title, status + label, durationLabel, start/end) — `mediaUrl`/`youtubeVideoId` podem vir nulos a `depth: 0` e não são renderizados aqui.
- **DTO do aninhamento:** o mesmo `SpeechCutViewModel`, agrupado por `speech.id` no `Map` — o card da fala passa os cortes daquela fala.
- **Contagem honesta:** "N cortes" na seção; "N corte(s) desta fala" no aninhamento; sem percentual/taxa.
## Fases verificáveis
1. **Árvore de nav + subitens (D1/D5/D7):** `subItems` em `nav.ts`; render em `CampaignSidebar` e no drawer de `CampaignBottomNav`; helper `campaignSpeechCutDetailHref`. Prova: `pnpm test:unit` (pins de nav/chrome) + `pnpm gate:fast`.
2. **Seção da fala (D2/D4):** `loadSpeechCutsForSpeech` + `SpeechCutsForSpeechSection` (populado/vazio/CTA) + `SpeechExcerptCta`/bridge no player + a seção na página `acervo/[id]`. Prova: int novo em `speechCut.int.spec.ts` (loader depth-0 sem vazar `error`, ordenação, vazio, fail-closed) + `pnpm gate:fast`.
3. **Busca com aninhamento (D3-A):** `loadSpeechCutsForSpeeches` + `cutsBySpeech` em `speechPageData`/`loadSpeechAcervoPageData` + `SpeechResultList`/`Card` aninhando. Prova: int do mapa por fala + `pnpm gate:fast`.
4. **Opção B (D3, condicionada ao GATE):** `loadSpeechCutsMatchingQuery` + dedupe/merge + card de origem mínimo. Prova: int de dedupe (fala que já casou não duplica) e da origem que aparece só por corte; se produto deferir, a fase vira no-op e o núcleo permanece verde.
5. **E2E + polimento:** `campaignSpeechCut.e2e.spec.ts` (página da fala mostra "Cortes desta fala" com o corte; rotas internas fail-closed por papel — hoje já cobertas); Impeccable C sobre as cenas; changelog `docs/changelog/2026-09-16-c174.md`; `pnpm push`. Prova: `pnpm gate:fast` + e2e selecionado.
## Rabbit holes / Não escopo (engenharia)
- **Reusar `SpeechCutResultCard.tsx:33`** — hardcoda "Corte publicado" e é session-only; a seção/busca têm card próprio por status (`SpeechCutStatusBadge`). Não tocar.
- **`searchText`/índice/migração em `speechCut`** — fora; vira item próprio no gatilho de volume do C168 (~5–10 mil cortes).
- **Query no componente / novo domínio `utilities/speechCut/`** — fora; o dono é `utilities/speech/`.
- **Segunda lista de cortes na busca (grupo "Cortes")** — fora; só aninhamento sob a fala (opção C rejeitada).
- **Editar título/descrição dentro da fala ou da busca** — fora; a fala/busca só listam e linkam para o detalhe (C168).
- **`CampaignNavItem` com `icon` opcional / renderer paralelo da nav** — fora; a extensão é `subItems` aditivo e sem estado de expansão.
- **Rotas novas sob `/acervo/cortes` que mexam na ordem das regras de chrome** — fora; nenhuma regra muda.
- **Mudar o contrato público `/corte/<id>`** — intocável.
- **Reposicionar/editar os saved-filters (B18) na nav** — fora; só os `subItems` de Comunicação entram, os `children` atuais ficam iguais.
## Débitos triados (simplify 2026-09-16)
- **Já resolvido no simplify (não reabrir):** dois subitens de nav ativos ao mesmo tempo (o `/acervo` é prefixo do `/acervo/cortes`) → helper puro `activeCampaignSubItemHref` (mais específico vence) + unit; `buildSpeechTextWhere`/`CampaignNavSubItem` com export não usado (knip) → un-exportados; ramo morto `branches.length === 1` removido; `bg-white`/`hover:bg-white` fora dos tokens do tema → `bg-card`; CTA "Selecionar trecho" exibido quando o picker não existe (fala < 5 s) → prop `excerptSelectionAvailable` + helpers `excerptSelectionDuration`/`isExcerptSelectionAvailable` compartilhados com o player; `campaignSpeechCutDetailHref` migrado para o `SpeechCutLibraryCard`; guard/`initialExcerptRange` duplicados no player → `requestSelection` (`useCallback`); stub de `scrollIntoView` restaurado no teste; `searchText` selecionado condicionalmente (só com `q`); `aria-label` nos "Abrir corte" repetidos; `behavior:'smooth'` explícito ignorando reduced-motion removido (o CSS decide).
- **Registrado (Issue nova):** **C180/#1097** (depende de #1084) — `select` enxuto + DTO estreito nos loaders de corte, predicado único busca↔VM e teto/estratégia do `id in` da origem. Plano curto: `docs/plans/acervo-cortes-divida-pos-c174.md`.
- **Defer com gatilho:**
- **Sub-lista de subitens duplicada** (`CampaignSidebar` × `CampaignBottomNav`) — gatilho: um 3º consumidor real de `subItems` (hoje os dois consomem primitivos distintos: `SidebarMenuSub*` × `div`/`Link`).
- **`[data-slot="speech-player"]` literal entre o CTA e o player** — gatilho: um 2º scroller externo do player ou rename do slot (a falha só degrada o scroll).
- **Descartado:** rename `matchedTextSearch` (score 1); `OpenCutButton`/`countLabel`/`campaignSpeechCutDetailHref` com <3 call sites; seletor de teste `button[data-start-seconds="43"]` (pré-existente do player, C166/C167).
## Riscos e mitigação
- **Regressão de nav/chrome (o "não achei")**: unit pinando os `subItems` do communicator e o ativo do pai/filho; chrome da lista/detalhe pinado (a regra exata permanece antes da regex genérica).
- **Pin exato do `campaignNav.unit.spec.ts`**: a extensão é aditiva no nível de topo; se o pin exigir ajuste, é para **acrescentar** asserções de `subItems`, nunca afrouxar o `toEqual` dos hrefs de topo.
- **Duplicar fala na busca (opção B)**: dedupe por `speech.id` antes de adicionar card; teste int cobre a fala que já casou e recebe o corte sem virar card duplicado.
- **Card de origem fabricado (opção B)**: VM mínimo honesto, sem inventar `excerpt`/segmentos; se o dado não basta, mostra menos.
- **CTA não aciona o player**: contrato de evento nomeado + unit; o listener vive no dono do estado (`SpeechDetailPlayer`), sem duplicar a seleção.
- **Mobile sem a vertical**: communicator vê no sheet (não é staff); coordinator/candidate veem no drawer "Mais" — pinado no unit de overflow.
- **`toSpeechCutLibraryItemViewModel` a `depth: 0`**: não usar nessa superfície; `origin: null` ali seria dado faltante, não degradação — usar `toSpeechCutViewModel`.
- **Manifest/e2e**: manter arquivos nos prefixos já mapeados; nenhum prefixo novo → manifest inalterado.
## Aceite de engenharia
| Aceite (intenção) | Evidência |
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| "Comunicação" tem subitens visíveis "Acervo de falas" e "Biblioteca de cortes" | `nav.ts` (`subItems`) + `CampaignSidebar`/drawer; unit de nav |
| Biblioteca/edição a um clique de qualquer lugar da vertical | subitem de nav + CTA mantido no acervo; chrome "Cortes"; unit do chrome |
| Detalhe da fala mostra os cortes **daquela** fala (título, status, duração, link) | `loadSpeechCutsForSpeech` + `SpeechCutsForSpeechSection`; int do loader; smoke/e2e |
| Vazio honesto "Nenhum corte desta fala ainda" + descrição + CTA "Selecionar trecho" | empty state + `SpeechExcerptCta`; unit do bridge |
| Busca mostra cortes como subitens da fala, sem lista paralela | `loadSpeechCutsForSpeeches` + `cutsBySpeech`; int do aninhamento; e2e/rota |
| Corte casa e fala não casa aparece como origem (opção B) — **decisão de produto no GATE** | `loadSpeechCutsMatchingQuery` + card de origem; int de dedupe (fase 4 isolada) |
| Gate `speechCatalog`; `advisor`/`leader` fail-closed, sem regressão no C168 | página/loader com `user`+`overrideAccess:false`; int/e2e da matriz (existentes) |
| FK `SET NULL` degrada sem quebrar | seção usa só o corte; `origin` indisponível não é renderizado aqui |
| Nenhum internals de corte vaza; `/corte/<id>` intocado | `toSpeechCutViewModel` (sem `error`/`createdBy`); nenhuma mudança em rota pública |
| Sem collection/`Consent`/migration | nenhum schema alterado; aceite de escopo |
| Qualidade | `pnpm gate:fast` verde + `pnpm push`; changelog `docs/changelog/2026-09-16-c174.md` |
Invariantes respeitadas: Local API sempre com `user` + `overrideAccess: false`; leituras novas são single-collection (sem transação); pessoa/corte seguem seus donos (`Contact`/`speechCut`), sem cadastro paralelo; sem `Consent` novo; `leader`/`advisor` lockdown intocado; copy pt-BR e identificadores em inglês; **nenhuma migration**; dependências `lib → utilities → components → app`.
## Self-score (decision-quality ≥4)
1. **Decisões caras com rejeitadas — 5/5:** D1–D7 têm `Opções/Recomendação/Alternativas rejeitadas`; o que é caro de reverter (forma da árvore de nav, contrato do loader, merge da busca, ponte CTA↔player, chrome) está explicitado com o que não fazer.
2. **Cabe no appetite — 4/5:** reuso integral dos shells, `SpeechCutStatusBadge`, `toSpeechCutViewModel` e dos helpers existentes; o custo real é a UI Impeccable C (nav + seção + aninhamento) e a opção B, que fica isolada numa fase. Pode apertar cortando a fase 4 se o produto deferir B.
3. **Rabbit holes nomeados — 5/5:** `SpeechCutResultCard`, `searchText`/índice, query no componente, grupo "Cortes", edição inline na fala/busca, renderer paralelo de nav, rotas/ordem de chrome e contrato público estão fora, com gatilho onde aplicável.
4. **Depth check reusa donos — 5/5:** `nav.ts` é a fonte única dos dois consumidores (sem twinning); `utilities/speech/` é o dono do C167/C168; `requireCampaignPageActor`/`campaignPageChrome`/`SpeechCutStatusBadge`/`toSpeechCutViewModel` reusados; nenhum wrapper pass-through novo.
5. **Intenção de produto satisfeita — 5/5:** cada aceite literal (subitens, seção da fala, aninhamento na busca, vazio/CTA, gate) tem evidência; a única decisão aberta (opção B) fica marcada como **produto a confirmar no GATE**, sem inventar comportamento.

View file

@ -6,13 +6,16 @@ import { getPayload } from 'payload'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { SpeechCutsForSpeechSection } from '@/components/campaign/speech/SpeechCutsForSpeechSection'
import { SpeechDetailPlayer } from '@/components/campaign/speech/SpeechDetailPlayer'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
import { isExcerptSelectionAvailable } from '@/lib/speechExcerptSelection'
import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadSpeechCutsForSpeech } from '@/utilities/speech/speechCutPageData'
import { loadSpeechDetailPageData, SpeechNotFoundError } from '@/utilities/speech/speechPageData'
import { parseSpeechSeekSeconds } from '@/utilities/speech/speechViewModels'
@ -55,10 +58,13 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
const q = firstValue(query.q)
const initialSeconds = parseSpeechSeekSeconds(firstValue(query.t))
const view = await loadSpeechDetailPageData(payload, user, speechId, q).catch((error) => {
if (error instanceof SpeechNotFoundError) notFound()
throw error
})
const [view, cuts] = await Promise.all([
loadSpeechDetailPageData(payload, user, speechId, q).catch((error) => {
if (error instanceof SpeechNotFoundError) notFound()
throw error
}),
loadSpeechCutsForSpeech(payload, user, speechId),
])
const sourceUrl = view.officialTextUrl ?? view.youtubeUrl
@ -76,8 +82,11 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
</Link>
</Button>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_280px]">
<div className="min-w-0">
{/* C174 — mobile: the cuts section comes before the metadata aside
(finding a cut is the job here). Desktop keeps the artifact's
full-width section below the player/aside grid. */}
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-[minmax(0,1fr)_280px]">
<div className="order-1 min-w-0 lg:col-start-1 lg:row-start-1">
<h1 className="text-lg font-medium">{view.type ?? 'Fala'}</h1>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{view.speechAtLabel}</span>
@ -129,7 +138,17 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
) : null}
</div>
<aside className="space-y-4">
<div className="order-2 lg:col-span-2 lg:row-start-2">
<SpeechCutsForSpeechSection
cuts={cuts}
excerptSelectionAvailable={isExcerptSelectionAvailable(
view.durationSeconds,
view.segments,
)}
/>
</div>
<aside className="order-3 space-y-4 lg:col-start-2 lg:row-start-1">
{view.summary ? (
<section>
<h2 className="text-xs tracking-wide text-muted-foreground uppercase">

View file

@ -57,7 +57,7 @@ export default async function SpeechAcervoPage({ searchParams }: SpeechAcervoPag
<CampaignListResults>
{data.rows.length > 0 ? (
<SpeechResultList rows={data.rows} />
<SpeechResultList rows={data.rows} query={data.state.q} />
) : (
<CampaignListEmptyState
icon={SearchXIcon}

View file

@ -9,6 +9,7 @@ import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth'
import { PeopleNavSavedFilters } from '@/components/campaign/people/PeopleNavSavedFilters'
import { MunicipalityNavSavedFilters } from '@/components/campaign/shell/MunicipalityNavSavedFilters'
import {
activeCampaignSubItemHref,
getCampaignBottomNav,
getCampaignOverflowNav,
isCampaignNavActive,
@ -123,21 +124,49 @@ export const CampaignBottomNav = ({ user }: { user: CampaignUserShellView }) =>
<div className="flex flex-col gap-1">
{overflowNav.map((item) => {
const active = isCampaignNavActive(pathname, item.href)
const activeSubHref = item.subItems
? activeCampaignSubItemHref(pathname, item.subItems)
: null
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? 'page' : undefined}
onClick={() => setOverflowOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium text-foreground outline-none',
'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring',
active && 'bg-muted text-primary',
)}
>
<item.icon aria-hidden="true" className="size-5 shrink-0" />
<span>{item.title}</span>
</Link>
<div key={item.href} className="flex flex-col">
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
onClick={() => setOverflowOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium text-foreground outline-none',
'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring',
active && 'bg-muted text-primary',
)}
>
<item.icon aria-hidden="true" className="size-5 shrink-0" />
<span>{item.title}</span>
</Link>
{/* C174 — the nested destinations of the vertical, same
indented sub-list as the desktop sidebar. */}
{item.subItems?.length ? (
<div className="ml-5 flex flex-col gap-1 border-l border-border py-1 pl-3">
{item.subItems.map((sub) => {
const subActive = activeSubHref === sub.href
return (
<Link
key={sub.href}
href={sub.href}
aria-current={subActive ? 'page' : undefined}
onClick={() => setOverflowOpen(false)}
className={cn(
'flex items-center rounded-md px-3 py-2 text-sm text-muted-foreground outline-none',
'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring',
subActive && 'bg-card font-medium text-primary ring-1 ring-border',
)}
>
<span>{sub.title}</span>
</Link>
)
})}
</div>
) : null}
</div>
)
})}
</div>

View file

@ -11,6 +11,7 @@ import { PeopleNavSavedFilters } from '@/components/campaign/people/PeopleNavSav
import { CampaignUserAvatar } from '@/components/campaign/shared/CampaignUserAvatar'
import { MunicipalityNavSavedFilters } from '@/components/campaign/shell/MunicipalityNavSavedFilters'
import {
activeCampaignSubItemHref,
getCampaignNav,
getCampaignSecondaryNav,
isCampaignNavActive,
@ -28,11 +29,15 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from '@/components/ui/Sidebar'
import { Spinner } from '@/components/ui/Spinner'
import { clearLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { cn } from '@/lib/utils'
import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient'
import type { CampaignUserShellView } from '@/utilities/campaignUserProfile'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
@ -45,25 +50,61 @@ export type CampaignSidebarUser = CampaignUserShellView
const CampaignSidebarLink = ({
item,
isActive,
pathname,
onNavigate,
children,
}: {
item: CampaignNavItem
isActive: boolean
pathname: string
onNavigate: () => void
/** Row affordances below the link — B18's saved-filter sub-list (B124: always on). */
children?: ReactNode
}) => (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.href} onClick={onNavigate} aria-current={isActive ? 'page' : undefined}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
{children}
</SidebarMenuItem>
)
}) => {
// C174 — only the most specific sibling is active (`/acervo` is a prefix of
// `/acervo/cortes`). `isActive=false` on the button so our own active style
// (card + ring) is the only one applied.
const activeSubHref = item.subItems ? activeCampaignSubItemHref(pathname, item.subItems) : null
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.href} onClick={onNavigate} aria-current={isActive ? 'page' : undefined}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
{item.subItems?.length ? (
<SidebarMenuSub>
{item.subItems.map((sub) => {
const subActive = activeSubHref === sub.href
return (
<SidebarMenuSubItem key={sub.href}>
<SidebarMenuSubButton
asChild
isActive={false}
className={cn(
'text-muted-foreground hover:bg-card hover:text-foreground',
subActive && 'bg-card font-medium text-primary ring-1 ring-border',
)}
>
<Link
href={sub.href}
onClick={onNavigate}
aria-current={subActive ? 'page' : undefined}
>
<span>{sub.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
})}
</SidebarMenuSub>
) : null}
{children}
</SidebarMenuItem>
)
}
export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
const pathname = usePathname()
@ -104,6 +145,7 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
key={item.href}
item={item}
isActive={isCampaignNavActive(pathname, item.href)}
pathname={pathname}
onNavigate={closeMobileSidebar}
>
{item.href === MUNICIPALITY_NAV_HREF ? (
@ -129,6 +171,7 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
key={item.href}
item={item}
isActive={isCampaignNavActive(pathname, item.href)}
pathname={pathname}
onNavigate={closeMobileSidebar}
/>
))}

View file

@ -21,6 +21,8 @@ import {
import {
CAMPAIGN_AGENDA_HOME,
CAMPAIGN_COMMUNICATION_ACERVO,
CAMPAIGN_COMMUNICATION_CORTES,
CAMPAIGN_COMMUNICATION_HOME,
CAMPAIGN_CONTACTS_HOME,
CAMPAIGN_UPDATES_HREF,
@ -34,12 +36,33 @@ import {
import type { CampaignUser } from '@/payload-types'
import { canAccessSupporterArea } from '@/utilities/supporter/supporterUi'
/**
* C174 — a nested destination under a top-level item. No icon: the design
* indents plain text under the parent (same shape for the sidebar sub-list and
* the mobile overflow drawer, so `nav.ts` stays the single source).
*/
type CampaignNavSubItem = {
title: string
href: string
}
export type CampaignNavItem = {
title: string
href: string
icon: LucideIcon
subItems?: readonly CampaignNavSubItem[]
}
/**
* C174 — the communication vertical's two destinations, hung under its single
* top-level item. Naming the array keeps both consumers (sidebar and drawer)
* from spelling the list twice.
*/
const communicationSubItems: readonly CampaignNavSubItem[] = [
{ title: 'Acervo de falas', href: CAMPAIGN_COMMUNICATION_ACERVO },
{ title: 'Biblioteca de cortes', href: CAMPAIGN_COMMUNICATION_CORTES },
]
/**
* Named because the sidebar has to recognize this one item to hang B18's saved
* filters under it, and a third spelling that silently no-ops when it drifts is
@ -67,7 +90,12 @@ const staffNav: CampaignNavItem[] = [
{ title: 'Demandas', href: '/campanha/demandas', icon: InboxIcon },
{ title: 'Apoiadores', href: '/campanha/apoiadores', icon: UsersIcon },
{ title: 'Assessores', href: '/campanha/assessores', icon: UserCogIcon },
{ title: 'Comunicação', href: CAMPAIGN_COMMUNICATION_HOME, icon: MegaphoneIcon },
{
title: 'Comunicação',
href: CAMPAIGN_COMMUNICATION_HOME,
icon: MegaphoneIcon,
subItems: communicationSubItems,
},
]
/**
@ -95,7 +123,12 @@ const leaderNav: CampaignNavItem[] = [
* the vertical, through the staff nav below.
*/
const communicatorNav: CampaignNavItem[] = [
{ title: 'Comunicação', href: CAMPAIGN_COMMUNICATION_HOME, icon: MegaphoneIcon },
{
title: 'Comunicação',
href: CAMPAIGN_COMMUNICATION_HOME,
icon: MegaphoneIcon,
subItems: communicationSubItems,
},
]
export const getCampaignNav = (role: CampaignUser['role']): CampaignNavItem[] => {
@ -126,6 +159,19 @@ export const isCampaignNavActive = (pathname: string, href: string): boolean =>
return pathname === href || pathname.startsWith(`${href}/`)
}
/**
* C174 — the sub-item that owns `pathname` when siblings share a prefix
* (`/acervo` is a prefix of `/acervo/cortes`): only the most specific match is
* active, so the acervo and the library never light up together.
*/
export const activeCampaignSubItemHref = (
pathname: string,
subItems: NonNullable<CampaignNavItem['subItems']>,
): string | null =>
subItems
.filter((sub) => isCampaignNavActive(pathname, sub.href))
.sort((left, right) => right.href.length - left.href.length)[0]?.href ?? null
// The four primary destinations shown in the bottom nav (all except "Mais").
// Used to exclude them from the overflow drawer.
const bottomNavPrimaryHrefs: ReadonlySet<string> = new Set([

View file

@ -4,7 +4,7 @@ import Link from 'next/link'
import { CopyLinkButton } from '@/components/CopyLinkButton'
import { SpeechCutStatusBadge } from '@/components/campaign/speech/SpeechCutStatusBadge'
import { Button } from '@/components/ui/button'
import { CAMPAIGN_COMMUNICATION_CORTES } from '@/lib/campaignPaths'
import { campaignSpeechCutDetailHref } from '@/lib/campaignPaths'
import type { SpeechCutLibraryItemViewModel } from '@/lib/speechCut'
/**
@ -44,7 +44,7 @@ export const SpeechCutLibraryCard = ({ cut }: { cut: SpeechCutLibraryItemViewMod
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button asChild variant="outline" className="min-h-10">
<Link href={`${CAMPAIGN_COMMUNICATION_CORTES}/${cut.id}`}>
<Link href={campaignSpeechCutDetailHref(cut.id)}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
Abrir corte
</Link>

View file

@ -0,0 +1,115 @@
import { PlayIcon, ScissorsIcon } from 'lucide-react'
import Link from 'next/link'
import { SpeechCutStatusBadge } from '@/components/campaign/speech/SpeechCutStatusBadge'
import { SpeechExcerptCta } from '@/components/campaign/speech/SpeechExcerptCta'
import { Button } from '@/components/ui/button'
import { campaignSpeechCutDetailHref } from '@/lib/campaignPaths'
import { formatSpeechClock } from '@/lib/speechClock'
import type { SpeechCutViewModel } from '@/lib/speechCut'
const SECTION_TITLE = 'Cortes desta fala'
const SECTION_DESCRIPTION = 'Abra um corte existente para editar ou reaproveitar.'
const OpenCutButton = ({
cutId,
title,
className,
}: {
cutId: number
title: string
className?: string
}) => (
<Button asChild variant="outline" className={className}>
<Link href={campaignSpeechCutDetailHref(cutId)} aria-label={`Abrir corte: ${title}`}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
Abrir corte
</Link>
</Button>
)
/**
* C174 — "Cortes desta fala" on the speech detail: what was already cut from
* this speech, so nobody re-cuts it. Lists and links only — editing continues in
* the C168 library detail. The empty state points at the player's own picker
* (only when that picker exists — a speech under the 5 s minimum offers none).
*/
export const SpeechCutsForSpeechSection = ({
cuts,
excerptSelectionAvailable,
}: {
cuts: readonly SpeechCutViewModel[]
excerptSelectionAvailable: boolean
}) => {
if (cuts.length === 0) {
return (
<section aria-labelledby="speech-cuts" className="border-t pt-6">
<h2 id="speech-cuts" className="text-lg font-semibold tracking-tight">
{SECTION_TITLE}
</h2>
<div className="mt-4 flex min-h-52 flex-col items-center justify-center rounded-xl border px-6 py-8 text-center">
<div className="flex size-10 items-center justify-center rounded-full bg-muted">
<ScissorsIcon className="size-5" aria-hidden="true" />
</div>
<h3 className="mt-3 text-base font-medium">Nenhum corte desta fala ainda</h3>
<p className="mt-1 text-sm text-muted-foreground">
Selecione um trecho no player para criar o primeiro corte.
</p>
{excerptSelectionAvailable ? (
<div className="mt-4">
<SpeechExcerptCta />
</div>
) : null}
</div>
</section>
)
}
return (
<section aria-labelledby="speech-cuts" className="border-t pt-6">
<div>
<h2 id="speech-cuts" className="text-lg font-semibold tracking-tight">
{SECTION_TITLE}
</h2>
<p className="mt-1 text-sm text-muted-foreground">{SECTION_DESCRIPTION}</p>
</div>
<div className="mt-4 hidden overflow-hidden rounded-xl border md:block">
<div className="grid grid-cols-[minmax(0,1fr)_130px_76px_112px] gap-3 border-b bg-muted/60 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Título</span>
<span>Status</span>
<span>Duração</span>
<span className="sr-only">Ação</span>
</div>
{cuts.map((cut) => (
<div
key={cut.id}
className="grid grid-cols-[minmax(0,1fr)_130px_76px_112px] items-center gap-3 border-b px-4 py-3 last:border-b-0"
>
<p className="truncate text-sm font-medium">{cut.title}</p>
<SpeechCutStatusBadge status={cut.status} />
<span className="text-xs text-muted-foreground tabular-nums">
{formatSpeechClock(cut.durationSeconds)}
</span>
<OpenCutButton cutId={cut.id} title={cut.title} className="min-h-9" />
</div>
))}
</div>
<div className="mt-4 flex flex-col gap-3 md:hidden">
{cuts.map((cut) => (
<div key={cut.id} className="rounded-xl border p-4">
<div className="flex flex-wrap items-center gap-2">
<SpeechCutStatusBadge status={cut.status} />
<span className="text-xs text-muted-foreground tabular-nums">
{formatSpeechClock(cut.durationSeconds)}
</span>
</div>
<p className="mt-2 text-sm font-medium">{cut.title}</p>
<OpenCutButton cutId={cut.id} title={cut.title} className="mt-3 min-h-11 w-full" />
</div>
))}
</div>
</section>
)
}

View file

@ -1,7 +1,7 @@
'use client'
import { DownloadIcon, ExternalLinkIcon, FilmIcon, PlayIcon, ScissorsIcon } from 'lucide-react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import type { SpeechVodResolveResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/resolver-vod/types'
import { SpeechCutDialog } from '@/components/campaign/speech/SpeechCutDialog'
@ -15,9 +15,12 @@ import { Spinner } from '@/components/ui/Spinner'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import type { SpeechCutViewModel } from '@/lib/speechCut'
import {
excerptSelectionDuration,
extendRangeToSegment,
initialExcerptRange,
isExcerptSelectionAvailable,
MIN_EXCERPT_SECONDS,
SPEECH_EXCERPT_REQUEST_EVENT,
type ExcerptRange,
} from '@/lib/speechExcerptSelection'
import { buildSpeechExcerptYoutubeUrl } from '@/lib/speechShare'
@ -158,8 +161,8 @@ export const SpeechDetailPlayer = ({
activeStart ?? initialSeconds ?? 0,
)
: null
const selectionDuration = durationSeconds ?? segments.at(-1)?.endSeconds ?? null
const selectionAvailable = selectionDuration !== null && selectionDuration >= MIN_EXCERPT_SECONDS
const selectionDuration = excerptSelectionDuration(durationSeconds, segments)
const selectionAvailable = isExcerptSelectionAvailable(durationSeconds, segments)
const selecting = selection !== null
const showExcerptShare = selecting && Boolean(youtubeVideoId)
const showCutAction = selecting && vodResolvable
@ -252,15 +255,28 @@ export const SpeechDetailPlayer = ({
setActiveStart(active?.startSeconds ?? null)
}
/** Turns the picker on with the initial range — idempotent (keeps the current one). */
const requestSelection = useCallback(() => {
if (selectionDuration === null || selectionDuration < MIN_EXCERPT_SECONDS) return
setSelection((current) => current ?? initialExcerptRange(segments, selectionDuration))
}, [segments, selectionDuration])
const toggleSelection = () => {
if (selecting) {
setSelection(null)
return
}
if (selectionDuration === null || selectionDuration < MIN_EXCERPT_SECONDS) return
setSelection(initialExcerptRange(segments, selectionDuration))
requestSelection()
}
// C174 — the empty "Cortes desta fala" CTA lives outside the player. It only
// announces the intent; the state stays here, so the request turns the picker
// on (idempotent, unlike the toggle) and never overwrites an active range.
useEffect(() => {
window.addEventListener(SPEECH_EXCERPT_REQUEST_EVENT, requestSelection)
return () => window.removeEventListener(SPEECH_EXCERPT_REQUEST_EVENT, requestSelection)
}, [requestSelection])
// C162 keeps the transcript click seeking; C166 turns it into a phrase magnet
// while the explicit selection mode is on.
const onSegmentActivate = (index: number) => {

View file

@ -0,0 +1,28 @@
'use client'
import { ScissorsIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { SPEECH_EXCERPT_REQUEST_EVENT } from '@/lib/speechExcerptSelection'
/**
* C174 — the "Selecionar trecho" CTA of the empty "Cortes desta fala" block. It
* is rendered outside the player (the section is server-side), so it announces
* the intent and scrolls the player into view instead of owning a second copy
* of the selection state.
*/
export const SpeechExcerptCta = () => (
<Button
type="button"
className="min-h-11"
data-slot="speech-excerpt-cta"
onClick={() => {
window.dispatchEvent(new CustomEvent(SPEECH_EXCERPT_REQUEST_EVENT))
// No explicit `behavior`: the CSS `scroll-behavior` owns smooth/reduced-motion.
document.querySelector('[data-slot="speech-player"]')?.scrollIntoView({ block: 'start' })
}}
>
<ScissorsIcon data-icon="inline-start" aria-hidden="true" />
Selecionar trecho
</Button>
)

View file

@ -0,0 +1,81 @@
import Link from 'next/link'
import { SpeechCutStatusBadge } from '@/components/campaign/speech/SpeechCutStatusBadge'
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
import { campaignSpeechCutDetailHref } from '@/lib/campaignPaths'
import { formatSpeechClock } from '@/lib/speechClock'
import type { SpeechCutViewModel } from '@/lib/speechCut'
import { splitHighlightedParts } from '@/lib/speechHighlight'
const countLabel = (count: number): string =>
count === 1 ? '1 corte desta fala' : `${count} cortes desta fala`
/** Touch-sized outlined button on mobile; the compact text link on `md+`. */
const CutAction = ({ cut }: { cut: SpeechCutViewModel }) => (
<Link
href={campaignSpeechCutDetailHref(cut.id)}
aria-label={`Abrir corte: ${cut.title}`}
className="flex min-h-11 w-full shrink-0 items-center justify-center rounded-lg border border-border bg-card px-3 text-sm font-medium text-foreground underline-offset-4 hover:bg-muted md:min-h-0 md:w-fit md:justify-start md:border-0 md:bg-transparent md:px-0 md:text-primary md:hover:bg-transparent md:hover:underline"
>
Abrir corte →
</Link>
)
/**
* C174 — the cuts of a speech nested under its search result. A list that only
* links to the C168 detail (editing stays there): the acervo search answers
* "this speech already rendered a cut" without becoming a second library. The
* origin-only card (`showDescription`) mirrors the approved scene: the matched
* term highlighted and the cut description as the evidence.
*/
export const SpeechNestedCuts = ({
cuts,
caption,
query,
showDescription = false,
}: {
cuts: readonly SpeechCutViewModel[]
caption?: string
/** The search term, highlighted in the title. */
query?: string
/** CENA 05 — origin-only result: title + description, action below. */
showDescription?: boolean
}) => (
<div className="mt-4 border-l pl-4">
<p className="mb-2 text-xs font-medium text-muted-foreground">
{caption ?? countLabel(cuts.length)}
</p>
<ul className="m-0 flex list-none flex-col gap-2 p-0">
{cuts.map((cut) => (
<li key={cut.id} className="rounded-lg bg-muted/55 p-3 ring-1 ring-border">
<div className="flex flex-wrap items-center gap-2">
<SpeechCutStatusBadge status={cut.status} />
<span className="text-xs text-muted-foreground tabular-nums">
{formatSpeechClock(cut.durationSeconds)}
</span>
</div>
{showDescription ? (
<>
<p className="mt-2 text-sm font-medium">
<SpeechHighlightParts parts={splitHighlightedParts(cut.title, query ?? '')} />
</p>
{cut.description ? (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{cut.description}</p>
) : null}
<div className="mt-3">
<CutAction cut={cut} />
</div>
</>
) : (
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center md:justify-between md:gap-x-4">
<p className="text-sm font-medium">
<SpeechHighlightParts parts={splitHighlightedParts(cut.title, query ?? '')} />
</p>
<CutAction cut={cut} />
</div>
)}
</li>
))}
</ul>
</div>
)

View file

@ -3,6 +3,7 @@ import Link from 'next/link'
import type { ReactNode } from 'react'
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
import { SpeechNestedCuts } from '@/components/campaign/speech/SpeechNestedCuts'
import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
@ -35,19 +36,126 @@ const ActionLink = ({
href,
children,
variant,
fullWidthOnMobile = false,
}: {
href: string
children: ReactNode
variant: 'default' | 'outline' | 'ghost'
fullWidthOnMobile?: boolean
}) => (
<Button asChild variant={variant} className="min-h-11">
<Button
asChild
variant={variant}
className={fullWidthOnMobile ? 'min-h-11 w-full md:w-auto' : 'min-h-11'}
>
<a href={href} target="_blank" rel="noreferrer">
{children}
</a>
</Button>
)
export const SpeechResultCard = ({ speech }: { speech: SpeechListItemViewModel }) => {
const SpeechMetaLine = ({
speech,
originLabel = false,
}: {
speech: SpeechListItemViewModel
originLabel?: boolean
}) => (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{originLabel ? (
<>
<span>Fala de origem</span>
<MetaSeparator />
</>
) : null}
<span>{speech.speechAtLabel}</span>
{speech.type ? (
<>
<MetaSeparator />
<span>{speech.type}</span>
</>
) : null}
{speech.durationLabel ? (
<>
<MetaSeparator />
<span>{speech.durationLabel}</span>
</>
) : null}
{speech.presidingOfficer ? (
<>
<MetaSeparator />
<span>Presidiu: {speech.presidingOfficer}</span>
</>
) : null}
</div>
)
const SpeechActions = ({
speech,
className,
fullWidthOnMobile = false,
}: {
speech: SpeechListItemViewModel
className: string
fullWidthOnMobile?: boolean
}) => (
<div className={className}>
<Button asChild className={fullWidthOnMobile ? 'min-h-11 w-full md:w-auto' : 'min-h-11'}>
<Link href={speech.watchHref}>
<PlayIcon data-icon="inline-start" aria-hidden="true" />
{speech.matchKind === 'segment' ? 'Assistir no trecho' : 'Ver fala'}
</Link>
</Button>
{speech.sourceUrl ? (
<ActionLink href={speech.sourceUrl} variant="ghost" fullWidthOnMobile={fullWidthOnMobile}>
<ExternalLinkIcon data-icon="inline-start" aria-hidden="true" />
Abrir fonte
</ActionLink>
) : null}
</div>
)
export const SpeechResultCard = ({
speech,
query,
}: {
speech: SpeechListItemViewModel
/** C174 — the search term, highlighted on the nested cuts. */
query?: string
}) => {
// C174 (option B): a speech that surfaced only because a cut matched the term
// shows the origin note instead of an excerpt the speech does not contain, and
// the actions sit at the top right (approved scene 05).
if (!speech.matchedTextSearch) {
return (
<article className="rounded-xl border bg-card p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between md:gap-4">
<div className="min-w-0">
<SpeechMetaLine speech={speech} originLabel />
<p className="mt-2 text-sm text-muted-foreground">
A fala não contém o termo exato; ela aparece porque um corte vinculado corresponde à
busca.
</p>
</div>
<SpeechActions
speech={speech}
className="flex flex-col items-stretch gap-2 md:shrink-0 md:flex-row md:items-center"
fullWidthOnMobile
/>
</div>
{speech.cuts.length ? (
<SpeechNestedCuts
cuts={speech.cuts}
caption="Correspondência no corte"
query={query}
showDescription
/>
) : null}
</article>
)
}
const visibleTopics = speech.topics.slice(0, MAX_TOPIC_CHIPS)
const visibleScopes = speech.scopes.slice(0, MAX_SCOPE_CHIPS)
const visibleKeywords = speech.keywords.slice(0, MAX_KEYWORD_CHIPS)
@ -61,27 +169,7 @@ export const SpeechResultCard = ({ speech }: { speech: SpeechListItemViewModel }
return (
<article className="rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{speech.speechAtLabel}</span>
{speech.type ? (
<>
<MetaSeparator />
<span>{speech.type}</span>
</>
) : null}
{speech.durationLabel ? (
<>
<MetaSeparator />
<span>{speech.durationLabel}</span>
</>
) : null}
{speech.presidingOfficer ? (
<>
<MetaSeparator />
<span>Presidiu: {speech.presidingOfficer}</span>
</>
) : null}
</div>
<SpeechMetaLine speech={speech} />
<div className="mt-2">
{speech.thumbnailUrl ? (
@ -137,6 +225,8 @@ export const SpeechResultCard = ({ speech }: { speech: SpeechListItemViewModel }
</ActionLink>
) : null}
</div>
{speech.cuts.length ? <SpeechNestedCuts cuts={speech.cuts} query={query} /> : null}
</article>
)
}

View file

@ -1,10 +1,17 @@
import { SpeechResultCard } from '@/components/campaign/speech/SpeechResultCard'
import type { SpeechListItemViewModel } from '@/utilities/speech/speechViewModels'
export const SpeechResultList = ({ rows }: { rows: readonly SpeechListItemViewModel[] }) => (
export const SpeechResultList = ({
rows,
query,
}: {
rows: readonly SpeechListItemViewModel[]
/** C174 — the search term, highlighted on the nested cuts. */
query?: string
}) => (
<div className="flex flex-col gap-3">
{rows.map((row) => (
<SpeechResultCard key={row.id} speech={row} />
<SpeechResultCard key={row.id} speech={row} query={query} />
))}
</div>
)

View file

@ -28,4 +28,8 @@ export const CAMPAIGN_COMMUNICATION_ACERVO = '/campanha/comunicacao/acervo' as c
/** C168 — library of cuts made from the acervo (list + `/<id>` detail). */
export const CAMPAIGN_COMMUNICATION_CORTES = '/campanha/comunicacao/acervo/cortes' as const
/** C168/C174 — internal detail of one cut in the library (id is immutable). */
export const campaignSpeechCutDetailHref = (id: number): string =>
`${CAMPAIGN_COMMUNICATION_CORTES}/${id}`
export const CAMPAIGN_UPDATES_HREF = '/campanha/atualizacoes' as const

View file

@ -6,6 +6,14 @@
* the client controls only translate pointers/keys into these calls.
*/
/**
* C174 — the empty-state CTA of "Cortes desta fala" lives outside the player
* but must turn the picker on. The player owns the selection state, so the CTA
* only announces the intent on `window`; the name lives here (client-safe, no
* DOM) so the two ends cannot drift into a silent no-op.
*/
export const SPEECH_EXCERPT_REQUEST_EVENT = 'speech:excerpt-request'
export type ExcerptSegment = {
startSeconds: number
endSeconds: number
@ -20,6 +28,25 @@ export type ExcerptEdge = 'start' | 'end'
export const MIN_EXCERPT_SECONDS = 5
/**
* C174 — the duration the picker can work with: the stored duration, falling
* back to the last transcript segment's end. Single owner of the rule so the
* player and the empty "Cortes desta fala" block agree on availability.
*/
export const excerptSelectionDuration = (
durationSeconds: number | null | undefined,
segments: readonly ExcerptSegment[],
): number | null => durationSeconds ?? segments.at(-1)?.endSeconds ?? null
/** Whether the picker is offered at all (the speech holds the 5 s minimum). */
export const isExcerptSelectionAvailable = (
durationSeconds: number | null | undefined,
segments: readonly ExcerptSegment[],
): boolean => {
const duration = excerptSelectionDuration(durationSeconds, segments)
return duration !== null && duration >= MIN_EXCERPT_SECONDS
}
export const rangeDurationSeconds = (range: ExcerptRange): number =>
range.endSeconds - range.startSeconds

View file

@ -4,7 +4,9 @@ import type { Payload } from 'payload'
import {
toSpeechCutLibraryItemViewModel,
toSpeechCutViewModel,
type SpeechCutLibraryItemViewModel,
type SpeechCutViewModel,
} from '@/lib/speechCut'
import type { CampaignUser } from '@/payload-types'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
@ -67,6 +69,94 @@ export const loadSpeechCutAcervoPageData = async (
}
}
/**
* C174 — every cut made from one speech, newest first, for the "Cortes desta
* fala" section of the speech detail. Depth 0 is enough: the origin here IS the
* page, and the section renders title/status/duration only (`error` still feeds
* the honest `failureMessage`, never the raw cause). No page cap: the number of
* cuts per speech is bounded by human effort.
*/
export const loadSpeechCutsForSpeech = async (
payload: Payload,
user: CampaignUser,
speechId: number,
): Promise<SpeechCutViewModel[]> => {
const result = await payload.find({
collection: 'speechCut',
where: { speech: { equals: speechId } },
depth: 0,
limit: 0,
pagination: false,
sort: '-createdAt',
user,
overrideAccess: false,
})
return result.docs.map((cut) => toSpeechCutViewModel(cut))
}
/**
* C174 — every cut of every speech on the acervo page, grouped by speech id, so
* the result list nests them under the speech without a query per row. Same
* precedent as `loadSegmentsForSpeeches`; depth 0 keeps the origin as the id.
*/
export const loadSpeechCutsForSpeeches = async (
payload: Payload,
user: CampaignUser,
speechIds: readonly number[],
): Promise<Map<number, SpeechCutViewModel[]>> => {
const bySpeech = new Map<number, SpeechCutViewModel[]>()
if (speechIds.length === 0) return bySpeech
const result = await payload.find({
collection: 'speechCut',
where: { speech: { in: [...speechIds] } },
depth: 0,
limit: 0,
pagination: false,
sort: '-createdAt',
user,
overrideAccess: false,
})
for (const cut of result.docs) {
const speechId = typeof cut.speech === 'number' ? cut.speech : cut.speech?.id
if (typeof speechId !== 'number') continue
const list = bySpeech.get(speechId) ?? []
list.push(toSpeechCutViewModel(cut))
bySpeech.set(speechId, list)
}
return bySpeech
}
/**
* C174 (option B) — the origin speech ids of the cuts whose own title/description
* match `q`. The acervo search uses them to surface a speech through a cut even
* when the speech text itself does not contain the term.
*/
export const loadSpeechCutOriginSpeechIds = async (
payload: Payload,
user: CampaignUser,
q: string,
): Promise<number[]> => {
const result = await payload.find({
collection: 'speechCut',
where: buildSpeechCutListWhere({ page: 1, q }),
depth: 0,
limit: 0,
pagination: false,
user,
overrideAccess: false,
})
const ids = new Set<number>()
for (const cut of result.docs) {
const speechId = typeof cut.speech === 'number' ? cut.speech : cut.speech?.id
if (typeof speechId === 'number') ids.add(speechId)
}
return [...ids]
}
/** One cut with its origin and stored media, for the library detail page. */
export const loadSpeechCutDetailPageData = async (
payload: Payload,

View file

@ -31,7 +31,7 @@ const durationBucketWhere = (bucket: SpeechDurationBucket): Where => {
}
}
export const buildSpeechListWhere = (state: SpeechListState): Where => {
const buildSpeechFacetWhere = (state: SpeechListState): Where[] => {
const filters: Where[] = []
if (state.years?.length) filters.push({ year: { in: state.years } })
@ -45,13 +45,39 @@ export const buildSpeechListWhere = (state: SpeechListState): Where => {
const branch = collapseListWhereOrBranches(state.durations.map(durationBucketWhere))
if (branch) filters.push(branch)
}
return filters
}
const buildSpeechTextBranches = (q: string): Where[] => [
{ searchText: { like: normalizeForSearch(q) } },
{ keywords: { contains: q } },
]
/** The textual branch: normalized speech text OR a raw official keyword. */
const buildSpeechTextWhere = (q: string): Where => ({ or: buildSpeechTextBranches(q) })
export const buildSpeechListWhere = (state: SpeechListState): Where => {
const filters = buildSpeechFacetWhere(state)
if (state.q) filters.push(buildSpeechTextWhere(state.q))
return filters.length ? { and: filters } : {}
}
/**
* C174 (option B) — the acervo `where` widened to the origin speech of a
* matching cut: the page still paginates by SPEECH, so the origin ids are OR-ed
* into the textual branch (facets stay AND-ed over every row).
*/
export const buildSpeechListWhereIncludingCutOrigins = (
state: SpeechListState,
originSpeechIds: readonly number[],
): Where => {
const filters = buildSpeechFacetWhere(state)
if (state.q) {
filters.push({
or: [
{ searchText: { like: normalizeForSearch(state.q) } },
{ keywords: { contains: state.q } },
],
})
const branches = buildSpeechTextBranches(state.q)
if (originSpeechIds.length) branches.push({ id: { in: [...originSpeechIds] } })
filters.push({ or: branches })
}
return filters.length ? { and: filters } : {}

View file

@ -5,7 +5,11 @@ import type { Payload } from 'payload'
import type { CampaignUser, Speech } from '@/payload-types'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
import { loadMunicipalityLabelsByIds } from '@/utilities/loadNamesByIds'
import { buildSpeechListWhere } from '@/utilities/speech/speechListFilters'
import {
loadSpeechCutOriginSpeechIds,
loadSpeechCutsForSpeeches,
} from '@/utilities/speech/speechCutPageData'
import { buildSpeechListWhereIncludingCutOrigins } from '@/utilities/speech/speechListFilters'
import {
resolveSpeechListUrl,
speechPageSize,
@ -156,28 +160,33 @@ export const loadSpeechAcervoPageData = async (
): Promise<SpeechAcervoPageData> => {
const rawSearchParams = await searchParams
const canonicalUrl = resolveSpeechListUrl(rawSearchParams)
const state = canonicalUrl.state
// C174 (option B) — a speech also shows up when one of its cuts matches the
// term by title/description, even if the speech text itself does not.
const originSpeechIds = state.q ? await loadSpeechCutOriginSpeechIds(payload, user, state.q) : []
const result = await payload.find({
collection: 'speech',
depth: 0,
limit: speechPageSize,
page: canonicalUrl.state.page,
page: state.page,
sort: '-speechAt',
where: buildSpeechListWhere(canonicalUrl.state),
select: speechListSelect,
where: buildSpeechListWhereIncludingCutOrigins(state, originSpeechIds),
// C174 — the row only needs its normalized text to tell whether it matched
// the term itself (option B); the unsearched list does not pay for it.
select: state.q ? { ...speechListSelect, searchText: true } : speechListSelect,
user,
overrideAccess: false,
})
const resolvedUrl = resolveSpeechListUrl(rawSearchParams, result.totalPages)
const speeches = result.docs as Speech[]
const speechIds = speeches.map((speech) => speech.id)
const [segmentsBySpeech, filterOptions] = await Promise.all([
loadSegmentsForSpeeches(
payload,
user,
speeches.map((speech) => speech.id),
),
const [segmentsBySpeech, cutsBySpeech, filterOptions] = await Promise.all([
loadSegmentsForSpeeches(payload, user, speechIds),
loadSpeechCutsForSpeeches(payload, user, speechIds),
loadSpeechFilterOptions(payload, user),
])
@ -194,8 +203,9 @@ export const loadSpeechAcervoPageData = async (
toSpeechListItemViewModel({
speech,
segments: segmentsBySpeech.get(speech.id) ?? [],
query: canonicalUrl.state.q,
query: state.q,
municipalityLabels,
cuts: cutsBySpeech.get(speech.id) ?? [],
}),
),
state: resolvedUrl.state,

View file

@ -5,6 +5,7 @@
*/
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
import { formatSpeechClock, formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock'
import type { SpeechCutViewModel } from '@/lib/speechCut'
import type { SpeechExcerptSegment } from '@/lib/speechExcerpt'
import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
import {
@ -42,6 +43,8 @@ export type SpeechListRecord = {
presidingOfficer?: string | null
officialTextUrl?: string | null
youtubeUrl?: string | null
/** C174 — normalized search text; lets the row tell whether it matched `q`. */
searchText?: string | null
}
type SpeechListMatchKind = 'segment' | 'keyword' | 'fallback'
@ -62,6 +65,13 @@ export type SpeechListItemViewModel = {
thumbnailUrl: string | null
watchHref: string
sourceUrl: string | null
/** C174 — the cuts already made from this speech, newest first. */
cuts: SpeechCutViewModel[]
/**
* C174 — false when the speech surfaced only because one of its cuts matched
* the term; the card then swaps the excerpt for the honest origin note.
*/
matchedTextSearch: boolean
}
export type SpeechDetailSegmentViewModel = {
@ -176,16 +186,33 @@ export const parseSpeechSeekSeconds = (raw: string | undefined): number | null =
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null
}
type SpeechCuts = readonly SpeechCutViewModel[]
/**
* C174 — does the speech itself match `q`? Mirrors the textual `where` branch
* (`speechListFilters.buildSpeechTextBranches`): normalized `searchText` LIKE
* and raw keyword contains. Keep the two spellings in step.
*/
const speechMatchesText = (speech: SpeechListRecord, query: string | undefined): boolean => {
const trimmed = query?.trim()
if (!trimmed) return true
if (normalizeForSearch(speech.searchText ?? '').includes(normalizeForSearch(trimmed))) return true
return (speech.keywords ?? []).some((keyword) => keyword.includes(trimmed))
}
export const toSpeechListItemViewModel = ({
speech,
segments,
query,
municipalityLabels,
cuts = [],
}: {
speech: SpeechListRecord
segments: readonly SpeechSegmentRecord[]
query?: string
municipalityLabels: ReadonlyMap<number, string>
/** C174 — the cuts of this speech, nested in the result card. */
cuts?: SpeechCuts
}): SpeechListItemViewModel => {
const matchedSegment = pickMatchingSegment(segments, query)
const q = query?.trim()
@ -213,6 +240,8 @@ export const toSpeechListItemViewModel = ({
thumbnailUrl: youtubeThumbnailUrl(parseYoutubeVideoId(speech.youtubeUrl)),
watchHref: buildWatchHref(speech.id, matchedSegment, q),
sourceUrl: speech.officialTextUrl ?? speech.youtubeUrl ?? null,
cuts: [...cuts],
matchedTextSearch: speechMatchesText(speech, query),
}
}

View file

@ -344,3 +344,52 @@ test.describe('Acervo cut library (C168)', () => {
}
})
})
/**
* C174 — finding a cut without re-cutting: the library is reachable from the
* vertical nav, the speech detail lists its own cuts, the search nests them
* under the speech, and a cut matching the term surfaces its origin speech.
*/
test.describe('Acervo: finding cuts (C174)', () => {
test.beforeAll(async () => {
await seedTestUser()
})
test('reaches the library, the speech cuts and the nested/origin search results', async ({
campaign,
campaignRequest,
request,
}) => {
const headers = await adminHeaders(request, BASE_URL)
const speech = await createSpeech(campaign)
const marker = randomUUID().slice(0, 8)
const title = `Corte localizável ${marker}`
await createCut(request, headers, { speechId: speech.id, status: 'published', title })
const communicator = await campaign.fixtures.createCampaignUser('communicator')
const communicatorRequest = await campaignRequest(communicator, communicator.password)
const detail = await communicatorRequest.get(`/campanha/comunicacao/acervo/${speech.id}`)
expect(detail.status()).toBe(200)
const detailHtml = rendered(await detail.text())
expect(detailHtml).toContain('Cortes desta fala')
expect(detailHtml).toContain(title)
// C174 — the library sub-item now lives in the vertical nav on every page.
expect(detailHtml).toContain('/campanha/comunicacao/acervo/cortes')
// The speech matches "retomada" (its searchText) and shows its cut nested.
const nested = await communicatorRequest.get('/campanha/comunicacao/acervo?q=retomada')
expect(nested.status()).toBe(200)
const nestedHtml = rendered(await nested.text())
expect(nestedHtml).toContain(title)
expect(nestedHtml).toContain('corte desta fala')
// Option B: only the cut matches; the origin speech still appears.
const byCut = await communicatorRequest.get(`/campanha/comunicacao/acervo?q=${marker}`)
expect(byCut.status()).toBe(200)
const byCutHtml = rendered(await byCut.text())
expect(byCutHtml).toContain(title)
expect(byCutHtml).toContain('Fala de origem')
expect(byCutHtml).toContain(`/campanha/comunicacao/acervo/${speech.id}`)
})
})

View file

@ -50,10 +50,14 @@ import {
import {
loadSpeechCutAcervoPageData,
loadSpeechCutDetailPageData,
loadSpeechCutOriginSpeechIds,
loadSpeechCutsForSpeech,
loadSpeechCutsForSpeeches,
SpeechCutNotFoundError,
} from '@/utilities/speech/speechCutPageData'
import { startSpeechCutJobInBackground } from '@/utilities/speech/speechCutScheduler'
import { upsertSpeechBundle, type SpeechImportBundle } from '@/utilities/speech/speechImport'
import { loadSpeechAcervoPageData } from '@/utilities/speech/speechPageData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { speechBundleFixture } from '../helpers/speechBundleFixture'
@ -824,3 +828,89 @@ describe('speech cut library (C168)', () => {
).rejects.toBeInstanceOf(SpeechCutNotFoundError)
})
})
describe('speech cut discovery (C174)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('lists every cut of one speech and groups the batch by speech', async () => {
const speech = await createSpeech()
const otherSpeech = await createSpeech()
const older = await createCut({
speech,
startSeconds: 0,
endSeconds: 20,
title: 'Primeiro corte',
})
const newer = await createCut({
speech,
startSeconds: 30,
endSeconds: 50,
title: 'Segundo corte',
})
const unrelated = await createCut({
speech: otherSpeech,
startSeconds: 0,
endSeconds: 20,
title: 'Corte de outra fala',
})
const { communicator } = await createUsers()
const forSpeech = await loadSpeechCutsForSpeech(payload, communicator, speech)
expect(forSpeech.map((cut) => cut.id)).toEqual([newer, older])
expect(forSpeech.map((cut) => cut.id)).not.toContain(unrelated)
// Depth 0: the row carries no media/file internals.
expect(forSpeech[0]?.mediaUrl).toBeNull()
expect(forSpeech[0]?.publicPath).toBe(`/corte/${newer}`)
const grouped = await loadSpeechCutsForSpeeches(payload, communicator, [speech, otherSpeech])
expect(grouped.get(speech)?.map((cut) => cut.id)).toEqual([newer, older])
expect(grouped.get(otherSpeech)?.map((cut) => cut.id)).toEqual([unrelated])
expect((await loadSpeechCutsForSpeeches(payload, communicator, [])).size).toBe(0)
})
it('keeps the collection access on the section loader (advisor sees published only)', async () => {
const speech = await createSpeech()
await createCut({ speech, startSeconds: 0, endSeconds: 20, status: 'published' })
await createCut({ speech, startSeconds: 30, endSeconds: 50, status: 'unpublished' })
const { communicator, advisor } = await createUsers()
expect(await loadSpeechCutsForSpeech(payload, communicator, speech)).toHaveLength(2)
expect(await loadSpeechCutsForSpeech(payload, advisor, speech)).toHaveLength(1)
})
it('surfaces an origin speech through a matching cut, flagged as origin-only', async () => {
const marker = `reformatrib${randomUUID().slice(0, 8)}`
const speech = await createSpeech()
const cutId = await createCut({
speech,
startSeconds: 0,
endSeconds: 20,
status: 'published',
title: `Reforma ${marker}`,
})
const { communicator } = await createUsers()
expect(await loadSpeechCutOriginSpeechIds(payload, communicator, marker)).toEqual([speech])
const data = await loadSpeechAcervoPageData(payload, communicator, { q: marker })
const row = data.rows.find((candidate) => candidate.id === speech)
expect(row).toBeDefined()
expect(row?.matchedTextSearch).toBe(false)
expect(row?.cuts.map((cut) => cut.id)).toEqual([cutId])
})
it('flags a speech whose own text matches as a regular result', async () => {
const marker = `saudepublica${randomUUID().slice(0, 8)}`
await createSpeech({
segments: [{ startSeconds: 0, endSeconds: 2.7, text: `A saúde pública ${marker}` }],
})
const { communicator } = await createUsers()
const data = await loadSpeechAcervoPageData(payload, communicator, { q: marker })
const row = data.rows.find((candidate) => candidate.matchedTextSearch)
expect(row).toBeDefined()
expect(row?.cuts).toHaveLength(0)
})
})

View file

@ -3,6 +3,7 @@
import { describe, expect, it } from 'vitest'
import {
activeCampaignSubItemHref,
getCampaignBottomNav,
getCampaignNav,
getCampaignOverflowNav,
@ -10,6 +11,8 @@ import {
} from '@/components/campaign/shell/nav'
import {
CAMPAIGN_AGENDA_HOME,
CAMPAIGN_COMMUNICATION_ACERVO,
CAMPAIGN_COMMUNICATION_CORTES,
CAMPAIGN_COMMUNICATION_HOME,
CAMPAIGN_UPDATES_HREF,
} from '@/lib/campaignPaths'
@ -94,6 +97,63 @@ describe('communicator sidebar', () => {
})
})
describe('communication vertical sub-items (C174)', () => {
const expectedSubHrefs = [CAMPAIGN_COMMUNICATION_ACERVO, CAMPAIGN_COMMUNICATION_CORTES]
it('hangs the acervo and the cut library under Comunicação for the communicator', () => {
const [item] = getCampaignNav('communicator')
expect(item?.subItems?.map((sub) => sub.href)).toEqual(expectedSubHrefs)
expect(item?.subItems?.map((sub) => sub.title)).toEqual([
'Acervo de falas',
'Biblioteca de cortes',
])
})
it('carries the same sub-items for staff with the vertical, and none for advisor', () => {
const forRole = (role: 'coordinator' | 'candidate' | 'advisor') =>
getCampaignNav(role).find((item) => item.href === CAMPAIGN_COMMUNICATION_HOME)
for (const role of ['coordinator', 'candidate'] as const) {
expect(forRole(role)?.subItems?.map((sub) => sub.href)).toEqual(expectedSubHrefs)
}
expect(forRole('advisor')).toBeUndefined()
})
it('keeps the sub-items in the mobile overflow drawer (staff)', () => {
const overflow = getCampaignOverflowNav('coordinator')
expect(overflow.find((item) => item.href === CAMPAIGN_COMMUNICATION_HOME)?.subItems).toEqual(
expect.arrayContaining(expectedSubHrefs.map((href) => expect.objectContaining({ href }))),
)
})
it('activates only the most specific sibling (the acervo is a prefix of the cortes path)', () => {
const subItems = getCampaignNav('communicator')[0]!.subItems!
expect(activeCampaignSubItemHref(CAMPAIGN_COMMUNICATION_CORTES, subItems)).toBe(
CAMPAIGN_COMMUNICATION_CORTES,
)
expect(activeCampaignSubItemHref(`${CAMPAIGN_COMMUNICATION_CORTES}/42`, subItems)).toBe(
CAMPAIGN_COMMUNICATION_CORTES,
)
expect(activeCampaignSubItemHref(`${CAMPAIGN_COMMUNICATION_ACERVO}/42`, subItems)).toBe(
CAMPAIGN_COMMUNICATION_ACERVO,
)
expect(activeCampaignSubItemHref(CAMPAIGN_COMMUNICATION_ACERVO, subItems)).toBe(
CAMPAIGN_COMMUNICATION_ACERVO,
)
expect(activeCampaignSubItemHref('/campanha/quadro', subItems)).toBeNull()
})
it('keeps the parent item active on any path of the vertical', () => {
expect(isCampaignNavActive(CAMPAIGN_COMMUNICATION_CORTES, CAMPAIGN_COMMUNICATION_HOME)).toBe(
true,
)
expect(
isCampaignNavActive(`${CAMPAIGN_COMMUNICATION_ACERVO}/42`, CAMPAIGN_COMMUNICATION_HOME),
).toBe(true)
})
})
describe('mobile bottom nav', () => {
it('offers exactly five primary items to staff, zero to leaders', () => {
for (const role of ['coordinator', 'advisor', 'candidate'] as const) {

View file

@ -3,6 +3,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite
import { SpeechDetailPlayer } from '@/components/campaign/speech/SpeechDetailPlayer'
import { resetCampaignCoarsePointerForTests } from '@/lib/campaignCoarsePointer'
import { SPEECH_EXCERPT_REQUEST_EVENT } from '@/lib/speechExcerptSelection'
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
import { stubMatchMedia } from '../helpers/matchMedia'
@ -530,6 +531,38 @@ describe('SpeechDetailPlayer — C171-F1 keeps the transcript point across the s
})
})
describe('SpeechDetailPlayer — C174 excerpt request from outside the player', () => {
const announcement = () =>
act(() => {
window.dispatchEvent(new CustomEvent(SPEECH_EXCERPT_REQUEST_EVENT))
})
it('turns the picker on when the empty-state CTA announces the intent', () => {
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
expect(screen.queryByRole('slider', { name: 'Início do trecho' })).toBeNull()
announcement()
expect(screen.getByRole('slider', { name: 'Início do trecho' })).toBeDefined()
})
it('does not overwrite an active range', () => {
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
fireEvent.click(screen.getByRole('button', { name: /selecionar trecho/i }))
// Clicking the 43s phrase twice re-selects it alone: [43, 50].
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
fireEvent.click(document.querySelector('button[data-start-seconds="43"]')!)
const startSlider = () => screen.getByRole('slider', { name: 'Início do trecho' })
expect(startSlider().getAttribute('aria-valuenow')).toBe('43')
announcement()
expect(startSlider().getAttribute('aria-valuenow')).toBe('43')
})
})
describe('SpeechDetailPlayer — no-video quadrant', () => {
it('renders the unavailable block without retry or download', () => {
renderPlayer({ vodResolvable: false, sourceUrl: 'https://imagem.camara.leg.br/diario.pdf' })

View file

@ -0,0 +1,37 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SpeechExcerptCta } from '@/components/campaign/speech/SpeechExcerptCta'
import { SPEECH_EXCERPT_REQUEST_EVENT } from '@/lib/speechExcerptSelection'
const originalScrollIntoView = Element.prototype.scrollIntoView
afterEach(() => {
cleanup()
// jsdom has no scrollIntoView; the stub is restored so it cannot leak.
if (originalScrollIntoView) Element.prototype.scrollIntoView = originalScrollIntoView
else delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView
})
describe('SpeechExcerptCta (C174)', () => {
it('announces the excerpt intent and scrolls the player into view', () => {
const scrollIntoView = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
const listened = vi.fn()
window.addEventListener(SPEECH_EXCERPT_REQUEST_EVENT, listened)
render(
<>
<div data-slot="speech-player" />
<SpeechExcerptCta />
</>,
)
fireEvent.click(screen.getByRole('button', { name: /selecionar trecho/i }))
expect(listened).toHaveBeenCalledTimes(1)
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start' })
window.removeEventListener(SPEECH_EXCERPT_REQUEST_EVENT, listened)
})
})

View file

@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest'
import {
excerptSelectionDuration,
extendRangeToSegment,
initialExcerptRange,
isExcerptSelectionAvailable,
moveRangeEdge,
normalizeExcerptRange,
rangeDurationSeconds,
@ -174,4 +176,16 @@ describe('track geometry', () => {
it('derives the span between the edges', () => {
expect(rangeDurationSeconds({ startSeconds: 43, endSeconds: 130 })).toBe(87)
})
// C174 — the player and the empty "Cortes desta fala" block share this rule.
it('resolves the picker duration and availability from the stored duration or segments', () => {
expect(excerptSelectionDuration(252, SEGMENTS)).toBe(252)
expect(excerptSelectionDuration(null, SEGMENTS)).toBe(252)
expect(excerptSelectionDuration(null, [])).toBeNull()
expect(isExcerptSelectionAvailable(252, [])).toBe(true)
expect(isExcerptSelectionAvailable(null, SEGMENTS)).toBe(true)
expect(isExcerptSelectionAvailable(3, SEGMENTS)).toBe(false)
expect(isExcerptSelectionAvailable(null, [{ startSeconds: 0, endSeconds: 2 }])).toBe(false)
})
})

View file

@ -2,7 +2,10 @@
import { describe, expect, it } from 'vitest'
import { buildSpeechListWhere } from '@/utilities/speech/speechListFilters'
import {
buildSpeechListWhere,
buildSpeechListWhereIncludingCutOrigins,
} from '@/utilities/speech/speechListFilters'
import { parseSpeechListParams } from '@/utilities/speech/speechListUrl'
describe('buildSpeechListWhere', () => {
@ -76,3 +79,46 @@ describe('buildSpeechListWhere', () => {
})
})
})
describe('buildSpeechListWhereIncludingCutOrigins (C174)', () => {
it('matches the text branch or the origin speech of a matching cut', () => {
const where = buildSpeechListWhereIncludingCutOrigins(
parseSpeechListParams({ q: 'reforma' }),
[7, 9],
)
expect(where).toEqual({
and: [
{
or: [
{ searchText: { like: 'reforma' } },
{ keywords: { contains: 'reforma' } },
{ id: { in: [7, 9] } },
],
},
],
})
})
it('keeps the facets AND-ed and stays identical to the plain where without origins', () => {
const withFacet = buildSpeechListWhereIncludingCutOrigins(
parseSpeechListParams({ q: 'reforma', topic: ['saude'] }),
[7],
)
expect(withFacet).toEqual({
and: [
{ topics: { in: ['saude'] } },
{
or: [
{ searchText: { like: 'reforma' } },
{ keywords: { contains: 'reforma' } },
{ id: { in: [7] } },
],
},
],
})
const state = parseSpeechListParams({ q: 'reforma' })
expect(buildSpeechListWhereIncludingCutOrigins(state, [])).toEqual(buildSpeechListWhere(state))
})
})