refactor(B193): simplify fixes pós-revisão — mapper único de updates, API de trigger uniforme, grupos de relação extraídos, hardening do sheet host

- Extrai toMunicipalityUpdateViewModel/loadUpdateAuthorNames compartilhados
  (feed + batch da lista); remove avatarClassName morto.
- MunicipalityRelationTriggerProps compartilhado nos 3 wrappers; triggerClassName
  no ExpectedVotesControl; ChipLabel/RelationGroup/CityDash no card.
- Sheet host: cleanup de unmount no overlay (sem drawer órfão), registerFooter
  valida open, branch nulo do handleOpenChange limpa footer; chrome freeze
  documentado.
- A11y/UX: ícone de alerta no rodapé frio (WCAG 1.4.1), iniciais de avatar
  aria-hidden, chip sem_base sem target morto, label da cidade com Tendência,
  aria-label da barra anuncia os 3 cenários, card layout 'colocação · votos'.
- E2E: settleStream no B184 (flake da cópia transitória do streaming), cards
  ancorados por hasText (q= casa prefixos), cor da borda via classe do token.
This commit is contained in:
Francisco Solla 2026-08-10 09:57:50 -03:00
parent 3d8dbae61f
commit b210939226
15 changed files with 215 additions and 134 deletions

View file

@ -8,18 +8,18 @@ import type { MunicipalityRelationEntry } from '@/components/campaign/shared/Mun
import {
MunicipalityRelationEditor,
type MunicipalityRelationMutationResult,
type MunicipalityRelationTriggerProps,
} from '@/components/campaign/shared/MunicipalityRelationEditor'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import type {
EligibleAdvisorOption,
MunicipalityAdvisorSummary,
} from '@/utilities/municipality/municipalityViewModels'
import type { ReactNode } from 'react'
const ADVISORS_ENDPOINT = '/campanha/municipios/advisors'
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar os assessores. Tente novamente.'
type MunicipalityListAdvisorsControlProps = {
type MunicipalityListAdvisorsControlProps = MunicipalityRelationTriggerProps & {
municipalityID: number
municipalityName: string
currentAdvisorIDs: number[]
@ -27,10 +27,6 @@ type MunicipalityListAdvisorsControlProps = {
advisorNamesById: ReadonlyMap<number, MunicipalityAdvisorSummary>
options: EligibleAdvisorOption[]
variant: CampaignCellEditOverlayVariant
/** B193 — dense mobile card trigger (labelled wrapping stack). */
trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode
/** B193 — dense card trigger styling override. */
triggerClassName?: string
}
const entryOf = (option: { id: number; name: string }): MunicipalityRelationEntry => ({

View file

@ -17,6 +17,7 @@ import { Spinner } from '@/components/ui/Spinner'
import { formatElectionNumber } from '@/lib/electionFormat'
import {
DEFAULT_VOTE_ESTIMATE_SCENARIO,
VOTE_ESTIMATE_SCENARIOS,
voteEstimateScenarioLabels,
voteEstimatesEqual,
type VoteEstimateScenario,
@ -50,6 +51,8 @@ type MunicipalityListExpectedVotesControlProps = {
value: VoteEstimateScenarioViewModel,
activeScenario: VoteEstimateScenario,
) => ReactNode
/** B193 — dense card styling override (same slot as the sibling controls). */
triggerClassName?: string
} & (
| { variant: 'popover'; municipalityName?: string }
| { variant: 'sheet'; municipalityName: string }
@ -62,6 +65,7 @@ export const MunicipalityListExpectedVotesControl = ({
pledgeCoverage,
variant,
trigger,
triggerClassName,
}: MunicipalityListExpectedVotesControlProps) => {
const scenarioContext = useMunicipalityEstimateScenarioOptional()
const activeScenario = scenarioContext?.scenario ?? DEFAULT_VOTE_ESTIMATE_SCENARIO
@ -79,13 +83,19 @@ export const MunicipalityListExpectedVotesControl = ({
// An `aria-label` replaces the trigger's content, and the content here is the
// number itself — so the label has to carry it, and name the scenario it
// belongs to (the picker can disagree with the column's sort, E9).
const activeValue = value[activeScenario]
// belongs to (the picker can disagree with the column's sort, E9). All three
// scenarios ride along: the strip shows the whole interval (B193), so the
// announcement must too.
const scenarioSummary = VOTE_ESTIMATE_SCENARIOS.map((scenario) => {
const scenarioValue = value[scenario]
return `${voteEstimateScenarioLabels[scenario]}: ${
scenarioValue == null ? 'sem estimativa' : formatElectionNumber(scenarioValue)
}`
}).join('; ')
const triggerLabel = [
'Editar votos estimados',
municipalityName ? ` em ${municipalityName}` : '',
` — ${voteEstimateScenarioLabels[activeScenario]}: `,
activeValue == null ? 'sem estimativa' : formatElectionNumber(activeValue),
` — ${scenarioSummary}`,
].join('')
return (
@ -98,7 +108,7 @@ export const MunicipalityListExpectedVotesControl = ({
triggerLabel={triggerLabel}
triggerBusy={isPending}
statusMessage={statusMessage}
triggerClassName="group flex w-full items-center justify-center"
triggerClassName={triggerClassName ?? 'group flex w-full items-center justify-center'}
align="center"
contentClassName="w-[15.5rem] p-3"
preventPopoverAutoFocus

View file

@ -7,28 +7,24 @@ import type { MunicipalityRelationEntry } from '@/components/campaign/shared/Mun
import {
MunicipalityRelationEditor,
type MunicipalityRelationMutationResult,
type MunicipalityRelationTriggerProps,
} from '@/components/campaign/shared/MunicipalityRelationEditor'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import type {
EligibleLeadershipOption,
MunicipalityLeadershipSummary,
} from '@/utilities/municipality/municipalityViewModels'
import type { ReactNode } from 'react'
const LEADERSHIPS_ENDPOINT = '/campanha/municipios/leaderships'
const SAVE_ERROR_MESSAGE = 'Não foi possível atualizar as lideranças. Tente novamente.'
type MunicipalityListLeadershipsControlProps = {
type MunicipalityListLeadershipsControlProps = MunicipalityRelationTriggerProps & {
municipalityID: number
municipalityName: string
currentLeadershipIDs: number[]
leadershipNamesById: ReadonlyMap<number, MunicipalityLeadershipSummary>
options: EligibleLeadershipOption[]
variant: CampaignCellEditOverlayVariant
/** B193 — dense mobile card trigger (labelled wrapping stack). */
trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode
/** B193 — dense card trigger styling override. */
triggerClassName?: string
}
const entryOf = (option: { id: number; name: string }): MunicipalityRelationEntry => ({

View file

@ -90,17 +90,23 @@ export const TerritorialClassCardReadout = ({
municipality,
}: {
municipality: MunicipalityListViewModel
}) => (
<CampaignHoverTooltip
content={
municipality.territorialClass === 'sem_base'
? null
: formatTerritorialClassWhy(municipality.territorialClassFactors)
}
align="start"
>
<span className="relative inline-flex items-center">
<TerritorialClassReadout municipality={municipality} />
</span>
</CampaignHoverTooltip>
)
}) => {
// `sem_base` has no reason to explain: the wrapper stays UNpositioned, so a
// tap on the dash falls through to the município link (tap-through
// contract) instead of intercepting it with a dead tooltip target.
const positioned = municipality.territorialClass !== 'sem_base'
return (
<CampaignHoverTooltip
content={
municipality.territorialClass === 'sem_base'
? null
: formatTerritorialClassWhy(municipality.territorialClassFactors)
}
align="start"
>
<span className={cn('inline-flex items-center', positioned && 'relative')}>
<TerritorialClassReadout municipality={municipality} />
</span>
</CampaignHoverTooltip>
)
}

View file

@ -1,6 +1,6 @@
'use client'
import { ChevronDownIcon } from 'lucide-react'
import { ChevronDownIcon, CircleAlertIcon } from 'lucide-react'
import Link from 'next/link'
import { useState, type ReactNode } from 'react'
@ -87,7 +87,12 @@ const chipTriggerClassName = 'min-h-8 rounded-full px-2.5'
/** Ghost trigger styling for the read-only relation groups (no hover pill). */
const relationTriggerClassName = 'min-h-0 rounded-md bg-transparent px-0 hover:bg-transparent'
const chipLabel = 'text-xs font-medium text-muted-foreground'
const ChipLabel = ({ children }: { children: ReactNode }) => (
<span className="text-xs font-medium text-muted-foreground">{children}</span>
)
/** Read-only city rows render a plain dash everywhere a control would sit. */
const CityDash = () => <span className="text-muted-foreground">—</span>
const RelationGroupTrigger = ({
label,
@ -99,11 +104,26 @@ const RelationGroupTrigger = ({
emptyState: ReactNode
}) => (
<div className="flex w-full min-w-0 flex-col gap-1.5">
<span className={chipLabel}>{label}</span>
<ChipLabel>{label}</ChipLabel>
<MunicipalityRelationAvatarStack entries={entries} emptyState={emptyState} wrap />
</div>
)
/**
* One third of the avatar row: the label + the relation control (or the
* read-only display) for a single association type.
*/
const RelationGroup = ({ label, children }: { label: string; children: ReactNode }) => (
<div className="min-w-0 flex-1">
{children ?? (
<div className="flex w-full flex-col gap-1.5">
<ChipLabel>{label}</ChipLabel>
<CityDash />
</div>
)}
</div>
)
/**
* B193 — the dense mobile municipality card: header with the 2022 vote
* position on the right, the estimate scenario bar with the active marker,
@ -226,7 +246,10 @@ export const MunicipalityMobileCard = ({
<div className="flex flex-wrap items-center gap-1.5">
<TerritorialClassCardReadout municipality={municipality} />
{isCity ? (
<Badge variant="outline">Não registrada</Badge>
<span className="inline-flex items-center gap-1.5">
<ChipLabel>Tendência</ChipLabel>
<Badge variant="outline">Não registrada</Badge>
</span>
) : (
<MunicipalityListTrendControl
municipalityID={municipality.id}
@ -237,7 +260,7 @@ export const MunicipalityMobileCard = ({
triggerClassName={chipTriggerClassName}
trigger={(trend) => (
<>
<span className={chipLabel}>Tendência</span>
<ChipLabel>Tendência</ChipLabel>
<Badge
variant={trend.status ? politicalTrendBadgeVariant[trend.status] : 'outline'}
>
@ -260,14 +283,14 @@ export const MunicipalityMobileCard = ({
triggerClassName={chipTriggerClassName}
trigger={(level, note) => (
<>
<span className={chipLabel}>Nível</span>
<ChipLabel>Nível</ChipLabel>
<MunicipalityLevelBadge level={level} note={note} layout="table" />
</>
)}
/>
) : (
<span className="inline-flex items-center gap-1.5">
<span className={chipLabel}>Nível</span>
<ChipLabel>Nível</ChipLabel>
<MunicipalityLevelBadge
level={municipality.engagementLevel}
note={municipality.levelNote}
@ -278,10 +301,8 @@ export const MunicipalityMobileCard = ({
</div>
<div className="flex flex-wrap items-start gap-3">
<div className="min-w-0 flex-1">
{isCity ? (
<span className="text-muted-foreground">—</span>
) : isCoordinator ? (
<RelationGroup label="Assessores">
{isCity ? null : isCoordinator ? (
<MunicipalityListAdvisorsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
@ -301,7 +322,7 @@ export const MunicipalityMobileCard = ({
/>
) : (
<div className="flex w-full flex-col gap-1.5">
<span className={chipLabel}>Assessores</span>
<ChipLabel>Assessores</ChipLabel>
<MunicipalityAdvisorAvatarStack
advisors={advisors}
isPriority={isPriority}
@ -309,11 +330,9 @@ export const MunicipalityMobileCard = ({
/>
</div>
)}
</div>
<div className="min-w-0 flex-1">
{isCity ? (
<span className="text-muted-foreground">—</span>
) : (
</RelationGroup>
<RelationGroup label="Lideranças">
{isCity ? null : (
<MunicipalityListLeadershipsControl
municipalityID={municipality.id}
municipalityName={municipality.name}
@ -331,11 +350,9 @@ export const MunicipalityMobileCard = ({
triggerClassName={relationTriggerClassName}
/>
)}
</div>
<div className="min-w-0 flex-1">
{isCity ? (
<span className="text-muted-foreground">—</span>
) : (
</RelationGroup>
<RelationGroup label="Dobradinhas">
{isCity ? null : (
<MunicipalityStateDeputyRelationCell
municipalityId={municipality.id}
municipalityName={municipality.name}
@ -354,11 +371,11 @@ export const MunicipalityMobileCard = ({
triggerClassName={relationTriggerClassName}
/>
)}
</div>
</RelationGroup>
</div>
{isCity ? (
<span className="text-sm text-muted-foreground">—</span>
<CityDash />
) : update ? (
<>
<button
@ -369,12 +386,15 @@ export const MunicipalityMobileCard = ({
>
<span
className={cn(
'tabular-nums',
'inline-flex items-center gap-1 tabular-nums',
updateIsCold
? 'font-medium text-estimate-pending-foreground'
: 'text-muted-foreground',
)}
>
{updateIsCold ? (
<CircleAlertIcon className="size-3.5 shrink-0" aria-hidden="true" />
) : null}
Última atualização {formatMunicipalitySignalAgeLabel(updateAge)}
</span>
<ChevronDownIcon

View file

@ -20,10 +20,14 @@ export const MunicipalityVotePositionReadout = ({
}) => {
const share = formatVoteSharePercent(position.share)
const rank = formatPlacementOrdinal(position.rank)
// B193 — the dense card reads "colocação · votos" per the wireframe; the
// table keeps "votos · colocação" (desktop untouched by B193).
const metaLine =
layout === 'search'
? formatElectionNumber(position.votes)
: `${formatElectionNumber(position.votes)} · ${rank}`
: layout === 'card'
? `${rank} · ${formatElectionNumber(position.votes)}`
: `${formatElectionNumber(position.votes)} · ${rank}`
const ariaLabel = `${share} da votação estadual, ${formatElectionNumber(position.votes)} votos, ${rank} de ${formatElectionNumber(position.totalUnits)}`
return (

View file

@ -111,6 +111,26 @@ export const CampaignCellEditOverlay = ({
const wasOpenRef = useRef(open)
/** C109 — the shared drawer session this control opened (see the host). */
const sessionIdRef = useRef<number | null>(null)
// Latest values for the mount-only cleanup below (never stale, no deps).
const sharedSheetRef = useRef(sharedSheet)
sharedSheetRef.current = sharedSheet
const onOpenChangeRef = useRef(onOpenChange)
onOpenChangeRef.current = onOpenChange
// The control may unmount with its session open (a filter collapse behind
// the modal): dismiss the host so no orphan drawer keeps a frozen chrome
// and a dead body. The host's session guard makes the call a no-op when
// another control already took over.
useEffect(
() => () => {
const sheet = sharedSheetRef.current
const sessionId = sessionIdRef.current
if (sessionId !== null && sheet) {
sheet.dismissSheet(sessionId, onOpenChangeRef.current)
}
},
[],
)
// Miss #52: without the provider every opened cell keeps its own Drawer
// root. The fallback below stays for prod resilience, but in dev the
@ -191,7 +211,10 @@ export const CampaignCellEditOverlay = ({
// Only the false→true transition opens a session: the controls' callbacks
// are recreated per render (autosave `onOpenChange`, level `handleOpenChange`),
// and calling `openSheet` on every re-render made the host dismiss the
// session it just started (C109 — "sheet quebra ao abrir").
// session it just started (C109 — "sheet quebra ao abrir"). Consequence:
// `title`/`description`/`hasCustomFooter` are frozen while the drawer is
// open — every caller passes static chrome, and the live parts (footer
// buttons/spinner) flow through `setFooterContent` below.
if (!wasOpen) {
sessionIdRef.current = sharedSheet.openSheet({
title,

View file

@ -126,10 +126,13 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode })
)
const registerFooter = useCallback((sessionId: number, footer: ReactNode | null) => {
if (activeSessionRef.current !== sessionId) return
if (activeSessionRef.current !== sessionId || !openRef.current) return
setFooterContent(footer)
}, [])
const openRef = useRef(open)
openRef.current = open
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) return
@ -137,7 +140,10 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode })
const onOpenChange = activeOnOpenChangeRef.current
if (sessionId === null || !onOpenChange) {
setOpen(false)
activeSessionRef.current = null
activeOnOpenChangeRef.current = null
setChrome(null)
setFooterContent(null)
return
}
dismissSheet(sessionId, onOpenChange)

View file

@ -18,7 +18,6 @@ export const MunicipalityRelationAvatarStack = ({
emptyState,
maxVisible = 3,
wrap = false,
avatarClassName,
}: {
entries: MunicipalityRelationEntry[]
emptyState: ReactNode
@ -28,7 +27,6 @@ export const MunicipalityRelationAvatarStack = ({
* row wraps to the available width instead of overlapping in a fixed stack.
*/
wrap?: boolean
avatarClassName?: string
}) => {
if (entries.length === 0) return emptyState
@ -40,13 +38,11 @@ export const MunicipalityRelationAvatarStack = ({
{visible.map((entry) => (
<Avatar
key={entry.id}
className={cn(
'size-8 border-2 border-background',
wrap && 'size-7 border-0',
avatarClassName,
)}
className={cn('size-8 border-2 border-background', wrap && 'size-7 border-0')}
>
<AvatarFallback>
{/* The full names ride in the sr-only span below (or the trigger's
aria-label); initials alone would be announced twice. */}
<AvatarFallback aria-hidden="true">
{campaignUserInitials(entry.initialsLabel ?? entry.label)}
</AvatarFallback>
</Avatar>

View file

@ -29,7 +29,17 @@ export type MunicipalityRelationMutationResult =
}
| { status: 'error'; message: string }
type MunicipalityRelationEditorProps = {
/**
* B193 — the closed-display override the dense mobile card passes through the
* relation wrappers (Advisors/Lideranças/Dobradinhas) to this editor.
*/
export type MunicipalityRelationTriggerProps = {
trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode
/** B193 — dense card trigger styling override (no min-height/hover pill). */
triggerClassName?: string
}
type MunicipalityRelationEditorProps = MunicipalityRelationTriggerProps & {
municipalityName: string
currentIDs: number[]
knownEntries?: MunicipalityRelationEntry[]
@ -44,16 +54,6 @@ type MunicipalityRelationEditorProps = {
createErrorMessage?: string
triggerLabel: (entries: MunicipalityRelationEntry[]) => string
emptyState: ReactNode
/**
* B193 — replaces the default avatar-stack trigger (e.g. the dense mobile
* card's labelled, wrapping stack). Same pattern as `RelationChipCell`'s
* `trigger` render prop: the overlay machinery stays, only the closed
* display changes. Receives the relation's own `emptyState` so the custom
* trigger can render the same absence affordance the default stack would.
*/
trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode
/** B193 — dense card trigger styling override (no min-height/hover pill). */
triggerClassName?: string
createLabel: (name: string) => string
createMaxLength?: number
sortSelected?: boolean

View file

@ -6,14 +6,14 @@ import type { MunicipalityRelationEntry } from '@/components/campaign/shared/Mun
import {
MunicipalityRelationEditor,
type MunicipalityRelationMutationResult,
type MunicipalityRelationTriggerProps,
} from '@/components/campaign/shared/MunicipalityRelationEditor'
import { stateDeputyDisplayName } from '@/lib/stateDeputyNameParty'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
import type { StateDeputyRelationOption } from '@/utilities/campaignRelationOptions'
import type { ReactNode } from 'react'
type MunicipalityStateDeputyRelationCellProps = {
type MunicipalityStateDeputyRelationCellProps = MunicipalityRelationTriggerProps & {
municipalityId: number
municipalityName: string
stateDeputyIDs: number[]
@ -24,10 +24,6 @@ type MunicipalityStateDeputyRelationCellProps = {
) => Promise<CampaignFormActionState>
createAction: MunicipalityStateDeputyCreateAction
editorVariant: CampaignCellEditOverlayVariant
/** B193 — dense mobile card trigger (labelled wrapping stack). */
trigger?: (entries: MunicipalityRelationEntry[], emptyState: ReactNode) => ReactNode
/** B193 — dense card trigger styling override. */
triggerClassName?: string
}
export type MunicipalityStateDeputyCreateAction = (

View file

@ -38,6 +38,39 @@ export const parseMunicipalityUpdateFeedParams = (
return { page }
}
/** One doc → view-model mapping, shared by the feed and the list batch (B193). */
const toMunicipalityUpdateViewModel = (
update: MunicipalityUpdate,
authorNameById: ReadonlyMap<number, string>,
): MunicipalityUpdateViewModel => {
const polarity = update.polarity as MunicipalityUpdatePolarity | undefined
return {
id: update.id,
authorName: authorNameById.get(relationshipId(update.author) ?? -1) ?? 'Usuário',
createdAt: update.createdAt,
body: update.body ?? null,
polarity: polarity && municipalityUpdatePolarities.includes(polarity) ? polarity : 'neutra',
urgent: Boolean(update.urgent),
activeVolunteers: update.activeVolunteers ?? null,
newSupports: update.newSupports ?? null,
adversarySignal: Boolean(update.adversarySignal),
}
}
const loadUpdateAuthorNames = async (
payload: Payload,
updates: MunicipalityUpdate[],
): Promise<ReadonlyMap<number, string>> => {
const authorIDs = [
...new Set(
updates
.map((update) => relationshipId(update.author))
.filter((id): id is number => id !== null),
),
]
return loadCampaignUserNamesByIds(payload, authorIDs)
}
export const loadMunicipalityUpdatesFeed = async (
payload: Payload,
user: CampaignUser,
@ -62,31 +95,11 @@ export const loadMunicipalityUpdatesFeed = async (
overrideAccess: false,
})
const authorIDs = [
...new Set(
result.docs
.map((update) => relationshipId((update as MunicipalityUpdate).author))
.filter((id): id is number => id !== null),
),
]
const authorNameById = await loadCampaignUserNamesByIds(payload, authorIDs)
const docs = result.docs as MunicipalityUpdate[]
const authorNameById = await loadUpdateAuthorNames(payload, docs)
return {
updates: result.docs.map((update) => {
const doc = update as MunicipalityUpdate
const polarity = doc.polarity as MunicipalityUpdatePolarity | undefined
return {
id: doc.id,
authorName: authorNameById.get(relationshipId(doc.author) ?? -1) ?? 'Usuário',
createdAt: doc.createdAt,
body: doc.body ?? null,
polarity: polarity && municipalityUpdatePolarities.includes(polarity) ? polarity : 'neutra',
urgent: Boolean(doc.urgent),
activeVolunteers: doc.activeVolunteers ?? null,
newSupports: doc.newSupports ?? null,
adversarySignal: Boolean(doc.adversarySignal),
}
}),
updates: docs.map((update) => toMunicipalityUpdateViewModel(update, authorNameById)),
totalDocs: result.totalDocs,
totalPages: result.totalPages,
page: result.page ?? state.page,
@ -140,31 +153,13 @@ export const loadMunicipalityLastUpdates = async (
overrideAccess: false,
})
const authorIDs = [
...new Set(
result.docs
.map((update) => relationshipId((update as MunicipalityUpdate).author))
.filter((id): id is number => id !== null),
),
]
const authorNameById = await loadCampaignUserNamesByIds(payload, authorIDs)
const authorNameById = await loadUpdateAuthorNames(payload, result.docs as MunicipalityUpdate[])
const lastByMunicipality = new Map<number, MunicipalityUpdateViewModel>()
for (const update of result.docs as MunicipalityUpdate[]) {
const municipalityID = relationshipId(update.municipality)
if (municipalityID === null || lastByMunicipality.has(municipalityID)) continue
const polarity = update.polarity as MunicipalityUpdatePolarity | undefined
lastByMunicipality.set(municipalityID, {
id: update.id,
authorName: authorNameById.get(relationshipId(update.author) ?? -1) ?? 'Usuário',
createdAt: update.createdAt,
body: update.body ?? null,
polarity: polarity && municipalityUpdatePolarities.includes(polarity) ? polarity : 'neutra',
urgent: Boolean(update.urgent),
activeVolunteers: update.activeVolunteers ?? null,
newSupports: update.newSupports ?? null,
adversarySignal: Boolean(update.adversarySignal),
})
lastByMunicipality.set(municipalityID, toMunicipalityUpdateViewModel(update, authorNameById))
}
return lastByMunicipality

View file

@ -888,6 +888,9 @@ test.describe('Municípios — filtro e cards sem moldura no celular (B184)', ()
await campaign.login(page, coordinator.email!, password)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
// C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell;
// strict-mode locators below would match BOTH copies while it settles.
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
// The mobile standard hides the field label; the input keeps its name via
// aria-label, and the filter form is a borderless sticky bar.
@ -978,7 +981,12 @@ test.describe('Municípios — card denso mobile (B193)', () => {
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
const card = page.locator('[data-view="mobile-cards"] article').first()
// `?q=` matches name PREFIXES ("São Félix" also returns "São Félix do
// Coribe"), so the card is anchored on the municipality's own name.
const card = page
.locator('[data-view="mobile-cards"] article')
.filter({ hasText: municipality.name })
.first()
await expect(card).toBeVisible()
await card.getByRole('button', { name: /Editar votos estimados/ }).click()
@ -1014,7 +1022,12 @@ test.describe('Municípios — card denso mobile (B193)', () => {
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
const card = page.locator('[data-view="mobile-cards"] article').first()
// `?q=` matches name PREFIXES ("São Félix" also returns "São Félix do
// Coribe"), so the card is anchored on the municipality's own name.
const card = page
.locator('[data-view="mobile-cards"] article')
.filter({ hasText: municipality.name })
.first()
await expect(card).toBeVisible()
await card.getByText('Nível', { exact: true }).click()
@ -1043,11 +1056,18 @@ test.describe('Municípios — card denso mobile (B193)', () => {
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
const card = page.locator('[data-view="mobile-cards"] article').first()
// `?q=` matches name PREFIXES ("São Félix" also returns "São Félix do
// Coribe"), so the card is anchored on the municipality's own name.
const card = page
.locator('[data-view="mobile-cards"] article')
.filter({ hasText: municipality.name })
.first()
await expect(card).toBeVisible()
await expect(card).toHaveCSS('border-right-width', '6px')
await expect(card).toHaveCSS('border-right-color', 'rgb(197, 20, 20)')
// The accent color is a token contract, not a literal: assert the utility
// class that paints it (`border-r-primary`) instead of the resolved color.
await expect(card).toHaveClass(/border-r-primary/)
})
test('footer expands to the last update card and offers register; empty footer is a direct CTA', async ({
@ -1074,7 +1094,11 @@ test.describe('Municípios — card denso mobile (B193)', () => {
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(withUpdate.name)}`,
)
const card = page.locator('[data-view="mobile-cards"] article').first()
// `?q=` matches name prefixes — anchor on the municipality's own name.
const card = page
.locator('[data-view="mobile-cards"] article')
.filter({ hasText: withUpdate.name })
.first()
await expect(card).toBeVisible()
// The footer button expands the card bottom and reveals the last update
@ -1098,7 +1122,10 @@ test.describe('Municípios — card denso mobile (B193)', () => {
// IS the register CTA, without chevron or expansion.
const empty = await fixtures.claimMunicipality()
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(empty.name)}`)
const emptyCard = page.locator('[data-view="mobile-cards"] article').first()
const emptyCard = page
.locator('[data-view="mobile-cards"] article')
.filter({ hasText: empty.name })
.first()
await expect(emptyCard).toBeVisible()
await expect(emptyCard.getByRole('button', { name: /Última atualização/ })).toHaveCount(0)
const emptyCta = emptyCard.getByRole('button', { name: /Registrar atualização em/ })

View file

@ -89,7 +89,9 @@ const overlayCases: OverlayCase[] = [
},
{
name: 'votos estimados',
triggerLabel: `Editar votos estimados em ${MUNICIPALITY_NAME} — Média: 1.200`,
// B193 — the votes trigger announces the whole interval (the strip shows
// all three scenarios), not just the active one.
triggerLabel: `Editar votos estimados em ${MUNICIPALITY_NAME} — Pessimista: sem estimativa; Média: 1.200; Otimista: sem estimativa`,
drawerTitle: 'Editar votos estimados',
element: (variant) =>
createElement(MunicipalityListExpectedVotesControl, {

View file

@ -599,6 +599,10 @@ describe('campaign visual foundation', () => {
// 40 days old, so the footer reads "há 40 dias" and paints cold — and the
// expansion stays collapsed (the update body is not in the DOM).
expect(html).toContain('Última atualização há 40 dias')
// The cold class is applied ONLY on the mobile footer (the desktop column
// uses `data-signal="cold"` on the readout's own span), so this pins the
// footer's cold paint without relying on the desktop tree.
expect(html).toContain('text-estimate-pending-foreground')
expect(html).not.toContain('Visita ao comitê da região.')
expect(html).toContain('data-signal="cold"')
})