+ {visible.map((entry) => (
+
+ {/* The full names ride in the sr-only span below (or the trigger's
+ aria-label); initials alone would be announced twice. */}
+
{campaignUserInitials(entry.initialsLabel ?? entry.label)}
diff --git a/src/components/campaign/shared/MunicipalityRelationEditor.tsx b/src/components/campaign/shared/MunicipalityRelationEditor.tsx
index 17f5f874..36b9e783 100644
--- a/src/components/campaign/shared/MunicipalityRelationEditor.tsx
+++ b/src/components/campaign/shared/MunicipalityRelationEditor.tsx
@@ -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[]
@@ -76,6 +86,8 @@ export const MunicipalityRelationEditor = ({
onToggle,
onCreate,
onCreated,
+ trigger,
+ triggerClassName,
}: MunicipalityRelationEditorProps) => {
const [open, setOpen] = useState(false)
const [selectedIDs, setSelectedIDs] = useState(currentIDs)
@@ -270,10 +282,15 @@ export const MunicipalityRelationEditor = ({
triggerBusy={isPending}
statusMessage={statusMessage}
tooltipContent={tooltipContent}
+ triggerClassName={triggerClassName}
contentClassName="w-80 p-0"
sheetBodyClassName="px-0 pt-2"
trigger={
-
+ trigger ? (
+ trigger(selectedEntries, emptyState)
+ ) : (
+
+ )
}
>
diff --git a/src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx b/src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx
index cabd2658..a5509603 100644
--- a/src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx
+++ b/src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx
@@ -6,13 +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'
-type MunicipalityStateDeputyRelationCellProps = {
+type MunicipalityStateDeputyRelationCellProps = MunicipalityRelationTriggerProps & {
municipalityId: number
municipalityName: string
stateDeputyIDs: number[]
@@ -64,6 +65,8 @@ export const MunicipalityStateDeputyRelationCell = ({
commitAction,
createAction,
editorVariant,
+ trigger,
+ triggerClassName,
}: MunicipalityStateDeputyRelationCellProps) => {
const toggle = async (
stateDeputyID: number,
@@ -122,6 +125,8 @@ export const MunicipalityStateDeputyRelationCell = ({
}`
}
emptyState={—}
+ trigger={trigger}
+ triggerClassName={triggerClassName}
createLabel={(name) => `Criar dobradinha “${name}”`}
createMaxLength={120}
sortSelected={false}
diff --git a/src/utilities/municipality/municipalityPageData.ts b/src/utilities/municipality/municipalityPageData.ts
index ac57d42f..f95e551e 100644
--- a/src/utilities/municipality/municipalityPageData.ts
+++ b/src/utilities/municipality/municipalityPageData.ts
@@ -58,6 +58,7 @@ import {
territorialClassSortWeight,
type MunicipalityTerritorialClassification,
} from '@/utilities/municipality/municipalityTerritorialClass'
+import { loadMunicipalityLastUpdates } from '@/utilities/municipality/municipalityUpdatePageData'
import {
loadMunicipalityLeadershipSummaries,
municipalityListSelect,
@@ -603,13 +604,22 @@ export const loadMunicipalityListPageBundle = async (
// B155 — the leaderships of the visible page only (staff surfaces; the
// leader view already returned above). One reverse batch: ids per município
// for the rows, contact names for the chips.
- const leadershipBundle = isStaff
- ? await loadMunicipalityLeadershipSummaries(
- payload,
- user,
- pageDocs.map((municipality) => municipality.id),
- )
- : null
+ const [leadershipBundle, lastUpdatesByMunicipalityID] = await Promise.all([
+ isStaff
+ ? loadMunicipalityLeadershipSummaries(
+ payload,
+ user,
+ pageDocs.map((municipality) => municipality.id),
+ )
+ : null,
+ isStaff
+ ? loadMunicipalityLastUpdates(
+ payload,
+ user,
+ pageDocs.map((d) => d.id),
+ )
+ : new Map(),
+ ])
return {
municipalities: pageDocs.map((municipality) => {
@@ -622,6 +632,7 @@ export const loadMunicipalityListPageBundle = async (
leadershipBundle?.leadershipIDsByMunicipality.get(municipality.id) ?? [],
isCity,
isCity ? classBySlug?.get(SALVADOR_CITY_SLUG) : undefined,
+ lastUpdatesByMunicipalityID.get(municipality.id) ?? null,
)
}),
totalDocs,
diff --git a/src/utilities/municipality/municipalityUpdatePageData.ts b/src/utilities/municipality/municipalityUpdatePageData.ts
index 82be2b91..252f57e5 100644
--- a/src/utilities/municipality/municipalityUpdatePageData.ts
+++ b/src/utilities/municipality/municipalityUpdatePageData.ts
@@ -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,
+): 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> => {
+ 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,
@@ -102,3 +115,52 @@ export const loadMunicipalityUpdatesPreview = async (
0,
3,
)
+
+/**
+ * B193 — the LAST update per listed municipality, for the mobile card's
+ * expandable footer. One batch over the visible page ids: docs arrive sorted
+ * by `-createdAt`, so the first row per municipality is the latest. The read
+ * runs with `overrideAccess: false`, so `canReadMunicipalityUpdate` scopes it
+ * to the actor's portfolio exactly like the C89 feed loader.
+ */
+export const loadMunicipalityLastUpdates = async (
+ payload: Payload,
+ user: CampaignUser,
+ municipalityIDs: readonly number[],
+): Promise> => {
+ const ids = [...new Set(municipalityIDs)]
+ if (ids.length === 0) return new Map()
+
+ const result = await payload.find({
+ collection: 'municipalityUpdate',
+ where: { municipality: { in: ids } },
+ depth: 0,
+ limit: 0,
+ pagination: false,
+ sort: '-createdAt',
+ select: {
+ municipality: true,
+ author: true,
+ polarity: true,
+ urgent: true,
+ activeVolunteers: true,
+ newSupports: true,
+ adversarySignal: true,
+ body: true,
+ createdAt: true,
+ },
+ user,
+ overrideAccess: false,
+ })
+
+ const authorNameById = await loadUpdateAuthorNames(payload, result.docs as MunicipalityUpdate[])
+
+ const lastByMunicipality = new Map()
+ for (const update of result.docs as MunicipalityUpdate[]) {
+ const municipalityID = relationshipId(update.municipality)
+ if (municipalityID === null || lastByMunicipality.has(municipalityID)) continue
+ lastByMunicipality.set(municipalityID, toMunicipalityUpdateViewModel(update, authorNameById))
+ }
+
+ return lastByMunicipality
+}
diff --git a/src/utilities/municipality/municipalityViewModels.ts b/src/utilities/municipality/municipalityViewModels.ts
index 7a448259..39a8fd85 100644
--- a/src/utilities/municipality/municipalityViewModels.ts
+++ b/src/utilities/municipality/municipalityViewModels.ts
@@ -24,6 +24,7 @@ import {
type MunicipalityTerritorialClassification,
type TerritorialFactor,
} from '@/utilities/municipality/municipalityTerritorialClass'
+import type { MunicipalityUpdateViewModel } from '@/utilities/municipality/municipalityUpdatePageData'
import type { StateDeputySummary } from '@/utilities/stateDeputyData'
import type { MunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
import { createEmptyMunicipalityPledgeAggregate } from '@/utilities/votePledgeViews'
@@ -81,6 +82,13 @@ export type MunicipalityListViewModel = {
* ordering.
*/
lastSignalAt: string | null
+ /**
+ * B193 — the LAST municipality update (body, polarity, author), for the
+ * mobile card's expandable footer; `null` when nothing was ever recorded
+ * (the footer becomes a direct register CTA). Staff-only — the leader never
+ * reaches the list page.
+ */
+ lastUpdate: MunicipalityUpdateViewModel | null
expectedVotes: VoteEstimateScenarioViewModel
politicalTrendStatus: PoliticalTrendStatus | null
politicalTrendNote: string | null
@@ -109,6 +117,8 @@ export const toMunicipalityListViewModel = (
isCity = false,
/** B178 — override for the city's AGGREGATE class (the artifact lookup by slug would miss). */
territorialClassOverride?: MunicipalityTerritorialClassification,
+ /** B193 — the last municipality update for the mobile card footer (staff-only). */
+ lastUpdate: MunicipalityUpdateViewModel | null = null,
): MunicipalityListViewModel => {
const territorialClass =
territorialClassOverride ?? computeMunicipalityTerritorialClass(municipality.slug)
@@ -136,6 +146,7 @@ export const toMunicipalityListViewModel = (
municipality.lastUpdateAt ?? null,
pledges?.lastPledgeAt ?? null,
),
+ lastUpdate,
expectedVotes: toVoteEstimateScenarioViewModel(municipality.expectedVotes),
politicalTrendStatus: municipality.politicalTrend?.status ?? null,
politicalTrendNote: municipality.politicalTrend?.note ?? null,
diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts
index c543d99b..da75d312 100644
--- a/tests/e2e/campaignMunicipalities.e2e.spec.ts
+++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts
@@ -786,15 +786,13 @@ test.describe('Municípios — cards no celular (B42)', () => {
// Anywhere outside a control is the município's own link: the click lands on
// the stretched `after:inset-0` overlay, which belongs to the card heading.
- // The tap goes on a field LABEL inside the metrics grid on purpose — the
- // first version of this card positioned each grid cell instead of each
- // control, which lifted the labels and their padding above the overlay and
- // made half the card a tap that neither edited nor navigated.
+ // B193 recomposed the card and every label became a trigger, so the tap goes
+ // on the card's top-left padding corner — the one area no control covers.
// `force`: Playwright's hit-target check retries forever here precisely
// BECAUSE the stretched overlay intercepts the pointer — which is the
- // behavior under test. Force dispatches the tap at the label's point, the
- // overlay receives it (as a user's finger would), and the card navigates.
- await card.getByText('Tendência', { exact: true }).click({ force: true })
+ // behavior under test. Force dispatches the tap at that point, the overlay
+ // receives it (as a user's finger would), and the card navigates.
+ await card.click({ position: { x: 6, y: 6 }, force: true })
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`)
await expect(campaignPageChrome(page, municipality.name)).toBeVisible()
})
@@ -847,9 +845,12 @@ test.describe('Municípios — cards no celular (B42)', () => {
// The toast is intentionally transient; the persisted freshness label is
// the stable success contract and must arrive after the server response.
- await expect(
- card.getByRole('button', { name: `Registrar atualização em ${municipality.name} — hoje` }),
- ).toBeVisible()
+ // B193 — once the município HAS an update, the card footer becomes the
+ // expandable "Última atualização" row (the register CTA lives inside the
+ // expansion), so the freshness contract is asserted on the footer itself.
+ const updateFooter = card.getByRole('button', { name: /^Última atualização/ })
+ await expect(updateFooter).toContainText('hoje')
+ await updateFooter.click()
// Reopen: the chrome is rebuilt fresh, and the custom footer comes back.
await card.getByRole('button', { name: /^Registrar atualização em/ }).click()
@@ -890,6 +891,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.
@@ -955,6 +959,184 @@ test.describe('Municípios — filtro e cards sem moldura no celular (B184)', ()
})
})
+/**
+ * B193 (2026-08-10): the mobile card is a dense composition — scenario bar
+ * with the active marker, chips (classe/tendência/nível), three labelled
+ * avatar groups — where every data point is its own bottom-sheet trigger
+ * (edit-where-you-see), the priority município gets a right accent border,
+ * and the "Última atualização" footer expands to reveal the last update card
+ * (or becomes a direct register CTA when nothing was ever recorded).
+ */
+test.describe('Municípios — card denso mobile (B193)', () => {
+ test.use({ viewport: { width: 390, height: 844 } })
+
+ test('scenario bar opens the estimate sheet and reflects saved values', async ({
+ campaign,
+ page,
+ }) => {
+ const { fixtures } = campaign
+ const coordinator = await fixtures.createCampaignUser('coordinator', {
+ name: fixtures.value('Coordenadora B193'),
+ })
+ const municipality = await fixtures.claimMunicipality()
+
+ await campaign.login(page, coordinator.email!, coordinator.password)
+ await page.goto(
+ `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
+ )
+ // `?q=` matches name PREFIXES ("Coribe" also returns "São Félix do
+ // Coribe"), so the card is anchored on the municipality's own name link.
+ const card = page
+ .locator('[data-view="mobile-cards"] article')
+ .filter({ has: page.getByRole('link', { name: municipality.name, exact: true }) })
+ .first()
+ await expect(card).toBeVisible()
+
+ await card.getByRole('button', { name: /Editar votos estimados/ }).click()
+ const drawer = page.getByRole('dialog', { name: 'Editar votos estimados' })
+ await expect(drawer).toBeVisible()
+
+ await drawer.getByLabel('Pessimista', { exact: true }).fill('7500')
+ await drawer.getByLabel('Média', { exact: true }).fill('8000')
+ await drawer.getByLabel('Otimista', { exact: true }).fill('10000')
+ // The autosave announces "Salvando…" through the trigger's own live region
+ // (next to the button, outside the drawer); closing flushes the save.
+ await drawer.getByRole('button', { name: 'Fechar' }).click()
+ await expect(drawer).toBeHidden()
+
+ // The strip is the live autosave value: the card shows all three
+ // scenarios after the save echoed back.
+ await expect(card.getByText('7.500')).toBeVisible()
+ await expect(card.getByText('8.000')).toBeVisible()
+ await expect(card.getByText('10.000')).toBeVisible()
+ await expect(page).toHaveURL(
+ `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
+ )
+ })
+
+ test('the Nível chip (label included) opens the level sheet', async ({ campaign, page }) => {
+ const { fixtures } = campaign
+ const coordinator = await fixtures.createCampaignUser('coordinator', {
+ name: fixtures.value('Coordenadora B193 Nível'),
+ })
+ const municipality = await fixtures.claimMunicipality()
+
+ await campaign.login(page, coordinator.email!, coordinator.password)
+ await page.goto(
+ `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
+ )
+ // `?q=` matches name PREFIXES ("Coribe" also returns "São Félix do
+ // Coribe"), so the card is anchored on the municipality's own name link.
+ const card = page
+ .locator('[data-view="mobile-cards"] article')
+ .filter({ has: page.getByRole('link', { name: municipality.name, exact: true }) })
+ .first()
+ await expect(card).toBeVisible()
+
+ await card.getByText('Nível', { exact: true }).click()
+ const drawer = page.getByRole('dialog', { name: 'Registrar nível de envolvimento' })
+ await expect(drawer).toBeVisible()
+ await expect(drawer.getByText(municipality.name, { exact: true })).toBeVisible()
+ await expect(page).toHaveURL(
+ `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
+ )
+ })
+
+ test('priority município gets the right accent border (B193)', async ({ campaign, page }) => {
+ const { fixtures } = campaign
+ const coordinator = await fixtures.createCampaignUser('coordinator', {
+ name: fixtures.value('Coordenadora B193 Prio'),
+ })
+ const municipality = await fixtures.claimMunicipality()
+ await fixtures.payload.update({
+ collection: 'municipality',
+ id: municipality.id,
+ data: { priority: 'alta' },
+ depth: 0,
+ })
+
+ await campaign.login(page, coordinator.email!, coordinator.password)
+ await page.goto(
+ `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
+ )
+ // `?q=` matches name PREFIXES ("Coribe" also returns "São Félix do
+ // Coribe"), so the card is anchored on the municipality's own name link.
+ const card = page
+ .locator('[data-view="mobile-cards"] article')
+ .filter({ has: page.getByRole('link', { name: municipality.name, exact: true }) })
+ .first()
+ await expect(card).toBeVisible()
+
+ await expect(card).toHaveCSS('border-right-width', '6px')
+ // 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 ({
+ campaign,
+ page,
+ }) => {
+ const { fixtures } = campaign
+ const coordinator = await fixtures.createCampaignUser('coordinator', {
+ name: fixtures.value('Coordenadora B193 Upd'),
+ })
+ const withUpdate = await fixtures.claimMunicipality()
+ await fixtures.payload.create({
+ collection: 'municipalityUpdate',
+ data: {
+ municipality: withUpdate.id,
+ author: coordinator.id,
+ polarity: 'boa',
+ body: `Fato de campo B193 em ${withUpdate.name}`,
+ },
+ depth: 0,
+ })
+
+ await campaign.login(page, coordinator.email!, coordinator.password)
+ await page.goto(
+ `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(withUpdate.name)}`,
+ )
+ // `?q=` matches name prefixes — anchor on the municipality's own name link.
+ const card = page
+ .locator('[data-view="mobile-cards"] article')
+ .filter({ has: page.getByRole('link', { name: withUpdate.name, exact: true }) })
+ .first()
+ await expect(card).toBeVisible()
+
+ // The footer button expands the card bottom and reveals the last update
+ // card (no surface change) plus the register CTA.
+ const footer = card.getByRole('button', { name: /Última atualização/ })
+ await expect(footer).toHaveAttribute('aria-expanded', 'false')
+ await footer.click()
+ await expect(footer).toHaveAttribute('aria-expanded', 'true')
+ await expect(card.getByText(`Fato de campo B193 em ${withUpdate.name}`)).toBeVisible()
+ await expect(card.getByText('Boa', { exact: true })).toBeVisible()
+
+ const registerCta = card.getByRole('button', { name: /Registrar atualização em/ })
+ await registerCta.click()
+ const drawer = page.getByRole('dialog', { name: 'Registrar atualização' })
+ await expect(drawer).toBeVisible()
+ await expect(drawer.getByText(withUpdate.name, { exact: true })).toBeVisible()
+ await drawer.getByRole('button', { name: 'Cancelar' }).click()
+ await expect(drawer).toBeHidden()
+
+ // A município with no update at all has no expandable footer: the footer
+ // 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')
+ .filter({ has: page.getByRole('link', { name: empty.name, exact: true }) })
+ .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/ })
+ await emptyCta.click()
+ await expect(page.getByRole('dialog', { name: 'Registrar atualização' })).toBeVisible()
+ })
+})
+
/**
* B126: FAB quick-actions overlay — no persistent drawer; opens on demand.
*/
diff --git a/tests/e2e/campaignUpdatesMobile.e2e.spec.ts b/tests/e2e/campaignUpdatesMobile.e2e.spec.ts
index 7de7e788..d0995d68 100644
--- a/tests/e2e/campaignUpdatesMobile.e2e.spec.ts
+++ b/tests/e2e/campaignUpdatesMobile.e2e.spec.ts
@@ -24,6 +24,10 @@ const seedUpdates = async (
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
+ // Explicitly increasing timestamps: rows created back-to-back can share the
+ // same Postgres `now()` (transaction-scoped), which makes the feed's
+ // `sort: -createdAt` unstable and the "#8 on top" assertion flaky.
+ const base = Date.now()
for (let i = 0; i < 8; i += 1) {
await fixtures.payload.create({
collection: 'municipalityUpdate',
@@ -32,6 +36,7 @@ const seedUpdates = async (
author: coordinator.id,
polarity: i % 2 === 0 ? 'boa' : 'ruim',
body: `Fato de campo C106 #${i + 1} em ${municipality.name}`,
+ createdAt: new Date(base + (i + 1) * 1000).toISOString(),
},
depth: 0,
})
diff --git a/tests/unit/campaignCellEditOverlay.unit.spec.ts b/tests/unit/campaignCellEditOverlay.unit.spec.ts
index 243a881d..183dc676 100644
--- a/tests/unit/campaignCellEditOverlay.unit.spec.ts
+++ b/tests/unit/campaignCellEditOverlay.unit.spec.ts
@@ -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, {
diff --git a/tests/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts
index 9294f659..800fed71 100644
--- a/tests/unit/campaignComponents.unit.spec.ts
+++ b/tests/unit/campaignComponents.unit.spec.ts
@@ -80,6 +80,7 @@ const responsiveMunicipality = stub({
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
+ lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@@ -221,6 +222,7 @@ describe('campaign visual foundation', () => {
priority: 'alta',
lastUpdateAt: null,
lastSignalAt: null,
+ lastUpdate: null,
expectedVotes: { pessimistic: null, central: 1500, optimistic: null },
politicalTrendStatus: 'favoravel',
politicalTrendNote: null,
@@ -267,6 +269,7 @@ describe('campaign visual foundation', () => {
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
+ lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@@ -361,6 +364,7 @@ describe('campaign visual foundation', () => {
leadershipIDs: [],
lastUpdateAt: null,
lastSignalAt: null,
+ lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@@ -421,6 +425,7 @@ describe('campaign visual foundation', () => {
stateDeputyIDs: [],
lastUpdateAt: null,
lastSignalAt: null,
+ lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@@ -553,6 +558,21 @@ describe('campaign visual foundation', () => {
priority: 'alta',
lastUpdateAt: staleSignal,
lastSignalAt: staleSignal,
+ // B193 — the mobile card footer's "Última atualização" age comes
+ // from the LAST UPDATE (not the pledge-merged frescor): a stale
+ // update paints the footer cold, and the register CTA lives inside
+ // the collapsed expansion.
+ lastUpdate: {
+ id: 99,
+ authorName: 'Maria',
+ createdAt: staleSignal,
+ body: 'Visita ao comitê da região.',
+ polarity: 'boa',
+ urgent: false,
+ activeVolunteers: null,
+ newSupports: null,
+ adversarySignal: false,
+ },
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@@ -575,9 +595,16 @@ describe('campaign visual foundation', () => {
// "Sem assessor" still appears in the Assessores column (the avatar stack's
// empty state); the status badge is what escalates to "Sem responsável".
expect(html).toContain('Sem responsável')
- expect(html).toContain(`há ${staleDays} dias`)
+ // The mobile footer's age is the last UPDATE's age (B193): the update is
+ // 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"')
- expect(html).toContain('Última atualização')
})
it('reads "Sem sinal" when nothing was ever recorded for the município', () => {
@@ -600,6 +627,7 @@ describe('campaign visual foundation', () => {
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
+ lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@@ -644,6 +672,7 @@ describe('campaign visual foundation', () => {
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
+ lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@@ -694,6 +723,7 @@ describe('campaign visual foundation', () => {
priority: 'alta',
lastUpdateAt: null,
lastSignalAt: null,
+ lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@@ -892,6 +922,7 @@ describe('campaign visual foundation', () => {
priority: 'normal' as const,
lastUpdateAt: null,
lastSignalAt: null,
+ lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts
index 6584da75..ae0ecbe5 100644
--- a/tests/unit/codebaseConventions.unit.spec.ts
+++ b/tests/unit/codebaseConventions.unit.spec.ts
@@ -552,6 +552,10 @@ describe('advisor scope fragment comes from access/shared.ts', () => {
// B155 — the municipality list surface reads `leadership.municipalities`
// by reverse batch; same distinct form as `leadershipData.ts` above.
'src/utilities/municipality/municipalityViewModels.ts',
+ // B193 — the mobile card footer batches the LAST update per listed
+ // municipality (a domain filter over the visible page ids); the actor
+ // scope still comes from `overrideAccess: false` + `user`.
+ 'src/utilities/municipality/municipalityUpdatePageData.ts',
'src/utilities/visit/visitPlannerData.ts',
'src/utilities/votePledgeData.ts',
// B178 — the city overview batches activities across the 19 Salvador zone