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

- Municípios + {sectionTitle}

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

- {errorMessage} + {results.message}

) : null} {showEmpty ?

Nenhum resultado.

: null} diff --git a/src/components/campaign/dashboard/useHomeSearchQuery.ts b/src/components/campaign/dashboard/useHomeSearchQuery.ts index a131ad33..9c382025 100644 --- a/src/components/campaign/dashboard/useHomeSearchQuery.ts +++ b/src/components/campaign/dashboard/useHomeSearchQuery.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { HOME_SEARCH_DEBOUNCE_MS, homeSearchQueryIsActive, + homeSearchUiFocused, normalizeHomeSearchRaw, type HomeSearchQuery, } from '@/lib/campaignHomeSearchContract' @@ -14,11 +15,15 @@ export type HomeSearchController = { setRaw: (next: string) => void clear: () => void isDebouncing: boolean + inputFocused: boolean + setInputFocused: (next: boolean) => void + uiFocused: boolean } export const useHomeSearchQuery = (): HomeSearchController => { const [raw, setRaw] = useState('') const [debounced, setDebounced] = useState('') + const [inputFocused, setInputFocused] = useState(false) useEffect(() => { if (raw === debounced) { @@ -44,9 +49,15 @@ export const useHomeSearchQuery = (): HomeSearchController => { [raw, trimmedDebounced], ) + const uiFocused = homeSearchUiFocused({ + inputFocused, + isActive: query.isActive, + }) + const clear = useCallback(() => { setRaw('') setDebounced('') + setInputFocused(false) }, []) return useMemo( @@ -55,7 +66,10 @@ export const useHomeSearchQuery = (): HomeSearchController => { setRaw, clear, isDebouncing, + inputFocused, + setInputFocused, + uiFocused, }), - [query, clear, isDebouncing], + [query, clear, isDebouncing, inputFocused, uiFocused], ) } diff --git a/src/lib/campaignHomeSearchContract.ts b/src/lib/campaignHomeSearchContract.ts index 07921bcf..cb0ed851 100644 --- a/src/lib/campaignHomeSearchContract.ts +++ b/src/lib/campaignHomeSearchContract.ts @@ -14,3 +14,10 @@ export const homeSearchQueryIsActive = (trimmedDebounced: string): boolean => trimmedDebounced.length >= HOME_SEARCH_MIN_QUERY_LENGTH export const normalizeHomeSearchRaw = (raw: string): string => raw.trim() + +/** + * B66/B68 — focused chrome vs active search query. Fetch uses `isActive`; + * layout hiding uses `uiFocused` (input focused or query active). + */ +export const homeSearchUiFocused = (input: { inputFocused: boolean; isActive: boolean }): boolean => + input.inputFocused || input.isActive diff --git a/src/lib/campaignHomeSearchHits.ts b/src/lib/campaignHomeSearchHits.ts index 28d8f9e0..d928c240 100644 --- a/src/lib/campaignHomeSearchHits.ts +++ b/src/lib/campaignHomeSearchHits.ts @@ -1,4 +1,8 @@ -import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank' +import { + DEFAULT_VOTE_RANK_YEAR, + getMunicipalityVoteRank, + type MunicipalityVoteRankEntry, +} from '@/lib/municipalityVoteRank' export type HomeSearchMunicipalityHit = { kind: 'municipality' @@ -15,8 +19,11 @@ export type HomeSearchTerritoryHit = { votes2022: number } +type HomeSearchResultKind = 'search' | 'suggest' + export type HomeSearchSuccessResponse = { status: 'success' + resultKind: HomeSearchResultKind municipalities: HomeSearchMunicipalityHit[] territories: HomeSearchTerritoryHit[] } @@ -24,3 +31,17 @@ export type HomeSearchSuccessResponse = { export const homeSearchMunicipalityGroupHasHits = ( data: Pick, ): boolean => data.municipalities.length > 0 || data.territories.length > 0 + +export const toHomeSearchMunicipalityHit = (doc: { + slug: string + name: string + region: string + priority?: 'alta' | 'normal' | null +}): HomeSearchMunicipalityHit => ({ + kind: 'municipality', + slug: doc.slug, + name: doc.name, + region: doc.region, + priority: doc.priority ?? null, + votePosition2022: getMunicipalityVoteRank(doc.slug, DEFAULT_VOTE_RANK_YEAR), +}) diff --git a/src/lib/homeSearchSuggest.ts b/src/lib/homeSearchSuggest.ts new file mode 100644 index 00000000..7f9b6a1f --- /dev/null +++ b/src/lib/homeSearchSuggest.ts @@ -0,0 +1,86 @@ +import type { CampaignRole } from '@/lib/campaignRoles' +import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles' + +/** Parity with B20 `DASHBOARD_PRIORITY_SAMPLE_LIMIT`. */ +export const HOME_SEARCH_SUGGEST_LIMIT = 8 + +export type HomeSearchSuggestMunicipalityInput = { + slug: string + name: string + region: string + priority: 'alta' | 'normal' | null + /** ISO timestamp or null when never signaled — colder sorts first. */ + lastSignalAt: string | null + /** E9 central deficit sort key; only used for unrestricted roles. */ + centralDeficitSortValue: number | null +} + +const municipalityNameCompare = ( + left: HomeSearchSuggestMunicipalityInput, + right: HomeSearchSuggestMunicipalityInput, +): number => left.name.localeCompare(right.name, 'pt-BR') + +/** Older signal first; null = coldest. */ +const compareSignalOldestFirst = ( + left: HomeSearchSuggestMunicipalityInput, + right: HomeSearchSuggestMunicipalityInput, +): number => { + if (left.lastSignalAt === null && right.lastSignalAt === null) { + return municipalityNameCompare(left, right) + } + if (left.lastSignalAt === null) return -1 + if (right.lastSignalAt === null) return 1 + const byTime = left.lastSignalAt.localeCompare(right.lastSignalAt) + if (byTime !== 0) return byTime + return municipalityNameCompare(left, right) +} + +const compareUnrestrictedSuggest = ( + left: HomeSearchSuggestMunicipalityInput, + right: HomeSearchSuggestMunicipalityInput, +): number => { + const leftValue = left.centralDeficitSortValue + const rightValue = right.centralDeficitSortValue + if (leftValue === null && rightValue === null) return compareSignalOldestFirst(left, right) + if (leftValue === null) return 1 + if (rightValue === null) return -1 + if (leftValue === rightValue) return compareSignalOldestFirst(left, right) + return rightValue - leftValue +} + +const rankAdvisorPortfolio = ( + municipalities: HomeSearchSuggestMunicipalityInput[], + limit: number, +): HomeSearchSuggestMunicipalityInput[] => + [...municipalities].sort(compareSignalOldestFirst).slice(0, limit) + +const rankUnrestrictedPriority = ( + municipalities: HomeSearchSuggestMunicipalityInput[], + limit: number, +): HomeSearchSuggestMunicipalityInput[] => + municipalities + .filter((municipality) => municipality.priority === 'alta') + .sort(compareUnrestrictedSuggest) + .slice(0, limit) + +/** + * B68 — curated municipality shortcuts for the Início search empty state. + * Pure policy pinned in unit tests; server loaders supply scoped inputs. + */ +export const rankHomeSearchSuggestMunicipalities = ( + role: CampaignRole, + municipalities: HomeSearchSuggestMunicipalityInput[], + limit = HOME_SEARCH_SUGGEST_LIMIT, +): HomeSearchSuggestMunicipalityInput[] => { + if (municipalities.length === 0) return [] + + if (isUnrestrictedCampaignRole(role)) { + return rankUnrestrictedPriority(municipalities, limit) + } + + if (role === 'advisor') { + return rankAdvisorPortfolio(municipalities, limit) + } + + return [] +} diff --git a/src/lib/schemas/homeSearch.ts b/src/lib/schemas/homeSearch.ts index 3440a987..f7dead6f 100644 --- a/src/lib/schemas/homeSearch.ts +++ b/src/lib/schemas/homeSearch.ts @@ -4,10 +4,18 @@ import { HOME_SEARCH_MIN_QUERY_LENGTH } from '@/lib/campaignHomeSearchContract' export const HOME_SEARCH_QUERY_MAX_LENGTH = 80 -export const homeSearchBodySchema = z.object({ - query: z - .string() - .trim() - .min(HOME_SEARCH_MIN_QUERY_LENGTH, 'Digite pelo menos 2 caracteres.') - .max(HOME_SEARCH_QUERY_MAX_LENGTH), -}) +const homeSearchQueryField = z + .string() + .trim() + .min(HOME_SEARCH_MIN_QUERY_LENGTH, 'Digite pelo menos 2 caracteres.') + .max(HOME_SEARCH_QUERY_MAX_LENGTH) + +export const homeSearchBodySchema = z.discriminatedUnion('mode', [ + z.object({ + mode: z.literal('search'), + query: homeSearchQueryField, + }), + z.object({ + mode: z.literal('suggest'), + }), +]) diff --git a/src/utilities/homeSearch/loadHomeSearchSuggestions.ts b/src/utilities/homeSearch/loadHomeSearchSuggestions.ts new file mode 100644 index 00000000..d7aa993e --- /dev/null +++ b/src/utilities/homeSearch/loadHomeSearchSuggestions.ts @@ -0,0 +1,83 @@ +import 'server-only' + +import type { Payload } from 'payload' + +import { + toHomeSearchMunicipalityHit, + type HomeSearchSuccessResponse, +} from '@/lib/campaignHomeSearchHits' +import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages' +import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles' +import { + rankHomeSearchSuggestMunicipalities, + type HomeSearchSuggestMunicipalityInput, +} from '@/lib/homeSearchSuggest' +import type { CampaignUser } from '@/payload-types' +import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope' +import { centralDeficitSortValue } from '@/utilities/municipality/goalCoverage' +import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipality/municipalityGoalAccount' +import { resolveMunicipalityLastSignalAt } from '@/utilities/municipality/municipalitySignal' + +export const loadHomeSearchSuggestions = async ( + payload: Payload, + user: CampaignUser, +): Promise => { + if (!isStaffCampaignRole(user.role)) { + throw new Error(HOME_SEARCH_STAFF_ONLY_MESSAGE) + } + + const { municipalities, pledgeAggregates } = await loadMunicipalityScope( + payload, + user, + {}, + { + extraSelect: { lastUpdateAt: true }, + }, + ) + + let coverageByMunicipalityID: + | Awaited>['coverageByMunicipalityID'] + | null = null + + if (isUnrestrictedCampaignRole(user.role)) { + const altaMunicipalities = municipalities.filter( + (municipality) => municipality.priority === 'alta', + ) + const bundle = await loadMunicipalityGoalCoverageBundle( + payload, + user, + altaMunicipalities, + pledgeAggregates, + ) + coverageByMunicipalityID = bundle.coverageByMunicipalityID + } + + const inputs: HomeSearchSuggestMunicipalityInput[] = municipalities.map((doc) => { + const aggregate = pledgeAggregates.get(doc.id) + const lastSignalAt = resolveMunicipalityLastSignalAt( + doc.lastUpdateAt ?? null, + aggregate?.lastPledgeAt ?? null, + ) + const centralCoverage = coverageByMunicipalityID?.get(doc.id)?.central + + return { + slug: doc.slug, + name: doc.name, + region: doc.region, + priority: doc.priority ?? null, + lastSignalAt, + centralDeficitSortValue: centralDeficitSortValue(centralCoverage), + } + }) + + const ranked = rankHomeSearchSuggestMunicipalities(user.role, inputs) + + const municipalityHits = ranked.map(toHomeSearchMunicipalityHit) + + return { + status: 'success', + resultKind: 'suggest', + municipalities: municipalityHits, + territories: [], + } +} diff --git a/src/utilities/homeSearch/searchHomeMunicipalities.ts b/src/utilities/homeSearch/searchHomeMunicipalities.ts index 7d78607c..9c0d66a0 100644 --- a/src/utilities/homeSearch/searchHomeMunicipalities.ts +++ b/src/utilities/homeSearch/searchHomeMunicipalities.ts @@ -3,10 +3,11 @@ import 'server-only' import type { Payload } from 'payload' import { homeSearchQueryIsActive, normalizeHomeSearchRaw } from '@/lib/campaignHomeSearchContract' -import type { - HomeSearchMunicipalityHit, - HomeSearchSuccessResponse, - HomeSearchTerritoryHit, +import { + toHomeSearchMunicipalityHit, + type HomeSearchMunicipalityHit, + type HomeSearchSuccessResponse, + type HomeSearchTerritoryHit, } from '@/lib/campaignHomeSearchHits' import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages' import { isStaffCampaignRole } from '@/lib/campaignRoles' @@ -14,7 +15,7 @@ import { compareHomeSearchNameRelevance, normalizeHomeSearchName, } from '@/lib/homeSearchMunicipalityMatch' -import { DEFAULT_VOTE_RANK_YEAR, getMunicipalityVoteRank } from '@/lib/municipalityVoteRank' +import { DEFAULT_VOTE_RANK_YEAR } from '@/lib/municipalityVoteRank' import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter' import type { CampaignUser } from '@/payload-types' import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope' @@ -22,6 +23,7 @@ import { loadTerritoryOverview } from '@/utilities/territory/loadTerritoryOvervi const emptyResponse = (): HomeSearchSuccessResponse => ({ status: 'success', + resultKind: 'search', municipalities: [], territories: [], }) @@ -50,17 +52,9 @@ export const searchHomeMunicipalities = async ( if (!matchesNormalizedAtWordStart(normalizedName, normalizedQuery)) { return null } - const votePosition2022 = getMunicipalityVoteRank(doc.slug, DEFAULT_VOTE_RANK_YEAR) return { normalizedName, - hit: { - kind: 'municipality' as const, - slug: doc.slug, - name: doc.name, - region: doc.region, - priority: doc.priority ?? null, - votePosition2022, - }, + hit: toHomeSearchMunicipalityHit(doc), } }) .filter((row): row is NonNullable => row !== null) @@ -113,6 +107,7 @@ export const searchHomeMunicipalities = async ( return { status: 'success', + resultKind: 'search', municipalities: municipalityHits, territories: territoryHits, } diff --git a/tests/int/homeSearchSuggestions.int.spec.ts b/tests/int/homeSearchSuggestions.int.spec.ts new file mode 100644 index 00000000..4beac880 --- /dev/null +++ b/tests/int/homeSearchSuggestions.int.spec.ts @@ -0,0 +1,69 @@ +// @vitest-environment node + +import type { Payload } from 'payload' +import { getPayload } from 'payload' +import { beforeAll, describe, expect, it } from 'vitest' + +import config from '@/payload.config' +import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearchSuggestions' +import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities' + +import { installCampaignFixtures } from '../helpers/campaignFixtures' + +let payload: Payload +const campaignFixtures = installCampaignFixtures({ + getPayload: () => payload, + setPayload: (nextPayload) => { + payload = nextPayload + }, +}) + +describe('loadHomeSearchSuggestions (B68)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('returns only high-priority municipalities for coordinator', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + + const result = await loadHomeSearchSuggestions(payload, coordinator) + expect(result.resultKind).toBe('suggest') + expect(result.territories).toEqual([]) + expect(result.municipalities.length).toBeLessThanOrEqual(8) + for (const hit of result.municipalities) { + expect(hit.priority).toBe('alta') + } + }) + + it('scopes advisor suggestions to administered municipalities', async () => { + const fixtures = campaignFixtures() + const advisor = await fixtures.createCampaignUser('advisor') + const administered = await fixtures.getMunicipality('cairu') + await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id]) + + const result = await loadHomeSearchSuggestions(payload, advisor) + expect(result.municipalities.every((hit) => hit.slug === 'cairu')).toBe(true) + }) + + it('rejects leaders from home search suggestions', async () => { + const fixtures = campaignFixtures() + const leader = await fixtures.createCampaignUser('leader') + + await expect(loadHomeSearchSuggestions(payload, leader)).rejects.toThrow(/equipe de campanha/i) + }) +}) + +describe('searchHomeMunicipalities resultKind (B68)', () => { + beforeAll(async () => { + payload = await getPayload({ config: await config }) + }) + + it('tags search responses with resultKind search', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + + const result = await searchHomeMunicipalities(payload, coordinator, 'Cai') + expect(result.resultKind).toBe('search') + }) +}) diff --git a/tests/unit/campaignHomeSearch.unit.spec.tsx b/tests/unit/campaignHomeSearch.unit.spec.tsx index 8bf334cd..a41d60a9 100644 --- a/tests/unit/campaignHomeSearch.unit.spec.tsx +++ b/tests/unit/campaignHomeSearch.unit.spec.tsx @@ -10,6 +10,9 @@ const providerValue = { setRaw: vi.fn(), clear: vi.fn(), isDebouncing: false, + inputFocused: false, + setInputFocused: vi.fn(), + uiFocused: true, } describe('CampaignHomeLayout focused mode', () => { diff --git a/tests/unit/campaignHomeSearchContract.unit.spec.ts b/tests/unit/campaignHomeSearchContract.unit.spec.ts new file mode 100644 index 00000000..1989ed5c --- /dev/null +++ b/tests/unit/campaignHomeSearchContract.unit.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' + +import { homeSearchUiFocused } from '@/lib/campaignHomeSearchContract' + +describe('homeSearchUiFocused', () => { + it('is true when the input is focused without an active query', () => { + expect(homeSearchUiFocused({ inputFocused: true, isActive: false })).toBe(true) + }) + + it('is true when the query is active even if the input lost focus', () => { + expect(homeSearchUiFocused({ inputFocused: false, isActive: true })).toBe(true) + }) + + it('is false when blurred and query inactive', () => { + expect(homeSearchUiFocused({ inputFocused: false, isActive: false })).toBe(false) + }) +}) diff --git a/tests/unit/campaignHomeSearchQuery.unit.spec.ts b/tests/unit/campaignHomeSearchQuery.unit.spec.ts index 25458ddb..ce7d468e 100644 --- a/tests/unit/campaignHomeSearchQuery.unit.spec.ts +++ b/tests/unit/campaignHomeSearchQuery.unit.spec.ts @@ -97,5 +97,17 @@ describe('useHomeSearchQuery', () => { expect(result.current.query.debounced).toBe('') expect(result.current.query.isActive).toBe(false) expect(result.current.isDebouncing).toBe(false) + expect(result.current.inputFocused).toBe(false) + }) + + it('uiFocused is true when input is focused without an active query', () => { + const { result } = renderHook(() => useHomeSearchQuery()) + + act(() => { + result.current.setInputFocused(true) + }) + + expect(result.current.uiFocused).toBe(true) + expect(result.current.query.isActive).toBe(false) }) }) diff --git a/tests/unit/homeSearchSuggest.unit.spec.ts b/tests/unit/homeSearchSuggest.unit.spec.ts new file mode 100644 index 00000000..34b66702 --- /dev/null +++ b/tests/unit/homeSearchSuggest.unit.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' + +import { + HOME_SEARCH_SUGGEST_LIMIT, + rankHomeSearchSuggestMunicipalities, + type HomeSearchSuggestMunicipalityInput, +} from '@/lib/homeSearchSuggest' + +const row = ( + partial: Partial & + Pick, +): HomeSearchSuggestMunicipalityInput => ({ + region: 'TI', + priority: 'normal', + lastSignalAt: null, + centralDeficitSortValue: null, + ...partial, +}) + +describe('rankHomeSearchSuggestMunicipalities', () => { + it('ranks advisor portfolio by oldest signal first, then name', () => { + const municipalities = [ + row({ slug: 'b', name: 'Brumado', lastSignalAt: '2026-07-20T12:00:00.000Z' }), + row({ slug: 'a', name: 'Abaíra', lastSignalAt: null }), + row({ slug: 'c', name: 'Cairu', lastSignalAt: '2026-07-01T12:00:00.000Z' }), + ] + + const ranked = rankHomeSearchSuggestMunicipalities('advisor', municipalities) + expect(ranked.map((m) => m.slug)).toEqual(['a', 'c', 'b']) + }) + + it('caps advisor results at HOME_SEARCH_SUGGEST_LIMIT', () => { + const municipalities = Array.from({ length: 12 }, (_, index) => + row({ + slug: `m-${index}`, + name: `Município ${index}`, + lastSignalAt: `2026-07-${String(index + 1).padStart(2, '0')}T12:00:00.000Z`, + }), + ) + + const ranked = rankHomeSearchSuggestMunicipalities('advisor', municipalities) + expect(ranked).toHaveLength(HOME_SEARCH_SUGGEST_LIMIT) + }) + + it('ranks coordinator suggestions to high-priority municipalities by central deficit', () => { + const municipalities = [ + row({ + slug: 'low', + name: 'Low', + priority: 'alta', + centralDeficitSortValue: 10, + lastSignalAt: '2026-07-20T12:00:00.000Z', + }), + row({ + slug: 'high', + name: 'High', + priority: 'alta', + centralDeficitSortValue: 500, + lastSignalAt: '2026-07-20T12:00:00.000Z', + }), + row({ + slug: 'normal', + name: 'Normal', + priority: 'normal', + centralDeficitSortValue: 900, + }), + ] + + const ranked = rankHomeSearchSuggestMunicipalities('coordinator', municipalities) + expect(ranked.map((m) => m.slug)).toEqual(['high', 'low']) + }) + + it('breaks coordinator deficit ties by colder signal', () => { + const municipalities = [ + row({ + slug: 'warm', + name: 'Warm', + priority: 'alta', + centralDeficitSortValue: 100, + lastSignalAt: '2026-07-28T12:00:00.000Z', + }), + row({ + slug: 'cold', + name: 'Cold', + priority: 'alta', + centralDeficitSortValue: 100, + lastSignalAt: '2026-06-01T12:00:00.000Z', + }), + ] + + const ranked = rankHomeSearchSuggestMunicipalities('candidate', municipalities) + expect(ranked.map((m) => m.slug)).toEqual(['cold', 'warm']) + }) + + it('returns empty for leader', () => { + const municipalities = [row({ slug: 'a', name: 'A' })] + expect(rankHomeSearchSuggestMunicipalities('leader', municipalities)).toEqual([]) + }) +})