From f066bee92bfce7bc7c09d144b0f66124e496c821 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Wed, 16 Sep 2026 12:29:13 -0300 Subject: [PATCH] =?UTF-8?q?feat(C174):=20encontrar=20cortes=20=E2=80=94=20?= =?UTF-8?q?navega=C3=A7=C3=A3o,=20na=20fala=20e=20na=20busca=20(Closes=20#?= =?UTF-8?q?1084)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-09-16-c174.md | 1 + docs/plans/acervo-cortes-divida-pos-c174.md | 40 ++++ docs/plans/acervo-encontrar-cortes-impl.md | 223 ++++++++++++++++++ .../(app)/comunicacao/acervo/[id]/page.tsx | 33 ++- .../(app)/comunicacao/acervo/page.tsx | 2 +- .../campaign/shell/CampaignBottomNav.tsx | 57 +++-- .../campaign/shell/CampaignSidebar.tsx | 65 ++++- src/components/campaign/shell/nav.ts | 50 +++- .../campaign/speech/SpeechCutLibraryCard.tsx | 4 +- .../speech/SpeechCutsForSpeechSection.tsx | 115 +++++++++ .../campaign/speech/SpeechDetailPlayer.tsx | 26 +- .../campaign/speech/SpeechExcerptCta.tsx | 28 +++ .../campaign/speech/SpeechNestedCuts.tsx | 81 +++++++ .../campaign/speech/SpeechResultCard.tsx | 136 +++++++++-- .../campaign/speech/SpeechResultList.tsx | 11 +- src/lib/campaignPaths.ts | 4 + src/lib/speechExcerptSelection.ts | 27 +++ src/utilities/speech/speechCutPageData.ts | 90 +++++++ src/utilities/speech/speechListFilters.ts | 40 +++- src/utilities/speech/speechPageData.ts | 32 ++- src/utilities/speech/speechViewModels.ts | 29 +++ tests/e2e/campaignSpeechCut.e2e.spec.ts | 49 ++++ tests/int/speechCut.int.spec.ts | 90 +++++++ tests/unit/campaignNav.unit.spec.ts | 60 +++++ tests/unit/speechDetailPlayer.unit.spec.tsx | 33 +++ tests/unit/speechExcerptCta.unit.spec.tsx | 37 +++ .../unit/speechExcerptSelection.unit.spec.ts | 14 ++ tests/unit/speechListFilters.unit.spec.ts | 48 +++- 28 files changed, 1339 insertions(+), 86 deletions(-) create mode 100644 docs/changelog/2026-09-16-c174.md create mode 100644 docs/plans/acervo-cortes-divida-pos-c174.md create mode 100644 docs/plans/acervo-encontrar-cortes-impl.md create mode 100644 src/components/campaign/speech/SpeechCutsForSpeechSection.tsx create mode 100644 src/components/campaign/speech/SpeechExcerptCta.tsx create mode 100644 src/components/campaign/speech/SpeechNestedCuts.tsx create mode 100644 tests/unit/speechExcerptCta.unit.spec.tsx diff --git a/docs/changelog/2026-09-16-c174.md b/docs/changelog/2026-09-16-c174.md new file mode 100644 index 00000000..aa1f31e5 --- /dev/null +++ b/docs/changelog/2026-09-16-c174.md @@ -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/` 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. diff --git a/docs/plans/acervo-cortes-divida-pos-c174.md b/docs/plans/acervo-cortes-divida-pos-c174.md new file mode 100644 index 00000000..252949c1 --- /dev/null +++ b/docs/plans/acervo-cortes-divida-pos-c174.md @@ -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. diff --git a/docs/plans/acervo-encontrar-cortes-impl.md b/docs/plans/acervo-encontrar-cortes-impl.md new file mode 100644 index 00000000..243d9608 --- /dev/null +++ b/docs/plans/acervo-encontrar-cortes-impl.md @@ -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/` 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 `` 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` 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` (D2); `loadSpeechCutsForSpeeches(payload, user, ids): Promise>` (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`; 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 `` **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/`** — 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/` 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. diff --git a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx index 51bb32a6..2c0c25a5 100644 --- a/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/comunicacao/acervo/[id]/page.tsx @@ -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 -
-
+ {/* 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. */} +
+

{view.type ?? 'Fala'}

{view.speechAtLabel} @@ -129,7 +138,17 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD ) : null}
-