From dbf6a7fdce54b8659008edbc838937e037de5dcd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 07:21:56 +0000 Subject: [PATCH] =?UTF-8?q?B93=20=E2=80=94=20wizard=20empty=20state=20cont?= =?UTF-8?q?inuity=20(visitados=20+=20=C3=BAltima=20a=C3=A7=C3=A3o)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge client-side recent visits and last-acted municipality storage into wizard municipality suggest idle list ahead of B92 server rankings. - Add campaignLastActedMunicipality + wizardMunicipalitySuggestMerge libs - Extend wizard-municipality-suggest with scopeMunicipalities for hit lookup - Record last-acted on wizard write success (votes, signal, trend, leadership) - Clear last-acted on logout alongside recent visits Co-authored-by: Francisco Solla --- .../campanha/(app)/acoes/[slug]/page.tsx | 1 + .../leadership/WizardLeadershipStep.tsx | 4 +- .../municipality/WizardSignalBodyStep.tsx | 2 + .../municipality/WizardTrendNoteStep.tsx | 2 + .../shared/WizardExpectedVotesStep.tsx | 4 + .../shared/WizardMunicipalitySearchStep.tsx | 95 +++++++++++- .../campaign/shell/CampaignSidebar.tsx | 2 + src/lib/campaignHomeSearchHits.ts | 9 ++ src/lib/campaignLastActedMunicipality.ts | 39 +++++ src/lib/wizardMunicipalitySuggestMerge.ts | 120 +++++++++++++++ .../loadWizardMunicipalitySuggestions.ts | 6 + .../wizardMunicipalitySuggestions.int.spec.ts | 1 + ...campaignLastActedMunicipality.unit.spec.ts | 36 +++++ ...izardMunicipalitySuggestMerge.unit.spec.ts | 137 ++++++++++++++++++ 14 files changed, 450 insertions(+), 8 deletions(-) create mode 100644 src/lib/campaignLastActedMunicipality.ts create mode 100644 src/lib/wizardMunicipalitySuggestMerge.ts create mode 100644 tests/unit/campaignLastActedMunicipality.unit.spec.ts create mode 100644 tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx index 4c7a5c2d..4dd96522 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx @@ -119,6 +119,7 @@ export default async function CampaignActionWizardPage({ actionSlug={slug} municipalityId={municipality.id} municipalityName={municipality.name} + municipalitySlug={municipality.slug} initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)} /> ) diff --git a/src/components/campaign/leadership/WizardLeadershipStep.tsx b/src/components/campaign/leadership/WizardLeadershipStep.tsx index 67d212bb..2c651ebe 100644 --- a/src/components/campaign/leadership/WizardLeadershipStep.tsx +++ b/src/components/campaign/leadership/WizardLeadershipStep.tsx @@ -21,6 +21,7 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes' import { wizardActionHref } from '@/lib/campaignActionRoutes' +import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality' import { CAMPAIGN_HOME } from '@/lib/campaignPaths' import { WIZARD_LEADERSHIP_ADD_TILE_LABEL, @@ -103,11 +104,12 @@ export const WizardLeadershipStep = ({ ) : undefined const handleSaved = useCallback(() => { + recordLastActedMunicipality(municipalitySlug) setDirty(true) setMode({ kind: 'grid' }) router.refresh() toast.success(WIZARD_LEADERSHIP_SAVED_TOAST) - }, [router]) + }, [municipalitySlug, router]) const handleContinue = () => { startContinueTransition(() => { diff --git a/src/components/campaign/municipality/WizardSignalBodyStep.tsx b/src/components/campaign/municipality/WizardSignalBodyStep.tsx index 5c17ea55..4a2a32c5 100644 --- a/src/components/campaign/municipality/WizardSignalBodyStep.tsx +++ b/src/components/campaign/municipality/WizardSignalBodyStep.tsx @@ -13,6 +13,7 @@ import { Spinner } from '@/components/ui/Spinner' import { Textarea } from '@/components/ui/textarea' import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes' import { wizardSignalHref } from '@/lib/campaignActionRoutes' +import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality' import { CAMPAIGN_HOME } from '@/lib/campaignPaths' import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy' import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate' @@ -49,6 +50,7 @@ export const WizardSignalBodyStep = ({ const stepTitle = `${WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX}: ${municipalitySignalTypeLabels[signalType]}` useCampaignFormSuccessToast(state, () => { + recordLastActedMunicipality(municipalitySlug) router.push(CAMPAIGN_HOME) }) diff --git a/src/components/campaign/municipality/WizardTrendNoteStep.tsx b/src/components/campaign/municipality/WizardTrendNoteStep.tsx index 22d8b481..5f340734 100644 --- a/src/components/campaign/municipality/WizardTrendNoteStep.tsx +++ b/src/components/campaign/municipality/WizardTrendNoteStep.tsx @@ -13,6 +13,7 @@ import { Spinner } from '@/components/ui/Spinner' import { Textarea } from '@/components/ui/textarea' import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes' import { wizardTrendHref } from '@/lib/campaignActionRoutes' +import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality' import { CAMPAIGN_HOME } from '@/lib/campaignPaths' import { wizardFlowTitleForSlug } from '@/lib/campaignWizardCopy' import { @@ -52,6 +53,7 @@ export const WizardTrendNoteStep = ({ const stepTitle = `Mudar tendência para ${politicalTrendLabels[trendStatus]}` useCampaignFormSuccessToast(state, () => { + recordLastActedMunicipality(municipalitySlug) router.push(CAMPAIGN_HOME) }) diff --git a/src/components/campaign/shared/WizardExpectedVotesStep.tsx b/src/components/campaign/shared/WizardExpectedVotesStep.tsx index 3f20254c..592b6a49 100644 --- a/src/components/campaign/shared/WizardExpectedVotesStep.tsx +++ b/src/components/campaign/shared/WizardExpectedVotesStep.tsx @@ -14,6 +14,7 @@ import { Alert, AlertDescription } from '@/components/ui/Alert' import { Button } from '@/components/ui/button' import { Spinner } from '@/components/ui/Spinner' import { wizardActionHref } from '@/lib/campaignActionRoutes' +import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality' import { postCampaignJson } from '@/lib/campaignJsonRequest' import { CAMPAIGN_HOME } from '@/lib/campaignPaths' import { @@ -37,6 +38,7 @@ type WizardExpectedVotesStepProps = { actionSlug: string municipalityId: number municipalityName: string + municipalitySlug: string initialExpectedVotes: VoteEstimateScenarioViewModel } @@ -44,6 +46,7 @@ export const WizardExpectedVotesStep = ({ actionSlug, municipalityId, municipalityName, + municipalitySlug, initialExpectedVotes, }: WizardExpectedVotesStepProps) => { const [isPending, startTransition] = useTransition() @@ -99,6 +102,7 @@ export const WizardExpectedVotesStep = ({ } setEstimates(payload.savedExpectedVotes) + recordLastActedMunicipality(municipalitySlug) setPhase('saved') }) } diff --git a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx index f9904be5..8aaa474f 100644 --- a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx +++ b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow' import { HomeSearchMunicipalityVoteTrailing } from '@/components/campaign/dashboard/HomeSearchMunicipalityVoteTrailing' @@ -8,10 +8,12 @@ import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearc import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput' import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell' import { wizardActionHref } from '@/lib/campaignActionRoutes' +import { getLastActedMunicipalitySlug } from '@/lib/campaignLastActedMunicipality' import type { HomeSearchMunicipalityHit, WizardMunicipalitySearchSuccessResponse, } from '@/lib/campaignHomeSearchHits' +import { toHomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits' import { HOME_SEARCH_GENERIC_ERROR_MESSAGE } from '@/lib/campaignHomeSearchMessages' import { postCampaignJson } from '@/lib/campaignJsonRequest' import { @@ -22,14 +24,26 @@ import { wizardFlowTitleForSlug, } from '@/lib/campaignWizardCopy' import { HOME_SEARCH_QUERY_MAX_LENGTH } from '@/lib/schemas/homeSearch' +import { + listWizardContinuitySlugs, + mergeWizardMunicipalitySuggestions, + type WizardContinuitySlug, +} from '@/lib/wizardMunicipalitySuggestMerge' +import { listRecentVisits } from '@/utilities/recentVisits' const HOME_SEARCH_ROUTE = '/campanha/home-search' const WIZARD_MUNICIPALITY_SEARCH_INPUT_ID = 'wizardMunicipalitySearchQuery' +type WizardSuggestSuccessState = { + municipalities: HomeSearchMunicipalityHit[] + hitBySlug: ReadonlyMap +} + type WizardSearchResultsState = | { status: 'idle' } | { status: 'loading' } - | { status: 'success'; municipalities: HomeSearchMunicipalityHit[] } + | { status: 'success'; mode: 'search'; municipalities: HomeSearchMunicipalityHit[] } + | { status: 'success'; mode: 'suggest'; suggest: WizardSuggestSuccessState } | { status: 'error'; message: string } type WizardMunicipalitySearchStepProps = { @@ -37,17 +51,50 @@ type WizardMunicipalitySearchStepProps = { previousHref: string } +const buildSuggestSuccessState = ( + payload: WizardMunicipalitySearchSuccessResponse, +): WizardSuggestSuccessState => { + const scopeMunicipalities = payload.scopeMunicipalities ?? [] + const hitBySlug = new Map( + scopeMunicipalities.map((doc) => [doc.slug, toHomeSearchMunicipalityHit(doc)]), + ) + return { + municipalities: payload.municipalities, + hitBySlug, + } +} + export const WizardMunicipalitySearchStep = ({ actionSlug, previousHref, }: WizardMunicipalitySearchStepProps) => { const { query, setRaw, isDebouncing } = useHomeSearchQuery() const [results, setResults] = useState({ status: 'idle' }) + const [continuitySlugs, setContinuitySlugs] = useState([]) + const [continuityReady, setContinuityReady] = useState(false) const requestSeq = useRef(0) const suggestMode = !query.isActive const searchMode = query.isActive const resultsBusy = isDebouncing || results.status === 'loading' + useEffect(() => { + if (!suggestMode || results.status !== 'success' || results.mode !== 'suggest') { + setContinuitySlugs([]) + setContinuityReady(false) + return + } + + const scopeSlugs = new Set(results.suggest.hitBySlug.keys()) + setContinuitySlugs( + listWizardContinuitySlugs({ + lastActedSlug: getLastActedMunicipalitySlug(), + recentVisits: listRecentVisits(), + scopeSlugs, + }), + ) + setContinuityReady(true) + }, [results, suggestMode]) + useEffect(() => { if (!suggestMode && !searchMode) { requestSeq.current += 1 @@ -77,7 +124,20 @@ export const WizardMunicipalitySearchStep = ({ return } - setResults({ status: 'success', municipalities: payload.municipalities }) + if (suggestMode) { + setResults({ + status: 'success', + mode: 'suggest', + suggest: buildSuggestSuccessState(payload), + }) + return + } + + setResults({ + status: 'success', + mode: 'search', + municipalities: payload.municipalities, + }) })().catch((error: unknown) => { if (seq !== requestSeq.current) return if (error instanceof DOMException && error.name === 'AbortError') return @@ -89,8 +149,29 @@ export const WizardMunicipalitySearchStep = ({ } }, [query.debounced, searchMode, suggestMode]) + const displayRows = useMemo(() => { + if (results.status !== 'success') return [] + + if (results.mode === 'search') { + return results.municipalities.map((hit) => ({ hit, continuityReason: undefined })) + } + + if (!continuityReady) { + return results.suggest.municipalities.map((hit) => ({ hit, continuityReason: undefined })) + } + + return mergeWizardMunicipalitySuggestions({ + continuity: continuitySlugs, + serverHits: results.suggest.municipalities, + hitBySlug: results.suggest.hitBySlug, + }) + }, [continuityReady, continuitySlugs, results]) + const showEmpty = - query.isActive && results.status === 'success' && results.municipalities.length === 0 + query.isActive && + results.status === 'success' && + results.mode === 'search' && + results.municipalities.length === 0 return ( ) : null} - {results.status === 'success' && results.municipalities.length > 0 ? ( + {results.status === 'success' && displayRows.length > 0 ? (
    - {results.municipalities.map((hit) => ( + {displayRows.map(({ hit, continuityReason }) => (
  • { if (isLoggingOut) return setIsLoggingOut(true) clearRecentVisits() + clearLastActedMunicipality() clearMunicipalitySavedFilters() await clearCampaignPwaCaches() // logoutCampaign redirects — no need to reset the pending flag on success. diff --git a/src/lib/campaignHomeSearchHits.ts b/src/lib/campaignHomeSearchHits.ts index 6399c8f8..a733389c 100644 --- a/src/lib/campaignHomeSearchHits.ts +++ b/src/lib/campaignHomeSearchHits.ts @@ -116,8 +116,17 @@ export const homeSearchHasAnyHits = (data: HomeSearchSuccessResponse): boolean = homeSearchActivityGroupHasHits(data) || homeSearchDemandGroupHasHits(data) +export type WizardMunicipalityScopeMunicipality = { + slug: string + name: string + region: string + priority: 'alta' | 'normal' | null +} + export type WizardMunicipalitySearchSuccessResponse = { status: 'success' resultKind?: 'wizard-suggest' municipalities: HomeSearchMunicipalityHit[] + /** Present on `wizard-municipality-suggest` for client continuity merge (B93). */ + scopeMunicipalities?: WizardMunicipalityScopeMunicipality[] } diff --git a/src/lib/campaignLastActedMunicipality.ts b/src/lib/campaignLastActedMunicipality.ts new file mode 100644 index 00000000..eeffb4d6 --- /dev/null +++ b/src/lib/campaignLastActedMunicipality.ts @@ -0,0 +1,39 @@ +/** Device-local last municipality where the staff actor completed a wizard write. */ + +export const LAST_ACTED_MUNICIPALITY_STORAGE_KEY = 'teqo:campaign:last-acted-municipality' + +const isStoredSlug = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0 + +export const getLastActedMunicipalitySlug = (): string | null => { + if (typeof window === 'undefined') return null + try { + const raw = localStorage.getItem(LAST_ACTED_MUNICIPALITY_STORAGE_KEY) + if (!raw) return null + const parsed: unknown = JSON.parse(raw) + if (isStoredSlug(parsed)) return parsed.trim() + return null + } catch { + return null + } +} + +export const recordLastActedMunicipality = (slug: string): void => { + if (typeof window === 'undefined') return + const trimmed = slug.trim() + if (!trimmed) return + try { + localStorage.setItem(LAST_ACTED_MUNICIPALITY_STORAGE_KEY, JSON.stringify(trimmed)) + } catch { + // Ignore quota / private-mode failures. + } +} + +export const clearLastActedMunicipality = (): void => { + if (typeof window === 'undefined') return + try { + localStorage.removeItem(LAST_ACTED_MUNICIPALITY_STORAGE_KEY) + } catch { + // Ignore private-mode failures. + } +} diff --git a/src/lib/wizardMunicipalitySuggestMerge.ts b/src/lib/wizardMunicipalitySuggestMerge.ts new file mode 100644 index 00000000..c40091e2 --- /dev/null +++ b/src/lib/wizardMunicipalitySuggestMerge.ts @@ -0,0 +1,120 @@ +import type { HomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits' +import { HOME_SEARCH_SUGGEST_LIMIT } from '@/lib/homeSearchSuggest' + +export const WIZARD_CONTINUITY_LAST_ACTED_LABEL = 'Última ação' as const +export const WIZARD_CONTINUITY_VISITED_LABEL = 'Visitado' as const + +export const WIZARD_CONTINUITY_MAX_VISITED = 3 + +export type WizardContinuitySource = 'last-acted' | 'visited' + +export type WizardContinuitySlug = { + source: WizardContinuitySource + slug: string +} + +export type WizardContinuityVisitInput = { + href: string + kind: 'municipality' | 'municipalityList' +} + +export type WizardMunicipalityMergedRow = { + hit: HomeSearchMunicipalityHit + continuityReason?: string +} + +const MUNICIPALITY_DETAIL_HREF_PREFIX = '/campanha/municipios/' + +export const municipalitySlugFromRecentVisitHref = (href: string): string | null => { + if (!href.startsWith(MUNICIPALITY_DETAIL_HREF_PREFIX)) return null + const slug = href.slice(MUNICIPALITY_DETAIL_HREF_PREFIX.length).split('/')[0]?.trim() + return slug ? slug : null +} + +const continuityReasonForSource = (source: WizardContinuitySource): string => { + switch (source) { + case 'last-acted': + return WIZARD_CONTINUITY_LAST_ACTED_LABEL + case 'visited': + return WIZARD_CONTINUITY_VISITED_LABEL + default: { + const _exhaustive: never = source + return _exhaustive + } + } +} + +export const listWizardContinuitySlugs = (input: { + lastActedSlug: string | null + recentVisits: WizardContinuityVisitInput[] + scopeSlugs: ReadonlySet + maxVisited?: number +}): WizardContinuitySlug[] => { + const maxVisited = input.maxVisited ?? WIZARD_CONTINUITY_MAX_VISITED + const continuity: WizardContinuitySlug[] = [] + const seen = new Set() + + const pushSlug = (source: WizardContinuitySource, slug: string) => { + if (!input.scopeSlugs.has(slug) || seen.has(slug)) return + seen.add(slug) + continuity.push({ source, slug }) + } + + if (input.lastActedSlug) { + pushSlug('last-acted', input.lastActedSlug) + } + + let visitedCount = 0 + for (const entry of input.recentVisits) { + if (visitedCount >= maxVisited) break + if (entry.kind !== 'municipality') continue + const slug = municipalitySlugFromRecentVisitHref(entry.href) + if (!slug) continue + if (seen.has(slug)) continue + if (!input.scopeSlugs.has(slug)) continue + seen.add(slug) + continuity.push({ source: 'visited', slug }) + visitedCount += 1 + } + + return continuity +} + +/** + * B93 — merge client continuity (last-acted + recent visits) ahead of B92 server + * suggestions. Dedup order: geo (B94) → last-acted → visited → forgotten. + */ +export const mergeWizardMunicipalitySuggestions = (input: { + continuity: WizardContinuitySlug[] + geoSlug?: string | null + serverHits: HomeSearchMunicipalityHit[] + hitBySlug: ReadonlyMap + limit?: number +}): WizardMunicipalityMergedRow[] => { + const limit = input.limit ?? HOME_SEARCH_SUGGEST_LIMIT + const merged: WizardMunicipalityMergedRow[] = [] + const seen = new Set() + + 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, continuityReason }) + } + + const geoSlug = input.geoSlug?.trim() + if (geoSlug) { + pushHit(geoSlug) + } + + for (const item of input.continuity) { + pushHit(item.slug, continuityReasonForSource(item.source)) + } + + for (const hit of input.serverHits) { + pushHit(hit.slug) + } + + return merged.slice(0, limit) +} diff --git a/src/utilities/homeSearch/loadWizardMunicipalitySuggestions.ts b/src/utilities/homeSearch/loadWizardMunicipalitySuggestions.ts index a106bf12..ab047205 100644 --- a/src/utilities/homeSearch/loadWizardMunicipalitySuggestions.ts +++ b/src/utilities/homeSearch/loadWizardMunicipalitySuggestions.ts @@ -65,5 +65,11 @@ export const loadWizardMunicipalitySuggestions = async ( status: 'success', resultKind: 'wizard-suggest', municipalities: ranked.map((input) => toHomeSearchMunicipalityHit(docBySlug.get(input.slug)!)), + scopeMunicipalities: municipalities.map((doc) => ({ + slug: doc.slug, + name: doc.name, + region: doc.region, + priority: doc.priority ?? null, + })), } } diff --git a/tests/int/wizardMunicipalitySuggestions.int.spec.ts b/tests/int/wizardMunicipalitySuggestions.int.spec.ts index 841bc27f..a1219069 100644 --- a/tests/int/wizardMunicipalitySuggestions.int.spec.ts +++ b/tests/int/wizardMunicipalitySuggestions.int.spec.ts @@ -30,6 +30,7 @@ describe('loadWizardMunicipalitySuggestions (B92)', () => { expect(result.resultKind).toBe('wizard-suggest') expect(result.municipalities.length).toBeGreaterThan(0) expect(result.municipalities.length).toBeLessThanOrEqual(8) + expect(result.scopeMunicipalities?.length).toBeGreaterThan(0) }) it('scopes advisor suggestions to administered municipalities', async () => { diff --git a/tests/unit/campaignLastActedMunicipality.unit.spec.ts b/tests/unit/campaignLastActedMunicipality.unit.spec.ts new file mode 100644 index 00000000..3fc7e203 --- /dev/null +++ b/tests/unit/campaignLastActedMunicipality.unit.spec.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { + clearLastActedMunicipality, + getLastActedMunicipalitySlug, + LAST_ACTED_MUNICIPALITY_STORAGE_KEY, + recordLastActedMunicipality, +} from '@/lib/campaignLastActedMunicipality' + +describe('campaignLastActedMunicipality storage', () => { + afterEach(() => { + localStorage.clear() + }) + + it('returns null when storage is missing or invalid', () => { + expect(getLastActedMunicipalitySlug()).toBeNull() + localStorage.setItem(LAST_ACTED_MUNICIPALITY_STORAGE_KEY, 'not-json') + expect(getLastActedMunicipalitySlug()).toBeNull() + localStorage.setItem(LAST_ACTED_MUNICIPALITY_STORAGE_KEY, JSON.stringify('')) + expect(getLastActedMunicipalitySlug()).toBeNull() + }) + + it('records and reads the last acted municipality slug', () => { + recordLastActedMunicipality('itabuna') + expect(getLastActedMunicipalitySlug()).toBe('itabuna') + recordLastActedMunicipality(' cairu ') + expect(getLastActedMunicipalitySlug()).toBe('cairu') + }) + + it('clears stored slug', () => { + recordLastActedMunicipality('valenca') + clearLastActedMunicipality() + expect(getLastActedMunicipalitySlug()).toBeNull() + expect(localStorage.getItem(LAST_ACTED_MUNICIPALITY_STORAGE_KEY)).toBeNull() + }) +}) diff --git a/tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts b/tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts new file mode 100644 index 00000000..243461c0 --- /dev/null +++ b/tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' + +import type { HomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits' +import { HOME_SEARCH_SUGGEST_LIMIT } from '@/lib/homeSearchSuggest' +import { + listWizardContinuitySlugs, + mergeWizardMunicipalitySuggestions, + municipalitySlugFromRecentVisitHref, + WIZARD_CONTINUITY_LAST_ACTED_LABEL, + WIZARD_CONTINUITY_VISITED_LABEL, + type WizardContinuityVisitInput, +} from '@/lib/wizardMunicipalitySuggestMerge' + +const hit = ( + partial: Partial & Pick, +): HomeSearchMunicipalityHit => ({ + kind: 'municipality', + region: 'Região', + priority: null, + votePosition2022: null, + ...partial, +}) + +const visit = (overrides: Partial = {}): WizardContinuityVisitInput => ({ + href: '/campanha/municipios/cairu', + kind: 'municipality', + ...overrides, +}) + +describe('municipalitySlugFromRecentVisitHref', () => { + it('extracts municipality slug from detail href', () => { + expect(municipalitySlugFromRecentVisitHref('/campanha/municipios/itabuna')).toBe('itabuna') + expect(municipalitySlugFromRecentVisitHref('/campanha/municipios')).toBeNull() + expect(municipalitySlugFromRecentVisitHref('/campanha/atividades/foo')).toBeNull() + }) +}) + +describe('listWizardContinuitySlugs', () => { + const scope = new Set(['itabuna', 'cairu', 'valenca', 'fora']) + + it('orders last-acted before visited and filters out of scope', () => { + const continuity = listWizardContinuitySlugs({ + lastActedSlug: 'itabuna', + recentVisits: [ + visit({ href: '/campanha/municipios/cairu' }), + visit({ href: '/campanha/municipios/fora' }), + visit({ href: '/campanha/municipios/valenca' }), + ], + scopeSlugs: scope, + }) + + expect(continuity).toEqual([ + { source: 'last-acted', slug: 'itabuna' }, + { source: 'visited', slug: 'cairu' }, + { source: 'visited', slug: 'valenca' }, + ]) + }) + + it('deduplicates visited slug that matches last-acted', () => { + const continuity = listWizardContinuitySlugs({ + lastActedSlug: 'cairu', + recentVisits: [visit({ href: '/campanha/municipios/cairu' })], + scopeSlugs: new Set(['cairu']), + }) + + expect(continuity).toEqual([{ source: 'last-acted', slug: 'cairu' }]) + }) + + it('ignores non-municipality visits and caps visited count', () => { + const continuity = listWizardContinuitySlugs({ + lastActedSlug: null, + recentVisits: [ + visit({ href: '/campanha/municipios', kind: 'municipalityList' }), + visit({ href: '/campanha/municipios/a' }), + visit({ href: '/campanha/municipios/b' }), + visit({ href: '/campanha/municipios/c' }), + visit({ href: '/campanha/municipios/d' }), + ], + scopeSlugs: new Set(['a', 'b', 'c', 'd']), + maxVisited: 3, + }) + + expect(continuity.map((item) => item.slug)).toEqual(['a', 'b', 'c']) + }) +}) + +describe('mergeWizardMunicipalitySuggestions', () => { + const hitBySlug = new Map( + [ + hit({ slug: 'geo', name: 'Geo' }), + hit({ slug: 'acted', name: 'Acted' }), + hit({ slug: 'visited', name: 'Visited' }), + hit({ slug: 'forgot-a', name: 'Forgot A' }), + hit({ slug: 'forgot-b', name: 'Forgot B' }), + ].map((row) => [row.slug, row]), + ) + + it('dedupes in geo > continuity > server order with reason labels', () => { + const merged = mergeWizardMunicipalitySuggestions({ + geoSlug: 'forgot-a', + continuity: [ + { source: 'last-acted', slug: 'acted' }, + { source: 'visited', slug: 'visited' }, + { source: 'visited', slug: 'forgot-a' }, + ], + serverHits: [ + hit({ slug: 'forgot-a', name: 'Forgot A' }), + hit({ slug: 'forgot-b', name: 'Forgot B' }), + ], + hitBySlug, + }) + + expect(merged.map((row) => row.hit.slug)).toEqual(['forgot-a', 'acted', 'visited', 'forgot-b']) + expect(merged.find((row) => row.hit.slug === 'acted')?.continuityReason).toBe( + WIZARD_CONTINUITY_LAST_ACTED_LABEL, + ) + expect(merged.find((row) => row.hit.slug === 'visited')?.continuityReason).toBe( + WIZARD_CONTINUITY_VISITED_LABEL, + ) + expect(merged.find((row) => row.hit.slug === 'forgot-b')?.continuityReason).toBeUndefined() + }) + + it('caps the merged list at HOME_SEARCH_SUGGEST_LIMIT', () => { + const manyHits = Array.from({ length: 10 }, (_, index) => + hit({ slug: `m-${index}`, name: `M ${index}` }), + ) + const manyBySlug = new Map(manyHits.map((row) => [row.slug, row])) + + const merged = mergeWizardMunicipalitySuggestions({ + continuity: [], + serverHits: manyHits, + hitBySlug: manyBySlug, + }) + + expect(merged).toHaveLength(HOME_SEARCH_SUGGEST_LIMIT) + }) +})