Add nucleus vote goals foundation for E1/E3.

Introduce voteGoals helpers, Metas 2026 UI card, migration, and unit tests as the first slice of scenario goals and priority work.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-19 04:35:44 -03:00
parent 7ddd0feb0f
commit 456b5d8a4e
5 changed files with 9532 additions and 0 deletions

View file

@ -0,0 +1,77 @@
import { Badge } from '@/components/ui/Badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/Progress'
import { formatElectionNumber } from '@/lib/electionInsights'
import type { NucleusPriority } from '@/lib/schemas/nucleus'
import { nucleusPriorityLabels } from '@/utilities/nucleusUi'
import { voteGoalProgressPercent, type VoteGoalsViewModel } from '@/utilities/voteGoals'
const formatGoal = (value: number | null): string =>
value == null ? '—' : formatElectionNumber(value)
export const NucleusVoteGoals = ({
voteGoals,
confirmedVoteEstimate,
priority,
}: {
voteGoals: VoteGoalsViewModel
confirmedVoteEstimate: number | null
priority: NucleusPriority
}) => {
const hasAnyGoal =
voteGoals.good != null || voteGoals.regular != null || voteGoals.minimum != null
const progressPercent = voteGoalProgressPercent(confirmedVoteEstimate, voteGoals.regular)
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<CardDescription>Metas 2026</CardDescription>
<CardTitle className="text-2xl">Cenários de votos</CardTitle>
</div>
{priority === 'alta' ? (
<Badge variant="destructive" className="shrink-0">
{nucleusPriorityLabels.alta}
</Badge>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{hasAnyGoal ? (
<dl className="grid gap-3 sm:grid-cols-3">
<div>
<dt className="text-sm text-muted-foreground">Bom</dt>
<dd className="text-2xl font-semibold tabular-nums">{formatGoal(voteGoals.good)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Regular</dt>
<dd className="text-2xl font-semibold tabular-nums">
{formatGoal(voteGoals.regular)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Mínimo</dt>
<dd className="text-2xl font-semibold tabular-nums">
{formatGoal(voteGoals.minimum)}
</dd>
</div>
</dl>
) : (
<p className="text-sm text-muted-foreground">Nenhuma meta cadastrada para este núcleo.</p>
)}
{progressPercent != null ? (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span>Estimativa confirmada vs. meta regular</span>
<span className="font-medium tabular-nums">{progressPercent}%</span>
</div>
<Progress
value={progressPercent}
aria-label={`Progresso da estimativa confirmada em relação à meta regular: ${progressPercent}%`}
/>
</div>
) : null}
</CardContent>
</Card>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TYPE "public"."enum_electoral_nucleus_priority" AS ENUM('alta', 'normal');
ALTER TABLE "electoral_nucleus" ADD COLUMN "dobradinha_notes" varchar;
ALTER TABLE "electoral_nucleus" ADD COLUMN "next_steps" varchar;
ALTER TABLE "electoral_nucleus" ADD COLUMN "vote_goals_good" numeric;
ALTER TABLE "electoral_nucleus" ADD COLUMN "vote_goals_regular" numeric;
ALTER TABLE "electoral_nucleus" ADD COLUMN "vote_goals_minimum" numeric;
ALTER TABLE "electoral_nucleus" ADD COLUMN "priority" "enum_electoral_nucleus_priority" DEFAULT 'normal';
CREATE INDEX "electoral_nucleus_priority_idx" ON "electoral_nucleus" USING btree ("priority");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP INDEX "electoral_nucleus_priority_idx";
ALTER TABLE "electoral_nucleus" DROP COLUMN "dobradinha_notes";
ALTER TABLE "electoral_nucleus" DROP COLUMN "next_steps";
ALTER TABLE "electoral_nucleus" DROP COLUMN "vote_goals_good";
ALTER TABLE "electoral_nucleus" DROP COLUMN "vote_goals_regular";
ALTER TABLE "electoral_nucleus" DROP COLUMN "vote_goals_minimum";
ALTER TABLE "electoral_nucleus" DROP COLUMN "priority";
DROP TYPE "public"."enum_electoral_nucleus_priority";`)
}

View file

@ -0,0 +1,93 @@
export type VoteGoalsFields = {
good?: number | null
regular?: number | null
minimum?: number | null
}
export type VoteGoalsViewModel = {
good: number | null
regular: number | null
minimum: number | null
}
export type VoteGoalsSumViewModel = {
good: number
regular: number
minimum: number
}
export type VoteGoalsAggregate = VoteGoalsSumViewModel & {
highPriorityCount: number
}
export const VOTE_GOALS_ORDER_ERROR_MESSAGE =
'As metas devem seguir a ordem Bom ≥ Regular ≥ Mínimo quando informadas.'
export type VoteGoalScenarioKey = keyof VoteGoalsFields
/** Returns the scenario key that breaks Bom ≥ Regular ≥ Mínimo, if any. */
export const getVoteGoalsOrderViolation = (
voteGoals: VoteGoalsFields | null | undefined,
): VoteGoalScenarioKey | null => {
if (!voteGoals) return null
const ordered = (
[
['good', voteGoals.good],
['regular', voteGoals.regular],
['minimum', voteGoals.minimum],
] as const
).filter((entry): entry is [VoteGoalScenarioKey, number] => entry[1] != null)
for (let index = 1; index < ordered.length; index += 1) {
const [, previous] = ordered[index - 1]!
const [key, current] = ordered[index]!
if (previous < current) return key
}
return null
}
export const toVoteGoalsViewModel = (voteGoals?: VoteGoalsFields | null): VoteGoalsViewModel => ({
good: voteGoals?.good ?? null,
regular: voteGoals?.regular ?? null,
minimum: voteGoals?.minimum ?? null,
})
export const voteGoalProgressPercent = (
confirmed: number | null,
regular: number | null,
): number | null => {
if (confirmed == null || regular == null || regular <= 0) return null
return Math.min(100, Math.round((confirmed / regular) * 100))
}
export const aggregateVoteGoals = (
nuclei: Array<{
voteGoals: VoteGoalsViewModel
priority?: 'alta' | 'normal' | null
}>,
): VoteGoalsAggregate => {
const totals: VoteGoalsSumViewModel = { good: 0, regular: 0, minimum: 0 }
let highPriorityCount = 0
for (const nucleus of nuclei) {
totals.good += nucleus.voteGoals.good ?? 0
totals.regular += nucleus.voteGoals.regular ?? 0
totals.minimum += nucleus.voteGoals.minimum ?? 0
if (nucleus.priority === 'alta') highPriorityCount += 1
}
return { ...totals, highPriorityCount }
}
export const sumVoteGoals = (
nuclei: Array<{ voteGoals: VoteGoalsViewModel }>,
): VoteGoalsSumViewModel => {
const { good, regular, minimum } = aggregateVoteGoals(nuclei)
return { good, regular, minimum }
}
export const countHighPriorityNuclei = (
nuclei: Array<{ priority: 'alta' | 'normal' | null }>,
): number => nuclei.reduce((count, { priority }) => count + (priority === 'alta' ? 1 : 0), 0)

View file

@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import { nucleusCreateSchema } from '@/lib/schemas/nucleus'
import { buildNucleusListWhere, parseNucleusListParams } from '@/utilities/nucleusUi'
import { toNucleusDetailViewModel } from '@/utilities/nucleusViewModels'
import {
aggregateVoteGoals,
countHighPriorityNuclei,
getVoteGoalsOrderViolation,
sumVoteGoals,
voteGoalProgressPercent,
} from '@/utilities/voteGoals'
describe('vote goals schema and aggregates', () => {
it('accepts ordered vote goals and priority on create', () => {
const parsed = nucleusCreateSchema.parse({
name: 'Núcleo Metas',
cities: ['Salvador'],
organizationKind: 'territorial',
voteGoals: { good: 1000, regular: 800, minimum: 500 },
priority: 'alta',
})
expect(parsed.voteGoals).toEqual({ good: 1000, regular: 800, minimum: 500 })
expect(parsed.priority).toBe('alta')
})
it('rejects vote goals that break Bom ≥ Regular ≥ Mínimo', () => {
const result = nucleusCreateSchema.safeParse({
name: 'Núcleo inválido',
cities: ['Salvador'],
organizationKind: 'territorial',
voteGoals: { good: 100, regular: 200 },
})
expect(result.success).toBe(false)
})
it('filters list results by alta priority', () => {
expect(parseNucleusListParams({ priority: 'alta' })).toEqual({ page: 1, priority: 'alta' })
expect(buildNucleusListWhere({ page: 1, priority: 'alta' })).toEqual({
and: [{ status: { equals: 'ativo' } }, { priority: { equals: 'alta' } }],
})
})
it('aggregates vote goals and counts high-priority nuclei', () => {
const nuclei = [
{
voteGoals: { good: 1000, regular: 800, minimum: 500 },
priority: 'alta' as const,
},
{
voteGoals: { good: 200, regular: 150, minimum: 100 },
priority: 'normal' as const,
},
]
expect(aggregateVoteGoals(nuclei)).toEqual({
good: 1200,
regular: 950,
minimum: 600,
highPriorityCount: 1,
})
expect(sumVoteGoals(nuclei)).toEqual({ good: 1200, regular: 950, minimum: 600 })
expect(countHighPriorityNuclei(nuclei)).toBe(1)
expect(voteGoalProgressPercent(400, 800)).toBe(50)
expect(voteGoalProgressPercent(null, 800)).toBeNull()
})
it('flags vote goal order violations on sparse inputs', () => {
expect(getVoteGoalsOrderViolation({ good: 100, regular: 200 })).toBe('regular')
expect(getVoteGoalsOrderViolation({ regular: 50, minimum: 100 })).toBe('minimum')
expect(getVoteGoalsOrderViolation({ good: 100, minimum: 200 })).toBe('minimum')
expect(getVoteGoalsOrderViolation({ good: 100, regular: 80 })).toBeNull()
})
it('exposes vote goals to lideranca but omits staff-only strategy notes', () => {
const view = toNucleusDetailViewModel(
{
id: 1,
name: 'Núcleo',
slug: 'nucleo',
status: 'ativo',
cities: ['Salvador'],
regions: ['Metropolitano de Salvador'],
organizationKind: 'territorial',
tseZones: [],
voteGoals: { good: 100, regular: 80, minimum: 50 },
priority: 'alta',
dobradinhaNotes: 'Negociação em curso',
nextSteps: 'Visitar prefeito',
strengths: [{ text: 'Sigiloso' }],
updatedAt: '',
createdAt: '',
},
'lideranca',
)
expect(view.voteGoals).toEqual({ good: 100, regular: 80, minimum: 50 })
expect(view.priority).toBe('alta')
expect(JSON.stringify(view.tabs)).not.toContain('Negociação em curso')
expect(JSON.stringify(view.tabs)).not.toContain('Visitar prefeito')
expect(JSON.stringify(view.tabs)).not.toContain('Sigiloso')
})
})