diff --git a/docs/plans/escala-dry-pos-b68.md b/docs/plans/escala-dry-pos-b68.md new file mode 100644 index 00000000..86a3320d --- /dev/null +++ b/docs/plans/escala-dry-pos-b68.md @@ -0,0 +1,58 @@ +# Escala e DRY pós-B68 (sugestões na busca aberta) + +Status: rascunho (débito pós-`/simplify` 2026-07-29) +Atualizado em: 2026-07-29 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B68+**) +Impeccable: A — perf/estrutura de loader; sem pixel novo +Appetite: ~0,5–0,75 dia eng (F1); F2 cortável +Responsável: — + +## Dados → decisão → apresentação + +- **Vou apresentar dados?** Não (N/A) — mesmos ~8 hits já mostrados; só muda de onde vêm no primeiro focus. + +## Contexto + +**B68 ✓ (2026-07-29)** entrega sugestões de municípios no empty state da busca do Início (`mode: 'suggest'` em `POST /campanha/home-search`). O `/simplify` do fechamento mediu que, para **coordenador/candidato**, cada focus dispara um POST que repete `loadMunicipalityScope` + `loadMunicipalityGoalCoverageBundle` — trabalho que a mesma página já pagou em `loadCampaignHomeSummary` no RSC. `React cache()` não cruza a fronteira RSC ↔ route handler. + +O simplify da sessão já aplicou mitigação parcial: o bundle E8 roda só em municípios `priority === 'alta'` (não nas 435 linhas) e `toHomeSearchMunicipalityHit` unificou o shaping dos hits. + +## Objetivos + +- **F1:** primeiro focus no Início staff **não** refaz scope+coverage no POST quando o RSC da página já carregou os insumos — embed `initialSuggest` (ou loader irmão no mesmo `Promise.all` da página) e hidratar `useHomeSearchResultsState` com TTL curto / invalidação ao navegar. +- Sem mudar a política de ranking (assessor = frescor; CG = déficit + frescor). +- Sem migration/collection/Consent. + +## Já resolvido no simplify/critique (não reabrir) + +- `aria-busy` duplicado (`resultsBusy ?? isDebouncing`). +- Shell de erro sem `errorMessage` nullable intermediário. +- `id` removido do tipo puro do ranker. +- Factory `toHomeSearchMunicipalityHit` nos dois loaders. +- Bundle de cobertura restrito a `priority === 'alta'`. + +## Fases + +### F1 — RSC embed do suggest (P1 perf) + +- Estender `loadCampaignHomeSummary` ou extrair `loadHomeSearchSuggestHits` reutilizável no mesmo request da página (`page.tsx`). +- Passar `initialSuggest` para `CampaignHomeStaffChrome` → `useHomeSearchResultsState({ initialSuggest })`. +- Client: usar payload inicial quando `uiFocused && !query.isActive` e não houver stale/error; POST só para revalidação explícita ou após TTL (opcional v1: só primeiro paint). + +### F2 — Comparator compartilhado de déficit (cortável) + +- Extrair `compareNullableNumberDesc` + tiebreak hook para `lib/municipalitySort.ts` quando houver 3º call site com política diferente de tiebreak (hoje: B20 nome, B68 frescor, E9 lista). + +## Explicitamente fora / Adiado com gatilho + +- **Cache client-side / stale-while-revalidate no refocus** — aceito no plano B68 (fetch ao focus); revisitar se sessão reclamar de flash de loading. +- **Unificar constante `8`** (`HOME_SEARCH_SUGGEST_LIMIT` vs `DASHBOARD_PRIORITY_SAMPLE_LIMIT`) — cosmético; mover para `lib/` só se um dos dois mudar. +- **`loadTerritoryOverview` em todo keystroke da busca** — débito adjacente B48 (search path), não B68; gatilho: latência perceptível ao digitar após B49+. +- **Mesclar `loadHomeSearchSuggestions` com `searchHomeMunicipalities`** — dois call sites; extrair só o mapper (já feito). + +## Referências + +- [sugestoes-busca-vazia-inicio.md](sugestoes-busca-vazia-inicio.md) (B68) +- [`loadHomeSearchSuggestions.ts`](../src/utilities/homeSearch/loadHomeSearchSuggestions.ts) +- [`campaignDashboardData.ts`](../src/utilities/campaignDashboardData.ts) (`loadCampaignHomeSummary`) +- [`HomeSearchResultsContext.tsx`](../src/components/campaign/dashboard/HomeSearchResultsContext.tsx) diff --git a/docs/plans/sugestoes-busca-vazia-inicio.md b/docs/plans/sugestoes-busca-vazia-inicio.md index b1abb0a5..c499cf8a 100644 --- a/docs/plans/sugestoes-busca-vazia-inicio.md +++ b/docs/plans/sugestoes-busca-vazia-inicio.md @@ -111,6 +111,8 @@ Componentes: - **Grupos Lideranças / Demandas / Atividades / Assessores no empty.** Revisitar quando **B49–B53** landarem **e** o empty de municípios já estiver em uso (evidência de “falta X”). - **Visitados recentes no topo do suggest.** Revisitar se sessão observada pedir continuidade de sessão no Início (não só no Quadro). - **Ranking “mais inteligente”** (déficit × classe E10 × nível E14). Revisitar após 2 semanas de uso real do v1 simples. +- **RSC embed do suggest (evitar POST duplicado no focus).** Registrado como **B68+** F1 — revisitar se latência no focus for P1 em produção. +- **Comparator compartilhado de déficit** (B20 vs B68 vs E9). Revisitar no 3º call site — ver **B68+** F2. - **Soft deadline UX-1 03/08:** se folga apertar, este item é cortável depois de B66 (busca ainda funciona digitando). ## Referências diff --git a/docs/roadmap.md b/docs/roadmap.md index 1bd938b2..8e7aacb7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -183,6 +183,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - ~~**B33+**~~ ✓ escala/DRY pós-B33 (entregue 2026-07-27) — `useCampaignListFilterNavigation` em `shared/` absorve o scaffold de navegação/debounce dos três filtros (**325 linhas removidas contra 228 em `src`**, das quais 138 são o hook novo; JSX só se moveu onde era bug); a auditoria derrubou o racional das "assinaturas diferentes" (os três `buildXListHref` já canonicalizam, então um só callback `toHref` basta) e achou **dois** bugs de comportamento: dobradinhas e territórios descartavam a busca digitada ao tocar um filtro (unificado na semântica de municípios via `navigateWithSearch`) e o timer do debounce capturava o `state` do render que o agendou, revertendo um sort clicado dentro da janela de 1s — municípios escapava pelo `key=` da página, que remonta e mata o timer (corrigido com `stateRef`, com caso de spec verificado vermelho nas três listas sem o fix). O `/simplify` somou o fallback de transition copiado em 5 lugares (o hook era o 6º) virando `useCampaignListTransition`, e um `aria-live` a menos; a 2ª rodada fechou a ressincronização da caixa de busca com o `q` commitado (o que permitiu **remover** o `key=` de municípios e unificar a política de montagem das três listas — `buildMunicipalityFiltersKey` orfanou e morreu, junto com `shouldUpdateMunicipalitySearchUrl`), colapsou `setSearch`+`scheduleSearch` num `onSearchChange` (superfície de 8 → 6 nomes), passou a reusar o `buildMunicipalityFilterHref` que já existia + criou o irmão `buildStateDeputyFilterHref` (4 sítios pinavam página 1 à mão), e deu a municípios o resumo de filtros ativos que faltava no bloco mobile; `campaignListFilterNavigation.unit.spec.ts` (10 casos × 3 listas) é a primeira cobertura desses três componentes. **Dívidas deferidas com gatilho** (no plano, § "Adiado com gatilho"): shell de `
` compartilhado (gatilho B29), `CampaignListSortSelect` (bloqueado numa decisão de altura de select, já divergida entre as três), `serialize`/`parseXSortValue` triplicado → `campaignListUrl`, `SupporterFilters`/`ActivityFilters` reconstruírem href à mão tendo builders pinados, ~10 kB gzip do catálogo no bundle cliente de `/campanha/municipios` (pré-existente, achado da review de performance — **gatilho já disparado duas vezes**: B41 ✓ e B42 ✓ entregaram na rota sem pagar, então deixou de ser condicional e é a dívida de bundle corrente da rota) e o 3º idioma de mock de router nos specs · [plano](plans/escala-dry-pos-b33.md) - ~~**B32+**~~ ✓ escala/DRY pós-B32 (entregue 2026-07-28) — **+564 / −802 linhas** em 15 arquivos mais quatro novos. A auditoria mudou três premissas antes do código: **`display` vs `draft` nunca divergiu** (os dois eram setados juntos em todos os caminhos — um valor com dois nomes; só o `noteDraft` do Trend, texto cru vs. nota normalizada, ficou no chamador), **`reportFailure` não nascera nos quatro controles** (o de assessores não importava `sonner`, então falha depois do fechamento era muda — furo do B42, fechado aqui) e **o helper do F2 não resolvia o motivo do F2**. Por isso F2 virou **wrapper de rota**: `campaignJsonMutationRoute(config, handler)` exporta o `POST` já com origem verificada, corpo parseado e `catch` mapeado, com o `bodySchema.parse` **dentro do mesmo `try`** para preservar a mensagem de hoje em corpo inválido; `parseCampaignJsonRequestBody`/`campaignJsonMutationErrorResponse` deixaram de ser exportados, `engagement-level` mantém o 409 dentro do handler, e um **guard de convenção** (molde do de `formActions`, verificado vermelho com um `route.ts` probe) impede a 6ª rota de nascer fora — é o que troca a política de origem de disciplina de cópia por estrutura. F3 é `postCampaignJson` (25 linhas, cinco call sites). F1 é `useCampaignCellAutosave`: **um** valor, `flush()` idempotente como caminho único de save (o `inFlightRef` do B24 entrou no hook e passou a proteger os três), o hook **dono do `open`** porque fechar É o commit, `valueRef` avançado sincronamente para não reler o render que agendou a mudança (bug que B33+ e B34 já pagaram) e `reportFailure` embutido; spec novo com fake timers cobre debounce, coalescência, resposta superada, flush no fechamento e revert com toast só depois de fechado — antes essa máquina só tinha rede e2e. F4 moveu a região `aria-live` para **fora** do Popover/Drawer (prop `statusMessage` na casca, que permanece montada). F5 deu `footer` ao `variant="sheet"` e **o Sinal migrou**: o `formId` que o plano queria na casca é desnecessário — `` no chamador + `