E11 post-rebase simplify: clamp the composed rationale, extract firstFormActionMessage at its 3rd call site, keep metrics server-side.

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-28 14:03:25 -03:00
parent 189ad7a349
commit 8cae4adae9
13 changed files with 125 additions and 52 deletions

File diff suppressed because one or more lines are too long

View file

@ -32,7 +32,19 @@ Três revisores paralelos (qualidade, performance, reuso); tudo aplicado sem mud
- **Card adotou os primitivos `ui/field`** (`FieldSet`/`FieldLegend`/`FieldLabel`, idioma do `ActivityForm`) no lugar de fieldset/legend/label à mão; `DAY_MS` (3 declarações no delivery) e `SUGGESTION_TEXT_MAX_LENGTH` unificados no catálogo; `formatSilenceAgeLabel` mudou para `municipalitySignal.ts`, ao lado do formatter irmão de idade de sinal.
- **Correção de registro:** as suítes são **685 unit / 453 int** — o número anterior desta página ("suíte 453") era só a fase int.
**Recomendações que ficaram (maiores que o cleanup):** consolidar as ~4 leituras de pledges/lideranças que a `OverviewTab` do detalhe faz por três loaders não coordenados (conta da cadeira + elegibilidade + sugestões — composição pré-existente, o E11 só somou o 4º caminho barato); extrair `firstErrorMessage`/`toMessageOnlyState` (card + apoiadores) para `campaignFormActionError.ts` **no 3º call site** — hoje arrastaria zod para o bundle do card; mover `formatDominanceAgainstOwnStandard` para `lib/` (cadeia com `formatRatioAsPercentLabel`) quando um segundo módulo `lib` precisar dele; os memos de estatística do catálogo (quartil superior, padrão intracampo estadual) migram para `municipalityPotential.ts` quando ganharem um 2º consumidor (precedente E13).
**Recomendações que ficaram (maiores que o cleanup):** consolidar as ~4 leituras de pledges/lideranças que a `OverviewTab` do detalhe faz por três loaders não coordenados (conta da cadeira + elegibilidade + sugestões — composição pré-existente, o E11 só somou o 4º caminho barato); ~~extrair `firstErrorMessage`/`toMessageOnlyState` no 3º call site~~ (feito na rodada 2 — o rebase entregou o 3º site); mover `formatDominanceAgainstOwnStandard` para `lib/` (cadeia com `formatRatioAsPercentLabel`) quando um segundo módulo `lib` precisar dele; os memos de estatística do catálogo (quartil superior, padrão intracampo estadual) migram para `municipalityPotential.ts` quando ganharem um 2º consumidor (precedente E13).
### Rodada 2 — pós-rebase sobre main (2026-07-28)
O rebase trouxe B17/B29/B32+/B37/B40; os três revisores confirmaram que **nenhuma premissa do E11 morreu** (o card corretamente NÃO adota a máquina de célula do B32+ — formulários inline com submit explícito não têm o problema "fechar desmonta o Alert"; o guard de convenções aceita `suggestionFormActions.ts`; o cache hit do scope sobreviveu — verificado o mecanismo da chave `JSON.stringify(where)`), mas o rebase **entregou o 3º call site** da extração adiada na rodada 1: `RelationChipCell` (B37, agora em main) extrai a mensagem do mesmo `CampaignFormActionState`. Aplicado:
- **P2 real — `rationale` composta estourava o cap de 2000 da collection:** nota no cap + prefixo do rótulo do menu > 2000 → Payload rejeitava o create e o usuário via o erro genérico, sem retry possível. Clamp `slice(0, SUGGESTION_TEXT_MAX_LENGTH)` (o prefixo carrega a decisão; só uma nota máxima perde a cauda) + caso int pinando (nota de 2000 chars grava com rationale = 2000).
- **`firstFormActionMessage`** em `utilities/campaignFormFields.ts` (client-safe, dono de `fieldError`) — adotado no card, no `RelationChipCell` (que antes ignorava `fieldErrors`) e no `toMessageOnlyState` de apoiadores.
- **Um canal por falha:** o card anunciava em dois (linha `role="alert"` + toast) — o card nunca desmonta na falha, então a linha inline basta; o toast ficou só para sucesso (o card sai da fila).
- **`metrics` não viaja mais ao cliente** (~209 B/card): o painel monta `SuggestionCardData` campo a campo; o view model completo (com metrics para o snapshot da action) fica no servidor.
- Miudezas: `SUGGESTION_OUTCOMES` exportado do schema (3ª grafia da lista morreu); fallback de snapshot malformado e `decisionDays` derivados de `Math.min/max` sobre os níveis (recalibração não inverte silenciosamente); `metrics` 100% lidos de `input` (contrato "os números sobre os quais a decisão foi tomada" provável por construção); IIFE de `fieldShareOfValid2022` virou irmã nomeada de `lqForYear`; `DAY_MS` do int spec importado do catálogo.
**Ficou (registrado):** juntar as 6 leituras do avaliador num estágio único para ator irrestrito; a tripla leitura de pledges da `OverviewTab` (pré-existente, acima).
## Revisões da auditoria (2026-07-28)

View file

@ -18,6 +18,7 @@ import {
mapCampaignFormActionError,
type CampaignFormErrorState,
} from '@/utilities/campaignFormActionError'
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
export type SupporterVoteIntentionFormState = {
status?: 'success'
@ -36,14 +37,8 @@ const safeVoteIntentionMessages = [
] as const
const toMessageOnlyState = (mapped: CampaignFormErrorState<unknown>): { message?: string } => {
if (mapped.message) return { message: mapped.message }
if (!mapped.fieldErrors) return {}
const firstFieldMessage = Object.values(mapped.fieldErrors)
.flat()
.find((message) => message.length > 0)
return firstFieldMessage ? { message: firstFieldMessage } : {}
const message = firstFormActionMessage(mapped)
return message ? { message } : {}
}
export const setSupporterVoteIntentionFormAction = async (

View file

@ -7,15 +7,13 @@ import {
requiredFormText,
requiredRelationshipFormValue,
} from '@/lib/formData'
import { SUGGESTION_STALE_MESSAGE } from '@/lib/schemas/suggestion'
import { SUGGESTION_OUTCOMES, SUGGESTION_STALE_MESSAGE } from '@/lib/schemas/suggestion'
import { suggestionPatternIds } from '@/lib/suggestionCatalog'
import {
runCampaignFormAction,
type CampaignFormActionState,
} from '@/utilities/campaignFormActionError'
const SUGGESTION_OUTCOMES = ['aceita', 'descarta', 'adiada'] as const
/**
* E11 — accept / postpone / dismiss one suggestion card. Lives at the `(app)`
* segment root because the SAME card renders on the two staff surfaces

View file

@ -12,6 +12,7 @@ import {
DAY_MS,
getSuggestionPattern,
isSuggestionSuppressedByDecision,
SUGGESTION_TEXT_MAX_LENGTH,
suggestionPostponeDays,
} from '@/lib/suggestionCatalog'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate'
@ -62,12 +63,17 @@ export const resolveSuggestionRecord = async (
? (pattern.menu.find((action) => action.id === data.chosenActionId) ?? null)
: null
const note = data.note?.trim()
const rationale =
const composedRationale =
data.outcome === 'aceita'
? `${chosenAction?.label ?? 'Ação do menu'}${note ? ` — ${note}` : ''}`
: data.outcome === 'adiada'
? `Adiada por ${postponeDays} dias.${note ? ` ${note}` : ''}`
: (note ?? 'Descartada — leitura alternativa registrada.')
// The note alone may already be at the cap the collection enforces, so the
// composed prefix (menu label / postpone sentence) can push past it — and a
// rejected create would read as a generic error no retry could fix. The
// prefix carries the decision; only a maximal note loses its tail.
const rationale = composedRationale.slice(0, SUGGESTION_TEXT_MAX_LENGTH)
const decision = await withPayloadTransaction(
payload,

View file

@ -23,6 +23,7 @@ import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/Popover'
import { sameIdSet } from '@/lib/sameIdSet'
import { cn } from '@/lib/utils'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
const COLLAPSED_CHIP_ROWS = 3
/** Matches `gap-1.5` on the chip row, and `max-h-18` (72px) is this math at a 20px chip. */
@ -350,7 +351,7 @@ export const RelationChipCell = ({
// Undo only THIS delta: reverting to the server baseline would also wipe
// a sibling toggle from the same burst that already saved.
setOptimistic((current) => withDelta(current ?? latestIds.current, changedIds, !assigned))
const message = result.message ?? updateErrorMessage
const message = firstFormActionMessage(result) ?? updateErrorMessage
setFeedback({ kind: 'error', message })
toast.error(message)
})

View file

@ -18,6 +18,7 @@ import {
type SuggestionTriageLevel,
} from '@/lib/suggestionCatalog'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
import { firstFormActionMessage } from '@/utilities/campaignFormFields'
/** The slice of the evaluator's view model the card renders — texts come from the catalog. */
export type SuggestionCardData = {
@ -36,15 +37,6 @@ type SuggestionResolveAction = (
const GENERIC_ERROR_MESSAGE = 'Não foi possível registrar a decisão. Tente novamente.'
const firstErrorMessage = (state: CampaignFormActionState): string => {
if ('message' in state && state.message) return state.message
if ('fieldErrors' in state && state.fieldErrors) {
const first = Object.values(state.fieldErrors).flat()[0]
if (first) return first
}
return GENERIC_ERROR_MESSAGE
}
/**
* E11 — one triggered pattern as a decision card: the reading, the observed
* facts, the ordered action menu, and the three ways a human answers it.
@ -103,16 +95,16 @@ export const SuggestionCard = ({
}
startTransition(async () => {
const result = await resolveAction({}, formData)
if ('status' in result && result.status === 'success') {
if (result.status === 'success') {
// The card leaves the queue when the revalidated RSC lands — the same
// transition — so the toast is the persistent copy of what happened.
toast.success(result.message)
setMode(null)
return
}
const message = firstErrorMessage(result)
setErrorMessage(message)
toast.error(message)
// One channel only: the card never unmounts on failure, so the inline
// `role="alert"` line is the announcement — a toast would say it twice.
setErrorMessage(firstFormActionMessage(result) ?? GENERIC_ERROR_MESSAGE)
})
}

View file

@ -82,15 +82,34 @@ export const SuggestionsPanel = ({
{suggestions.length ? (
<ul className="m-0 flex list-none flex-col gap-3 p-0 [&>li]:mt-0">
{suggestions.map((suggestion) => (
<li key={`${suggestion.municipalityID}:${suggestion.patternId}`}>
<SuggestionCard
suggestion={suggestion}
showMunicipality={showMunicipality}
resolveAction={resolveAction}
/>
</li>
))}
{suggestions.map(
({
municipalityID,
municipalityName,
municipalitySlug,
patternId,
triageLevel,
factors,
}) => (
<li key={`${municipalityID}:${patternId}`}>
<SuggestionCard
// Picked field by field: the loader's view model also carries the
// snapshot `metrics`, which only the server action reads — spreading
// the whole object would serialize them into the RSC payload.
suggestion={{
municipalityID,
municipalityName,
municipalitySlug,
patternId,
triageLevel,
factors,
}}
showMunicipality={showMunicipality}
resolveAction={resolveAction}
/>
</li>
),
)}
</ul>
) : (
emptyState

View file

@ -11,6 +11,9 @@ import {
export const SUGGESTION_STALE_MESSAGE =
'A sugestão não está mais ativa — a fila foi atualizada. Recarregue para ver o estado atual.'
/** The three outcomes a card can record — one spelling for zod and the form boundary. */
export const SUGGESTION_OUTCOMES = ['aceita', 'descarta', 'adiada'] as const
/**
* E11 — one recorded decision about one triggered pattern. What each outcome
* requires is schema, not form courtesy: an acceptance without the chosen
@ -21,7 +24,7 @@ export const suggestionDecisionSchema = z
.object({
municipality: positiveRelationshipId,
patternId: z.enum(suggestionPatternIds),
outcome: z.enum(['aceita', 'descarta', 'adiada']),
outcome: z.enum(SUGGESTION_OUTCOMES),
/** The menu action taken — required (and validated against the menu) on `aceita`. */
chosenActionId: z.string().trim().max(120).optional(),
note: z.string().trim().max(SUGGESTION_TEXT_MAX_LENGTH).optional(),

View file

@ -147,6 +147,11 @@ export const SUGGESTION_SUPPRESSION_DAYS = {
export const suggestionPostponeDays = (level: SuggestionTriageLevel): number =>
level >= 4 ? 14 : 7
/** Derived over every level so a recalibration cannot silently invert them. */
const TRIAGE_LEVELS: readonly SuggestionTriageLevel[] = [1, 2, 3, 4, 5]
const SHORTEST_POSTPONE_DAYS = Math.min(...TRIAGE_LEVELS.map(suggestionPostponeDays))
const LONGEST_POSTPONE_DAYS = Math.max(...TRIAGE_LEVELS.map(suggestionPostponeDays))
/** Windows the evaluator uses when it assembles signal/agenda inputs. */
export const SUGGESTION_INPUT_WINDOWS = {
/** Days an adversary signal stays "recent" for P1. */
@ -162,7 +167,7 @@ export const SUGGESTION_INPUT_WINDOWS = {
decisionDays: Math.max(
SUGGESTION_SUPPRESSION_DAYS.aceita,
SUGGESTION_SUPPRESSION_DAYS.descarta,
suggestionPostponeDays(5),
LONGEST_POSTPONE_DAYS,
),
} as const
@ -206,7 +211,7 @@ export const isSuggestionSuppressedByDecision = (
const untilMs = typeof untilRaw === 'string' ? new Date(untilRaw).getTime() : Number.NaN
if (!Number.isNaN(untilMs)) return now.getTime() < untilMs
// Malformed snapshot: fall back to the shortest postpone rather than never expiring.
return now.getTime() < decidedAtMs + 7 * DAY_MS
return now.getTime() < decidedAtMs + SHORTEST_POSTPONE_DAYS * DAY_MS
}
if (decision.outcome === 'aceita') {
return now.getTime() < decidedAtMs + SUGGESTION_SUPPRESSION_DAYS.aceita * DAY_MS

View file

@ -3,6 +3,22 @@ export const fieldError = (
field: string,
): string | undefined => fieldErrors?.[field]?.[0]
/**
* Flatten a form-action failure state to ONE message for controls that render
* a single line (suggestion cards, chip cells, the supporter intention
* control): the top-level message when the action set one, otherwise the
* first non-empty field error.
*/
export const firstFormActionMessage = (state: {
message?: string
fieldErrors?: Record<string, string[]>
}): string | undefined => {
if (state.message) return state.message
return Object.values(state.fieldErrors ?? {})
.flat()
.find((message) => message.length > 0)
}
export const errorProps = (
fieldErrors: Record<string, string[]> | undefined,
field: string,

View file

@ -161,6 +161,13 @@ const lqForYear = (baseline: MunicipalityFederalBaseline, year: number): number
return own / valid / (totals.ownVotes / totals.validVotes)
}
/** Campo federal votes ÷ valid votes, 2022 — P3's "captura agregada do campo". */
const fieldShareOfValid2022 = (baseline: MunicipalityFederalBaseline): number | null => {
const valid = baseline.validVotesByYear[String(ELECTION_YEAR_2022)] ?? 0
const campo = baseline.campoFederalVotesByYear[String(ELECTION_YEAR_2022)] ?? 0
return valid > 0 ? campo / valid : null
}
type LatestDecision = Pick<AllocationDecision, 'outcome' | 'snapshot' | 'createdAt'>
const compareSuggestions = (
@ -372,11 +379,7 @@ export const loadMunicipalitySuggestions = async (
uncapturedFieldVotes: uncapturedFieldVotes(baseline),
uncapturedFieldVotesCut: catalogMedianUncapturedFieldVotes(),
captureRate2022: potential.captureRate2022,
fieldShareOfValid2022: (() => {
const valid = baseline.validVotesByYear[String(ELECTION_YEAR_2022)] ?? 0
const campo = baseline.campoFederalVotesByYear[String(ELECTION_YEAR_2022)] ?? 0
return valid > 0 ? campo / valid : null
})(),
fieldShareOfValid2022: fieldShareOfValid2022(baseline),
intraFieldShare2022: potential.intraFieldShareByYear[ELECTION_YEAR_2022] ?? null,
intraFieldShareStateStandard2022: intraFieldShareStateStandard2022(),
advisorCount: uniqueRelationshipIds(municipality.advisors).length,
@ -404,12 +407,14 @@ export const loadMunicipalitySuggestions = async (
})
}
// Every field comes from `input` — the snapshot's contract is "the numbers
// the decision was made ON", which is exactly what the predicates saw.
const metrics: SuggestionMetrics = {
lq2022: input.lqByYear[ELECTION_YEAR_2022] ?? null,
coverageRatio: coverage.coverageRatio,
coverageDeficit: coverage.deficit,
projectedValidVotes: potential.projectedValidVotes,
pledgeCount: pledgeAggregate.pledgeCount,
lq2022: input.lqByYear[ELECTION_YEAR_2022],
coverageRatio: input.coverageRatio,
coverageDeficit: input.coverageDeficit,
projectedValidVotes: input.projectedValidVotes,
pledgeCount: input.pledgeCount,
leadershipCount: input.leadershipCount,
advisorCount: input.advisorCount,
lastSignalAgeDays: input.lastSignalAgeDays,

View file

@ -5,6 +5,7 @@ import { beforeAll, describe, expect, it } from 'vitest'
import { resolveSuggestionRecord } from '@/app/(campaign)/campanha/actions/suggestion'
import { SUGGESTION_STALE_MESSAGE } from '@/lib/schemas/suggestion'
import { DAY_MS } from '@/lib/suggestionCatalog'
import type { CampaignUser, Municipality } from '@/payload-types'
import config from '@/payload.config'
import {
@ -27,8 +28,6 @@ const campaignFixtures = installCampaignFixtures({
},
})
const DAY_MS = 86_400_000
/**
* E11's loader meets real rows here: the access scope, the four auxiliary
* queries (pledges, leadership counts, signals→agenda inputs, decisions) and
@ -356,6 +355,28 @@ describe('resolveSuggestionRecord (E11)', () => {
expect(bundle.suggestions.some((suggestion) => suggestion.patternId === 'P7')).toBe(false)
})
it('clamps the composed rationale so a maximal note still records', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.getMunicipality()
await stageStalePledge(fixtures, municipality)
// The note alone sits at the collection cap; the menu-label prefix would
// push the composition past it and Payload would reject the create with a
// message no retry could fix.
const { decision } = await resolveSuggestionRecord(payload, coordinator, {
municipality: municipality.id,
patternId: 'P7',
outcome: 'aceita',
chosenActionId: 'diagnostico-diferencial',
note: 'x'.repeat(2000),
})
await ownDecisions(fixtures, municipality.id)
expect(decision.rationale).toHaveLength(2000)
expect(decision.rationale).toContain('Diagnóstico diferencial')
})
it('postponing snoozes with a recorded suppressUntil', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')