Merge pull request #90 from fsolla/cursor/pool-i12-d50dd384-d1b2

B57 — Delta 7 dias do total estimado no Início
This commit is contained in:
Francisco Solla 2026-08-01 03:49:37 -03:00 committed by GitHub
commit bcfd3fe598
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 13227 additions and 8 deletions

View file

@ -1,7 +1,7 @@
# Delta de 7 dias do total de estimativas (Início)
Status: rascunho
Atualizado em: 2026-07-29
Status: entregue
Atualizado em: 2026-08-01
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B57 — baixa prioridade / futuro)
Impeccable: B — encaixe de seta + Δ no bloco **B56** (quando existir trajetória)
Appetite: ~1,5–2 dias eng quando aberto; **snapshot diário** + reader + UI; migration leve

View file

@ -0,0 +1,70 @@
import type { CollectionConfig } from 'payload'
import { isCampaignStaff, payloadAdminOnly } from '@/utilities/campaignAccess'
/**
* Daily statewide snapshot of the B56 hero scalar (`staffVoteTotal` central).
* B57 reads T−7d for the Início delta; rows are written idempotently on the
* first staff home load of each Bahia civil day and pruned after 30 days.
*
* Hidden from admin nav — staff interact via `loadCampaignHomeSummary`; internal
* snapshot writes use an intentional admin bypass through the Local API.
*/
export const CampaignVoteSummarySnapshot: CollectionConfig = {
slug: 'campaignVoteSummarySnapshot',
labels: {
singular: 'Snapshot de estimativa',
plural: 'Snapshots de estimativa',
},
admin: {
group: 'Campanha',
hidden: () => true,
useAsTitle: 'day',
defaultColumns: ['day', 'scopeKey', 'staffVoteTotalCentral'],
},
access: {
create: payloadAdminOnly,
read: ({ req }) => isCampaignStaff(req.user),
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
fields: [
{
name: 'day',
type: 'date',
label: 'Dia (Bahia)',
required: true,
index: true,
admin: {
description: 'Dia civil America/Bahia (`aaaa-mm-dd`), armazenado como meia-noite UTC.',
date: {
pickerAppearance: 'dayOnly',
},
},
},
{
name: 'scopeKey',
type: 'text',
label: 'Escopo',
required: true,
defaultValue: 'statewide',
index: true,
admin: {
readOnly: true,
},
},
{
name: 'staffVoteTotalCentral',
type: 'number',
label: 'Total estimado (central)',
required: true,
min: 0,
},
],
indexes: [
{
fields: ['day', 'scopeKey'],
unique: true,
},
],
}

View file

@ -1,18 +1,55 @@
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from 'lucide-react'
import { Progress } from '@/components/ui/Progress'
import {
formatHomeSummaryDeltaMagnitude,
homeSummaryDeltaAriaLabel,
homeSummaryDeltaPeriodLabel,
resolveHomeSummaryDeltaDirection,
} from '@/lib/campaignHomeSummaryDelta'
import { formatElectionNumber } from '@/lib/electionFormat'
import { cn } from '@/lib/utils'
import type { CampaignHomeSummaryView } from '@/utilities/campaignDashboardData'
import {
formatGoalCoverageRatioLabel,
goalCoverageProgressPercent,
} from '@/utilities/municipality/goalCoverage'
const HomeSummaryDelta = ({ delta }: { delta: number | null }) => {
const direction = resolveHomeSummaryDeltaDirection(delta)
const magnitude = formatHomeSummaryDeltaMagnitude(delta)
return (
<span
aria-label={homeSummaryDeltaAriaLabel(delta)}
className={cn(
'inline-flex items-center gap-0.5 text-sm font-medium tabular-nums',
direction === 'up' && 'text-emerald-700 dark:text-emerald-400',
direction === 'down' && 'text-rose-700 dark:text-rose-400',
(direction === 'flat' || direction === 'unavailable') && 'text-muted-foreground',
)}
>
{direction === 'up' ? <ArrowUpIcon aria-hidden className="size-3.5 shrink-0" /> : null}
{direction === 'down' ? <ArrowDownIcon aria-hidden className="size-3.5 shrink-0" /> : null}
{direction === 'flat' ? <MinusIcon aria-hidden className="size-3.5 shrink-0" /> : null}
<span>{magnitude ?? '—'}</span>
</span>
)
}
export const CampaignHomeSummary = ({ view }: { view: CampaignHomeSummaryView }) => (
<section aria-label="Resumo da campanha" className="flex flex-col gap-4 pb-4 md:pb-6">
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">Votos estimados</p>
<p className="text-3xl font-semibold tabular-nums tracking-tight">
{formatElectionNumber(view.staffVoteTotalCentral)}
</p>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<p className="text-3xl font-semibold tabular-nums tracking-tight">
{formatElectionNumber(view.staffVoteTotalCentral)}
</p>
<HomeSummaryDelta delta={view.homeSummaryDelta} />
</div>
{view.homeSummaryDelta !== null ? (
<p className="text-xs text-muted-foreground">{homeSummaryDeltaPeriodLabel}</p>
) : null}
</div>
<div className="flex max-w-md flex-col gap-1.5">

View file

@ -0,0 +1,33 @@
import { formatElectionNumber } from '@/lib/electionFormat'
/** B57 — client-safe contract for the Início hero delta (window + copy). */
export const HOME_SUMMARY_DELTA_WINDOW_DAYS = 7
export const homeSummaryDeltaPeriodLabel = `nos últimos ${HOME_SUMMARY_DELTA_WINDOW_DAYS} dias`
export type HomeSummaryDeltaDirection = 'up' | 'down' | 'flat' | 'unavailable'
export const resolveHomeSummaryDeltaDirection = (
delta: number | null,
): HomeSummaryDeltaDirection => {
if (delta === null) return 'unavailable'
if (delta === 0) return 'flat'
return delta > 0 ? 'up' : 'down'
}
/** Absolute magnitude for display; null when unavailable; "0" when flat. */
export const formatHomeSummaryDeltaMagnitude = (delta: number | null): string | null => {
if (delta === null) return null
if (delta === 0) return formatElectionNumber(0)
return formatElectionNumber(Math.abs(delta))
}
export const homeSummaryDeltaAriaLabel = (delta: number | null): string => {
const period = homeSummaryDeltaPeriodLabel
if (delta === null) return `Variação ${period} indisponível`
if (delta === 0) return `Sem variação ${period}`
const formatted = formatElectionNumber(Math.abs(delta))
return delta > 0
? `Aumento de ${formatted} votos ${period}`
: `Queda de ${formatted} votos ${period}`
}

View file

@ -124,6 +124,25 @@ const bahiaDateDisplayFormatter = new Intl.DateTimeFormat('pt-BR', {
/** Formats an instant as `dd/mm` in Bahia civil time — the day the staff names things by. */
export const formatBahiaDayLabel = (date: Date): string => bahiaDateDisplayFormatter.format(date)
/** UTC midnight ISO for a Bahia civil date anchor (`aaaa-mm-dd`). */
export const civilDateToUtcMidnightIso = (civilDate: string): string => `${civilDate}T00:00:00.000Z`
/** Calendar-day difference between two Bahia civil dates (`later − earlier`). */
export const civilDateDaysBetween = (earlier: string, later: string): number => {
const [y1, m1, d1] = earlier.split('-').map(Number)
const [y2, m2, d2] = later.split('-').map(Number)
const msPerDay = 86_400_000
return Math.round((Date.UTC(y2, m2 - 1, d2) - Date.UTC(y1, m1 - 1, d1)) / msPerDay)
}
/** Subtract whole civil days from a Bahia civil date anchor (`aaaa-mm-dd`). */
export const subtractBahiaCivilDays = (civilDate: string, days: number): string => {
const [year, month, day] = civilDate.split('-').map(Number)
const anchor = new Date(Date.UTC(year, month - 1, day))
anchor.setUTCDate(anchor.getUTCDate() - days)
return `${anchor.getUTCFullYear()}-${pad(anchor.getUTCMonth() + 1)}-${pad(anchor.getUTCDate())}`
}
/**
* Latest of two ISO timestamps, ignoring nulls. String comparison is only
* sound because every writer here produces fixed-width UTC — Payload's own

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,32 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "campaign_vote_summary_snapshot" (
"id" serial PRIMARY KEY NOT NULL,
"day" timestamp(3) with time zone NOT NULL,
"scope_key" varchar DEFAULT 'statewide' NOT NULL,
"staff_vote_total_central" numeric NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "campaign_vote_summary_snapshot_id" integer;
CREATE INDEX "campaign_vote_summary_snapshot_day_idx" ON "campaign_vote_summary_snapshot" USING btree ("day");
CREATE INDEX "campaign_vote_summary_snapshot_scope_key_idx" ON "campaign_vote_summary_snapshot" USING btree ("scope_key");
CREATE INDEX "campaign_vote_summary_snapshot_updated_at_idx" ON "campaign_vote_summary_snapshot" USING btree ("updated_at");
CREATE INDEX "campaign_vote_summary_snapshot_created_at_idx" ON "campaign_vote_summary_snapshot" USING btree ("created_at");
CREATE UNIQUE INDEX "day_scopeKey_idx" ON "campaign_vote_summary_snapshot" USING btree ("day","scope_key");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_campaign_vote_summary_snaps_fk" FOREIGN KEY ("campaign_vote_summary_snapshot_id") REFERENCES "public"."campaign_vote_summary_snapshot"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_campaign_vote_summary_snap_idx" ON "payload_locked_documents_rels" USING btree ("campaign_vote_summary_snapshot_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "campaign_vote_summary_snapshot" DISABLE ROW LEVEL SECURITY;
DROP TABLE "campaign_vote_summary_snapshot" CASCADE;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_campaign_vote_summary_snaps_fk";
DROP INDEX "payload_locked_documents_rels_campaign_vote_summary_snap_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "campaign_vote_summary_snapshot_id";`)
}

View file

@ -37,6 +37,7 @@ import * as migration_20260728_041958_add_campaign_webauthn_credentials from './
import * as migration_20260730_010601_simplify_municipality_signal_fields from './20260730_010601_simplify_municipality_signal_fields';
import * as migration_20260730_043306_simplify_leadership_fields from './20260730_043306_simplify_leadership_fields';
import * as migration_20260731_014319_add_campaign_notifications from './20260731_014319_add_campaign_notifications';
import * as migration_20260801_062558_add_campaign_vote_summary_snapshot from './20260801_062558_add_campaign_vote_summary_snapshot';
export const migrations = [
{
@ -232,6 +233,11 @@ export const migrations = [
{
up: migration_20260731_014319_add_campaign_notifications.up,
down: migration_20260731_014319_add_campaign_notifications.down,
name: '20260731_014319_add_campaign_notifications'
name: '20260731_014319_add_campaign_notifications',
},
{
up: migration_20260801_062558_add_campaign_vote_summary_snapshot.up,
down: migration_20260801_062558_add_campaign_vote_summary_snapshot.down,
name: '20260801_062558_add_campaign_vote_summary_snapshot'
},
];

View file

@ -74,6 +74,7 @@ export interface Config {
notification: Notification;
pushSubscription: PushSubscription;
campaignInvite: CampaignInvite;
campaignVoteSummarySnapshot: CampaignVoteSummarySnapshot;
municipality: Municipality;
leadership: Leadership;
organization: Organization;
@ -112,6 +113,7 @@ export interface Config {
notification: NotificationSelect<false> | NotificationSelect<true>;
pushSubscription: PushSubscriptionSelect<false> | PushSubscriptionSelect<true>;
campaignInvite: CampaignInviteSelect<false> | CampaignInviteSelect<true>;
campaignVoteSummarySnapshot: CampaignVoteSummarySnapshotSelect<false> | CampaignVoteSummarySnapshotSelect<true>;
municipality: MunicipalitySelect<false> | MunicipalitySelect<true>;
leadership: LeadershipSelect<false> | LeadershipSelect<true>;
organization: OrganizationSelect<false> | OrganizationSelect<true>;
@ -603,6 +605,21 @@ export interface Organization {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignVoteSummarySnapshot".
*/
export interface CampaignVoteSummarySnapshot {
id: number;
/**
* Dia civil America/Bahia (`aaaa-mm-dd`), armazenado como meia-noite UTC.
*/
day: string;
scopeKey: string;
staffVoteTotalCentral: number;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "votePledge".
@ -1252,6 +1269,10 @@ export interface PayloadLockedDocument {
relationTo: 'campaignInvite';
value: number | CampaignInvite;
} | null)
| ({
relationTo: 'campaignVoteSummarySnapshot';
value: number | CampaignVoteSummarySnapshot;
} | null)
| ({
relationTo: 'municipality';
value: number | Municipality;
@ -1502,6 +1523,17 @@ export interface CampaignInviteSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaignVoteSummarySnapshot_select".
*/
export interface CampaignVoteSummarySnapshotSelect<T extends boolean = true> {
day?: T;
scopeKey?: T;
staffVoteTotalCentral?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "municipality_select".
@ -2318,6 +2350,7 @@ export interface TaskCreateCollectionExport {
| 'notification'
| 'pushSubscription'
| 'campaignInvite'
| 'campaignVoteSummarySnapshot'
| 'municipality'
| 'leadership'
| 'organization'

View file

@ -14,6 +14,7 @@ import { AllocationDecision } from './collections/AllocationDecision'
import { CampaignDemand } from './collections/CampaignDemand'
import { CampaignInvite } from './collections/CampaignInvite'
import { CampaignUser } from './collections/CampaignUser'
import { CampaignVoteSummarySnapshot } from './collections/CampaignVoteSummarySnapshot'
import { CampaignWebAuthnCredential } from './collections/CampaignWebAuthnCredential'
import { Consent } from './collections/Consent'
import { Contact } from './collections/Contact'
@ -92,6 +93,7 @@ export default buildConfig({
Notification,
PushSubscription,
CampaignInvite,
CampaignVoteSummarySnapshot,
Municipality,
Leadership,
Organization,

View file

@ -2,10 +2,16 @@ import 'server-only'
import type { Payload } from 'payload'
import { isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import type { AccessibleMunicipality } from '@/lib/municipalityProximity'
import { relationshipId, requireRelationshipId } from '@/lib/relationship'
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
import type { CampaignUser, VotePledge } from '@/payload-types'
import {
loadCampaignHomeSummaryDelta,
loadStatewideStaffVoteTotalCentral,
recordCampaignVoteSummarySnapshotIfNeeded,
} from '@/utilities/campaignVoteSummarySnapshot'
import {
pickDashboardPriorityMunicipalities,
type DashboardPriorityMunicipality,
@ -224,9 +230,11 @@ export const getCampaignDashboardData = async (
/** Staff Início: central staff vote total + E8 aggregate coverage (Quadro KPI contract). */
export type CampaignHomeSummaryView = Pick<StaffDashboardView, 'goalCoverage'> & {
staffVoteTotalCentral: StaffDashboardView['staffVoteTotalByScenario']['central']
/** Statewide Δ over 7 Bahia civil days; null until enough daily snapshots exist. */
homeSummaryDelta: number | null
}
/** Slim loader for `/campanha` — scope + rollup + coverage only (no map, suggestions, or queues). */
/** Slim loader for `/campanha` — scope + rollup + coverage + B57 delta. */
export const loadCampaignHomeSummary = async (
payload: Payload,
user: CampaignUser,
@ -239,8 +247,19 @@ export const loadCampaignHomeSummary = async (
scope.pledgeAggregates,
)
const statewideTotal = isUnrestrictedCampaignRole(user.role)
? rollup.staffVoteTotalByScenario.central
: await loadStatewideStaffVoteTotalCentral(payload)
// B57 option A: hero stays actor-scoped; delta compares statewide live vs T−7d snapshot.
const [homeSummaryDelta] = await Promise.all([
loadCampaignHomeSummaryDelta(payload, statewideTotal),
recordCampaignVoteSummarySnapshotIfNeeded(payload, statewideTotal),
])
return {
staffVoteTotalCentral: rollup.staffVoteTotalByScenario.central,
goalCoverage: goalCoverageBundle.aggregateByScenario.central,
homeSummaryDelta,
}
}

View file

@ -0,0 +1,138 @@
import 'server-only'
/**
* B57 daily statewide vote-total snapshots. All Local API writes use an
* intentional admin bypass — rows are internal aggregates, not user-facing CRUD.
*/
import type { Payload } from 'payload'
import { HOME_SUMMARY_DELTA_WINDOW_DAYS } from '@/lib/campaignHomeSummaryDelta'
import {
civilDateToUtcMidnightIso,
formatBahiaCivilDate,
subtractBahiaCivilDays,
} from '@/lib/campaignTime'
import type { Municipality } from '@/payload-types'
import { aggregateAllPledgesByMunicipality } from '@/utilities/votePledgeData'
import { rollupMunicipalityStaffVotes } from '@/utilities/votePledgeViews'
const SNAPSHOT_COLLECTION = 'campaignVoteSummarySnapshot' as const
const SNAPSHOT_SCOPE_STATEWIDE = 'statewide'
const SNAPSHOT_RETENTION_DAYS = 30
const STATEWIDE_MUNICIPALITY_SELECT = {
expectedVotes: true,
} as const
const pruneOldCampaignVoteSummarySnapshots = async (
payload: Payload,
today: string,
): Promise<void> => {
const cutoffDay = subtractBahiaCivilDays(today, SNAPSHOT_RETENTION_DAYS)
await payload.delete({
collection: SNAPSHOT_COLLECTION,
where: {
day: { less_than: civilDateToUtcMidnightIso(cutoffDay) },
},
overrideAccess: true,
})
}
/**
* Statewide B56 hero scalar for the daily snapshot series (B57 option A).
* Intentional admin bypass: internal aggregate not tied to the requesting actor's
* municipality scope — the snapshot is always statewide.
*/
export const loadStatewideStaffVoteTotalCentral = async (payload: Payload): Promise<number> => {
const [municipalities, pledgeAggregates] = await Promise.all([
payload
.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
select: STATEWIDE_MUNICIPALITY_SELECT,
overrideAccess: true,
})
.then((result) => result.docs as Pick<Municipality, 'id' | 'expectedVotes'>[]),
aggregateAllPledgesByMunicipality(payload),
])
const rollup = rollupMunicipalityStaffVotes(municipalities, pledgeAggregates)
return rollup.staffVoteTotalByScenario.central
}
/** Idempotent daily write — first staff home load of the Bahia civil day wins. */
export const recordCampaignVoteSummarySnapshotIfNeeded = async (
payload: Payload,
staffVoteTotalCentral: number,
): Promise<void> => {
const today = formatBahiaCivilDate(new Date())
const dayIso = civilDateToUtcMidnightIso(today)
const existing = await payload.find({
collection: SNAPSHOT_COLLECTION,
where: {
and: [{ day: { equals: dayIso } }, { scopeKey: { equals: SNAPSHOT_SCOPE_STATEWIDE } }],
},
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
if (existing.docs.length > 0) return
try {
await payload.create({
collection: SNAPSHOT_COLLECTION,
data: {
day: dayIso,
scopeKey: SNAPSHOT_SCOPE_STATEWIDE,
staffVoteTotalCentral,
},
overrideAccess: true,
})
await pruneOldCampaignVoteSummarySnapshots(payload, today)
} catch {
const raced = await payload.find({
collection: SNAPSHOT_COLLECTION,
where: {
and: [{ day: { equals: dayIso } }, { scopeKey: { equals: SNAPSHOT_SCOPE_STATEWIDE } }],
},
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
if (raced.docs.length === 0) throw new Error('Falha ao gravar snapshot diário de estimativa.')
}
}
/** Live statewide total minus the snapshot on T−7d Bahia civil days; null when the anchor row is missing. */
export const loadCampaignHomeSummaryDelta = async (
payload: Payload,
liveStatewideTotal: number,
): Promise<number | null> => {
const today = formatBahiaCivilDate(new Date())
const targetDayIso = civilDateToUtcMidnightIso(
subtractBahiaCivilDays(today, HOME_SUMMARY_DELTA_WINDOW_DAYS),
)
const target = await payload.find({
collection: SNAPSHOT_COLLECTION,
where: {
and: [{ day: { equals: targetDayIso } }, { scopeKey: { equals: SNAPSHOT_SCOPE_STATEWIDE } }],
},
depth: 0,
limit: 1,
pagination: false,
overrideAccess: true,
})
const snapshot = target.docs[0]
if (!snapshot) return null
return liveStatewideTotal - snapshot.staffVoteTotalCentral
}

View file

@ -13,6 +13,7 @@ describe('CampaignHomeSummary', () => {
<CampaignHomeSummary
view={{
staffVoteTotalCentral: 125_430,
homeSummaryDelta: 5_000,
goalCoverage: {
goal: 200_000,
committed: 80_000,
@ -26,6 +27,8 @@ describe('CampaignHomeSummary', () => {
expect(screen.getByRole('region', { name: 'Resumo da campanha' })).toBeTruthy()
expect(screen.getByText('Votos estimados')).toBeTruthy()
expect(screen.getByText('125.430')).toBeTruthy()
expect(screen.getByLabelText('Aumento de 5.000 votos nos últimos 7 dias')).toBeTruthy()
expect(screen.getByText('nos últimos 7 dias')).toBeTruthy()
expect(screen.getByText('Cobertura por lideranças')).toBeTruthy()
expect(screen.getByText('40%')).toBeTruthy()
expect(screen.getByRole('progressbar', { name: 'Cobertura por lideranças: 40%' })).toBeTruthy()
@ -36,6 +39,7 @@ describe('CampaignHomeSummary', () => {
<CampaignHomeSummary
view={{
staffVoteTotalCentral: 0,
homeSummaryDelta: null,
goalCoverage: {
goal: 0,
committed: 0,
@ -46,7 +50,7 @@ describe('CampaignHomeSummary', () => {
/>,
)
expect(screen.getByText('—')).toBeTruthy()
expect(screen.getByLabelText('Variação nos últimos 7 dias indisponível')).toBeTruthy()
expect(screen.queryByRole('progressbar')).toBeNull()
})
})

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import {
formatHomeSummaryDeltaMagnitude,
homeSummaryDeltaAriaLabel,
resolveHomeSummaryDeltaDirection,
} from '@/lib/campaignHomeSummaryDelta'
describe('campaignHomeSummaryDelta', () => {
it('classifies direction from signed delta', () => {
expect(resolveHomeSummaryDeltaDirection(1_500)).toBe('up')
expect(resolveHomeSummaryDeltaDirection(-200)).toBe('down')
expect(resolveHomeSummaryDeltaDirection(0)).toBe('flat')
expect(resolveHomeSummaryDeltaDirection(null)).toBe('unavailable')
})
it('formats magnitude as absolute pt-BR integer votes', () => {
expect(formatHomeSummaryDeltaMagnitude(12_345)).toBe('12.345')
expect(formatHomeSummaryDeltaMagnitude(-12_345)).toBe('12.345')
expect(formatHomeSummaryDeltaMagnitude(null)).toBeNull()
})
it('shows zero magnitude for a flat delta', () => {
expect(formatHomeSummaryDeltaMagnitude(0)).toBe('0')
})
it('spells out aria labels with direction and magnitude', () => {
expect(homeSummaryDeltaAriaLabel(500)).toBe('Aumento de 500 votos nos últimos 7 dias')
expect(homeSummaryDeltaAriaLabel(-500)).toBe('Queda de 500 votos nos últimos 7 dias')
expect(homeSummaryDeltaAriaLabel(0)).toBe('Sem variação nos últimos 7 dias')
expect(homeSummaryDeltaAriaLabel(null)).toBe('Variação nos últimos 7 dias indisponível')
})
})

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import {
civilDateDaysBetween,
civilDateToUtcMidnightIso,
formatBahiaCivilDate,
subtractBahiaCivilDays,
} from '@/lib/campaignTime'
describe('Bahia civil date helpers (B57)', () => {
it('converts civil dates to UTC midnight ISO anchors', () => {
expect(civilDateToUtcMidnightIso('2026-08-01')).toBe('2026-08-01T00:00:00.000Z')
})
it('subtracts whole civil days', () => {
expect(subtractBahiaCivilDays('2026-08-08', 7)).toBe('2026-08-01')
})
it('counts calendar days between civil dates', () => {
expect(civilDateDaysBetween('2026-08-01', '2026-08-08')).toBe(7)
})
it('round-trips formatBahiaCivilDate through subtract', () => {
const today = formatBahiaCivilDate(new Date('2026-08-08T15:00:00.000Z'))
expect(today).toBe('2026-08-08')
expect(subtractBahiaCivilDays(today, 7)).toBe('2026-08-01')
})
})

View file

@ -377,6 +377,7 @@ describe('src/utilities top-level is pinned', () => {
'campaignColumnVisibilityCookie.ts',
'campaignConsent.ts',
'campaignDashboardData.ts',
'campaignVoteSummarySnapshot.ts',
'campaignDemandData.ts',
'campaignEntityActions.ts',
'campaignFormActionError.ts',