14 KiB
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/leadernegados; 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 semt=(nunca inventar). - O que reavaliar: a intenção sugere
utilities/speechpara a lógica pura — o módulo puro vai parasrc/lib/speech*(casa com o prefixo do manifesto e não carrega Payload);formatSpeechClockmuda de dono para o layer puro (hoje emspeechViewModels.ts); o VM precisa de campos crus (durationSeconds,speechDateLabel) que a intenção não listava.
Abordagem recomendada
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 deformatSpeechClock(movido despeechViewModels.ts, que passa a importá-lo;src/utilities/ai/tools/findSpeechExcerpts.ts:22troca o import). Sem re-export-twin: dono único, 2 call sites atualizados. Necessário porquesrc/libé proibido de importar@/utilities/**e o share precisa do mesmo mm:ss.src/lib/speechExcerptSelection.ts(novo, puro): tipos estruturaisExcerptSegment/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 porfloor/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)>(semtquandooffsetSeconds === null);messageliteralTrecho de <tipo|"fala"> (<dd/mm/aaaa>): de <mm:ss> a <mm:ss> <url>;whatsAppUrlreusabuildWhatsAppTextShareUrl(src/lib/phone.ts:63).speechViewModels.ts:durationSeconds: number | nullespeechDateLabel: stringnoSpeechDetailViewModel;formatSpeechDate(mesmo regex deformatSpeechAt, sódd/mm/aaaa); semendLabel(o controle formataendSecondscomformatSpeechClock— um campo a mais seria dado morto). ImportaformatSpeechClockde@/lib/speechClock.[id]/page.tsx: passadurationSeconds,speechType={view.type}espeechDateLabelao player.SpeechDetailPlayer.tsx: props novas; estadoselectingerange; toggle "Selecionar trecho" (aria-pressed) na linha de ações; ao ligar o modo, inicializarangecominitialExcerptRange(segments, effectiveDuration); transcrição muda em modo seleção (onClickmarca em vez deseekTo;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/seekToficam byte a byte como hoje (C162).SpeechExcerptControls.tsx(novo, client): barra com trilha própria e 2 handlesrole="slider"(aria-valuemin/max/now/valuetext, labels "Início do trecho"/"Fim do trecho"); pointer events +setPointerCapturecom í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) ouSheet(SheetContent, coarse); opções exatas "Copiar link" (estado localidle|copied|error, reset 2 s, live region sr-only — padrãoContentShareButton, 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" quandoyoutubeOffsetSeconds === null. "Copiar link" copia a mensagem completa (intervalo + URL) — o fim não cabe not=, então o texto é o que circula.- Migration: nenhuma. Access/Consent: nenhum; o gate
speechCatalogdo detalhe segue sendo a única barreira (advisor/leader intactos). - UI: Impeccable B — tema
data-theme="campaign"; reusaButton,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
- Núcleo puro + VM (~0,5 dia): mover
formatSpeechClock, criarspeechExcerptSelection.ts/speechShare.ts, campos do VM + wiring da página; testes puros verdes. - UI (~0,5 dia): modo no player +
SpeechExcerptControls+SpeechExcerptShare(Popover/Sheet) + aviso sem-YouTube; RTL estendido. - Gates (~0,25 dia):
pnpm gate:fast; e2e browserless com asserções SSR novas;pnpm push; CI roda o e2ecampaignSpeechAcervo(manifest já mapeiasrc/lib/speech, component/speech eutilities/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çãosegments.at(-1)?.endSeconds; URL comtinteiro e semtno offset desconhecido; mensagem com tipo e fallback "fala"; igualdade combuildWhatsAppTextShareUrl. - 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 embutton[data-start-seconds="43"]marca (não mexe novideo.currentTime/iframe); botões de transcrição habilitam mesmo semseekable; alças por teclado mudam os labels; 180 s capado aparece no label de duração; "Copiar link" comnavigator.clipboardmockado (precedentecampaignInviteInteractions.unit.spec.ts) → texto = mensagem + URL e feedback "Link copiado" (reset 2 s); href do WhatsApp contémwa.me/?text=com o texto codificado; sem YouTube → sem "Compartilhar" + aviso; offsetnull→ link semt=+ 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" edata-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 destart). - 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:
disabledpassa a considerarselecting; teste dedicado no quadrante sem YouTube. - Frase de 4 s (fixture e2e) vs mínimo de 5 s:
initialExcerptRange/selectSegmentRangeestendem 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 parasegments.at(-1)?.endSecondse, 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,tcomMath.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/libsem importar utilities; labels pt-BR; identificadores em inglês). - Contrato C162 pinado verde (9 pins) + e2e browserless atualizado;
pnpm gate:fastlimpo.
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/SpeechExcerptShareirmã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 —stepnativo 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:
durationSecondscru no VM | derivar dedurationLabel|endLabelpor segmento. Recomendação:durationSeconds: number | null(já selecionado nospeechPageData), fallback no cliente parasegments.at(-1)?.endSeconds; semendLabel(o controle formata comformatSpeechClock). Rejeitadas: parseardurationLabel(formatação não se lê de volta). - E) Datas/mensagem. Opções:
speechDateLabelno VM | parsearspeechAtLabelno lib. Recomendação:speechDateLabel(dono éformatSpeechAt; o lib não deve conhecer o formato de exibição;· HH:mmnão vaza). Fallback detypenulo:"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 comuseCoarsePointerreusandoPopover/Sheet(ambos emsrc/components/ui), precedenteCampaignHomeActionStrip/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).