Merge branch 'cursor/e61261e7'
This commit is contained in:
commit
b29a332b9f
9 changed files with 212 additions and 27 deletions
|
|
@ -7,11 +7,11 @@ alwaysApply: false
|
|||
|
||||
## Status
|
||||
|
||||
- **B20 entregue (2026-07-25):** Municípios prioritários no Início — `pickDashboardPriorityMunicipalities` (`src/utilities/dashboardPriorityMunicipalities.ts`) ordena `priority=alta` por déficit E9/`central`, amostra 8; `CampaignDashboard` exibe Badge com `highPriorityCount`, CTA “Ver todas” → `?priority=alta`, chips com `list-none`. Unit `dashboardPriorityMunicipalities.unit.spec.ts`. Plano: [`municipios-prioritarios-inicio.md`](../../../docs/plans/municipios-prioritarios-inicio.md).
|
||||
- **B28 registrado (2026-07-25):** e-mail + celular na lista `/campanha/liderancas` — colunas com copy-on-click + WhatsApp (`wa.me`) no padrão de `/campanha/assessores` (B19 ✓); `email` no view model da lista; extrair primitivo compartilhado (2º call site); **sem** toggle Editar in-list (Contact update adiado). Appetite ~0,5–0,75d; Janela 1; cortável; Impeccable B. Plano: [`email-celular-lista-liderancas.md`](../../../docs/plans/email-celular-lista-liderancas.md).
|
||||
- **B27 registrado (2026-07-25):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (pedido de produto). Hoje: lista de checkboxes de todas as contas elegíveis + botão "Salvar assessores" (`MunicipalityListAdvisorsControl`, B9) → replace do array + `revalidateMunicipalityListPaths`. Plano: chips removíveis acima de um `Command` com busca acento-insensível; gravação **por delta** em endpoint JSON espelho do `expected-votes/` (sem revalidar 435 linhas), reusando o lock `municipality-advisors:{id}` e o cálculo puro do B19 (`nextAdvisorIdsAfterMembership` sobe para `src/lib/`) — mas **não** `setAdvisorMunicipalityMembership`, cujo `assertTargetAdvisor` recusa coordenador/candidato e o próprio ator (legais nesta tela). Gatilho da célula intacto (contrato de tooltip do B23); UI segue coordenador-only (candidato = adiado com gatilho, servidor já permite desde B19 ✓). Appetite ~0,75d; Janela 1–2; cortável; Impeccable B. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md).
|
||||
- **Fill-in registrado (2026-07-25):** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); só lista + label de sort `deficit`; glossário/KPI strips intactos. Soft B16 ✓ (liberou o nome). Appetite ~0,25d; Janela 1; cortável; Impeccable B. Plano: [`renomear-coluna-cobertura-meta.md`](../../../docs/plans/renomear-coluna-cobertura-meta.md).
|
||||
- **Fill-in registrado (2026-07-25):** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtrar ZE = filtrar Salvador (19 slugs). Remove coluna + filtro/sort UI + `?kind=` do contrato URL; schema/catálogo `kind` intacto (mapa, B8, dossiê). Appetite ~0,25–0,5d; Janela 1; cortável; Impeccable B. Plano: [`remover-coluna-tipo-municipios.md`](../../../docs/plans/remover-coluna-tipo-municipios.md).
|
||||
- **B20 registrado (2026-07-25):** Municípios prioritários no Início (`/campanha`) — a seção hoje faz `.slice(0, 6)` sem sort sobre ~50 `priority=alta` (E4R) e renderiza `<ul>` com bullets. Plano: atalho das 8 com maior déficit de cobertura (mesmo critério E9/`central`) + contagem + “Ver todas” → `?priority=alta`; markup `list-none`. Appetite ~0,5d; Janela 1; cortável; Impeccable B. Plano: [`municipios-prioritarios-inicio.md`](../../../docs/plans/municipios-prioritarios-inicio.md).
|
||||
- **Pass 2 — Consolidação de engenharia entregue (2026-07-25, W0–W5 + D3):** tudo que foi one-shotted desde o Pass 1 virou infraestrutura compartilhada e arquitetura documentada. **W1 sistema de listas:** colunas como dado em `shared/CampaignTable.tsx` (seams `mandatory`/`defaultVisible` deixam **B17 trivial**; serialização canônica congelada deixa **B18 trivial**), o god module `municipalityUi.ts` (765 linhas/59 exports) dividido em `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`, 8 superfícies migradas com URLs byte-idênticas (pins do W0 verdes) e **B16+ absorvido** (`useOptimistic` no popover, facet por slugs, hrefs via serializador canônico com pin de byte-igualdade, facets no mesmo `Promise.all`); fora por decisão D5: planos (cards), `TerritoryOverviewTable`, comparação, LeaderContacts, preview de import. **W2 fronteiras:** `slug`/`phone`/`wordStartFilter`/`voteEstimate` → `lib/`, chaves de consent em `lib/campaignConsentKeys.ts`, 21 loaders `server-only`, `lib/campaignRoles.ts` client-safe (sidebar sem o barrel), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em **15 subpastas de domínio**; decisão D1: **sem `src/domains/`**, sem ports-and-adapters (`utilities/` adiado com gatilho: 3º módulo novo de um domínio num mês). **W4a:** `electionInsights.ts` (941 linhas, 2 exports vivos) **deletado** — sobreviventes em `electionFormat.ts`/`voteTrend.ts`; **E10 nasce em módulo novo**. **W4b:** knip `exports`/`types`/`enumMembers` em **ERROR** no CI, 0 findings (kit ui sem peças mortas; `duplicates` off — únicos hits eram os 6 aliases deliberados `canUpdateX = canReadX`). **W4c:** tabs de detalhe extraídas + factory `detailTabUi`. **W4d:** `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções comentadas). **W4e:** tipos de fonte única (cenários, trend, strategy VM nomeado). **W4f:** copy Praça→Município varrida (~70 strings; apoiadores desambiguado: cidade do contato = "Cidade"). **D3:** `submitWhatsapp` resolve `Consent.key='whatsapp-inscricao'` fail-closed; migration `20260725_170000` tagueia o doc vivo — **revisar o SQL antes de push/deploy**. Artefatos novos: `docs/ARCHITECTURE.md` + `.cursor/rules/codebase-map.mdc` (atualizados no mesmo PR que mover coisas). Suites: 46 unit / 48 int / 7 e2e; gates verdes por entrega. Tracker: [`IMPROVE-CODE-QUALITY-PLAN.md`](../../../docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2.
|
||||
- **Correções e débitos pós-B16 (2026-07-25):** a revisão paralela achou três defeitos funcionais, **corrigidos na própria entrega**: as facetas omitiam só o param multi, então "Prioritária" escondia os não-prioritários do popover de Município e "Sem assessor" esvaziava o de Assessores (cada faceta passou a omitir o grupo do seu popover — `slugs`+`priority`, `advisors`+`coverage`, com int test); "todas as tendências" tinha duas codificações (as três marcadas deixavam `trend=` inútil na URL com o funil inativo — agora `parseMunicipalityListParams` canonicaliza o conjunto completo para ausente e `narrowingMunicipalityTrends` foi deletado); e o Território havia sumido do mobile ao virar multi (voltou como `MobileMultiFilterField` alimentado pela faceta, via novo prop `regionFilterOptions`). O `/simplify` da entrega (quality / performance / reuse em paralelo) aplicou o cleanup barato no próprio branch — setter único sobre params crus em `municipalityUi.ts` (fim de três casts e da escada de `if`), resumo de filtros de três funções para uma, remoção do array `municipalitySlugFilterOptions` (435 objetos que iam no bundle sem leitor), linha de opção única no Popover, facetas reusando o escopo já carregado e `getEligibleAdvisorOptions` de volta a coordenador-only. O que sobrou é maior que cleanup e virou o fill-in **B16+**: `useOptimistic` no lugar do par `{baseKey,next}` manual, payload dos rótulos de município (~20 KB por navegação) e hrefs do Popover a partir de uma base `URLSearchParams` em vez de duas serializações por opção — [`escala-dry-pos-b16.md`](../../../docs/plans/escala-dry-pos-b16.md). Adiados com gatilho no plano: gêmeo `MunicipalityFilterHead` (3º header com filtro), `ui/Command` no Popover (2º popover de busca), predicado de busca unificado, multi-seleção mobile honesta. Fora do `/campanha`: o `next build` inteiro morre quando o global `metadata` vem vazio (`/abaixo-assinado/[id]` chama `stripTrailingSlash` no `undefined`) — registrado em "Site público" no roadmap.
|
||||
- **B16 entregue em código (2026-07-25):** filtros no header das colunas em `/campanha/municipios` — funil + Popover (`MunicipalityHeaderFilter`) ao lado do sort do B15, com multi-seleção OR em Território (`region`), Município (`slug`, com busca acento-insensível e o check "Prioritária" no mesmo popover), Assessores (`advisor`, que também hospeda o toggle com/sem assessor) e Tendência (`trend`); "Limpar" nos multi grandes; mobile mantém os selects empilhados. Header sticky resolvendo contra o scroller do `<main>` (sem scroller interno na tabela) e empty state que troca só as linhas de conteúdo — visão geral e linha de filtros continuam montadas, senão o usuário fica preso em zero resultados. Facetas cross-filtradas (as opções de um filtro respeitam os demais, mantendo o que já está selecionado visível) reaproveitando o escopo já carregado em `loadMunicipalityListFilterFacets`. A pedido do usuário, a coluna "Assessoria" saiu: a ordenação `coverage` passou para "Assessores", o filtro com/sem assessor virou opção do popover, e o alerta "sem responsável" do E9 (prioridade alta sem ninguém) migrou para a célula de assessores, onde antes só haveria um "Sem assessor" apagado. Plano: [`filtros-no-header-lista-municipios.md`](../../../docs/plans/filtros-no-header-lista-municipios.md).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# Municípios prioritários no Início
|
||||
|
||||
Status: rascunho
|
||||
Status: entregue (2026-07-25)
|
||||
Atualizado em: 2026-07-25
|
||||
Revisão 2026-07-25: implementado — `dashboardPriorityMunicipalities.ts`, loader + `CampaignDashboard` (Badge, Ver todas, list-none); auditoria corrigiu `coverageByMunicipalityID` no texto do plano.
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, B20)
|
||||
Impeccable: B — encaixe na seção existente do dashboard staff (`CampaignDashboard` em `/campanha`); sem rota nova
|
||||
Appetite: ~0,5 dia eng; semântica da seção + ordenação + link “Ver todas” + markup sem bullets; sem migration
|
||||
|
|
@ -87,7 +88,7 @@ flowchart LR
|
|||
|
||||
Componentes:
|
||||
|
||||
- **`getCampaignDashboardData`** (`src/utilities/campaignDashboardData.ts`): após o bundle E8, filtrar `priority === 'alta'`, ordenar por `goalCoverageBundle.byMunicipalityId` (ou equivalente já exposto) no cenário `central` — maior `deficit` primeiro; `null`/sem meta no fim; `slice(0, DASHBOARD_PRIORITY_SAMPLE_LIMIT)`; continuar populando `highPriorityCount` (agora consumido na UI). Extrair helper puro testável se o sort não couber inline (<15 linhas → pode ficar no próprio arquivo; ≥ complexidade → `pickDashboardPriorityMunicipalities` no mesmo módulo ou ao lado de `goalCoverage`).
|
||||
- **`getCampaignDashboardData`** (`src/utilities/campaignDashboardData.ts`): após o bundle E8, filtrar `priority === 'alta'`, ordenar por `goalCoverageBundle.coverageByMunicipalityID` no cenário `central` — maior `deficit` primeiro; `null`/sem meta no fim; `slice(0, DASHBOARD_PRIORITY_SAMPLE_LIMIT)`; continuar populando `highPriorityCount` (agora consumido na UI). Helper puro `pickDashboardPriorityMunicipalities` em `src/utilities/dashboardPriorityMunicipalities.ts`.
|
||||
- **`CampaignDashboard`** (`src/components/campaign/dashboard/CampaignDashboard.tsx`): header da seção com título + `Badge`/`span` com `highPriorityCount`; `ul` com `m-0 flex list-none flex-wrap gap-2 p-0 [&>li]:mt-0`; chips `Button outline` + `Link` como hoje; CTA `Link` “Ver todas” via `buildMunicipalityListHref` com `priority: 'alta'` (defaults de sort da lista — `deficit` desc — alinhados a E9). Esconder CTA se `highPriorityCount <= amostra`.
|
||||
- **Unit test** do helper de pick/sort (fixture 10 municipíos, assert ordem e limite).
|
||||
- **Migration:** Sem migration, sem collection, sem server action.
|
||||
|
|
@ -115,12 +116,15 @@ Componentes:
|
|||
|
||||
- **Priorizar “alta sem assessor” na amostra.** Revisitar quando: CG pedir explicitamente no Início **ou** a coluna da vergonha deixar de ser descoberta na lista (evidência de sessão/R6).
|
||||
- **Expor `highPriorityCount` no metric strip.** Revisitar quando: houver 2º consumidor além do header da seção (hoje seria vaidade).
|
||||
- **Extrair `sortByNullableValue` para `lib/`.** Revisitar no 3º call site de ordenação por valor nullable (E9 lista + B20 já compartilham `centralDeficitSortValue` em `goalCoverage.ts`).
|
||||
- **Paralelizar `loadMunicipalityGoalCoverageBundle` com os finds de pledge/update no loader do Início.** Revisitar se TTFB medido do `/campanha` staff virar gargalo.
|
||||
|
||||
## Referências
|
||||
|
||||
- `docs/roadmap.md` (B20; E9 fila; E4R seed ~50 alta)
|
||||
- `src/utilities/campaignDashboardData.ts` — loader + `slice(0, 6)` atual + `highPriorityCount` morto
|
||||
- `src/components/campaign/dashboard/CampaignDashboard.tsx` — seção + `ul` sem `list-none`
|
||||
- `src/utilities/campaignDashboardData.ts` — `pickDashboardPriorityMunicipalities` + `highPriorityCount`
|
||||
- `src/components/campaign/dashboard/CampaignDashboard.tsx` — Badge, “Ver todas”, `list-none`
|
||||
- `src/utilities/dashboardPriorityMunicipalities.ts` — pick/sort (E9 `central` déficit)
|
||||
- `src/utilities/municipalityListUrl.ts` — `buildMunicipalityListHref` / filtro `priority`
|
||||
- `src/utilities/municipalityPageData.ts` — `applyDerivedMunicipalitySort` (precedente déficit)
|
||||
- `src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx` — strip `list-none` de referência
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -19,6 +19,7 @@ import {
|
|||
formatGoalCoverageRatioLabel,
|
||||
goalCoverageProgressPercent,
|
||||
} from '@/utilities/goalCoverage'
|
||||
import { buildMunicipalityListHref } from '@/utilities/municipalityListUrl'
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
|
||||
dateStyle: 'short',
|
||||
|
|
@ -154,10 +155,23 @@ export const CampaignDashboard = ({
|
|||
|
||||
{view.priorityMunicipalities.length ? (
|
||||
<section aria-labelledby="dashboard-priority-title" className="flex flex-col gap-3">
|
||||
<h2 id="dashboard-priority-title" className="text-base font-medium">
|
||||
Municípios prioritários
|
||||
</h2>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 id="dashboard-priority-title" className="text-base font-medium">
|
||||
Municípios prioritários
|
||||
</h2>
|
||||
<Badge variant="secondary">{view.highPriorityCount}</Badge>
|
||||
</div>
|
||||
{view.highPriorityCount > view.priorityMunicipalities.length ? (
|
||||
<Button asChild variant="ghost" className="min-h-11">
|
||||
<Link href={buildMunicipalityListHref({ page: 1, priority: 'alta' }, 1)}>
|
||||
Ver todas
|
||||
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<ul className="m-0 flex list-none flex-wrap gap-2 p-0 [&>li]:mt-0">
|
||||
{view.priorityMunicipalities.map((municipality) => (
|
||||
<li key={municipality.slug}>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import type { Payload } from 'payload'
|
|||
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
|
||||
import type { CampaignUser, VotePledge } from '@/payload-types'
|
||||
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
|
||||
import {
|
||||
type DashboardPriorityMunicipality,
|
||||
pickDashboardPriorityMunicipalities,
|
||||
} from '@/utilities/dashboardPriorityMunicipalities'
|
||||
import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
|
||||
import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipalityGoalAccount'
|
||||
import { relationshipId, requireRelationshipId } from '@/utilities/relationship'
|
||||
|
|
@ -29,7 +33,7 @@ export type StaffDashboardView = {
|
|||
contactName: string
|
||||
declaredVotes: number
|
||||
}>
|
||||
priorityMunicipalities: Array<{ name: string; slug: string }>
|
||||
priorityMunicipalities: DashboardPriorityMunicipality[]
|
||||
recentUpdates: Array<{
|
||||
id: number
|
||||
municipalityName: string
|
||||
|
|
@ -139,6 +143,11 @@ export const getCampaignDashboardData = async (
|
|||
resolveNames(payload, 'campaignUser', authorIDs, { name: true }),
|
||||
])
|
||||
|
||||
const dashboardPriority = pickDashboardPriorityMunicipalities(
|
||||
municipalities.docs,
|
||||
goalCoverageBundle.coverageByMunicipalityID,
|
||||
)
|
||||
|
||||
return {
|
||||
kind: 'staff',
|
||||
role:
|
||||
|
|
@ -147,9 +156,6 @@ export const getCampaignDashboardData = async (
|
|||
withAdvisorCount: municipalities.docs.filter(
|
||||
(municipality) => (municipality.advisors ?? []).length > 0,
|
||||
).length,
|
||||
highPriorityCount: municipalities.docs.filter(
|
||||
(municipality) => municipality.priority === 'alta',
|
||||
).length,
|
||||
staffVoteTotalByScenario: { ...staffVoteTotalByScenario },
|
||||
declaredVotesTotal,
|
||||
pledgeCount,
|
||||
|
|
@ -171,10 +177,8 @@ export const getCampaignDashboardData = async (
|
|||
declaredVotes: pledge.declaredVotes,
|
||||
}
|
||||
}),
|
||||
priorityMunicipalities: municipalities.docs
|
||||
.filter((municipality) => municipality.priority === 'alta')
|
||||
.slice(0, 6)
|
||||
.map((municipality) => ({ name: municipality.name, slug: municipality.slug })),
|
||||
highPriorityCount: dashboardPriority.highPriorityCount,
|
||||
priorityMunicipalities: dashboardPriority.municipalities,
|
||||
recentUpdates: recentUpdatesResult.docs.map((update) => {
|
||||
const municipality = municipalityById.get(relationshipId(update.municipality) ?? -1)
|
||||
const author = authorsById.get(relationshipId(update.author) ?? -1)
|
||||
|
|
|
|||
48
src/utilities/dashboardPriorityMunicipalities.ts
Normal file
48
src/utilities/dashboardPriorityMunicipalities.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
|
||||
import type { Municipality } from '@/payload-types'
|
||||
import { centralDeficitSortValue, type MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
|
||||
|
||||
export const DASHBOARD_PRIORITY_SAMPLE_LIMIT = 8
|
||||
|
||||
export type DashboardPriorityMunicipality = {
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export type DashboardPriorityMunicipalitiesPick = {
|
||||
highPriorityCount: number
|
||||
municipalities: DashboardPriorityMunicipality[]
|
||||
}
|
||||
|
||||
type DashboardPriorityMunicipalitySource = Pick<Municipality, 'id' | 'name' | 'slug' | 'priority'>
|
||||
|
||||
const municipalityNameCompare = (
|
||||
left: DashboardPriorityMunicipalitySource,
|
||||
right: DashboardPriorityMunicipalitySource,
|
||||
): number => left.name.localeCompare(right.name, 'pt-BR')
|
||||
|
||||
/** B20: `priority === 'alta'` sample ordered like E9 `deficit` / `central` desc. */
|
||||
export const pickDashboardPriorityMunicipalities = (
|
||||
municipalities: DashboardPriorityMunicipalitySource[],
|
||||
coverageByMunicipalityID: ReadonlyMap<
|
||||
number,
|
||||
Record<VoteEstimateScenario, MunicipalityGoalCoverage>
|
||||
>,
|
||||
sampleLimit = DASHBOARD_PRIORITY_SAMPLE_LIMIT,
|
||||
): DashboardPriorityMunicipalitiesPick => {
|
||||
const highPriority = municipalities.filter((municipality) => municipality.priority === 'alta')
|
||||
const municipalitiesSample = [...highPriority]
|
||||
.sort((left, right) => {
|
||||
const leftValue = centralDeficitSortValue(coverageByMunicipalityID.get(left.id)?.central)
|
||||
const rightValue = centralDeficitSortValue(coverageByMunicipalityID.get(right.id)?.central)
|
||||
if (leftValue === null && rightValue === null) return municipalityNameCompare(left, right)
|
||||
if (leftValue === null) return 1
|
||||
if (rightValue === null) return -1
|
||||
if (leftValue === rightValue) return municipalityNameCompare(left, right)
|
||||
return rightValue - leftValue
|
||||
})
|
||||
.slice(0, sampleLimit)
|
||||
.map((municipality) => ({ name: municipality.name, slug: municipality.slug }))
|
||||
|
||||
return { highPriorityCount: highPriority.length, municipalities: municipalitiesSample }
|
||||
}
|
||||
|
|
@ -37,6 +37,11 @@ export type MunicipalityGoalCoverage = {
|
|||
deficit: number
|
||||
}
|
||||
|
||||
/** E9 list + B20 dashboard: rank key for `central` when goal > 0; null sorts last. */
|
||||
export const centralDeficitSortValue = (
|
||||
coverage: MunicipalityGoalCoverage | null | undefined,
|
||||
): number | null => (coverage && coverage.goal > 0 ? coverage.deficit : null)
|
||||
|
||||
/** Read-only zero coverage — never mutate; use for view models outside the staff scope. */
|
||||
const emptyMunicipalityGoalCoverage: Readonly<MunicipalityGoalCoverage> = {
|
||||
goal: 0,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { type VoteEstimateScenario } from '@/lib/voteEstimate'
|
|||
import type { CampaignUser, Municipality } from '@/payload-types'
|
||||
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
|
||||
import { loadMunicipalityScope } from '@/utilities/campaignMunicipalityScope'
|
||||
import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
|
||||
import { centralDeficitSortValue, type MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
|
||||
import { loadMunicipalityGoalCoverageBundle } from '@/utilities/municipalityGoalAccount'
|
||||
import {
|
||||
buildMunicipalityListWhere,
|
||||
|
|
@ -226,10 +226,9 @@ const applyDerivedMunicipalitySort = (
|
|||
// server has no scenario to sort by (moving it to the URL is the
|
||||
// "Cenário junto aos filtros" fill-in). A município with no goal at all
|
||||
// has no deficit to rank — `null` sends it to the end either way.
|
||||
return sortByNullableValue(docs, dir, (municipality) => {
|
||||
const coverage = goalCoverageByMunicipalityID.get(municipality.id)?.central
|
||||
return coverage && coverage.goal > 0 ? coverage.deficit : null
|
||||
})
|
||||
return sortByNullableValue(docs, dir, (municipality) =>
|
||||
centralDeficitSortValue(goalCoverageByMunicipalityID.get(municipality.id)?.central),
|
||||
)
|
||||
case 'frescor': {
|
||||
// One clock read for the whole sort: the accessor runs per comparison,
|
||||
// and a "now" that drifts mid-sort is not a consistent ordering key.
|
||||
|
|
|
|||
110
tests/unit/dashboardPriorityMunicipalities.unit.spec.ts
Normal file
110
tests/unit/dashboardPriorityMunicipalities.unit.spec.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { VoteEstimateScenario } from '@/lib/voteEstimate'
|
||||
import type { Municipality } from '@/payload-types'
|
||||
import {
|
||||
DASHBOARD_PRIORITY_SAMPLE_LIMIT,
|
||||
pickDashboardPriorityMunicipalities,
|
||||
} from '@/utilities/dashboardPriorityMunicipalities'
|
||||
import type { MunicipalityGoalCoverage } from '@/utilities/goalCoverage'
|
||||
import { stub } from '../helpers/stub'
|
||||
|
||||
const centralCoverage = (goal: number, committed: number): MunicipalityGoalCoverage => ({
|
||||
goal,
|
||||
committed,
|
||||
coverageRatio: goal > 0 ? committed / goal : null,
|
||||
deficit: goal - committed,
|
||||
})
|
||||
|
||||
const coverageMap = (
|
||||
entries: Array<[number, MunicipalityGoalCoverage]>,
|
||||
): Map<number, Record<VoteEstimateScenario, MunicipalityGoalCoverage>> =>
|
||||
new Map(
|
||||
entries.map(([id, central]) => [
|
||||
id,
|
||||
{
|
||||
pessimistic: centralCoverage(0, 0),
|
||||
central,
|
||||
optimistic: centralCoverage(0, 0),
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const municipality = (id: number, slug: string, priority: Municipality['priority']): Municipality =>
|
||||
stub<Municipality>({
|
||||
id,
|
||||
name: slug,
|
||||
slug,
|
||||
priority,
|
||||
})
|
||||
|
||||
describe('pickDashboardPriorityMunicipalities', () => {
|
||||
it('orders alta by central deficit desc and caps at the sample limit', () => {
|
||||
const municipalities = [
|
||||
municipality(1, 'alpha', 'alta'),
|
||||
municipality(2, 'beta', 'alta'),
|
||||
municipality(3, 'gamma', 'alta'),
|
||||
municipality(4, 'delta', 'alta'),
|
||||
municipality(5, 'epsilon', 'alta'),
|
||||
municipality(6, 'zeta', 'alta'),
|
||||
municipality(7, 'eta', 'alta'),
|
||||
municipality(8, 'theta', 'alta'),
|
||||
municipality(9, 'iota', 'alta'),
|
||||
municipality(10, 'kappa', 'alta'),
|
||||
municipality(11, 'normal', 'normal'),
|
||||
]
|
||||
|
||||
const coverageByMunicipalityID = coverageMap([
|
||||
[1, centralCoverage(100, 10)],
|
||||
[2, centralCoverage(100, 20)],
|
||||
[3, centralCoverage(100, 30)],
|
||||
[4, centralCoverage(100, 40)],
|
||||
[5, centralCoverage(100, 50)],
|
||||
[6, centralCoverage(100, 60)],
|
||||
[7, centralCoverage(100, 70)],
|
||||
[8, centralCoverage(100, 80)],
|
||||
[9, centralCoverage(100, 90)],
|
||||
[10, centralCoverage(100, 95)],
|
||||
[11, centralCoverage(500, 0)],
|
||||
])
|
||||
|
||||
const { municipalities: result, highPriorityCount } = pickDashboardPriorityMunicipalities(
|
||||
municipalities,
|
||||
coverageByMunicipalityID,
|
||||
)
|
||||
|
||||
expect(highPriorityCount).toBe(10)
|
||||
expect(result).toHaveLength(DASHBOARD_PRIORITY_SAMPLE_LIMIT)
|
||||
expect(result.map((m) => m.slug)).toEqual([
|
||||
'alpha',
|
||||
'beta',
|
||||
'gamma',
|
||||
'delta',
|
||||
'epsilon',
|
||||
'zeta',
|
||||
'eta',
|
||||
'theta',
|
||||
])
|
||||
})
|
||||
|
||||
it('sends municipalities without a positive goal to the end', () => {
|
||||
const municipalities = [
|
||||
municipality(1, 'with-goal', 'alta'),
|
||||
municipality(2, 'no-goal', 'alta'),
|
||||
municipality(3, 'missing-coverage', 'alta'),
|
||||
]
|
||||
|
||||
const coverageByMunicipalityID = coverageMap([
|
||||
[1, centralCoverage(100, 50)],
|
||||
[2, centralCoverage(0, 0)],
|
||||
])
|
||||
|
||||
const { municipalities: result } = pickDashboardPriorityMunicipalities(
|
||||
municipalities,
|
||||
coverageByMunicipalityID,
|
||||
3,
|
||||
)
|
||||
|
||||
expect(result.map((m) => m.slug)).toEqual(['with-goal', 'missing-coverage', 'no-goal'])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue