feat(campanha): B133 — listas sem subtítulo de prosa + filtro salvo no header

- Remove prose subtitles from list routes, Quadro and create/edit chrome
- Keep subtitles on Organizações, Conceitos, Perfil, Contatos (out of scope)
- MunicipalityListPageChrome: saved filter name as header subtitle when URL matches
- Edit pages: title-only chrome override
- Impl plan: docs/plans/listas-sem-subtitulo-prosa-impl.md

Closes #283

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-02 21:16:46 +00:00
parent 62171f5127
commit f89fc4b81f
No known key found for this signature in database
7 changed files with 160 additions and 92 deletions

View file

@ -0,0 +1,67 @@
# Impl: Listas `/campanha` — subtítulos curtos (sem prosa; filtro salvo em Municípios)
Status: aprovado
Atualizado em: 2026-08-02
Issue: #283
Intenção: docs/plans/listas-sem-subtitulo-prosa.md
Appetite restante: ~0,5 dia eng (herdado)
## Leitura da intenção
- **Outcome:** Header das listas citadas, Quadro e create/edit estáticos mostram só o título — sem parágrafo de prosa. Em `/campanha/municipios`, quando a URL casa com um filtro salvo (B18), o subtítulo do header passa a ser o **nome** do filtro; a label de resumo duplicada some da barra.
- **O que NÃO negociar:** Organizações, Conceitos, Perfil, Contatos mantêm subtítulos atuais; chips/omnibox como editor de filtro ficam; leader lockdown intocado; não puxar serializador pesado de URL para o layout `(app)`.
- **O que reavaliar:** A label de resumo (`formatMunicipalityActiveFiltersSummary`) ao lado da busca **já foi removida** no B127 (omnibox). O aceite de “sumir a label duplicada” está satisfeito — só garantir que não reintroduzimos resumo visível na barra.
## Abordagem recomendada
```mermaid
flowchart LR
A[campaignPageChrome pathRules] -->|sectionOnly| B[Header título só]
C[MunicipalityListPageChrome] -->|SetCampaignPageChrome| D[Subtítulo = nome filtro salvo]
E[Edit pages] -->|remove subtitle override| B
```
**Opções consideradas:**
- A) Apagar `subtitle` no catálogo e manter `pathRules` com `resolveCatalogEntry` — catálogo ainda carrega prosa morta.
- B) **`pathRules` → `sectionOnly(title)`** para rotas no escopo; catálogo limpo nas entradas afetadas; metadata já usa só `title`.
- C) `SetCampaignPageChrome` em cada rota create/edit — duplica o catálogo.
**Recomendação:** B — o dono (`campaignPageChrome.ts`) já resolve chrome por pathname; overrides pontuais só onde o estado é client-side (filtro salvo) ou dinâmico (editar com nome da entidade).
**Rejeitadas:** C (twins); A (prosa morta no catálogo).
### Componentes / mudanças
- **`campaignPageChrome.ts`**: `sectionOnly` nas listas citadas, Quadro, create/edit estáticos; remove prosa das entradas de catálogo afetadas; mantém subtítulos em org/conceitos/perfil/contatos.
- **`MunicipalityListPageChrome.tsx`** (novo): client; `useMunicipalitySavedFilters` + `isSameListHref` (mesmo contrato da sidebar B18); `SetCampaignPageChrome` só quando há match.
- **Edit pages** (`municipios/.../editar`, `atividades/.../editar`): chrome/metadata sem `subtitle`.
- **Migration:** sem migration.
- **Access / Consent:** nenhum.
- **UI:** B — encaixe no chrome B123; sem redesign.
### Dados → forma
- Não há dados novos — só orientação espacial no header.
## Fases verificáveis
1. **Chrome catálogo + edit** — `campaignPageChrome.ts`, edit pages, testes unitários.
2. **Filtro salvo** — `MunicipalityListPageChrome` na página de lista; teste unitário do match.
3. **Gates** — `pnpm gate:fast`; push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Reescrever subtítulos de Organizações/Conceitos/Perfil/Contatos.
- Subtítulo para URL filtrada ad hoc (só filtro salvo casado).
- Reintroduzir `formatMunicipalityActiveFiltersSummary` visível na barra.
## Riscos e mitigação
- **Acoplamento layout ↔ URL municípios:** `isSameListHref` já é client-safe e usado na sidebar — reusar, não importar `buildMunicipalityListHref` no layout.
- **Regressão E2E filtros salvos:** sidebar + Renomear intactos; só chrome do header muda.
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes de domínio previstos (unit chrome + saved-filter match)

View file

@ -22,12 +22,9 @@ type EditActivityPageProps = {
params: Promise<{ slug: string }>
}
const activityEditSubtitle = 'Atualize os detalhes da atividade sem alterar o título original.'
export async function generateMetadata({ params }: EditActivityPageProps) {
const { slug } = await params
if (!slug)
return campaignPageMetadata({ title: 'Editar atividade', subtitle: activityEditSubtitle })
if (!slug) return campaignPageMetadata({ title: 'Editar atividade' })
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/atividades' }),
@ -36,9 +33,9 @@ export async function generateMetadata({ params }: EditActivityPageProps) {
try {
const view = await getActivityEditPageData(payload, user, slug)
return campaignPageMetadata({ title: `Editar ${view.title}`, subtitle: activityEditSubtitle })
return campaignPageMetadata({ title: `Editar ${view.title}` })
} catch {
return campaignPageMetadata({ title: 'Editar atividade', subtitle: activityEditSubtitle })
return campaignPageMetadata({ title: 'Editar atividade' })
}
}
@ -63,9 +60,7 @@ export default async function EditActivityPage({ params }: EditActivityPageProps
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<SetCampaignPageChrome
chrome={{ title: `Editar ${view.title}`, subtitle: activityEditSubtitle }}
/>
<SetCampaignPageChrome chrome={{ title: `Editar ${view.title}` }} />
<ActivityForm
action={updateActivityFormAction}
activity={view}

View file

@ -29,9 +29,6 @@ type MunicipalityEditPageProps = {
params: Promise<{ slug: string }>
}
const municipalityEditSubtitle =
'A geografia do município é pré-definida e não pode ser alterada. Aqui você edita metas, votos estimados, inteligência e tendência política.'
export async function generateMetadata({ params }: MunicipalityEditPageProps) {
const { slug } = await params
const payload = await getPayload({ config })
@ -43,12 +40,9 @@ export async function generateMetadata({ params }: MunicipalityEditPageProps) {
try {
const context = await resolveAccessibleMunicipalityContext(payload, user, slug)
const view = await getMunicipalityDetailViewModel(payload, context, user)
return campaignPageMetadata({
title: `Editar ${view.name}`,
subtitle: municipalityEditSubtitle,
})
return campaignPageMetadata({ title: `Editar ${view.name}` })
} catch {
return campaignPageMetadata({ title: 'Editar município', subtitle: municipalityEditSubtitle })
return campaignPageMetadata({ title: 'Editar município' })
}
}
@ -76,9 +70,7 @@ export default async function MunicipalityEditPage({ params }: MunicipalityEditP
return (
<CampaignPageShell>
<SetCampaignPageChrome
chrome={{ title: `Editar ${view.name}`, subtitle: municipalityEditSubtitle }}
/>
<SetCampaignPageChrome chrome={{ title: `Editar ${view.name}` }} />
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href={`/campanha/municipios/${view.slug}`}>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />

View file

@ -9,6 +9,7 @@ import { getPayload } from 'payload'
import { RecentVisitTracker } from '@/components/campaign/dashboard/RecentVisitTracker'
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/municipality/MunicipalityEstimateScenarioContext'
import { MunicipalityFilters } from '@/components/campaign/municipality/MunicipalityFilters'
import { MunicipalityListPageChrome } from '@/components/campaign/municipality/MunicipalityListPageChrome'
import { MunicipalityList } from '@/components/campaign/municipality/MunicipalityList'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
@ -150,6 +151,7 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
return (
<CampaignPageShell>
<MunicipalityListPageChrome />
{main}
{listVisitLabel ? (
<RecentVisitTracker

View file

@ -0,0 +1,41 @@
'use client'
import { usePathname, useSearchParams } from 'next/navigation'
import { useMemo } from 'react'
import { useMunicipalitySavedFilters } from '@/components/campaign/shared/useMunicipalitySavedFilters'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { campaignPageChromeCatalog } from '@/lib/campaignPageChrome'
import { isSameListHref } from '@/lib/listQueryMatch'
/** A page is a position inside a recorte, not part of it (B18). */
const IGNORED_PARAMS = ['page']
/**
* When the current URL matches a saved municipality filter, the filter name
* becomes the page subtitle (B133) — the bar keeps chips as the editable recorte.
*/
export const MunicipalityListPageChrome = (): null => {
const savedFilters = useMunicipalitySavedFilters()
const pathname = usePathname()
const searchParams = useSearchParams()
const activeFilter = useMemo(() => {
const query = searchParams.toString()
const currentHref = query ? `${pathname}?${query}` : pathname
return savedFilters.find((entry) =>
isSameListHref(currentHref, entry.href, IGNORED_PARAMS),
)
}, [pathname, searchParams, savedFilters])
if (!activeFilter) return null
return (
<SetCampaignPageChrome
chrome={{
title: campaignPageChromeCatalog.municipios.title,
subtitle: activeFilter.name,
}}
/>
)
}

View file

@ -17,87 +17,56 @@ const normalizePathname = (pathname: string): string => {
return pathname
}
const quadroSubtitle = (role: CampaignRole): string =>
role === 'advisor'
? 'Quadro dos municípios sob sua assessoria.'
: 'Quadro geral da campanha por município.'
/** Client-safe vocabulary for shell chrome and tab titles (B123). */
export const campaignPageChromeCatalog = {
home: null,
quadro: {
title: 'Quadro',
subtitle: quadroSubtitle,
},
municipios: {
title: 'Municípios',
subtitle:
'Os 435 municípios da campanha: um por município da Bahia — em Salvador, uma zona eleitoral cada.',
},
territorios: {
title: 'Territórios de Identidade',
subtitle:
'Compare a concentração histórica e a cobertura de assessoria das regiões da Bahia. Abra um território para ver seus municípios.',
},
liderancas: {
title: 'Lideranças',
subtitle:
'Uma ficha por pessoa — cada liderança pode atuar em vários municípios e organizações.',
},
liderancasNova: {
title: 'Nova liderança',
subtitle:
'O contato é reaproveitado pelo celular quando já existe. Registre o consentimento antes de inserir dados reais.',
},
dobradinhas: {
title: 'Dobradinhas',
subtitle:
'Deputados estaduais com quem a campanha dobra — vincule lideranças e municípios direto na lista.',
},
dobradinhasNova: {
title: 'Nova dobradinha',
subtitle:
'Cadastre deputados estaduais com quem a campanha dobra. Vincule a municípios e lideranças nas fichas correspondentes.',
},
atividades: {
title: 'Atividades',
subtitle: 'Organize caminhadas, comícios, panfletagens e demais ações de campanha.',
},
atividadesNova: {
title: 'Nova atividade',
subtitle: 'Defina a ação, quando e onde ela acontece e quem responde por ela.',
},
atividadesGiros: {
title: 'Planejar giro',
subtitle:
'Um giro por território: o ato onde o voto comprometido já está, as paradas com rede para receber, e uma semente de expansão para o giro não repetir só a base. A proposta é sugestão — você edita antes de gerar.',
},
demandas: {
title: 'Demandas',
subtitle:
'Necessidades da campanha abertas pelas lideranças, revisadas pela assessoria e — quando preciso — decididas pelo Coordenador Geral.',
},
demandasNova: {
title: 'Nova demanda',
subtitle:
'Descreva a necessidade (material, transporte, espaço, apoio…) e o município. A assessoria revisa e responde por aqui.',
},
apoiadores: {
title: 'Apoiadores',
subtitle: 'Base nominal de apoio com intenção de voto e vínculo opcional a municípios.',
},
apoiadoresNovo: {
title: 'Novo apoiador',
subtitle: 'Cadastre um apoiador com telefone obrigatório e consentimento LGPD.',
},
apoiadoresImportar: {
title: 'Importar apoiadores via CSV',
subtitle: 'Envie uma planilha, confira a prévia e confirme a importação em lote.',
},
assessores: {
title: 'Assessores',
subtitle:
'Consulte na tabela; ative Editar para alterar campos e carteira. Nome abre a ficha; e-mail/celular copiam; município abre o município.',
},
organizacoes: {
title: 'Organizações',
@ -122,25 +91,20 @@ export const campaignPageChromeCatalog = {
title: 'Meu perfil',
subtitle: 'Gerencie sua foto, sua senha e a entrada por biometria.',
},
} as const satisfies Record<
string,
CampaignPageChrome | { title: string; subtitle: (role: CampaignRole) => string } | null
>
} as const satisfies Record<string, CampaignPageChrome | null>
const resolveCatalogEntry = (
entry: (typeof campaignPageChromeCatalog)[keyof typeof campaignPageChromeCatalog],
role: CampaignRole,
): CampaignPageChrome | null => {
if (!entry) return null
const subtitle = typeof entry.subtitle === 'function' ? entry.subtitle(role) : entry.subtitle
return { title: entry.title, subtitle }
return { title: entry.title, subtitle: entry.subtitle }
}
const sectionOnly = (title: string): CampaignPageChrome => ({ title })
type PathRule = {
match: (pathname: string) => boolean
resolve: (role: CampaignRole) => CampaignPageChrome | null
resolve: () => CampaignPageChrome | null
}
const pathRules: PathRule[] = [
@ -150,11 +114,11 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => pathname === '/campanha/quadro',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.quadro, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.quadro),
},
{
match: (pathname) => pathname === '/campanha/municipios',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.municipios, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.municipios),
},
{
match: (pathname) => /^\/campanha\/municipios\/[^/]+\/editar$/.test(pathname),
@ -166,11 +130,11 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => pathname === '/campanha/territorios',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.territorios, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.territorios),
},
{
match: (pathname) => pathname === '/campanha/liderancas/nova',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.liderancasNova, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.liderancasNova),
},
{
match: (pathname) => /^\/campanha\/liderancas\/[^/]+$/.test(pathname),
@ -178,11 +142,11 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => pathname === '/campanha/liderancas',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.liderancas, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.liderancas),
},
{
match: (pathname) => pathname === '/campanha/dobradinhas/nova',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.dobradinhasNova, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.dobradinhasNova),
},
{
match: (pathname) => /^\/campanha\/dobradinhas\/[^/]+$/.test(pathname),
@ -190,15 +154,15 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => pathname === '/campanha/dobradinhas',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.dobradinhas, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.dobradinhas),
},
{
match: (pathname) => pathname === '/campanha/atividades/nova',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.atividadesNova, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atividadesNova),
},
{
match: (pathname) => pathname === '/campanha/atividades/giros',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.atividadesGiros, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atividadesGiros),
},
{
match: (pathname) => /^\/campanha\/atividades\/[^/]+\/editar$/.test(pathname),
@ -210,11 +174,11 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => pathname === '/campanha/atividades',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.atividades, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.atividades),
},
{
match: (pathname) => pathname === '/campanha/demandas/nova',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.demandasNova, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.demandasNova),
},
{
match: (pathname) => /^\/campanha\/demandas\/[^/]+$/.test(pathname),
@ -222,15 +186,15 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => pathname === '/campanha/demandas',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.demandas, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.demandas),
},
{
match: (pathname) => pathname === '/campanha/apoiadores/novo',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.apoiadoresNovo, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.apoiadoresNovo),
},
{
match: (pathname) => pathname === '/campanha/apoiadores/importar',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.apoiadoresImportar, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.apoiadoresImportar),
},
{
match: (pathname) => /^\/campanha\/apoiadores\/[^/]+$/.test(pathname),
@ -238,7 +202,7 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => pathname === '/campanha/apoiadores',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.apoiadores, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.apoiadores),
},
{
match: (pathname) => /^\/campanha\/assessores\/[^/]+$/.test(pathname),
@ -246,11 +210,11 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => pathname === '/campanha/assessores',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.assessores, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.assessores),
},
{
match: (pathname) => pathname === '/campanha/organizacoes/nova',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.organizacoesNova, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.organizacoesNova),
},
{
match: (pathname) => /^\/campanha\/organizacoes\/[^/]+$/.test(pathname),
@ -258,30 +222,30 @@ const pathRules: PathRule[] = [
},
{
match: (pathname) => pathname === '/campanha/organizacoes',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.organizacoes, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.organizacoes),
},
{
match: (pathname) => pathname === '/campanha/conceitos',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.conceitos, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.conceitos),
},
{
match: (pathname) => pathname === LEADER_CONTACTS_HOME,
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.contatos, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.contatos),
},
{
match: (pathname) => pathname === '/campanha/perfil',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.perfil, role),
resolve: () => resolveCatalogEntry(campaignPageChromeCatalog.perfil),
},
]
export const resolveCampaignPageChrome = (
pathname: string,
role: CampaignRole,
_role: CampaignRole,
): CampaignPageChrome | null => {
const normalized = normalizePathname(pathname)
for (const rule of pathRules) {
if (rule.match(normalized)) {
return rule.resolve(role)
return rule.resolve()
}
}
return null
@ -301,5 +265,4 @@ export const campaignPageMetadata = (chrome: CampaignPageChrome | null): Metadat
export const campaignPageMetadataFromCatalog = (
key: keyof typeof campaignPageChromeCatalog,
role: CampaignRole = 'coordinator',
): Metadata => campaignPageMetadata(resolveCatalogEntry(campaignPageChromeCatalog[key], role))
): Metadata => campaignPageMetadata(resolveCatalogEntry(campaignPageChromeCatalog[key]))

View file

@ -12,11 +12,12 @@ describe('campaignPageChrome', () => {
expect(resolveCampaignPageChrome('/campanha/', 'advisor')).toBeNull()
})
it('resolves section list chrome', () => {
it('resolves section list chrome without prose subtitle', () => {
expect(resolveCampaignPageChrome('/campanha/municipios', 'coordinator')).toEqual({
title: 'Municípios',
subtitle:
'Os 435 municípios da campanha: um por município da Bahia — em Salvador, uma zona eleitoral cada.',
})
expect(resolveCampaignPageChrome('/campanha/liderancas', 'advisor')).toEqual({
title: 'Lideranças',
})
})
@ -26,12 +27,19 @@ describe('campaignPageChrome', () => {
})
})
it('uses role-based subtitle for quadro', () => {
expect(resolveCampaignPageChrome('/campanha/quadro', 'advisor')?.subtitle).toContain(
'sua assessoria',
it('resolves quadro without role-based prose subtitle', () => {
expect(resolveCampaignPageChrome('/campanha/quadro', 'advisor')).toEqual({ title: 'Quadro' })
expect(resolveCampaignPageChrome('/campanha/quadro', 'coordinator')).toEqual({
title: 'Quadro',
})
})
it('keeps prose subtitle on out-of-scope sections', () => {
expect(resolveCampaignPageChrome('/campanha/conceitos', 'coordinator')?.subtitle).toContain(
'cada número',
)
expect(resolveCampaignPageChrome('/campanha/quadro', 'coordinator')?.subtitle).toContain(
'geral',
expect(resolveCampaignPageChrome('/campanha/perfil', 'coordinator')?.subtitle).toContain(
'biometria',
)
})