mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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
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:
parent
01be1de7be
commit
f066bee92b
28 changed files with 1339 additions and 86 deletions
1
docs/changelog/2026-09-16-c174.md
Normal file
1
docs/changelog/2026-09-16-c174.md
Normal 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.
|
||||
40
docs/plans/acervo-cortes-divida-pos-c174.md
Normal file
40
docs/plans/acervo-cortes-divida-pos-c174.md
Normal 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.
|
||||
223
docs/plans/acervo-encontrar-cortes-impl.md
Normal file
223
docs/plans/acervo-encontrar-cortes-impl.md
Normal 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.
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
115
src/components/campaign/speech/SpeechCutsForSpeechSection.tsx
Normal file
115
src/components/campaign/speech/SpeechCutsForSpeechSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
28
src/components/campaign/speech/SpeechExcerptCta.tsx
Normal file
28
src/components/campaign/speech/SpeechExcerptCta.tsx
Normal 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>
|
||||
)
|
||||
81
src/components/campaign/speech/SpeechNestedCuts.tsx
Normal file
81
src/components/campaign/speech/SpeechNestedCuts.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } : {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
37
tests/unit/speechExcerptCta.unit.spec.tsx
Normal file
37
tests/unit/speechExcerptCta.unit.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue