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.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 22:05:38 -03:00
parent e1eafe3145
commit e733efbe42
21 changed files with 543 additions and 100 deletions

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

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)

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([])
})
})