refactor(C216-FOLLOWUP-DRY): excerpt e chips compartilhados nos cards de fala (F3) (Related #1325)

This commit is contained in:
Francisco Solla 2026-09-24 18:29:29 -03:00
parent 17cd5d0b8d
commit f5ec847f1f
5 changed files with 272 additions and 80 deletions

View file

@ -0,0 +1,32 @@
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
/**
* C216-FOLLOWUP-DRY — the truncated excerpt shared by the speech cards and the
* C192 provenance block: a leading `… ` and a trailing ` …` around the
* highlighted parts, nothing when there is no excerpt. `quoted` wraps the
* provenance excerpt in typographic quotes; `className` keeps each site's own
* line-height/margin (the sites differ on purpose).
*/
export const SpeechExcerpt = ({
excerpt,
className,
quoted = false,
}: {
excerpt: SpeechHighlightedExcerpt
className: string
quoted?: boolean
}) => {
if (excerpt.parts.length === 0) return null
const prefix = excerpt.truncatedStart ? '… ' : ''
const suffix = excerpt.truncatedEnd ? ' …' : ''
return (
<p className={className}>
{quoted ? `“${prefix}` : prefix}
<SpeechHighlightParts parts={excerpt.parts} />
{quoted ? `${suffix}”` : suffix}
</p>
)
}

View file

@ -2,10 +2,13 @@ import { ExternalLinkIcon, PlayIcon, SparklesIcon } from 'lucide-react'
import Link from 'next/link'
import type { ReactNode } from 'react'
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
import { SpeechExcerpt } from '@/components/campaign/speech/SpeechExcerpt'
import { SpeechNestedCuts } from '@/components/campaign/speech/SpeechNestedCuts'
import {
SpeechResultChips,
type SpeechChipGroup,
} from '@/components/campaign/speech/SpeechResultChips'
import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
import type { SpeechListItemViewModel } from '@/utilities/speech/speechViewModels'
@ -20,18 +23,6 @@ const MAX_KEYWORD_CHIPS = 3
const MetaSeparator = () => <span aria-hidden="true">·</span>
const SpeechExcerpt = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt }) => {
if (excerpt.parts.length === 0) return null
return (
<p className="text-sm leading-relaxed text-foreground/90">
{excerpt.truncatedStart ? '… ' : null}
<SpeechHighlightParts parts={excerpt.parts} />
{excerpt.truncatedEnd ? ' …' : null}
</p>
)
}
/**
* C192 — the theme result provenance: the real excerpt that matched the theme,
* with the expanded term highlighted. It replaces the common excerpt (never
@ -45,11 +36,11 @@ const SpeechThemeProvenance = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt
<p className="text-[11px] font-semibold uppercase tracking-wide text-primary">
Por que apareceu
</p>
<p className="mt-1 text-sm leading-6 text-foreground/90">
{`“${excerpt.truncatedStart ? '… ' : ''}`}
<SpeechHighlightParts parts={excerpt.parts} />
{`${excerpt.truncatedEnd ? ' …' : ''}”`}
</p>
<SpeechExcerpt
excerpt={excerpt}
className="mt-1 text-sm leading-6 text-foreground/90"
quoted
/>
</div>
)
}
@ -210,14 +201,17 @@ export const SpeechResultCard = ({
)
}
const visibleTopics = speech.topics.slice(0, MAX_TOPIC_CHIPS)
const visibleScopes = speech.scopes.slice(0, MAX_SCOPE_CHIPS)
const visibleKeywords = speech.keywords.slice(0, MAX_KEYWORD_CHIPS)
const hiddenChips =
speech.topics.length -
visibleTopics.length +
(speech.scopes.length - visibleScopes.length) +
(speech.keywords.length - visibleKeywords.length)
const chipGroups: SpeechChipGroup[] = [
{ key: 'topics', items: speech.topics, max: MAX_TOPIC_CHIPS },
{ key: 'scopes', items: speech.scopes, max: MAX_SCOPE_CHIPS },
{
key: 'keywords',
items: speech.keywords,
max: MAX_KEYWORD_CHIPS,
variant: 'outline',
className: 'font-normal text-muted-foreground',
},
]
const isTheme = speech.themeMatchTerm !== null
// C192 — a theme card only offers the trecho seek when the speech really
// contains the literal query (a stopword-only segment hit must not claim it).
@ -228,32 +222,12 @@ export const SpeechResultCard = ({
const bodyNode = isTheme ? (
<SpeechThemeProvenance excerpt={speech.excerpt} />
) : (
<SpeechExcerpt excerpt={speech.excerpt} />
)
const chips = (
<>
{visibleTopics.map((topic) => (
<Badge key={topic.value} variant="secondary" className="font-normal">
{topic.label}
</Badge>
))}
{visibleScopes.map((scope) => (
<Badge key={scope.value} variant="secondary" className="font-normal">
{scope.label}
</Badge>
))}
{visibleKeywords.map((keyword) => (
<Badge key={keyword} variant="outline" className="font-normal text-muted-foreground">
{keyword}
</Badge>
))}
{hiddenChips > 0 ? (
<Badge variant="outline" className="font-normal text-muted-foreground">
+{hiddenChips}
</Badge>
) : null}
</>
<SpeechExcerpt
excerpt={speech.excerpt}
className="text-sm leading-relaxed text-foreground/90"
/>
)
const chips = <SpeechResultChips groups={chipGroups} />
const hasChips = Boolean(speech.topics.length || speech.scopes.length || speech.keywords.length)
return (

View file

@ -0,0 +1,50 @@
import { Badge } from '@/components/ui/Badge'
/**
* One chip group: `max` bounds how many of its items the card shows; the hidden
* ones are summed with the other groups' into a single `+N`. The muted group is
* the keyword vocabulary only the Câmara card has.
*/
export type SpeechChipGroup = {
key: string
items: readonly { value: string; label: string }[] | readonly string[]
max: number
variant?: 'secondary' | 'outline'
className?: string
}
/**
* C216-FOLLOWUP-DRY — the chips line shared by the speech cards (C154/C216):
* the leading few per group plus one `+N` badge summing every hidden item. The
* container and the empty guard stay with the caller.
*/
export const SpeechResultChips = ({ groups }: { groups: readonly SpeechChipGroup[] }) => {
const hidden = groups.reduce(
(total, group) => total + Math.max(0, group.items.length - group.max),
0,
)
return (
<>
{groups.flatMap((group) =>
group.items.slice(0, group.max).map((item) => {
const chip = typeof item === 'string' ? { value: item, label: item } : item
return (
<Badge
key={`${group.key}:${chip.value}`}
variant={group.variant ?? 'secondary'}
className={group.className ?? 'font-normal'}
>
{chip.label}
</Badge>
)
}),
)}
{hidden > 0 ? (
<Badge variant="outline" className="font-normal text-muted-foreground">
+{hidden}
</Badge>
) : null}
</>
)
}

View file

@ -1,10 +1,13 @@
import { PlayIcon, VideoIcon } from 'lucide-react'
import Link from 'next/link'
import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
import { SpeechExcerpt } from '@/components/campaign/speech/SpeechExcerpt'
import {
SpeechResultChips,
type SpeechChipGroup,
} from '@/components/campaign/speech/SpeechResultChips'
import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail'
import { WebSpeechPlatformPill } from '@/components/campaign/speech/WebSpeechPlatformPill'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import type { WebSpeechListItemViewModel } from '@/utilities/speech/speechViewModels'
@ -21,10 +24,10 @@ const MAX_SCOPE_CHIPS = 2
export const WebSpeechResultCard = ({ speech }: { speech: WebSpeechListItemViewModel }) => {
const { excerpt, thumbnailUrl, watchHref } = speech
const metaLine = [speech.dateLabel, speech.durationLabel].filter(Boolean).join(' · ')
const visibleTopics = speech.topics.slice(0, MAX_TOPIC_CHIPS)
const visibleScopes = speech.scopes.slice(0, MAX_SCOPE_CHIPS)
const hiddenChips =
speech.topics.length - visibleTopics.length + (speech.scopes.length - visibleScopes.length)
const chipGroups: SpeechChipGroup[] = [
{ key: 'topics', items: speech.topics, max: MAX_TOPIC_CHIPS },
{ key: 'scopes', items: speech.scopes, max: MAX_SCOPE_CHIPS },
]
const hasChips = Boolean(speech.topics.length || speech.scopes.length)
return (
@ -65,31 +68,11 @@ export const WebSpeechResultCard = ({ speech }: { speech: WebSpeechListItemViewM
</div>
<h3 className="mt-2 text-sm font-semibold">{speech.title}</h3>
{excerpt.parts.length > 0 ? (
<p className="mt-2 text-sm leading-6 text-foreground/90">
{excerpt.truncatedStart ? '… ' : null}
<SpeechHighlightParts parts={excerpt.parts} />
{excerpt.truncatedEnd ? ' …' : null}
</p>
) : null}
<SpeechExcerpt excerpt={excerpt} className="mt-2 text-sm leading-6 text-foreground/90" />
{hasChips ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{visibleTopics.map((topic) => (
<Badge key={topic.value} variant="secondary" className="font-normal">
{topic.label}
</Badge>
))}
{visibleScopes.map((scope) => (
<Badge key={scope.value} variant="secondary" className="font-normal">
{scope.label}
</Badge>
))}
{hiddenChips > 0 ? (
<Badge variant="outline" className="font-normal text-muted-foreground">
+{hiddenChips}
</Badge>
) : null}
<SpeechResultChips groups={chipGroups} />
</div>
) : null}
</div>

View file

@ -0,0 +1,153 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
import { SpeechResultCard } from '@/components/campaign/speech/SpeechResultCard'
import { WebSpeechResultCard } from '@/components/campaign/speech/WebSpeechResultCard'
import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
import type {
SpeechListItemViewModel,
WebSpeechListItemViewModel,
} from '@/utilities/speech/speechViewModels'
import { stub } from '../helpers/stub'
const mockAppRouter = stub<Parameters<typeof AppRouterContext.Provider>[0]['value']>({
push: () => {},
replace: () => {},
refresh: () => {},
forward: () => {},
back: () => {},
prefetch: () => Promise.resolve(),
})
const renderWithAppRouter = (element: Parameters<typeof renderToStaticMarkup>[0]) =>
renderToStaticMarkup(createElement(AppRouterContext.Provider, { value: mockAppRouter }, element))
const excerpt = (overrides: Partial<SpeechHighlightedExcerpt> = {}): SpeechHighlightedExcerpt => ({
parts: [
{ text: 'um trecho sobre ', highlighted: false },
{ text: 'saúde', highlighted: true },
],
truncatedStart: true,
truncatedEnd: true,
...overrides,
})
const speech = (overrides: Partial<SpeechListItemViewModel> = {}): SpeechListItemViewModel => ({
id: 1,
speechAtLabel: '11/08/2026',
type: 'Discurso',
durationLabel: '2min',
presidingOfficer: null,
excerpt: excerpt(),
matchKind: 'segment',
topics: [
{ value: 'saude', label: 'Saúde' },
{ value: 'educacao', label: 'Educação' },
{ value: 'cultura', label: 'Cultura' },
{ value: 'esporte', label: 'Esporte' },
],
scopes: [
{ value: 'bahia', label: 'Bahia' },
{ value: 'brasil', label: 'Brasil' },
{ value: 'internacional', label: 'Internacional' },
],
keywords: ['sus', 'vacina', 'atenção básica', 'orçamento'],
municipalities: [],
thumbnailUrl: null,
watchHref: '/campanha/comunicacao/acervo/1',
officialTextUrl: null,
cuts: [],
matchedTextSearch: true,
themeMatchTerm: null,
...overrides,
})
const webSpeech = (
overrides: Partial<WebSpeechListItemViewModel> = {},
): WebSpeechListItemViewModel => ({
id: 9,
title: 'Fala na internet',
platform: { value: 'youtube', label: 'YouTube' },
dateLabel: '12/09/2026',
durationLabel: '5min',
excerpt: excerpt(),
topics: [
{ value: 'saude', label: 'Saúde' },
{ value: 'educacao', label: 'Educação' },
{ value: 'cultura', label: 'Cultura' },
{ value: 'esporte', label: 'Esporte' },
],
scopes: [
{ value: 'bahia', label: 'Bahia' },
{ value: 'brasil', label: 'Brasil' },
{ value: 'internacional', label: 'Internacional' },
],
thumbnailUrl: null,
watchHref: '/campanha/comunicacao/acervo/internet/9',
...overrides,
})
describe('speech result cards excerpt', () => {
it('renders the truncated excerpt with the highlighted part', () => {
const html = renderWithAppRouter(createElement(SpeechResultCard, { speech: speech() }))
expect(html).toContain('text-sm leading-relaxed text-foreground/90')
expect(html).toContain('… ')
expect(html).toContain(' …')
expect(html).toContain('<mark')
expect(html).toContain('saúde')
})
it('omits the excerpt when there is nothing to show', () => {
const html = renderWithAppRouter(
createElement(SpeechResultCard, {
speech: speech({
excerpt: { parts: [], truncatedStart: false, truncatedEnd: false },
}),
}),
)
expect(html).not.toContain('um trecho sobre')
})
it('wraps the theme provenance excerpt in quotes', () => {
const html = renderWithAppRouter(
createElement(SpeechResultCard, { speech: speech({ themeMatchTerm: 'saúde' }) }),
)
expect(html).toContain('Por que apareceu')
expect(html).toContain('“')
expect(html).toContain('”')
})
it('keeps the web card own excerpt classes', () => {
const html = renderWithAppRouter(createElement(WebSpeechResultCard, { speech: webSpeech() }))
expect(html).toContain('mt-2 text-sm leading-6 text-foreground/90')
})
})
describe('speech result cards chips', () => {
it('caps each group and sums every hidden chip on the Câmara card', () => {
const html = renderWithAppRouter(createElement(SpeechResultCard, { speech: speech() }))
expect(html).toContain('Saúde')
expect(html).toContain('Bahia')
expect(html).toContain('sus')
expect(html).not.toContain('Esporte')
expect(html).not.toContain('Internacional')
expect(html).not.toContain('orçamento')
expect(html).toContain('+3')
})
it('sums only the groups the web card has', () => {
const html = renderWithAppRouter(createElement(WebSpeechResultCard, { speech: webSpeech() }))
expect(html).toContain('+2')
expect(html).not.toContain('sus')
})
})