From 7e6bc9d98441f003d06f6f232c46072d536530e4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 18:21:52 +0000 Subject: [PATCH 1/2] =?UTF-8?q?B125=20=E2=80=94=20nearest=20munic=C3=ADpio?= =?UTF-8?q?=20first=20in=20search=20suggest=20+=20wizard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore B117 home search: scopeMunicipalities, merge prefix, session prompt - Shared useNearestMunicipalitySlug (B14 key) for busca geral + wizard - Wizard merge uses geoSlug only — no Perto de você / km copy on rows - Impl plan: docs/plans/municipio-geo-topo-sugestoes-busca-impl.md Co-authored-by: Francisco Solla --- ...municipio-geo-topo-sugestoes-busca-impl.md | 65 ++++++++++++++ scripts/lib/e2e-affected-manifest.mjs | 2 +- .../dashboard/HomeSearchMunicipalityGroup.tsx | 45 +++++++++- .../useHomeSearchNearestMunicipality.ts | 14 +++ .../shared/WizardMunicipalitySearchStep.tsx | 8 +- ...ality.ts => useNearestMunicipalitySlug.ts} | 42 +++++---- src/lib/campaignHomeSearchHits.ts | 11 +++ src/lib/homeSearchNearestMunicipalityMerge.ts | 20 +++++ src/lib/wizardMunicipalitySuggestMerge.ts | 42 ++------- .../homeSearch/loadHomeSearchSuggestions.ts | 7 ++ tests/int/homeSearchSuggestions.int.spec.ts | 2 + ...earchNearestMunicipalityMerge.unit.spec.ts | 69 ++++++++++++++ tests/unit/homeSearchUi.unit.spec.tsx | 89 +++++++++++-------- ...izardMunicipalitySuggestMerge.unit.spec.ts | 30 ++----- 14 files changed, 327 insertions(+), 119 deletions(-) create mode 100644 docs/plans/municipio-geo-topo-sugestoes-busca-impl.md create mode 100644 src/components/campaign/dashboard/useHomeSearchNearestMunicipality.ts rename src/components/campaign/shared/{useWizardNearestMunicipality.ts => useNearestMunicipalitySlug.ts} (65%) create mode 100644 src/lib/homeSearchNearestMunicipalityMerge.ts create mode 100644 tests/unit/homeSearchNearestMunicipalityMerge.unit.spec.ts diff --git a/docs/plans/municipio-geo-topo-sugestoes-busca-impl.md b/docs/plans/municipio-geo-topo-sugestoes-busca-impl.md new file mode 100644 index 00000000..a84d7c40 --- /dev/null +++ b/docs/plans/municipio-geo-topo-sugestoes-busca-impl.md @@ -0,0 +1,65 @@ +# Impl: B125 — Município mais próximo no topo das sugestões (busca geral + wizard) + +Status: aprovado +Atualizado em: 2026-08-02 +Issue: #254 +Intenção: docs/plans/municipio-geo-topo-sugestoes-busca.md +Appetite restante: ~1 dia — restauração B117 + alinhamento wizard + +## Leitura da intenção + +- **Outcome:** Na busca geral (Início + drawer) e no passo município do wizard, com query vazia, o município operacional mais próximo no escopo do ator aparece como **1ª linha normal** da lista — sem grupo geo, sem copy “Perto de você”, sem km. +- **O que NÃO negociar:** prompt geo 1×/sessão de aba (`GEO_PROMPT_SESSION_KEY` B14); matching só no cliente; leader lockdown; Salvador multi-ZE omitir prefixo quando `zoneCity`; sem migration/Consent/POST lat/lng. +- **O que reavaliar:** B117 (`fe2748c5`) é referência de restauração para home search, não contrato; wizard B94 deve **parar** de usar reason geo na UI; hooks finos compartilhados vs duplicar prompt. + +## Abordagem recomendada + +```mermaid +flowchart LR + focus[Focus busca / idle wizard] --> hook[useNearestMunicipalitySlug] + hook --> prompt[Prompt 1x sessão B14] + prompt --> resolve[resolveNearbyMunicipality client] + resolve --> merge[merge prefix dedup] + merge --> row[HomeSearchHitRow normal] +``` + +**Opções consideradas:** A) GeoProvider global; B) restaurar B117 + patch wizard separado; C) hook geo compartilhado + dois merges (home + wizard). +**Recomendação:** C — um `useNearestMunicipalitySlug` com política B14/B117; `mergeHomeSearchNearestMunicipality` (restaurar) e `mergeWizardMunicipalitySuggestions` com `geoSlug` sem override de region. +**Rejeitadas:** A (refactor grande); duplicar prompt em dois hooks (twin). + +### Componentes / mudanças + +- **`useNearestMunicipalitySlug`** (`src/components/campaign/shared/`): resolve nearest slug; prompt `hasPromptedThisSession` / `markPromptedThisSession`; fail-soft. +- **`useHomeSearchNearestMunicipality`**: re-export ou uso direto do shared hook em `HomeSearchMunicipalityGroup`. +- **`homeSearchNearestMunicipalityMerge`**: restaurar prefix + dedup sem geo copy. +- **`loadHomeSearchSuggestions`**: devolver `scopeMunicipalities` com `ibgeCode` para merge client. +- **`wizardMunicipalitySuggestMerge`**: `geoSlug` em vez de objeto com distance; remover `formatWizardGeoSecondary` / `WIZARD_GEO_NEARBY_REASON`. +- **`useWizardNearestMunicipality`**: substituir por shared hook ou delegar. +- **Migration:** sem migration +- **Access / Consent:** staff-only loaders existentes; geo client-only +- **UI:** mesma `HomeSearchHitRow`; Impeccable B — empty/suggest only + +### Dados → forma + +- Proximidade só ordena; row usa `hit.region` normal — rejeitada copy geo na secondary (produto gate 2026-08-02). + +## Fases verificáveis + +1. **Server + lib** — `scopeMunicipalities`, merges, shared hook, wizard merge sem copy +2. **UI** — `HomeSearchMunicipalityGroup` wiring; wizard step usa slug hook +3. **Gates** — unit merges + int suggest scope; `pnpm gate:fast`; `pnpm push` + +## Rabbit holes / Não escopo (engenharia) + +- GeoProvider global; CTA retry quando denied; alterar `NearestMunicipalityCard` (B14). + +## Riscos e mitigação + +- **Prompt duplicado Quadro × busca:** mesma `GEO_PROMPT_SESSION_KEY` — já compartilhada. +- **Salvador zoneCity:** `resolveNearbyMunicipality` retorna `zoneCity` → hook retorna null — aceite. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto +- [x] Invariantes AGENTS/engineering-standards +- [x] Testes unit merges + int scopeMunicipalities diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs index f27a72f8..dd061551 100644 --- a/scripts/lib/e2e-affected-manifest.mjs +++ b/scripts/lib/e2e-affected-manifest.mjs @@ -107,7 +107,7 @@ export const E2E_AFFECTED_MANIFEST = [ 'src/components/campaign/auth', `${CAMPAIGN_APP}/acoes`, 'src/components/campaign/shared/WizardMunicipality', - 'src/components/campaign/shared/useWizardNearestMunicipality', + 'src/components/campaign/shared/useNearestMunicipalitySlug', 'src/lib/wizardMunicipalitySuggestMerge', `${CAMPAIGN_APP}/apoiadores`, `${CAMPAIGN_APP}/assessores`, diff --git a/src/components/campaign/dashboard/HomeSearchMunicipalityGroup.tsx b/src/components/campaign/dashboard/HomeSearchMunicipalityGroup.tsx index 49373bb7..40c1c9e4 100644 --- a/src/components/campaign/dashboard/HomeSearchMunicipalityGroup.tsx +++ b/src/components/campaign/dashboard/HomeSearchMunicipalityGroup.tsx @@ -1,27 +1,68 @@ 'use client' +import { useMemo } from 'react' + import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext' import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow' import { HomeSearchMunicipalityVoteTrailing } from '@/components/campaign/dashboard/HomeSearchMunicipalityVoteTrailing' +import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext' import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext' -import { homeSearchMunicipalityGroupHasHits } from '@/lib/campaignHomeSearchHits' +import { useHomeSearchNearestMunicipality } from '@/components/campaign/dashboard/useHomeSearchNearestMunicipality' +import { + homeSearchMunicipalityGroupHasHits, + toHomeSearchMunicipalityHit, + type HomeSearchScopeMunicipality, +} from '@/lib/campaignHomeSearchHits' import { formatElectionNumber } from '@/lib/electionFormat' +import { mergeHomeSearchNearestMunicipality } from '@/lib/homeSearchNearestMunicipalityMerge' import { sliceHomeSearchMunicipalityGroup } from '@/lib/homeSearchHitBudget' import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi' +import type { AccessibleMunicipality } from '@/lib/municipalityProximity' import { buildTerritoryPageHref } from '@/lib/territoryAnchor' +const scopeToAccessible = (scope: readonly HomeSearchScopeMunicipality[]): AccessibleMunicipality[] => + scope.map(({ slug, name, ibgeCode }) => ({ slug, name, ibgeCode })) + +const buildHitBySlug = (scope: readonly HomeSearchScopeMunicipality[]) => + new Map(scope.map((doc) => [doc.slug, toHomeSearchMunicipalityHit(doc)])) + export const HomeSearchMunicipalityGroup = () => { + const { query, uiFocused } = useHomeSearch() const { results, resultKind } = useHomeSearchResults() const hitLimit = useHomeSearchHitLimit('municipalities') + const suggestMode = uiFocused && !query.isActive + + const scopeMunicipalities = useMemo( + () => (results.status === 'success' ? results.data.scopeMunicipalities ?? [] : []), + [results], + ) + + const accessible = useMemo(() => scopeToAccessible(scopeMunicipalities), [scopeMunicipalities]) + const hitBySlug = useMemo(() => buildHitBySlug(scopeMunicipalities), [scopeMunicipalities]) + + const nearestSlug = useHomeSearchNearestMunicipality( + accessible, + suggestMode && scopeMunicipalities.length > 0, + ) + if (results.status !== 'success') return null const { municipalities, territories } = results.data if (!homeSearchMunicipalityGroupHasHits(results.data)) return null + const mergedMunicipalities = + resultKind === 'suggest' + ? mergeHomeSearchNearestMunicipality({ + nearestSlug, + serverHits: municipalities, + hitBySlug, + }) + : municipalities + const includeTerritories = resultKind === 'search' const { municipalities: visibleMunicipalities, territories: visibleTerritories } = - sliceHomeSearchMunicipalityGroup(municipalities, territories, hitLimit, includeTerritories) + sliceHomeSearchMunicipalityGroup(mergedMunicipalities, territories, hitLimit, includeTerritories) if (visibleMunicipalities.length === 0 && visibleTerritories.length === 0) return null diff --git a/src/components/campaign/dashboard/useHomeSearchNearestMunicipality.ts b/src/components/campaign/dashboard/useHomeSearchNearestMunicipality.ts new file mode 100644 index 00000000..83513a3d --- /dev/null +++ b/src/components/campaign/dashboard/useHomeSearchNearestMunicipality.ts @@ -0,0 +1,14 @@ +'use client' + +import type { AccessibleMunicipality } from '@/lib/municipalityProximity' + +import { useNearestMunicipalitySlug } from '@/components/campaign/shared/useNearestMunicipalitySlug' + +/** + * B117 / B125 — nearest in-scope município slug for global search suggest. + * Reuses shared prompt + resolve hook (B14 session key). + */ +export const useHomeSearchNearestMunicipality = ( + accessible: readonly AccessibleMunicipality[], + enabled: boolean, +): string | null => useNearestMunicipalitySlug(accessible, enabled) diff --git a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx index 64bf62f9..fe8cbe67 100644 --- a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx +++ b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx @@ -8,7 +8,7 @@ import { HomeSearchMunicipalityVoteTrailing } from '@/components/campaign/dashbo import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery' import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput' import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell' -import { useWizardNearestMunicipality } from '@/components/campaign/shared/useWizardNearestMunicipality' +import { useNearestMunicipalitySlug } from '@/components/campaign/shared/useNearestMunicipalitySlug' import { wizardActionHref } from '@/lib/campaignActionRoutes' import type { HomeSearchMunicipalityHit, @@ -84,7 +84,7 @@ export const WizardMunicipalitySearchStep = ({ const suggestMode = !query.isActive const searchMode = query.isActive const resultsBusy = isDebouncing || results.status === 'loading' - const nearestMunicipality = useWizardNearestMunicipality(accessibleMunicipalities, suggestMode) + const nearestSlug = useNearestMunicipalitySlug(accessibleMunicipalities, suggestMode) useEffect(() => { if (!suggestMode || results.status !== 'success' || results.mode !== 'suggest') { @@ -161,11 +161,11 @@ export const WizardMunicipalitySearchStep = ({ return mergeWizardMunicipalitySuggestions({ continuity: continuityReady ? continuitySlugs : [], - geo: nearestMunicipality, + geoSlug: nearestSlug, serverHits: results.suggest.municipalities, hitBySlug: results.suggest.hitBySlug, }) - }, [continuityReady, continuitySlugs, nearestMunicipality, results]) + }, [continuityReady, continuitySlugs, nearestSlug, results]) const showEmpty = query.isActive && diff --git a/src/components/campaign/shared/useWizardNearestMunicipality.ts b/src/components/campaign/shared/useNearestMunicipalitySlug.ts similarity index 65% rename from src/components/campaign/shared/useWizardNearestMunicipality.ts rename to src/components/campaign/shared/useNearestMunicipalitySlug.ts index 1357c6c4..6ab7e42e 100644 --- a/src/components/campaign/shared/useWizardNearestMunicipality.ts +++ b/src/components/campaign/shared/useNearestMunicipalitySlug.ts @@ -7,22 +7,23 @@ import { loadMunicipalityZoneGeometryModule, } from '@/lib/bahiaGeometries' import { resolveNearbyMunicipality, type AccessibleMunicipality } from '@/lib/municipalityProximity' -import type { WizardGeoMunicipalitySuggestion } from '@/lib/wizardMunicipalitySuggestMerge' import { + hasPromptedThisSession, + markPromptedThisSession, readGeolocationPermissionState, requestCurrentPosition, } from '@/utilities/campaignGeolocation' /** - * B94 — resolves the nearest in-scope município for the wizard idle state. - * Only runs when geolocation permission is already `granted` (prompt lives on the Quadro). + * B125 — nearest in-scope município slug for suggest surfaces (home search + wizard). + * Prompts once per tab session (B14 key) on first enable when permission is not denied. * Fail-soft: every other outcome returns `null` with no error UI. */ -export const useWizardNearestMunicipality = ( +export const useNearestMunicipalitySlug = ( accessible: readonly AccessibleMunicipality[], enabled: boolean, -): WizardGeoMunicipalitySuggestion | null => { - const [geo, setGeo] = useState(null) +): string | null => { + const [nearestSlug, setNearestSlug] = useState(null) const autoStartedRef = useRef(false) useEffect(() => { @@ -31,10 +32,7 @@ export const useWizardNearestMunicipality = ( let cancelled = false - const resolve = async () => { - const permission = await readGeolocationPermissionState() - if (permission !== 'granted') return - + const resolveNearest = async () => { const loaded = await Promise.all([ requestCurrentPosition(), loadMunicipalityGeometryModule(), @@ -56,19 +54,29 @@ export const useWizardNearestMunicipality = ( if (cancelled || resolution.kind !== 'inScope') return - setGeo({ - slug: resolution.municipality.slug, - name: resolution.municipality.name, - distanceKm: resolution.match === 'nearestZone' ? resolution.distanceKm : undefined, - }) + setNearestSlug(resolution.municipality.slug) } - void resolve() + const start = async () => { + const permission = await readGeolocationPermissionState() + + if (permission === 'granted') { + void resolveNearest() + return + } + + if (permission !== 'denied' && !hasPromptedThisSession()) { + markPromptedThisSession() + void resolveNearest() + } + } + + void start() return () => { cancelled = true } }, [accessible, enabled]) - return geo + return nearestSlug } diff --git a/src/lib/campaignHomeSearchHits.ts b/src/lib/campaignHomeSearchHits.ts index 511ec9b6..688e1ead 100644 --- a/src/lib/campaignHomeSearchHits.ts +++ b/src/lib/campaignHomeSearchHits.ts @@ -57,6 +57,15 @@ export type HomeSearchDemandHit = { type HomeSearchResultKind = 'search' | 'suggest' +/** Scope rows for client geo merge (B117 / B125) — full portfolio, not only top-8 suggest. */ +export type HomeSearchScopeMunicipality = { + slug: string + name: string + region: string + priority: 'alta' | 'normal' | null + ibgeCode: string +} + export type HomeSearchSuccessResponse = { status: 'success' resultKind: HomeSearchResultKind @@ -67,6 +76,8 @@ export type HomeSearchSuccessResponse = { stateDeputies: HomeSearchStateDeputyHit[] activities: HomeSearchActivityHit[] demands: HomeSearchDemandHit[] + /** Present on `suggest` for client nearest-municipality prefix (B117 / B125). */ + scopeMunicipalities?: HomeSearchScopeMunicipality[] } export const homeSearchMunicipalityGroupHasHits = ( diff --git a/src/lib/homeSearchNearestMunicipalityMerge.ts b/src/lib/homeSearchNearestMunicipalityMerge.ts new file mode 100644 index 00000000..4d9a2ca4 --- /dev/null +++ b/src/lib/homeSearchNearestMunicipalityMerge.ts @@ -0,0 +1,20 @@ +import type { HomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits' + +/** + * B117 / B125 — prefix the nearest in-scope município on suggest, deduping the server list. + * The hit uses the normal suggest shape — no geo reason or distance metadata. + */ +export const mergeHomeSearchNearestMunicipality = (input: { + nearestSlug: string | null | undefined + serverHits: readonly HomeSearchMunicipalityHit[] + hitBySlug: ReadonlyMap +}): HomeSearchMunicipalityHit[] => { + const slug = input.nearestSlug?.trim() + if (!slug) return [...input.serverHits] + + const nearestHit = input.hitBySlug.get(slug) + if (!nearestHit) return [...input.serverHits] + + const withoutDup = input.serverHits.filter((hit) => hit.slug !== slug) + return [nearestHit, ...withoutDup] +} diff --git a/src/lib/wizardMunicipalitySuggestMerge.ts b/src/lib/wizardMunicipalitySuggestMerge.ts index e698500f..e1c96bf4 100644 --- a/src/lib/wizardMunicipalitySuggestMerge.ts +++ b/src/lib/wizardMunicipalitySuggestMerge.ts @@ -1,8 +1,5 @@ import type { HomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits' import { HOME_SEARCH_SUGGEST_LIMIT } from '@/lib/homeSearchSuggest' -import { formatDistanceKm } from '@/lib/municipalityProximity' - -export const WIZARD_GEO_NEARBY_REASON = 'Perto de você' as const export const WIZARD_CONTINUITY_LAST_ACTED_LABEL = 'Última ação' as const export const WIZARD_CONTINUITY_VISITED_LABEL = 'Visitado' as const @@ -21,12 +18,6 @@ export type WizardContinuityVisitInput = { kind: 'municipality' | 'municipalityList' } -export type WizardGeoMunicipalitySuggestion = { - slug: string - name: string - distanceKm?: number -} - export type WizardMunicipalityMergedRow = { hit: HomeSearchMunicipalityHit continuityReason?: string @@ -40,19 +31,6 @@ export const municipalitySlugFromRecentVisitHref = (href: string): string | null return slug ? slug : null } -/** Secondary line for a geo-resolved wizard hit — region first when known. */ -export const formatWizardGeoSecondary = ( - region: string | undefined, - distanceKm?: number, -): string => { - const reason = - distanceKm !== undefined - ? `${WIZARD_GEO_NEARBY_REASON} (~${formatDistanceKm(distanceKm)})` - : WIZARD_GEO_NEARBY_REASON - - return region ? `${region} · ${reason}` : reason -} - const continuityReasonForSource = (source: WizardContinuitySource): string => { switch (source) { case 'last-acted': @@ -103,12 +81,12 @@ export const listWizardContinuitySlugs = (input: { } /** - * B93 + B94 — merge geo (B94), continuity (B93) and server suggestions (B92). - * Dedup order: geo → last-acted → visited → forgotten. + * B93 + B125 — merge geo prefix, continuity (B93) and server suggestions (B92). + * Dedup order: geo → last-acted → visited → forgotten. Geo row has no proximity copy. */ export const mergeWizardMunicipalitySuggestions = (input: { continuity: WizardContinuitySlug[] - geo?: WizardGeoMunicipalitySuggestion | null + geoSlug?: string | null serverHits: HomeSearchMunicipalityHit[] hitBySlug: ReadonlyMap limit?: number @@ -117,23 +95,17 @@ export const mergeWizardMunicipalitySuggestions = (input: { const merged: WizardMunicipalityMergedRow[] = [] const seen = new Set() - const pushHit = (slug: string, continuityReason?: string, regionOverride?: string) => { + const pushHit = (slug: string, continuityReason?: string) => { if (seen.has(slug)) return const hit = input.hitBySlug.get(slug) if (!hit) return seen.add(slug) - merged.push({ - hit: regionOverride ? { ...hit, region: regionOverride } : hit, - continuityReason, - }) + merged.push({ hit, continuityReason }) } - const geoSlug = input.geo?.slug.trim() + const geoSlug = input.geoSlug?.trim() if (geoSlug) { - const baseHit = input.hitBySlug.get(geoSlug) - if (baseHit) { - pushHit(geoSlug, undefined, formatWizardGeoSecondary(baseHit.region, input.geo?.distanceKm)) - } + pushHit(geoSlug) } for (const item of input.continuity) { diff --git a/src/utilities/homeSearch/loadHomeSearchSuggestions.ts b/src/utilities/homeSearch/loadHomeSearchSuggestions.ts index 78eab864..853bf363 100644 --- a/src/utilities/homeSearch/loadHomeSearchSuggestions.ts +++ b/src/utilities/homeSearch/loadHomeSearchSuggestions.ts @@ -78,6 +78,13 @@ export const loadHomeSearchSuggestions = async ( status: 'success', resultKind: 'suggest', municipalities: municipalityHits, + scopeMunicipalities: municipalities.map((doc) => ({ + slug: doc.slug, + name: doc.name, + region: doc.region, + priority: doc.priority ?? null, + ibgeCode: doc.ibgeCode, + })), territories: [], advisors: [], leaderships: [], diff --git a/tests/int/homeSearchSuggestions.int.spec.ts b/tests/int/homeSearchSuggestions.int.spec.ts index 7416d68a..cf2bcd33 100644 --- a/tests/int/homeSearchSuggestions.int.spec.ts +++ b/tests/int/homeSearchSuggestions.int.spec.ts @@ -34,6 +34,8 @@ describe('loadHomeSearchSuggestions (B68)', () => { for (const hit of result.municipalities) { expect(hit.priority).toBe('alta') } + expect(result.scopeMunicipalities?.length).toBeGreaterThan(0) + expect(result.scopeMunicipalities?.every((row) => row.ibgeCode.length > 0)).toBe(true) }) it('scopes advisor suggestions to administered municipalities', async () => { diff --git a/tests/unit/homeSearchNearestMunicipalityMerge.unit.spec.ts b/tests/unit/homeSearchNearestMunicipalityMerge.unit.spec.ts new file mode 100644 index 00000000..3717dad6 --- /dev/null +++ b/tests/unit/homeSearchNearestMunicipalityMerge.unit.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' + +import type { HomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits' +import { mergeHomeSearchNearestMunicipality } from '@/lib/homeSearchNearestMunicipalityMerge' + +const hit = (slug: string, name: string): HomeSearchMunicipalityHit => ({ + kind: 'municipality', + slug, + name, + region: 'TI', + priority: 'normal', + votePosition2022: null, +}) + +describe('mergeHomeSearchNearestMunicipality (B117 / B125)', () => { + const serverHits = [hit('b', 'Brumado'), hit('c', 'Cairu')] + const hitBySlug = new Map([ + ['a', hit('a', 'Abaíra')], + ['b', hit('b', 'Brumado')], + ['c', hit('c', 'Cairu')], + ]) + + it('prefixes nearest slug and dedupes from server list', () => { + const merged = mergeHomeSearchNearestMunicipality({ + nearestSlug: 'a', + serverHits, + hitBySlug, + }) + expect(merged.map((row) => row.slug)).toEqual(['a', 'b', 'c']) + }) + + it('keeps server order when nearest is already first on server', () => { + const merged = mergeHomeSearchNearestMunicipality({ + nearestSlug: 'b', + serverHits, + hitBySlug, + }) + expect(merged.map((row) => row.slug)).toEqual(['b', 'c']) + }) + + it('returns server list unchanged when nearest slug is missing', () => { + const merged = mergeHomeSearchNearestMunicipality({ + nearestSlug: 'missing', + serverHits, + hitBySlug, + }) + expect(merged).toEqual(serverHits) + }) + + it('returns server list unchanged when nearest slug is null', () => { + const merged = mergeHomeSearchNearestMunicipality({ + nearestSlug: null, + serverHits, + hitBySlug, + }) + expect(merged).toEqual(serverHits) + }) + + it('does not add geo reason or distance to the prefixed hit', () => { + const merged = mergeHomeSearchNearestMunicipality({ + nearestSlug: 'a', + serverHits, + hitBySlug, + }) + const nearest = merged[0] + expect(nearest.region).toBe('TI') + expect(JSON.stringify(nearest)).not.toMatch(/Perto de você|km/i) + }) +}) diff --git a/tests/unit/homeSearchUi.unit.spec.tsx b/tests/unit/homeSearchUi.unit.spec.tsx index db94f38a..59e640ea 100644 --- a/tests/unit/homeSearchUi.unit.spec.tsx +++ b/tests/unit/homeSearchUi.unit.spec.tsx @@ -2,6 +2,7 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow' +import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext' import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup' import { HomeSearchResultsProvider } from '@/components/campaign/dashboard/HomeSearchResultsContext' import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits' @@ -10,6 +11,30 @@ import { HOME_SEARCH_GROUP_LIST_CLASS, HOME_SEARCH_HIT_ROW_WRAPPER_CLASS, } from '@/lib/homeSearchUi' +import { stub } from '../helpers/stub' + +const homeSearchController = (uiFocused = false) => + stub({ + query: { raw: '', debounced: '', isActive: false }, + setRaw: () => {}, + clear: () => {}, + isDebouncing: false, + inputFocused: uiFocused, + setInputFocused: () => {}, + uiFocused, + }) + +const renderMunicipalityGroup = ( + resultsValue: ReturnType, + uiFocused = false, +) => + render( + + + + + , + ) const searchResultsValue = ( data: Partial = {}, @@ -50,42 +75,15 @@ describe('HomeSearchHitRow', () => { const bleed = container.firstElementChild expect(bleed?.className).toBe(HOME_SEARCH_HIT_ROW_WRAPPER_CLASS) + expect(screen.getByRole('link', { name: /Cairu/i }).className).not.toContain('rounded-md') }) }) describe('HomeSearchMunicipalityGroup', () => { it('omits the visible heading in suggest mode while keeping an accessible section name', () => { - render( - - - , - ) - - expect(screen.queryByRole('heading', { name: 'Sugestões' })).toBeNull() - expect(screen.getByRole('region', { name: 'Sugestões' })).toBeTruthy() - expect(screen.getByRole('link', { name: /Cairu/i })).toBeTruthy() - }) - - it('renders a discreet sentence-case heading and list without bullets', () => { - const { container } = render( - { votePosition2022: null, }, ], - })} - > - - , + }, + 'suggest', + ), + true, + ) + + expect(screen.queryByRole('heading', { name: 'Sugestões' })).toBeNull() + expect(screen.getByRole('region', { name: 'Sugestões' })).toBeTruthy() + expect(screen.getByRole('link', { name: /Cairu/i })).toBeTruthy() + }) + + it('renders a discreet sentence-case heading and list without bullets', () => { + const { container } = renderMunicipalityGroup( + searchResultsValue({ + municipalities: [ + { + kind: 'municipality', + slug: 'cairu', + name: 'Cairu', + region: 'Recôncavo', + priority: null, + votePosition2022: null, + }, + ], + }), ) const heading = screen.getByRole('heading', { name: 'Municípios' }) diff --git a/tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts b/tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts index 95e31699..e2358a6c 100644 --- a/tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts +++ b/tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts @@ -3,13 +3,11 @@ import { describe, expect, it } from 'vitest' import type { HomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits' import { HOME_SEARCH_SUGGEST_LIMIT } from '@/lib/homeSearchSuggest' import { - formatWizardGeoSecondary, listWizardContinuitySlugs, mergeWizardMunicipalitySuggestions, municipalitySlugFromRecentVisitHref, WIZARD_CONTINUITY_LAST_ACTED_LABEL, WIZARD_CONTINUITY_VISITED_LABEL, - WIZARD_GEO_NEARBY_REASON, type WizardContinuityVisitInput, } from '@/lib/wizardMunicipalitySuggestMerge' @@ -31,24 +29,6 @@ const visit = ( ...overrides, }) -describe('formatWizardGeoSecondary', () => { - it('formats reason without distance', () => { - expect(formatWizardGeoSecondary(undefined)).toBe(WIZARD_GEO_NEARBY_REASON) - expect(formatWizardGeoSecondary('Costa do Dendê')).toBe( - `Costa do Dendê · ${WIZARD_GEO_NEARBY_REASON}`, - ) - }) - - it('formats reason with distance', () => { - expect(formatWizardGeoSecondary('Costa do Dendê', 1.2)).toBe( - `Costa do Dendê · ${WIZARD_GEO_NEARBY_REASON} (~1,2 km)`, - ) - expect(formatWizardGeoSecondary(undefined, 0.4)).toBe( - `${WIZARD_GEO_NEARBY_REASON} (~menos de 1 km)`, - ) - }) -}) - describe('municipalitySlugFromRecentVisitHref', () => { it('extracts municipality slug from detail href', () => { expect(municipalitySlugFromRecentVisitHref('/campanha/municipios/itabuna')).toBe('itabuna') @@ -117,9 +97,9 @@ describe('mergeWizardMunicipalitySuggestions', () => { ].map((row) => [row.slug, row]), ) - it('dedupes in geo > continuity > server order with reason labels', () => { + it('dedupes in geo > continuity > server order without geo copy on the row', () => { const merged = mergeWizardMunicipalitySuggestions({ - geo: { slug: 'forgot-a', name: 'Forgot A', distanceKm: 2.5 }, + geoSlug: 'forgot-a', continuity: [ { source: 'last-acted', slug: 'acted' }, { source: 'visited', slug: 'visited' }, @@ -133,10 +113,10 @@ describe('mergeWizardMunicipalitySuggestions', () => { }) expect(merged.map((row) => row.hit.slug)).toEqual(['forgot-a', 'acted', 'visited', 'forgot-b']) - expect(merged.find((row) => row.hit.slug === 'forgot-a')?.hit.region).toContain( - WIZARD_GEO_NEARBY_REASON, + expect(merged.find((row) => row.hit.slug === 'forgot-a')?.hit.region).toBe('Costa do Dendê') + expect(JSON.stringify(merged.find((row) => row.hit.slug === 'forgot-a')?.hit)).not.toMatch( + /Perto de você|km/i, ) - expect(merged.find((row) => row.hit.slug === 'forgot-a')?.hit.region).toContain('~2,5 km') expect(merged.find((row) => row.hit.slug === 'acted')?.continuityReason).toBe( WIZARD_CONTINUITY_LAST_ACTED_LABEL, ) From 1bcb49805c1a079e6992d57b2e20914496cafc3c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 18:22:36 +0000 Subject: [PATCH 2/2] format: HomeSearchMunicipalityGroup + homeSearchUi spec --- .../dashboard/HomeSearchMunicipalityGroup.tsx | 18 ++++++++++++------ tests/unit/homeSearchUi.unit.spec.tsx | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/campaign/dashboard/HomeSearchMunicipalityGroup.tsx b/src/components/campaign/dashboard/HomeSearchMunicipalityGroup.tsx index 40c1c9e4..86aa658e 100644 --- a/src/components/campaign/dashboard/HomeSearchMunicipalityGroup.tsx +++ b/src/components/campaign/dashboard/HomeSearchMunicipalityGroup.tsx @@ -2,10 +2,10 @@ import { useMemo } from 'react' +import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext' import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext' import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow' import { HomeSearchMunicipalityVoteTrailing } from '@/components/campaign/dashboard/HomeSearchMunicipalityVoteTrailing' -import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext' import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext' import { useHomeSearchNearestMunicipality } from '@/components/campaign/dashboard/useHomeSearchNearestMunicipality' import { @@ -14,14 +14,15 @@ import { type HomeSearchScopeMunicipality, } from '@/lib/campaignHomeSearchHits' import { formatElectionNumber } from '@/lib/electionFormat' -import { mergeHomeSearchNearestMunicipality } from '@/lib/homeSearchNearestMunicipalityMerge' import { sliceHomeSearchMunicipalityGroup } from '@/lib/homeSearchHitBudget' +import { mergeHomeSearchNearestMunicipality } from '@/lib/homeSearchNearestMunicipalityMerge' import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi' import type { AccessibleMunicipality } from '@/lib/municipalityProximity' import { buildTerritoryPageHref } from '@/lib/territoryAnchor' -const scopeToAccessible = (scope: readonly HomeSearchScopeMunicipality[]): AccessibleMunicipality[] => - scope.map(({ slug, name, ibgeCode }) => ({ slug, name, ibgeCode })) +const scopeToAccessible = ( + scope: readonly HomeSearchScopeMunicipality[], +): AccessibleMunicipality[] => scope.map(({ slug, name, ibgeCode }) => ({ slug, name, ibgeCode })) const buildHitBySlug = (scope: readonly HomeSearchScopeMunicipality[]) => new Map(scope.map((doc) => [doc.slug, toHomeSearchMunicipalityHit(doc)])) @@ -34,7 +35,7 @@ export const HomeSearchMunicipalityGroup = () => { const suggestMode = uiFocused && !query.isActive const scopeMunicipalities = useMemo( - () => (results.status === 'success' ? results.data.scopeMunicipalities ?? [] : []), + () => (results.status === 'success' ? (results.data.scopeMunicipalities ?? []) : []), [results], ) @@ -62,7 +63,12 @@ export const HomeSearchMunicipalityGroup = () => { const includeTerritories = resultKind === 'search' const { municipalities: visibleMunicipalities, territories: visibleTerritories } = - sliceHomeSearchMunicipalityGroup(mergedMunicipalities, territories, hitLimit, includeTerritories) + sliceHomeSearchMunicipalityGroup( + mergedMunicipalities, + territories, + hitLimit, + includeTerritories, + ) if (visibleMunicipalities.length === 0 && visibleTerritories.length === 0) return null diff --git a/tests/unit/homeSearchUi.unit.spec.tsx b/tests/unit/homeSearchUi.unit.spec.tsx index 59e640ea..14ab6a59 100644 --- a/tests/unit/homeSearchUi.unit.spec.tsx +++ b/tests/unit/homeSearchUi.unit.spec.tsx @@ -1,8 +1,8 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow' import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext' +import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow' import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup' import { HomeSearchResultsProvider } from '@/components/campaign/dashboard/HomeSearchResultsContext' import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'