mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
parent
7ddd0feb0f
commit
456b5d8a4e
5 changed files with 9532 additions and 0 deletions
77
src/components/campaign/NucleusVoteGoals.tsx
Normal file
77
src/components/campaign/NucleusVoteGoals.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9232
src/migrations/20260719_054522_add_nucleus_goals_strategy.json
Normal file
9232
src/migrations/20260719_054522_add_nucleus_goals_strategy.json
Normal file
File diff suppressed because it is too large
Load diff
25
src/migrations/20260719_054522_add_nucleus_goals_strategy.ts
Normal file
25
src/migrations/20260719_054522_add_nucleus_goals_strategy.ts
Normal 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";`)
|
||||
}
|
||||
93
src/utilities/voteGoals.ts
Normal file
93
src/utilities/voteGoals.ts
Normal 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)
|
||||
105
tests/unit/voteGoals.unit.spec.ts
Normal file
105
tests/unit/voteGoals.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue