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é é ` + {expanded ? ( +
+
+
+ + {municipalityUpdatePolarityLabels[update.polarity]} + + {update.urgent ? Urgente : null} + {update.adversarySignal ? Adversário : null} +
+

+ {update.body ?? 'Sem texto.'} +

+

+ {update.authorName} + + {dateTimeFormatter.format(new Date(update.createdAt))} +

+
+ {updateControl(registerCta)} +
+ ) : null} + + ) : ( + updateControl(registerCta) + )} + + ) : null} +
+ ) +} diff --git a/src/components/campaign/municipality/MunicipalityVotePositionReadout.tsx b/src/components/campaign/municipality/MunicipalityVotePositionReadout.tsx index 0a7a9526..46f2f7e1 100644 --- a/src/components/campaign/municipality/MunicipalityVotePositionReadout.tsx +++ b/src/components/campaign/municipality/MunicipalityVotePositionReadout.tsx @@ -11,9 +11,12 @@ export type MunicipalityVotePositionReadoutLayout = 'table' | 'card' | 'search' export const MunicipalityVotePositionReadout = ({ position, layout, + className, }: { position: MunicipalityVoteRankEntry layout: MunicipalityVotePositionReadoutLayout + /** B193 — the dense mobile card header right-aligns the card layout. */ + className?: string }) => { const share = formatVoteSharePercent(position.share) const rank = formatPlacementOrdinal(position.rank) @@ -28,6 +31,7 @@ export const MunicipalityVotePositionReadout = ({ className={cn( 'flex flex-col gap-0.5 tabular-nums', layout === 'table' || layout === 'search' ? 'items-end text-right' : 'text-sm', + className, )} aria-label={ariaLabel} > diff --git a/src/components/campaign/shared/CampaignCellEditOverlay.tsx b/src/components/campaign/shared/CampaignCellEditOverlay.tsx index a0f6cf68..28e5a455 100644 --- a/src/components/campaign/shared/CampaignCellEditOverlay.tsx +++ b/src/components/campaign/shared/CampaignCellEditOverlay.tsx @@ -109,6 +109,8 @@ export const CampaignCellEditOverlay = ({ }: CampaignCellEditOverlayProps) => { const sharedSheet = useCampaignListSheet() const wasOpenRef = useRef(open) + /** C109 — the shared drawer session this control opened (see the host). */ + const sessionIdRef = useRef(null) // Miss #52: without the provider every opened cell keeps its own Drawer // root. The fallback below stays for prod resilience, but in dev the @@ -179,23 +181,45 @@ export const CampaignCellEditOverlay = ({ wasOpenRef.current = open if (!open) { - if (wasOpen) sharedSheet.dismissSheet(onOpenChange) + if (wasOpen && sessionIdRef.current !== null) { + sharedSheet.dismissSheet(sessionIdRef.current, onOpenChange) + sessionIdRef.current = null + } return } - sharedSheet.openSheet({ - title, - description, - footer, - sheetBodyClassName, - onOpenChange, - }) + // 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"). + if (!wasOpen) { + sessionIdRef.current = sharedSheet.openSheet({ + title, + description, + hasCustomFooter: Boolean(footer), + sheetBodyClassName, + onOpenChange, + }) + } }, [variant, sharedSheet, open, title, description, footer, sheetBodyClassName, onOpenChange]) + // C109 — the custom footer is REGISTERED here and rendered by the host + // INSIDE the Drawer's React tree; a portal would keep the Dialog context + // away from its `DialogClose`. Re-registers on every change (spinner, + // label) while the session stays active; a stale session's call is a no-op + // in the host. + useLayoutEffect(() => { + if (variant !== 'sheet' || !sharedSheet) return + const sessionId = sessionIdRef.current + if (sessionId === null) return + sharedSheet.setFooterContent(sessionId, open ? (footer ?? null) : null) + }, [variant, sharedSheet, open, footer]) + if (variant === 'sheet' && sharedSheet) { - const showPortals = open && sharedSheet.isActiveSheet(onOpenChange) + const showPortals = + open && sessionIdRef.current !== null && sharedSheet.isActiveSheet(sessionIdRef.current) const bodyTarget = showPortals ? sharedSheet.bodyPortalRef.current : null - // portalRevision keeps this render in sync after the drawer mounts its target. + // portalRevision keeps this render in sync after the drawer mounts its targets. void sharedSheet.portalRevision return ( diff --git a/src/components/campaign/shared/CampaignListSheetHost.tsx b/src/components/campaign/shared/CampaignListSheetHost.tsx index 92c2b0ae..35732094 100644 --- a/src/components/campaign/shared/CampaignListSheetHost.tsx +++ b/src/components/campaign/shared/CampaignListSheetHost.tsx @@ -25,18 +25,7 @@ import { cn } from '@/lib/utils' export type CampaignListSheetChrome = { title: string description?: string - /** - * Custom footer for a sheet whose commit is an explicit submit (E14's - * "Registrar movimento", B42's "Registrar atualização"). Rendered INLINE in - * the host's DrawerFooter so its controls resolve the Drawer's context — - * the cell's own tree has no `Drawer.Root`, and a portaled footer keeps the - * source context and crashes base-ui's `DialogClose` (C109). The element is - * still CREATED by the cell (form state, `form` attribute association live - * there); only its render location moves — so it can rely on props/state - * captured by the cell and on providers ABOVE the sheet provider, never on - * providers local to the cell. - */ - footer?: ReactNode + hasCustomFooter: boolean sheetBodyClassName?: string onOpenChange: (open: boolean) => void } @@ -45,9 +34,19 @@ type CampaignListSheetContextValue = { bodyPortalRef: RefObject /** Bumps when the drawer body mount target attaches — portals read this. */ portalRevision: number - openSheet: (chrome: CampaignListSheetChrome) => void - dismissSheet: (onOpenChange: (open: boolean) => void) => void - isActiveSheet: (onOpenChange: (open: boolean) => void) => boolean + /** Opens the shared drawer; returns the session id the caller must keep. */ + openSheet: (chrome: CampaignListSheetChrome) => number + /** Closes the drawer only when `sessionId` is the active session. */ + dismissSheet: (sessionId: number, onOpenChange: (open: boolean) => void) => void + isActiveSheet: (sessionId: number) => boolean + /** + * C109 — the caller's custom footer (submit + `DrawerCloseButton`) must + * render INSIDE the drawer's React tree: the overlay lives outside the + * `Drawer`, so a portal keeps the Dialog context away from `DialogClose`. + * The host renders the registered node for the ACTIVE session only — a + * stale session's close render can never wipe the open drawer's footer. + */ + setFooterContent: (sessionId: number, footer: ReactNode | null) => void } const CampaignListSheetContext = createContext(null) @@ -57,9 +56,7 @@ export const useCampaignListSheet = () => useContext(CampaignListSheetContext) /** * One bottom Drawer for an entire list surface (B42 mobile cards). Cell bodies * portal into it so we never mount hundreds of Drawer roots and never sync form - * trees through provider state (which tripped React #185 in prod). Custom - * footers travel in the chrome instead of portaling: a portaled footer keeps - * the cell's context, which has no `Drawer.Root`, and crashes (C109). + * trees through provider state (which tripped React #185 in prod). */ export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) => { const titleRef = useRef(null) @@ -67,27 +64,45 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) const [open, setOpen] = useState(false) const [chrome, setChrome] = useState(null) const [portalRevision, setPortalRevision] = useState(0) + const [footerContent, setFooterContent] = useState(null) const activeOnOpenChangeRef = useRef<((open: boolean) => void) | null>(null) + /** + * C109 (2026-08-10, B193) — sheet sessions are keyed by a monotonically + * increasing id instead of the control's `onOpenChange` identity: the + * autosave/level callbacks are recreated per render, so ANY re-render of an + * open control (a keystroke, `isPending`) used to look like "a different + * control opened" — the host dismissed the old session, the drawer stayed + * up with its body portal torn down, and the next `DialogClose` crashed + * outside its root. A session id survives re-renders; only an explicit + * open/close moves it. + */ + const sessionCounterRef = useRef(0) + const activeSessionRef = useRef(null) const attachBodyPortal = useCallback((node: HTMLDivElement | null) => { bodyPortalRef.current = node if (node) setPortalRevision((value) => value + 1) }, []) - const dismissSheet = useCallback((onOpenChange: (open: boolean) => void) => { - if (activeOnOpenChangeRef.current !== onOpenChange) return + const dismissSheet = useCallback((sessionId: number, onOpenChange: (open: boolean) => void) => { + if (activeSessionRef.current !== sessionId) return setOpen(false) + activeSessionRef.current = null activeOnOpenChangeRef.current = null setChrome(null) + setFooterContent(null) onOpenChange(false) }, []) - const openSheet = useCallback((next: CampaignListSheetChrome) => { + const openSheet = useCallback((next: CampaignListSheetChrome): number => { + const session = ++sessionCounterRef.current const previousOnOpenChange = activeOnOpenChangeRef.current if (previousOnOpenChange && previousOnOpenChange !== next.onOpenChange) { previousOnOpenChange(false) } activeOnOpenChangeRef.current = next.onOpenChange + activeSessionRef.current = session + setFooterContent(null) setChrome((current) => { if ( current && @@ -95,35 +110,37 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) current.title === next.title && current.description === next.description && current.sheetBodyClassName === next.sheetBodyClassName && - // Deliberately NOT `hasCustomFooter`: a custom footer is a fresh JSX - // element on every cell render, and this comparison is what forces the - // host to adopt it — `isPending`/spinner freshness depends on the - // footer being re-rendered here (the cell no longer renders it itself). - current.footer === next.footer + current.hasCustomFooter === next.hasCustomFooter ) { return current } return next }) setOpen(true) + return session }, []) const isActiveSheet = useCallback( - (onOpenChange: (open: boolean) => void) => - open && activeOnOpenChangeRef.current === onOpenChange, + (sessionId: number) => open && activeSessionRef.current === sessionId, [open], ) + const registerFooter = useCallback((sessionId: number, footer: ReactNode | null) => { + if (activeSessionRef.current !== sessionId) return + setFooterContent(footer) + }, []) + const handleOpenChange = useCallback( (nextOpen: boolean) => { if (nextOpen) return + const sessionId = activeSessionRef.current const onOpenChange = activeOnOpenChangeRef.current - if (!onOpenChange) { + if (sessionId === null || !onOpenChange) { setOpen(false) setChrome(null) return } - dismissSheet(onOpenChange) + dismissSheet(sessionId, onOpenChange) }, [dismissSheet], ) @@ -135,8 +152,9 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) openSheet, dismissSheet, isActiveSheet, + setFooterContent: registerFooter, }), - [portalRevision, openSheet, dismissSheet, isActiveSheet], + [portalRevision, openSheet, dismissSheet, isActiveSheet, registerFooter], ) return ( @@ -161,7 +179,13 @@ export const CampaignListSheetProvider = ({ children }: { children: ReactNode }) )} /> - {chrome.footer ?? Fechar} + {/* C109 — the custom footer renders HERE, inside the Drawer's + React tree, so its `DialogClose` has the root context; the + body keeps the portal (its content never needs Dialog). */} + {footerContent ? ( +
{footerContent}
+ ) : null} + {!chrome.hasCustomFooter ? Fechar : null}
) : null} diff --git a/src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx b/src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx index d2f8c833..6d6079e3 100644 --- a/src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx +++ b/src/components/campaign/shared/MunicipalityRelationAvatarStack.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import { Avatar, AvatarFallback } from '@/components/ui/Avatar' +import { cn } from '@/lib/utils' import { campaignUserInitials } from '@/utilities/campaignUserProfile' export type MunicipalityRelationEntry = { @@ -16,18 +17,35 @@ export const MunicipalityRelationAvatarStack = ({ entries, emptyState, maxVisible = 3, + wrap = false, + avatarClassName, }: { entries: MunicipalityRelationEntry[] emptyState: ReactNode maxVisible?: number + /** + * B193 — dense card mode: every entry gets its own avatar (no cap) and the + * row wraps to the available width instead of overlapping in a fixed stack. + */ + wrap?: boolean + avatarClassName?: string }) => { if (entries.length === 0) return emptyState + const visible = wrap ? entries : entries.slice(0, maxVisible) + return ( -
-
- {entries.slice(0, maxVisible).map((entry) => ( - +
+
+ {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
-
- {isCity ? ( - — - ) : isCoordinator ? ( + + {isCity ? null : isCoordinator ? ( ) : (
- Assessores + Assessores
)} -
-
- {isCity ? ( - — - ) : ( + + + {isCity ? null : ( )} -
-
- {isCity ? ( - — - ) : ( + + + {isCity ? null : ( )} -
+
{isCity ? ( - — + ) : update ? ( <>