Merge branch 'cursor/960ab59c' (B68 home search suggestions)

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 22:07:04 -03:00
commit 98619a3f00
22 changed files with 604 additions and 100 deletions

View file

@ -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)

View file

@ -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
@ -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

View file

@ -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)
@ -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 `<form>` 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 — `<form id>` no chamador + `<button form={id}>` no footer é HTML padrão, e com isso `MunicipalityListSignalControl` perdeu o Popover/Drawer próprios e `campaignCellEditTriggerClassName` virou privada. Gate: 661 unit / 444 int, e2e de tendência e de sinal reexecutados isoladamente (as falhas da suíte cheia foram compilação a frio sob duas outras sessões `next dev` no mesmo host, o padrão já fichado em B27 ✓/B32 ✓/B42 ✓) · [plano](plans/escala-dry-pos-b32.md)
- ~~**B17+**~~ ✓ escala/DRY pós-B17 (entregue 2026-07-29) — duas fases independentes do `/simplify` do seletor de colunas, ambas de estrutura e nenhuma de cálculo (o corte server-side do B17 já economiza as 25 chamadas de `cell()` e o payload RSC da coluna oculta): **F1** esconder a coluna **não** desliga a query que existe só para ela — `loadLeadershipOptions` (dobradinhas) e `loadStateDeputyOptions` (lideranças) são `limit: 0, pagination: false` dentro do `Promise.all`, e o cookie é lido **depois**; como `cookies()` não faz I/O, subir a leitura e condicionar a promise é ordem de statements, e a rota que mais paga é `/campanha/liderancas` (~229 ms / 371 statements, P1 no TECH-DEBT); **F2** o botão "Colunas" some junto com a tabela no estado vazio em demandas/organizações/apoiadores, que trocam a tabela inteira por um bloco (`rows.length ? … : …`) em vez de usar a prop `empty` que a `CampaignTable` expõe e que municípios/lideranças/territórios já adotam — filtrar até zero linhas tira um controle que não tem relação com o filtro · ~0,5d, F2 cortável · Impeccable A (F1) · [plano](plans/escala-dry-pos-b17.md)
- **B68+** escala/DRY pós-B68 — **F1** embed RSC de suggest no Início (evitar POST duplicado scope+coverage no focus CG/candidato; `initialSuggest` hidratando `useHomeSearchResultsState`); **F2** cortável: comparator compartilhado de déficit central quando 3º call site · gatilho: latência observada no focus ou próximo touch em ranking · ~0,5–0,75d eng · Impeccable A · [plano](plans/escala-dry-pos-b68.md)
- Higiene PascalCase de componentes legados
### A validar (assumptions)

View file

@ -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<HomeSearchSuccessResponse>(result)
}
const result = await searchHomeMunicipalities(payload, actor, body.query)
return NextResponse.json<HomeSearchSuccessResponse>(result)
},
)

View file

@ -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 (
<div className="flex w-full min-w-0 flex-col gap-4">
@ -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()

View file

@ -35,7 +35,7 @@ export const CampaignHomeStaffChrome = ({
summarySlot?: ReactNode
}) => {
const searchState = useHomeSearchQuery()
const focused = searchState.query.isActive
const focused = searchState.uiFocused
return (
<HomeSearchProvider value={searchState}>

View file

@ -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
}) => (
<Link
href={href}
className={cn(
'flex min-h-11 items-center gap-3 py-2.5 text-foreground',
'rounded-md outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<span className={PRIORITY_ICON_SLOT_CLASS} aria-hidden={!showPriority}>
{showPriority ? <MunicipalityPriorityIndicator /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{primary}</span>
{secondary ? (
<span className="block truncate text-xs text-muted-foreground">{secondary}</span>
) : null}
</span>
<span className="shrink-0">{trailing}</span>
</Link>
)

View file

@ -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
}) => (
<Link
href={href}
className={cn(
'flex min-h-11 items-center gap-3 py-2.5 text-foreground',
'rounded-md outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<span className={PRIORITY_ICON_SLOT_CLASS} aria-hidden={!showPriority}>
{showPriority ? <MunicipalityPriorityIndicator /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{primary}</span>
{secondary ? (
<span className="block truncate text-xs text-muted-foreground">{secondary}</span>
) : null}
</span>
<span className="shrink-0">{trailing}</span>
</Link>
)
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 (
<section aria-label="Municípios" className="flex flex-col gap-1">
<section aria-label={sectionTitle} className="flex flex-col gap-1">
<h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Municípios
{sectionTitle}
</h2>
<ul className="flex flex-col">
{municipalities.map((hit) => (
@ -80,20 +43,22 @@ export const HomeSearchMunicipalityGroup = () => {
/>
</li>
))}
{territories.map((hit) => (
<li key={`territory-${hit.region}`}>
<HomeSearchHitRow
href={buildTerritoryPageHref(hit.region)}
primary={hit.region}
showPriority={false}
trailing={
<span className="tabular-nums text-sm font-medium">
{formatElectionNumber(hit.votes2022)}
</span>
}
/>
</li>
))}
{resultKind === 'search'
? territories.map((hit) => (
<li key={`territory-${hit.region}`}>
<HomeSearchHitRow
href={buildTerritoryPageHref(hit.region)}
primary={hit.region}
showPriority={false}
trailing={
<span className="tabular-nums text-sm font-medium">
{formatElectionNumber(hit.votes2022)}
</span>
}
/>
</li>
))
: null}
</ul>
</section>
)

View file

@ -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<HomeSearchResultsContextValue | null>(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<HomeSearchResultsState>({ 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],
)

View file

@ -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' ? (
<p className="text-sm text-destructive" role="alert">
{errorMessage}
{results.message}
</p>
) : null}
{showEmpty ? <p className="text-sm text-muted-foreground">Nenhum resultado.</p> : null}

View file

@ -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],
)
}

View file

@ -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

View file

@ -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<HomeSearchSuccessResponse, 'municipalities' | 'territories'>,
): 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),
})

View file

@ -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 []
}

View file

@ -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'),
}),
])

View file

@ -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<HomeSearchSuccessResponse> => {
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<ReturnType<typeof loadMunicipalityGoalCoverageBundle>>['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: [],
}
}

View file

@ -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<typeof row> => row !== null)
@ -113,6 +107,7 @@ export const searchHomeMunicipalities = async (
return {
status: 'success',
resultKind: 'search',
municipalities: municipalityHits,
territories: territoryHits,
}

View file

@ -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')
})
})

View file

@ -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', () => {

View file

@ -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)
})
})

View file

@ -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)
})
})

View file

@ -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<HomeSearchSuggestMunicipalityInput> &
Pick<HomeSearchSuggestMunicipalityInput, 'slug' | 'name'>,
): 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([])
})
})