feat(C166): seleciona trecho do acervo e compartilha link no ponto + WhatsApp (#1013)

This commit is contained in:
Francisco Solla 2026-09-15 07:31:34 -03:00
parent 0c0725835b
commit 93416cd5ef
16 changed files with 1348 additions and 34 deletions

View file

@ -0,0 +1 @@
**C166 — Acervo: selecionar trecho e compartilhar por link (YouTube no ponto + WhatsApp) (2026-09-15):** o detalhe da fala agora tem o modo explícito **"Selecionar trecho"** (o clique-para-posicionar do C162 segue intacto fora dele): um clique nas frases marca os limites com ímã nos segmentos de transcrição, as duas alças (`role="slider"`, setas ±1 s, PageUp/Down ±5 s, Home/End, ponteiro com `setPointerCapture`) fazem o ajuste fino, e a barra mostra início/fim/duração em `mm:ss` com o mínimo de 5 s e o teto de 180 s. O botão **"Compartilhar"** abre exatamente duas opções — **"Copiar link"** (mensagem completa com o intervalo + URL, feedback "Link copiado" com reset de 2 s e live region) e **"Enviar no WhatsApp"** (`wa.me/?text=`, sem destinatário, nova aba) — em Popover no ponteiro fino e bottom sheet no coarse (`useCoarsePointer`). O link é `https://www.youtube.com/watch?v=<id>&t=<offset da sessão + início, segundos inteiros>`; o fim vai na mensagem (`Trecho de <tipo|fala> (<dd/mm/aaaa>): de <mm:ss> a <mm:ss>`), porque o YouTube não codifica o fim. Sem YouTube a opção de link não é oferecida — aviso honesto no lugar, com a seleção preservada (porta do corte do C167) e MP4/fonte intactos; com offset desconhecido o link vai sem `t=` e a nota diz que abre no início da sessão. Toda a lógica é pura e client-safe: `src/lib/speechExcerptSelection.ts` (ímã, clamps 5 s/180 s, px→segundos), `src/lib/speechShare.ts` (URL + mensagem + `wa.me`) e `src/lib/speechClock.ts`, que passa a ser o dono de `formatSpeechClock`/`formatSpeechSpan` (o VM do detalhe ganhou `durationSeconds` e `speechDateLabel`). Sem migration, sem `Consent`, sem server action, sem escrita; `advisor`/`leader` seguem negados e o crédito CC BY não mudou. Testes: 32 unit puros (geometria/seleção e share), 19 RTL do player (9 pins C162 + modo, teclado, drag com ímã, copiar/WhatsApp, coarse, reset do feedback) e o e2e browserless `campaignSpeechAcervo` com as asserções SSR do novo contrato.

View file

@ -0,0 +1,98 @@
# Impl: Acervo: selecionar trecho e compartilhar por link (YouTube no ponto + WhatsApp)
Status: em execução
Atualizado em: 2026-09-15
Issue: #1013
Intenção: docs/plans/c166-compartilhar-trecho-link.md
Appetite restante: ~1–1,5 dia (herdado; sem migration, sem server action, sem escrita)
## Leitura da intenção
- **Outcome:** marcar [início, fim] no detalhe do acervo (ímã de frase, 5 s–180 s, duração visível) e compartilhar um link do YouTube que abre no ponto + mensagem com o intervalo — via copiar (feedback "Link copiado") ou `wa.me/?text=` do remetente.
- **O que NÃO negociar:** nenhum link autenticado do acervo para fora; sem escrita/`Consent`/collection/migration; `advisor`/`leader` negados; crédito CC BY e o clique-para-posicionar C162 intactos; exatamente 2 opções de share; sem YouTube → sem link + aviso honesto (seleção continua); offset desconhecido → link sem `t=` (nunca inventar).
- **O que reavaliar:** a intenção sugere `utilities/speech` para a lógica pura — o módulo puro vai para `src/lib/speech*` (casa com o prefixo do manifesto e não carrega Payload); `formatSpeechClock` muda de dono para o layer puro (hoje em `speechViewModels.ts`); o VM precisa de campos crus (`durationSeconds`, `speechDateLabel`) que a intenção não listava.
## Abordagem recomendada
```mermaid
flowchart LR
VM["VM do servidor<br/>youtubeVideoId/offset, segments,<br/>durationSeconds, speechDateLabel"] --> P["SpeechDetailPlayer<br/>modo + range (segundos)"]
P --> C["SpeechExcerptControls<br/>track + alças + teclado"]
C -->|clique-ímã| T["Transcrição<br/>marca limites no modo"]
C -->|range| S["SpeechExcerptShare<br/>Popover fino / Sheet coarse"]
S -->|Copiar link| L["speechShare.ts<br/>URL + mensagem"]
S -->|Enviar no WhatsApp| W["buildWhatsAppTextShareUrl"]
L --> Y["youtube.com/watch?v=..&t=.."]
W --> A["wa.me/?text=Trecho de ... + URL"]
```
**Opções consideradas:** A) lógica pura em `src/lib/speech*` + estado no player existente + 2 componentes irmãos | B) tudo dentro de `SpeechDetailPlayer.tsx` | C) componente/player próprio do trecho.
**Recomendação:** A — geometria e composição de link/mensagem unit-testáveis sem DOM; o player segue sendo o único (anti-goal); o contrato C162 fica atrás de um modo explícito.
**Rejeitadas:** B porque o arquivo já tem 381 linhas e a geometria ficaria refém do jsdom; C porque é o rabbit hole "segundo player" que a intenção corta.
### Componentes / mudanças
- **`src/lib/speechClock.ts`** (novo): dono de `formatSpeechClock` (movido de `speechViewModels.ts`, que passa a importá-lo; `src/utilities/ai/tools/findSpeechExcerpts.ts:22` troca o import). Sem re-export-twin: dono único, 2 call sites atualizados. Necessário porque `src/lib` é proibido de importar `@/utilities/**` e o share precisa do mesmo mm:ss.
- **`src/lib/speechExcerptSelection.ts`** (novo, puro): tipos estruturais `ExcerptSegment`/`ExcerptRange` (sem importar o VM); `MIN_EXCERPT_SECONDS = 5`, `MAX_EXCERPT_SECONDS = 180`; `selectSegmentRange(segments, index, durationSeconds)` (frase inteira; se a frase tiver < 5 s, estende até 5 s respeitando a duração; cap 180; ASR fracionário snap por `floor`/`ceil`), `extendRangeToSegment(range, segments, index, durationSeconds)` (estende para antes/depois; clique dentro/sobreposto re-seleciona a frase), `moveRangeEdge(range, edge, nextSeconds, durationSeconds)` (alça/teclado com min 5 s e max 180 s), `secondsFromTrackRatio(ratio, durationSeconds)`, `rangeDurationSeconds(range)`, `initialExcerptRange(segments, durationSeconds)`.
- **`src/lib/speechShare.ts`** (novo, puro): `buildSpeechExcerptShare({ videoId, offsetSeconds, startSeconds, endSeconds, speechType, dateLabel })` → `{ url, message, whatsAppUrl }`; `url` = `https://www.youtube.com/watch?v=<id>&t=<Math.floor(offset+start)>` (sem `t` quando `offsetSeconds === null`); `message` literal `Trecho de <tipo|"fala"> (<dd/mm/aaaa>): de <mm:ss> a <mm:ss> <url>`; `whatsAppUrl` reusa `buildWhatsAppTextShareUrl` (`src/lib/phone.ts:63`).
- **`speechViewModels.ts`**: `durationSeconds: number | null` e `speechDateLabel: string` no `SpeechDetailViewModel`; `formatSpeechDate` (mesmo regex de `formatSpeechAt`, só `dd/mm/aaaa`); sem `endLabel` (o controle formata `endSeconds` com `formatSpeechClock` — um campo a mais seria dado morto). Importa `formatSpeechClock` de `@/lib/speechClock`.
- **`[id]/page.tsx`**: passa `durationSeconds`, `speechType={view.type}` e `speechDateLabel` ao player.
- **`SpeechDetailPlayer.tsx`**: props novas; estado `selecting` e `range`; toggle "Selecionar trecho" (`aria-pressed`) na linha de ações; ao ligar o modo, inicializa `range` com `initialExcerptRange(segments, effectiveDuration)`; transcrição muda em modo seleção (`onClick` marca em vez de `seekTo`; `disabled={!seekable && !selecting}` — sem isso a seleção morreria no quadrante sem YouTube/sem offset); copy da transcrição vira "clique para marcar o trecho" no modo; renderiza os 2 irmãos novos; aviso honesto quando `!youtubeVideoId`. Fora do modo, `onClick`/`seekTo` ficam byte a byte como hoje (C162).
- **`SpeechExcerptControls.tsx`** (novo, client): barra com trilha própria e 2 handles `role="slider"` (`aria-valuemin/max/now/valuetext`, labels "Início do trecho"/"Fim do trecho"); pointer events + `setPointerCapture` com ímã nos limites de segmento (tolerância em px) e ajuste fino; teclado setas ±1 s, PageUp/Down ±5 s, Home/End; mostra início/fim/duração e a dica "mínimo 5s · máximo 3min"; chama só as funções puras.
- **`SpeechExcerptShare.tsx`** (novo, client): botão "Compartilhar" (desabilitado sem range válido — min 5 s); `useCoarsePointer()` → `Popover` (fino) ou `Sheet` (`SheetContent`, coarse); opções exatas "Copiar link" (estado local `idle|copied|error`, reset 2 s, live region sr-only — padrão `ContentShareButton`, classes do tema campaign, sem `--pt-red`) e "Enviar no WhatsApp" (`<a target="_blank" rel="noopener noreferrer">`); preview da mensagem; nota "abre no início da sessão" quando `youtubeOffsetSeconds === null`. "Copiar link" copia a **mensagem completa** (intervalo + URL) — o fim não cabe no `t=`, então o texto é o que circula.
- **Migration:** nenhuma. **Access/Consent:** nenhum; o gate `speechCatalog` do detalhe segue sendo a única barreira (advisor/leader intactos).
- **UI:** Impeccable B — tema `data-theme="campaign"`; reusa `Button`, `Popover`, `Sheet`, `useCoarsePointer`; copy do rascunho; nenhum shell novo.
### Dados → forma
Não se aplica: a intenção já decidiu "não apresenta dados" — duração/mm:ss são feedback de ação, não dado do acervo.
## Fases verificáveis
1. **Núcleo puro + VM** (~0,5 dia): mover `formatSpeechClock`, criar `speechExcerptSelection.ts`/`speechShare.ts`, campos do VM + wiring da página; testes puros verdes.
2. **UI** (~0,5 dia): modo no player + `SpeechExcerptControls` + `SpeechExcerptShare` (Popover/Sheet) + aviso sem-YouTube; RTL estendido.
3. **Gates** (~0,25 dia): `pnpm gate:fast`; e2e browserless com asserções SSR novas; `pnpm push`; CI roda o e2e `campaignSpeechAcervo` (manifest já mapeia `src/lib/speech`, component/speech e `utilities/speech`).
## Plano de testes
- **Unit puro** `tests/unit/speechExcerptSelection.unit.spec.ts` + `tests/unit/speechShare.unit.spec.ts` (jsdom irrelevante): snap de segmentos fracionários; frase inteira; frase < 5 s estendida até 5 s; cap 180 em frase longa; estender para trás/frente; clique dentro re-seleciona; min 5 s/max 180 s nas alças; deltas de teclado; Home/End; px→segundos (round/clamp); fallback de duração `segments.at(-1)?.endSeconds`; URL com `t` inteiro e sem `t` no offset desconhecido; mensagem com tipo e fallback "fala"; igualdade com `buildWhatsAppTextShareUrl`.
- **Unit RTL** `tests/unit/speechDetailPlayer.unit.spec.tsx` (estender): manter os 9 pins C162 (4 VOD + 5 YouTube) e o de no-video; novos casos — alternar modo seleção não regride o seek fora dele; em modo, clique em `button[data-start-seconds="43"]` marca (não mexe no `video.currentTime`/iframe); botões de transcrição habilitam mesmo sem `seekable`; alças por teclado mudam os labels; 180 s capado aparece no label de duração; "Copiar link" com `navigator.clipboard` mockado (precedente `campaignInviteInteractions.unit.spec.ts`) → texto = mensagem + URL e feedback "Link copiado" (reset 2 s); href do WhatsApp contém `wa.me/?text=` com o texto codificado; sem YouTube → sem "Compartilhar" + aviso; offset `null` → link sem `t=` + nota. Popover radix em jsdom tem precedente (`campaignHeaderFilterPopover.unit.spec.ts`).
- **E2E:** `tests/e2e/campaignSpeechAcervo.e2e.spec.ts` (browserless) segue o contrato HTTP existente e ganha asserções SSR baratas do novo contrato: detalhe com YouTube contém o toggle "Selecionar trecho" e `data-slot="speech-excerpt-controls"`; detalhe só-VOD contém o aviso "Compartilhar por link exige o vídeo no YouTube" e **não** contém o botão "Compartilhar". Sem novo browser e2e — a interação é dona do jsdom RTL; um spec de browser só adicionaria flake/custo sem novo contrato de servidor.
## Rabbit holes / Não escopo (engenharia)
- IFrame API/`window.YT`/postMessage/enablejsapi — não existe no repo; playhead/pause do YouTube ficam fora (seleção independente do player, sem seek no YouTube além do reload de `start`).
- Preview do trecho, loop, zoom/linha de tempo frame-a-frame, drag livre sobre a transcrição.
- Persistir seleção na URL (`?t=&e=`), telemetria/UTM, encurtador, página pública/OG (C167/C168).
- Extrair a transcrição para outro componente (churn no contrato pinado), segundo player, toast sonner, fallback `document.execCommand`.
## Riscos e mitigação
- **Regressão do clique C162:** modo explícito + pins existentes rodando a cada mudança; fora do modo o `onClick` é o atual.
- **Seleção impossível sem seek:** `disabled` passa a considerar `selecting`; teste dedicado no quadrante sem YouTube.
- **Frase de 4 s (fixture e2e) vs mínimo de 5 s:** `initialExcerptRange`/`selectSegmentRange` estendem até 5 s dentro da duração; nunca nasce range inválido.
- **Clipboard indisponível (contexto inseguro):** catch → "Não foi possível copiar" + live region, sem crash (padrão ContentShareButton).
- **Offset/duração ausentes:** sem `t=`; duração cai para `segments.at(-1)?.endSeconds` e, se < 5 s, o controle de seleção não renderiza (seleção indisponível honesta).
- **ASR fracionário:** âncoras `floor`/`ceil`, alças em inteiros, `t` com `Math.floor` — fixtures fracionárias nos testes.
- **Popover clipado no mobile:** Sheet em ponteiro coarse (precedente `CampaignHomeActionStrip`).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto (5 s–180 s com ímã; 2 opções; `t=` = offset + início; mensagem com intervalo; sem YouTube → sem link + aviso; seleção preservada).
- [ ] Invariantes AGENTS/engineering-standards (sem migration/Consent/write; `src/lib` sem importar utilities; labels pt-BR; identificadores em inglês).
- [ ] Contrato C162 pinado verde (9 pins) + e2e browserless atualizado; `pnpm gate:fast` limpo.
## Decisões de engenharia
- **A) Lógica pura.** Opções: `src/lib/speechShare.ts` + `src/lib/speechExcerptSelection.ts` | `utilities/speech` | dentro do componente. **Recomendação:** `src/lib/speech*` — client-safe, sem Payload, casa com o prefixo do manifesto e é unit-testável sem DOM. **Rejeitadas:** `utilities/speech` (o componente cliente importaria utilities por nada); no componente (não testável).
- **B) Estado/UI.** Opções: estado no `SpeechDetailPlayer` + `SpeechExcerptControls`/`SpeechExcerptShare` irmãos | tudo no player | player separado. **Recomendação:** estado no player (único dono do vídeo) com 2 irmãos; modo altera a transcrição. **Rejeitadas:** tudo no player (381 linhas, geometria no jsdom); player separado (anti-goal); extrair a transcrição (churn C162).
- **C) Seleção.** Opções: track própria com 2 handles `role="slider"` + clique-ímã | 2 `<input type=range>` | handles livres. **Recomendação:** track própria — `step` nativo não encaixa em limites arbitrários de segmento; ímã de frase + ajuste fino; teclado ±1/±5, Home/End. **Rejeitadas:** ranges nativos (step/dois polegares); alças livres (erram a frase).
- **D) Duração.** Opções: `durationSeconds` cru no VM | derivar de `durationLabel` | `endLabel` por segmento. **Recomendação:** `durationSeconds: number | null` (já selecionado no `speechPageData`), fallback no cliente para `segments.at(-1)?.endSeconds`; sem `endLabel` (o controle formata com `formatSpeechClock`). **Rejeitadas:** parsear `durationLabel` (formatação não se lê de volta).
- **E) Datas/mensagem.** Opções: `speechDateLabel` no VM | parsear `speechAtLabel` no lib. **Recomendação:** `speechDateLabel` (dono é `formatSpeechAt`; o lib não deve conhecer o formato de exibição; `· HH:mm` não vaza). Fallback de `type` nulo: `"fala"` minúsculo (mensagem natural, sem inventar classificação). **Rejeitadas:** parsear label (frágil, acopla a mensagem ao formato do rótulo).
- **F) Sem YouTube / offset nulo.** Opções: sem opção de link + aviso, seleção continua | link autenticado do acervo | sem seleção. **Recomendação:** sem YouTube → nenhum "Compartilhar" e aviso honesto no lugar; offset nulo → link sem `t=` com nota "abre no início da sessão". **Rejeitadas:** link autenticado (não funciona para quem recebe e vaza URL interna); bloquear a seleção (ela é a porta do C167).
- **G) Mobile.** Opções: Popover fino / Sheet coarse via `useCoarsePointer` | só Popover | só Sheet. **Recomendação:** split com `useCoarsePointer` reusando `Popover`/`Sheet` (ambos em `src/components/ui`), precedente `CampaignHomeActionStrip`/`CampaignHomeActionButton`. **Rejeitadas:** só Popover (clipa/pequeno demais no toque); só Sheet (pesado no desktop).
- **H) Feedback de copiar.** Opções: estado local 2 s + live region | toast sonner. **Recomendação:** local (o feedback vive na linha da opção; não cobre o popover nem some com o Sheet aberto; padrão `ContentShareButton`). **Rejeitadas:** toast (fora do contexto da opção; segundo padrão de feedback na mesma fatia).
## Self-score decision-quality
5/5 — (1) todas as decisões caras (A–E, G) têm opções e rejeitadas explícitas; (2) cabe no appetite herdado: sem schema, sem servidor, 2 módulos puros + 2 componentes + 1 move; (3) rabbit holes nomeados (YT API, preview, timeline, persistência, telemetria); (4) depth check: reusa `buildWhatsAppTextShareUrl`, `Popover`/`Sheet`/`Button`, `useCoarsePointer`, o player e o VM existentes — nenhum mecanismo paralelo; (5) o aceite de produto permanece intacto (a engenharia não reescreveu o outcome).

View file

@ -110,6 +110,9 @@ export default async function SpeechDetailPage({ params, searchParams }: SpeechD
segments={view.segments}
initialSeconds={initialSeconds}
sourceUrl={sourceUrl}
durationSeconds={view.durationSeconds}
speechType={view.type}
speechDateLabel={view.speechDateLabel}
/>
</div>

View file

@ -1,13 +1,21 @@
'use client'
import { DownloadIcon, ExternalLinkIcon, FilmIcon, PlayIcon } from 'lucide-react'
import { DownloadIcon, ExternalLinkIcon, FilmIcon, PlayIcon, ScissorsIcon } from 'lucide-react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { SpeechVodResolveResponse } from '@/app/(campaign)/campanha/(app)/comunicacao/acervo/resolver-vod/types'
import { SpeechExcerptControls } from '@/components/campaign/speech/SpeechExcerptControls'
import { SpeechExcerptShare } from '@/components/campaign/speech/SpeechExcerptShare'
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import {
extendRangeToSegment,
initialExcerptRange,
MIN_EXCERPT_SECONDS,
type ExcerptRange,
} from '@/lib/speechExcerptSelection'
import type { SpeechVodResolution } from '@/lib/speechVod'
import { cn } from '@/lib/utils'
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
@ -52,6 +60,12 @@ type SpeechDetailPlayerProps = {
segments: readonly SpeechDetailSegmentViewModel[]
initialSeconds: number | null
sourceUrl: string | null
/** C166 — raw duration for the excerpt picker; null falls back to the last segment end. */
durationSeconds: number | null
/** C166 — speech type for the share message (`null` reads as "fala"). */
speechType: string | null
/** C166 — day label (`dd/mm/aaaa`) for the share message. */
speechDateLabel: string
}
const StatusPanel = ({
@ -100,10 +114,16 @@ export const SpeechDetailPlayer = ({
segments,
initialSeconds,
sourceUrl,
durationSeconds,
speechType,
speechDateLabel,
}: SpeechDetailPlayerProps) => {
const videoRef = useRef<HTMLVideoElement>(null)
const [resolution, setResolution] = useState<ResolutionState>({ kind: 'idle' })
const [activeStart, setActiveStart] = useState<number | null>(null)
// C166: one state for the explicit selection mode — a non-null range means
// the mode is on, so "selecting" cannot drift from "has a range".
const [selection, setSelection] = useState<ExcerptRange | null>(null)
const [youtubeStart, setYoutubeStart] = useState<number | null>(() =>
youtubeVideoId && youtubeOffsetSeconds !== null
? youtubeOffsetSeconds + (initialSeconds ?? 0)
@ -112,6 +132,10 @@ export const SpeechDetailPlayer = ({
const resolving = resolution.kind === 'resolving'
const playbackUrl = resolution.kind === 'resolved' ? resolution.playbackUrl : null
const selectionDuration = durationSeconds ?? segments.at(-1)?.endSeconds ?? null
const selectionAvailable = selectionDuration !== null && selectionDuration >= MIN_EXCERPT_SECONDS
const selecting = selection !== null
const showExcerptShare = selecting && Boolean(youtubeVideoId)
useEffect(() => {
const video = videoRef.current
@ -186,6 +210,27 @@ export const SpeechDetailPlayer = ({
setActiveStart(active?.startSeconds ?? null)
}
const toggleSelection = () => {
if (selecting) {
setSelection(null)
return
}
if (selectionDuration === null || selectionDuration < MIN_EXCERPT_SECONDS) return
setSelection(initialExcerptRange(segments, selectionDuration))
}
// C162 keeps the transcript click seeking; C166 turns it into a phrase magnet
// while the explicit selection mode is on.
const onSegmentActivate = (index: number) => {
const segment = segments[index]
if (!segment) return
if (selection === null || selectionDuration === null) {
seekTo(segment.startSeconds)
return
}
setSelection(extendRangeToSegment(selection, segments, index, selectionDuration))
}
const seekable = youtubeVideoId ? youtubeOffsetSeconds !== null : Boolean(playbackUrl)
const renderMedia = (): ReactNode => {
@ -302,9 +347,44 @@ export const SpeechDetailPlayer = ({
<div data-slot="speech-player" aria-busy={resolving || undefined}>
{renderMedia()}
{selection && selectionDuration !== null ? (
<SpeechExcerptControls
segments={segments}
range={selection}
durationSeconds={selectionDuration}
onChange={setSelection}
/>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
{selectionAvailable ? (
<Button
type="button"
variant={selecting ? 'secondary' : 'outline'}
className="min-h-10"
aria-pressed={selecting}
data-slot="speech-excerpt-toggle"
onClick={toggleSelection}
>
<ScissorsIcon data-icon="inline-start" aria-hidden="true" />
Selecionar trecho
</Button>
) : null}
{showExcerptShare && selection && youtubeVideoId ? (
<SpeechExcerptShare
videoId={youtubeVideoId}
offsetSeconds={youtubeOffsetSeconds}
startSeconds={selection.startSeconds}
endSeconds={selection.endSeconds}
speechType={speechType}
dateLabel={speechDateLabel}
/>
) : null}
{vodResolvable ? (
<Button
// While the excerpt share is on screen it is the primary action;
// the download steps back to outline instead of splitting the CTA.
variant={showExcerptShare ? 'outline' : 'default'}
className="min-h-10"
disabled={resolving}
aria-busy={resolving || undefined}
@ -336,32 +416,56 @@ export const SpeechDetailPlayer = ({
{inlineNotice}
{!youtubeVideoId && selectionAvailable ? (
<div className="mt-3 rounded-lg border bg-muted/40 px-4 py-3">
<p className="text-xs font-medium text-foreground/90">
Compartilhar por link exige o vídeo no YouTube
</p>
<p className="mt-1 text-xs text-muted-foreground">
Esta fala não tem vídeo no YouTube — não há link externo que abra no ponto. Você ainda
pode baixar o MP4 ou abrir a fonte oficial, e a seleção de trecho continua disponível.
</p>
</div>
) : null}
{segments.length > 0 ? (
<section className="mt-5" aria-label="Transcrição">
<p className="text-xs tracking-wide text-muted-foreground uppercase">
{seekable ? 'Transcrição (clique para posicionar)' : 'Transcrição'}
{selecting
? 'Transcrição (clique nas frases para marcar o trecho)'
: seekable
? 'Transcrição (clique para posicionar)'
: 'Transcrição'}
</p>
<ol className="mt-2 space-y-0.5">
{segments.map((segment) => {
{segments.map((segment, index) => {
const active = activeStart === segment.startSeconds
const inSelection =
selection !== null &&
segment.endSeconds > selection.startSeconds &&
segment.startSeconds < selection.endSeconds
return (
<li key={segment.startSeconds}>
<button
type="button"
data-start-seconds={segment.startSeconds}
onClick={() => seekTo(segment.startSeconds)}
disabled={!seekable}
data-in-selection={inSelection || undefined}
onClick={() => onSegmentActivate(index)}
disabled={!seekable && !selecting}
className={cn(
'flex w-full gap-3 rounded-lg px-2 py-1.5 text-left transition-colors',
seekable && 'hover:bg-muted',
seekable && !selecting && 'hover:bg-muted',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active && 'bg-muted',
!selecting && active && 'bg-muted',
inSelection && 'border-l-2 border-primary bg-primary/10',
)}
>
<span
className={cn(
'w-12 shrink-0 pt-0.5 text-xs tabular-nums',
active ? 'text-primary' : 'text-muted-foreground',
inSelection || (!selecting && active)
? 'text-primary'
: 'text-muted-foreground',
)}
>
{segment.startLabel}

View file

@ -0,0 +1,178 @@
'use client'
import type { KeyboardEvent, PointerEvent } from 'react'
import { useRef } from 'react'
import { formatSpeechClock, formatSpeechSpan } from '@/lib/speechClock'
import {
MAX_EXCERPT_SECONDS,
MIN_EXCERPT_SECONDS,
moveRangeEdge,
rangeDurationSeconds,
secondsFromTrackRatio,
snapSecondsToSegmentBoundary,
type ExcerptEdge,
type ExcerptRange,
type ExcerptSegment,
} from '@/lib/speechExcerptSelection'
/** Handles snap to a nearby phrase boundary within this screen distance... */
const SNAP_TOLERANCE_PX = 8
/** ...never more than this in seconds, so a wide/short track cannot over-magnetize. */
const SNAP_TOLERANCE_MAX_SECONDS = 2
const HANDLE_KEY_DELTAS: Record<string, number> = {
ArrowLeft: -1,
ArrowRight: 1,
PageDown: -5,
PageUp: 5,
}
const excerptLimitsLabel = `Mínimo ${MIN_EXCERPT_SECONDS}s · máximo ${MAX_EXCERPT_SECONDS / 60}min`
type SpeechExcerptControlsProps = {
segments: readonly ExcerptSegment[]
range: ExcerptRange
durationSeconds: number
onChange: (range: ExcerptRange) => void
}
/**
* C166 — the excerpt range bar: two keyboard-operable handles (`role="slider"`)
* over the speech duration, with a magnet on the transcript phrase boundaries.
* It only translates pointers/keys into the pure geometry (`lib/speechExcerptSelection`);
* the phrase clicks themselves stay on the transcript.
*/
export const SpeechExcerptControls = ({
segments,
range,
durationSeconds,
onChange,
}: SpeechExcerptControlsProps) => {
const trackRef = useRef<HTMLDivElement>(null)
const dragEdgeRef = useRef<ExcerptEdge | null>(null)
const durationLabel = formatSpeechClock(durationSeconds)
const startLabel = formatSpeechClock(range.startSeconds)
const endLabel = formatSpeechClock(range.endSeconds)
const spanLabel = formatSpeechSpan(rangeDurationSeconds(range))
const percentOf = (seconds: number): number =>
durationSeconds > 0 ? Math.min(100, Math.max(0, (seconds / durationSeconds) * 100)) : 0
const secondsFromPointer = (clientX: number): number => {
const track = trackRef.current
if (!track) return range.startSeconds
const rect = track.getBoundingClientRect()
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0
const raw = secondsFromTrackRatio(ratio, durationSeconds)
const tolerance =
rect.width > 0
? Math.min(SNAP_TOLERANCE_MAX_SECONDS, (SNAP_TOLERANCE_PX / rect.width) * durationSeconds)
: 0
return snapSecondsToSegmentBoundary(raw, segments, tolerance)
}
const onHandlePointerDown = (edge: ExcerptEdge) => (event: PointerEvent<HTMLDivElement>) => {
event.preventDefault()
event.currentTarget.focus()
dragEdgeRef.current = edge
event.currentTarget.setPointerCapture(event.pointerId)
}
const onHandlePointerMove = (edge: ExcerptEdge) => (event: PointerEvent<HTMLDivElement>) => {
if (dragEdgeRef.current !== edge) return
onChange(moveRangeEdge(range, edge, secondsFromPointer(event.clientX), durationSeconds))
}
const endDrag = (event: PointerEvent<HTMLDivElement>) => {
dragEdgeRef.current = null
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
const onHandleKeyDown = (edge: ExcerptEdge) => (event: KeyboardEvent<HTMLDivElement>) => {
const current = edge === 'start' ? range.startSeconds : range.endSeconds
const delta =
event.key === 'Home'
? -durationSeconds
: event.key === 'End'
? durationSeconds
: (HANDLE_KEY_DELTAS[event.key] ?? 0)
if (delta === 0) return
event.preventDefault()
onChange(moveRangeEdge(range, edge, current + delta, durationSeconds))
}
const handleProps = (edge: ExcerptEdge, seconds: number) => ({
role: 'slider' as const,
tabIndex: 0,
'aria-label': edge === 'start' ? 'Início do trecho' : 'Fim do trecho',
'aria-valuemin':
edge === 'start'
? Math.max(0, range.endSeconds - MAX_EXCERPT_SECONDS)
: range.startSeconds + MIN_EXCERPT_SECONDS,
'aria-valuemax':
edge === 'start'
? range.endSeconds - MIN_EXCERPT_SECONDS
: Math.min(durationSeconds, range.startSeconds + MAX_EXCERPT_SECONDS),
'aria-valuenow': seconds,
'aria-valuetext': formatSpeechClock(seconds),
'data-excerpt-edge': edge,
onPointerDown: onHandlePointerDown(edge),
onPointerMove: onHandlePointerMove(edge),
onPointerUp: endDrag,
onPointerCancel: endDrag,
onKeyDown: onHandleKeyDown(edge),
})
return (
<div className="mt-3 rounded-lg border bg-muted/30 p-3" data-slot="speech-excerpt-controls">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-medium text-primary">Trecho selecionado</span>
<span className="text-xs text-muted-foreground tabular-nums">
<span className="text-foreground">{startLabel}</span> →{' '}
<span className="text-foreground">{endLabel}</span> · {spanLabel}
</span>
</div>
<div
ref={trackRef}
data-slot="speech-excerpt-track"
className="relative mt-4 h-9 touch-none select-none"
>
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" />
<div
className="absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary"
style={{
left: `${percentOf(range.startSeconds)}%`,
right: `${100 - percentOf(range.endSeconds)}%`,
}}
/>
<div
{...handleProps('start', range.startSeconds)}
className="absolute top-1/2 h-7 w-3 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize rounded-sm border border-primary bg-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none pointer-coarse:h-8 pointer-coarse:w-5"
style={{ left: `${percentOf(range.startSeconds)}%` }}
/>
<div
{...handleProps('end', range.endSeconds)}
className="absolute top-1/2 h-7 w-3 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize rounded-sm border border-primary bg-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none pointer-coarse:h-8 pointer-coarse:w-5"
style={{ left: `${percentOf(range.endSeconds)}%` }}
/>
</div>
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground tabular-nums">
<span>00:00</span>
<span>{startLabel}</span>
<span>{endLabel}</span>
<span>{durationLabel}</span>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
Clique nas frases: o limite encaixa no segmento (ímã). Arraste as alças para o ajuste fino.{' '}
{excerptLimitsLabel} — a duração aparece enquanto seleciona.
</p>
</div>
)
}

View file

@ -0,0 +1,166 @@
'use client'
import { CheckIcon, CopyIcon, Share2Icon } from 'lucide-react'
import { useEffect, useState } from 'react'
import { WhatsAppIcon } from '@/components/socialIcons'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/Sheet'
import { useCoarsePointer } from '@/lib/campaignCoarsePointer'
import { buildSpeechExcerptShare } from '@/lib/speechShare'
const COPY_FEEDBACK_RESET_MS = 2000
type CopyFeedback = 'idle' | 'copied' | 'error'
const COPY_LABELS: Record<CopyFeedback, string> = {
idle: 'Copiar link',
copied: 'Link copiado',
error: 'Não foi possível copiar',
}
const COPY_LIVE_MESSAGES: Record<CopyFeedback, string> = {
idle: '',
copied: 'Link copiado.',
error: 'Não foi possível copiar o link.',
}
type SpeechExcerptShareProps = {
videoId: string
/** Session offset in seconds; null makes the link open at the session start. */
offsetSeconds: number | null
startSeconds: number
endSeconds: number
speechType: string | null
dateLabel: string
}
const OPTION_CLASS =
'flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm font-medium hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none'
/**
* C166 — the excerpt share control: one "Compartilhar" action with exactly two
* options (copy the ready message with the interval + link, or open the
* sender's own WhatsApp via `wa.me`). The message is what circulates because
* YouTube cannot encode the excerpt end. Popover on fine pointers, bottom sheet
* on coarse ones; the copy feedback lives in the option row (local state), so
* it never covers the control and vanishes on its own.
*/
export const SpeechExcerptShare = ({
videoId,
offsetSeconds,
startSeconds,
endSeconds,
speechType,
dateLabel,
}: SpeechExcerptShareProps) => {
const isCoarsePointer = useCoarsePointer()
const [open, setOpen] = useState(false)
const [copyFeedback, setCopyFeedback] = useState<CopyFeedback>('idle')
useEffect(() => {
if (copyFeedback === 'idle') return
const reset = setTimeout(() => setCopyFeedback('idle'), COPY_FEEDBACK_RESET_MS)
return () => clearTimeout(reset)
}, [copyFeedback])
const share = buildSpeechExcerptShare({
videoId,
offsetSeconds,
startSeconds,
endSeconds,
speechType,
dateLabel,
})
const copyMessage = async () => {
try {
await navigator.clipboard.writeText(share.message)
setCopyFeedback('copied')
} catch {
setCopyFeedback('error')
}
}
const trigger = (onClick?: () => void, expanded?: boolean) => (
<Button
type="button"
className="min-h-10"
onClick={onClick}
aria-haspopup="dialog"
aria-expanded={expanded}
>
<Share2Icon data-icon="inline-start" aria-hidden="true" />
Compartilhar
</Button>
)
const options = (
<div data-slot="speech-excerpt-share-options">
<button type="button" onClick={copyMessage} className={OPTION_CLASS}>
{copyFeedback === 'copied' ? (
<CheckIcon className="size-4 text-emerald-600" aria-hidden="true" />
) : (
<CopyIcon className="size-4" aria-hidden="true" />
)}
{COPY_LABELS[copyFeedback]}
</button>
<a
href={share.whatsAppUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className={OPTION_CLASS}
>
<WhatsAppIcon className="size-4 text-[#25D366]" />
Enviar no WhatsApp
</a>
<p className="border-t px-2 py-2 text-[11px] leading-relaxed break-words text-muted-foreground">
{share.message}
</p>
{offsetSeconds === null ? (
<p className="px-2 pb-2 text-[11px] text-muted-foreground">
O link abre o vídeo no início da sessão.
</p>
) : null}
<span aria-live="polite" className="sr-only">
{COPY_LIVE_MESSAGES[copyFeedback]}
</span>
</div>
)
if (isCoarsePointer) {
return (
<div data-slot="speech-excerpt-share">
{trigger(() => setOpen(true), open)}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="bottom" className="gap-2 px-2 pb-4">
<SheetHeader>
<SheetTitle>Compartilhar trecho</SheetTitle>
<SheetDescription>O link abre o vídeo no ponto escolhido.</SheetDescription>
</SheetHeader>
{options}
</SheetContent>
</Sheet>
</div>
)
}
return (
<div data-slot="speech-excerpt-share">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger()}</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-1">
{options}
</PopoverContent>
</Popover>
</div>
)
}

27
src/lib/speechClock.ts Normal file
View file

@ -0,0 +1,27 @@
/**
* C166 — the one owner of the acervo clock label ("01:12" / "1:02:03").
* Lives in `lib/` because the pure share/selection modules build messages with
* it and `lib/` never imports `utilities/`.
*/
const pad = (value: number): string => String(value).padStart(2, '0')
/** Transcript timestamp ("01:12" / "1:02:03"). */
export const formatSpeechClock = (seconds: number): string => {
const total = Math.max(0, Math.round(seconds))
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const secs = total % 60
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${pad(minutes)}:${pad(secs)}`
}
/** Compact duration label ("45s" / "1min27s" / "1h02min") for speech surfaces. */
export const formatSpeechSpan = (seconds: number): string => {
const total = Math.max(0, Math.round(seconds))
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const secs = total % 60
if (hours > 0) return `${hours}h${pad(minutes)}min`
if (minutes > 0) return `${minutes}min${pad(secs)}s`
return `${secs}s`
}

View file

@ -0,0 +1,174 @@
/**
* C166 — pure geometry for the acervo excerpt picker: whole-second
* [start, end] ranges snapped to transcript segment boundaries, clamped to the
* excerpt limits (5 s..180 s) and to the speech duration. No DOM and no React:
* the client controls only translate pointers/keys into these calls.
*/
export type ExcerptSegment = {
startSeconds: number
endSeconds: number
}
export type ExcerptRange = {
startSeconds: number
endSeconds: number
}
export type ExcerptEdge = 'start' | 'end'
export const MIN_EXCERPT_SECONDS = 5
export const MAX_EXCERPT_SECONDS = 180
export const rangeDurationSeconds = (range: ExcerptRange): number =>
range.endSeconds - range.startSeconds
const durationFloor = (durationSeconds: number): number =>
Number.isFinite(durationSeconds) ? Math.max(0, Math.floor(durationSeconds)) : 0
const wholeFloor = (seconds: number): number => (Number.isFinite(seconds) ? Math.floor(seconds) : 0)
const wholeCeil = (seconds: number): number => (Number.isFinite(seconds) ? Math.ceil(seconds) : 0)
/**
* Clamp a raw [start, end] pair into a valid excerpt range: whole seconds,
* inside [0, duration], at least 5 s and at most 180 s long (a short pair
* grows forward, and backwards when the speech ends first). Returns null when
* the duration cannot hold the minimum — the caller then hides the picker.
*/
export const normalizeExcerptRange = (
rawStart: number,
rawEnd: number,
durationSeconds: number,
): ExcerptRange | null => {
const duration = durationFloor(durationSeconds)
if (duration < MIN_EXCERPT_SECONDS) return null
let start = Math.min(Math.max(0, wholeFloor(rawStart)), duration - MIN_EXCERPT_SECONDS)
let end = Math.min(Math.max(0, wholeCeil(rawEnd)), duration)
if (end < start) [start, end] = [end, start]
if (end - start < MIN_EXCERPT_SECONDS) {
end = Math.min(duration, start + MIN_EXCERPT_SECONDS)
start = Math.max(0, end - MIN_EXCERPT_SECONDS)
}
if (end - start > MAX_EXCERPT_SECONDS) end = start + MAX_EXCERPT_SECONDS
return { startSeconds: start, endSeconds: end }
}
/** The picked phrase as the initial range (or the first 5 s when there is no ASR). */
export const initialExcerptRange = (
segments: readonly ExcerptSegment[],
durationSeconds: number,
): ExcerptRange | null =>
segments.length > 0
? selectSegmentRange(segments, 0, durationSeconds)
: normalizeExcerptRange(0, MIN_EXCERPT_SECONDS, durationSeconds)
/** A whole phrase, clamped by the duration and by the 5 s/180 s limits. */
export const selectSegmentRange = (
segments: readonly ExcerptSegment[],
index: number,
durationSeconds: number,
): ExcerptRange | null => {
const segment = segments[index]
if (!segment) return null
return normalizeExcerptRange(segment.startSeconds, segment.endSeconds, durationSeconds)
}
/**
* Adjacent ASR phrases share a fractional boundary (one ends at 50.1 and the
* next starts at 50.1): a click whose edge lands this close to the current
* range edge is the neighbour phrase, not an overlap. Without it, selecting
* the next phrase right after the current one would re-select instead of
* extending.
*/
const ADJACENT_PHRASE_EPSILON_SECONDS = 1
/**
* Phrase click in selection mode: a phrase after the range extends the end, a
* phrase before it moves the start back, and a phrase already inside the range
* re-selects that phrase alone. Segment seconds are fractional (ASR), so the
* start floors and the end ceils while clamping.
*/
export const extendRangeToSegment = (
range: ExcerptRange,
segments: readonly ExcerptSegment[],
index: number,
durationSeconds: number,
): ExcerptRange | null => {
const segment = segments[index]
if (!segment) return range
if (segment.endSeconds <= range.startSeconds + ADJACENT_PHRASE_EPSILON_SECONDS) {
return normalizeExcerptRange(segment.startSeconds, range.endSeconds, durationSeconds)
}
if (segment.startSeconds >= range.endSeconds - ADJACENT_PHRASE_EPSILON_SECONDS) {
return normalizeExcerptRange(range.startSeconds, segment.endSeconds, durationSeconds)
}
return selectSegmentRange(segments, index, durationSeconds)
}
/**
* Handle/keyboard move of one edge: the other edge stays put, the span stays
* between 5 s and 180 s, and the range stays inside the speech.
*/
export const moveRangeEdge = (
range: ExcerptRange,
edge: ExcerptEdge,
nextSeconds: number,
durationSeconds: number,
): ExcerptRange => {
const duration = durationFloor(durationSeconds)
const requested = Math.round(Number.isFinite(nextSeconds) ? nextSeconds : 0)
if (edge === 'start') {
const minimum = Math.max(0, range.endSeconds - MAX_EXCERPT_SECONDS)
const maximum = range.endSeconds - MIN_EXCERPT_SECONDS
return {
startSeconds: Math.min(Math.max(requested, minimum), maximum),
endSeconds: range.endSeconds,
}
}
const minimum = range.startSeconds + MIN_EXCERPT_SECONDS
const maximum = Math.min(duration, range.startSeconds + MAX_EXCERPT_SECONDS)
return {
startSeconds: range.startSeconds,
endSeconds: Math.min(Math.max(requested, minimum), maximum),
}
}
/** Pointer ratio on the track (0..1) → whole seconds inside the speech. */
export const secondsFromTrackRatio = (ratio: number, durationSeconds: number): number => {
const clamped = Math.min(Math.max(Number.isFinite(ratio) ? ratio : 0, 0), 1)
return Math.round(clamped * durationFloor(durationSeconds))
}
/** Segment boundaries as whole seconds, ascending — the magnet targets of the handles. */
export const segmentBoundarySeconds = (segments: readonly ExcerptSegment[]): number[] =>
[
...new Set(
segments.flatMap((segment) => [
wholeFloor(segment.startSeconds),
wholeCeil(segment.endSeconds),
]),
),
].sort((left, right) => left - right)
/** Snap to the nearest segment boundary within the tolerance (px converted to seconds). */
export const snapSecondsToSegmentBoundary = (
seconds: number,
segments: readonly ExcerptSegment[],
toleranceSeconds: number,
): number => {
let best = seconds
let bestDistance = toleranceSeconds
for (const boundary of segmentBoundarySeconds(segments)) {
const distance = Math.abs(boundary - seconds)
if (distance <= bestDistance) {
best = boundary
bestDistance = distance
}
}
return best
}

66
src/lib/speechShare.ts Normal file
View file

@ -0,0 +1,66 @@
/**
* C166 — pure share text for an acervo excerpt: the YouTube watch URL opening
* at the picked point (session offset + excerpt start, whole seconds) and the
* message that carries the interval, since YouTube cannot encode the end. The
* WhatsApp URL reuses the repo's recipient-less `wa.me` builder.
*/
import { buildWhatsAppTextShareUrl } from '@/lib/phone'
import { formatSpeechClock } from '@/lib/speechClock'
const EXCERPT_SHARE_FALLBACK_TYPE = 'fala'
export type SpeechExcerptShareInput = {
videoId: string
/** Session offset in seconds; null means unknowable and the link carries no `t=`. */
offsetSeconds: number | null
startSeconds: number
endSeconds: number
speechType: string | null
/** Day label (`dd/mm/aaaa`) of the speech. */
dateLabel: string
}
export type SpeechExcerptShare = {
url: string
message: string
whatsAppUrl: string
}
/** `https://www.youtube.com/watch?v=<id>&t=<segundos>` — no `t=` when the offset is unknown. */
export const buildSpeechExcerptYoutubeUrl = (
videoId: string,
offsetSeconds: number | null,
startSeconds: number,
): string => {
const url = new URL('https://www.youtube.com/watch')
url.searchParams.set('v', videoId)
if (offsetSeconds !== null && Number.isFinite(offsetSeconds)) {
url.searchParams.set(
't',
String(Math.max(0, Math.floor(offsetSeconds + Math.max(0, startSeconds)))),
)
}
return url.toString()
}
export const buildSpeechExcerptMessage = ({
speechType,
dateLabel,
startSeconds,
endSeconds,
url,
}: Omit<SpeechExcerptShareInput, 'videoId' | 'offsetSeconds'> & { url: string }): string =>
`Trecho de ${speechType?.trim() || EXCERPT_SHARE_FALLBACK_TYPE} (${dateLabel}): de ${formatSpeechClock(startSeconds)} a ${formatSpeechClock(endSeconds)} ${url}`
export const buildSpeechExcerptShare = (input: SpeechExcerptShareInput): SpeechExcerptShare => {
const { videoId, offsetSeconds, startSeconds, endSeconds, speechType, dateLabel } = input
const url = buildSpeechExcerptYoutubeUrl(videoId, offsetSeconds, startSeconds)
const message = buildSpeechExcerptMessage({
speechType,
dateLabel,
startSeconds,
endSeconds,
url,
})
return { url, message, whatsAppUrl: buildWhatsAppTextShareUrl(message) }
}

View file

@ -3,6 +3,7 @@ import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import { canReadSpeechCatalog } from '@/lib/campaignRoles'
import { formatSpeechClock } from '@/lib/speechClock'
import {
buildSpeechExcerpt,
EXCERPT_VARIANT_TARGETS,
@ -16,11 +17,7 @@ import {
type SpeechExcerptRerankChoice,
} from '@/utilities/ai/rerankSpeechExcerpts'
import { loadSegmentsForSpeeches } from '@/utilities/speech/speechPageData'
import {
buildWatchHref,
formatSpeechAt,
formatSpeechClock,
} from '@/utilities/speech/speechViewModels'
import { buildWatchHref, formatSpeechAt } from '@/utilities/speech/speechViewModels'
const DENIED_MESSAGE = 'Leitura do acervo de falas negada.'
const NO_TERMS_MESSAGE = 'Tema sem termos de busca: informe palavras com 3 caracteres ou mais.'

View file

@ -4,6 +4,7 @@
* list/detail render.
*/
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
import { formatSpeechClock, formatSpeechSpan } from '@/lib/speechClock'
import type { SpeechExcerptSegment } from '@/lib/speechExcerpt'
import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
import {
@ -66,9 +67,13 @@ export type SpeechDetailSegmentViewModel = {
export type SpeechDetailViewModel = {
id: number
speechAtLabel: string
/** Day-only label (`dd/mm/aaaa`) for the share message. */
speechDateLabel: string
type: string | null
phase: string | null
durationLabel: string | null
/** Raw duration for the C166 excerpt picker; null when the row has none. */
durationSeconds: number | null
presidingOfficer: string | null
summary: string | null
officialTranscript: string | null
@ -87,8 +92,6 @@ export type SpeechDetailViewModel = {
vodResolvable: boolean
}
const pad = (value: number): string => String(value).padStart(2, '0')
/**
* `speechAt` is the Câmara wall-clock string ("2026-08-11T18:48", no timezone);
* slicing it keeps the local reading and avoids a `Date` shifting it.
@ -100,27 +103,18 @@ export const formatSpeechAt = (speechAt: string): string => {
return `${day}/${month}/${year} · ${hour}:${minute}`
}
const formatSpeechDuration = (seconds: number | null | undefined): string | null => {
if (seconds === null || seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) {
return null
}
const total = Math.round(seconds)
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const secs = total % 60
if (hours > 0) return `${hours}h${pad(minutes)}min`
if (minutes > 0) return `${minutes}min${pad(secs)}s`
return `${secs}s`
/** Day-only label (`dd/mm/aaaa`) for share messages; the clock stays out of it. */
const formatSpeechDate = (speechAt: string): string => {
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(speechAt)
if (!match) return speechAt
const [, year, month, day] = match
return `${day}/${month}/${year}`
}
/** Transcript timestamp ("01:12" / "1:02:03"). */
export const formatSpeechClock = (seconds: number): string => {
const total = Math.max(0, Math.round(seconds))
const hours = Math.floor(total / 3600)
const minutes = Math.floor((total % 3600) / 60)
const secs = total % 60
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${pad(minutes)}:${pad(secs)}`
}
const formatSpeechDuration = (seconds: number | null | undefined): string | null =>
seconds === null || seconds === undefined || !Number.isFinite(seconds) || seconds <= 0
? null
: formatSpeechSpan(seconds)
type MunicipalityMentions = {
mentionedMunicipalities?: (number | Municipality)[] | null
@ -250,9 +244,11 @@ export const toSpeechDetailViewModel = ({
return {
id: speech.id,
speechAtLabel: formatSpeechAt(speech.speechAt),
speechDateLabel: formatSpeechDate(speech.speechAt),
type: speech.type ?? null,
phase: speech.phase ?? null,
durationLabel: formatSpeechDuration(speech.durationSeconds),
durationSeconds: speech.durationSeconds ?? null,
presidingOfficer: speech.presidingOfficer ?? null,
summary: speech.summary ?? null,
officialTranscript: speech.officialTranscript ?? null,

View file

@ -166,6 +166,11 @@ test.describe('communication vertical (C154/C162)', () => {
expect(detailHtml).toContain('Baixar vídeo (MP4)')
expect(detailHtml).toContain('Abrir fonte')
expect(detailHtml).toContain('Fonte: Câmara dos Deputados')
// C166 — the excerpt picker's door is server-rendered; its link limit is
// not in this quadrant (the share control itself only appears in the mode,
// which is client interaction).
expect(detailHtml).toContain('Selecionar trecho')
expect(detailHtml).not.toContain('Compartilhar por link exige o vídeo no YouTube')
expect(detailHtml).not.toContain('<video')
expect(detailHtml).not.toContain('vod.camara.leg.br')
})
@ -185,6 +190,10 @@ test.describe('communication vertical (C154/C162)', () => {
const html = rendered(await detail.text())
expect(html).toContain('Assistir o trecho')
expect(html).toContain('Baixar vídeo (MP4)')
// C166 — no YouTube means no excerpt link: the honest notice replaces it
// while the selection door stays offered.
expect(html).toContain('Selecionar trecho')
expect(html).toContain('Compartilhar por link exige o vídeo no YouTube')
expect(html).not.toContain('<video')
expect(html).not.toContain('<iframe')
// The stored link is an eligibility signal, never a rendered URL.

View file

@ -190,6 +190,9 @@ describe('speech acervo (C154)', () => {
expect(view.id).toBe(id)
expect(view.segments).toHaveLength(2)
expect(view.segments[1]?.startSeconds).toBe(3)
// C166 — the excerpt picker needs the raw duration and the day-only label.
expect(view.durationSeconds).toBe(252)
expect(view.speechDateLabel).toBe('07/02/2023')
expect(view.municipalities.map((municipality) => municipality.name)).toContain(
'Feira de Santana',
)

View file

@ -2,8 +2,11 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { SpeechDetailPlayer } from '@/components/campaign/speech/SpeechDetailPlayer'
import { resetCampaignCoarsePointerForTests } from '@/lib/campaignCoarsePointer'
import type { SpeechDetailSegmentViewModel } from '@/utilities/speech/speechViewModels'
import { stubMatchMedia } from '../helpers/matchMedia'
const PLAYBACK_URL = 'https://vod.camara.leg.br/trecho.mp4'
const DOWNLOAD_URL = 'https://vod.camara.leg.br/trecho-download.mp4'
@ -53,6 +56,9 @@ const renderPlayer = (props: Partial<Parameters<typeof SpeechDetailPlayer>[0]> =
segments={SEGMENTS}
initialSeconds={null}
sourceUrl={null}
durationSeconds={300}
speechType="BREVES COMUNICAÇÕES"
speechDateLabel="11/08/2026"
{...props}
/>,
)
@ -77,10 +83,13 @@ beforeAll(() => {
beforeEach(() => {
fetchMock.mockReset()
vi.stubGlobal('fetch', fetchMock)
resetCampaignCoarsePointerForTests()
stubMatchMedia(false)
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
@ -258,3 +267,202 @@ describe('SpeechDetailPlayer — no-video quadrant', () => {
expect(iframeElement()).toBeNull()
})
})
describe('SpeechDetailPlayer — C166 excerpt selection and share', () => {
const segmentButton = (seconds: number) =>
document.querySelector(`button[data-start-seconds="${seconds}"]`) as HTMLButtonElement
const startSlider = () => screen.getByRole('slider', { name: 'Início do trecho' })
const endSlider = () => screen.getByRole('slider', { name: 'Fim do trecho' })
const toggleSelection = () =>
fireEvent.click(screen.getByRole('button', { name: /selecionar trecho/i }))
it('turns phrase clicks into magnet selection while the mode is on, keeping C162 outside it', () => {
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
toggleSelection()
expect(startSlider().getAttribute('aria-valuenow')).toBe('0')
expect(endSlider().getAttribute('aria-valuenow')).toBe('5')
fireEvent.click(segmentButton(43))
expect(startSlider().getAttribute('aria-valuenow')).toBe('0')
expect(endSlider().getAttribute('aria-valuenow')).toBe('50')
expect(endSlider().getAttribute('aria-valuemax')).toBe('180')
// C162 contract: the selection mode does not seek the embed.
expect(iframeElement()?.getAttribute('src')).toBe(
'https://www.youtube-nocookie.com/embed/lLhRDkSPw0A?playsinline=1&rel=0&start=2634',
)
// Clicking a phrase inside the range re-selects it alone.
fireEvent.click(segmentButton(43))
expect(startSlider().getAttribute('aria-valuenow')).toBe('43')
// Outside the mode the transcript click seeks exactly as before.
toggleSelection()
fireEvent.click(segmentButton(43))
expect(iframeElement()?.getAttribute('src')).toContain('start=2677')
})
it('adjusts the edges by keyboard with the 5 s/180 s limits', () => {
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
toggleSelection()
fireEvent.click(segmentButton(43))
fireEvent.keyDown(startSlider(), { key: 'ArrowRight' })
expect(startSlider().getAttribute('aria-valuenow')).toBe('1')
fireEvent.keyDown(startSlider(), { key: 'PageUp' })
expect(startSlider().getAttribute('aria-valuenow')).toBe('6')
fireEvent.keyDown(startSlider(), { key: 'End' })
expect(startSlider().getAttribute('aria-valuenow')).toBe('45')
fireEvent.keyDown(endSlider(), { key: 'End' })
expect(endSlider().getAttribute('aria-valuenow')).toBe('225')
expect(screen.getByText(/3min00s/)).toBeDefined()
fireEvent.keyDown(endSlider(), { key: 'ArrowLeft' })
expect(endSlider().getAttribute('aria-valuenow')).toBe('224')
fireEvent.keyDown(endSlider(), { key: 'Home' })
expect(endSlider().getAttribute('aria-valuenow')).toBe('50')
})
it('keeps the selection available without YouTube and states the link limit honestly', () => {
renderPlayer({ youtubeVideoId: null, vodResolvable: false })
expect(screen.getByText('Compartilhar por link exige o vídeo no YouTube')).toBeDefined()
toggleSelection()
expect(screen.queryByRole('button', { name: /^compartilhar$/i })).toBeNull()
fireEvent.click(segmentButton(43))
expect(endSlider().getAttribute('aria-valuenow')).toBe('50')
})
it('enables the phrases in selection mode and drops t when the offset is unknown', async () => {
const writeText = vi.fn(async (_text: string) => undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: null })
expect(segmentButton(43).disabled).toBe(true)
toggleSelection()
expect(segmentButton(43).disabled).toBe(false)
fireEvent.click(segmentButton(43))
fireEvent.click(screen.getByRole('button', { name: /^compartilhar$/i }))
expect(await screen.findByText('O link abre o vídeo no início da sessão.')).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: 'Copiar link' }))
await waitFor(() => expect(writeText).toHaveBeenCalled())
const copied = String(writeText.mock.calls[0]?.[0])
expect(copied).toContain('https://www.youtube.com/watch?v=lLhRDkSPw0A')
expect(copied).not.toContain('&t=')
})
it('copies the message with the interval and links WhatsApp with the same text', async () => {
const writeText = vi.fn(async () => undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
toggleSelection()
fireEvent.click(segmentButton(43))
fireEvent.click(screen.getByRole('button', { name: /^compartilhar$/i }))
const whatsapp = await screen.findByRole('link', { name: /enviar no whatsapp/i })
expect(whatsapp.getAttribute('href')).toContain('https://wa.me/?text=')
fireEvent.click(screen.getByRole('button', { name: 'Copiar link' }))
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith(
'Trecho de BREVES COMUNICAÇÕES (11/08/2026): de 00:00 a 00:50 https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2634',
),
)
expect(await screen.findByRole('button', { name: 'Link copiado' })).toBeDefined()
})
it('reports a copy failure without crashing', async () => {
const writeText = vi.fn(async () => {
throw new Error('denied')
})
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
toggleSelection()
fireEvent.click(screen.getByRole('button', { name: /^compartilhar$/i }))
fireEvent.click(await screen.findByRole('button', { name: 'Copiar link' }))
expect(await screen.findByRole('button', { name: 'Não foi possível copiar' })).toBeDefined()
})
it('opens the share options as a bottom sheet on coarse pointers', async () => {
stubMatchMedia(true)
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
toggleSelection()
fireEvent.click(screen.getByRole('button', { name: /^compartilhar$/i }))
expect(await screen.findByText('Compartilhar trecho')).toBeDefined()
expect(screen.getByText('O link abre o vídeo no ponto escolhido.')).toBeDefined()
expect(screen.getByRole('button', { name: 'Copiar link' })).toBeDefined()
expect(screen.getByRole('link', { name: /enviar no whatsapp/i })).toBeDefined()
})
it('drags a handle with the phrase-boundary magnet', () => {
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
toggleSelection()
const track = document.querySelector('[data-slot="speech-excerpt-track"]') as HTMLDivElement
track.getBoundingClientRect = () => ({
left: 0,
width: 300,
top: 0,
height: 36,
right: 300,
bottom: 36,
x: 0,
y: 0,
toJSON: () => ({}),
})
const handle = document.querySelector('[data-excerpt-edge="end"]') as HTMLDivElement
const setPointerCapture = vi.fn()
const releasePointerCapture = vi.fn()
handle.setPointerCapture = setPointerCapture
handle.hasPointerCapture = vi.fn(() => true)
handle.releasePointerCapture = releasePointerCapture
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 5 })
expect(setPointerCapture).toHaveBeenCalledWith(1)
// 44s is within the magnet window of the 43s phrase start.
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 44 })
expect(endSlider().getAttribute('aria-valuenow')).toBe('43')
// 150s is far from every boundary, so the handle moves freely.
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 150 })
expect(endSlider().getAttribute('aria-valuenow')).toBe('150')
// 250s would pass the 180 s span cap from the 0 s start.
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 250 })
expect(endSlider().getAttribute('aria-valuenow')).toBe('180')
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 250 })
expect(releasePointerCapture).toHaveBeenCalled()
})
it('resets the copy feedback after its window', async () => {
const writeText = vi.fn(async () => undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
renderPlayer({ youtubeVideoId: 'lLhRDkSPw0A', youtubeOffsetSeconds: 2634 })
toggleSelection()
fireEvent.click(screen.getByRole('button', { name: /^compartilhar$/i }))
const copyButton = await screen.findByRole('button', { name: 'Copiar link' })
vi.useFakeTimers()
fireEvent.click(copyButton)
await act(async () => {})
expect(screen.getByRole('button', { name: 'Link copiado' })).toBeDefined()
act(() => {
vi.advanceTimersByTime(2000)
})
expect(screen.getByRole('button', { name: 'Copiar link' })).toBeDefined()
})
})

View file

@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest'
import {
extendRangeToSegment,
initialExcerptRange,
moveRangeEdge,
normalizeExcerptRange,
rangeDurationSeconds,
secondsFromTrackRatio,
segmentBoundarySeconds,
selectSegmentRange,
snapSecondsToSegmentBoundary,
} from '@/lib/speechExcerptSelection'
/** ASR segments are fractional seconds — the picker works in whole seconds. */
const SEGMENTS = [
{ startSeconds: 0, endSeconds: 4.2 },
{ startSeconds: 43.6, endSeconds: 50.1 },
{ startSeconds: 50.1, endSeconds: 130.9 },
{ startSeconds: 130.9, endSeconds: 252 },
] as const
describe('normalizeExcerptRange', () => {
it('never leaves 0..duration and reads whole seconds', () => {
expect(normalizeExcerptRange(10.4, 20.7, 100)).toEqual({
startSeconds: 10,
endSeconds: 21,
})
expect(normalizeExcerptRange(-5, 500, 100)).toEqual({ startSeconds: 0, endSeconds: 100 })
expect(normalizeExcerptRange(80, 20, 100)).toEqual({ startSeconds: 20, endSeconds: 80 })
})
it('grows a short range to the 5 s minimum', () => {
expect(normalizeExcerptRange(0, 4, 100)).toEqual({ startSeconds: 0, endSeconds: 5 })
})
it('grows backwards when the speech ends first', () => {
expect(normalizeExcerptRange(248, 252, 252)).toEqual({ startSeconds: 247, endSeconds: 252 })
})
it('caps at 180 s', () => {
expect(normalizeExcerptRange(0, 500, 600)).toEqual({ startSeconds: 0, endSeconds: 180 })
expect(normalizeExcerptRange(500, 500, 600)).toEqual({ startSeconds: 500, endSeconds: 505 })
})
it('is null when the duration cannot hold the minimum', () => {
expect(normalizeExcerptRange(0, 4, 4)).toBeNull()
expect(normalizeExcerptRange(0, 4, Number.NaN)).toBeNull()
})
it('treats non-finite edges as zero instead of leaking NaN', () => {
expect(normalizeExcerptRange(Number.NaN, Number.NaN, 100)).toEqual({
startSeconds: 0,
endSeconds: 5,
})
})
})
describe('selectSegmentRange', () => {
it('selects the whole phrase with fractional edges snapped outward', () => {
expect(selectSegmentRange(SEGMENTS, 1, 252)).toEqual({ startSeconds: 43, endSeconds: 51 })
})
it('extends a phrase shorter than 5 s', () => {
expect(selectSegmentRange(SEGMENTS, 0, 252)).toEqual({ startSeconds: 0, endSeconds: 5 })
})
it('is null for an unknown phrase or an impossible speech', () => {
expect(selectSegmentRange(SEGMENTS, 9, 252)).toBeNull()
expect(selectSegmentRange(SEGMENTS, 0, 3)).toBeNull()
})
})
describe('initialExcerptRange', () => {
it('starts on the first phrase', () => {
expect(initialExcerptRange(SEGMENTS, 252)).toEqual({ startSeconds: 0, endSeconds: 5 })
})
it('falls back to the first 5 s without ASR', () => {
expect(initialExcerptRange([], 252)).toEqual({ startSeconds: 0, endSeconds: 5 })
expect(initialExcerptRange([], 3)).toBeNull()
})
})
describe('extendRangeToSegment', () => {
const range = { startSeconds: 43, endSeconds: 51 }
it('extends forward to the clicked phrase end', () => {
expect(extendRangeToSegment(range, SEGMENTS, 2, 252)).toEqual({
startSeconds: 43,
endSeconds: 131,
})
})
it('extends backwards to the clicked phrase start', () => {
expect(extendRangeToSegment(range, SEGMENTS, 0, 252)).toEqual({
startSeconds: 0,
endSeconds: 51,
})
})
it('re-selects a phrase already inside the range', () => {
expect(extendRangeToSegment({ startSeconds: 0, endSeconds: 131 }, SEGMENTS, 1, 252)).toEqual({
startSeconds: 43,
endSeconds: 51,
})
})
it('keeps the range when the phrase is unknown', () => {
expect(extendRangeToSegment(range, SEGMENTS, 9, 252)).toEqual(range)
})
it('caps the extension at 180 s when the clicked phrase is long', () => {
expect(extendRangeToSegment(range, SEGMENTS, 3, 252)).toEqual({
startSeconds: 43,
endSeconds: 223,
})
})
})
describe('moveRangeEdge', () => {
const range = { startSeconds: 43, endSeconds: 51 }
it('moves the start down to the beginning and up to the end minus 5 s', () => {
expect(moveRangeEdge(range, 'start', 0, 252)).toEqual({ startSeconds: 0, endSeconds: 51 })
expect(moveRangeEdge(range, 'start', 80, 252)).toEqual({ startSeconds: 46, endSeconds: 51 })
expect(moveRangeEdge(range, 'start', 20, 252)).toEqual({ startSeconds: 20, endSeconds: 51 })
})
it('moves the end inside start + 5 s and the 180 s cap', () => {
expect(moveRangeEdge(range, 'end', 0, 252)).toEqual({ startSeconds: 43, endSeconds: 48 })
expect(moveRangeEdge(range, 'end', 500, 252)).toEqual({ startSeconds: 43, endSeconds: 223 })
expect(moveRangeEdge(range, 'end', 200, 252)).toEqual({ startSeconds: 43, endSeconds: 200 })
})
it('never leaves the speech duration', () => {
expect(moveRangeEdge({ startSeconds: 198, endSeconds: 203 }, 'end', 500, 203)).toEqual({
startSeconds: 198,
endSeconds: 203,
})
expect(moveRangeEdge({ startSeconds: 240, endSeconds: 252 }, 'start', 10, 252)).toEqual({
startSeconds: 72,
endSeconds: 252,
})
})
it('ignores non-finite input', () => {
expect(moveRangeEdge(range, 'end', Number.NaN, 252)).toEqual({
startSeconds: 43,
endSeconds: 48,
})
})
})
describe('track geometry', () => {
it('converts the pointer ratio to whole seconds inside the duration', () => {
expect(secondsFromTrackRatio(0.5, 100)).toBe(50)
expect(secondsFromTrackRatio(0.333, 90)).toBe(30)
expect(secondsFromTrackRatio(-1, 100)).toBe(0)
expect(secondsFromTrackRatio(2, 100)).toBe(100)
expect(secondsFromTrackRatio(Number.NaN, 100)).toBe(0)
})
it('lists the phrase boundaries as ascending whole seconds without duplicates', () => {
expect(segmentBoundarySeconds(SEGMENTS)).toEqual([0, 5, 43, 50, 51, 130, 131, 252])
})
it('snaps to a boundary within the tolerance and stays put outside it', () => {
expect(snapSecondsToSegmentBoundary(45, SEGMENTS, 3)).toBe(43)
expect(snapSecondsToSegmentBoundary(45, SEGMENTS, 1)).toBe(45)
})
it('derives the span between the edges', () => {
expect(rangeDurationSeconds({ startSeconds: 43, endSeconds: 130 })).toBe(87)
})
})

View file

@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import {
buildSpeechExcerptMessage,
buildSpeechExcerptShare,
buildSpeechExcerptYoutubeUrl,
} from '@/lib/speechShare'
const decodeWaText = (url: string): string =>
decodeURIComponent(url.slice('https://wa.me/?text='.length)).replace(/\+/g, ' ')
describe('buildSpeechExcerptYoutubeUrl', () => {
it('opens the watch page at the session offset plus the excerpt start', () => {
expect(buildSpeechExcerptYoutubeUrl('lLhRDkSPw0A', 2634, 43)).toBe(
'https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2677',
)
})
it('floors fractional seconds — the link carries whole seconds', () => {
expect(buildSpeechExcerptYoutubeUrl('lLhRDkSPw0A', 2634.6, 43.9)).toBe(
'https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2678',
)
})
it('omits t when the session offset is unknown', () => {
expect(buildSpeechExcerptYoutubeUrl('lLhRDkSPw0A', null, 43)).toBe(
'https://www.youtube.com/watch?v=lLhRDkSPw0A',
)
})
it('never goes negative', () => {
expect(buildSpeechExcerptYoutubeUrl('id', 10, -30)).toBe(
'https://www.youtube.com/watch?v=id&t=10',
)
})
})
describe('buildSpeechExcerptMessage', () => {
it('carries the interval and the link, since YouTube cannot encode the end', () => {
expect(
buildSpeechExcerptMessage({
speechType: 'BREVES COMUNICAÇÕES',
dateLabel: '11/08/2026',
startSeconds: 43,
endSeconds: 130,
url: 'https://www.youtube.com/watch?v=id&t=2634',
}),
).toBe(
'Trecho de BREVES COMUNICAÇÕES (11/08/2026): de 00:43 a 02:10 https://www.youtube.com/watch?v=id&t=2634',
)
})
it('falls back to "fala" when the speech has no type', () => {
const message = buildSpeechExcerptMessage({
speechType: ' ',
dateLabel: '11/08/2026',
startSeconds: 0,
endSeconds: 65,
url: 'https://www.youtube.com/watch?v=id',
})
expect(message).toBe(
'Trecho de fala (11/08/2026): de 00:00 a 01:05 https://www.youtube.com/watch?v=id',
)
})
it('keeps the hour in the clock when the speech is long', () => {
const message = buildSpeechExcerptMessage({
speechType: null,
dateLabel: '11/08/2026',
startSeconds: 3723,
endSeconds: 3843,
url: 'https://www.youtube.com/watch?v=id',
})
expect(message).toContain('de 1:02:03 a 1:04:03')
})
})
describe('buildSpeechExcerptShare', () => {
const input = {
videoId: 'lLhRDkSPw0A',
offsetSeconds: 2634,
startSeconds: 43,
endSeconds: 130,
speechType: 'BREVES COMUNICAÇÕES',
dateLabel: '11/08/2026',
} as const
it('bundles the positioned URL and the message with the interval', () => {
const share = buildSpeechExcerptShare(input)
expect(share.url).toBe('https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2677')
expect(share.message).toBe(
'Trecho de BREVES COMUNICAÇÕES (11/08/2026): de 00:43 a 02:10 https://www.youtube.com/watch?v=lLhRDkSPw0A&t=2677',
)
})
it('reuses the recipient-less wa.me builder with the exact message', () => {
const share = buildSpeechExcerptShare(input)
expect(share.whatsAppUrl.startsWith('https://wa.me/?text=')).toBe(true)
expect(decodeWaText(share.whatsAppUrl)).toBe(share.message)
})
it('shares a link without t when the session offset is unknown', () => {
const share = buildSpeechExcerptShare({ ...input, offsetSeconds: null })
expect(share.url).toBe('https://www.youtube.com/watch?v=lLhRDkSPw0A')
expect(share.message).toContain('https://www.youtube.com/watch?v=lLhRDkSPw0A')
expect(share.message).not.toContain('&t=')
})
})