From e733efbe42542ae087d5b46ff1bf84d575584438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 29 Jul 2026 22:05:38 -0300 Subject: [PATCH 1/2] B68: curated municipality suggestions on empty home search focus. Staff see role-ranked shortcuts (advisor portfolio by signal age; CG/candidate by alta priority deficit) via POST mode suggest, without treating empty query as search. --- docs/plans/sugestoes-busca-vazia-inicio.md | 4 +- docs/roadmap.md | 4 +- .../campanha/(app)/home-search/route.ts | 11 ++- .../campaign/dashboard/CampaignHomeSearch.tsx | 13 ++- .../dashboard/CampaignHomeStaffChrome.tsx | 2 +- .../campaign/dashboard/HomeSearchHitRow.tsx | 42 ++++++++ .../dashboard/HomeSearchMunicipalityGroup.tsx | 79 +++++---------- .../dashboard/HomeSearchResultsContext.tsx | 18 +++- .../dashboard/HomeSearchResultsShell.tsx | 10 +- .../campaign/dashboard/useHomeSearchQuery.ts | 16 ++- src/lib/campaignHomeSearchContract.ts | 7 ++ src/lib/campaignHomeSearchHits.ts | 23 ++++- src/lib/homeSearchSuggest.ts | 86 ++++++++++++++++ src/lib/schemas/homeSearch.ts | 22 +++-- .../homeSearch/loadHomeSearchSuggestions.ts | 83 ++++++++++++++++ .../homeSearch/searchHomeMunicipalities.ts | 23 ++--- tests/int/homeSearchSuggestions.int.spec.ts | 69 +++++++++++++ tests/unit/campaignHomeSearch.unit.spec.tsx | 3 + .../campaignHomeSearchContract.unit.spec.ts | 17 ++++ .../unit/campaignHomeSearchQuery.unit.spec.ts | 12 +++ tests/unit/homeSearchSuggest.unit.spec.ts | 99 +++++++++++++++++++ 21 files changed, 543 insertions(+), 100 deletions(-) create mode 100644 src/components/campaign/dashboard/HomeSearchHitRow.tsx create mode 100644 src/lib/homeSearchSuggest.ts create mode 100644 src/utilities/homeSearch/loadHomeSearchSuggestions.ts create mode 100644 tests/int/homeSearchSuggestions.int.spec.ts create mode 100644 tests/unit/campaignHomeSearchContract.unit.spec.ts create mode 100644 tests/unit/homeSearchSuggest.unit.spec.ts diff --git a/docs/plans/sugestoes-busca-vazia-inicio.md b/docs/plans/sugestoes-busca-vazia-inicio.md index 469344f7..b1abb0a5 100644 --- a/docs/plans/sugestoes-busca-vazia-inicio.md +++ b/docs/plans/sugestoes-busca-vazia-inicio.md @@ -1,7 +1,7 @@ # Sugestões na busca aberta (empty state por papel) -Status: rascunho -Atualizado em: 2026-07-29 +Status: entregue (2026-07-29) +Atualizado em: 2026-07-29 — as-built: `POST /campanha/home-search` com body discriminado `mode: 'search' | 'suggest'`; `loadHomeSearchSuggestions` + `rankHomeSearchSuggestMunicipalities` (assessor = carteira fria; CG/candidato = `priority === 'alta'` por déficit central + frescor); `homeSearchUiFocused` + fetch suggest quando `uiFocused && !query.isActive`; seção **Sugestões** reutilizando `HomeSearchHitRow` / grupo B48; `resultKind` na resposta; sem migration. **Overlap B66:** predicado `uiFocused` e chrome oculto no focus — animação de retração continua no plano B66. Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B68** — UX-1 busca Início) Impeccable: B — região de resultados do Início (`HomeSearchResultsShell` / grupos B48) quando focus + query inativa Appetite: ~1–1,25 dia eng; loader de sugestões por role + UI reusando linhas B48; v1 só Municípios (lógica simples); sem migration diff --git a/docs/roadmap.md b/docs/roadmap.md index 684c30e9..1bd938b2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-29 — **B69**–**B70** A4 liderança (exclusivo+limpeza → wizard grid); ~~**B56**~~ ✓ resumo; ~~**B67**~~ ✓; **B68**; **B66**; ~~**B59**~~ ✓ chassis wizard; ~~**B58**~~ ✓; **B65**; B48 ✓; B49–B55; wizards B60–B64. +Atualizado em: 2026-07-29 — **B69**–**B70** A4 liderança (exclusivo+limpeza → wizard grid); ~~**B56**~~ ✓ resumo; ~~**B67**~~ ✓; ~~**B68**~~ ✓ sugestões na busca aberta; **B66**; ~~**B59**~~ ✓ chassis wizard; ~~**B58**~~ ✓; **B65**; B48 ✓; B49–B55; wizards B60–B64. Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -102,7 +102,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **B66** Modo focado da busca no **focus** (não na digitação) + animação de retração do chrome (strip/spacer/resumo) como se a barra expandisse — emenda a decisão B47 “expand = query”; hits continuam gated por limiar ≥2 · Depende de B47 ✓ · soft B65 · ~0,5–0,75d · Janela 1 · Paralelizável com B49–B53 / B59 / B62 · Impeccable B · [plano](plans/modo-focado-busca-no-focus.md) · empty útil → **B68** - **B65** Ancorar busca + ações do Início no limite inferior (mobile) — B46 ✓ entregou ordem/slots mas o spacer `flex-1` não empurra: `min-h-full` no filho do scrollport não cria altura livre; preencher cadeia (`h-full`/flex fill) no chrome do Início; `md+` inalterado; modo focado B47/B66 intacto · Depende de B46 ✓ / B47 ✓ (código) · soft B66 (animação parte do dock) · ~0,25–0,5d · Janela 1 · Paralelizável com B58 / B49 / B59 · Impeccable B · [plano](plans/ancorar-busca-acoes-inicio-mobile.md) - ~~**B48** Busca — resultados Municípios (+ TIs no mesmo grupo; flag de prioridade alinhada; “2022” / agregado à direita; sem card)~~ — **entregue 2026-07-29** (`POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup`; word-start; assessor scoped; TIs via `loadTerritoryOverview`) · [plano](plans/busca-global-resultados-municipios.md) -- **B68** Sugestões na busca aberta (empty state por papel) — focus + query inativa mostra atalhos curados (v1: Municípios — assessor = carteira fria; CG = prioritários frios/déficit); **não** é search com q=""; digitar → hits B48+; entidades extras quando B49–B53 · Depende de B47 ✓ / B48 ✓ · soft B66 · ~1–1,25d · Janela 1 · Paralelizável com B49–B53 / B59 / B67 · Impeccable B · [plano](plans/sugestoes-busca-vazia-inicio.md) +- ~~**B68** Sugestões na busca aberta (empty state por papel)~~ — **entregue 2026-07-29** (`POST /campanha/home-search` com `mode: 'suggest'`; `loadHomeSearchSuggestions` + `rankHomeSearchSuggestMunicipalities`; assessor = carteira fria; CG/candidato = `priority === 'alta'` por déficit central + frescor; `homeSearchUiFocused` + seção **Sugestões**; `resultKind`; sem migration) · [plano](plans/sugestoes-busca-vazia-inicio.md) - **B49** Busca — resultados Lideranças · Depende de B47 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-liderancas.md) - **B50** Busca — resultados Assessores (access B19) · Depende de B47 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-assessores.md) - **B51** Busca — resultados Atividades · Depende de B47 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-atividades.md) diff --git a/src/app/(campaign)/campanha/(app)/home-search/route.ts b/src/app/(campaign)/campanha/(app)/home-search/route.ts index 59fd6583..38f9df4e 100644 --- a/src/app/(campaign)/campanha/(app)/home-search/route.ts +++ b/src/app/(campaign)/campanha/(app)/home-search/route.ts @@ -8,6 +8,7 @@ import { import { homeSearchBodySchema } from '@/lib/schemas/homeSearch' import { getCampaignActionContext } from '@/utilities/campaignActionContext' import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute' +import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearchSuggestions' import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities' export const dynamic = 'force-dynamic' @@ -18,9 +19,15 @@ export const POST = campaignJsonMutationRoute( safeMessages: [HOME_SEARCH_STAFF_ONLY_MESSAGE], genericMessage: HOME_SEARCH_GENERIC_ERROR_MESSAGE, }, - async ({ query }) => { + async (body) => { const { payload, actor } = await getCampaignActionContext() - const result = await searchHomeMunicipalities(payload, actor, query) + + if (body.mode === 'suggest') { + const result = await loadHomeSearchSuggestions(payload, actor) + return NextResponse.json(result) + } + + const result = await searchHomeMunicipalities(payload, actor, body.query) return NextResponse.json(result) }, ) diff --git a/src/components/campaign/dashboard/CampaignHomeSearch.tsx b/src/components/campaign/dashboard/CampaignHomeSearch.tsx index f97dcf96..a4920075 100644 --- a/src/components/campaign/dashboard/CampaignHomeSearch.tsx +++ b/src/components/campaign/dashboard/CampaignHomeSearch.tsx @@ -11,13 +11,14 @@ const HOME_SEARCH_LABEL = 'Buscar na campanha' export const CampaignHomeSearch = ({ children, - resultsBusy = false, + resultsBusy, }: { children?: ReactNode + /** When set, must already fold debounce (see `useHomeSearchResultsState.isFetching`). */ resultsBusy?: boolean }) => { - const { query, setRaw, clear, isDebouncing } = useHomeSearch() - const ariaBusy = isDebouncing || resultsBusy || undefined + const { query, setRaw, clear, isDebouncing, setInputFocused } = useHomeSearch() + const ariaBusy = (resultsBusy ?? isDebouncing) || undefined return (
@@ -27,6 +28,12 @@ export const CampaignHomeSearch = ({ placeholder="Município, liderança, atividade…" value={query.raw} onChange={(event) => setRaw(event.target.value)} + onFocus={() => setInputFocused(true)} + onBlur={() => { + if (!query.isActive) { + setInputFocused(false) + } + }} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault() diff --git a/src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx b/src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx index b70fdb04..3b134ecc 100644 --- a/src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx +++ b/src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx @@ -35,7 +35,7 @@ export const CampaignHomeStaffChrome = ({ summarySlot?: ReactNode }) => { const searchState = useHomeSearchQuery() - const focused = searchState.query.isActive + const focused = searchState.uiFocused return ( diff --git a/src/components/campaign/dashboard/HomeSearchHitRow.tsx b/src/components/campaign/dashboard/HomeSearchHitRow.tsx new file mode 100644 index 00000000..1bcf9669 --- /dev/null +++ b/src/components/campaign/dashboard/HomeSearchHitRow.tsx @@ -0,0 +1,42 @@ +'use client' + +import Link from 'next/link' +import type { ReactNode } from 'react' + +import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator' +import { cn } from '@/lib/utils' + +const PRIORITY_ICON_SLOT_CLASS = 'flex w-4 shrink-0 justify-center' + +export const HomeSearchHitRow = ({ + href, + primary, + secondary, + trailing, + showPriority, +}: { + href: string + primary: string + secondary?: string + trailing: ReactNode + showPriority: boolean +}) => ( + + + {showPriority ? : null} + + + {primary} + {secondary ? ( + {secondary} + ) : null} + + {trailing} + +) diff --git a/src/components/campaign/dashboard/HomeSearchMunicipalityGroup.tsx b/src/components/campaign/dashboard/HomeSearchMunicipalityGroup.tsx index 475ad0df..e9eec65b 100644 --- a/src/components/campaign/dashboard/HomeSearchMunicipalityGroup.tsx +++ b/src/components/campaign/dashboard/HomeSearchMunicipalityGroup.tsx @@ -1,63 +1,26 @@ 'use client' -import Link from 'next/link' -import type { ReactNode } from 'react' - +import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow' import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext' -import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator' import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout' import { homeSearchMunicipalityGroupHasHits } from '@/lib/campaignHomeSearchHits' import { formatElectionNumber } from '@/lib/electionFormat' import { buildTerritoryPageHref } from '@/lib/territoryAnchor' -import { cn } from '@/lib/utils' - -const PRIORITY_ICON_SLOT_CLASS = 'flex w-4 shrink-0 justify-center' - -const HomeSearchHitRow = ({ - href, - primary, - secondary, - trailing, - showPriority, -}: { - href: string - primary: string - secondary?: string - trailing: ReactNode - showPriority: boolean -}) => ( - - - {showPriority ? : null} - - - {primary} - {secondary ? ( - {secondary} - ) : null} - - {trailing} - -) export const HomeSearchMunicipalityGroup = () => { - const { results } = useHomeSearchResults() + const { results, resultKind } = useHomeSearchResults() if (results.status !== 'success') return null const { municipalities, territories } = results.data if (!homeSearchMunicipalityGroupHasHits(results.data)) return null + const sectionTitle = resultKind === 'suggest' ? 'Sugestões' : 'Municípios' + return ( -
+

- Municípios + {sectionTitle}

    {municipalities.map((hit) => ( @@ -80,20 +43,22 @@ export const HomeSearchMunicipalityGroup = () => { /> ))} - {territories.map((hit) => ( -
  • - - {formatElectionNumber(hit.votes2022)} - - } - /> -
  • - ))} + {resultKind === 'search' + ? territories.map((hit) => ( +
  • + + {formatElectionNumber(hit.votes2022)} + + } + /> +
  • + )) + : null}
) diff --git a/src/components/campaign/dashboard/HomeSearchResultsContext.tsx b/src/components/campaign/dashboard/HomeSearchResultsContext.tsx index 6e53a537..d121b3d4 100644 --- a/src/components/campaign/dashboard/HomeSearchResultsContext.tsx +++ b/src/components/campaign/dashboard/HomeSearchResultsContext.tsx @@ -26,6 +26,8 @@ type HomeSearchResultsState = export type HomeSearchResultsContextValue = { results: HomeSearchResultsState isFetching: boolean + /** B68 — whether the current success payload came from suggest vs search. */ + resultKind: 'idle' | HomeSearchSuccessResponse['resultKind'] } const HomeSearchResultsContext = createContext(null) @@ -51,12 +53,15 @@ export const useHomeSearchResults = (): HomeSearchResultsContextValue => { type HomeSearchErrorResponse = { status: 'error'; message: string } export const useHomeSearchResultsState = (): HomeSearchResultsContextValue => { - const { query, isDebouncing } = useHomeSearch() + const { query, isDebouncing, uiFocused } = useHomeSearch() const [results, setResults] = useState({ status: 'idle' }) const requestSeq = useRef(0) + const suggestMode = uiFocused && !query.isActive + const searchMode = query.isActive + useEffect(() => { - if (!query.isActive) { + if (!suggestMode && !searchMode) { requestSeq.current += 1 setResults({ status: 'idle' }) return @@ -66,10 +71,14 @@ export const useHomeSearchResultsState = (): HomeSearchResultsContextValue => { const controller = new AbortController() setResults({ status: 'loading' }) + const body = suggestMode + ? ({ mode: 'suggest' } as const) + : ({ mode: 'search', query: query.debounced } as const) + void (async () => { const { ok, payload } = await postCampaignJson< HomeSearchSuccessResponse | HomeSearchErrorResponse - >(HOME_SEARCH_ROUTE, { query: query.debounced }, controller.signal) + >(HOME_SEARCH_ROUTE, body, controller.signal) if (seq !== requestSeq.current) return @@ -100,7 +109,7 @@ export const useHomeSearchResultsState = (): HomeSearchResultsContextValue => { return () => { controller.abort() } - }, [query.debounced, query.isActive]) + }, [query.debounced, searchMode, suggestMode]) const isFetching = isDebouncing || results.status === 'loading' @@ -108,6 +117,7 @@ export const useHomeSearchResultsState = (): HomeSearchResultsContextValue => { () => ({ results, isFetching, + resultKind: results.status === 'success' ? results.data.resultKind : 'idle', }), [results, isFetching], ) diff --git a/src/components/campaign/dashboard/HomeSearchResultsShell.tsx b/src/components/campaign/dashboard/HomeSearchResultsShell.tsx index 9e8926df..8a252b80 100644 --- a/src/components/campaign/dashboard/HomeSearchResultsShell.tsx +++ b/src/components/campaign/dashboard/HomeSearchResultsShell.tsx @@ -8,22 +8,20 @@ import { homeSearchMunicipalityGroupHasHits } from '@/lib/campaignHomeSearchHits export const HomeSearchResultsShell = ({ children }: { children: ReactNode }) => { const { query } = useHomeSearch() - const { results } = useHomeSearchResults() + const { results, resultKind } = useHomeSearchResults() const showEmpty = query.isActive && + resultKind === 'search' && results.status === 'success' && !homeSearchMunicipalityGroupHasHits(results.data) - const showError = results.status === 'error' - const errorMessage = results.status === 'error' ? results.message : null - return ( <> {children} - {showError && errorMessage ? ( + {results.status === 'error' ? (

- {errorMessage} + {results.message}

) : null} {showEmpty ?

Nenhum resultado.

: null} diff --git a/src/components/campaign/dashboard/useHomeSearchQuery.ts b/src/components/campaign/dashboard/useHomeSearchQuery.ts index a131ad33..9c382025 100644 --- a/src/components/campaign/dashboard/useHomeSearchQuery.ts +++ b/src/components/campaign/dashboard/useHomeSearchQuery.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { HOME_SEARCH_DEBOUNCE_MS, homeSearchQueryIsActive, + homeSearchUiFocused, normalizeHomeSearchRaw, type HomeSearchQuery, } from '@/lib/campaignHomeSearchContract' @@ -14,11 +15,15 @@ export type HomeSearchController = { setRaw: (next: string) => void clear: () => void isDebouncing: boolean + inputFocused: boolean + setInputFocused: (next: boolean) => void + uiFocused: boolean } export const useHomeSearchQuery = (): HomeSearchController => { const [raw, setRaw] = useState('') const [debounced, setDebounced] = useState('') + const [inputFocused, setInputFocused] = useState(false) useEffect(() => { if (raw === debounced) { @@ -44,9 +49,15 @@ export const useHomeSearchQuery = (): HomeSearchController => { [raw, trimmedDebounced], ) + const uiFocused = homeSearchUiFocused({ + inputFocused, + isActive: query.isActive, + }) + const clear = useCallback(() => { setRaw('') setDebounced('') + setInputFocused(false) }, []) return useMemo( @@ -55,7 +66,10 @@ export const useHomeSearchQuery = (): HomeSearchController => { setRaw, clear, isDebouncing, + inputFocused, + setInputFocused, + uiFocused, }), - [query, clear, isDebouncing], + [query, clear, isDebouncing, inputFocused, uiFocused], ) } diff --git a/src/lib/campaignHomeSearchContract.ts b/src/lib/campaignHomeSearchContract.ts index 07921bcf..cb0ed851 100644 --- a/src/lib/campaignHomeSearchContract.ts +++ b/src/lib/campaignHomeSearchContract.ts @@ -14,3 +14,10 @@ export const homeSearchQueryIsActive = (trimmedDebounced: string): boolean => trimmedDebounced.length >= HOME_SEARCH_MIN_QUERY_LENGTH export const normalizeHomeSearchRaw = (raw: string): string => raw.trim() + +/** + * B66/B68 — focused chrome vs active search query. Fetch uses `isActive`; + * layout hiding uses `uiFocused` (input focused or query active). + */ +export const homeSearchUiFocused = (input: { inputFocused: boolean; isActive: boolean }): boolean => + input.inputFocused || input.isActive diff --git a/src/lib/campaignHomeSearchHits.ts b/src/lib/campaignHomeSearchHits.ts index 28d8f9e0..d928c240 100644 --- a/src/lib/campaignHomeSearchHits.ts +++ b/src/lib/campaignHomeSearchHits.ts @@ -1,4 +1,8 @@ -import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank' +import { + DEFAULT_VOTE_RANK_YEAR, + getMunicipalityVoteRank, + type MunicipalityVoteRankEntry, +} from '@/lib/municipalityVoteRank' export type HomeSearchMunicipalityHit = { kind: 'municipality' @@ -15,8 +19,11 @@ export type HomeSearchTerritoryHit = { votes2022: number } +type HomeSearchResultKind = 'search' | 'suggest' + export type HomeSearchSuccessResponse = { status: 'success' + resultKind: HomeSearchResultKind municipalities: HomeSearchMunicipalityHit[] territories: HomeSearchTerritoryHit[] } @@ -24,3 +31,17 @@ export type HomeSearchSuccessResponse = { export const homeSearchMunicipalityGroupHasHits = ( data: Pick, ): boolean => data.municipalities.length > 0 || data.territories.length > 0 + +export const toHomeSearchMunicipalityHit = (doc: { + slug: string + name: string + region: string + priority?: 'alta' | 'normal' | null +}): HomeSearchMunicipalityHit => ({ + kind: 'municipality', + slug: doc.slug, + name: doc.name, + region: doc.region, + priority: doc.priority ?? null, + votePosition2022: getMunicipalityVoteRank(doc.slug, DEFAULT_VOTE_RANK_YEAR), +}) diff --git a/src/lib/homeSearchSuggest.ts b/src/lib/homeSearchSuggest.ts new file mode 100644 index 00000000..7f9b6a1f --- /dev/null +++ b/src/lib/homeSearchSuggest.ts @@ -0,0 +1,86 @@ +import type { CampaignRole } from '@/lib/campaignRoles' +import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles' + +/** Parity with B20 `DASHBOARD_PRIORITY_SAMPLE_LIMIT`. */ +export const HOME_SEARCH_SUGGEST_LIMIT = 8 + +export type HomeSearchSuggestMunicipalityInput = { + slug: string + name: string + region: string + priority: 'alta' | 'normal' | null + /** ISO timestamp or null when never signaled — colder sorts first. */ + lastSignalAt: string | null + /** E9 central deficit sort key; only used for unrestricted roles. */ + centralDeficitSortValue: number | null +} + +const municipalityNameCompare = ( + left: HomeSearchSuggestMunicipalityInput, + right: HomeSearchSuggestMunicipalityInput, +): number => left.name.localeCompare(right.name, 'pt-BR') + +/** Older signal first; null = coldest. */ +const compareSignalOldestFirst = ( + left: HomeSearchSuggestMunicipalityInput, + right: HomeSearchSuggestMunicipalityInput, +): number => { + if (left.lastSignalAt === null && right.lastSignalAt === null) { + return municipalityNameCompare(left, right) + } + if (left.lastSignalAt === null) return -1 + if (right.lastSignalAt === null) return 1 + const byTime = left.lastSignalAt.localeCompare(right.lastSignalAt) + if (byTime !== 0) return byTime + return municipalityNameCompare(left, right) +} + +const compareUnrestrictedSuggest = ( + left: HomeSearchSuggestMunicipalityInput, + right: HomeSearchSuggestMunicipalityInput, +): number => { + const leftValue = left.centralDeficitSortValue + const rightValue = right.centralDeficitSortValue + if (leftValue === null && rightValue === null) return compareSignalOldestFirst(left, right) + if (leftValue === null) return 1 + if (rightValue === null) return -1 + if (leftValue === rightValue) return compareSignalOldestFirst(left, right) + return rightValue - leftValue +} + +const rankAdvisorPortfolio = ( + municipalities: HomeSearchSuggestMunicipalityInput[], + limit: number, +): HomeSearchSuggestMunicipalityInput[] => + [...municipalities].sort(compareSignalOldestFirst).slice(0, limit) + +const rankUnrestrictedPriority = ( + municipalities: HomeSearchSuggestMunicipalityInput[], + limit: number, +): HomeSearchSuggestMunicipalityInput[] => + municipalities + .filter((municipality) => municipality.priority === 'alta') + .sort(compareUnrestrictedSuggest) + .slice(0, limit) + +/** + * B68 — curated municipality shortcuts for the Início search empty state. + * Pure policy pinned in unit tests; server loaders supply scoped inputs. + */ +export const rankHomeSearchSuggestMunicipalities = ( + role: CampaignRole, + municipalities: HomeSearchSuggestMunicipalityInput[], + limit = HOME_SEARCH_SUGGEST_LIMIT, +): HomeSearchSuggestMunicipalityInput[] => { + if (municipalities.length === 0) return [] + + if (isUnrestrictedCampaignRole(role)) { + return rankUnrestrictedPriority(municipalities, limit) + } + + if (role === 'advisor') { + return rankAdvisorPortfolio(municipalities, limit) + } + + return [] +} diff --git a/src/lib/schemas/homeSearch.ts b/src/lib/schemas/homeSearch.ts index 3440a987..f7dead6f 100644 --- a/src/lib/schemas/homeSearch.ts +++ b/src/lib/schemas/homeSearch.ts @@ -4,10 +4,18 @@ import { HOME_SEARCH_MIN_QUERY_LENGTH } from '@/lib/campaignHomeSearchContract' export const HOME_SEARCH_QUERY_MAX_LENGTH = 80 -export const homeSearchBodySchema = z.object({ - query: z - .string() - .trim() - .min(HOME_SEARCH_MIN_QUERY_LENGTH, 'Digite pelo menos 2 caracteres.') - .max(HOME_SEARCH_QUERY_MAX_LENGTH), -}) +const homeSearchQueryField = z + .string() + .trim() + .min(HOME_SEARCH_MIN_QUERY_LENGTH, 'Digite pelo menos 2 caracteres.') + .max(HOME_SEARCH_QUERY_MAX_LENGTH) + +export const homeSearchBodySchema = z.discriminatedUnion('mode', [ + z.object({ + mode: z.literal('search'), + query: homeSearchQueryField, + }), + z.object({ + mode: z.literal('suggest'), + }), +]) diff --git a/src/utilities/homeSearch/loadHomeSearchSuggestions.ts b/src/utilities/homeSearch/loadHomeSearchSuggestions.ts new file mode 100644 index 00000000..d7aa993e --- /dev/null +++ b/src/utilities/homeSearch/loadHomeSearchSuggestions.ts @@ -0,0 +1,83 @@ +import 'server-only' + +import type { Payload } from 'payload' + +import { + toHomeSearchMunicipalityHit, + type HomeSearchSuccessResponse, +} from '@/lib/campaignHomeSearchHits' +import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages' +import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles' +import { + rankHomeSearchSuggestMunicipalities, + type HomeSearchSuggestMunicipalityInput, +} from '@/lib/homeSearchSuggest' +import type { CampaignUser } from '@/payload-types' +import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope' +import { centralDeficitSortValue } from '@/utilities/municipality/goalCoverage' +import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipality/municipalityGoalAccount' +import { resolveMunicipalityLastSignalAt } from '@/utilities/municipality/municipalitySignal' + +export const loadHomeSearchSuggestions = async ( + payload: Payload, + user: CampaignUser, +): Promise => { + if (!isStaffCampaignRole(user.role)) { + throw new Error(HOME_SEARCH_STAFF_ONLY_MESSAGE) + } + + const { municipalities, pledgeAggregates } = await loadMunicipalityScope( + payload, + user, + {}, + { + extraSelect: { lastUpdateAt: true }, + }, + ) + + let coverageByMunicipalityID: + | Awaited>['coverageByMunicipalityID'] + | null = null + + if (isUnrestrictedCampaignRole(user.role)) { + const altaMunicipalities = municipalities.filter( + (municipality) => municipality.priority === 'alta', + ) + const bundle = await loadMunicipalityGoalCoverageBundle( + payload, + user, + altaMunicipalities, + pledgeAggregates, + ) + coverageByMunicipalityID = bundle.coverageByMunicipalityID + } + + const inputs: HomeSearchSuggestMunicipalityInput[] = municipalities.map((doc) => { + const aggregate = pledgeAggregates.get(doc.id) + const lastSignalAt = resolveMunicipalityLastSignalAt( + doc.lastUpdateAt ?? null, + aggregate?.lastPledgeAt ?? null, + ) + const centralCoverage = coverageByMunicipalityID?.get(doc.id)?.central + + return { + slug: doc.slug, + name: doc.name, + region: doc.region, + priority: doc.priority ?? null, + lastSignalAt, + centralDeficitSortValue: centralDeficitSortValue(centralCoverage), + } + }) + + const ranked = rankHomeSearchSuggestMunicipalities(user.role, inputs) + + const municipalityHits = ranked.map(toHomeSearchMunicipalityHit) + + return { + status: 'success', + resultKind: 'suggest', + municipalities: municipalityHits, + territories: [], + } +} diff --git a/src/utilities/homeSearch/searchHomeMunicipalities.ts b/src/utilities/homeSearch/searchHomeMunicipalities.ts index 7d78607c..9c0d66a0 100644 --- a/src/utilities/homeSearch/searchHomeMunicipalities.ts +++ b/src/utilities/homeSearch/searchHomeMunicipalities.ts @@ -3,10 +3,11 @@ import 'server-only' import type { Payload } from 'payload' import { homeSearchQueryIsActive, normalizeHomeSearchRaw } from '@/lib/campaignHomeSearchContract' -import type { - HomeSearchMunicipalityHit, - HomeSearchSuccessResponse, - HomeSearchTerritoryHit, +import { + toHomeSearchMunicipalityHit, + type HomeSearchMunicipalityHit, + type HomeSearchSuccessResponse, + type HomeSearchTerritoryHit, } from '@/lib/campaignHomeSearchHits' import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages' import { isStaffCampaignRole } from '@/lib/campaignRoles' @@ -14,7 +15,7 @@ import { compareHomeSearchNameRelevance, normalizeHomeSearchName, } from '@/lib/homeSearchMunicipalityMatch' -import { DEFAULT_VOTE_RANK_YEAR, getMunicipalityVoteRank } from '@/lib/municipalityVoteRank' +import { DEFAULT_VOTE_RANK_YEAR } from '@/lib/municipalityVoteRank' import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter' import type { CampaignUser } from '@/payload-types' import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope' @@ -22,6 +23,7 @@ import { loadTerritoryOverview } from '@/utilities/territory/loadTerritoryOvervi const emptyResponse = (): HomeSearchSuccessResponse => ({ status: 'success', + resultKind: 'search', municipalities: [], territories: [], }) @@ -50,17 +52,9 @@ export const searchHomeMunicipalities = async ( if (!matchesNormalizedAtWordStart(normalizedName, normalizedQuery)) { return null } - const votePosition2022 = getMunicipalityVoteRank(doc.slug, DEFAULT_VOTE_RANK_YEAR) return { normalizedName, - hit: { - kind: 'municipality' as const, - slug: doc.slug, - name: doc.name, - region: doc.region, - priority: doc.priority ?? null, - votePosition2022, - }, + hit: toHomeSearchMunicipalityHit(doc), } }) .filter((row): row is NonNullable => row !== null) @@ -113,6 +107,7 @@ export const searchHomeMunicipalities = async ( return { status: 'success', + resultKind: 'search', municipalities: municipalityHits, territories: territoryHits, } diff --git a/tests/int/homeSearchSuggestions.int.spec.ts b/tests/int/homeSearchSuggestions.int.spec.ts new file mode 100644 index 00000000..4beac880 --- /dev/null +++ b/tests/int/homeSearchSuggestions.int.spec.ts @@ -0,0 +1,69 @@ +// @vitest-environment node + +import type { Payload } from 'payload' +import { getPayload } from 'payload' +import { beforeAll, describe, expect, it } from 'vitest' + +import config from '@/payload.config' +import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearchSuggestions' +import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities' + +import { installCampaignFixtures } from '../helpers/campaignFixtures' + +let payload: Payload +const campaignFixtures = installCampaignFixtures({ + getPayload: () => payload, + setPayload: (nextPayload) => { + payload = nextPayload + }, +}) + +describe('loadHomeSearchSuggestions (B68)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('returns only high-priority municipalities for coordinator', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + + const result = await loadHomeSearchSuggestions(payload, coordinator) + expect(result.resultKind).toBe('suggest') + expect(result.territories).toEqual([]) + expect(result.municipalities.length).toBeLessThanOrEqual(8) + for (const hit of result.municipalities) { + expect(hit.priority).toBe('alta') + } + }) + + it('scopes advisor suggestions to administered municipalities', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const administered = await fixtures.getMunicipality('cairu') + await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) + + const result = await loadHomeSearchSuggestions(payload, advisor) + expect(result.municipalities.every((hit) => hit.slug === 'cairu')).toBe(true) + }) + + it('rejects leaders from home search suggestions', async () => { + const fixtures = campaignFixtures() + const leader = await fixtures.createCampaignUser('leader') + + await expect(loadHomeSearchSuggestions(payload, leader)).rejects.toThrow(/equipe de campanha/i) + }) +}) + +describe('searchHomeMunicipalities resultKind (B68)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('tags search responses with resultKind search', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + + const result = await searchHomeMunicipalities(payload, coordinator, 'Cai') + expect(result.resultKind).toBe('search') + }) +}) diff --git a/tests/unit/campaignHomeSearch.unit.spec.tsx b/tests/unit/campaignHomeSearch.unit.spec.tsx index 8bf334cd..a41d60a9 100644 --- a/tests/unit/campaignHomeSearch.unit.spec.tsx +++ b/tests/unit/campaignHomeSearch.unit.spec.tsx @@ -10,6 +10,9 @@ const providerValue = { setRaw: vi.fn(), clear: vi.fn(), isDebouncing: false, + inputFocused: false, + setInputFocused: vi.fn(), + uiFocused: true, } describe('CampaignHomeLayout focused mode', () => { diff --git a/tests/unit/campaignHomeSearchContract.unit.spec.ts b/tests/unit/campaignHomeSearchContract.unit.spec.ts new file mode 100644 index 00000000..1989ed5c --- /dev/null +++ b/tests/unit/campaignHomeSearchContract.unit.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { homeSearchUiFocused } from '@/lib/campaignHomeSearchContract' + +describe('homeSearchUiFocused', () => { + it('is true when the input is focused without an active query', () => { + expect(homeSearchUiFocused({ inputFocused: true, isActive: false })).toBe(true) + }) + + it('is true when the query is active even if the input lost focus', () => { + expect(homeSearchUiFocused({ inputFocused: false, isActive: true })).toBe(true) + }) + + it('is false when blurred and query inactive', () => { + expect(homeSearchUiFocused({ inputFocused: false, isActive: false })).toBe(false) + }) +}) diff --git a/tests/unit/campaignHomeSearchQuery.unit.spec.ts b/tests/unit/campaignHomeSearchQuery.unit.spec.ts index 25458ddb..ce7d468e 100644 --- a/tests/unit/campaignHomeSearchQuery.unit.spec.ts +++ b/tests/unit/campaignHomeSearchQuery.unit.spec.ts @@ -97,5 +97,17 @@ describe('useHomeSearchQuery', () => { expect(result.current.query.debounced).toBe('') expect(result.current.query.isActive).toBe(false) expect(result.current.isDebouncing).toBe(false) + expect(result.current.inputFocused).toBe(false) + }) + + it('uiFocused is true when input is focused without an active query', () => { + const { result } = renderHook(() => useHomeSearchQuery()) + + act(() => { + result.current.setInputFocused(true) + }) + + expect(result.current.uiFocused).toBe(true) + expect(result.current.query.isActive).toBe(false) }) }) diff --git a/tests/unit/homeSearchSuggest.unit.spec.ts b/tests/unit/homeSearchSuggest.unit.spec.ts new file mode 100644 index 00000000..34b66702 --- /dev/null +++ b/tests/unit/homeSearchSuggest.unit.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' + +import { + HOME_SEARCH_SUGGEST_LIMIT, + rankHomeSearchSuggestMunicipalities, + type HomeSearchSuggestMunicipalityInput, +} from '@/lib/homeSearchSuggest' + +const row = ( + partial: Partial & + Pick, +): HomeSearchSuggestMunicipalityInput => ({ + region: 'TI', + priority: 'normal', + lastSignalAt: null, + centralDeficitSortValue: null, + ...partial, +}) + +describe('rankHomeSearchSuggestMunicipalities', () => { + it('ranks advisor portfolio by oldest signal first, then name', () => { + const municipalities = [ + row({ slug: 'b', name: 'Brumado', lastSignalAt: '2026-07-20T12:00:00.000Z' }), + row({ slug: 'a', name: 'Abaíra', lastSignalAt: null }), + row({ slug: 'c', name: 'Cairu', lastSignalAt: '2026-07-01T12:00:00.000Z' }), + ] + + const ranked = rankHomeSearchSuggestMunicipalities('advisor', municipalities) + expect(ranked.map((m) => m.slug)).toEqual(['a', 'c', 'b']) + }) + + it('caps advisor results at HOME_SEARCH_SUGGEST_LIMIT', () => { + const municipalities = Array.from({ length: 12 }, (_, index) => + row({ + slug: `m-${index}`, + name: `Município ${index}`, + lastSignalAt: `2026-07-${String(index + 1).padStart(2, '0')}T12:00:00.000Z`, + }), + ) + + const ranked = rankHomeSearchSuggestMunicipalities('advisor', municipalities) + expect(ranked).toHaveLength(HOME_SEARCH_SUGGEST_LIMIT) + }) + + it('ranks coordinator suggestions to high-priority municipalities by central deficit', () => { + const municipalities = [ + row({ + slug: 'low', + name: 'Low', + priority: 'alta', + centralDeficitSortValue: 10, + lastSignalAt: '2026-07-20T12:00:00.000Z', + }), + row({ + slug: 'high', + name: 'High', + priority: 'alta', + centralDeficitSortValue: 500, + lastSignalAt: '2026-07-20T12:00:00.000Z', + }), + row({ + slug: 'normal', + name: 'Normal', + priority: 'normal', + centralDeficitSortValue: 900, + }), + ] + + const ranked = rankHomeSearchSuggestMunicipalities('coordinator', municipalities) + expect(ranked.map((m) => m.slug)).toEqual(['high', 'low']) + }) + + it('breaks coordinator deficit ties by colder signal', () => { + const municipalities = [ + row({ + slug: 'warm', + name: 'Warm', + priority: 'alta', + centralDeficitSortValue: 100, + lastSignalAt: '2026-07-28T12:00:00.000Z', + }), + row({ + slug: 'cold', + name: 'Cold', + priority: 'alta', + centralDeficitSortValue: 100, + lastSignalAt: '2026-06-01T12:00:00.000Z', + }), + ] + + const ranked = rankHomeSearchSuggestMunicipalities('candidate', municipalities) + expect(ranked.map((m) => m.slug)).toEqual(['cold', 'warm']) + }) + + it('returns empty for leader', () => { + const municipalities = [row({ slug: 'a', name: 'A' })] + expect(rankHomeSearchSuggestMunicipalities('leader', municipalities)).toEqual([]) + }) +}) From f243c8a88acaff431522ec10f2e6da50beacda54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 29 Jul 2026 22:06:39 -0300 Subject: [PATCH 2/2] docs(B68): mark delivered and register B68+ post-simplify perf debt. Close roadmap/plan status after rebase conflict merge and capture simplify deferrals (RSC embed suggest, shared deficit comparator). --- docs/plans/escala-dry-pos-b68.md | 58 ++++++++++++++++++++++ docs/plans/sugestoes-busca-vazia-inicio.md | 2 + docs/roadmap.md | 1 + 3 files changed, 61 insertions(+) create mode 100644 docs/plans/escala-dry-pos-b68.md diff --git a/docs/plans/escala-dry-pos-b68.md b/docs/plans/escala-dry-pos-b68.md new file mode 100644 index 00000000..86a3320d --- /dev/null +++ b/docs/plans/escala-dry-pos-b68.md @@ -0,0 +1,58 @@ +# Escala e DRY pós-B68 (sugestões na busca aberta) + +Status: rascunho (débito pós-`/simplify` 2026-07-29) +Atualizado em: 2026-07-29 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B68+**) +Impeccable: A — perf/estrutura de loader; sem pixel novo +Appetite: ~0,5–0,75 dia eng (F1); F2 cortável +Responsável: — + +## Dados → decisão → apresentação + +- **Vou apresentar dados?** Não (N/A) — mesmos ~8 hits já mostrados; só muda de onde vêm no primeiro focus. + +## Contexto + +**B68 ✓ (2026-07-29)** entrega sugestões de municípios no empty state da busca do Início (`mode: 'suggest'` em `POST /campanha/home-search`). O `/simplify` do fechamento mediu que, para **coordenador/candidato**, cada focus dispara um POST que repete `loadMunicipalityScope` + `loadMunicipalityGoalCoverageBundle` — trabalho que a mesma página já pagou em `loadCampaignHomeSummary` no RSC. `React cache()` não cruza a fronteira RSC ↔ route handler. + +O simplify da sessão já aplicou mitigação parcial: o bundle E8 roda só em municípios `priority === 'alta'` (não nas 435 linhas) e `toHomeSearchMunicipalityHit` unificou o shaping dos hits. + +## Objetivos + +- **F1:** primeiro focus no Início staff **não** refaz scope+coverage no POST quando o RSC da página já carregou os insumos — embed `initialSuggest` (ou loader irmão no mesmo `Promise.all` da página) e hidratar `useHomeSearchResultsState` com TTL curto / invalidação ao navegar. +- Sem mudar a política de ranking (assessor = frescor; CG = déficit + frescor). +- Sem migration/collection/Consent. + +## Já resolvido no simplify/critique (não reabrir) + +- `aria-busy` duplicado (`resultsBusy ?? isDebouncing`). +- Shell de erro sem `errorMessage` nullable intermediário. +- `id` removido do tipo puro do ranker. +- Factory `toHomeSearchMunicipalityHit` nos dois loaders. +- Bundle de cobertura restrito a `priority === 'alta'`. + +## Fases + +### F1 — RSC embed do suggest (P1 perf) + +- Estender `loadCampaignHomeSummary` ou extrair `loadHomeSearchSuggestHits` reutilizável no mesmo request da página (`page.tsx`). +- Passar `initialSuggest` para `CampaignHomeStaffChrome` → `useHomeSearchResultsState({ initialSuggest })`. +- Client: usar payload inicial quando `uiFocused && !query.isActive` e não houver stale/error; POST só para revalidação explícita ou após TTL (opcional v1: só primeiro paint). + +### F2 — Comparator compartilhado de déficit (cortável) + +- Extrair `compareNullableNumberDesc` + tiebreak hook para `lib/municipalitySort.ts` quando houver 3º call site com política diferente de tiebreak (hoje: B20 nome, B68 frescor, E9 lista). + +## Explicitamente fora / Adiado com gatilho + +- **Cache client-side / stale-while-revalidate no refocus** — aceito no plano B68 (fetch ao focus); revisitar se sessão reclamar de flash de loading. +- **Unificar constante `8`** (`HOME_SEARCH_SUGGEST_LIMIT` vs `DASHBOARD_PRIORITY_SAMPLE_LIMIT`) — cosmético; mover para `lib/` só se um dos dois mudar. +- **`loadTerritoryOverview` em todo keystroke da busca** — débito adjacente B48 (search path), não B68; gatilho: latência perceptível ao digitar após B49+. +- **Mesclar `loadHomeSearchSuggestions` com `searchHomeMunicipalities`** — dois call sites; extrair só o mapper (já feito). + +## Referências + +- [sugestoes-busca-vazia-inicio.md](sugestoes-busca-vazia-inicio.md) (B68) +- [`loadHomeSearchSuggestions.ts`](../src/utilities/homeSearch/loadHomeSearchSuggestions.ts) +- [`campaignDashboardData.ts`](../src/utilities/campaignDashboardData.ts) (`loadCampaignHomeSummary`) +- [`HomeSearchResultsContext.tsx`](../src/components/campaign/dashboard/HomeSearchResultsContext.tsx) diff --git a/docs/plans/sugestoes-busca-vazia-inicio.md b/docs/plans/sugestoes-busca-vazia-inicio.md index b1abb0a5..c499cf8a 100644 --- a/docs/plans/sugestoes-busca-vazia-inicio.md +++ b/docs/plans/sugestoes-busca-vazia-inicio.md @@ -111,6 +111,8 @@ Componentes: - **Grupos Lideranças / Demandas / Atividades / Assessores no empty.** Revisitar quando **B49–B53** landarem **e** o empty de municípios já estiver em uso (evidência de “falta X”). - **Visitados recentes no topo do suggest.** Revisitar se sessão observada pedir continuidade de sessão no Início (não só no Quadro). - **Ranking “mais inteligente”** (déficit × classe E10 × nível E14). Revisitar após 2 semanas de uso real do v1 simples. +- **RSC embed do suggest (evitar POST duplicado no focus).** Registrado como **B68+** F1 — revisitar se latência no focus for P1 em produção. +- **Comparator compartilhado de déficit** (B20 vs B68 vs E9). Revisitar no 3º call site — ver **B68+** F2. - **Soft deadline UX-1 03/08:** se folga apertar, este item é cortável depois de B66 (busca ainda funciona digitando). ## Referências diff --git a/docs/roadmap.md b/docs/roadmap.md index 1bd938b2..8e7aacb7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -183,6 +183,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**B33+**~~ ✓ escala/DRY pós-B33 (entregue 2026-07-27) — `useCampaignListFilterNavigation` em `shared/` absorve o scaffold de navegação/debounce dos três filtros (**325 linhas removidas contra 228 em `src`**, das quais 138 são o hook novo; JSX só se moveu onde era bug); a auditoria derrubou o racional das "assinaturas diferentes" (os três `buildXListHref` já canonicalizam, então um só callback `toHref` basta) e achou **dois** bugs de comportamento: dobradinhas e territórios descartavam a busca digitada ao tocar um filtro (unificado na semântica de municípios via `navigateWithSearch`) e o timer do debounce capturava o `state` do render que o agendou, revertendo um sort clicado dentro da janela de 1s — municípios escapava pelo `key=` da página, que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix). O `/simplify` somou o fallback de transition copiado em 5 lugares (o hook era o 6º) virando `useCampaignListTransition`, e um `aria-live` a menos; a 2ª rodada fechou a ressincronização da caixa de busca com o `q` commitado (o que permitiu **remover** o `key=` de municípios e unificar a política de montagem das três listas — `buildMunicipalityFiltersKey` orfanou e morreu, junto com `shouldUpdateMunicipalitySearchUrl`), colapsou `setSearch`+`scheduleSearch` num `onSearchChange` (superfície de 8 → 6 nomes), passou a reusar o `buildMunicipalityFilterHref` que já existia + criou o irmão `buildStateDeputyFilterHref` (4 sítios pinavam página 1 à mão), e deu a municípios o resumo de filtros ativos que faltava no bloco mobile; `campaignListFilterNavigation.unit.spec.ts` (10 casos × 3 listas) é a primeira cobertura desses três componentes. **Dívidas deferidas com gatilho** (no plano, § "Adiado com gatilho"): shell de `
` compartilhado (gatilho B29), `CampaignListSortSelect` (bloqueado numa decisão de altura de select, já divergida entre as três), `serialize`/`parseXSortValue` triplicado → `campaignListUrl`, `SupporterFilters`/`ActivityFilters` reconstruírem href à mão tendo builders pinados, ~10 kB gzip do catálogo no bundle cliente de `/campanha/municipios` (pré-existente, achado da review de performance — **gatilho já disparado duas vezes**: B41 ✓ e B42 ✓ entregaram na rota sem pagar, então deixou de ser condicional e é a dívida de bundle corrente da rota) e o 3º idioma de mock de router nos specs · [plano](plans/escala-dry-pos-b33.md) - ~~**B32+**~~ ✓ escala/DRY pós-B32 (entregue 2026-07-28) — **+564 / −802 linhas** em 15 arquivos mais quatro novos. A auditoria mudou três premissas antes do código: **`display` vs `draft` nunca divergiu** (os dois eram setados juntos em todos os caminhos — um valor com dois nomes; só o `noteDraft` do Trend, texto cru vs. nota normalizada, ficou no chamador), **`reportFailure` não nascera nos quatro controles** (o de assessores não importava `sonner`, então falha depois do fechamento era muda — furo do B42, fechado aqui) e **o helper do F2 não resolvia o motivo do F2**. Por isso F2 virou **wrapper de rota**: `campaignJsonMutationRoute(config, handler)` exporta o `POST` já com origem verificada, corpo parseado e `catch` mapeado, com o `bodySchema.parse` **dentro do mesmo `try`** para preservar a mensagem de hoje em corpo inválido; `parseCampaignJsonRequestBody`/`campaignJsonMutationErrorResponse` deixaram de ser exportados, `engagement-level` mantém o 409 dentro do handler, e um **guard de convenção** (molde do de `formActions`, verificado vermelho com um `route.ts` probe) impede a 6ª rota de nascer fora — é o que troca a política de origem de disciplina de cópia por estrutura. F3 é `postCampaignJson` (25 linhas, cinco call sites). F1 é `useCampaignCellAutosave`: **um** valor, `flush()` idempotente como caminho único de save (o `inFlightRef` do B24 entrou no hook e passou a proteger os três), o hook **dono do `open`** porque fechar É o commit, `valueRef` avançado sincronamente para não reler o render que agendou a mudança (bug que B33+ e B34 já pagaram) e `reportFailure` embutido; spec novo com fake timers cobre debounce, coalescência, resposta superada, flush no fechamento e revert com toast só depois de fechado — antes essa máquina só tinha rede e2e. F4 moveu a região `aria-live` para **fora** do Popover/Drawer (prop `statusMessage` na casca, que permanece montada). F5 deu `footer` ao `variant="sheet"` e **o Sinal migrou**: o `formId` que o plano queria na casca é desnecessário — `` no chamador + `