Merge pull request #98 from fsolla/cursor/pool-i92-174dd43e-5e6f

B92 — Empty state do wizard: chassis + ranking de esquecidos
This commit is contained in:
Francisco Solla 2026-08-01 04:17:53 -03:00 committed by GitHub
commit 7f7c707d4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 398 additions and 7 deletions

View file

@ -12,6 +12,7 @@ import { homeSearchBodySchema } from '@/lib/schemas/homeSearch'
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearchSuggestions'
import { loadWizardMunicipalitySuggestions } from '@/utilities/homeSearch/loadWizardMunicipalitySuggestions'
import { searchHomeActivities } from '@/utilities/homeSearch/searchHomeActivities'
import { searchHomeAdvisors } from '@/utilities/homeSearch/searchHomeAdvisors'
import { searchHomeDemands } from '@/utilities/homeSearch/searchHomeDemands'
@ -22,23 +23,32 @@ import { searchStaffMunicipalityHits } from '@/utilities/homeSearch/searchStaffM
export const dynamic = 'force-dynamic'
type HomeSearchRouteSuccessBody =
| HomeSearchSuccessResponse
| WizardMunicipalitySearchSuccessResponse
export const POST = campaignJsonMutationRoute(
{
bodySchema: homeSearchBodySchema,
safeMessages: [HOME_SEARCH_STAFF_ONLY_MESSAGE],
genericMessage: HOME_SEARCH_GENERIC_ERROR_MESSAGE,
},
async (body) => {
async (body): Promise<NextResponse<HomeSearchRouteSuccessBody>> => {
const { payload, actor } = await getCampaignActionContext()
if (body.mode === 'suggest') {
const result = await loadHomeSearchSuggestions(payload, actor)
return NextResponse.json<HomeSearchSuccessResponse>(result)
return NextResponse.json(result)
}
if (body.mode === 'wizard-municipality-suggest') {
const result = await loadWizardMunicipalitySuggestions(payload, actor)
return NextResponse.json(result)
}
if (body.mode === 'wizard-municipality') {
const municipalities = await searchStaffMunicipalityHits(payload, actor, body.query)
return NextResponse.json<WizardMunicipalitySearchSuccessResponse>({
return NextResponse.json({
status: 'success',
municipalities,
})
@ -53,7 +63,7 @@ export const POST = campaignJsonMutationRoute(
searchHomeStateDeputies(payload, actor, body.query),
searchHomeDemands(payload, actor, body.query),
])
return NextResponse.json<HomeSearchSuccessResponse>({
return NextResponse.json({
...municipalityResult,
advisors,
leaderships,

View file

@ -44,10 +44,12 @@ export const WizardMunicipalitySearchStep = ({
const { query, setRaw, isDebouncing } = useHomeSearchQuery()
const [results, setResults] = useState<WizardSearchResultsState>({ status: 'idle' })
const requestSeq = useRef(0)
const suggestMode = !query.isActive
const searchMode = query.isActive
const resultsBusy = isDebouncing || results.status === 'loading'
useEffect(() => {
if (!query.isActive) {
if (!suggestMode && !searchMode) {
requestSeq.current += 1
setResults({ status: 'idle' })
return
@ -57,10 +59,14 @@ export const WizardMunicipalitySearchStep = ({
const controller = new AbortController()
setResults({ status: 'loading' })
const body = suggestMode
? ({ mode: 'wizard-municipality-suggest' } as const)
: ({ mode: 'wizard-municipality', query: query.debounced } as const)
void (async () => {
const { ok, payload } = await postCampaignJson<WizardMunicipalitySearchSuccessResponse>(
HOME_SEARCH_ROUTE,
{ mode: 'wizard-municipality', query: query.debounced },
body,
controller.signal,
)
@ -81,7 +87,7 @@ export const WizardMunicipalitySearchStep = ({
return () => {
controller.abort()
}
}, [query.debounced, query.isActive])
}, [query.debounced, searchMode, suggestMode])
const showEmpty =
query.isActive && results.status === 'success' && results.municipalities.length === 0

View file

@ -118,5 +118,6 @@ export const homeSearchHasAnyHits = (data: HomeSearchSuccessResponse): boolean =
export type WizardMunicipalitySearchSuccessResponse = {
status: 'success'
resultKind?: 'wizard-suggest'
municipalities: HomeSearchMunicipalityHit[]
}

View file

@ -22,4 +22,7 @@ export const homeSearchBodySchema = z.discriminatedUnion('mode', [
mode: z.literal('wizard-municipality'),
query: homeSearchQueryField,
}),
z.object({
mode: z.literal('wizard-municipality-suggest'),
}),
])

View file

@ -0,0 +1,122 @@
import { engagementLevelRank, type EngagementLevel } from '@/lib/engagementLevel'
import { HOME_SEARCH_SUGGEST_LIMIT } from '@/lib/homeSearchSuggest'
import type { PoliticalTrendStatusValue } from '@/lib/schemas/municipality'
import type { MunicipalityTerritorialClass } from '@/lib/territorialClassAnchors'
import { territorialClassSortWeight } from '@/lib/territorialClassSortWeight'
export type WizardMunicipalitySuggestInput = {
slug: string
name: string
/** ISO timestamp or null when never signaled — colder sorts first. */
lastSignalAt: string | null
engagementLevel: EngagementLevel | null
politicalTrend: PoliticalTrendStatusValue | null
territorialClass: MunicipalityTerritorialClass
/** Nominal 2022 candidate votes; null when absent from artifact. */
votes2022: number | null
}
/** Higher = more urgent for wizard tie-break (desfavorável first). */
const politicalTrendSortWeight: Record<PoliticalTrendStatusValue, number> = {
desfavoravel: 3,
neutra: 2,
favoravel: 1,
}
const municipalityNameCompare = (
left: WizardMunicipalitySuggestInput,
right: WizardMunicipalitySuggestInput,
): number => left.name.localeCompare(right.name, 'pt-BR')
const compareEngagementLevel = (
left: WizardMunicipalitySuggestInput,
right: WizardMunicipalitySuggestInput,
): number => {
const leftRank = left.engagementLevel === null ? -1 : engagementLevelRank[left.engagementLevel]
const rightRank = right.engagementLevel === null ? -1 : engagementLevelRank[right.engagementLevel]
if (leftRank !== rightRank) return rightRank - leftRank
return 0
}
const comparePoliticalTrend = (
left: WizardMunicipalitySuggestInput,
right: WizardMunicipalitySuggestInput,
): number => {
const leftWeight =
left.politicalTrend === null ? 0 : politicalTrendSortWeight[left.politicalTrend]
const rightWeight =
right.politicalTrend === null ? 0 : politicalTrendSortWeight[right.politicalTrend]
if (leftWeight !== rightWeight) return rightWeight - leftWeight
return 0
}
const compareTerritorialClass = (
left: WizardMunicipalitySuggestInput,
right: WizardMunicipalitySuggestInput,
): number => {
const leftWeight = territorialClassSortWeight[left.territorialClass]
const rightWeight = territorialClassSortWeight[right.territorialClass]
if (leftWeight === null && rightWeight === null) return 0
if (leftWeight === null) return 1
if (rightWeight === null) return -1
if (leftWeight !== rightWeight) return rightWeight - leftWeight
return 0
}
const compareVotes2022 = (
left: WizardMunicipalitySuggestInput,
right: WizardMunicipalitySuggestInput,
): number => {
const leftVotes = left.votes2022
const rightVotes = right.votes2022
if (leftVotes === null && rightVotes === null) return 0
if (leftVotes === null || leftVotes === 0) return 1
if (rightVotes === null || rightVotes === 0) return -1
if (leftVotes !== rightVotes) return rightVotes - leftVotes
return 0
}
/** Older signal first; null = coldest; equal timestamps defer to later tie-breakers. */
const compareSignalOldestFirst = (
left: WizardMunicipalitySuggestInput,
right: WizardMunicipalitySuggestInput,
): number => {
if (left.lastSignalAt === null && right.lastSignalAt === null) return 0
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 0
}
const compareWizardMunicipalitySuggest = (
left: WizardMunicipalitySuggestInput,
right: WizardMunicipalitySuggestInput,
): number => {
const bySignal = compareSignalOldestFirst(left, right)
if (bySignal !== 0) return bySignal
const byEngagement = compareEngagementLevel(left, right)
if (byEngagement !== 0) return byEngagement
const byTrend = comparePoliticalTrend(left, right)
if (byTrend !== 0) return byTrend
const byClass = compareTerritorialClass(left, right)
if (byClass !== 0) return byClass
const byVotes = compareVotes2022(left, right)
if (byVotes !== 0) return byVotes
return municipalityNameCompare(left, right)
}
/**
* B92 — wizard municipality idle suggestions: coldest signal first, then
* operational tie-breakers (E14 → trend → E10 class → 2022 votes → name).
*/
export const rankWizardMunicipalitySuggestions = (
municipalities: WizardMunicipalitySuggestInput[],
limit = HOME_SEARCH_SUGGEST_LIMIT,
): WizardMunicipalitySuggestInput[] =>
[...municipalities].sort(compareWizardMunicipalitySuggest).slice(0, limit)

View file

@ -0,0 +1,69 @@
import 'server-only'
import type { Payload } from 'payload'
import {
toHomeSearchMunicipalityHit,
type WizardMunicipalitySearchSuccessResponse,
} from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { DEFAULT_VOTE_RANK_YEAR, getMunicipalityVoteRank } from '@/lib/municipalityVoteRank'
import {
rankWizardMunicipalitySuggestions,
type WizardMunicipalitySuggestInput,
} from '@/lib/wizardMunicipalitySuggest'
import type { CampaignUser } from '@/payload-types'
import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope'
import { resolveMunicipalityLastSignalAt } from '@/utilities/municipality/municipalitySignal'
import { computeMunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
export const loadWizardMunicipalitySuggestions = async (
payload: Payload,
user: CampaignUser,
): Promise<WizardMunicipalitySearchSuccessResponse> => {
if (!isStaffCampaignRole(user.role)) {
throw new Error(HOME_SEARCH_STAFF_ONLY_MESSAGE)
}
const { municipalities, pledgeAggregates } = await loadMunicipalityScope(
payload,
user,
{},
{
extraSelect: {
lastUpdateAt: true,
engagementLevel: true,
politicalTrend: true,
},
},
)
const inputs: WizardMunicipalitySuggestInput[] = municipalities.map((doc) => {
const aggregate = pledgeAggregates.get(doc.id)
const lastSignalAt = resolveMunicipalityLastSignalAt(
doc.lastUpdateAt ?? null,
aggregate?.lastPledgeAt ?? null,
)
const voteRank = getMunicipalityVoteRank(doc.slug, DEFAULT_VOTE_RANK_YEAR)
return {
slug: doc.slug,
name: doc.name,
lastSignalAt,
engagementLevel: doc.engagementLevel ?? null,
politicalTrend: doc.politicalTrend?.status ?? null,
territorialClass: computeMunicipalityTerritorialClass(doc.slug).class,
votes2022: voteRank?.votes ?? null,
}
})
const ranked = rankWizardMunicipalitySuggestions(inputs)
const docBySlug = new Map(municipalities.map((doc) => [doc.slug, doc]))
return {
status: 'success',
resultKind: 'wizard-suggest',
municipalities: ranked.map((input) => toHomeSearchMunicipalityHit(docBySlug.get(input.slug)!)),
}
}

View file

@ -0,0 +1,53 @@
// @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 { loadWizardMunicipalitySuggestions } from '@/utilities/homeSearch/loadWizardMunicipalitySuggestions'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('loadWizardMunicipalitySuggestions (B92)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('returns up to eight scoped municipalities for coordinator', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const result = await loadWizardMunicipalitySuggestions(payload, coordinator)
expect(result.resultKind).toBe('wizard-suggest')
expect(result.municipalities.length).toBeGreaterThan(0)
expect(result.municipalities.length).toBeLessThanOrEqual(8)
})
it('scopes advisor suggestions to administered municipalities', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const administered = await fixtures.getMunicipality()
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const result = await loadWizardMunicipalitySuggestions(payload, advisor)
expect(result.municipalities.every((hit) => hit.slug === administered.slug)).toBe(true)
})
it('rejects leaders from wizard municipality suggestions', async () => {
const fixtures = campaignFixtures()
const leader = await fixtures.createCampaignUser('leader')
await expect(loadWizardMunicipalitySuggestions(payload, leader)).rejects.toThrow(
/equipe de campanha/i,
)
})
})

View file

@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import { HOME_SEARCH_SUGGEST_LIMIT } from '@/lib/homeSearchSuggest'
import {
rankWizardMunicipalitySuggestions,
type WizardMunicipalitySuggestInput,
} from '@/lib/wizardMunicipalitySuggest'
const row = (
partial: Partial<WizardMunicipalitySuggestInput> &
Pick<WizardMunicipalitySuggestInput, 'slug' | 'name'>,
): WizardMunicipalitySuggestInput => ({
lastSignalAt: null,
engagementLevel: null,
politicalTrend: null,
territorialClass: 'manutencao',
votes2022: null,
...partial,
})
describe('rankWizardMunicipalitySuggestions', () => {
it('ranks 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 = rankWizardMunicipalitySuggestions(municipalities)
expect(ranked.map((m) => m.slug)).toEqual(['a', 'c', 'b'])
})
it('breaks signal ties by engagement level (higher first)', () => {
const municipalities = [
row({
slug: 'low',
name: 'Low',
lastSignalAt: '2026-07-01T12:00:00.000Z',
engagementLevel: 'n1',
}),
row({
slug: 'high',
name: 'High',
lastSignalAt: '2026-07-01T12:00:00.000Z',
engagementLevel: 'n4',
}),
]
const ranked = rankWizardMunicipalitySuggestions(municipalities)
expect(ranked.map((m) => m.slug)).toEqual(['high', 'low'])
})
it('breaks engagement ties by political trend (desfavorável first)', () => {
const municipalities = [
row({
slug: 'fav',
name: 'Fav',
lastSignalAt: '2026-07-01T12:00:00.000Z',
engagementLevel: 'n2',
politicalTrend: 'favoravel',
}),
row({
slug: 'des',
name: 'Des',
lastSignalAt: '2026-07-01T12:00:00.000Z',
engagementLevel: 'n2',
politicalTrend: 'desfavoravel',
}),
]
const ranked = rankWizardMunicipalitySuggestions(municipalities)
expect(ranked.map((m) => m.slug)).toEqual(['des', 'fav'])
})
it('breaks trend ties by territorial class (reduto before marginal)', () => {
const municipalities = [
row({
slug: 'marg',
name: 'Marg',
lastSignalAt: '2026-07-01T12:00:00.000Z',
territorialClass: 'marginal',
}),
row({
slug: 'red',
name: 'Red',
lastSignalAt: '2026-07-01T12:00:00.000Z',
territorialClass: 'reduto',
}),
]
const ranked = rankWizardMunicipalitySuggestions(municipalities)
expect(ranked.map((m) => m.slug)).toEqual(['red', 'marg'])
})
it('breaks class ties by 2022 votes (higher first)', () => {
const municipalities = [
row({
slug: 'low',
name: 'Low',
lastSignalAt: '2026-07-01T12:00:00.000Z',
votes2022: 100,
}),
row({
slug: 'high',
name: 'High',
lastSignalAt: '2026-07-01T12:00:00.000Z',
votes2022: 5000,
}),
]
const ranked = rankWizardMunicipalitySuggestions(municipalities)
expect(ranked.map((m) => m.slug)).toEqual(['high', 'low'])
})
it('caps 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 = rankWizardMunicipalitySuggestions(municipalities)
expect(ranked).toHaveLength(HOME_SEARCH_SUGGEST_LIMIT)
})
})