E11 post-rebase simplify: clamp the composed rationale, extract firstFormActionMessage at its 3rd call site, keep metrics server-side.
This commit is contained in:
parent
189ad7a349
commit
8cae4adae9
13 changed files with 125 additions and 52 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue