B25: link do território na lista de municípios

Fecha a navegação inversa para /campanha/territorios com âncoras estáveis compartilhadas entre lista e destino.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 18:59:49 -03:00
parent e5dd0c3b3f
commit a60200cf6c
13 changed files with 145 additions and 18 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B25 entregue em código (2026-07-26):** link do território na lista de `/campanha/municipios` — coluna "Território" e card mobile usam `TerritoryLink` → `/campanha/territorios#ti-<slug>`; helper compartilhado `territoryAnchor.ts`; destino com `id` nas linhas parent de `TerritoryList` via `CampaignTable.rowId`; sem migration/action. Gate: tsc/lint/format/knip/check:cycles; unit+int verdes (1 int flake por timeout em suíte cheia, pré-existente); `pnpm build` não rodou neste worktree (falta `PAYLOAD_SECRET` no ambiente). Plano: [`link-territorio-lista-municipios.md`](../../../docs/plans/link-territorio-lista-municipios.md).
- **Fill-in "Limitar mapa à Bahia" entregue em código (2026-07-26):** `BahiaMap` trava pan com `maxBounds: L.latLngBounds(BAHIA_BOUNDS).pad(0.1)` + `maxBoundsViscosity: 1` e zoom-out com `applyBahiaMinZoom` (`getBoundsZoom` + padding 16px alinhado ao `fitBounds`, guarda `Number.isFinite`, recalcula no `ResizeObserver` existente). Único arquivo; props/`MunicipalityMapPanel` intactos. Impeccable B: critique 0 P0/P1; harden/optimize out. Gate: tsc/lint/format/knip (P3 pré-existente)/cycles/test (2 timeouts flaky re-rodados verdes)/build; Aikido 0 findings. Plano: [`limitar-mapa-a-bahia.md`](../../../docs/plans/limitar-mapa-a-bahia.md).
- **B38 entregue em código (2026-07-26):** sidebar recolhido em tablet no shell de `/campanha`. `CampaignSidebar` deixou `collapsible="none"` e passou a `collapsible="offcanvas"` (gap `w-0` — listas densas ganham largura no iPad). Layout `(app)` lê cookie `sidebar_state` no SSR para `defaultOpen`; `CampaignSidebarViewportDefault` colapsa **uma vez** em viewport 768–1023 px quando não há cookie (`matchMedia` `min-width: 1024px`); após o primeiro toggle o cookie vence. `SidebarTrigger` no header vermelho mobile (`md:hidden`) inalterado; nova fileira slim `hidden md:flex` no inset com o mesmo label pt-BR. Mobile continua Sheet. Sem migration. Gate: tsc/lint/format/knip/check:cycles/522 unit verdes; int 399/400 (1 timeout pré-existente em `municipalityPageData`); build não rodou neste worktree sem `.env.local`. Plano: [`sidebar-recolhido-tablet.md`](../../../docs/plans/sidebar-recolhido-tablet.md). Soft **B18:** com rail collapsed o submenu de filtros salvos exigirá expandir antes do 2º nível.
- **B24 entregue em código (2026-07-26):** auto-save + justificativa editável no popover de Tendência de `/campanha/municipios`. Tirou o botão "Salvar" (anti-goal do princípio 4) e o `trendNote` hidden; `MunicipalityListTrendControl` agora espelha a máquina de `MunicipalityListExpectedVotesControl` — draft/display/committed + AbortController + geração, status ~150 ms / nota 600 ms (timer cancelável), flush no blur e no fechamento do Popover, Badge otimista, `Textarea` "Justificativa" (`maxLength` 2000), `Alert` + live region `sr-only`, tooltip B23 lendo a nota exibida. Endpoint `POST /campanha/municipios/political-trend` (mesmo shell CSRF/JSON do B27: `isSameOriginRequest` + `campaignJsonMutationRoute`) chama `setMunicipalityPoliticalTrend` **sem** `revalidatePath` — faceta/`?trend=` reconciliam na próxima navegação. Lista/page deixam de passar `trendFormAction`; `/editar` mantém o form action multi-campo. Sem migration. Gate verde (tsc/lint/format/knip/check:cycles/479 unit/393 int/build); e2e B24 + prewarm da rota; Aikido 0 findings. Plano: [`autosave-tendencia-lista-municipios.md`](../../../docs/plans/autosave-tendencia-lista-municipios.md).

View file

@ -1,7 +1,7 @@
# B25 — Link do território na lista de municípios
Status: rascunho
Atualizado em: 2026-07-25
Status: entregue
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B25; entrada da página entregue por B21)
Impeccable: B — encaixe na célula "Território" da lista existente (`MunicipalityList`), mais o `id` de âncora nas linhas da tabela do B21
Appetite: ~0,25 dia eng; sem migration, sem collection, sem server action, sem loader novo — uma célula vira link, um helper puro e um `id` por linha
@ -31,9 +31,9 @@ Brief compacto:
Na lista `/campanha/municipios` a coluna "Território" é texto morto: em `src/components/campaign/municipality/MunicipalityList.tsx` a definição de coluna `region` tem `cell: (municipality) => municipality.region`, e no card mobile o território aparece dentro de `formatMunicipalityGeographyLabel(...)` como parágrafo. A única coisa que se pode fazer com o território ali é usá-lo como **filtro** (Popover do header, B16 ✓).
A navegação inversa já existe e é assimétrica: no painel do Início, `TerritoryOverviewTable` (E17 ✓) linka cada TI para `/campanha/municipios?region=<TI>`. Ou seja, do território se chega aos municípios, mas do município não se chega ao território.
A navegação inversa já existe e é assimétrica: em `/campanha/territorios` (B21 ✓), cada linha de TI linka para `/campanha/municipios?region=<TI>` (rollup herdado do E17 ✓). Ou seja, do território se chega aos municípios, mas do município não se chega ao território.
O pedido (2026-07-25) fecha essa assimetria: clicar no território da lista deve levar à página daquele território. Hoje **não existe** superfície de TI fora do painel do Início — a página própria é o **B21** (`/campanha/territorios`, planejado no mesmo dia), que entrega os 27 TIs no sistema de listas e **exclui explicitamente** uma rota de detalhe por TI ("o drill continua sendo a lista de municípios filtrada"). Este item é, portanto, a entrada de navegação para a página do B21 — não uma tela nova.
O pedido (2026-07-25) fecha essa assimetria: clicar no território da lista deve levar à página daquele território. A superfície de comparação dos 27 TIs é o **B21** (`/campanha/territorios`), que **exclui explicitamente** uma rota de detalhe por TI ("o drill continua sendo a lista de municípios filtrada"). Este item é, portanto, a entrada de navegação para a página do B21 — não uma tela nova.
## Objetivos
@ -76,7 +76,8 @@ Componentes:
- **`src/lib/territoryAnchor.ts`** (novo, puro/client-safe): `territoryAnchorId(region: BahiaIdentityTerritory): string` (`ti-${slugify(region)}`) e `buildTerritoryPageHref(region)` (`/campanha/territorios#${territoryAnchorId(region)}`). Reusa `slugify` de `src/lib/slug.ts`. Unit test curto garantindo estabilidade e unicidade sobre `bahiaIdentityTerritories` (27 âncoras distintas) — é o pino que impede link e `id` de divergirem.
- **`TerritoryLink`** (novo, em `src/components/campaign/municipality/`): `<Link>` com o nome do TI, `min-h-11`, estilo alinhado ao link do nome do município (`underline-offset-4 hover:underline`), porém em `text-muted-foreground` para manter a hierarquia atual da coluna. **Depth check:** é um wrapper fino de `Link` **com** duas responsabilidades reais (href canônico + rótulo acessível) e 2–3 call sites imediatos (célula desktop, card mobile, futuro cabeçalho do detalhe); se ficar em 1 call site na implementação, inline o `Link` e mantenha só o helper de href.
- **`src/components/campaign/municipality/MunicipalityList.tsx`** (alterado): coluna `region` passa a renderizar `<TerritoryLink region={municipality.region} />`; no card mobile, o parágrafo de geografia usa o link no trecho do território, preservando o sufixo `· ZE N` das zonas (hoje montado por `formatMunicipalityGeographyLabel` em `src/utilities/municipalityLabels.ts` — dividir a formatação em rótulo + sufixo em vez de duplicar a regra).
- **Página/tabela do B21** (alterada): cada `<TableRow>` de território recebe `id={territoryAnchorId(row.region)}` e as classes de chegada (`scroll-mt-*` para folgar o header sticky + `target:bg-muted/50`), no precedente de `src/app/(campaign)/campanha/(app)/conceitos/page.tsx`.
- **`CampaignTable`** (alterado): prop opcional `rowId` para expor `id` no `<TableRow>` sem duplicar tabelas.
- **`TerritoryList`** (B21, alterado): `rowId` nas linhas `parent` com `territoryAnchorId(row.region)` e classes de chegada (`scroll-mt-*` + `target:bg-muted/50`), no precedente de `src/app/(campaign)/campanha/(app)/conceitos/page.tsx`.
- **Sem migration, sem collection, sem `Consent`, sem server action, sem alteração de loader.**
## Dependências
@ -110,8 +111,19 @@ Componentes:
- `docs/roadmap.md` (Trilha B / "Demais itens abertos", B25; Janela 1–2; cortes seguros)
- [pagina-territorios-identidade.md](pagina-territorios-identidade.md) (B21 — o destino, incl. o Não escopo da rota de detalhe) · [tabela-ti-inicio.md](tabela-ti-inicio.md) (E17 ✓ — rollup e o link inverso) · [camada-territorios-identidade.md](camada-territorios-identidade.md) (E12) · [tooltip-celulas-listas.md](tooltip-celulas-listas.md) (B23 — por que o hover de dado não é deste item)
- `src/components/campaign/municipality/MunicipalityList.tsx` — coluna `region` e card mobile a alterar
- `src/components/campaign/municipality/TerritoryOverviewTable.tsx` — linhas que recebem o `id` de âncora (e o link inverso já existente)
- `src/components/campaign/municipality/TerritoryList.tsx` — linhas parent que recebem o `id` de âncora (link inverso para municípios em `TerritoryListColumns.tsx`)
- `src/utilities/municipalityLabels.ts` (`formatMunicipalityGeographyLabel`), `src/lib/slug.ts`, `src/lib/bahiaTerritories.ts` — o que o helper reusa
- `src/app/(campaign)/campanha/(app)/conceitos/page.tsx` — precedente de âncora com `scroll-mt` + `target:`
- AGENTS.md — Campaign auth e acesso por papel (a página destino é staff-only; `leader` nunca vê a coluna), naming (identificadores em inglês, copy pt-BR), sem migration neste item
- `PRODUCT.md` / `DESIGN.md` — Field Desk, anti-goal de dashboard/spreadsheet, hierarquia de links na lista
## Revisão (2026-07-26)
Auditoria pré-implementação: o painel `TerritoryOverviewTable` saiu do Início com o B21 — âncoras e link inverso vivem em `TerritoryList` + seam `rowId` em `CampaignTable`.
## As built (2026-07-26)
- `src/lib/territoryAnchor.ts` — `territoryAnchorId` / `buildTerritoryPageHref`; unit test com 27 âncoras distintas.
- `TerritoryLink` — coluna desktop e trecho de território no card mobile (`municipalityGeographyParts` preserva `· ZE N` fora do link).
- `CampaignTable.rowId` — linhas parent em `TerritoryList` com `id` + `scroll-mt-6 target:bg-muted/50`.
- Escopo A (só lista); detalhe/dossiê permanecem adiados.

File diff suppressed because one or more lines are too long

View file

@ -13,6 +13,7 @@ import { MunicipalityListGoalCoverageCell } from '@/components/campaign/municipa
import { MunicipalityListSignalControl } from '@/components/campaign/municipality/MunicipalityListSignalControl'
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
import { MunicipalitySortableHead } from '@/components/campaign/municipality/MunicipalitySortableHead'
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/shared/CampaignTable'
import { Badge } from '@/components/ui/Badge'
@ -33,9 +34,9 @@ import {
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import {
formatMunicipalityGeographyLabel,
formatTerritorialClassWhy,
municipalityColumnDescriptions,
municipalityGeographyParts,
municipalityKindLabels,
municipalityPriorityLabels,
territorialClassBadgeVariant,
@ -294,7 +295,7 @@ const municipalityListColumns = ({
</MunicipalitySortableHead>
),
cellClassName: 'max-w-56 whitespace-normal text-muted-foreground',
cell: (municipality) => municipality.region,
cell: (municipality) => <TerritoryLink region={municipality.region} />,
},
{
id: 'kind',
@ -515,13 +516,15 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
const names = advisorNames(municipality, advisorNamesById)
const position = municipality.votePosition2022
const isPriority = municipality.priority === 'alta'
const { region, zoneSuffix } = municipalityGeographyParts(municipality)
return (
<article key={municipality.id} className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<h3 className="font-medium">{municipality.name}</h3>
<p className="text-sm text-muted-foreground">
{formatMunicipalityGeographyLabel(municipality)}
<TerritoryLink region={region} />
{zoneSuffix ? ` ${zoneSuffix}` : null}
</p>
{position ? <VotePositionReadout position={position} layout="card" /> : null}
</div>

View file

@ -0,0 +1,12 @@
import Link from 'next/link'
import { buildTerritoryPageHref } from '@/lib/territoryAnchor'
export const TerritoryLink = ({ region }: { region: string }) => (
<Link
href={buildTerritoryPageHref(region)}
className="inline-flex min-h-11 items-center text-muted-foreground underline-offset-4 hover:underline"
>
{region}
</Link>
)

View file

@ -12,6 +12,7 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/Empty'
import { territoryAnchorId } from '@/lib/territoryAnchor'
import { cn } from '@/lib/utils'
import {
clearTerritoryListFilters,
@ -76,7 +77,10 @@ export const TerritoryList = ({
rowKey={(row) =>
row.variant === 'parent' ? row.region : `${row.parentRegion}-${row.label}`
}
rowClassName={(row) => (row.variant === 'sub' ? 'bg-muted/30' : undefined)}
rowId={(row) => (row.variant === 'parent' ? territoryAnchorId(row.region) : undefined)}
rowClassName={(row) =>
row.variant === 'sub' ? 'bg-muted/30' : 'scroll-mt-6 target:bg-muted/50'
}
empty={<TerritoryListEmptyState state={state} />}
/>
</>

View file

@ -101,6 +101,8 @@ type CampaignTableProps<Row> = {
columns: Array<CampaignTableColumn<Row>>
rows: readonly Row[]
rowKey: (row: Row) => string | number
/** Optional DOM id per row (e.g. hash targets on `/campanha/territorios`). */
rowId?: (row: Row) => string | undefined
/** Rendered inside a full-width row when there are no rows — the header chrome stays mounted. */
empty?: ReactNode
/** Screen-reader table description. */
@ -118,6 +120,7 @@ export const CampaignTable = <Row,>({
columns,
rows,
rowKey,
rowId,
empty,
caption,
className,
@ -146,6 +149,7 @@ export const CampaignTable = <Row,>({
{rows.map((row) => (
<TableRow
key={rowKey(row)}
id={rowId?.(row)}
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
>
{columns.map((column) => {

View file

@ -0,0 +1,11 @@
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import { slugify } from '@/lib/slug'
const TERRITORY_PAGE_PATH = '/campanha/territorios'
/** Fragment id for parent TI rows — must match `TerritoryList` `rowId` and `TerritoryLink` hrefs. */
export const territoryAnchorId = (region: BahiaIdentityTerritory | string): string =>
`ti-${slugify(region)}`
export const buildTerritoryPageHref = (region: BahiaIdentityTerritory | string): string =>
`${TERRITORY_PAGE_PATH}#${territoryAnchorId(region)}`

View file

@ -163,15 +163,27 @@ export const getCampaignScopeLabel = (
return `${municipalityCount} ${municipalityCount === 1 ? 'município' : 'municípios'}`
}
export const municipalityGeographyParts = (municipality: {
region: string
kind: Municipality['kind']
zoneNumber?: number | null
}): { region: string; zoneSuffix: string | null } => ({
region: municipality.region,
zoneSuffix:
municipality.kind === 'zona' && municipality.zoneNumber != null
? `· ZE ${municipality.zoneNumber}`
: null,
})
/** Short human description of a municipality's geography, e.g. "Chapada Diamantina · ZE 105". */
export const formatMunicipalityGeographyLabel = (municipality: {
region: string
kind: Municipality['kind']
zoneNumber?: number | null
}): string =>
municipality.kind === 'zona' && municipality.zoneNumber != null
? `${municipality.region} · ZE ${municipality.zoneNumber}`
: municipality.region
}): string => {
const { region, zoneSuffix } = municipalityGeographyParts(municipality)
return zoneSuffix ? `${region} ${zoneSuffix}` : region
}
/**
* The 11 real column ids in `/campanha/municipios` — 10 staff columns plus

View file

@ -39,6 +39,31 @@ test.describe('Territórios de Identidade', () => {
await expect(page).toHaveURL(/\/campanha\/municipios\?region=Irec%C3%AA/)
})
test('parent territory rows expose hash anchor ids for deep links', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const email = `${fixtures.value('territorios-anchors')}@example.com`
await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenadora Âncoras'),
email,
password,
role: 'coordinator',
},
depth: 0,
})
await campaign.login(page, email, password)
await page.goto('/campanha/territorios')
await expect(page.locator('#ti-irece')).toBeVisible()
await expect(page.locator('#ti-velho-chico')).toBeVisible()
})
test('leader cannot open the territories page', async ({ campaign, page }) => {
const { fixtures } = campaign
const password = fixtures.value('senha')

View file

@ -233,6 +233,7 @@ describe('campaign visual foundation', () => {
expect(html).toContain('Seabra')
expect(html).toContain('Chapada Diamantina')
expect(html).toContain('href="/campanha/territorios#ti-chapada-diamantina"')
expect(html).toContain('href="/campanha/municipios/seabra"')
expect(html).toContain('1.500')
expect(html).toContain('Cenários de estimativa')

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { municipalityGeographyParts } from '@/utilities/municipalityLabels'
describe('municipalityGeographyParts', () => {
it('returns only the region for whole municipalities', () => {
expect(
municipalityGeographyParts({
region: 'Chapada Diamantina',
kind: 'municipio',
zoneNumber: null,
}),
).toEqual({ region: 'Chapada Diamantina', zoneSuffix: null })
})
it('keeps the ZE suffix outside the linkable region name', () => {
expect(
municipalityGeographyParts({
region: 'Salvador',
kind: 'zona',
zoneNumber: 5,
}),
).toEqual({ region: 'Salvador', zoneSuffix: '· ZE 5' })
})
})

View file

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
import { buildTerritoryPageHref, territoryAnchorId } from '@/lib/territoryAnchor'
describe('territoryAnchor', () => {
it('pins a known territory name to a stable anchor and href', () => {
expect(territoryAnchorId('Velho Chico')).toBe('ti-velho-chico')
expect(buildTerritoryPageHref('Velho Chico')).toBe('/campanha/territorios#ti-velho-chico')
})
it('produces 27 distinct anchor ids for the official TI catalog', () => {
const ids = bahiaIdentityTerritories.map((name) => territoryAnchorId(name))
expect(ids).toHaveLength(27)
expect(new Set(ids).size).toBe(27)
})
})