mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge pull request #99 from fsolla/cursor/pool-i93-28926117-13a5
B93 — Empty state do wizard — continuity (visitados + última ação)
This commit is contained in:
commit
eb80f8c32c
15 changed files with 451 additions and 8 deletions
|
|
@ -119,6 +119,7 @@ export default async function CampaignActionWizardPage({
|
|||
actionSlug={slug}
|
||||
municipalityId={municipality.id}
|
||||
municipalityName={municipality.name}
|
||||
municipalitySlug={municipality.slug}
|
||||
initialExpectedVotes={toVoteEstimateScenarioViewModel(municipality.expectedVotes)}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { wizardActionHref } from '@/lib/campaignActionRoutes'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
import { recordLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
|
||||
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
|
||||
import {
|
||||
WIZARD_VOTES_FINAL_CTA_LABEL,
|
||||
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -12,8 +12,10 @@ 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 { getLastActedMunicipalitySlug } from '@/lib/campaignLastActedMunicipality'
|
||||
import {
|
||||
WIZARD_MUNICIPALITY_SEARCH_EMPTY,
|
||||
WIZARD_MUNICIPALITY_SEARCH_LABEL,
|
||||
|
|
@ -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<string, HomeSearchMunicipalityHit>
|
||||
}
|
||||
|
||||
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<WizardSearchResultsState>({ status: 'idle' })
|
||||
const [continuitySlugs, setContinuitySlugs] = useState<WizardContinuitySlug[]>([])
|
||||
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 (
|
||||
<CampaignWizardShell
|
||||
|
|
@ -124,14 +205,14 @@ export const WizardMunicipalitySearchStep = ({
|
|||
{results.message}
|
||||
</p>
|
||||
) : null}
|
||||
{results.status === 'success' && results.municipalities.length > 0 ? (
|
||||
{results.status === 'success' && displayRows.length > 0 ? (
|
||||
<ul className="flex flex-col">
|
||||
{results.municipalities.map((hit) => (
|
||||
{displayRows.map(({ hit, continuityReason }) => (
|
||||
<li key={hit.slug}>
|
||||
<HomeSearchHitRow
|
||||
href={wizardActionHref(actionSlug, hit.slug)}
|
||||
primary={hit.name}
|
||||
secondary={hit.region}
|
||||
secondary={continuityReason ?? hit.region}
|
||||
showPriority={hit.priority === 'alta'}
|
||||
wizardNavigation
|
||||
trailing={
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
useSidebar,
|
||||
} from '@/components/ui/Sidebar'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { clearLastActedMunicipality } from '@/lib/campaignLastActedMunicipality'
|
||||
import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient'
|
||||
import type { CampaignUserShellView } from '@/utilities/campaignUserProfile'
|
||||
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
|
||||
|
|
@ -76,6 +77,7 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
|
|||
if (isLoggingOut) return
|
||||
setIsLoggingOut(true)
|
||||
clearRecentVisits()
|
||||
clearLastActedMunicipality()
|
||||
clearMunicipalitySavedFilters()
|
||||
await clearCampaignPwaCaches()
|
||||
// logoutCampaign redirects — no need to reset the pending flag on success.
|
||||
|
|
|
|||
|
|
@ -120,4 +120,11 @@ export type WizardMunicipalitySearchSuccessResponse = {
|
|||
status: 'success'
|
||||
resultKind?: 'wizard-suggest'
|
||||
municipalities: HomeSearchMunicipalityHit[]
|
||||
/** Present on `wizard-municipality-suggest` for client continuity merge (B93). */
|
||||
scopeMunicipalities?: {
|
||||
slug: string
|
||||
name: string
|
||||
region: string
|
||||
priority: 'alta' | 'normal' | null
|
||||
}[]
|
||||
}
|
||||
|
|
|
|||
39
src/lib/campaignLastActedMunicipality.ts
Normal file
39
src/lib/campaignLastActedMunicipality.ts
Normal file
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
120
src/lib/wizardMunicipalitySuggestMerge.ts
Normal file
120
src/lib/wizardMunicipalitySuggestMerge.ts
Normal file
|
|
@ -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
|
||||
|
||||
const WIZARD_CONTINUITY_MAX_VISITED = 3
|
||||
|
||||
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<string>
|
||||
maxVisited?: number
|
||||
}): WizardContinuitySlug[] => {
|
||||
const maxVisited = input.maxVisited ?? WIZARD_CONTINUITY_MAX_VISITED
|
||||
const continuity: WizardContinuitySlug[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
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<string, HomeSearchMunicipalityHit>
|
||||
limit?: number
|
||||
}): WizardMunicipalityMergedRow[] => {
|
||||
const limit = input.limit ?? HOME_SEARCH_SUGGEST_LIMIT
|
||||
const merged: WizardMunicipalityMergedRow[] = []
|
||||
const seen = new Set<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, 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
36
tests/unit/campaignLastActedMunicipality.unit.spec.ts
Normal file
36
tests/unit/campaignLastActedMunicipality.unit.spec.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -20,6 +20,7 @@ const defaultProps = {
|
|||
actionSlug: 'atualizar-votos',
|
||||
municipalityId: 1,
|
||||
municipalityName: 'Cairu',
|
||||
municipalitySlug: 'cairu',
|
||||
initialExpectedVotes: { pessimistic: 100, central: 200, optimistic: 300 },
|
||||
}
|
||||
|
||||
|
|
|
|||
139
tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts
Normal file
139
tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
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<HomeSearchMunicipalityHit> & Pick<HomeSearchMunicipalityHit, 'slug' | 'name'>,
|
||||
): HomeSearchMunicipalityHit => ({
|
||||
kind: 'municipality',
|
||||
region: 'Região',
|
||||
priority: null,
|
||||
votePosition2022: null,
|
||||
...partial,
|
||||
})
|
||||
|
||||
const visit = (
|
||||
overrides: Partial<WizardContinuityVisitInput> = {},
|
||||
): 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'])
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue