mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
commit
7f7c707d4d
8 changed files with 398 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -118,5 +118,6 @@ export const homeSearchHasAnyHits = (data: HomeSearchSuccessResponse): boolean =
|
|||
|
||||
export type WizardMunicipalitySearchSuccessResponse = {
|
||||
status: 'success'
|
||||
resultKind?: 'wizard-suggest'
|
||||
municipalities: HomeSearchMunicipalityHit[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}),
|
||||
])
|
||||
|
|
|
|||
122
src/lib/wizardMunicipalitySuggest.ts
Normal file
122
src/lib/wizardMunicipalitySuggest.ts
Normal 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)
|
||||
|
|
@ -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)!)),
|
||||
}
|
||||
}
|
||||
53
tests/int/wizardMunicipalitySuggestions.int.spec.ts
Normal file
53
tests/int/wizardMunicipalitySuggestions.int.spec.ts
Normal 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,
|
||||
)
|
||||
})
|
||||
})
|
||||
127
tests/unit/wizardMunicipalitySuggest.unit.spec.ts
Normal file
127
tests/unit/wizardMunicipalitySuggest.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue