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:
parent
e1eafe3145
commit
e733efbe42
21 changed files with 543 additions and 100 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
42
src/components/campaign/dashboard/HomeSearchHitRow.tsx
Normal file
42
src/components/campaign/dashboard/HomeSearchHitRow.tsx
Normal 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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
86
src/lib/homeSearchSuggest.ts
Normal file
86
src/lib/homeSearchSuggest.ts
Normal 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 []
|
||||
}
|
||||
|
|
@ -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'),
|
||||
}),
|
||||
])
|
||||
|
|
|
|||
83
src/utilities/homeSearch/loadHomeSearchSuggestions.ts
Normal file
83
src/utilities/homeSearch/loadHomeSearchSuggestions.ts
Normal 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: [],
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
69
tests/int/homeSearchSuggestions.int.spec.ts
Normal file
69
tests/int/homeSearchSuggestions.int.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
17
tests/unit/campaignHomeSearchContract.unit.spec.ts
Normal file
17
tests/unit/campaignHomeSearchContract.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
99
tests/unit/homeSearchSuggest.unit.spec.ts
Normal file
99
tests/unit/homeSearchSuggest.unit.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue