mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge branch 'cursor/cfe1358a'
This commit is contained in:
commit
878fda68c8
9 changed files with 322 additions and 65 deletions
|
|
@ -10,6 +10,7 @@ alwaysApply: false
|
|||
- **B17+ entregue em código (2026-07-29):** Escala/DRY pós-B17 — F1: `readCampaignColumnVisibility` antes do `Promise.all` e `loadLeadershipOptions` / `loadStateDeputyOptions` só quando a coluna de relação está visível (`resolveVisibleColumns`). F2: `CampaignListEmptyState` na prop `empty` de `CampaignTable` (e `SupporterList`) em demandas, organizações e apoiadores, para o botão "Colunas" sobreviver com zero linhas. Plano: [`escala-dry-pos-b17.md`](../../../docs/plans/escala-dry-pos-b17.md).
|
||||
- **Fill-in remover coluna Tipo entregue em código (2026-07-29):** em `/campanha/municipios`, sumiram a coluna "Tipo", o filtro single-select no header (B16), o ramo mobile em `MunicipalityFilters`, a sort key `kind` e o param `?kind=` em `municipalityListUrl` / `buildMunicipalityListWhere` / `payloadSortFieldByKey` — bookmarks antigos ignoram `kind` no parse. `municipalityKindLabels` permanece para detalhe, mapa e dossiê. `MunicipalityHeaderFilter` ficou só com filtros multi (`singleRows`/`applyMunicipalityKindFilter` deletados). B17: 11 colunas staff; e2e do picker usa Território; B18 usa `?priority=alta`. Sem migration/action/Consent. Plano: [`remover-coluna-tipo-municipios.md`](../../../docs/plans/remover-coluna-tipo-municipios.md).
|
||||
- **B18 entregue em código (2026-07-28):** filtros salvos na lista de Municípios — o recorte ativo vira um bookmark nomeado no `localStorage` (`teqo:campaign:municipality-saved-filters`, `MAX_ENTRIES = 12`) e reaparece como submenu recolhível sob "Municípios" no sidebar. **Duas premissas do plano de 24/07 morreram na auditoria.** (1) `SidebarMenuSub`/`SidebarMenuSubItem`/`SidebarMenuSubButton`/`SidebarMenuAction` **não existiam** — o plano afirmava que estavam no kit sem uso, mas o Pass 2 W4b pôs knip `exports` em ERROR e o kit foi podado; os quatro primitivos voltaram do upstream, e **o re-add expôs um bug pré-existente no `SidebarMenuButton`**: `data-active={false}` renderiza como `data-active="false"` e o variante `data-active:` do Tailwind casa por **presença** do atributo, então todo item **não** ativo estava recebendo o realce do ativo (`isActive || undefined` nos dois botões). (2) `municipalityUi.ts` não existe mais (Pass 2 W1 o dividiu) e o estado da URL cresceu para 14 params — absorvido serializando o estado inteiro menos `page`/`compare` em `buildMunicipalitySavedFilterHref` (`compare` é a lente do mapa do Início, não recorte desta lista). Também corrigido no plano: **B17 persiste em cookie**, não `localStorage`. **A restrição que manda no desenho é o layout:** a sidebar está no layout de `(app)`, então o casamento "URL atual == filtro salvo" **não pode** usar o serializador do domínio (a lição dos 21 kB do B14 ✓) — virou `src/lib/listQueryMatch.ts`, comparador puro de `URLSearchParams` insensível à ordem e ignorando `page`, o que de passagem resolve estar na página 3 de um filtro salvo (caso que o plano não previa). Medido contra árvore limpa: `/campanha` **byte-idêntico**, `/campanha/municipios` +2 kB. **Identidade é o href canônico, não um id:** salvar o mesmo recorte duas vezes é upsert — o Popover pré-preenche o nome atual e o botão lê "Renomear"/"Atualizar nome". O nome padrão e o gate "tem filtro salvável?" saem os dois de `formatMunicipalityActiveFiltersSummary`, que o `Limpar` já usava, então o `municipalityListHasSavableFilters` que o plano pedia não precisou existir. Sincronização das duas ilhas (barra grava, sidebar lê) por `useSyncExternalStore` com cache de snapshot invalidado na escrita e no evento de outra aba — a armadilha conhecida do loop de render. **Três defeitos de comportamento achados na verificação, e os dois primeiros são a mesma família de bug que este repo já pagou várias vezes** (estado lido do render que agendou o trabalho / hidratação confundida com ação): (1) abrir o grupo quando `activeHref` **aparece** reabria o submenu em **todo reload**, porque o store responde vazio até o cliente ler o `localStorage` e o match surgia da hidratação — o gatilho passou a ser a **URL mudar**, guardada num `useRef`, e só então a preferência recolhida sobrevive a um F5 para quem mais usa a feature; (2) apagar o último filtro derrubava o foco para `<body>`, porque o botão que o detinha desmontava — o sucessor é resolvido **antes** da remoção, e o fallback tem de ser o link de nav, já que esvaziar o grupo desmonta o próprio disclosure; (3) SC 2.5.3 — o `aria-label` "Salvar este filtro" **não continha** o rótulo visível "Salvar filtro", então "clique em Salvar filtro" por voz não casava nada; removido (o caso de renomear pode manter porque a palavra visível vem primeiro). Mais acessibilidade: `aria-describedby` ligando a recusa ao input, `aria-label` no `<ul>` do submenu, `aria-current="page"` nos links ativos. Apagar é **desfazível** (toast com "Desfazer" restaurando nome e posição alfabética), que é por que nunca pede confirmação; o logout limpa o storage ao lado de `clearRecentVisits()`. Sem migration, collection, action ou `Consent`; nada de rota `/vistas/[id]`, sync multi-device ou saved views genéricas nas outras listas. **Cuidado operacional novo:** `pnpm build` limpa o `.next`, e com ele o `.next/e2e` que o dev server do Playwright usa — rodar e2e logo depois de um build estoura o timeout do `setup` enquanto a rota recompila (o e2e ganhou orçamento de 20 s nas navegações, mesmo motivo do `campaignColumnPicker`). Plano as-built: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md).
|
||||
- **B14 débito B8 F2-D1 entregue em código (2026-07-29):** o card "Onde estou" resolve a ZE de Salvador por containment na malha `bahia-municipality-zones` (`loadMunicipalityZoneGeometryModule`, opcional — falha do chunk degrada para lista filtrada); fallback `nearestZone` (≤ 30 km) quando o ponto cai no município mas fora de todo polígono de zona. `resolveNearbyMunicipality` + `NearestMunicipalityCard`; int/e2e atualizados. Plano: [`municipio-mais-proximo.md`](../../../docs/plans/municipio-mais-proximo.md). filtros salvos na lista de Municípios — o recorte ativo vira um bookmark nomeado no `localStorage` (`teqo:campaign:municipality-saved-filters`, `MAX_ENTRIES = 12`) e reaparece como submenu recolhível sob "Municípios" no sidebar. **Duas premissas do plano de 24/07 morreram na auditoria.** (1) `SidebarMenuSub`/`SidebarMenuSubItem`/`SidebarMenuSubButton`/`SidebarMenuAction` **não existiam** — o plano afirmava que estavam no kit sem uso, mas o Pass 2 W4b pôs knip `exports` em ERROR e o kit foi podado; os quatro primitivos voltaram do upstream, e **o re-add expôs um bug pré-existente no `SidebarMenuButton`**: `data-active={false}` renderiza como `data-active="false"` e o variante `data-active:` do Tailwind casa por **presença** do atributo, então todo item **não** ativo estava recebendo o realce do ativo (`isActive || undefined` nos dois botões). (2) `municipalityUi.ts` não existe mais (Pass 2 W1 o dividiu) e o estado da URL cresceu para 14 params — absorvido serializando o estado inteiro menos `page`/`compare` em `buildMunicipalitySavedFilterHref` (`compare` é a lente do mapa do Início, não recorte desta lista). Também corrigido no plano: **B17 persiste em cookie**, não `localStorage`. **A restrição que manda no desenho é o layout:** a sidebar está no layout de `(app)`, então o casamento "URL atual == filtro salvo" **não pode** usar o serializador do domínio (a lição dos 21 kB do B14 ✓) — virou `src/lib/listQueryMatch.ts`, comparador puro de `URLSearchParams` insensível à ordem e ignorando `page`, o que de passagem resolve estar na página 3 de um filtro salvo (caso que o plano não previa). Medido contra árvore limpa: `/campanha` **byte-idêntico**, `/campanha/municipios` +2 kB. **Identidade é o href canônico, não um id:** salvar o mesmo recorte duas vezes é upsert — o Popover pré-preenche o nome atual e o botão lê "Renomear"/"Atualizar nome". O nome padrão e o gate "tem filtro salvável?" saem os dois de `formatMunicipalityActiveFiltersSummary`, que o `Limpar` já usava, então o `municipalityListHasSavableFilters` que o plano pedia não precisou existir. Sincronização das duas ilhas (barra grava, sidebar lê) por `useSyncExternalStore` com cache de snapshot invalidado na escrita e no evento de outra aba — a armadilha conhecida do loop de render. **Três defeitos de comportamento achados na verificação, e os dois primeiros são a mesma família de bug que este repo já pagou várias vezes** (estado lido do render que agendou o trabalho / hidratação confundida com ação): (1) abrir o grupo quando `activeHref` **aparece** reabria o submenu em **todo reload**, porque o store responde vazio até o cliente ler o `localStorage` e o match surgia da hidratação — o gatilho passou a ser a **URL mudar**, guardada num `useRef`, e só então a preferência recolhida sobrevive a um F5 para quem mais usa a feature; (2) apagar o último filtro derrubava o foco para `<body>`, porque o botão que o detinha desmontava — o sucessor é resolvido **antes** da remoção, e o fallback tem de ser o link de nav, já que esvaziar o grupo desmonta o próprio disclosure; (3) SC 2.5.3 — o `aria-label` "Salvar este filtro" **não continha** o rótulo visível "Salvar filtro", então "clique em Salvar filtro" por voz não casava nada; removido (o caso de renomear pode manter porque a palavra visível vem primeiro). Mais acessibilidade: `aria-describedby` ligando a recusa ao input, `aria-label` no `<ul>` do submenu, `aria-current="page"` nos links ativos. Apagar é **desfazível** (toast com "Desfazer" restaurando nome e posição alfabética), que é por que nunca pede confirmação; o logout limpa o storage ao lado de `clearRecentVisits()`. Sem migration, collection, action ou `Consent`; nada de rota `/vistas/[id]`, sync multi-device ou saved views genéricas nas outras listas. **Cuidado operacional novo:** `pnpm build` limpa o `.next`, e com ele o `.next/e2e` que o dev server do Playwright usa — rodar e2e logo depois de um build estoura o timeout do `setup` enquanto a rota recompila (o e2e ganhou orçamento de 20 s nas navegações, mesmo motivo do `campaignColumnPicker`). Plano as-built: [`filtros-salvos-municipios.md`](../../../docs/plans/filtros-salvos-municipios.md).
|
||||
- **Fill-in ✓ dimir resultado no compositor de giro (2026-07-28):** em `/campanha/atividades/giros`, `CampaignListPendingBoundary` + `CampaignListResults` dimam a proposta ao trocar TI; `TourRegionPicker` usa `useCampaignListTransition` (spinner do select permanece); `CalendarPhaseNote` dentro de Results; unit `tourRegionPending.unit.spec.ts`. Sem migration/action/`Consent`. Critique sem P0/P1. Plano: [`feedback-pendente-compositor-giro.md`](../../../docs/plans/feedback-pendente-compositor-giro.md).
|
||||
- **Fill-in ícone de prioridade na lista entregue em código (2026-07-28):** em `/campanha/municipios`, o Badge destrutivo "Prioritária" na coluna do nome (tabela) e no cabeçalho do card mobile (`priority === 'alta'`, staff) virou `MunicipalityPriorityIndicator` — `FlagIcon` `text-primary`, `aria-label` e `CampaignHoverTooltip` (B23 ✓) com copy fixa **"Município prioritário"** (filtro/forms/StrategyCard/Dossier continuam no rótulo curto "Prioritária"). No card, `className="relative size-11"` no trigger para ficar acima do link esticado B42. Ilha client mínima; `MunicipalityList` segue RSC. Pin unit: staff vê `aria-label="Município prioritário"`; liderança não. Sem migration/action/Consent. Gate: tsc/lint/format/cycles; unit 770; knip P3 pré-existente; Aikido 0 findings no indicador. Impeccable B; harden/optimize out. Plano: [`icone-prioridade-lista-municipios.md`](../../../docs/plans/icone-prioridade-lista-municipios.md).
|
||||
- **Fill-in remover lista de zonas do Início entregue em código (2026-07-28):** sumiu o bloco "Municípios por zona eleitoral" sob o mapa de `/campanha`; `zoneBreakdown` / `MunicipalityZoneBreakdownRow` / `zoneVotes*BySlug` saíram do contrato (`municipalityMapContract`) e do loader (`municipalityMapData`); a ressalva de aproximação do **B8+ F3 ✓** permanece gated por `hasZoneMunicipalities`. Int `municipalityMapData` e `TESTING.md` atualizados. Sem migration/action/Consent. Plano: [`remover-lista-zonas-mapa-inicio.md`](../../../docs/plans/remover-lista-zonas-mapa-inicio.md).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# B14 — Município mais próximo (acesso rápido por geolocalização)
|
||||
|
||||
Status: entregue (2026-07-26)
|
||||
Atualizado em: 2026-07-26
|
||||
Status: entregue (2026-07-26); débito B8 F2-D1 (ZE exata em Salvador) fechado em 2026-07-29
|
||||
Atualizado em: 2026-07-29
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Demais itens abertos, B14; superfície de coordenação)
|
||||
Impeccable: B — encaixe no dashboard staff (`/campanha`), sem rota nova
|
||||
Appetite: ~1 dia eng (gasto ~0,75d: o artefato de centroides e seu script saíram do escopo); ilha cliente no Início; sem migration, sem collection, sem Consent
|
||||
|
|
@ -73,7 +73,7 @@ Decisão de produto (2026-07-24, pedido explícito): sugerir acesso rápido ao m
|
|||
- **Client-only, casando contra a malha que o mapa já carrega (sem modelo no servidor).** Posição fica no browser; containment por point-in-polygon sobre o chunk memoizado de `bahiaMunicipalityGeometries`, e Haversine sobre centroides derivados na hora só para ordenar o fallback. Análogo a visitados recentes. **Rejeitado:** POST de lat/lng ao servidor (PII de localização + Consent + superfície de abuso); reverse-geocode comercial (custo/vendor); PostGIS (fora de escopo do AGENTS.md sem query espacial real); **artefato commitado de centroides** — era a decisão de 2026-07-24 e foi revertida em 2026-07-26 por ser menos precisa a custo de bundle zero (o chunk já está no browser).
|
||||
- **Prompt automático no máximo 1× por sessão (`sessionStorage`).** Cumpre o pedido de produto sem martelar o usuário a cada navegação no Início. Chave estável: `teqo:campaign:geo-prompted-session`. **Rejeitado:** prompt a cada mount do card; `localStorage` "nunca mais perguntar" como único gate (impediria retry legítimo em sessão nova após o usuário mudar a permissão no SO).
|
||||
- **Sem `Consent` / sem lote jurídico.** Tratamento local da própria localização do usuário autenticado, sem transmissão — mesmo racional de visitados. **Rejeitado:** chave `Consent` só para UX de GPS (multiplica lead time jurídico sem dado no servidor).
|
||||
- **Malha de matching = município IBGE (417), depois mapear ao catálogo operacional.** Polígonos por `ibgeCode`; entradas `kind: 'municipio'` 1:1; entradas `kind: 'zona'` (Salvador) **não** recebem ZE automática na v1 — quem está em Salvador vai para `/campanha/municipios?q=Salvador`, com o href serializado no servidor e a contagem de zonas limitada ao que o ator pode abrir (um assessor com três zonas não é informado de dezenove). **Rejeitado:** escolher ZE aleatória/primeira; inventar centroides de zona sem polígonos (B8 F2).
|
||||
- **Malha de matching = município IBGE (417), depois ZE em Salvador (B8 F2).** Containment na malha municipal; cidades multi-zona usam `bahia-municipality-zones` quando o card carrega o chunk — link direto à ZE acessível, ou ZE mais próxima (≤ `NEAREST_ZONE_MAX_KM` = 30 km), ou lista filtrada (`zoneCityHrefs`). **Rejeitado:** chute de ZE sem polígonos; lista sempre.
|
||||
- **Staff only no Início.** Leader lockdown intacto. **Rejeitado:** expor municípios ao leader via atalho geo.
|
||||
- **i18n e naming:** identificadores em inglês (`NearestMunicipalityCard`, `municipalityProximity`, `resolveNearbyMunicipality`, `GEO_PROMPT_SESSION_KEY`); strings visíveis em pt-BR (título "Onde estou", "Abrir <município>", "Usar minha localização", "Ver zonas de Salvador").
|
||||
- **Precisão declarada, nunca escondida.** Fix com raio acima de 10 km (rede/IP, típico de desktop) mostra o resultado com a ressalva "Localização aproximada — confira o município antes de agir", entre o título e o CTA. **Rejeitado:** descartar o fix grosseiro (o município costuma estar certo) e confiar nele em silêncio (nomear o município errado com confiança total é o pior dos dois).
|
||||
|
|
@ -134,7 +134,7 @@ Componentes:
|
|||
|
||||
## Adiado com gatilho
|
||||
|
||||
- **Desambiguação Salvador ZE por ponto-em-polígono.** Revisitar quando: **B8 F2** entregue (polígonos no mapa).
|
||||
- ~~**Desambiguação Salvador ZE por ponto-em-polígono.**~~ **Resolvido 2026-07-29** (débito B8 F2-D1): `NearestMunicipalityCard` carrega `loadMunicipalityZoneGeometryModule()` de forma opcional; falha do chunk degrada para `zoneCity`.
|
||||
- **Atalho também na lista `/campanha/municipios`.** Revisitar quando: evidência de uso no Início (campo pede o mesmo atalho na lista).
|
||||
- **Lembrar última sugestão em `sessionStorage` para paint imediato.** Revisitar quando: critique acusar flash/vazio perceptível no card. _(Não acusou: o chunk de geometria costuma estar resolvido antes do fix chegar.)_
|
||||
- **Atalho geo também para `leader`.** Permanece fora: liderança não sai do `LeaderContactsPanel`.
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ Os relatórios dos três revisores chegaram **depois** do merge; o cleanup barat
|
|||
## Adiado com gatilho
|
||||
|
||||
- **S6 — CSS compartilhado lista zona** (`MunicipalityMapPanel` zone breakdown ↔ `MunicipalityZoneNeighborhoodsCard`). **Obsoleto 2026-07-26:** o fill-in [remover-lista-zonas-mapa-inicio.md](remover-lista-zonas-mapa-inicio.md) remove a lista do mapa; sem segundo call site, não há o que extrair. O card de bairros no detalhe permanece.
|
||||
- **F2-D1 — card "Onde estou" (B14 ✓) resolver a ZE exata de Salvador.** Os polígonos passaram a existir nesta entrega, e `featureContainsPoint` já é genérico o suficiente para recebê-los; o card continua levando à lista filtrada por decisão. **Gatilho:** pedido de campo ("estou em Salvador e quero abrir minha zona"). Custo real é uma segunda malha no chunk do card, não matemática nova. Aresta já registrada no grafo do roadmap.
|
||||
- ~~**F2-D1 — card "Onde estou" (B14 ✓) resolver a ZE exata de Salvador.**~~ **Resolvido 2026-07-29** — `resolveNearbyMunicipality` + `NearestMunicipalityCard` usam a malha das 19 ZE; fallback: ZE acessível mais próxima (30 km) ou lista filtrada. Plano: [municipio-mais-proximo.md](municipio-mais-proximo.md).
|
||||
- **F2-D2 — rank competitivo por ZE.** Exige dimensão nova no artefato TSE (hoje `federalRankByIbgeCode`), o que mexe no budget de 700 KB. **Gatilho:** E11 pedir o eixo de competição dentro de Salvador, ou o dossiê de um Município-zona exibir posição própria. Hoje as 19 zonas herdam a posição da cidade, com int pinando o fato.
|
||||
- **S2 — fonte única TS ↔ fixture do catálogo de bairros** (gerar `municipalityZoneNeighborhoods.ts` a partir do JSON). **Cortado na F2 prep:** a curadoria que a F2 exigia foi para o **script** de geometrias, não para o catálogo, então o gerador não teria a quem servir; o `deepEqual` fixture↔código já pega drift. **Gatilho:** um terceiro consumidor precisar do catálogo em runtime não-TS.
|
||||
- **Malha GeoSalvador em vez de IBGE.** Revisitar quando: spike F2 mostrar unmatched críticos em Salvador (>10% bairros TRE sem polígono).
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -7,7 +7,10 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Spinner } from '@/components/ui/Spinner'
|
||||
import { loadMunicipalityGeometryModule } from '@/lib/bahiaGeometries'
|
||||
import {
|
||||
loadMunicipalityGeometryModule,
|
||||
loadMunicipalityZoneGeometryModule,
|
||||
} from '@/lib/bahiaGeometries'
|
||||
import {
|
||||
formatDistanceKm,
|
||||
resolveNearbyMunicipality,
|
||||
|
|
@ -64,9 +67,9 @@ const failureCopy: Record<ActionableFailureReason, { title: string; hint: string
|
|||
/**
|
||||
* B14 — "abrir a ficha do município onde estou" no Início staff.
|
||||
*
|
||||
* A posição fica no navegador: o casamento é feito contra a malha municipal que
|
||||
* o mapa do dashboard já carrega (mesmo chunk memoizado), sem action, sem
|
||||
* artefato e sem `Consent` — nada de coordenada sai do aparelho.
|
||||
* A posição fica no navegador: o casamento usa a malha municipal que o mapa já
|
||||
* carrega e, em Salvador multi-zona, a malha das 19 ZE (B8 F2). Falha no chunk
|
||||
* de zonas degrada para a lista filtrada — sem action, sem `Consent`.
|
||||
*
|
||||
* `leader` nunca chega aqui: a página `/campanha` roteia liderança para o
|
||||
* `LeaderContactsPanel` antes do dashboard.
|
||||
|
|
@ -122,10 +125,17 @@ export const NearestMunicipalityCard = ({
|
|||
return
|
||||
}
|
||||
|
||||
const zoneModule = await loadMunicipalityZoneGeometryModule().catch(() => null)
|
||||
|
||||
setState({
|
||||
status: 'resolved',
|
||||
coarse: result.fix.accuracyM > COARSE_ACCURACY_M,
|
||||
resolution: resolveNearbyMunicipality({ point: result.fix, geometry, accessible }),
|
||||
resolution: resolveNearbyMunicipality({
|
||||
point: result.fix,
|
||||
geometry,
|
||||
zoneGeometry: zoneModule ?? undefined,
|
||||
accessible,
|
||||
}),
|
||||
})
|
||||
}, [accessible])
|
||||
|
||||
|
|
@ -330,13 +340,25 @@ const resolutionDetail = (
|
|||
) : null
|
||||
|
||||
switch (resolution.kind) {
|
||||
case 'inScope':
|
||||
case 'inScope': {
|
||||
const nearestZone =
|
||||
resolution.match === 'nearestZone' && resolution.distanceKm !== undefined ? (
|
||||
<Explanation>
|
||||
Zona eleitoral mais próxima na sua carteira, a {formatDistanceKm(resolution.distanceKm)}{' '}
|
||||
— confira antes de agir.
|
||||
</Explanation>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<OpenMunicipalityButton
|
||||
slug={resolution.municipality.slug}
|
||||
name={resolution.municipality.name}
|
||||
/>
|
||||
<>
|
||||
{nearestZone}
|
||||
<OpenMunicipalityButton
|
||||
slug={resolution.municipality.slug}
|
||||
name={resolution.municipality.name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
case 'zoneCity': {
|
||||
// Server and client read the same `accessible` set, so the href is there —
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Position } from 'geojson'
|
|||
import type {
|
||||
BahiaMunicipalityFeature,
|
||||
MunicipalityGeometryModule,
|
||||
MunicipalityZoneFeature,
|
||||
PolygonalFeature,
|
||||
} from '@/lib/bahiaGeometriesTypes'
|
||||
import { oneDecimalFormatter } from '@/lib/electionFormat'
|
||||
|
|
@ -14,9 +15,10 @@ import { oneDecimalFormatter } from '@/lib/electionFormat'
|
|||
* loads `bahiaMunicipalityGeometries` on mount, so the same memoized chunk
|
||||
* answers containment without a centroid artifact of its own.
|
||||
*
|
||||
* Containment is the real answer ("o município onde estou"); the centroid
|
||||
* distance below only ranks the fallback suggestions, where being off by a few
|
||||
* kilometres changes nothing.
|
||||
* Salvador multi-zona: when the caller also supplies the B8 F2 zone mesh,
|
||||
* containment runs on the 19 ZE polygons first; centroid distance ranks only
|
||||
* the "nearest accessible zone" fallback when the point is in the city polygon
|
||||
* but outside every zone polygon (bay water, mesh gap, coarse fix).
|
||||
*/
|
||||
|
||||
export type GeoPoint = {
|
||||
|
|
@ -34,6 +36,10 @@ type MunicipalityGeometryLookup = Pick<
|
|||
'features' | 'getMunicipalityFeature'
|
||||
>
|
||||
|
||||
type ZoneGeometryLookup = {
|
||||
features: readonly MunicipalityZoneFeature[]
|
||||
}
|
||||
|
||||
/** The minimum a município needs to be linkable — what the server ships to the island. */
|
||||
export type AccessibleMunicipality = {
|
||||
slug: string
|
||||
|
|
@ -47,18 +53,22 @@ type NearestInScope = {
|
|||
distanceKm: number
|
||||
}
|
||||
|
||||
export type InScopeMunicipalityMatch = 'municipality' | 'zoneContainment' | 'nearestZone'
|
||||
|
||||
export type NearbyMunicipalityResolution =
|
||||
/** The point falls inside a município the actor can open. */
|
||||
| { kind: 'inScope'; municipality: AccessibleMunicipality }
|
||||
| {
|
||||
kind: 'inScope'
|
||||
municipality: AccessibleMunicipality
|
||||
/** Omitted for a single whole-municipality unit; set for Salvador ZE paths. */
|
||||
match?: InScopeMunicipalityMatch
|
||||
/** Present when `match` is `nearestZone` — distance to the zone centroid. */
|
||||
distanceKm?: number
|
||||
}
|
||||
/**
|
||||
* The point falls inside a city modeled as several zone municipalities
|
||||
* (Salvador). The answer is the filtered list, never a guessed ZE. B8 F2 has
|
||||
* since drawn the zone polygons, so resolving the exact ZE became possible —
|
||||
* this resolver deliberately still does not, because that is a separate
|
||||
* decision (an open debt) rather than a side effect of the map delivery.
|
||||
* `zoneCount` is what the actor can open, so an advisor with three zones is
|
||||
* not told there are nineteen. `ibgeCode` is how the caller finds the
|
||||
* filtered-list href the server serialized for it.
|
||||
* The actor can open several zone municipalities in the same city but the
|
||||
* resolver could not pick one (zone mesh missing, or no zone within
|
||||
* `NEAREST_ZONE_MAX_KM`). The filtered list is the honest answer.
|
||||
*/
|
||||
| { kind: 'zoneCity'; city: string; ibgeCode: string; zoneCount: number }
|
||||
/** Inside Bahia, but in a município outside the actor's portfolio. */
|
||||
|
|
@ -176,22 +186,31 @@ export const featureCentroid = (feature: PolygonalFeature): GeoPoint => {
|
|||
|
||||
/**
|
||||
* First feature whose polygons contain the point. A point on a shared border
|
||||
* lands in exactly one município by the half-open convention in `isPointInRing`,
|
||||
* lands in exactly one feature by the half-open convention in `isPointInRing`,
|
||||
* not by this scan's order: the mesh is TopoJSON-derived, so neighbours share
|
||||
* numerically identical arcs and never overlap. Feature order only decides
|
||||
* pathological overlaps the committed mesh does not contain.
|
||||
* numerically identical arcs and never overlap.
|
||||
*/
|
||||
export const findContainingFeature = <F extends PolygonalFeature>(
|
||||
features: readonly F[],
|
||||
point: GeoPoint,
|
||||
): F | undefined => features.find((feature) => featureContainsPoint(feature, point))
|
||||
|
||||
export const findContainingMunicipality = (
|
||||
features: readonly BahiaMunicipalityFeature[],
|
||||
point: GeoPoint,
|
||||
): BahiaMunicipalityFeature | undefined =>
|
||||
features.find((feature) => featureContainsPoint(feature, point))
|
||||
): BahiaMunicipalityFeature | undefined => findContainingFeature(features, point)
|
||||
|
||||
/**
|
||||
* Past this, "o mais próximo na sua carteira" stops being a shortcut and starts
|
||||
* being noise — an advisor in Salvador does not need a link to Barreiras.
|
||||
*/
|
||||
const NEAREST_IN_SCOPE_MAX_KM = 150
|
||||
export const NEAREST_IN_SCOPE_MAX_KM = 150
|
||||
|
||||
/**
|
||||
* When the point is inside Salvador's municipal polygon but outside every ZE
|
||||
* polygon, only zones within this radius are offered as a direct link.
|
||||
*/
|
||||
export const NEAREST_ZONE_MAX_KM = 30
|
||||
|
||||
const findNearestInScope = (
|
||||
getMunicipalityFeature: MunicipalityGeometryLookup['getMunicipalityFeature'],
|
||||
|
|
@ -214,6 +233,77 @@ const findNearestInScope = (
|
|||
return nearest
|
||||
}
|
||||
|
||||
const findNearestAccessibleZone = (
|
||||
zoneFeatures: readonly MunicipalityZoneFeature[],
|
||||
accessibleBySlug: ReadonlyMap<string, AccessibleMunicipality>,
|
||||
point: GeoPoint,
|
||||
): NearestInScope | null => {
|
||||
let nearest: NearestInScope | null = null
|
||||
|
||||
for (const zone of zoneFeatures) {
|
||||
const municipality = accessibleBySlug.get(zone.properties.municipalitySlug)
|
||||
if (!municipality) continue
|
||||
|
||||
const distanceKm = haversineKm(point, featureCentroid(zone))
|
||||
if (distanceKm > NEAREST_ZONE_MAX_KM) continue
|
||||
if (nearest && nearest.distanceKm <= distanceKm) continue
|
||||
|
||||
nearest = { municipality, distanceKm }
|
||||
}
|
||||
|
||||
return nearest
|
||||
}
|
||||
|
||||
const resolveMultiZoneCity = ({
|
||||
city,
|
||||
ibgeCode,
|
||||
inCity,
|
||||
point,
|
||||
zoneGeometry,
|
||||
geometry,
|
||||
accessible,
|
||||
}: {
|
||||
city: string
|
||||
ibgeCode: string
|
||||
inCity: readonly AccessibleMunicipality[]
|
||||
point: GeoPoint
|
||||
zoneGeometry: ZoneGeometryLookup | undefined
|
||||
geometry: MunicipalityGeometryLookup
|
||||
accessible: readonly AccessibleMunicipality[]
|
||||
}): NearbyMunicipalityResolution => {
|
||||
if (!zoneGeometry) {
|
||||
return { kind: 'zoneCity', city, ibgeCode, zoneCount: inCity.length }
|
||||
}
|
||||
|
||||
const accessibleBySlug = new Map(inCity.map((entry) => [entry.slug, entry]))
|
||||
const containingZone = findContainingFeature(zoneGeometry.features, point)
|
||||
|
||||
if (containingZone) {
|
||||
const municipality = accessibleBySlug.get(containingZone.properties.municipalitySlug)
|
||||
if (municipality) {
|
||||
return { kind: 'inScope', municipality, match: 'zoneContainment' }
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'outOfScope',
|
||||
city: containingZone.properties.name,
|
||||
nearestInScope: findNearestInScope(geometry.getMunicipalityFeature, accessible, point),
|
||||
}
|
||||
}
|
||||
|
||||
const nearestZone = findNearestAccessibleZone(zoneGeometry.features, accessibleBySlug, point)
|
||||
if (nearestZone) {
|
||||
return {
|
||||
kind: 'inScope',
|
||||
municipality: nearestZone.municipality,
|
||||
match: 'nearestZone',
|
||||
distanceKm: nearestZone.distanceKm,
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'zoneCity', city, ibgeCode, zoneCount: inCity.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a browser position into the one município the card should offer.
|
||||
*
|
||||
|
|
@ -225,10 +315,12 @@ const findNearestInScope = (
|
|||
export const resolveNearbyMunicipality = ({
|
||||
point,
|
||||
geometry,
|
||||
zoneGeometry,
|
||||
accessible,
|
||||
}: {
|
||||
point: GeoPoint
|
||||
geometry: MunicipalityGeometryLookup
|
||||
zoneGeometry?: ZoneGeometryLookup
|
||||
accessible: readonly AccessibleMunicipality[]
|
||||
}): NearbyMunicipalityResolution => {
|
||||
const containing = findContainingMunicipality(geometry.features, point)
|
||||
|
|
@ -246,12 +338,15 @@ export const resolveNearbyMunicipality = ({
|
|||
|
||||
if (inCity.length === 1) return { kind: 'inScope', municipality: inCity[0] }
|
||||
if (inCity.length > 1) {
|
||||
return {
|
||||
kind: 'zoneCity',
|
||||
return resolveMultiZoneCity({
|
||||
city: containing.properties.name,
|
||||
ibgeCode: containing.properties.codarea,
|
||||
zoneCount: inCity.length,
|
||||
}
|
||||
inCity,
|
||||
point,
|
||||
zoneGeometry,
|
||||
geometry,
|
||||
accessible,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
import type { Locator, Page } from '@playwright/test'
|
||||
import { loadMunicipalityGeometryModule } from '../../src/lib/bahiaGeometries.js'
|
||||
import {
|
||||
loadMunicipalityGeometryModule,
|
||||
loadMunicipalityZoneGeometryModule,
|
||||
} from '../../src/lib/bahiaGeometries.js'
|
||||
import type { BahiaMunicipalityFeature } from '../../src/lib/bahiaGeometriesTypes.js'
|
||||
import { getMunicipalityCatalogEntry } from '../../src/lib/municipalityCatalog.js'
|
||||
import {
|
||||
getMunicipalityCatalogEntry,
|
||||
municipalityCatalog,
|
||||
} from '../../src/lib/municipalityCatalog.js'
|
||||
import {
|
||||
featureCentroid,
|
||||
featureContainsPoint,
|
||||
haversineKm,
|
||||
resolveNearbyMunicipality,
|
||||
type GeoPoint,
|
||||
} from '../../src/lib/municipalityProximity.js'
|
||||
import { featureBounds } from '../helpers/featureBounds.js'
|
||||
|
|
@ -15,13 +22,34 @@ import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
|||
/**
|
||||
* B14 — the geo shortcut on the staff dashboard. What these tests protect is the
|
||||
* contract the field depends on: a granted permission resolves the município the
|
||||
* user is standing in, Salvador degrades to its zone list instead of guessing a
|
||||
* ZE, a position outside the portfolio says so, and a refused permission leaves
|
||||
* user is standing in (including a Salvador ZE when the zone mesh matches),
|
||||
* a position outside the portfolio says so, and a refused permission leaves
|
||||
* a card the user can act on instead of a spinner.
|
||||
*/
|
||||
|
||||
const SALVADOR_CENTRE: GeoPoint = { lat: -12.973, lng: -38.5121 }
|
||||
|
||||
const salvadorZoneAtCentre = async () => {
|
||||
const geometry = await loadMunicipalityGeometryModule()
|
||||
const zoneGeometry = await loadMunicipalityZoneGeometryModule()
|
||||
const accessible = municipalityCatalog
|
||||
.filter((entry) => entry.city === 'Salvador')
|
||||
.map((entry) => ({ slug: entry.slug, name: entry.name, ibgeCode: entry.ibgeCode }))
|
||||
|
||||
const resolution = resolveNearbyMunicipality({
|
||||
point: SALVADOR_CENTRE,
|
||||
geometry,
|
||||
zoneGeometry,
|
||||
accessible,
|
||||
})
|
||||
|
||||
if (resolution.kind !== 'inScope' || resolution.match !== 'zoneContainment') {
|
||||
throw new Error('Expected Salvador centre to resolve to an in-scope zone for the e2e contract.')
|
||||
}
|
||||
|
||||
return resolution.municipality
|
||||
}
|
||||
|
||||
/**
|
||||
* The card mirrors its headline in an sr-only live region (so the outcome is
|
||||
* announced), which means every headline matches twice. `.last()` is the visible
|
||||
|
|
@ -146,10 +174,8 @@ test.describe('Município mais próximo', () => {
|
|||
await expect(card.getByRole('link', { name: /^Abrir/ })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('coordinator in Salvador is sent to the zone list, never to a guessed ZE', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
test('coordinator in Salvador opens the matching zone directly', async ({ campaign, page }) => {
|
||||
const zone = await salvadorZoneAtCentre()
|
||||
const { fixtures } = campaign
|
||||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||||
name: fixtures.value('Coordenadora Geo'),
|
||||
|
|
@ -158,27 +184,20 @@ test.describe('Município mais próximo', () => {
|
|||
const email = coordinator.email!
|
||||
|
||||
await page.context().grantPermissions(['geolocation'])
|
||||
// A desk fix comes from the network, not the GPS: wide enough to be wrong about
|
||||
// the município, so the card must hedge.
|
||||
await page.context().setGeolocation({
|
||||
latitude: SALVADOR_CENTRE.lat,
|
||||
longitude: SALVADOR_CENTRE.lng,
|
||||
accuracy: 25_000,
|
||||
})
|
||||
|
||||
await campaign.login(page, email, password)
|
||||
|
||||
const card = geoCard(page)
|
||||
await expect(visibleText(card, 'dividida por zona eleitoral')).toBeVisible()
|
||||
await expect(card.getByText('Localização aproximada')).toBeVisible()
|
||||
await card.getByRole('link', { name: 'Ver zonas de Salvador' }).click()
|
||||
await expect(visibleText(card, zone.name)).toBeVisible()
|
||||
await card.getByRole('link', { name: `Abrir ${zone.name}` }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/campanha\/municipios\?.*q=Salvador/)
|
||||
// The deficit default sort can push a given zone off page 1 (P3-C root
|
||||
// cause of the deterministic red here): narrow the list to the zone first.
|
||||
// Exact: "Salvador — ZE 1" is otherwise a prefix of ZE 12 and ZE 17.
|
||||
await page.getByLabel('Buscar município').fill('Salvador — ZE 1')
|
||||
await expect(page.getByRole('link', { name: 'Salvador — ZE 1', exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/campanha/municipios/${zone.slug.replace(/-/g, '\\-')}$`),
|
||||
)
|
||||
})
|
||||
|
||||
test('a refused permission leaves an actionable card instead of a spinner', async ({
|
||||
|
|
|
|||
|
|
@ -280,9 +280,40 @@ describe('Bahia static geometries', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('sends a Salvador position to the zone list and an out-of-portfolio one to the nearest', async () => {
|
||||
it('resolves Salvador to an accessible zone when the zone mesh is loaded', async () => {
|
||||
const geometry = await loadMunicipalityGeometryModule()
|
||||
const salvadorZones = municipalityCatalog.filter((entry) => entry.city === 'Salvador')
|
||||
const zoneGeometry = await loadMunicipalityZoneGeometryModule()
|
||||
const salvadorZones = municipalityCatalog
|
||||
.filter((entry) => entry.city === 'Salvador')
|
||||
.map((entry) => ({
|
||||
slug: entry.slug,
|
||||
name: entry.name,
|
||||
ibgeCode: entry.ibgeCode,
|
||||
}))
|
||||
const salvadorPoint = { lat: -12.973, lng: -38.5121 }
|
||||
|
||||
const resolution = resolveNearbyMunicipality({
|
||||
point: salvadorPoint,
|
||||
geometry,
|
||||
zoneGeometry,
|
||||
accessible: salvadorZones,
|
||||
})
|
||||
|
||||
expect(resolution.kind).toBe('inScope')
|
||||
if (resolution.kind !== 'inScope') return
|
||||
expect(resolution.match).toBe('zoneContainment')
|
||||
expect(salvadorZones.some((zone) => zone.slug === resolution.municipality.slug)).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the zone list without zone mesh and reports out-of-portfolio in Salvador', async () => {
|
||||
const geometry = await loadMunicipalityGeometryModule()
|
||||
const salvadorZones = municipalityCatalog
|
||||
.filter((entry) => entry.city === 'Salvador')
|
||||
.map((entry) => ({
|
||||
slug: entry.slug,
|
||||
name: entry.name,
|
||||
ibgeCode: entry.ibgeCode,
|
||||
}))
|
||||
const seabra = municipalityCatalog.find((entry) => entry.slug === 'seabra')!
|
||||
const salvadorPoint = { lat: -12.973, lng: -38.5121 }
|
||||
|
||||
|
|
@ -303,7 +334,13 @@ describe('Bahia static geometries', () => {
|
|||
resolveNearbyMunicipality({
|
||||
point: salvadorPoint,
|
||||
geometry,
|
||||
accessible: [seabra],
|
||||
accessible: [
|
||||
{
|
||||
slug: seabra.slug,
|
||||
name: seabra.name,
|
||||
ibgeCode: seabra.ibgeCode,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ kind: 'outOfScope', city: 'Salvador', nearestInScope: null })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { BahiaMunicipalityFeature } from '@/lib/bahiaGeometriesTypes'
|
||||
import type { BahiaMunicipalityFeature, MunicipalityZoneFeature } from '@/lib/bahiaGeometriesTypes'
|
||||
import {
|
||||
featureCentroid,
|
||||
featureContainsPoint,
|
||||
|
|
@ -33,6 +33,28 @@ const square = (
|
|||
},
|
||||
})
|
||||
|
||||
const zoneSquare = (
|
||||
municipalitySlug: string,
|
||||
name: string,
|
||||
ibgeCode: string,
|
||||
{ west, south, size }: { west: number; south: number; size: number },
|
||||
): MunicipalityZoneFeature => ({
|
||||
type: 'Feature',
|
||||
properties: { municipalitySlug, name, ibgeCode },
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[west, south],
|
||||
[west + size, south],
|
||||
[west + size, south + size],
|
||||
[west, south + size],
|
||||
[west, south],
|
||||
],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const accessible = (slug: string, name: string, ibgeCode: string): AccessibleMunicipality => ({
|
||||
slug,
|
||||
name,
|
||||
|
|
@ -215,7 +237,7 @@ describe('municipalityProximity', () => {
|
|||
).toEqual({ kind: 'inScope', municipality: seabraEntry })
|
||||
})
|
||||
|
||||
it('falls back to the filtered list for a multi-zone city', () => {
|
||||
it('falls back to the filtered list for a multi-zone city without zone mesh', () => {
|
||||
expect(
|
||||
resolveNearbyMunicipality({
|
||||
point: { lat: -12.9, lng: -38.5 },
|
||||
|
|
@ -228,6 +250,67 @@ describe('municipalityProximity', () => {
|
|||
).toEqual({ kind: 'zoneCity', city: 'Salvador', ibgeCode: '2927408', zoneCount: 2 })
|
||||
})
|
||||
|
||||
it('resolves an accessible zone by containment when the zone mesh is present', () => {
|
||||
const zoneOne = accessible('salvador-ze-1', 'Salvador — ZE 1', '2927408')
|
||||
const zoneTwo = accessible('salvador-ze-2', 'Salvador — ZE 2', '2927408')
|
||||
const zoneGeometry = {
|
||||
features: [
|
||||
zoneSquare('salvador-ze-1', 'Salvador — ZE 1', '2927408', {
|
||||
west: -38.6,
|
||||
south: -13,
|
||||
size: 0.2,
|
||||
}),
|
||||
zoneSquare('salvador-ze-2', 'Salvador — ZE 2', '2927408', {
|
||||
west: -38.4,
|
||||
south: -13,
|
||||
size: 0.2,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveNearbyMunicipality({
|
||||
point: { lat: -12.9, lng: -38.55 },
|
||||
geometry,
|
||||
zoneGeometry,
|
||||
accessible: [zoneOne, zoneTwo],
|
||||
}),
|
||||
).toEqual({ kind: 'inScope', municipality: zoneOne, match: 'zoneContainment' })
|
||||
})
|
||||
|
||||
it('offers the nearest accessible zone when the point is in the city but outside every zone polygon', () => {
|
||||
const zoneOne = accessible('salvador-ze-1', 'Salvador — ZE 1', '2927408')
|
||||
const zoneTwo = accessible('salvador-ze-2', 'Salvador — ZE 2', '2927408')
|
||||
const zoneGeometry = {
|
||||
features: [
|
||||
zoneSquare('salvador-ze-1', 'Salvador — ZE 1', '2927408', {
|
||||
west: -38.58,
|
||||
south: -12.95,
|
||||
size: 0.05,
|
||||
}),
|
||||
zoneSquare('salvador-ze-2', 'Salvador — ZE 2', '2927408', {
|
||||
west: -38.45,
|
||||
south: -12.95,
|
||||
size: 0.05,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
const resolution = resolveNearbyMunicipality({
|
||||
// Inside the Salvador municipal square, outside both tiny zone squares.
|
||||
point: { lat: -12.85, lng: -38.5 },
|
||||
geometry,
|
||||
zoneGeometry,
|
||||
accessible: [zoneOne, zoneTwo],
|
||||
})
|
||||
|
||||
expect(resolution.kind).toBe('inScope')
|
||||
if (resolution.kind !== 'inScope') return
|
||||
expect(resolution.match).toBe('nearestZone')
|
||||
expect(['salvador-ze-1', 'salvador-ze-2']).toContain(resolution.municipality.slug)
|
||||
expect(resolution.distanceKm).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('names where the actor is and offers the closest município in scope', () => {
|
||||
const jequieEntry = accessible('jequie', 'Jequié', '2918001')
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue