From 3d8dbae61f1937f5d913d9b9cdfcebf196689ead Mon Sep 17 00:00:00 2001
From: Francisco Solla
Date: Mon, 10 Aug 2026 09:30:34 -0300
Subject: [PATCH 1/4] =?UTF-8?q?feat(B193):=20card=20de=20munic=C3=ADpio=20?=
=?UTF-8?q?mobile=20denso=20com=20edit-where-you-see=20e=20rodap=C3=A9=20d?=
=?UTF-8?q?e=20atualiza=C3=A7=C3=A3o=20expand=C3=ADvel?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Card denso (wireframe): posição de voto à direita, barra de cenários com
marcador do ativo, chips classe/tendência/nível (label à esquerda), 3 grupos
de avatares com wrap (um por entidade), rodapé Última atualização + chevron
que expande revelando o último card de atualização + CTA registrar; sem
atualização → CTA direto; prioridade = borda lateral direita 6px primary.
- Controles ganham prop trigger/triggerClassName (precedente RelationChipCell);
MunicipalityRelationAvatarStack ganha modo wrap.
- C109 (sheet quebra ao abrir) corrigida: sessões do sheet host keyed por id
(imune à identidade dos callbacks recriados por render) e footer customizado
renderizado pelo host dentro da árvore do Drawer (portal perdia o Dialog
context — reproduzido digitando no sheet).
- Dado: MunicipalityListViewModel.lastUpdate (batch por página, scoped).
- E2E novos (barra/votos, chip nível, borda prioridade, expansão/CTA vazio).
Sem migration/access/Consent.
---
docs/CHANGELOG-AGENTS.md | 2 +
...ios-card-mobile-edit-where-you-see-impl.md | 99 +++++
.../MunicipalityAdvisorAvatarStack.tsx | 4 +
.../MunicipalityListAdvisorsControl.tsx | 9 +
.../MunicipalityListExpectedVotesControl.tsx | 34 +-
.../MunicipalityListLeadershipsControl.tsx | 9 +
.../MunicipalityListLevelControl.tsx | 16 +-
.../MunicipalityListMobileCards.tsx | 261 +----------
.../MunicipalityListTrendControl.tsx | 40 +-
.../municipality/MunicipalityMobileCard.tsx | 418 ++++++++++++++++++
.../MunicipalityVotePositionReadout.tsx | 4 +
.../shared/CampaignCellEditOverlay.tsx | 44 +-
.../campaign/shared/CampaignListSheetHost.tsx | 88 ++--
.../MunicipalityRelationAvatarStack.tsx | 26 +-
.../shared/MunicipalityRelationEditor.tsx | 19 +-
.../MunicipalityStateDeputyRelationCell.tsx | 9 +
.../municipality/municipalityPageData.ts | 25 +-
.../municipalityUpdatePageData.ts | 67 +++
.../municipality/municipalityViewModels.ts | 11 +
tests/e2e/campaignMunicipalities.e2e.spec.ts | 166 ++++++-
tests/unit/campaignComponents.unit.spec.ts | 31 +-
tests/unit/codebaseConventions.unit.spec.ts | 4 +
22 files changed, 1061 insertions(+), 325 deletions(-)
create mode 100644 docs/plans/municipios-card-mobile-edit-where-you-see-impl.md
create mode 100644 src/components/campaign/municipality/MunicipalityMobileCard.tsx
diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index 36e695af..fe05b565 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
+**Recently resolved (2026-08-10):** **B193** (Issue #542) — card de município mobile denso + edit-where-you-see + última atualização expandível. O card de `/campanha/municipios` no mobile deixou o grid de labels e virou composição do wireframe: cabeçalho nome+território com **posição de voto 2022 à direita** (percentual em cima; colocação · votos embaixo), **barra de cenários** (pessimista·central·otimista + marcador do ativo — `VoteEstimateScenarioStrip` como trigger da sheet de votos existente), **chips** classe/tendência/nível (tendência e nível com label à esquerda, dentro do trigger — tocar no label abre a sheet; classe com razão via tooltip que abre no toque), **3 grupos de avatares com label** (Assessores/Lideranças/Dobradinhas — um avatar por entidade, wrap, sem cap; leitura de assessores para não-coordenador), rodapé **"Última atualização há X dias" + chevron que expande o limite inferior** revelando o último card de atualização (badges + corpo + autor · data, sem moldura) + CTA "Registrar atualização" (sheet existente); **sem atualização → o rodapé vira CTA direto** (sem chevron/expansão); prioritário = **borda lateral direita 6px** `border-primary` no lugar do flag; Salvador read-only; "Cobertura" (conta da cadeira) sai do card (fica na tabela/detalhe — gate humano); rodapé mede a idade da **atualização** (não o frescor update∨pledge — coluna desktop intacta). **C109 corrigida no caminho (a "sheet quebra ao abrir" era real):** os callbacks dos controles (autosave `onOpenChange`, `handleOpenChange` do nível) são recriados por render — qualquer re-render com a sheet aberta (digitar no sheet!) fazia o host descartar a sessão e o corpo sumia; sessões do host agora são **keyed por id de sessão** (imune a identidade) e o **footer customizado** (submit + `DialogClose`) passou a ser **registrado e renderizado pelo host dentro da árvore do Drawer** (portal perdia o Dialog context — o e2e antigo só abria a sheet de tendência, sem footer custom, por isso a C109 não reproduzia). Controles ganharam prop `trigger`/`triggerClassName` (precedente `RelationChipCell.trigger`); `MunicipalityRelationAvatarStack` ganhou modo wrap. Dado: `MunicipalityListViewModel.lastUpdate` (batch por página via `loadMunicipalityLastUpdates`, scoped por portfólio). E2E novos (390×844): barra abre sheet e reflete valores salvos, chip Nível abre a sheet, borda de prioridade (6px + cor), expansão/CTA vazio. Sem migration/access/Consent. Intenção [`municipios-card-mobile-edit-where-you-see.md`](plans/municipios-card-mobile-edit-where-you-see.md); impl [`municipios-card-mobile-edit-where-you-see-impl.md`](plans/municipios-card-mobile-edit-where-you-see-impl.md).
+
**Recently resolved (2026-08-10):** **OPS26** (Issue #571) — `worktree next`/`plan` **no terminal** terminam com o TUI do opencode aberto no worktree: a função `worktree()` de `.agents/shell/worktree.sh` marca `TEQO_WORKTREE_TERMINAL=1` e o script imprime a diretiva `launch opencode --model deepseek/deepseek-v4-flash --auto [--prompt /work-issue]` **antes** do `cd` (que segue sendo a última linha — contrato OPS24 intacto); a função aplica o cd e executa a linha (sem `exec`: sair do TUI volta ao shell no worktree; sem auto-`kill`). `next` envia `/work-issue` (o comando OPS25 roda o ciclo completo); `plan` abre **sem prompt** — a CLI do opencode não tem pre-fill sem submit (`--prompt` sempre auto-envia; fallback acordado: `/plan-` + autocomplete; gap registrado para o repo do opencode). Presets são constantes em `scripts/lib/worktree.mjs` (`OPENCODE_PRESET_MODEL`, `OPENCODE_SKILL_COMMAND_BY_PURPOSE`, `opencodeLaunchDirective` — pura, unit-testada; trocar de modelo é editar a constante). **Sem o marcador (comando `/worktree` do opencode, automação) a diretiva não é impressa** — nunca abre TUI aninhado; `--stay` suprime cd e launch; `kill` intocado. Docs: AGENTS.md ("Per-worktree environments"), header/uso do script, `.opencode/commands/worktree.md`, skill `worktree-next-issue`. Sem migration/dados/access/UI. Intenção [`worktree-go-abre-opencode-com-presets.md`](plans/worktree-go-abre-opencode-com-presets.md); impl [`worktree-go-abre-opencode-com-presets-impl.md`](plans/worktree-go-abre-opencode-com-presets-impl.md).
**Recently resolved (2026-08-10):** **OPS24** (Issue #569) — `--go` virou o **padrão** do worktree CLI e `kill` volta ao main: `worktree next`/`plan` imprimem `cd ` na última linha **por padrão** (antes exigiam `--go`); `--stay` suprime; `--go` explícito continua aceito como no-op (não quebra hábito/automação). `kill` troca a mensagem "Volte ao main: cd …" (que nunca casava com o sed `s/^cd //p` da função shell) pela **linha máquina `cd ` como última linha** — depois do drop dos DBs, que rodava antes — e **não aceita `--stay`** (ficar em cwd destruído é exatamente o bug corrigido; `--go` no kill é no-op). Vale nas duas superfícies que aplicam o cd: função `worktree()` de `.agents/shell/worktree.sh` (agora sempre captura/applica, `--stay` retorna cedo) e comando `/worktree` do opencode (agente aplica o `cd` da última linha sempre, exceto `--stay`). Docs: header/uso do script, AGENTS.md ("Per-worktree environments"), skills `worktree-next-issue`/`local-database`. Sem migration/dados/access; derivação pura (`worktree-env.mjs`/`worktree.mjs` libs) intocada. Intenção [`worktree-go-default-e-kill-volta-ao-main.md`](plans/worktree-go-default-e-kill-volta-ao-main.md); impl [`worktree-go-default-e-kill-volta-ao-main-impl.md`](plans/worktree-go-default-e-kill-volta-ao-main-impl.md).
diff --git a/docs/plans/municipios-card-mobile-edit-where-you-see-impl.md b/docs/plans/municipios-card-mobile-edit-where-you-see-impl.md
new file mode 100644
index 00000000..493dd652
--- /dev/null
+++ b/docs/plans/municipios-card-mobile-edit-where-you-see-impl.md
@@ -0,0 +1,99 @@
+# Impl: B193 — Card de município mobile: composição densa + edit-where-you-see + última atualização expandível
+
+Status: aprovado
+Atualizado em: 2026-08-10
+Issue: #542
+Intenção: docs/plans/municipios-card-mobile-edit-where-you-see.md
+Appetite restante: herdado (~1–1,5 dia eng; recomposição de card + fiação de affordances sobre sheets existentes)
+
+## Leitura da intenção
+
+- **Outcome:** no mobile (`md:hidden`), o card de `/campanha/municipios` vira composição densa na ordem do wireframe (cabeçalho nome+território com posição de voto 2022 à direita; barra de cenários com marcador do ativo; chips classe/tendência/nível; pilhas de avatares sem cap; rodapé última atualização com chevron) com edit-where-you-see em bottom sheets já existentes; toque atravessado abre o detalhe; prioritário ganha borda lateral direita ~6px; rodapé expande revelando o último card de atualização + CTA de registrar; sem atualização → CTA direto.
+- **O que NÃO negociar:** assimetria staff/leader (estimativas nunca para liderança — página já é `gate: noLeader`); B13: classe nunca sem razão acessível; Salvador (linha agregada) read-only; desktop da lista e demais listas inalterados; não adicionar dado novo ao card; expansão ≠ feed completo; não recriar editors (reusar variantes `sheet`).
+- **O que reavaliar:** (1) a intenção assume que os controles `variant="sheet"` servem o card novo como estão — os **triggers são internos** a cada controle, e a composição densa exige trigger customizado por alvo (barra de cenários, chip com label à esquerda, pilha de avatares com wrap); (2) a intenção não diz de onde vem o "último card de atualização" — o view model da lista **não carrega a última atualização** (só `lastUpdateAt`/`lastSignalAt`), então é dado novo no _load_ da página (não no card); (3) o rodapé hoje usa frescor (max atualização/pledge) — a expansão exige idade da **atualização**, não do sinal.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ page["municipios/page.tsx loadMunicipalityListPageBundle"] -->|"+ lastUpdate por página"| vm["MunicipalityListViewModel + lastUpdate: MunicipalityUpdateViewModel | null"]
+ vm --> cards["MunicipalityListMobileCards (novo)"]
+ cards --> card["MunicipalityMobileCard (client, estado expandido)"]
+ card --> bar["ExpectedVotesControl + trigger barra (VoteEstimateScenarioStrip)"]
+ card --> chips["Trend/Level controls + trigger chip label-esquerda"]
+ card --> avatars["RelationEditor + trigger pilha wrap (3 grupos)"]
+ card --> footer["Rodapé: expande | CTA direto (UpdateControl)"]
+```
+
+**Opções consideradas:**
+
+- **A — Controles ganham prop `trigger` opcional (recomendada).** Cada controle de edição (`MunicipalityListExpectedVotesControl`, `MunicipalityListLevelControl`, `MunicipalityListTrendControl`, `MunicipalityRelationEditor` + os 3 wrappers de relação) passa a aceitar um `trigger` customizado que substitui o interno; o `CampaignCellEditOverlay` já tem o `aria-label` que cobre a11y de qualquer conteúdo. O card compõe label+valor/barra/pilha dentro do botão, então o toque no **label** abre a sheet (aceite), e o trigger continua `relative` acima do overlay `after:inset-0` (invariante B42 documentado no próprio `CampaignCellEditOverlay`).
+- **B — Card posiciona label e trigger como irmãos, e o label abre a sheet via handler próprio.** Duplica o estado de abertura do sheet (dois triggers para um overlay) e fura a maquinaria `openSheet/dismissSheet` do host compartilhado — o host guarda `activeOnOpenChangeRef` e o segundo trigger entraria em conflito. Rejeitada: é a C109 que se descartou; quebraria a invariante "um control = um onOpenChange".
+- **C — Recriar o editor no card (componentes novos de sheet).** Rejeitada: rabbit hole "recriar editors" nomeado na intenção; anti-goal explícito.
+- **D — Barra/pilhas como affordances estáticas + navegação para a página de edição.** Rejeitada: viola edit-where-you-see (editar "sem sair do card", aceite).
+
+**Decisões de engenharia:**
+
+1. **Dado da expansão — última atualização no view model** (custo de reverter: médio — toca loader e tipo).
+ - Opções: (a) `lastUpdate: MunicipalityUpdateViewModel | null` no `MunicipalityListViewModel`, carregado em batch por página; (b) fetch per-card no client (N+1, e o read precisa de access server-side); (c) `lastUpdateAt` sozinho (não dá o corpo/autoria do card).
+ - Recomendação: (a) — um `payload.find` em `municipalityUpdate` com `where: { municipality: { in: idsDaPágina } }`, `sort: '-createdAt'`, select enxuto, `overrideAccess: false` (o `canReadMunicipalityUpdate` escopa por portfólio), primeiro doc por município; nomes de autor via `loadCampaignUserNamesByIds` (mesmo padrão de `loadMunicipalityUpdatesFeed`). Alternativas rejeitadas: (b) porque explode o número de reads por página e expõe o access no client; (c) porque o card expandido precisa de corpo/polaridade/autor (aceite).
+ - Gatilho de revisitação (barato, não agora): se um município tiver centenas de updates, o batch puxa tudo — `DISTINCT ON` via drizzle quando houver medição (mesma política de `supporterListOverviewAggregate`).
+2. **Cobertura (conta da cadeira) sai do card mobile.** O wireframe não a inclui; o card atual mostra "Cobertura" (goal coverage). Fica na tabela desktop e no detalhe. Sinalizo no gate como remoção de dado da superfície (não é "dado novo", é composição nova do wireframe).
+3. **Rodapé passa a medir a idade da ATUALIZAÇÃO, não o frescor (update∨pledge).**
+ - O wireframe é explícito ("Última atualização há X dias", "sem nenhuma atualização registrada → CTA direto") e o estado vazio é chaveado na existência de update, não de pledge. O `SignalAgeReadout`/frescor continua na coluna desktop (`lastSignal`), intacto. A cor de "sinal frio" (`estimate-pending`) é mantida no rodapé quando a **atualização** está fria (≥21 dias), preservando o alerta da fila.
+ - Sinalizo como divergência pequena de produto no gate (a intenção herda o vocabulário frescor; troco para update-only no card).
+4. **Borda de prioridade** — `border-r-[6px] border-primary` no `` quando `isPriority && isStaffView && !isCity`, mantendo o `border-b` separador do B184. O `MunicipalityPriorityIndicator` (flag) sai do card mobile; a coluna desktop e o detalhe não mudam.
+5. **Pilha de avatares com wrap** — estender `MunicipalityRelationAvatarStack` (owner, não twin) com modo wrap: `flex-wrap`, sem cap (`maxVisible` indefinido = todos), avatares densos (`size-7`); os três grupos (Assessores/Lideranças/Dobradinhas) são label + pilha dentro do trigger de cada relation editor via prop `trigger` (decisão A).
+6. **Chip de classe** — mantém `TerritorialClassCardReadout` (só o rótulo + tooltip com a razão; `CampaignHoverTooltip` já abre no toque — `openOnTouch` default true, satisfaz "razão via tooltip/sheet" sem linha extra). Sem sheet nova.
+7. **Barra de cenários** — trigger = `VoteEstimateScenarioStrip` com `markerMode="active-only"` + `labelMode="all"` (os três valores; ativo em destaque — igual ao canvas). Cenário ativo vem do `MunicipalityEstimateScenarioProvider` (já envolve a lista staff). Cidade: sem barra.
+8. **Expansão** — estado `expanded` por card (`MunicipalityMobileCard` client); rodapé é `
+ {visible.map((entry) => (
+
{campaignUserInitials(entry.initialsLabel ?? entry.label)}
diff --git a/src/components/campaign/shared/MunicipalityRelationEditor.tsx b/src/components/campaign/shared/MunicipalityRelationEditor.tsx
index 17f5f874..7e139e4e 100644
--- a/src/components/campaign/shared/MunicipalityRelationEditor.tsx
+++ b/src/components/campaign/shared/MunicipalityRelationEditor.tsx
@@ -44,6 +44,16 @@ 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
@@ -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..a9a7af84 100644
--- a/src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx
+++ b/src/components/campaign/shared/MunicipalityStateDeputyRelationCell.tsx
@@ -11,6 +11,7 @@ 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 = {
municipalityId: number
@@ -23,6 +24,10 @@ type MunicipalityStateDeputyRelationCellProps = {
) => Promise
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 = (
@@ -64,6 +69,8 @@ export const MunicipalityStateDeputyRelationCell = ({
commitAction,
createAction,
editorVariant,
+ trigger,
+ triggerClassName,
}: MunicipalityStateDeputyRelationCellProps) => {
const toggle = async (
stateDeputyID: number,
@@ -122,6 +129,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..2c394d7f 100644
--- a/src/utilities/municipality/municipalityUpdatePageData.ts
+++ b/src/utilities/municipality/municipalityUpdatePageData.ts
@@ -102,3 +102,70 @@ 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 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 lastByMunicipality = new Map()
+ 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),
+ })
+ }
+
+ 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..a0fb32ba 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()
})
@@ -955,6 +953,160 @@ 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)}`,
+ )
+ const card = page.locator('[data-view="mobile-cards"] article').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)}`,
+ )
+ const card = page.locator('[data-view="mobile-cards"] article').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)}`,
+ )
+ const card = page.locator('[data-view="mobile-cards"] article').first()
+ await expect(card).toBeVisible()
+
+ await expect(card).toHaveCSS('border-right-width', '6px')
+ await expect(card).toHaveCSS('border-right-color', 'rgb(197, 20, 20)')
+ })
+
+ 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)}`,
+ )
+ const card = page.locator('[data-view="mobile-cards"] article').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').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/unit/campaignComponents.unit.spec.ts b/tests/unit/campaignComponents.unit.spec.ts
index 9294f659..d7a335d7 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,12 @@ 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')
+ 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 +623,7 @@ describe('campaign visual foundation', () => {
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
+ lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@@ -644,6 +668,7 @@ describe('campaign visual foundation', () => {
priority: 'normal',
lastUpdateAt: null,
lastSignalAt: null,
+ lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@@ -694,6 +719,7 @@ describe('campaign visual foundation', () => {
priority: 'alta',
lastUpdateAt: null,
lastSignalAt: null,
+ lastUpdate: null,
expectedVotes: toVoteEstimateScenarioViewModel(null),
politicalTrendStatus: null,
politicalTrendNote: null,
@@ -892,6 +918,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
From b210939226bd32c19dc3f44b63872599e627a2a1 Mon Sep 17 00:00:00 2001
From: Francisco Solla
Date: Mon, 10 Aug 2026 09:57:50 -0300
Subject: [PATCH 2/4] =?UTF-8?q?refactor(B193):=20simplify=20fixes=20p?=
=?UTF-8?q?=C3=B3s-revis=C3=A3o=20=E2=80=94=20mapper=20=C3=BAnico=20de=20u?=
=?UTF-8?q?pdates,=20API=20de=20trigger=20uniforme,=20grupos=20de=20rela?=
=?UTF-8?q?=C3=A7=C3=A3o=20extra=C3=ADdos,=20hardening=20do=20sheet=20host?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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.
---
.../MunicipalityListAdvisorsControl.tsx | 8 +-
.../MunicipalityListExpectedVotesControl.tsx | 20 +++--
.../MunicipalityListLeadershipsControl.tsx | 8 +-
.../MunicipalityListRowReadouts.tsx | 34 ++++----
.../municipality/MunicipalityMobileCard.tsx | 70 ++++++++++------
.../MunicipalityVotePositionReadout.tsx | 6 +-
.../shared/CampaignCellEditOverlay.tsx | 25 +++++-
.../campaign/shared/CampaignListSheetHost.tsx | 8 +-
.../MunicipalityRelationAvatarStack.tsx | 12 +--
.../shared/MunicipalityRelationEditor.tsx | 22 ++---
.../MunicipalityStateDeputyRelationCell.tsx | 8 +-
.../municipalityUpdatePageData.ts | 81 +++++++++----------
tests/e2e/campaignMunicipalities.e2e.spec.ts | 39 +++++++--
.../unit/campaignCellEditOverlay.unit.spec.ts | 4 +-
tests/unit/campaignComponents.unit.spec.ts | 4 +
15 files changed, 215 insertions(+), 134 deletions(-)
diff --git a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx
index 21104f1c..5520e328 100644
--- a/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx
+++ b/src/components/campaign/municipality/MunicipalityListAdvisorsControl.tsx
@@ -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
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 => ({
diff --git a/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx b/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx
index b9ffe674..fefc3265 100644
--- a/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx
+++ b/src/components/campaign/municipality/MunicipalityListExpectedVotesControl.tsx
@@ -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
diff --git a/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx b/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx
index 1ae6ba2a..f8f41793 100644
--- a/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx
+++ b/src/components/campaign/municipality/MunicipalityListLeadershipsControl.tsx
@@ -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
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 => ({
diff --git a/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx b/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx
index 76cfa2da..7d59b31d 100644
--- a/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx
+++ b/src/components/campaign/municipality/MunicipalityListRowReadouts.tsx
@@ -90,17 +90,23 @@ export const TerritorialClassCardReadout = ({
municipality,
}: {
municipality: MunicipalityListViewModel
-}) => (
-
-
-
-
-
-)
+}) => {
+ // `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 (
+
+
+
+
+
+ )
+}
diff --git a/src/components/campaign/municipality/MunicipalityMobileCard.tsx b/src/components/campaign/municipality/MunicipalityMobileCard.tsx
index 884d3a3c..4187afd8 100644
--- a/src/components/campaign/municipality/MunicipalityMobileCard.tsx
+++ b/src/components/campaign/municipality/MunicipalityMobileCard.tsx
@@ -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 }) => (
+ {children}
+)
+
+/** Read-only city rows render a plain dash everywhere a control would sit. */
+const CityDash = () => —
const RelationGroupTrigger = ({
label,
@@ -99,11 +104,26 @@ const RelationGroupTrigger = ({
emptyState: ReactNode
}) => (
- {label}
+ {label}
)
+/**
+ * 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 }) => (
+
+ {children ?? (
+
+ {label}
+
+
+ )}
+
+)
+
/**
* 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 = ({
{isCity ? (
- Não registrada
+
+ Tendência
+ Não registrada
+
) : (
(
<>
- Tendência
+ Tendência
@@ -260,14 +283,14 @@ export const MunicipalityMobileCard = ({
triggerClassName={chipTriggerClassName}
trigger={(level, note) => (
<>
- Nível
+ Nível
>
)}
/>
) : (
- Nível
+ Nível