diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 00000000..7f4d076f --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "command": "node \"/Users/francisco.solla/.cursor/skills/impeccable/scripts/hook-before-edit.mjs\"", + "timeout": 5 + } + ] + } +} diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 9cd03d1e..ccf32156 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -48,6 +48,7 @@ alwaysApply: false - **Em 2026-07-19, C9 Escala e DRY pós-C8 entregue:** `supporterListFilters.ts` (filtro unificado Payload↔SQL + `contactSearchQuery`), `loadSupportersPageData` (1× lookup de núcleos do coordenador), `apoiadores/[id]/formActions` no mapper compartilhado, `fieldError` nos 8 componentes de form restantes. Plano: [`docs/plans/escala-dry-pos-c8.md`](../../../docs/plans/escala-dry-pos-c8.md). - **Em 2026-07-19, C10 Escala e DRY pós-C9 registrado** (após `/simplify` do C9): dedup de access na lista (`req.context`), query única de núcleos do coordenador, `errorProps` nos forms grandes. Plano: [`docs/plans/escala-dry-pos-c9.md`](../../../docs/plans/escala-dry-pos-c9.md). - **Em 2026-07-19, C11 Escala e DRY pós-C7 registrado** (após `/simplify` do C7): feed O(n) condicional, loaders do detalhe + `loadCampaignUserNamesById`, selects por aba, hot paths mutação/form, utilities de busca/label. Plano: [`docs/plans/escala-dry-pos-c7.md`](../../../docs/plans/escala-dry-pos-c7.md). +- **Em 2026-07-19, Field Desk polish (Impeccable) + débitos FD+/FD2:** quieter→polish em `/campanha` (critique 28→32); shells/strips/freshness/filas-first. Débitos `/simplify` → **FD+** ([`escala-dry-pos-field-desk.md`](../../../docs/plans/escala-dry-pos-field-desk.md)); débitos de UX/produto do critique não implementados → **FD2** ([`field-desk-ux-pos-critique.md`](../../../docs/plans/field-desk-ux-pos-critique.md)). ## Decisões diff --git a/.impeccable/critique/2026-07-19T10-15-47Z__src-app-campaign-campanha.md b/.impeccable/critique/2026-07-19T10-15-47Z__src-app-campaign-campanha.md new file mode 100644 index 00000000..b1c3d937 --- /dev/null +++ b/.impeccable/critique/2026-07-19T10-15-47Z__src-app-campaign-campanha.md @@ -0,0 +1,116 @@ +--- +target: /campanha +total_score: 28 +p0_count: 0 +p1_count: 3 +p1_count_note: filters, kpi-grid, side-tab +p2_count: 2 +timestamp: 2026-07-19T10-15-47Z +slug: src-app-campaign-campanha +--- +Method: dual-agent (A: 89c6189f-1341-4e15-8d45-e1aa7c6f5167 · B: 3e87667e-e21e-4a29-85b4-db65afcc49c5) + +# Critique: /campanha + +## Design Health Score + +| # | Heuristic | Score | Key Issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 3 | Pending/aria-live on filters; no freshness/"as of" on KPI aggregates | +| 2 | Match System / Real World | 4 | Domain language precise (Núcleo vs Zona TSE disambiguated) | +| 3 | User Control and Freedom | 3 | Clear filters/back; limited undo for irreversible paths | +| 4 | Consistency and Standards | 3 | Strong component discipline; role dashboards diverge in max-width/chrome | +| 5 | Error Prevention | 3 | Constrained selects + inline ZE validation | +| 6 | Recognition Rather Than Recall | 4 | Filter state in URL; labels on actions | +| 7 | Flexibility and Efficiency | 2 | No saved views; queues are one-by-one, no bulk | +| 8 | Aesthetic and Minimalist Design | 2 | geral dashboard = even wall of same-weight Cards | +| 9 | Error Recovery | 3 | Forgot-password names fallback; leadership empty does not | +| 10 | Help and Documentation | 1 | Almost no glossary/tooltips for ZE / engajado / estimativa | +| **Total** | | **28/40** | **Good** | + +## Anti-Patterns Verdict + +**LLM assessment:** Not classic AI slop. Theme quarantine (`data-theme='campaign'` + `bg-none`) and semantic status tokens are deliberate. The tell is the repeated **hero-metric Card grid** (dashboard / núcleos overview / apoiadores) — exactly the anti-reference named in PRODUCT.md/DESIGN.md — plus icon-per-button and copy-pasted page shells. + +**Deterministic scan (CLI):** 1 finding — `side-tab` (`border-l-4 border-primary`) in `src/components/campaign/NucleusElectoralBaseline.tsx:79`. Real hit, not a false positive; absolute ban in DESIGN.md. + +**Visual overlays:** Injection succeeded on the live dashboard (session redirected login → `/campanha`). Overlay flagged `overused-font` / `single-font` (Inter-by-design → false positive), `layout-transition` (unverified), and many `nested-cards` (shadcn Card/CardHeader class-name false positives). No reliable additional anti-pattern beyond the CLI `side-tab` and the LLM-noted KPI grid density. + +## Overall Impression + +A serious field desk with strong a11y/touch discipline and honest empty-state copy — undermined by a template dashboard of identical metric cards, an always-open 6-filter wall on núcleos, and a leadership empty state that strands the persona the product calls a peer. Biggest opportunity: make hierarchy and triage decisions for the user instead of presenting equal-weight problem scoreboards. + +## What's Working + +1. **Theme quarantine is real** — campaign tokens + `bg-none` kill public-site gradient kitsch. +2. **Systemic touch/a11y** — `min-h-11`, live regions on filters, proper `aria-invalid` on ZE input. +3. **Scope-honest empty/error copy** — "você só vê núcleos no seu escopo"; phone-only reset routes to coordinator. + +## Priority Issues + +### [P1] Filter panel decision overload +**What:** `NucleusFilters` shows 6 fields at once on desktop (TI, município, ZE, cobertura, estimativa, prioridade) plus search. +**Why it matters:** Primary triage tool under time pressure; violates clarity-under-pressure and >4 options rule. +**Fix:** Keep territory + search primary; put coverage/estimate/priority behind "Mais filtros" at all breakpoints. +**Suggested command:** `/impeccable distill` / `/impeccable layout` + +### [P1] Hero-metric Card grids contradict own anti-references +**What:** General dashboard, NucleusListOverview, SupporterListOverview all use Card → description → big number grids. +**Why it matters:** PRODUCT/DESIGN ban identical card grids / hero-metric templates; this is the strongest "template AI" tell. +**Fix:** Compact secondary metrics into a stat row; reserve Card + priority lift for 1–2 decisive numbers; rank action queues. +**Suggested command:** `/impeccable quieter` / `/impeccable layout` + +### [P1] Side-tab accent on baseline row (detector) +**What:** `border-l-4 border-primary` on highlighted candidate in NucleusElectoralBaseline. +**Why it matters:** Absolute ban (side-stripe >1px); deterministic AI-tell. +**Fix:** Full border, background tint only, or leading mark — no thick left stripe. +**Suggested command:** `/impeccable quieter` + +### [P2] Liderança empty state is a dead end +**What:** "Nenhum núcleo" / "vínculo engajado" + warning icon, no CTA. +**Why it matters:** Peer persona stranded; jargon without next step. +**Fix:** Mirror forgot-password — name who to contact (coordenador / WhatsApp). +**Suggested command:** `/impeccable clarify` / `/impeccable onboard` + +### [P2] No freshness signal on aggregates +**What:** KPIs/overviews never show when data was computed. +**Why it matters:** Field decisions need trust in recency. +**Fix:** Relative "atualizado há…" near overview headers. +**Suggested command:** `/impeccable harden` + +### [P3] Split brand chrome (mobile red bar vs desktop stone rail) +**What:** Mobile top bar is solid Mandate Red; desktop sidebar is quiet rail. +**Why it matters:** Breaks Signal Red Rule for Casey/Lia switching devices unless intentional "field mode." +**Fix:** Document as field-mode signal or quiet the mobile chrome. +**Suggested command:** `/impeccable quieter` + +## Persona Red Flags + +**Alex (geral/coordenador):** No saved filter views; queues are one-by-one with equal weight — must supply triage the UI should do. + +**Casey (mobile field):** Opened accordion still stacks 6 full-width fields; no ambient stale/offline cue in shell; primary CTAs scroll away. + +**Lia (liderança phone-first):** Reset and unlinked-núcleo paths both require a more-privileged human; dashboard opens on "Sem estimativa confirmada" as the boldest line; jargon (engajado, ZE) unexplained. + +## Minor Observations + +- Leadership empty uses `AlertTriangleIcon` in wrong Empty slot vs `EmptyMedia`. +- Three dashboard max-widths (`2xl` / `5xl` / `4xl`) without clear intent. +- Dual relative-time helpers. +- `Badge secondary` overloaded (empty count vs category). +- Icon-on-every-button may be decorative filler vs confident-compact. + +## Cognitive Load + +2–3 checklist failures: 6-filter wall; geral dashboard lacks progressive disclosure; no single primary "do this now." Moderate load — address soon. + +## Questions to Consider + +1. Why do dashboards converge on the banned hero-metric grid three times — exception or unreviewed? +2. Is Lia's human-gated recovery acceptable honesty, or quiet demotion? +3. Should the UI pick which fire to put out first among the three equal queues? +4. Is the mobile red bar deliberate field-mode, or Signal Red Rule drift? + +## Emotional Journey + +Peak: confirmed estimate / successful update. End/valley: leadership dashboard often closes on absence ("Sem estimativa confirmada"); unlinked empty is a hard stop; problem-scoreboard queues create ambient anxiety without priority. diff --git a/.impeccable/critique/2026-07-19T11-03-23Z__src-app-campaign-campanha.md b/.impeccable/critique/2026-07-19T11-03-23Z__src-app-campaign-campanha.md new file mode 100644 index 00000000..4aca3562 --- /dev/null +++ b/.impeccable/critique/2026-07-19T11-03-23Z__src-app-campaign-campanha.md @@ -0,0 +1,108 @@ +--- +target: /campanha +total_score: 32 +p0_count: 0 +p1_count: 0 +p2_count: 3 +p3_count: 2 +timestamp: 2026-07-19T11-03-23Z +slug: src-app-campaign-campanha +--- +Method: dual-agent (A: 43c7ddf0-03c4-40b6-86e4-167ed820fbc3 · B: c685e95f-8884-4bd6-9174-53d1a4613689) + +# Critique: /campanha + +## Design Health Score + +| # | Heuristic | Score | Key Issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 4 | Freshness on KPI surfaces; filter aria-busy/live region | +| 2 | Match System / Real World | 4 | Núcleo vs Zona TSE stated on list; domain language solid | +| 3 | User Control and Freedom | 4 | Differentiated AlertDialogs; clear filter clear/back | +| 4 | Consistency and Standards | 3 | List/dashboard on CampaignPageShell; detail/forms still ad-hoc max-w | +| 5 | Error Prevention | 3 | Constrained selects + ZE validation; otherwise unchanged | +| 6 | Recognition Rather Than Recall | 4 | URL filters; RecentlyVisited; freshness timestamps | +| 7 | Flexibility and Efficiency | 2 | No saved views; triage queues still one-by-one (CSV bulk only) | +| 8 | Aesthetic and Minimalist Design | 3 | Metric strips + one priority lift; MetricStrip still double-card surface | +| 9 | Error Recovery | 3 | Leadership empty has CTA; coordenador empty still prose-only | +| 10 | Help and Documentation | 2 | One Núcleo/ZE line; engajado / estimativa vocabulary still unexplained | +| **Total** | | **32/40** | **Good** | + +## Anti-Patterns Verdict + +**LLM assessment:** Not AI slop. Field Desk intent lands: CampaignMetricStrip (explicit anti-grid), priority surface reserved for one panel, theme quarantine, semantic badges, side-tab ban verified clean in source. + +**Deterministic scan (CLI):** 0 findings on `src/app/(campaign)/campanha` and `src/components/campaign` (exit 0). Regex engine is blind to DOM/heading order and Tailwind arbitrary transitions — under-covers by design. + +**Visual overlays:** Injection succeeded on live dashboard (session authenticated as geral). Overlay: overused-font/single-font Inter (false positive by design); nested-cards on CardHeader (false positive); nested-cards on CampaignMetricStrip `
` (real — card-in-card styling); skipped-heading h1→h3 (real); layout-transition height on Sidebar/Drawer primitives (real, systemic). + +## Overall Impression + +Prior P1s (filter wall, hero-metric card grids, side-tab) are closed. The surface now reads as a serious ops desk with hierarchy and progressive disclosure. Remaining drag is power-user throughput (bulk triage), first-timer jargon, and a few a11y/composition leftovers the CLI cannot see. + +## What's Working + +1. **CampaignMetricStrip + priority lift** — shared replacement for the banned identical KPI card grid across dashboard, núcleos, apoiadores. +2. **NucleusFilters progressive disclosure** — primary territory + search; cobertura/estimativa/prioridade behind Mais filtros with count badge. +3. **Side-tab ban + shell convergence** — no `border-l-*` accents in campaign components; list/dashboard share CampaignPageShell. + +## Priority Issues + +### [P2] Domain jargon still opaque beyond one sentence +**What:** engajado, estimativa confirmada vs proposta, support statuses lack tooltips/help. +**Why it matters:** Lia and first-timers translate under pressure; Help heuristic still weakest. +**Fix:** Inline ? / first-visit hint on SupportStatusBadge and estimate labels. +**Suggested command:** `/impeccable clarify` + +### [P2] Dashboard queues cannot be triaged in bulk +**What:** Sem coordenador / stale updates / pending estimates are one-row links only. +**Why it matters:** Alex under time pressure on a growing queue; CSV bulk exists for import but not daily triage. +**Fix:** Multi-select + assign on the priority queue only. +**Suggested command:** `/impeccable layout` + +### [P2] Dashboard heading outline skips h2 +**What:** Detector + source: h1 "Visão geral" then QueueSection h3 before Indicadores h2. +**Why it matters:** Screen-reader document outline; Sam persona. +**Fix:** Promote Filas section title to h2, demote queue titles to h3; or insert sr-only/visible h2. +**Suggested command:** `/impeccable audit` + +### [P3] Coordenador empty state has no CTA +**What:** Prose-only empty vs leadership empty with Ver meu perfil. +**Why it matters:** Last sibling of the old dead-end pattern. +**Fix:** Mirror EmptyContent CTA pattern. +**Suggested command:** `/impeccable onboard` + +### [P3] MetricStrip double card surface + sidebar height transition +**What:** Overlay nested-cards on strip `bg-card` inside `bg-card`; Sidebar `transition-[width,height,padding]`. +**Why it matters:** Mild visual nesting / layout jank risk — not task-blocking. +**Fix:** Flatten inner metric cells to transparent; prefer transform/opacity on chrome. +**Suggested command:** `/impeccable quieter` / `/impeccable optimize` + +## Persona Red Flags + +**Alex (geral/coordenador):** Still one-by-one queues; no saved filter views. + +**Casey (mobile field):** Touch targets OK; no ambient offline/stale cue in shell during normal use (only /campanha/offline hard fallback). + +**Lia (liderança):** Empty state improved with CTA; card still leads with Sem estimativa confirmada; engajado unexplained. + +## Minor Observations + +- AlertTriangle on routine leadership empty is tonally alarmist for a peer first-run. +- Detail/form pages still hand-roll max-w-3xl/4xl/6xl outside CampaignPageShell. +- Icon-on-every-button reads intentional (confident-compact), not filler. + +## Cognitive Load + +2 checklist failures (no bulk/saved views; jargon). Primary NucleusFilters decision point ≤4 visible options. Moderate → low-moderate. + +## Questions to Consider + +1. Is bulk triage intentionally deferred because "onboarding is bulk, ops is retail"? +2. Should CampaignPageShell grow size variants, or stay list/dashboard-only? +3. Is coordenador empty prose-only deliberate (escalate verbally) vs leadership self-service? +4. How much inline glossary is enough before a help surface? + +## Emotional Journey + +Peak: estimate confirm / archive with consequence-clear dialogs. Valley: liderança card absence line; coordenador empty without next action. diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 00000000..f1e4d3bd --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,382 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-07-19T10:03:00.000Z", + "title": "Design System: Teqo", + "extensions": { + "colorMeta": { + "primary": { + "role": "primary", + "displayName": "Mandate Red", + "canonical": "#c51414", + "tonalRamp": [ + "#4a0808", + "#7a0c0c", + "#a01010", + "#c51414", + "#d64545", + "#e57a7a", + "#f2b0b0", + "#fce8e8" + ] + }, + "brand-header": { + "role": "primary", + "displayName": "Header Crimson", + "canonical": "#ae1603", + "tonalRamp": [ + "#3d0801", + "#6b0e02", + "#8c1202", + "#ae1603", + "#c84a3a", + "#e08a7a", + "#f0c4bc", + "#fbecea" + ] + }, + "background": { + "role": "neutral", + "displayName": "Desk White", + "canonical": "#ffffff", + "tonalRamp": [ + "#1c1917", + "#44403c", + "#78716c", + "#a8a29e", + "#d6d3d1", + "#e7e5e4", + "#f5f5f4", + "#ffffff" + ] + }, + "foreground": { + "role": "neutral", + "displayName": "Ink Stone", + "canonical": "#1c1917", + "tonalRamp": [ + "#0c0a09", + "#1c1917", + "#292524", + "#44403c", + "#78716c", + "#a8a29e", + "#d6d3d1", + "#f5f5f4" + ] + }, + "muted": { + "role": "neutral", + "displayName": "Quiet Wash", + "canonical": "#f4f4f5", + "tonalRamp": [ + "#27272a", + "#3f3f46", + "#71717a", + "#a1a1aa", + "#d4d4d8", + "#e4e4e7", + "#f4f4f5", + "#fafafa" + ] + }, + "muted-foreground": { + "role": "neutral", + "displayName": "Quiet Ink", + "canonical": "#6b7280", + "tonalRamp": [ + "#1f2937", + "#374151", + "#4b5563", + "#6b7280", + "#9ca3af", + "#d1d5db", + "#e5e7eb", + "#f3f4f6" + ] + }, + "secondary": { + "role": "secondary", + "displayName": "Stone Wash", + "canonical": "#f5f5f4", + "tonalRamp": [ + "#1c1917", + "#292524", + "#57534e", + "#a8a29e", + "#d6d3d1", + "#e7e5e4", + "#f5f5f4", + "#fafaf9" + ] + }, + "destructive": { + "role": "primary", + "displayName": "Alert Crimson", + "canonical": "#b42318", + "tonalRamp": [ + "#4a0f0c", + "#7a1710", + "#9a1e15", + "#b42318", + "#d45a52", + "#e89a94", + "#f5d0cd", + "#fceceb" + ] + }, + "sidebar": { + "role": "neutral", + "displayName": "Rail Mist", + "canonical": "#fafaf9", + "tonalRamp": [ + "#1c1917", + "#44403c", + "#78716c", + "#a8a29e", + "#d6d3d1", + "#e7e5e4", + "#f5f5f4", + "#fafaf9" + ] + }, + "scope": { + "role": "tertiary", + "displayName": "Territory Peach", + "canonical": "#fff5ed", + "tonalRamp": [ + "#3b180c", + "#6b2d15", + "#9a5230", + "#c48a68", + "#e8c5ae", + "#f5ddcc", + "#fff5ed", + "#fffaf6" + ] + }, + "support-engaged": { + "role": "secondary", + "displayName": "Engaged Green", + "canonical": "#dcfce7", + "tonalRamp": [ + "#052e16", + "#166534", + "#15803d", + "#4ade80", + "#86efac", + "#bbf7d0", + "#dcfce7", + "#f0fdf4" + ] + }, + "estimate-pending": { + "role": "secondary", + "displayName": "Pending Amber", + "canonical": "#fef3c7", + "tonalRamp": [ + "#451a03", + "#92400e", + "#b45309", + "#f59e0b", + "#fcd34d", + "#fde68a", + "#fef3c7", + "#fffbeb" + ] + }, + "petition-cta": { + "role": "tertiary", + "displayName": "Petition Gold", + "canonical": "#ffcf2e", + "tonalRamp": [ + "#332300", + "#664600", + "#997000", + "#cc9a00", + "#ffcf2e", + "#ffdb66", + "#ffe9a3", + "#fff6d6" + ] + } + }, + "typographyMeta": { + "headline": { + "displayName": "Headline", + "purpose": "Campaign page and section titles; left-aligned, fixed rem." + }, + "title": { + "displayName": "Title", + "purpose": "Card and dialog titles." + }, + "body": { + "displayName": "Body", + "purpose": "Default UI copy at text-sm." + }, + "label": { + "displayName": "Label", + "purpose": "Badges, meta, compact controls." + } + }, + "shadows": [ + { + "name": "flat-desk", + "value": "none", + "purpose": "Default resting state for lists, forms, and most cards." + }, + { + "name": "hairline-ring", + "value": "0 0 0 1px rgb(28 25 23 / 10%)", + "purpose": "Card separation without drop shadow (ring-1 ring-foreground/10)." + }, + { + "name": "priority-lift", + "value": "0 4px 24px rgb(28 25 23 / 8%)", + "purpose": "Soft ambient lift for overview/insight panels only." + }, + { + "name": "overlay", + "value": "0 16px 48px rgb(28 25 23 / 16%)", + "purpose": "Dialogs, sheets, drawers, popovers." + } + ], + "motion": [ + { + "name": "ease-state", + "value": "cubic-bezier(0.16, 1, 0.3, 1)", + "purpose": "State transitions 150–250ms; ease-out expo family." + }, + { + "name": "duration-fast", + "value": "150ms", + "purpose": "Hover, focus, press feedback." + }, + { + "name": "duration-standard", + "value": "200ms", + "purpose": "Default UI state changes." + } + ], + "breakpoints": [ + { "name": "2xs", "value": "23.75rem" }, + { "name": "xs", "value": "26.25rem" }, + { "name": "sm", "value": "40rem" }, + { "name": "md", "value": "48rem" }, + { "name": "lg", "value": "64rem" } + ] + }, + "components": [ + { + "name": "Primary Button", + "kind": "button", + "refersTo": "button-primary", + "description": "Mandate Red CTA — the one clear next action on a region.", + "html": "", + "css": ".ds-btn-primary { display: inline-flex; align-items: center; justify-content: center; height: 32px; padding: 0 10px; border: none; border-radius: 8px; background: #c51414; color: #ffffff; font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: background 150ms cubic-bezier(0.16, 1, 0.3, 1), transform 150ms cubic-bezier(0.16, 1, 0.3, 1); } .ds-btn-primary:hover { background: color-mix(in srgb, #c51414 80%, transparent); } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, #c51414 50%, transparent); } .ds-btn-primary:active { transform: translateY(1px); }" + }, + { + "name": "Secondary Button", + "kind": "button", + "refersTo": "button-secondary", + "description": "Stone wash secondary action.", + "html": "", + "css": ".ds-btn-secondary { display: inline-flex; align-items: center; justify-content: center; height: 32px; padding: 0 10px; border: none; border-radius: 8px; background: #f5f5f4; color: #292524; font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: background 150ms cubic-bezier(0.16, 1, 0.3, 1); } .ds-btn-secondary:hover { background: color-mix(in srgb, #f5f5f4 80%, #1c1917); } .ds-btn-secondary:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, #c51414 50%, transparent); }" + }, + { + "name": "Outline Button", + "kind": "button", + "refersTo": "button-outline", + "description": "Bordered quiet action on the desk.", + "html": "", + "css": ".ds-btn-outline { display: inline-flex; align-items: center; justify-content: center; height: 32px; padding: 0 10px; border: 1px solid #e7e5e4; border-radius: 8px; background: #ffffff; color: #1c1917; font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: background 150ms cubic-bezier(0.16, 1, 0.3, 1); } .ds-btn-outline:hover { background: #f4f4f5; } .ds-btn-outline:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, #c51414 50%, transparent); }" + }, + { + "name": "Campaign Input", + "kind": "input", + "refersTo": "input-field", + "description": "Compact field with visible stone border; campaign radius-md.", + "html": "", + "css": ".ds-input { width: 100%; height: 32px; padding: 0 12px; border: 1px solid #d6d3d1; border-radius: 6px; background: #ffffff; color: #1c1917; font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-size: 0.875rem; outline: none; transition: border-color 150ms, box-shadow 150ms; } .ds-input::placeholder { color: rgb(28 25 23 / 50%); } .ds-input:focus-visible { border-color: #c51414; box-shadow: 0 0 0 3px color-mix(in srgb, #c51414 30%, transparent); } .ds-input:disabled { background: #f5f5f4; color: rgb(28 25 23 / 55%); cursor: not-allowed; }" + }, + { + "name": "Status Badge", + "kind": "chip", + "refersTo": "badge-status", + "description": "Pill status chip — engaged / pending / negative via semantic tokens.", + "html": "Engajado Pendente", + "css": ".ds-badge { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: 9999px; font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-size: 0.75rem; font-weight: 500; white-space: nowrap; } .ds-badge-engaged { background: #dcfce7; color: #166534; } .ds-badge-pending { background: #fef3c7; color: #92400e; }" + }, + { + "name": "Desk Card", + "kind": "card", + "refersTo": "card-surface", + "description": "Flat card with hairline ring — default container on the field desk.", + "html": "
Núcleo Centro

Estimativa confirmada · 3 lideranças

", + "css": ".ds-card { display: flex; flex-direction: column; gap: 4px; padding: 16px; border-radius: 12px; background: #ffffff; color: #1c1917; box-shadow: 0 0 0 1px rgb(28 25 23 / 10%); font-family: Inter, ui-sans-serif, system-ui, sans-serif; } .ds-card-title { font-size: 1rem; font-weight: 500; line-height: 1.375; } .ds-card-body { margin: 0; font-size: 0.875rem; color: #6b7280; line-height: 1.5; }" + }, + { + "name": "Campaign Mobile Nav", + "kind": "nav", + "refersTo": "button-primary", + "description": "Mobile top bar in Mandate Red with white wordmark.", + "html": "
Jorge SollaCampanha
", + "css": ".ds-nav-mobile { display: flex; flex-direction: column; justify-content: center; min-height: 56px; padding: 0 16px; background: #c51414; color: #ffffff; font-family: Inter, ui-sans-serif, system-ui, sans-serif; } .ds-nav-title { font-size: 0.875rem; font-weight: 600; } .ds-nav-sub { font-size: 0.75rem; color: rgb(255 255 255 / 80%); }" + } + ], + "narrative": { + "northStar": "The Field Desk", + "overview": "Teqo’s visual system is a sober ops surface for people organizing under pressure. Design defaults to `/campanha`: stone-white surfaces, Inter, and brand red as a signal (primary actions, selection, status urgency) — never as spectacle or brochure chrome. Staff screens can be information-dense; leadership paths stay phone-first with larger touch targets and obvious next actions.\n\nPublic themes (`editorial`, `petition`, and the home brand-red body) share the same token contract and primary red, but they are surface variants. When inventing new UI, start from the campaign desk unless the task is explicitly a public/marketing page.\n\nThe system rejects generic SaaS dashboards (purple gradients, hero-metric templates, identical card grids, AI-startup chrome) and traditional Brazilian campaign kitsch (clipart flags, shouty all-caps, brochure clutter). Sovereignty and clarity beat decoration.", + "keyCharacteristics": [ + "Product register: familiar shadcn affordances, earned consistency over surprise", + "Restrained color strategy: neutrals dominate; primary red ≤ ~10% of any screen", + "Hybrid elevation: flat lists/forms; light lift only for priority or overlay surfaces", + "Confident and compact controls — denser desktop, phone-first leadership paths", + "Semantic status colors (support / estimate / cadence) carry meaning, not fashion" + ], + "rules": [ + { + "name": "The Signal Red Rule", + "body": "Mandate Red is for primary action, focus, and urgency. It must not wash backgrounds, decorate cards, or paint inactive chrome.", + "section": "colors" + }, + { + "name": "The Theme Contract Rule", + "body": "New surfaces reuse the shadcn token names (`primary`, `muted`, `field-*`, etc.). Do not introduce one-off hex outside a `data-theme` block.", + "section": "colors" + }, + { + "name": "The One Family Rule", + "body": "Inter everywhere in product UI. No decorative display faces in buttons, labels, or data.", + "section": "typography" + }, + { + "name": "The Fixed Scale Rule", + "body": "Prefer rem/Tailwind steps over fluid clamp() headings inside the campaign shell.", + "section": "typography" + }, + { + "name": "The Flat-By-Default Rule", + "body": "Surfaces are flat at rest. Lift appears for priority or overlay — never as default card decoration across a grid.", + "section": "elevation" + } + ], + "dos": [ + "Do treat `/campanha` as the design default: stone desk, Inter, Signal Red for the next action.", + "Do keep primary red rare — one clear CTA per region beats a sea of red buttons.", + "Do use semantic Badge tokens for support/estimate/cadence state.", + "Do keep leadership paths phone-first: obvious actions, larger taps, plain language.", + "Do meet WCAG 2.2 AA (contrast, keyboard, focus rings, prefers-reduced-motion).", + "Do reuse shadcn primitives (Button, Input, Badge, Card, dialogs/sheets) for consistency." + ], + "donts": [ + "Don't ship generic SaaS dashboards — purple gradients, hero-metric templates, identical card grids, AI-startup chrome.", + "Don't ship traditional Brazilian campaign kitsch — clipart flags, shouty all-caps, brochure clutter.", + "Don't use side-stripe borders (border-left/border-right > 1px) as accent decoration.", + "Don't use gradient text or glassmorphism as default chrome.", + "Don't put tiny uppercase tracked eyebrows above every section.", + "Don't use Petition Gold or home body gradients inside the campaign desk.", + "Don't invent display fonts for UI labels, buttons, or data.", + "Don't lift every card; flat is the desk's resting state." + ] + } +} diff --git a/.impeccable/live/config.json b/.impeccable/live/config.json new file mode 100644 index 00000000..a5453592 --- /dev/null +++ b/.impeccable/live/config.json @@ -0,0 +1,9 @@ +{ + "files": [ + "src/app/(frontend)/layout.tsx", + "src/app/(campaign)/layout.tsx" + ], + "insertBefore": "", + "commentSyntax": "jsx", + "cspChecked": true +} diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 00000000..1005b2e7 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,279 @@ +--- +name: Teqo +description: Field ops and public voice for Jorge Solla — campaign desk first, owned channel second. +colors: + primary: '#c51414' + primary-foreground: '#ffffff' + brand-header: '#ae1603' + brand-header-foreground: '#fff8f2' + background: '#ffffff' + foreground: '#1c1917' + card: '#ffffff' + muted: '#f4f4f5' + muted-foreground: '#6b7280' + secondary: '#f5f5f4' + secondary-foreground: '#292524' + accent: '#f4f4f5' + accent-foreground: '#1c1917' + border: '#e7e5e4' + input: '#d6d3d1' + ring: '#c51414' + destructive: '#b42318' + sidebar: '#fafaf9' + sidebar-foreground: '#1c1917' + scope: '#fff5ed' + scope-foreground: '#6b2d15' + support-engaged: '#dcfce7' + support-engaged-foreground: '#166534' + support-disputed: '#fef3c7' + support-disputed-foreground: '#92400e' + support-negative: '#fee2e2' + support-negative-foreground: '#991b1b' + estimate-confirmed: '#dcfce7' + estimate-confirmed-foreground: '#166534' + estimate-pending: '#fef3c7' + estimate-pending-foreground: '#92400e' + cadence-overdue: '#b42318' + editorial-bg: '#f6f4f3' + editorial-fg: '#201b1a' + petition-bg: '#fff8f2' + petition-cta: '#ffcf2e' + petition-cta-foreground: '#332300' +typography: + headline: + fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif' + fontSize: '1.5rem' + fontWeight: 600 + lineHeight: 1.25 + letterSpacing: '-0.025em' + title: + fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif' + fontSize: '1rem' + fontWeight: 500 + lineHeight: 1.375 + body: + fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif' + fontSize: '0.875rem' + fontWeight: 400 + lineHeight: 1.5 + label: + fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif' + fontSize: '0.75rem' + fontWeight: 500 + lineHeight: 1 +rounded: + sm: '4px' + md: '6px' + lg: '8px' + xl: '12px' + full: '9999px' +spacing: + xs: '4px' + sm: '8px' + md: '16px' + lg: '24px' + xl: '32px' +components: + button-primary: + backgroundColor: '{colors.primary}' + textColor: '{colors.primary-foreground}' + rounded: '{rounded.lg}' + padding: '8px 10px' + height: '32px' + typography: '{typography.label}' + button-primary-hover: + backgroundColor: '#c51414cc' + button-secondary: + backgroundColor: '{colors.secondary}' + textColor: '{colors.secondary-foreground}' + rounded: '{rounded.lg}' + padding: '8px 10px' + height: '32px' + button-outline: + backgroundColor: '{colors.background}' + textColor: '{colors.foreground}' + rounded: '{rounded.lg}' + padding: '8px 10px' + height: '32px' + button-ghost: + backgroundColor: 'transparent' + textColor: '{colors.foreground}' + rounded: '{rounded.lg}' + padding: '8px 10px' + height: '32px' + input-field: + backgroundColor: '{colors.background}' + textColor: '{colors.foreground}' + rounded: '{rounded.md}' + padding: '8px 16px' + height: '32px' + badge-status: + backgroundColor: '{colors.muted}' + textColor: '{colors.foreground}' + rounded: '{rounded.full}' + padding: '2px 8px' + height: '20px' + typography: '{typography.label}' + card-surface: + backgroundColor: '{colors.card}' + textColor: '{colors.foreground}' + rounded: '{rounded.xl}' + padding: '16px' +--- + +# Design System: Teqo + +## 1. Overview + +**Creative North Star: "The Field Desk"** + +Teqo’s visual system is a sober ops surface for people organizing under pressure. Design defaults to `/campanha`: stone-white surfaces, Inter, and brand red as a **signal** (primary actions, selection, status urgency) — never as spectacle or brochure chrome. Staff screens can be information-dense; leadership paths stay phone-first with larger touch targets and obvious next actions. + +Public themes (`editorial`, `petition`, and the home brand-red body) share the same token contract and primary red, but they are surface variants. When inventing new UI, start from the campaign desk unless the task is explicitly a public/marketing page. + +The system rejects generic SaaS dashboards (purple gradients, hero-metric templates, identical card grids, AI-startup chrome) and traditional Brazilian campaign kitsch (clipart flags, shouty all-caps, brochure clutter). Sovereignty and clarity beat decoration. + +**Key Characteristics:** + +- Product register: familiar shadcn affordances, earned consistency over surprise +- Restrained color strategy: neutrals dominate; primary red ≤ ~10% of any screen +- Hybrid elevation: flat lists/forms; light lift only for priority or overlay surfaces +- Confident and compact controls — denser desktop, phone-first leadership paths +- Semantic status colors (support / estimate / cadence) carry meaning, not fashion + +## 2. Colors + +A stone-neutral desk with one mobilizing red. Semantic pastels mark operational state; they never replace the primary action color. + +### Primary + +- **Mandate Red** (`#c51414`): Primary buttons, focus rings, active nav, brand emphasis on `/campanha` and editorial/petition themes. Use for the one clear next action. +- **Header Crimson** (`#ae1603`): Public site header bar (`--site-header`) and home body gradient anchor — inherited across themes so the masthead stays constant. + +### Secondary + +- **Stone Wash** (`#f5f5f4` / `#f4f4f5`): Secondary buttons, muted fills, hover washes. Quiet structure, not a second brand. + +### Tertiary + +- **Petition Gold** (`#ffcf2e`): Petition-theme CTA only (`data-theme='petition'`). Forbidden as a general accent on campaign screens. + +### Neutral + +- **Desk White** (`#ffffff`): Page and card background on campaign. +- **Ink Stone** (`#1c1917`): Body text and strong labels. +- **Quiet Ink** (`#6b7280`): Muted labels, secondary metadata — keep contrast ≥ 4.5:1 on white. +- **Hairline Stone** (`#e7e5e4` / `rgb(28 25 23 / 12%)`): Borders and dividers. +- **Field Border** (`#d6d3d1`): Visible input stroke on white cards. +- **Rail Mist** (`#fafaf9`): Campaign sidebar rail. + +### Semantic (campaign ops) + +- Scope / territory chips: warm peach (`#fff5ed` / `#6b2d15`) +- Support & estimates: green engaged/confirmed, amber disputed/pending, red negative/overdue — use only via Badge variants, never invent parallel hues + +### Named Rules + +**The Signal Red Rule.** Mandate Red is for primary action, focus, and urgency. It must not wash backgrounds, decorate cards, or paint inactive chrome. + +**The Theme Contract Rule.** New surfaces reuse the shadcn token names (`primary`, `muted`, `field-*`, etc.). Do not introduce one-off hex outside a `data-theme` block. + +## 3. Typography + +**Display Font:** Inter (with `ui-sans-serif, system-ui, sans-serif`) +**Body Font:** Inter (same stack) +**Label/Mono Font:** Inter for UI labels; mono only for code snippets + +**Character:** One tuned sans for the whole product desk — no display serif in app chrome. Public article heroes may use larger extrabold Inter; campaign headings stay left-aligned section titles. + +### Hierarchy + +- **Headline** (600, `1.5rem` / `text-2xl`, tight tracking): Page and section titles on `/campanha`. +- **Title** (500, `1rem` / `text-base`): Card titles, dialog titles. +- **Body** (400, `0.875rem` / `text-sm`, leading ~1.5): Default UI copy; prose max ~65–75ch when reading length matters. +- **Label** (500, `0.75rem` / `text-xs`): Badges, meta, table headers. Never all-caps tracked eyebrows as section scaffolding. + +### Named Rules + +**The One Family Rule.** Inter everywhere in product UI. No decorative display faces in buttons, labels, or data. + +**The Fixed Scale Rule.** Prefer rem/Tailwind steps over fluid `clamp()` headings inside the campaign shell. + +## 4. Elevation + +Hybrid: most of the desk is flat. Depth comes from tonal layers (sidebar rail, muted fills, hairline rings) and from overlays when the user must focus. + +Lists, forms, filters, and table rows stay flat. Cards may use a light ring (`ring-1 ring-foreground/10`) without drop shadow. Soft ambient lift is reserved for priority overview panels and for floating UI (dialogs, sheets, drawers, popovers). + +### Shadow Vocabulary + +- **Flat desk** (no shadow): Default for rows, forms, filters, most cards. +- **Priority lift** (soft ambient, low opacity): Overview / insight panels that must read as “above” the list — keep blur soft and shadow light; if it looks like a 2014 app, the shadow is too dark. +- **Overlay** (modal/sheet/popover system shadow): Only for temporary focus layers. + +### Named Rules + +**The Flat-By-Default Rule.** Surfaces are flat at rest. Lift appears for priority or overlay — never as default card decoration across a grid. + +## 5. Components + +Controls feel **confident and compact**: short primary buttons, dense toolbars, phone-first tap targets where leaderships work. + +### Buttons + +- **Shape:** Gently rounded (`rounded-lg` ≈ 8px; smaller sizes clamp toward `--radius-md`) +- **Primary:** Mandate Red fill, white text, height 32px default; primary submit may use `font-bold` +- **Hover / Focus:** Primary hover at ~80% opacity; focus-visible ring `ring-3 ring-ring/50` with border-ring +- **Secondary / Outline / Ghost / Destructive:** Standard shadcn variants; destructive is tinted text/fill, not a second brand red for routine actions +- **Active:** Slight `translate-y-px` press + +### Chips / Badges + +- **Style:** Pill (`rounded-4xl`), height 20px, `text-xs` medium +- **State:** Semantic variants (`support-*`, `estimate-*`, `scope`, `tse`) — status meaning only; do not invent decorative chip colors + +### Cards / Containers + +- **Corner Style:** `rounded-xl` (~12px) +- **Background:** Card white on desk white — separation via hairline ring, not heavy shadow +- **Shadow Strategy:** Flat by default; priority lift only when the panel is an overview/insight surface +- **Internal Padding:** `--card-spacing` default 16px (`spacing(4)`), sm 12px + +### Inputs / Fields + +- **Style:** Campaign forces `--radius-md` (~6px) on input/select triggers (overrides public `rounded-full` input). Visible `--field-border` on white. +- **Focus:** Border primary + `ring-3 ring-primary/30` +- **Error / Disabled:** Destructive border/ring; disabled uses field-disabled tokens at reduced opacity +- **Touch:** Keep leadership-facing controls generous; public Input uses larger py for form comfort + +### Navigation + +- **Campaign shell:** Light stone sidebar rail on desktop; mobile top bar in Mandate Red with white wordmark — intentional field-mode contrast (phone in the street vs desk rail), not Signal Red Rule drift. +- **Active:** Primary-tinted or bold text — red signals location, not decoration +- **Public header:** Header Crimson bar, constant across themes + +### Status Badge (signature) + +Operational meaning for leadership/support/estimates. Always use `Badge` variants tied to CSS tokens — never hardcode greens/ambers that drift from `--support-*` / `--estimate-*`. + +## 6. Do's and Don'ts + +### Do: + +- **Do** treat `/campanha` as the design default: stone desk, Inter, Signal Red for the next action. +- **Do** keep primary red rare — one clear CTA per region beats a sea of red buttons. +- **Do** use semantic Badge tokens for support/estimate/cadence state. +- **Do** keep leadership paths phone-first: obvious actions, larger taps, plain language. +- **Do** meet WCAG 2.2 AA (contrast, keyboard, focus rings, `prefers-reduced-motion`). +- **Do** reuse shadcn primitives (`Button`, `Input`, `Badge`, `Card`, dialogs/sheets) for consistency. + +### Don't: + +- **Don't** ship generic SaaS dashboards — purple gradients, hero-metric templates, identical card grids, AI-startup chrome. +- **Don't** ship traditional Brazilian campaign kitsch — clipart flags, shouty all-caps, brochure clutter. +- **Don't** use side-stripe borders (`border-left`/`border-right` > 1px) as accent decoration. +- **Don't** use gradient text or glassmorphism as default chrome. +- **Don't** put tiny uppercase tracked eyebrows above every section. +- **Don't** use Petition Gold or home body gradients inside the campaign desk. +- **Don't** invent display fonts for UI labels, buttons, or data. +- **Don't** lift every card; flat is the desk’s resting state. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 00000000..f2c7ec91 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,51 @@ +# Product + +## Register + +product + +## Platform + +web + +## Users + +Two surfaces, one product. + +**`/campanha` (primary for design defaults):** campaign coordination (`geral`, `coordenador`) and leaderships (`lideranca`) as first-class peers — staff need depth for territory, people, estimates, and agenda; leaderships need a simpler, phone-first path into their nucleus. Used in the field and on the go, often under time pressure through the 2026 calendar. + +**Public site:** citizens and the base in Bahia first (news, petitions, donate, stay close to the mandate); institutional visitors second (press, partners, official voice). + +## Product Purpose + +**`/campanha`:** field operations and electoral intelligence as one job — nuclei, people, estimates, agenda, and share kits, plus a live picture of territory and vote gap so the team can prioritize action. Nuclei, people, and estimates live here as the system of record, even when outreach uses pragmatic channels. + +**Public site:** own the relationship with the base first (engagement channel the campaign controls); project the mandate’s official voice and credibility second. + +**Success near-term:** `/campanha` is the daily ops tool with real nuclei, people, and estimates in use, and the public site is the owned citizen channel — both live, not one or the other. + +## Positioning + +Sovereign political intelligence, organization, and community engagement — the destination. Pragmatic channels (e.g. WhatsApp) are acceptable shortcuts today; the product should not make those the permanent system of record. + +## Brand Personality + +Socialist, clear, inspiring. + +Voice and UI should feel like tools built by people who believe a better internet and stronger grassroots organization are possible — functional and respectful (Mastodon), builders of owned channels (Automattic), willing to reimagine workflows when the old ones fail (Hey.com), and rooted in social mobilization practice (Bonde.org) — without pretending the team never uses the platforms people already live on. + +## Anti-references + +Not generic SaaS dashboards (purple gradients, hero-metric templates, identical card grids, AI-startup chrome). Not traditional Brazilian campaign kitsch (clipart flags, shouty all-caps, brochure clutter). + +## Design Principles + +1. **Sovereignty as the horizon** — own audience, data, and ops over time; Big Tech shortcuts (WhatsApp kits, share links) are fine for now when they help mobilization, but design should keep the campaign’s own records and workflows as the source of truth and leave room to reduce platform dependency later. +2. **Clarity under pressure** — every screen earns its keep for field use; prefer plain language and obvious next actions over decoration. +3. **Depth and simplicity as peers** — staff workflows can be rich; leadership paths stay light and phone-first without becoming a second-class product. +4. **Intelligence serves organization** — electoral baseline and insights exist to decide where to act, not to impress. +5. **Inspire without kitsch** — socialist and mobilizing in spirit; never shouty campaign brochure aesthetics. + +## Accessibility & Inclusion + +WCAG 2.2 AA as the bar (contrast, keyboard, focus, reduced motion). Plus: larger touch targets for leadership / field phone use, and screen-reader priority on forms (labels, errors, and critical actions must be announced clearly). diff --git a/docs/plans/escala-dry-pos-field-desk.md b/docs/plans/escala-dry-pos-field-desk.md new file mode 100644 index 00000000..93d55896 --- /dev/null +++ b/docs/plans/escala-dry-pos-field-desk.md @@ -0,0 +1,96 @@ +# Escala e DRY pós-Field Desk + +Status: registrado no roadmap (fases pendentes) +Atualizado em: 2026-07-19 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (FD+, fill-in de engenharia pós-polish Impeccable `/campanha`) +Responsável: — + +## Contexto + +Em 2026-07-19 a vertical `/campanha` passou por um ciclo Impeccable (critique 28→32, quieter/layout/distill/clarify/harden/polish) alinhado ao North Star **Field Desk** (`PRODUCT.md` / `DESIGN.md`). Entregas locais incluem `CampaignPageShell`, `CampaignMetricStrip`, `CampaignDataFreshness`, filas-first no dashboard, `NucleusFilters` com “Mais filtros”, empties de liderança/coordenador esclarecidos, freshness request-time, tipografia `font-semibold` e loadings alinhados. + +A passagem `/simplify` (2026-07-19) aplicou cleanup pontual (freshness sem ramo relativo morto, strip sem métricas duplicadas do card de prioridade, `hidden` único nos filtros avançados, wrappers de data inlined, Badge simplificado). Os revisores (quality / reuse / performance) deixaram débitos **maiores que cleanup** — registrados aqui. + +**Já resolvido no simplify (não reabrir):** `formatCampaignDataFreshness` morto removido; `CampaignDataFreshness` só `asOf` + “Atualizado agora”; overview sem Gap/série no strip (ficam no card); MetricStrip sem `bg-card` interno em células quietas; `hidden={!advancedOpen}` sem `className` duplicado; `relativeDate*` inlined em overview/feed; comments de shell/strip. + +**Overlap com VR+:** VR+ Fase 2 ainda cita migrar `lastUpdateLabel` / feed — o feed já usa `formatRelativeAge`; o restante de VR+ F2 (variante floor-dias no dashboard) permanece em [escala-dry-pos-visitados-recentemente.md](escala-dry-pos-visitados-recentemente.md), não aqui. + +## Objetivos + +- Um único shell de página (`CampaignPageShell` + variantes de largura) para listas, detalhe e forms de `/campanha`. +- Um token de tipografia de página (`campaignPageHeadingClassName`) sem repetir `text-2xl font-semibold tracking-tight` em ~15 h1s. +- Datas absolutas/relativas de superfície de campanha passam pelos helpers canônicos (`formatRelativeAge` / `formatBahiaDateTimeLabel`), não por `Intl` local. +- `NucleusVoteGoals` reutiliza `CampaignMetricStrip` onde o layout for equivalente. +- `NucleusFilters` deixa de carregar bag `handlers` + lista `primaryFilterNames` só para concatenar nomes. +- Guardrails: sem migration, sem collection, sem Consent; só DRY/composição UI. + +## Decisões travadas + +- **Um plano FD+, fases ordenadas.** Precedente VR+/RS+/O0+: um registro no roadmap, PRs por fase. +- **Dependência suave do polish Field Desk** (branch local / merge pendente). Não bloqueia uso da vertical. +- **Cortável:** Fases 3–5 (VoteGoals strip, filtros bag, datas em listagens fora do core polish) se o prazo apertar; Fases 1–2 (shell size + heading class) são as de maior ROI de consistência. +- **i18n e naming** (AGENTS.md): identificadores em inglês (`campaignPageHeadingClassName`, `size: 'form' | 'detail' | 'list'`), strings visíveis em pt-BR. + +## Questões em aberto + +- **`size` no shell vs `className` ad-hoc?** Forms usam `max-w-3xl`/`4xl`, detalhe `max-w-6xl`, listas `max-w-screen-2xl`. **Recomendação:** prop `size: 'list' | 'form' | 'detail'` mapeando para classes já usadas; permitir `className` só para exceções (ex. perfil `max-w-2xl`). +- **Heading class no shell ou export solto?** **Recomendação:** exportar `campaignPageHeadingClassName` ao lado de `campaignPageShellClassName` em `CampaignPageShell.tsx` — mesmo padrão de discovery. +- **VoteGoals → MetricStrip agora?** Só 3 métricas + Progress separado. **Recomendação:** Fase 3 só se o Progress “vs meta regular” couber no strip (`progress` na meta regular) sem perder o card de contexto do detalhe. + +## Abordagem proposta + +```mermaid +flowchart TD + FD["Field Desk polish ✓ (local)"] --> F1 + F1["Fase 1 — CampaignPageShell size
list / form / detail"] + F1 --> F2["Fase 2 — campaignPageHeadingClassName"] + F2 --> F3["Fase 3 — NucleusVoteGoals → MetricStrip"] + F3 --> F4["Fase 4 — Datas canônicas
NucleusList / detalhe / ActionPlanUpdateFeed"] + F4 --> F5["Fase 5 — NucleusFilters handlers
+ filterNames flat"] +``` + +### Fase 1 — Shell com `size` + +- Estender **`CampaignPageShell`** (`src/components/campaign/CampaignPageShell.tsx`) com `size?: 'list' | 'form' | 'detail'` (default `'list'` = `campaignPageShellClassName` atual). +- Migrar páginas que ainda usam `mx-auto flex w-full max-w-{3xl,4xl,6xl} flex-col gap-6`: `nucleos/novo`, `nucleos/[slug]`, `nucleos/[slug]/editar`, `apoiadores/novo|importar|[id]`, `planos/novo|[slug]|editar`, e alinhar `CampaignProfileSettings` (`max-w-2xl` via `className` ou size `form` + override). +- Loadings já no shell — sem mudança. + +### Fase 2 — Heading class compartilhada + +- Exportar **`campaignPageHeadingClassName = 'text-2xl font-semibold tracking-tight'`**. +- Aplicar nos h1 de páginas de campanha + títulos de dashboard (`CampaignDashboard`) + perfil. + +### Fase 3 — `NucleusVoteGoals` → `CampaignMetricStrip` + +- Refatorar **`NucleusVoteGoals`** para emitir as três metas via `CampaignMetricStrip`; manter Progress de “vs meta regular” abaixo ou como `progress` na métrica regular. +- Não alterar domínio/loaders de `voteGoals`. + +### Fase 4 — Datas canônicas fora do core polish + +- **`NucleusList.tsx`**: `lastUpdateAt` → `formatRelativeAge` (paridade com dashboard) ou `formatBahiaDateTimeLabel` se produto quiser absoluto — **Recomendação:** relativo, como dashboard. +- **`nucleos/[slug]/page.tsx`**: timestamps de estimativa → `formatBahiaDateTimeLabel`. +- **`ActionPlanUpdateFeed.tsx`**: alinhar a `formatRelativeAge` + `now` do servidor (paridade com `NucleusUpdateFeed`). + +### Fase 5 — `NucleusFilters` bag + +- Remover objeto `handlers: SharedFilterHandlers` e espalhar props; colapsar `primaryFilterNames` + `advancedFilterNames` numa única lista `filterNames` com marcação advanced se ainda precisar do count. + +## Dependências + +- Soft: polish Field Desk mergeado (shell/strip/freshness já no branch). +- Soft overlap: VR+ F2 (relative age no dashboard cards) — coordenar para não duplicar PRs. +- Sem migration, sem Consent. + +## Não escopo + +- Triagem em lote das filas, glossário/tooltips, outline h1→h2, empty de coordenador com CTA, motion do Sidebar — item **FD2** ([field-desk-ux-pos-critique.md](field-desk-ux-pos-critique.md)). +- Reabrir quieter/layout/distill já entregues (KPI strips, Mais filtros, side-tab). +- Saved filter views / bulk além da fila de prioridade — produto em FD2 Fase 1; escopo além disso fica para item futuro. + +## Referências + +- `docs/roadmap.md` (Fill-ins FD+) +- `.impeccable/critique/2026-07-19T11-03-23Z__src-app-campaign-campanha.md` — score 32; minor “detail/form max-w” +- `src/components/campaign/CampaignPageShell.tsx`, `CampaignMetricStrip.tsx`, `NucleusFilters.tsx`, `NucleusVoteGoals.tsx`, `NucleusList.tsx`, `ActionPlanUpdateFeed.tsx` +- [escala-dry-pos-visitados-recentemente.md](escala-dry-pos-visitados-recentemente.md) — VR+ (overlap tempo relativo) +- AGENTS.md — naming inglês; tema `campaign` diff --git a/docs/plans/field-desk-ux-pos-critique.md b/docs/plans/field-desk-ux-pos-critique.md new file mode 100644 index 00000000..4f912b84 --- /dev/null +++ b/docs/plans/field-desk-ux-pos-critique.md @@ -0,0 +1,97 @@ +# UX Field Desk pós-critique + +Status: registrado no roadmap (fases pendentes) +Atualizado em: 2026-07-19 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (FD2, fill-in de design/produto pós-critique Impeccable `/campanha`) +Responsável: — + +## Contexto + +O re-critique de `/campanha` (2026-07-19, dual-agent) subiu o score de **28 → 32/40** após o ciclo quieter → polish. Os P1 do baseline (parede de filtros, grade de KPI cards, side-tab) estão fechados. Permanecem débitos de **design/produto** (não só DRY) que o time não implementou naquele ciclo — registrados aqui para não se perderem no chat. + +Fonte: [`.impeccable/critique/2026-07-19T11-03-23Z__src-app-campaign-campanha.md`](../../.impeccable/critique/2026-07-19T11-03-23Z__src-app-campaign-campanha.md) e síntese em sessão (heurísticas 7=2 flexibilidade, 10=2 ajuda). + +## Objetivos + +- Alex (geral/coordenador) consegue tratar a fila **Prioridade · Sem coordenador** com multi-select + ação em lote (atribuir coordenador), sem N navegações. +- Lia e first-timers encontram definição inline de jargão recorrente (`engajado`, estimativa confirmada vs proposta, status de apoio) sem sair da tela. +- Outline do dashboard `geral` não pula de `h1` para `h3` (detector `skipped-heading`; Sam/a11y). +- Empty do coordenador sem núcleos espelha o padrão de liderança (CTA + próximo passo). +- Guardrails: sem migration obrigatória na v1 da triagem (reusa designação de coordenador existente); sem Consent novo; touch `min-h-11` preservado. + +## Decisões travadas + +- **Um plano FD2, fases por severidade do critique.** P2 antes de P3. Ordem: triagem em lote (impacto Alex) → glossário inline (Help) → outline a11y → empty coordenador → chrome motion (cortável). +- **Bulk só na fila de prioridade na v1.** As filas “sem atualização” e “estimativas pendentes” continuam one-by-one até haver evidência de volume — evita escopo de “saved views” genérico. +- **Glossário = affordance inline, não página de docs.** Tooltip/`Popover`/`?` em `SupportStatusBadge` e labels de estimativa; uma frase Núcleo/ZE já existe na lista. +- **P3 mobile red bar vs rail** permanece documentado em `DESIGN.md` como field-mode intencional — **fora deste plano** (escolha explícita no ciclo Impeccable). +- **i18n e naming:** identificadores em inglês (`bulkAssignCoordinators`, `CampaignGlossaryHint`), copy em pt-BR. + +## Questões em aberto + +- **Bulk assign: mesmo sheet de designação ou fluxo novo?** **Recomendação:** reusar o shell/lazy de `assignCoordinators` por núcleo em sequência sob uma txn ou N actions com progresso — não inventar multi-núcleo no Payload sem necessidade; UX é checklist + um coordenador aplicado a N slugs. +- **Quem pode bulk?** **Recomendação:** só `geral` (já é quem designa coordenadores em massa conceitualmente); `coordenador` não aparece na fila “Sem coordenador”. +- **Primeiro glossário: tooltip Radix ou texto expandido?** **Recomendação:** `Popover`/`HoverCard` com `min-h-11` no gatilho `?` — funciona em touch melhor que hover-only. +- **Ícone AlertTriangle no empty de liderança?** Critique minor: tom alarmista. **Recomendação:** trocar para `UsersIcon` / `InboxIcon` na mesma passagem do empty do coordenador (Fase 4). + +## Abordagem proposta + +```mermaid +flowchart TD + CR["Critique 32/40"] --> F1 + F1["Fase 1 — Triagem em lote
fila Sem coordenador"] + F1 --> F2["Fase 2 — Glossário inline
engajado / estimativa / apoio"] + F2 --> F3["Fase 3 — Outline h2 Filas
dashboard geral"] + F3 --> F4["Fase 4 — Empty coordenador + CTA
(+ ícone liderança)"] + F4 --> F5["Fase 5 — Sidebar layout-transition
(cortável)"] +``` + +### Fase 1 — Triagem em lote (P2) + +- Em **`CampaignDashboard`** / `QueueSection` com `openCoordinatorAssignment`: modo multi-select (checkbox por linha) + barra “Atribuir coordenador (N)”. +- Server action (ou extensão da existente de assign) recebe `nucleusSlugs[]` + `coordinatorIds[]`; access `geral`; `overrideAccess: false`; txn se multi-write. +- Reusa padrões de `CoordinatorAssignment` / shell lazy do detalhe do núcleo — extrair só o necessário para não puxar o editor completo N vezes no client. + +### Fase 2 — Glossário inline (P2) + +- **`CampaignGlossaryHint`** (novo, pequeno): props `termKey`, children trigger. +- Ligar em **`SupportStatusBadge`**, cartão de estimativa (`VoteEstimateCard` / labels no dashboard de liderança), e opcionalmente chip de ZE se ainda houver dúvida após a frase da lista. +- Copy curta aprovável por produto (sem inventar doutrina eleitoral). + +### Fase 3 — Outline do dashboard (P2 a11y) + +- Em **`CampaignDashboard`** (`GeneralDashboard`): promover “Filas de ação” a `h2` visível (hoje só `CardTitle` em `div`); manter títulos de cada fila como `h3`. +- Verificar ordem DOM: h1 → h2 Filas → h3s → h2 Indicadores. + +### Fase 4 — Empty coordenador (P3) + +- Espelhar **`LeadershipDashboard` empty**: `EmptyMedia` + CTA (`Ver meu perfil` e/ou texto “peça à coordenação geral…”). +- Suavizar ícone do empty de liderança (AlertTriangle → ícone não-alarme). + +### Fase 5 — Motion do Sidebar (P3, cortável) + +- Em **`Sidebar.tsx`** (ou wrapper campaign): evitar `transition-[width,height,padding]` em favor de `transform`/`opacity` onde o detector marcou `layout-transition`. +- Só se profiling/UX mostrar jank; caso contrário cortar. + +## Dependências + +- Soft: polish Field Desk (filas, empties base) mergeado. +- Soft: FD+ (shell/heading) melhora consistência mas **não bloqueia** FD2. +- Reusa `CampaignDashboard.tsx`, access de designação de coordenador, `SupportStatusBadge`, tema campaign. +- Sem migration na v1; sem Consent. + +## Não escopo + +- DRY de shell/heading/datas/VoteGoals/filters bag — **FD+** ([escala-dry-pos-field-desk.md](escala-dry-pos-field-desk.md)). +- Saved views genéricas de filtros de lista (além do bulk da fila). +- Quiet da barra vermelha mobile (field-mode documentado). +- Bulk nas filas secundárias do dashboard. +- Push/offline ambient cue no shell (Casey) — candidato a D1/D2 follow-up, não este plano. + +## Referências + +- [`.impeccable/critique/2026-07-19T11-03-23Z__src-app-campaign-campanha.md`](../../.impeccable/critique/2026-07-19T11-03-23Z__src-app-campaign-campanha.md) +- `docs/roadmap.md` (Fill-ins FD2) +- `PRODUCT.md` / `DESIGN.md` — Field Desk, Signal Red, personas Alex/Casey/Lia +- `src/components/campaign/CampaignDashboard.tsx`, `SupportStatusBadge.tsx`, shells de assign de coordenador +- AGENTS.md — campaign auth, `overrideAccess: false`, naming diff --git a/docs/roadmap.md b/docs/roadmap.md index bfe2eb92..1f717ab1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **Reset senha + foto de perfil MVP** implementado (fill-in, Resend + `/campanha/perfil`) — [plano](plans/reset-senha-foto-perfil.md); **RS+ registrado** — escala/DRY pós-reset senha/perfil do `/simplify` — [plano](plans/escala-dry-pos-reset-senha-perfil.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md)) +Atualizado em: 2026-07-19 (MVP + Ciclo 2 deployados; A2 entregue; A4 Baseline no produto + Gap vs 2022 implementado e mesclado em `main` (Fases 2–4 + simplify); A7 registrado — escala/DRY pós-A4 do `/simplify`; C2 engenharia pronta e mesclada em `main`; C3 Planos de Ação implementado e mesclado em `main`; C6 Escala e DRY pós-C2 implementado e mesclado em `main` (Fases 1–5 + simplify); C7 Escala e DRY pós-C3 implementado (Fases 1–5; feed O(n) condicional fora de escopo); C8 Escala e DRY pós-C6 implementado e mesclado em `main` (Fases 1–4 + duas passagens `/simplify`); C9 Escala e DRY pós-C8 implementado e mesclado em `main` (Fases 1–4 + simplify); C10 registrado — escala/DRY pós-C9 do `/simplify`; **C11 registrado** — escala/DRY pós-C7 do `/simplify` — [plano](plans/escala-dry-pos-c7.md); B2 fundação de geometrias do mapa entregue; B5 registrado — escala/DRY pós-B2 do `/simplify`; **Trilha E registrada — E1–E5 mapa de projeção por município**, equiparar/superar as planilhas "Mapa de projeção de votos 2026" — [plano](plans/mapa-projecao-municipios.md); **E2 série TSE 2014/2018 + tendência implementado 2026-07-19**; **E7 registrado** — escala/DRY pós-E2 do `/simplify` — [plano](plans/escala-dry-pos-e2.md); **E6 registrado** — escala/DRY pós-E1+E3 do `/simplify` (aggregate lista/overview, geografia baseline, DRY formatação) — [plano](plans/escala-dry-pos-e1.md); **Onda 0 MVP** — Consent/privacidade provisórios auto-provisionados (migrations) + `/privacidade`; hold de PII real até jurídico — [plano](plans/onda-0.md); **O0+ registrado** — escala/DRY pós-Onda 0 do `/simplify` — [plano](plans/escala-dry-pos-onda0.md); **Visitados recentemente MVP** implementado (fill-in, client-side `localStorage`) — [plano](plans/visitados-recentemente.md); **VR+ registrado** — escala/DRY pós-visitados recentemente do `/simplify` — [plano](plans/escala-dry-pos-visitados-recentemente.md); **Reset senha + foto de perfil MVP** implementado (fill-in, Resend + `/campanha/perfil`) — [plano](plans/reset-senha-foto-perfil.md); **RS+ registrado** — escala/DRY pós-reset senha/perfil do `/simplify` — [plano](plans/escala-dry-pos-reset-senha-perfil.md); **A5 alavancagem da chapa expandido** — Fase 2 “oportunidade de virada” (majoritários esquerda + federal direita por vencedor **ou** por participação proporcional alta) absorvida em [insight-alavancagem-chapa.md](plans/insight-alavancagem-chapa.md), sem item paralelo; **A8 registrado** — perfil médio do eleitorado via IBGE (Censo) + manuais na aba Eleitorado — [plano](plans/perfil-eleitorado-ibge.md); **Field Desk polish** (Impeccable quieter→polish, critique 28→32) em branch local — [PRODUCT.md](../PRODUCT.md) / [DESIGN.md](../DESIGN.md); **FD+ registrado** — escala/DRY pós-Field Desk do `/simplify` — [plano](plans/escala-dry-pos-field-desk.md); **FD2 registrado** — UX pós-critique (bulk fila, glossário, outline, empty coordenador) — [plano](plans/field-desk-ux-pos-critique.md)) Registro canônico no repositório dos planos futuros e débitos conhecidos. Status operacional do ciclo atual de Núcleos fica em [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc); este arquivo lista o que ainda é futuro ou bloqueador, **em ordem de execução**, com dependências e paralelismo explícitos. @@ -121,6 +121,8 @@ Os designs gerados pelo UX Pilot para os próximos ciclos estão em [`docs/desig | O0+ Escala e DRY pós-Onda 0 | — (sem design novo; reusa `/privacidade` + padrão revalidate `posts`) | [escala-dry-pos-onda0.md](plans/escala-dry-pos-onda0.md) | | VR+ Escala e DRY pós-visitados | — (sem design novo; reusa dashboard + padrões de lista de núcleos) | [escala-dry-pos-visitados-recentemente.md](plans/escala-dry-pos-visitados-recentemente.md) | | RS+ Escala e DRY pós-reset senha/perfil | — (sem design novo; reusa login + perfil + padrões de form de campanha) | [escala-dry-pos-reset-senha-perfil.md](plans/escala-dry-pos-reset-senha-perfil.md) | +| FD+ Escala e DRY pós-Field Desk | — (sem design novo; reusa shells/strips Field Desk) | [escala-dry-pos-field-desk.md](plans/escala-dry-pos-field-desk.md) | +| FD2 UX Field Desk pós-critique | — (critique Impeccable; reusa dashboard + badges) | [field-desk-ux-pos-critique.md](plans/field-desk-ux-pos-critique.md) | | B5 Escala e DRY pós-B2 | — (sem design novo; reusa geometrias B2 + padrões de scripts CLI) | [escala-dry-pos-b2.md](plans/escala-dry-pos-b2.md) | | E6 Escala e DRY pós-E1+E3 | — (sem design novo; reusa Metas 2026 + overview B1) | [escala-dry-pos-e1.md](plans/escala-dry-pos-e1.md) | | E7 Escala e DRY pós-E2 | — (sem design novo; reusa Baseline + Insights + overview tendência) | [escala-dry-pos-e2.md](plans/escala-dry-pos-e2.md) | @@ -242,7 +244,7 @@ flowchart TD E6 -.geo cache.-> E7 ``` -Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓), **RS+** (após Reset senha + perfil ✓) e fill-ins (listas globais, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. Fill-ins entregues em branch local (2026-07-19): Visitados recentemente, Reset senha + foto de perfil. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), **A8** (após A1 ✓; suave B2 ✓ para `codarea` — paralelizável agora), E2 (após A4 ✓), **E7 (após E2 merge; Fase 2 int preferível antes de E4)**, **E6 (após merge E1+E3 em `main`)**, B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import; suave: E6 F1 e E7 F2 reduzem custo/risco do overview antes do import em massa). +Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0+** (após engenharia Onda 0 ✓), **VR+** (após Visitados recentemente ✓), **RS+** (após Reset senha + perfil ✓), **FD+** / **FD2** (após polish Field Desk em branch) e fill-ins (listas globais, higiene PascalCase). **Entregues e mesclados em `main` em 2026-07-18/19** (✓ no grafo): A1, A2, A3, A4, B1, B2, C1, C2, C3, C6, C7, C8, C9 e D1 — ver "Ciclo 2" e "Ciclo 2+" acima. Fill-ins entregues em branch local (2026-07-19): Visitados recentemente, Reset senha + foto de perfil. C2 segue com produção bloqueada pelo lote jurídico (Onda 0: Consent keys `apoiador-cadastro` / `apoiador-intencao-voto` + aprovação). Destravados por eles: A5/A6 (após A4 ✓), A7 (após A4 ✓; preferencialmente antes ou com A5/B3/E2), **A8** (após A1 ✓; suave B2 ✓ para `codarea` — paralelizável agora), E2 (após A4 ✓), **E7 (após E2 merge; Fase 2 int preferível antes de E4)**, **E6 (após merge E1+E3 em `main`)**, B3 (B1 ✓ + B2 ✓; suave: A4 ✓ para coroplético e B5 F1 para lazy load), B5 (após B2; preferencialmente com B3), C4 (A1 + C3 prontos), C10 (após C9 ✓; cortável se a base nominal permanecer pequena), C11 (após C7 ✓; cortável se a agenda permanecer pequena; Fase 1 feed condicional) e D2 push (D1 pronto; falta a chave jurídica). E4 destravará após E1+E3 (campos precisam existir antes do import; suave: E6 F1 e E7 F2 reduzem custo/risco do overview antes do import em massa). ### Sequência de execução por janela do calendário @@ -305,10 +307,13 @@ Itens sem seta de entrada (**paralelizáveis a qualquer momento**): E1, E3, **O0 - **VR+ Escala e DRY pós-visitados recentemente** (refresh bfcache/storage, DRY tempo relativo, `CampaignLinkListRow`, shell do dashboard) — engenharia pós-MVP; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-visitados-recentemente.md) - **Reset de senha self-service + foto de perfil** ✓ (Resend + `/campanha/perfil`; MVP entregue 2026-07-19). → [detalhes](plans/reset-senha-foto-perfil.md) - **RS+ Escala e DRY pós-reset senha/perfil** (auth read `depth: 0`, avatar fora da TX, `CampaignAuthPageShell`, schemas de senha unificados, login em `CampaignFormActionState`) — engenharia pós-MVP; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-reset-senha-perfil.md) +- **Field Desk polish** (Impeccable quieter→polish; critique 28→32) — shells/strips/freshness/filas-first/Mais filtros/empties; em branch local 2026-07-19. → `PRODUCT.md` / `DESIGN.md` +- **FD+ Escala e DRY pós-Field Desk** (`CampaignPageShell` size, heading class, VoteGoals→MetricStrip, datas canônicas, NucleusFilters bag) — engenharia pós-polish; paralelizável com qualquer janela. → [detalhes](plans/escala-dry-pos-field-desk.md) +- **FD2 UX Field Desk pós-critique** (bulk na fila Sem coordenador, glossário inline, outline h2 Filas, empty coordenador+CTA, Sidebar motion cortável) — produto/UX pós-critique; _(validar prioridades com produto)_. → [detalhes](plans/field-desk-ux-pos-critique.md) - **Listas globais** (lideranças, atualizações, territórios no nível raiz). _(plano MVP; sem plano detalhado ainda)_ - **Higiene de código:** varredura PascalCase dos componentes legados. _(notebook Núcleos)_ -**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **A8 perfis IBGE** (a aba Eleitorado e `voterProfiles` manuais já existem; o calculado só acelera o ponto de partida), **E7 F1 e F3** (dedup visual/API — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), **RS+ Fases 3–5** (shell auth / campos de senha compartilhados / avatar intent único — Fase 1 auth read é a mais valiosa se `/campanha` ficar lento), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **RS+ Fase 1** (auth read sem join de avatar em rotas operacionais), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend — evita regressão silenciosa antes de E4) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa. +**Cortes seguros se o prazo apertar** (nesta ordem): E5 Salvador por bairro (já registrado como futuro), B4 camada de zonas, B5 F2 (cache CLI — duplicação pequena), E4 import da planilha (os campos E1/E3 podem ser preenchidos à mão pela coordenação; o import só acelera), **A8 perfis IBGE** (a aba Eleitorado e `voterProfiles` manuais já existem; o calculado só acelera o ponto de partida), **E7 F1 e F3** (dedup visual/API — Fase 2 int loader é a mais valiosa antes de E4), **E6 F2–F4** (geografia baseline / DRY UI / tipos dashboard — Fase 1 aggregate só se poucos núcleos; preferir E6 F1 antes de E4 ou B3 se a lista ficar lenta), A7 F2–F3 (`cityCode` / Alert DRY — só se a geografia real dos núcleos permanecer 1–2 municípios), E2 série 2014/2018 (reduzível a só 2018 se o formato TSE de 2014 atrasar; a tendência vira 2 pontos), B3 mapa Leaflet (preferir levar B5 F1 lazy load junto se B3 entrar), C4 demandas, D2 push (mantendo o sino), C6 fases 3–5 (import bulk / preview token / KPI — só se a base nominal permanecer pequena), C8 (escala/DRY pós-C6 — só se a base nominal permanecer pequena; Fases 3–4 DRY podem entrar isoladas), C9 (escala/DRY pós-C8 — só se a base nominal permanecer pequena; Fases 3–4 DRY de forms podem entrar isoladas), C10 (escala/DRY pós-C9 — só se a base nominal permanecer pequena; Fase 3 `errorProps` pode entrar isolada; Fase 1 access dedup é a mais valiosa se coordenadores usarem `/apoiadores` com frequência), C11 (escala/DRY pós-C7 — só se a agenda permanecer pequena; Fase 1 migration `actionPlanUpdate` só com volume medido; Fases 2–3 loaders/selects são as mais valiosas se `/campanha/planos` tiver uso real), **O0+ Fase 4–5** (Lexical/layout shell e Footer condicional — Fases 1–3 preferíveis antes de tráfego real em `/privacidade`), **VR+ Fases 3–5** (row shell, layout wrapper, micro-perf — Fase 1 refresh bfcache é a mais valiosa se usuários reclamarem de painel stale), **RS+ Fases 3–5** (shell auth / campos de senha compartilhados / avatar intent único — Fase 1 auth read é a mais valiosa se `/campanha` ficar lento), **FD+ Fases 3–5** (VoteGoals strip / datas / filtros bag — Fases 1–2 shell size + heading são as de maior ROI de consistência), **FD2 Fase 5** (Sidebar motion — cortável; preferir Fases 1–2 bulk+glossário se Alex reclamar da fila), fill-ins (D1 PWA já entregue, saiu da lista de cortes). Preferir manter C6 fases 1–2, C7 (entregue), C8 fases 1–2 (perf import/lista), C9 Fase 1 (filtro unificado — evita drift KPI/lista), C10 Fase 1 (access dedup na lista), C11 Fases 2–3 (loaders/selects por aba) se a agenda for usada antes de 16/08, **O0+ Fase 1** (revalidate globals pós-migration), **RS+ Fase 1** (auth read sem join de avatar em rotas operacionais), **E6 Fase 1** (aggregate metas/prioridade — evita dupla query na lista antes de E4/B3), **E7 Fase 2** (int do loader combinado gap+trend — evita regressão silenciosa antes de E4) e **A7 F1** (agregar federal no detalhe) se A5/B3/E2 forem entrar — o custo de I/O do loader do A4 é herdado por eles. **Não cortáveis:** Onda 0 (jurídico/Consent), C2 cadastro de apoiadores, C3 eventos/agenda, A4 baseline + gap, **E1+E3 metas/prioridade/estratégia manual** — são respectivamente o risco legal, a base de dados, a operação da propaganda, o instrumento de alocação de esforço e a paridade mínima com a planilha que a campanha já usa. ### Itens consolidados/removidos nesta revisão (2026-07-17) diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx index 52940858..c22749b8 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx @@ -72,7 +72,7 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag {isCampaignGeneral(user) ? 'Coordenação geral' : 'Coordenação de núcleo'} -

{supporter.name}

+

{supporter.name}

{supporter.city ?? 'Município não informado'} {supporter.territory ? ` · ${supporter.territory}` : ''} diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx index d32053d9..f02f11c6 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx @@ -20,7 +20,7 @@ export default async function ImportSupportersPage() { ← Voltar para apoiadores Importação · Coordenação geral -

Importar apoiadores via CSV

+

Importar apoiadores via CSV

Envie uma planilha, confira a prévia e confirme a importação em lote.

diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx index 5854786a..d1eff1c5 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx @@ -29,7 +29,7 @@ export default async function NewSupporterPage() { {isCampaignGeneral(user) ? 'Coordenação geral' : 'Coordenação de núcleo'} -

Novo apoiador

+

Novo apoiador

Cadastre um apoiador com telefone obrigatório e consentimento LGPD.

diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx index 6fd22e57..899fc8f2 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/page.tsx @@ -1,10 +1,11 @@ +import config from '@payload-config' import { FileUpIcon, PlusIcon, SearchXIcon } from 'lucide-react' import Link from 'next/link' import { redirect } from 'next/navigation' -import config from '@payload-config' import { getPayload } from 'payload' import { CampaignListPagination } from '@/components/campaign/CampaignListPagination' +import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' import { SupporterFilters } from '@/components/campaign/SupporterFilters' import { SupporterList } from '@/components/campaign/SupporterList' @@ -21,13 +22,13 @@ import { import { isCampaignGeneral } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { loadSupportersPageData } from '@/utilities/supporterPageData' -import { toSupporterListItemViewModel } from '@/utilities/supporterViewModels' import { buildSupporterFiltersKey, buildSupporterListHref, canAccessSupporterArea, getSupporterScopeLabel, } from '@/utilities/supporterUi' +import { toSupporterListItemViewModel } from '@/utilities/supporterViewModels' type SupportersPageProps = { searchParams: Promise> @@ -40,6 +41,7 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro if (!canAccessSupporterArea(user.role)) redirect('/campanha') const rawSearchParams = await searchParams + const now = new Date() const { result, state, redirectHref, nucleusOptions, overview } = await loadSupportersPageData( payload, user, @@ -48,10 +50,10 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro if (redirectHref) redirect(redirectHref) return ( -
+
-

Apoiadores

+

Apoiadores

Base nominal de apoio com intenção de voto e vínculo opcional a núcleos.

@@ -87,7 +89,7 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro {result.docs.length ? ( <> - {overview ? : null} + {overview ? : null} toSupporterListItemViewModel(supporter))} /> @@ -121,6 +123,6 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro )} -
+ ) } diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx index 3bd451f3..771ab7a8 100644 --- a/src/app/(campaign)/campanha/(app)/layout.tsx +++ b/src/app/(campaign)/campanha/(app)/layout.tsx @@ -34,7 +34,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
{children}
diff --git a/src/app/(campaign)/campanha/(app)/loading.tsx b/src/app/(campaign)/campanha/(app)/loading.tsx index c6f2dbee..8910d92b 100644 --- a/src/app/(campaign)/campanha/(app)/loading.tsx +++ b/src/app/(campaign)/campanha/(app)/loading.tsx @@ -1,27 +1,19 @@ +import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' import { Skeleton } from '@/components/ui/skeleton' export default function CampaignDashboardLoading() { return ( -
-
+ +
- +
-
- {Array.from({ length: 5 }, (_, index) => ( - - ))} + +
+ + +
- -
- {Array.from({ length: 3 }, (_, index) => ( - - ))} -
-
+
) } diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/editar/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/editar/page.tsx index 16edbcc6..b64399d1 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/editar/page.tsx +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/editar/page.tsx @@ -34,7 +34,7 @@ export default async function EditNucleusPage({ params }: EditNucleusPageProps) : 'Edição · Núcleo sob sua coordenação'}

Núcleos Eleitorais

-

Editar {view.name}

+

Editar {view.name}

Atualize o território sem alterar o nome ou as estimativas confirmadas.

diff --git a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx index e4c37ce6..8c91551e 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/nucleos/[slug]/page.tsx @@ -94,7 +94,7 @@ export default async function NucleusDetailPage({ params, searchParams }: Nucleu

{view.name}

diff --git a/src/app/(campaign)/campanha/(app)/nucleos/loading.tsx b/src/app/(campaign)/campanha/(app)/nucleos/loading.tsx index 79f1d635..8c2aeb3b 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/loading.tsx +++ b/src/app/(campaign)/campanha/(app)/nucleos/loading.tsx @@ -1,18 +1,16 @@ +import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' import { Skeleton } from '@/components/ui/skeleton' export default function NucleiLoading() { return ( -
-
+ +
- +
- - -
+ + + + ) } diff --git a/src/app/(campaign)/campanha/(app)/nucleos/novo/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/novo/page.tsx index 5148a323..d86dc7d3 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/novo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/nucleos/novo/page.tsx @@ -21,7 +21,7 @@ export default async function NewNucleusPage() {
Criação · Coordenação geral

Núcleos Eleitorais

-

Novo núcleo

+

Novo núcleo

Defina a operação, o território obrigatório e quem responde por ele.

diff --git a/src/app/(campaign)/campanha/(app)/nucleos/page.tsx b/src/app/(campaign)/campanha/(app)/nucleos/page.tsx index 77d5d877..531f8f61 100644 --- a/src/app/(campaign)/campanha/(app)/nucleos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/nucleos/page.tsx @@ -5,8 +5,9 @@ import Link from 'next/link' import { redirect } from 'next/navigation' import { getPayload } from 'payload' -import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' import { CampaignListPagination } from '@/components/campaign/CampaignListPagination' +import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' +import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' import { NucleusFilters } from '@/components/campaign/NucleusFilters' import { NucleusList } from '@/components/campaign/NucleusList' import { NucleusListOverview } from '@/components/campaign/NucleusListOverview' @@ -58,10 +59,10 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) { const listVisitLabel = buildNucleusListVisitLabel(state) return ( -
+
-

Núcleos Eleitorais

+

Núcleos Eleitorais

Núcleo é a operação da campanha; Zona TSE é a circunscrição oficial.

@@ -89,7 +90,7 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) { toNucleusListViewModel(nucleus as ElectoralNucleus), )} /> -
+

{result.totalDocs}{' '} {result.totalDocs === 1 ? 'núcleo encontrado' : 'núcleos encontrados'} @@ -128,6 +129,6 @@ export default async function NucleiPage({ searchParams }: NucleiPageProps) { }} /> ) : null} -

+ ) } diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx index 62ec3c1a..569f01ca 100644 --- a/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx +++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/editar/page.tsx @@ -42,7 +42,7 @@ export default async function EditActionPlanPage({ params }: EditActionPlanPageP

Planos de ação

-

Editar {view.title}

+

Editar {view.title}

Atualize os detalhes do plano sem alterar o título original.

diff --git a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx index babdb5c6..5d461f0a 100644 --- a/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/planos/[slug]/page.tsx @@ -69,7 +69,7 @@ export default async function ActionPlanDetailPage({
-

{view.title}

+

{view.title}

diff --git a/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx b/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx index c726f33b..7533002b 100644 --- a/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/planos/novo/page.tsx @@ -23,7 +23,7 @@ export default async function NewActionPlanPage() {

Planos de ação

-

Novo plano

+

Novo plano

Defina a ação, quando e onde ela acontece e quem responde por ela.

diff --git a/src/app/(campaign)/campanha/(app)/planos/page.tsx b/src/app/(campaign)/campanha/(app)/planos/page.tsx index efa88550..eebf93f4 100644 --- a/src/app/(campaign)/campanha/(app)/planos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/planos/page.tsx @@ -7,6 +7,7 @@ import { getPayload } from 'payload' import { ActionPlanFilters } from '@/components/campaign/ActionPlanFilters' import { ActionPlanList } from '@/components/campaign/ActionPlanList' import { CampaignListPagination } from '@/components/campaign/CampaignListPagination' +import { CampaignPageShell } from '@/components/campaign/CampaignPageShell' import { Button } from '@/components/ui/button' import { Empty, @@ -17,10 +18,14 @@ import { EmptyTitle, } from '@/components/ui/Empty' import type { ActionPlan } from '@/payload-types' -import { getCampaignUser } from '@/utilities/campaignAuth' import { loadActionPlanListPageData } from '@/utilities/actionPlanPageData' -import { buildActionPlanFiltersKey, buildActionPlanListHref, resolveActionPlanListUrl } from '@/utilities/actionPlanUi' +import { + buildActionPlanFiltersKey, + buildActionPlanListHref, + resolveActionPlanListUrl, +} from '@/utilities/actionPlanUi' import { toActionPlanListViewModel } from '@/utilities/actionPlanViewModels' +import { getCampaignUser } from '@/utilities/campaignAuth' type ActionPlanListPageProps = { searchParams: Promise> @@ -42,10 +47,10 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis const canCreate = user.role === 'geral' || user.role === 'coordenador' return ( -
+
-

Planos de ação

+

Planos de ação

Organize caminhadas, comícios, panfletagens e demais ações de campanha.

@@ -69,7 +74,8 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis />

- {result.totalDocs} {result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'} + {result.totalDocs}{' '} + {result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}

)} -
+ ) } diff --git a/src/components/campaign/CampaignDashboard.tsx b/src/components/campaign/CampaignDashboard.tsx index 89979745..be9a70c4 100644 --- a/src/components/campaign/CampaignDashboard.tsx +++ b/src/components/campaign/CampaignDashboard.tsx @@ -8,6 +8,12 @@ import { } from 'lucide-react' import Link from 'next/link' +import { CampaignDataFreshness } from '@/components/campaign/CampaignDataFreshness' +import { CampaignMetricStrip } from '@/components/campaign/CampaignMetricStrip' +import { + CampaignPageShell, + campaignPrioritySurfaceClassName, +} from '@/components/campaign/CampaignPageShell' import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge' import { NucleusCard } from '@/components/campaign/NucleusCard' import { RecentlyVisited } from '@/components/campaign/RecentlyVisited' @@ -28,29 +34,28 @@ import { EmptyContent, EmptyDescription, EmptyHeader, + EmptyMedia, EmptyTitle, } from '@/components/ui/Empty' -import { Progress } from '@/components/ui/Progress' import { actionPlanKindLabels } from '@/lib/schemas/actionPlan' import { leadershipSupportStatuses } from '@/lib/schemas/leadership' import type { ActionPlanUpcomingPreviewRecord } from '@/utilities/actionPlanUpcomingPreview' -import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' import type { DashboardQueueItem, GeneralDashboardViewModel, ScopedDashboardViewModel, } from '@/utilities/campaignDashboardViewModels' +import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' +import { formatRelativeAge } from '@/utilities/formatRelativeAge' import { buildLeadershipPanelHref } from '@/utilities/leadershipUi' import { getCampaignScopeLabel } from '@/utilities/nucleusUi' import { buildWhatsAppUrl } from '@/utilities/phone' const numberFormatter = new Intl.NumberFormat('pt-BR') -const relativeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' }) const lastUpdateLabel = (value: string | null, now: Date): string => { if (!value) return 'Nenhuma atualização' - const days = Math.floor((new Date(value).getTime() - now.getTime()) / (24 * 60 * 60 * 1000)) - return `Última atualização ${relativeFormatter.format(days, 'day')}` + return `Última atualização ${formatRelativeAge(new Date(value).getTime(), now.getTime())}` } export const buildDashboardQueueItemHref = ( @@ -58,68 +63,52 @@ export const buildDashboardQueueItemHref = ( openCoordinatorAssignment: boolean, ): string => `/campanha/nucleos/${slug}${openCoordinatorAssignment ? '?assignCoordinators=1' : ''}` -const KpiCard = ({ - label, - value, - progress, -}: { - label: string - value: string - progress?: number -}) => ( - - - {label} - {value} - - {progress !== undefined ? ( - - - - ) : null} - -) - -const QueueList = ({ +const QueueSection = ({ title, items, empty, + priority = false, openCoordinatorAssignment = false, }: { title: string items: DashboardQueueItem[] empty: string + priority?: boolean openCoordinatorAssignment?: boolean }) => ( - - - {title} - {items.length} - - - {items.length ? ( -
    - {items.map((item) => ( -
  • - -
  • - ))} -
- ) : ( -

{empty}

- )} -
-
+ +
) const UpcomingActionPlansCard = ({ plans }: { plans: ActionPlanUpcomingPreviewRecord[] }) => ( @@ -135,9 +124,12 @@ const UpcomingActionPlansCard = ({ plans }: { plans: ActionPlanUpcomingPreviewRe {plans.length ? ( -
    +
      {plans.map((plan) => ( -
    • +
    • {actionPlanKindLabels[plan.kind]} @@ -162,10 +154,10 @@ const UpcomingActionPlansCard = ({ plans }: { plans: ActionPlanUpcomingPreviewRe ) const GeneralDashboard = ({ view, now }: { view: GeneralDashboardViewModel; now: Date }) => ( -
      +
      -

      Visão geral da campanha

      +

      Visão geral da campanha

      Cobertura, mobilização e pendências dos núcleos.

      {getCampaignScopeLabel('geral', view.kpis.activeNuclei)} @@ -181,79 +173,93 @@ const GeneralDashboard = ({ view, now }: { view: GeneralDashboardViewModel; now: -
      -

      - Indicadores da campanha -

      -
      - - - - - - -
      +
      + + + Filas de ação + + Trate a prioridade primeiro; as demais filas vêm em seguida. + + + + + + + +
      - - - Lideranças por status de apoio - - +
      +
      +

      + Indicadores +

      + +
      + + +
      + Lideranças {leadershipSupportStatuses.map((status) => (
      - + {numberFormatter.format(view.supportCounts[status])} - +
      ))} - - - -
      -

      - Filas de ação -

      -
      - - -
      -
      + ) const CoordinatorDashboard = ({ @@ -263,10 +269,10 @@ const CoordinatorDashboard = ({ view: Extract now: Date }) => ( -
      +
      {getCampaignScopeLabel(view.role, view.cards.length)} -

      Sua região

      +

      Sua região

      O que precisa de atenção nos seus núcleos?

      @@ -310,14 +316,18 @@ const CoordinatorDashboard = ({ ) : ( + + Nenhum núcleo sob sua coordenação - A coordenação geral ainda não atribuiu um núcleo a você. + A coordenação geral ainda não atribuiu um núcleo a você. Peça que te incluam como + coordenador no núcleo — aí a região aparece aqui. )} -
      + ) const LeadershipDashboard = ({ @@ -327,10 +337,10 @@ const LeadershipDashboard = ({ view: Extract now: Date }) => ( -
      +
      {getCampaignScopeLabel(view.role, view.cards.length)} -

      Meus núcleos

      +

      Meus núcleos

      Envie seu reporte e acompanhe a estimativa confirmada.

      @@ -350,19 +360,28 @@ const LeadershipDashboard = ({ {[card.organization, card.territory].filter(Boolean).join(' · ')} - +
      {card.tseZones.map((zoneNumber) => ( ))}
      -

      - - {card.confirmedVoteEstimate === null - ? 'Sem estimativa confirmada' - : `${numberFormatter.format(card.confirmedVoteEstimate)} votos`} - +

      + {card.confirmedVoteEstimate === null + ? 'Sem estimativa confirmada' + : `${numberFormatter.format(card.confirmedVoteEstimate)} votos`}

      + {card.confirmedVoteEstimate === null ? ( +

      + Sugira na página do núcleo; a coordenação confirma o número. +

      + ) : null}

      Coordenação

      {card.coordinators.length ? ( @@ -406,17 +425,27 @@ const LeadershipDashboard = ({ ) : ( - Nenhum núcleo disponível + + + Ainda sem núcleo por aqui - Seu acesso depende de um vínculo engajado com o núcleo. + Seu acesso a um núcleo só aparece depois que a coordenação te cadastra como liderança e + marca o apoio como engajado (participando de verdade). Peça isso ao coordenador — o + mesmo caminho de quem entra só com o celular. - )} -
      + ) export const CampaignDashboard = ({ diff --git a/src/components/campaign/CampaignDataFreshness.tsx b/src/components/campaign/CampaignDataFreshness.tsx new file mode 100644 index 00000000..805015d0 --- /dev/null +++ b/src/components/campaign/CampaignDataFreshness.tsx @@ -0,0 +1,15 @@ +import { cn } from '@/lib/utils' +import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' + +/** Request-time aggregate stamp — absolute Bahia time on hover. */ +export const CampaignDataFreshness = ({ asOf, className }: { asOf: Date; className?: string }) => { + const iso = asOf.toISOString() + + return ( +

      + +

      + ) +} diff --git a/src/components/campaign/CampaignMetricStrip.tsx b/src/components/campaign/CampaignMetricStrip.tsx new file mode 100644 index 00000000..99a5a197 --- /dev/null +++ b/src/components/campaign/CampaignMetricStrip.tsx @@ -0,0 +1,53 @@ +import { Progress } from '@/components/ui/Progress' +import { cn } from '@/lib/utils' + +export type CampaignMetric = { + label: string + value: string + emphasize?: boolean + progress?: number +} + +export const CampaignMetricStrip = ({ + metrics, + className, +}: { + metrics: CampaignMetric[] + className?: string +}) => ( +
      + {metrics.map((metric) => ( +
      +
      {metric.label}
      +
      + {metric.value} +
      + {metric.progress !== undefined ? ( + + ) : null} +
      + ))} +
      +) diff --git a/src/components/campaign/CampaignPageShell.tsx b/src/components/campaign/CampaignPageShell.tsx new file mode 100644 index 00000000..29811906 --- /dev/null +++ b/src/components/campaign/CampaignPageShell.tsx @@ -0,0 +1,21 @@ +import type { ComponentProps, ReactNode } from 'react' + +import { cn } from '@/lib/utils' + +export const campaignPageShellClassName = 'mr-auto flex w-full max-w-screen-2xl flex-col gap-8' + +export const campaignPrioritySurfaceClassName = + 'shadow-[0_4px_24px_rgb(28_25_23/0.08)] ring-1 ring-foreground/10' + +export const CampaignPageShell = ({ + children, + className, + ...props +}: { + children: ReactNode + className?: string +} & Omit, 'children' | 'className'>) => ( +
      + {children} +
      +) diff --git a/src/components/campaign/CampaignProfileSettings.tsx b/src/components/campaign/CampaignProfileSettings.tsx index bdf6caa1..2ebda824 100644 --- a/src/components/campaign/CampaignProfileSettings.tsx +++ b/src/components/campaign/CampaignProfileSettings.tsx @@ -13,7 +13,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field' import { Input } from '@/components/ui/input' import { Spinner } from '@/components/ui/Spinner' -import type { CampaignUser } from '@/payload-types' import type { CampaignFormActionState } from '@/utilities/campaignFormActionError' import { fieldError } from '@/utilities/campaignFormFields' import { campaignRoleLabels, type CampaignUserShellView } from '@/utilities/campaignUserProfile' @@ -137,7 +136,12 @@ export const CampaignProfileSettings = ({ {user.avatarUrl ? (
      -
      @@ -151,7 +155,7 @@ export const CampaignProfileSettings = ({ {user.email ? 'Use sua senha atual para definir uma nova.' - : 'Se você esqueceu a senha, peça um novo convite de acesso ao coordenador.'} + : 'Se você esqueceu a senha e acessa só com celular, peça um novo convite de acesso ao coordenador.'} @@ -200,7 +204,9 @@ export const CampaignProfileSettings = ({ {fieldError(passwordState.fieldErrors, 'password')} ) : null} {fieldError(passwordState.fieldErrors, 'passwordConfirmation') ? ( - {fieldError(passwordState.fieldErrors, 'passwordConfirmation')} + + {fieldError(passwordState.fieldErrors, 'passwordConfirmation')} + ) : null} - -
      -
      - -
      -

      - Cada escolha atualiza os resultados e fica na URL para compartilhamento. -

      - + + +
      + + +
      + {hasFilters ? ( -
      - -
      + ) : null}
      + +
      ) diff --git a/src/components/campaign/NucleusListOverview.tsx b/src/components/campaign/NucleusListOverview.tsx index 2d73e0c3..5bb339c3 100644 --- a/src/components/campaign/NucleusListOverview.tsx +++ b/src/components/campaign/NucleusListOverview.tsx @@ -1,21 +1,19 @@ import Link from 'next/link' +import { CampaignDataFreshness } from '@/components/campaign/CampaignDataFreshness' +import { CampaignMetricStrip, type CampaignMetric } from '@/components/campaign/CampaignMetricStrip' +import { campaignPrioritySurfaceClassName } from '@/components/campaign/CampaignPageShell' import { Badge } from '@/components/ui/Badge' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Progress } from '@/components/ui/Progress' -import { comparableTrendCount, formatElectionNumber } from '@/lib/electionInsights' +import { formatElectionNumber } from '@/lib/electionInsights' import { actionPlanKindLabels } from '@/lib/schemas/actionPlan' import { formatBahiaDateTimeLabel } from '@/utilities/campaignTime' +import { formatRelativeAge } from '@/utilities/formatRelativeAge' import type { NucleusListOverviewViewModel } from '@/utilities/nucleusListOverviewViewModels' import { nucleusUpdateKindLabels } from '@/utilities/nucleusUpdateUi' const numberFormatter = new Intl.NumberFormat('pt-BR') -const relativeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' }) - -const relativeDateLabel = (value: string, now: Date): string => { - const days = Math.floor((new Date(value).getTime() - now.getTime()) / (24 * 60 * 60 * 1000)) - return relativeFormatter.format(days, 'day') -} export const NucleusListOverview = ({ view, @@ -23,30 +21,43 @@ export const NucleusListOverview = ({ }: { view: NucleusListOverviewViewModel now: Date -}) => ( -
      -
      -

      - Visão geral dos núcleos filtrados -

      -

      - Mostrando agregados de{' '} - - {numberFormatter.format(view.totalFiltered)} - {' '} - {view.totalFiltered === 1 ? 'núcleo filtrado' : 'núcleos filtrados'} -

      -
      +}) => { + const secondaryMetrics: CampaignMetric[] = [ + { + label: 'Cobertura com coordenador', + value: `${view.coverage.percent}%`, + progress: view.coverage.percent, + }, + { + label: 'Meta regular 2026', + value: numberFormatter.format(view.voteGoals.regular), + }, + ] -
      - + return ( +
      +
      +

      + Visão geral dos núcleos filtrados +

      +

      + Mostrando agregados de{' '} + + {numberFormatter.format(view.totalFiltered)} + {' '} + {view.totalFiltered === 1 ? 'núcleo filtrado' : 'núcleos filtrados'} +

      + +
      + + - Estimativa de votos - + Estimativa de votos (prioridade) + {numberFormatter.format(view.estimate.confirmedTotal)} - +
      {view.estimate.confirmedPercent}% com estimativa confirmada @@ -68,182 +79,125 @@ export const NucleusListOverview = ({ : 'sugestões pendentes'} ) : null} - - - - - - Cobertura - {view.coverage.percent}% - - -

      {numberFormatter.format(view.coverage.coordinatedCount)} de{' '} {numberFormatter.format(view.totalFiltered)} com coordenador + {view.highPriorityCount > 0 + ? ` · ${numberFormatter.format(view.highPriorityCount)} prioritários` + : ''}

      -
      -
      - - - - Metas 2026 - - {numberFormatter.format(view.voteGoals.regular)} - - - -

      - Soma do cenário regular no conjunto filtrado -

      -

      - Bom: {numberFormatter.format(view.voteGoals.good)} · Mínimo:{' '} - {numberFormatter.format(view.voteGoals.minimum)} -

      -

      - {numberFormatter.format(view.highPriorityCount)}{' '} - {view.highPriorityCount === 1 ? 'núcleo prioritário' : 'núcleos prioritários'} -

      -
      -
      - - {view.baseline2022 ? ( - - - Baseline 2022 - - {view.baseline2022.gapTotal === null - ? '—' - : `${view.baseline2022.gapTotal >= 0 ? '+' : ''}${numberFormatter.format(view.baseline2022.gapTotal)}`} - - - + {view.baseline2022 && view.baseline2022.gapTotal !== null ? (

      - {view.baseline2022.gapTotal === null - ? 'Sem estimativas comparáveis ao patamar de 2022' - : view.baseline2022.gapTotal >= 0 - ? 'votos acima do patamar' - : 'votos abaixo do patamar'} -

      -

      - + Baseline 2022:{' '} + {numberFormatter.format(view.baseline2022.above)} acima {' · '} - + {numberFormatter.format(view.baseline2022.below)} abaixo + {' · '} + + {view.baseline2022.gapTotal >= 0 ? '+' : ''} + {numberFormatter.format(view.baseline2022.gapTotal)} gap +

      -
      -
      - ) : null} - - {view.trend ? ( - - - Tendência histórica - - {formatElectionNumber(comparableTrendCount(view.trend))} - - - + ) : null} + {view.trend ? (

      - Núcleos com série comparável (2014/2018/2022) -

      -

      - + Tendência:{' '} + {formatElectionNumber(view.trend.increase)} aumento {' · '} - + {formatElectionNumber(view.trend.stable)} mantém {' · '} - + {formatElectionNumber(view.trend.decline)} queda

      - {view.trend.noBaseline > 0 ? ( -

      - {formatElectionNumber(view.trend.noBaseline)} sem série suficiente -

      - ) : null} + ) : null} +
      +
      + +
      +

      Demais indicadores

      + +
      + +
      + + + Últimas atualizações + + + {view.recentUpdates.length ? ( +
        + {view.recentUpdates.map((update) => ( +
      • +
        + {update.authorName} + + {nucleusUpdateKindLabels[update.kind]} + + + {formatRelativeAge(new Date(update.createdAt).getTime(), now.getTime())} + +
        + + {update.nucleusName} + +
      • + ))} +
      + ) : ( +

      Nenhuma atualização recente

      + )}
      - ) : null} -
      - - - Últimas atualizações - - - {view.recentUpdates.length ? ( -
        - {view.recentUpdates.map((update) => ( -
      • -
        - {update.authorName} - - {nucleusUpdateKindLabels[update.kind]} - - - {relativeDateLabel(update.createdAt, now)} - -
        - - {update.nucleusName} - -
      • - ))} -
      - ) : ( -

      Nenhuma atualização recente

      - )} -
      -
      - - - - Próximos eventos - - - {view.upcomingActionPlans.length ? ( -
        - {view.upcomingActionPlans.map((plan) => ( -
      • -
        - {actionPlanKindLabels[plan.kind]} - - {formatBahiaDateTimeLabel(plan.startAt)} - {plan.city ? ` · ${plan.city}` : ''} - -
        - - {plan.title} - -
      • - ))} -
      - ) : ( -

      Nenhum evento agendado

      - )} -
      -
      -
      -) + + + Próximos eventos + + + {view.upcomingActionPlans.length ? ( +
        + {view.upcomingActionPlans.map((plan) => ( +
      • +
        + {actionPlanKindLabels[plan.kind]} + + {formatBahiaDateTimeLabel(plan.startAt)} + {plan.city ? ` · ${plan.city}` : ''} + +
        + + {plan.title} + +
      • + ))} +
      + ) : ( +

      Nenhum evento agendado

      + )} +
      +
      +
      +
      + ) +} diff --git a/src/components/campaign/NucleusUpdateFeed.tsx b/src/components/campaign/NucleusUpdateFeed.tsx index f435b23b..433ce0e4 100644 --- a/src/components/campaign/NucleusUpdateFeed.tsx +++ b/src/components/campaign/NucleusUpdateFeed.tsx @@ -1,10 +1,11 @@ -import Link from 'next/link' import { ChevronLeftIcon, ChevronRightIcon, MessageSquareTextIcon } from 'lucide-react' +import Link from 'next/link' import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/Empty' +import { formatRelativeAge } from '@/utilities/formatRelativeAge' import type { NucleusUpdatesPageData } from '@/utilities/nucleusUpdatePageData' import { buildNucleusUpdateHref, @@ -27,13 +28,6 @@ const updateFilters: Array<{ label: string; state: NucleusUpdateListState }> = [ })), ] -const relativeDate = (value: string, now: string): string => { - const elapsedDays = Math.round( - (new Date(value).getTime() - new Date(now).getTime()) / (24 * 60 * 60 * 1000), - ) - return new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' }).format(elapsedDays, 'day') -} - const UpdateCard = ({ update, now }: { update: NucleusUpdateViewModel; now: string }) => (
      @@ -43,7 +37,7 @@ const UpdateCard = ({ update, now }: { update: NucleusUpdateViewModel; now: stri {nucleusUpdateKindLabels[update.kind]} - {relativeDate(update.createdAt, now)} + {formatRelativeAge(new Date(update.createdAt).getTime(), new Date(now).getTime())}
      {update.authorName} diff --git a/src/components/campaign/SupporterListOverview.tsx b/src/components/campaign/SupporterListOverview.tsx index 4d3a94b3..dd4c4aa6 100644 --- a/src/components/campaign/SupporterListOverview.tsx +++ b/src/components/campaign/SupporterListOverview.tsx @@ -1,31 +1,39 @@ +import { CampaignDataFreshness } from '@/components/campaign/CampaignDataFreshness' +import { CampaignMetricStrip } from '@/components/campaign/CampaignMetricStrip' import type { SupporterListOverviewViewModel } from '@/utilities/supporterViewModels' const numberFormatter = new Intl.NumberFormat('pt-BR') export const SupporterListOverview = ({ view, + now, }: { view: SupporterListOverviewViewModel + now: Date }) => ( -
      -

      - Indicadores dos apoiadores filtrados -

      -
      -

      {numberFormatter.format(view.total)}

      -

      Total

      -
      -
      -

      - {numberFormatter.format(view.certoAndTende)} -

      -

      Certo + Tende

      -
      -
      -

      - {numberFormatter.format(view.indeciso)} -

      -

      Indecisos

      +
      +
      +

      + Indicadores dos apoiadores filtrados +

      +
      +
      ) diff --git a/tests/e2e/campaign-composition-accessibility.e2e.spec.ts b/tests/e2e/campaign-composition-accessibility.e2e.spec.ts index cfcd0c90..daa57109 100644 --- a/tests/e2e/campaign-composition-accessibility.e2e.spec.ts +++ b/tests/e2e/campaign-composition-accessibility.e2e.spec.ts @@ -1,5 +1,5 @@ /* eslint-disable check-file/filename-naming-convention */ -import { test, expect } from './fixtures/campaignE2EFixtures' +import { expect, test } from './fixtures/campaignE2EFixtures' test.describe('Campaign composition and accessibility', () => { test.describe.configure({ mode: 'parallel' }) @@ -82,18 +82,26 @@ test.describe('Campaign composition and accessibility', () => { await campaign.login(page, email, password) await page.setViewportSize({ width: 390, height: 844 }) await page.goto(`${campaign.baseURL}/campanha/nucleos`) - const toggle = page.getByRole('button', { name: 'Filtros' }) - const controls = page.locator('#nucleus-filter-controls') + + await expect(page.getByLabel('Território de identidade')).toBeVisible() + await expect(page.getByLabel('Município')).toBeVisible() + + const toggle = page.getByRole('button', { name: 'Mais filtros' }) + const controls = page.locator('#nucleus-advanced-filters') await expect(controls).toBeHidden() await toggle.click() await expect(controls).toBeVisible() + await expect(page.getByLabel('Cobertura')).toBeVisible() await expect(page.getByLabel('Território de identidade')).toHaveCount(1) await toggle.click() await expect(controls).toBeHidden() await page.setViewportSize({ width: 1440, height: 900 }) - await expect(toggle).toBeHidden() - await expect(controls).toBeVisible() await expect(page.getByLabel('Território de identidade')).toBeVisible() + await expect(toggle).toBeVisible() + await expect(controls).toBeHidden() + await toggle.click() + await expect(controls).toBeVisible() + await expect(page.getByLabel('Cobertura')).toBeVisible() }) }) diff --git a/tests/unit/campaignNucleusFilters.unit.spec.ts b/tests/unit/campaignNucleusFilters.unit.spec.ts index 7ed8aea0..82943011 100644 --- a/tests/unit/campaignNucleusFilters.unit.spec.ts +++ b/tests/unit/campaignNucleusFilters.unit.spec.ts @@ -1,5 +1,5 @@ -import { createElement } from 'react' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { createElement } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const replace = vi.fn() @@ -105,29 +105,42 @@ describe('campaign nucleus automatic filters', () => { } }) - it('uses a 44px semantic mobile disclosure button controlling the single filters tree', () => { - render(createElement(NucleusFilters, { state: initialState })) + it('keeps territory filters visible and discloses advanced filters behind Mais filtros', () => { + render(createElement(NucleusFilters, { state: { page: 1 } })) - const button = screen.getByRole('button', { name: 'Filtros' }) + expect(screen.getByLabelText('Território de identidade')).toBeTruthy() + expect(screen.getByLabelText('Município')).toBeTruthy() + expect(screen.getByLabelText('Nº da ZE')).toBeTruthy() + + const button = screen.getByRole('button', { name: 'Mais filtros' }) const controlsID = button.getAttribute('aria-controls') const controls = controlsID ? document.getElementById(controlsID) : null expect(document.querySelector('details')).toBeNull() expect(button.className).toContain('min-h-11') - expect(button.className).toContain('lg:hidden') expect(button.getAttribute('aria-expanded')).toBe('false') expect(controls).not.toBeNull() - expect(controls?.className).toContain('hidden') - expect(controls?.className).toContain('lg:block') + expect(controls?.hasAttribute('hidden')).toBe(true) + expect(controls?.getAttribute('hidden')).not.toBeNull() fireEvent.click(button) expect(button.getAttribute('aria-expanded')).toBe('true') - expect(controls?.className).toContain('block') + expect(controls?.hasAttribute('hidden')).toBe(false) + expect(screen.getByLabelText('Cobertura')).toBeTruthy() expect(screen.getAllByLabelText('Território de identidade')).toHaveLength(1) fireEvent.click(button) expect(button.getAttribute('aria-expanded')).toBe('false') - expect(controls?.className).toContain('hidden') + expect(controls?.hasAttribute('hidden')).toBe(true) + }) + + it('opens advanced filters by default when coverage/estimate/priority are already in the URL', () => { + render(createElement(NucleusFilters, { state: initialState })) + + expect( + screen.getByRole('button', { name: /Mais filtros \(2\)/ }).getAttribute('aria-expanded'), + ).toBe('true') + expect(screen.getByLabelText('Cobertura')).toBeTruthy() }) it('applies selects and valid ZE immediately while preserving the other URL filters', async () => { @@ -150,13 +163,13 @@ describe('campaign nucleus automatic filters', () => { it.each(['01', '5e1', '58.0', '+58', '-58', ' 58', '58 ', '1000'])( 'rejects client ZE value %j without replacing the server query', (value) => { - render(createElement(NucleusFilters, { state: initialState })) + render(createElement(NucleusFilters, { state: initialState })) - const zone = screen.getByLabelText('Nº da ZE') + const zone = screen.getByLabelText('Nº da ZE') fireEvent.change(zone, { target: { value } }) - expect(replace).not.toHaveBeenCalled() - expect(screen.getByText('Informe uma Zona Eleitoral de 1 a 999.')).toBeTruthy() + expect(replace).not.toHaveBeenCalled() + expect(screen.getByText('Informe uma Zona Eleitoral de 1 a 999.')).toBeTruthy() }, ) @@ -198,12 +211,11 @@ describe('campaign nucleus automatic filters', () => { }), ) - expect( - (screen.getByLabelText('Território de identidade') as HTMLInputElement).value, - ).toBe('Itaparica') - expect((screen.getByLabelText('Município') as HTMLInputElement).value).toBe( - 'Paulo Afonso', + expect((screen.getByLabelText('Território de identidade') as HTMLInputElement).value).toBe( + 'Itaparica', ) + expect((screen.getByLabelText('Município') as HTMLInputElement).value).toBe('Paulo Afonso') + fireEvent.click(screen.getByRole('button', { name: 'Mais filtros' })) expect((screen.getByLabelText('Cobertura') as HTMLSelectElement).value).toBe('') expect( (screen.getByLabelText('Buscar núcleo ou número da Zona TSE') as HTMLInputElement).value, @@ -225,15 +237,15 @@ describe('campaign nucleus automatic filters', () => { it('removes non-canonical state before emitting a client update', async () => { render(createElement(NucleusFilters, { state: { page: 1 } })) + fireEvent.click(screen.getByRole('button', { name: 'Mais filtros' })) fireEvent.change(screen.getByLabelText('Cobertura'), { target: { value: 'com_coordenador' }, }) await waitFor(() => - expect(replace).toHaveBeenLastCalledWith( - '/campanha/nucleos?coverage=com_coordenador', - { scroll: false }, - ), + expect(replace).toHaveBeenLastCalledWith('/campanha/nucleos?coverage=com_coordenador', { + scroll: false, + }), ) }) @@ -387,9 +399,11 @@ describe('campaign nucleus server filter parsing', () => { const resolved = resolveNucleusListUrl(params) expect(resolved.redirectHref).toBeUndefined() - expect(resolveNucleusListUrl(Object.fromEntries(new URL(resolved.href, 'https://teqo.test').searchParams))).toEqual( - resolved, - ) + expect( + resolveNucleusListUrl( + Object.fromEntries(new URL(resolved.href, 'https://teqo.test').searchParams), + ), + ).toEqual(resolved) }) it('redirects valid parameters supplied out of canonical order', () => { @@ -400,9 +414,7 @@ describe('campaign nucleus server filter parsing', () => { region: 'Chapada Diamantina', q: 'Chapada', }).redirectHref, - ).toBe( - '/campanha/nucleos?q=Chapada®ion=Chapada+Diamantina&city=Seabra&page=2', - ) + ).toBe('/campanha/nucleos?q=Chapada®ion=Chapada+Diamantina&city=Seabra&page=2') }) it('uses the same canonical resolver for out-of-range pagination', () => { @@ -484,9 +496,7 @@ describe('campaign nucleus server filter parsing', () => { }, 2, ), - ).toBe( - '/campanha/nucleos?region=Chapada+Diamantina&city=Mucug%C3%AA&tseZone=50&page=2', - ) + ).toBe('/campanha/nucleos?region=Chapada+Diamantina&city=Mucug%C3%AA&tseZone=50&page=2') expect( buildNucleusListHref( diff --git a/tests/unit/campaignNucleusListOverview.unit.spec.ts b/tests/unit/campaignNucleusListOverview.unit.spec.ts index a8630c80..5473c859 100644 --- a/tests/unit/campaignNucleusListOverview.unit.spec.ts +++ b/tests/unit/campaignNucleusListOverview.unit.spec.ts @@ -140,10 +140,11 @@ describe('NucleusListOverview', () => { const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now })) expect(html).toContain('Mostrando agregados de') + expect(html).toContain('Atualizado agora') expect(html).toContain('1.200') expect(html).toContain('50% com estimativa confirmada') expect(html).toContain('1 de 2 com coordenador') - expect(html).toContain('Metas 2026') + expect(html).toContain('Meta regular 2026') expect(html).toContain('Últimas atualizações') expect(html).toContain('Maria') expect(html).toContain('Semanal') @@ -184,6 +185,8 @@ describe('NucleusListOverview', () => { expect(html).toContain('+2.140') expect(html).toContain('8 acima') expect(html).toContain('6 abaixo') + expect(html).toContain('+2.140 gap') + expect(html).not.toContain('Gap vs 2022') }) it('hides the Baseline 2022 card when aggregate is null', () => { @@ -211,9 +214,10 @@ describe('NucleusListOverview', () => { const html = renderToStaticMarkup(createElement(NucleusListOverview, { view, now })) - expect(html).toContain('Tendência histórica') + expect(html).toContain('Tendência:') expect(html).toContain('2 aumento') expect(html).toContain('5 mantém') expect(html).toContain('3 queda') + expect(html).not.toContain('Com série histórica') }) }) diff --git a/tests/unit/formatRelativeAge.unit.spec.ts b/tests/unit/formatRelativeAge.unit.spec.ts new file mode 100644 index 00000000..2af59524 --- /dev/null +++ b/tests/unit/formatRelativeAge.unit.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { formatRelativeAge } from '@/utilities/formatRelativeAge' + +const now = Date.parse('2026-07-19T12:00:00.000Z') + +describe('formatRelativeAge', () => { + it('formats minutes and hours with numeric cues', () => { + expect(formatRelativeAge(now - 5 * 60_000, now)).toContain('5') + expect(formatRelativeAge(now - 3 * 60 * 60_000, now)).toContain('3') + }) + + it('formats multi-day spans via Intl auto rules', () => { + expect(formatRelativeAge(now - 2 * 24 * 60 * 60_000, now)).toBe( + new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' }).format(-2, 'day'), + ) + }) +})