mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
commit
bcfd3fe598
16 changed files with 13227 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
70
src/collections/CampaignVoteSummarySnapshot.ts
Normal file
70
src/collections/CampaignVoteSummarySnapshot.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
33
src/lib/campaignHomeSummaryDelta.ts
Normal file
33
src/lib/campaignHomeSummaryDelta.ts
Normal 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}`
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
12764
src/migrations/20260801_062558_add_campaign_vote_summary_snapshot.json
Normal file
12764
src/migrations/20260801_062558_add_campaign_vote_summary_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||
}
|
||||
|
|
@ -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'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
src/utilities/campaignVoteSummarySnapshot.ts
Normal file
138
src/utilities/campaignVoteSummarySnapshot.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
33
tests/unit/campaignHomeSummaryDelta.unit.spec.ts
Normal file
33
tests/unit/campaignHomeSummaryDelta.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
28
tests/unit/campaignTimeCivilDate.unit.spec.ts
Normal file
28
tests/unit/campaignTimeCivilDate.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue