diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index 860665d2..5fdfb07d 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
+**Recently resolved (2026-08-11):** **B197+** (Issue #642) — o menu do picker de colunas de assessores nasce da **mesma fonte** que os `
` do `AdvisorsTable` (fim do espelho): o módulo client declara `advisorTableColumns` (id/label/mandatory + `headerClassName` + `visuallyHiddenLabel` para o `sr-only` de Ações) e exporta `advisorPickerColumns = toCampaignColumnPickerColumns(...)` (precedente `supporterPickerColumns`); o header renderiza por `resolveVisibleColumns(advisorTableColumns, hiddenColumnIds)` — mesmo helper B17 das outras listas — e `emailHidden` deriva do mesmo `visibleColumns`. **Descoberta do /simplify incorporada:** o corpo só condiciona a coluna `email` (células à mão + exceção do draft), então `phone`/`municipalities`/`actions` são `mandatory: true` — esconder um deles (cookie velho do toggle inerte do B197) derrubaria o ` | ` sobre um corpo de 5 células; os checkboxes viram "sempre visível" (honestos). `assessores/page.tsx` consome a fonte exportada e perdeu o array espelho + imports órfãos. Comportamento da tabela idêntico (ids/labels/ordem, exceção do draft intacta); e2e `campaignColumnPicker` ganhou pin dos mandatory. Sem migration/access/Consent/UI/cache. Observações de ambiente (não da mudança): o e2e do picker falha no harness dev sob carga desta máquina (painel do popover não monta; falha idêntica na base e em lideranças — intocado; conjunto falho deriva com a carga; verificado funcional em Chromium real); warning React de key pré-existente no assessores (`CampaignListOmnibox` — base também). Intenção [`escala-dry-pos-b197.md`](plans/escala-dry-pos-b197.md); impl [`escala-dry-pos-b197-impl.md`](plans/escala-dry-pos-b197-impl.md).
+
**Recently resolved (2026-08-11):** **C113** (Issue #631) — feed iCal da agenda **vivo**: eventos criados depois da criação do link aparecem no mesmo link. **Spike diagnóstico primeiro** (e2e novo com o aceite de dois `GET`s via fixture — roda em dev E no build de produção): o teste **passou no modo prod ANTES do fix**, provando que o Next 15 **não** congela route handlers GET (docs: "not cached by default" desde 15.0; build compila a rota como `route.js` dinâmica, sem `.body`) — **o freeze é o contrato de resposta**: `Cache-Control: public, max-age=3600` sem validadores permitia a caches compartilhados (CDN da Vercel, infra do Google, proxies) reter o snapshot do primeiro fetch. **Fix (mínimo):** `export const dynamic = 'force-dynamic'` na rota `ical/[secret]` (explícito, convenção do repo) + novo `buildICalFeedResponse` em `src/utilities/calendarFeed.ts` (helper puro): `Cache-Control: public, no-cache` (armazenar só com revalidação obrigatória — fecha também a janela de 1h servindo feed revogado), `ETag` forte (sha256 do corpo, hash sempre muda com conteúdo), `Last-Modified` informacional, `X-PUBLISHED-TTL: PT1H` (RFC 7986) e **304 só por `If-None-Match`** — nunca por `If-Modified-Since`, porque exclusão de atividade não move `Last-Modified` (linha some) e um 304 por data esconderia "evento fantasma". **Testes:** unit do helper (12/12 — headers, ETag estável/mutável, 304 sem corpo, `*`/lista/weak, ETag velho → 200, IMS nunca 304); int na rota real com payload (`route.dynamic === 'force-dynamic'` + A → GET → B → GET ambos → edição de horário → DTSTART novo → cancelamento → some → GET condicional → 304, 18/18); e2e do aceite completo no spec existente `campaignAgendaFeed` (dev 5/5, prod build 1/1). Aceite: edição/cancelamento refletem, revogação/escopo do criador fail-closed intactos, sem migration/schema/access/UI/Consent. Verificação pós-deploy pendente no elo CDN externo (link real de produção). Intenção [`c113-feed-ical-agenda-nao-atualiza.md`](plans/c113-feed-ical-agenda-nao-atualiza.md); impl [`c113-feed-ical-agenda-nao-atualiza-impl.md`](plans/c113-feed-ical-agenda-nao-atualiza-impl.md).
**Recently resolved (2026-08-10):** **OPS36** (Issue #601) — journeys com **sessão compartilhada** nos 3 arquivos e2e pesados (`campaignHomeActions` 17, `campaignMunicipalities` 20, `campaignBottomNav` 9 testes): o overhead repetido por teste **amortizado de verdade**, não o que a intenção supunha. **Premissa da intenção corrigida por medição:** `getPayload` NÃO é pago por teste — Payload 3.82 memoiza a instância em `global._payload` keyed por `options.key` (medido: 364 ms na 1ª chamada do worker, 0 ms nas seguintes). O custo real era **o login de browser + a criação de usuário por teste** (bcrypt): login isolado medido em **3.6 s (quente) / 30.3 s (1º hit)** vs **112–134 ms** do `payload.login` de seed — ~32×. **Mecanismo:** `beforeAll` por arquivo cria UM coordinator + UM leader (instância `CampaignE2EOwnership` própria, cleanup em `afterAll`) e minta o token via `payload.login` (a MESMA via do login real — session row + JWT que o app valida); `beforeEach` injeta o cookie `campaign-token` (path `/campanha`, httpOnly, sameSite lax) no context fresh de cada teste — helpers `mintCampaignSession`/`seedCampaignSession`/`createCampaignOwnership` no fixture. **Divergências declaradas da intenção:** (1) **sem `mode: 'serial'`** — serial existia para reusar o contexto do browser; com seed por cookie o reuso é desnecessário e o serial skipa N+1..k em falha (perde diagnóstico — anti-goal "mega-teste" da própria intenção); (2) storageState rejeitado (file race entre workers + `test.use` em tempo de definição); (3) **login UI sai dos 46 testes** — caminho permanece coberto por `campaignAuth.e2e.spec.ts` + journey mista t9 (advisor→leader, mantém browser login); advisors **nunca** compartilhados (OPS29 exige portfólio vazio; B176 atribui portfólio). **Flake classe exposta e endurecida:** sem o warm-up incidental do login, clicks de estado client-side (drawer/sheet/overlay) passaram a correr contra a hidratação — a classe B13/B17 da casa; 7 interações ganharam o padrão `toPass` (B70 add-liderança, Mais drawer, B42 trend/C109, B193 scenario, FAB×3) e 7 lookups do strip de ações/omnibox ganharam `.first()` (duplicado transitório RSC do shell — classe `.nth(0)` já documentada), sem enfraquecer asserção. **Medição antes/depois:** baseline 29/49 passou em 10.0 m a load ~55 (20 falhas = classe de carga/restart de Postgres por outro worktree — OPS37; A/B no código original: B97 falha identicamente na mesma carga, e o conjunto falhante casa o do baseline); com o seed, runs limpas: homeActions 18/19 (2.6 m), interações endurecidas 8/10 (falhas residuais = render lento sob load, classe pré-existente comprovada). Sem migration/access/Consent/UI. Intenção [`e2e-journeys-seriais-arquivos-pesados.md`](plans/e2e-journeys-seriais-arquivos-pesados.md); impl [`e2e-journeys-seriais-arquivos-pesados-impl.md`](plans/e2e-journeys-seriais-arquivos-pesados-impl.md).
diff --git a/docs/plans/escala-dry-pos-b197-impl.md b/docs/plans/escala-dry-pos-b197-impl.md
new file mode 100644
index 00000000..cbce4485
--- /dev/null
+++ b/docs/plans/escala-dry-pos-b197-impl.md
@@ -0,0 +1,133 @@
+# Impl: Assessores — menu do picker de colunas derivado da mesma fonte do AdvisorsTable
+
+Status: concluído (executado 2026-08-11)
+Atualizado em: 2026-08-11
+Issue: #642
+Intenção: docs/plans/escala-dry-pos-b197.md
+Appetite restante: ~0,5 dia (herdado)
+
+## Leitura da intenção
+
+- **Outcome:** o menu do picker de colunas de assessores nasce da mesma fonte
+ que os ` | ` da tabela — adicionar coluna num lugar atualiza o outro de
+ graça. Zero mudança de comportamento.
+- **O que NÃO negociar:** mesmos ids (`name` mandatory, `email`, `phone`,
+ `municipalities`, `actions` — contrato do cookie `campaign_columns`), mesmos
+ labels pt-BR, mesma ordem, exceção do draft intacta (input de e-mail na
+ linha-draft mesmo com a coluna oculta), e o não-escopo travado de não portar
+ o `AdvisorsTable` para `CampaignTable`.
+- **O que reavaliar:** o plano de intenção já recomenda a Opção A (fonte única
+ no módulo da tabela); a reavaliação aqui é de engenharia fina — onde a
+ constante vive, como o ` | ` consome, como o `emailHidden` deriva.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ S[advisorTableColumns AdvisorsTable.tsx] --> H[ | via resolveVisibleColumns]
+ S --> P[advisorPickerColumns = toCampaignColumnPickerColumns]
+ P --> T[CampaignColumnPickerTrailing assessores/page.tsx]
+```
+
+**Opções consideradas:** A | B | C | D
+**Recomendação:** A — porque segue o precedente em produção
+(`supporterColumns`/`supporterPickerColumns` em `SupporterList.tsx` — o RSC de
+`apoiadores` importa a constante serializável do módulo do componente),
+reusa os helpers B17 (`toCampaignColumnPickerColumns`, `resolveVisibleColumns`)
+e mantém o diff em 2 arquivos.
+**Rejeitadas:**
+
+- **B — lib nova (`src/lib/advisorTableColumns.ts`):** só há dois consumidores
+ e um é o mesmo módulo que renderiza; módulo lib com 1 call site real é
+ camada sem volatilidade (depth check — pass-through).
+- **C — status quo (comentário):** o achado que originou a Issue é exatamente
+ que o comentário é insuficiente; rejeitada pela intenção.
+- **D — extrair só id/label/mandatory e manter widths/sr-only no JSX:** deixa
+ dois lugares para tocar ao adicionar coluna (layout do header) e obrigaria
+ exceção manual no map para o caso `Ações`; a classe de header é propriedade
+ da coluna, declarada junto.
+
+### Decisões de engenharia
+
+1. **Fonte única em `AdvisorsTable.tsx` (client).** Novo tipo local
+ `AdvisorTableColumn { id, label, mandatory?, headerClassName?,
+visuallyHiddenLabel? }` e constante `advisorTableColumns` com as 5 colunas
+ atuais (incluindo `w-[20%]`/`w-[24%]`/`w-[14%]`/`w-28 text-right` e
+ `visuallyHiddenLabel: true` em `Ações`, que preserva o ``).
+2. **` | ` consomem `resolveVisibleColumns(advisorTableColumns,
+columnVisibility.hiddenColumnIds)`** — o mesmo helper B17 das outras listas:
+ filtra por cookie respeitando `mandatory`, e `emailHidden` passa a derivar
+ de `visibleColumns.some(id === 'email')`. **Descoberta do /simplify (fix
+ incorporado):** o corpo renderiza as células incondicionalmente (só `email`
+ tem ` | ` condicional + exceção do draft), então `phone`/`municipalities`/
+ `actions` são `mandatory: true` — um id escondido por cookie velho derruba
+ o ` | ` sobre um corpo de 5 células (table-fixed) se não for; o checkbox
+ do picker vira "sempre visível" (honesto em vez do toggle inerte do B197).
+ O corpo (células + exceção do draft) fica intocado.
+3. \*\*`export const advisorPickerColumns = toCampaignColumnPickerColumns(advisorTableColumns)`
+ no módulo da tabela — mesma forma de `supporterPickerColumns`.
+4. **`assessores/page.tsx` importa `advisorPickerColumns`** e passa direto ao
+ `CampaignColumnPickerTrailing`; removidos o array local, o comentário do
+ espelho e os imports órfãos (`toCampaignColumnPickerColumns`,
+ `CampaignColumnPickerColumn`).
+5. **e2e `campaignColumnPicker` (assessores):** asserts novos pinando o
+ contrato mandatory — `Nome`/`Celular` "sempre visível" (checked + disabled).
+
+### Componentes / mudanças
+
+- **`AdvisorsTable.tsx`** (`src/components/campaign/advisor/`): declara a
+ fonte única, exporta `advisorPickerColumns`, header por map; corpo intacto.
+- **`assessores/page.tsx`** (`src/app/(campaign)/campanha/(app)/assessores/`):
+ consome a fonte exportada.
+- **Migration:** sem migration.
+- **Access / Consent:** sem mudança (acesso já gateado no page).
+- **UI:** Impeccable A — sem superfície nova; refactor estrutura que preserva
+ DOM/aria (sr-only de `Ações` mantido).
+
+### Dados → forma
+
+- Forma escolhida: colunas como dados serializáveis no módulo do componente,
+ convertidas na fonte via helper B17. Alternativas rejeitadas: lib separada
+ (sem call site extra) e manutenção do espelho (o próprio defeito).
+
+## Fases verificáveis
+
+1. **Fonte única + header** — `AdvisorsTable.tsx`: tipo + constante + export +
+ map do header com `resolveVisibleColumns`; `emailHidden` derivado.
+2. **Consumo no page** — `assessores/page.tsx`: import + remoção do array
+ local e imports órfãos.
+3. **Gates** — `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`,
+ `pnpm test` (unit: `campaignColumnVisibility` — lib intacta), e2e
+ `campaignColumnPicker` (spec de assessores cobre exatamente os comportamentos
+ preservados: default oculto, draft com input, religar, persistência);
+ `pnpm exec knip` (removidos imports órfãos), `pnpm check:cycles`.
+4. **Fechamento** — status do plano de intenção → concluído; entrada curta em
+ `docs/CHANGELOG-AGENTS.md`.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Não portar `AdvisorsTable` para `CampaignTable` (decisão travada do B197).
+- Não mexer em labels/ordem/ids — contrato do cookie `campaign_columns`.
+- Não criar coluna nova, não tocar células do corpo além de `emailHidden`
+ derivado.
+- Sem novo unit test: o comportamento é idêntico e coberto pelo e2e existente;
+ testar a constante exigiria importar o módulo client inteiro (lucide/next/
+ sonner) — custo sem retorno para um espelho que deixou de existir.
+
+## Riscos e mitigação
+
+- **Import de client module em RSC:** precedente em produção
+ (`supporterPickerColumns` em `apoiadores/page.tsx`); valor serializável
+ (array de `{id,label,mandatory}`).
+- **Layout `table-fixed`:** classes de largura movem para o mesmo ` | `, na
+ mesma ordem — DOM e classes idênticos, só que gerados por map com `key`.
+- **`emailHidden` divergente do header:** eliminado por construção — deriva do
+ mesmo `visibleColumns` que renderiza o header.
+
+## Aceite de engenharia
+
+- [x] Aceite de produto da intenção ainda coberto (mesma fonte, zero mudança)
+- [x] Invariantes AGENTS/engineering-standards (edit the owner: a tabela já
+ possui o concern do menu; reusa lib B17)
+- [x] Testes de domínio: sem mudança de access/write path; e2e existente é o
+ guard do comportamento
diff --git a/docs/plans/escala-dry-pos-b197.md b/docs/plans/escala-dry-pos-b197.md
index cf6270f1..804b706c 100644
--- a/docs/plans/escala-dry-pos-b197.md
+++ b/docs/plans/escala-dry-pos-b197.md
@@ -1,6 +1,6 @@
# Escala e DRY pós-B197 (picker de colunas de assessores)
-Status: pendente (capture-review-debts pós-B197, gate humano confirmado)
+Status: concluído (Issue #642, impl aprovado e executado 2026-08-11)
Atualizado em: 2026-08-11
Item do roadmap: fill-in **B197+** pós-[#627](https://github.com/fsolla/teqo/issues/627)
Impeccable: A — sem superfície UI nova (DRY/estrutura)
diff --git a/src/app/(campaign)/campanha/(app)/assessores/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/page.tsx
index 6cfca92c..4583372a 100644
--- a/src/app/(campaign)/campanha/(app)/assessores/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/assessores/page.tsx
@@ -3,7 +3,7 @@ import config from '@payload-config'
import { getPayload } from 'payload'
import { AdvisorFilters } from '@/components/campaign/advisor/AdvisorFilters'
-import { AdvisorsTable } from '@/components/campaign/advisor/AdvisorsTable'
+import { AdvisorsTable, advisorPickerColumns } from '@/components/campaign/advisor/AdvisorsTable'
import { CampaignColumnPickerTrailing } from '@/components/campaign/shared/CampaignColumnPickerTrailing'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import {
@@ -11,10 +11,6 @@ import {
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
-import {
- toCampaignColumnPickerColumns,
- type CampaignColumnPickerColumn,
-} from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { hasAdvisorListActiveFilters } from '@/utilities/advisor/advisorListFilters'
@@ -40,17 +36,6 @@ type AdvisorsPageProps = {
searchParams: Promise>
}
-// B197 — labels mirror the `| ` copy of `AdvisorsTable`; the table's own
-// columns are hand-rolled (not `CampaignTableColumn`), so the picker menu is
-// spelled out here instead of derived.
-const advisorPickerColumns: readonly CampaignColumnPickerColumn[] = [
- { id: 'name', label: 'Nome', mandatory: true },
- { id: 'email', label: 'E-mail' },
- { id: 'phone', label: 'Celular' },
- { id: 'municipalities', label: 'Municípios' },
- { id: 'actions', label: 'Ações' },
-]
-
export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) {
const rawSearchParams = await searchParams
const [, payload] = await Promise.all([
@@ -82,7 +67,7 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps)
trailing={
}
/>
diff --git a/src/components/campaign/advisor/AdvisorsTable.tsx b/src/components/campaign/advisor/AdvisorsTable.tsx
index 32032355..d35dfd19 100644
--- a/src/components/campaign/advisor/AdvisorsTable.tsx
+++ b/src/components/campaign/advisor/AdvisorsTable.tsx
@@ -34,7 +34,11 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/Table'
-import type { CampaignColumnVisibility } from '@/lib/campaignColumnVisibility'
+import {
+ resolveVisibleColumns,
+ toCampaignColumnPickerColumns,
+ type CampaignColumnVisibility,
+} from '@/lib/campaignColumnVisibility'
import { campaignInlineInputClassName } from '@/lib/campaignInlineInput'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
import {
@@ -54,6 +58,37 @@ type DraftAdvisor = {
municipalityIds: number[]
}
+// B197+ — single source for the ` | `s and the picker menu: a column added
+// here appears in both, and `resolveVisibleColumns` keeps the cookie filtering
+// honest (a `mandatory` column is never dropped). Only what the body renders
+// conditionally is hideable: the cells are hand-wired per column id (draft row
+// AND data row), so `email` is the sole non-mandatory column — the others must
+// stay mandatory or a hidden id would drop the header under a 5-cell body.
+type AdvisorTableColumn = {
+ id: string
+ label: string
+ mandatory?: boolean
+ headerClassName?: string
+ /** Renders the label visually-hidden — the icon-only actions column. */
+ visuallyHiddenLabel?: boolean
+}
+
+const advisorTableColumns: readonly AdvisorTableColumn[] = [
+ { id: 'name', label: 'Nome', mandatory: true, headerClassName: 'w-[20%]' },
+ { id: 'email', label: 'E-mail', headerClassName: 'w-[24%]' },
+ { id: 'phone', label: 'Celular', mandatory: true, headerClassName: 'w-[14%]' },
+ { id: 'municipalities', label: 'Municípios', mandatory: true },
+ {
+ id: 'actions',
+ label: 'Ações',
+ mandatory: true,
+ headerClassName: 'w-28 text-right',
+ visuallyHiddenLabel: true,
+ },
+]
+
+export const advisorPickerColumns = toCampaignColumnPickerColumns(advisorTableColumns)
+
type AdvisorsTableProps = {
rows: AdvisorRowViewModel[]
municipalityIndex: MunicipalityPortfolioIndexEntry[]
@@ -108,10 +143,15 @@ export const AdvisorsTable = ({
const [isCreating, startCreate] = useTransition()
// B197 — email starts hidden on this list (cookie default); the picker
- // writes the cookie and the server re-renders with a fresh value. The
- // create-draft row below is the ONE exception: email is required to create
- // an advisor, so its input renders even while the column is hidden.
- const emailHidden = columnVisibility.hiddenColumnIds.includes('email')
+ // writes the cookie and the server re-renders with a fresh value. B197+:
+ // the visible set is what the ` | `s render, so this exception below can
+ // never disagree with the header. The create-draft row is the ONE exception:
+ // email is required to create an advisor, so its input renders always.
+ const visibleColumns = resolveVisibleColumns(
+ advisorTableColumns,
+ columnVisibility.hiddenColumnIds,
+ )
+ const emailHidden = !visibleColumns.some((column) => column.id === 'email')
const refresh = () => router.refresh()
@@ -220,13 +260,15 @@ export const AdvisorsTable = ({
- Nome
- {!emailHidden ? E-mail : null}
- Celular
- Municípios
-
- Ações
-
+ {visibleColumns.map((column) => (
+
+ {column.visuallyHiddenLabel ? (
+ {column.label}
+ ) : (
+ column.label
+ )}
+
+ ))}
diff --git a/tests/e2e/campaignColumnPicker.e2e.spec.ts b/tests/e2e/campaignColumnPicker.e2e.spec.ts
index 16d1fcb8..c01808eb 100644
--- a/tests/e2e/campaignColumnPicker.e2e.spec.ts
+++ b/tests/e2e/campaignColumnPicker.e2e.spec.ts
@@ -172,6 +172,17 @@ test.describe('E-mail oculto por padrão nas listas de pessoas (B197)', () => {
await page.getByRole('button', { name: 'Novo assessor', exact: true }).click()
await expect(page.getByLabel('E-mail do novo assessor')).toBeVisible()
+ // B197+ — the table body only conditions the email column, so every other
+ // column is mandatory in the single source: their checkboxes are locked.
+ await picker.click()
+ const nameCheckbox = page.getByRole('checkbox', { name: 'Nome sempre visível' })
+ await expect(nameCheckbox).toBeChecked()
+ await expect(nameCheckbox).toBeDisabled()
+ const phoneCheckbox = page.getByRole('checkbox', { name: 'Celular sempre visível' })
+ await expect(phoneCheckbox).toBeChecked()
+ await expect(phoneCheckbox).toBeDisabled()
+ await page.keyboard.press('Escape')
+
// Re-enabling paints the column and the choice survives a reload.
await picker.click()
await page.getByRole('checkbox', { name: 'E-mail', exact: true }).click()
| |