Espelho oficial da agenda em calendário Google compartilhado via API
(service account, jose + fetch): reconciliação por passada com id
determinístico teqo+base32hex (upsert idempotente, sem estado por evento),
hooks afterChange/afterDelete na activity (só campos espelhados, nunca
lança), auto-resync quando calendarId muda, retry na view da agenda,
pill de estado no header + FAB + diálogo (4 estados). Cancelado no Teqo
deleta o evento no Google. Sem participantes no v1 (decisão de produto).
Sem migration nova de tabela (hand-written). Closes#635
- Contact.phone (text único) → phones (array field Payload, subcampo value):
fonte única; principal = phones[0]; beforeValidate normaliza, filtra vazios,
rejeita inválido e duplicado DENTRO da ficha (C111 entre fichas preservado)
- migration add_contact_phones_array: backfill phone → phones[0] (gen_random_uuid)
antes do drop da coluna + índice em value (hand-edit documentado; snapshot .json
gerado mantém as colunas C99/C100 já existentes)
- primaryPhoneOf / phoneValuesOf / reorderWithPrimaryPhone em src/lib/phone.ts
(ordem = prioridade; inline edit = seta o principal preservando o resto)
- findOrCreateContactByPhone aceita phones[] (dedupe pelo primário; reuse faz
append dos faltantes; locks em todos os números digitados)
- sync conta→ficha (CampaignUser): phone da conta faz APPEND quando a ficha não
tem (nunca reordena nem remove); tombstone LGPD substitui TODOS os telefones
- convite redemption: telefone digitado vira principal, demais preservados
- import CSV: preview sinaliza compartilhado também por número secundário;
bulk insere contact_phones (JOIN nas leituras, uuid no insert)
- busca por telefone (apoiadores, pickers, combobox) casa qualquer número
- UI: PhonesFieldEditor compartilhado (repetível mascarado, remover, ↑/↓,
modo inline com Salvar) nas 5 superfícies — liderança (form/ficha/lista),
apoiador (form/ficha), dobradinha (ficha), assessor (ficha), wizard
- UI pública (petição/WhatsApp) intocada: zod e forms ficam com phone;
as ações mapeiam phone → phones[0]
- testes: spec novo campaignMultiPhones (aceite completo), specs C111/C99
atualizados, e2e wizard/apoiador ajustados
Spike no build de produção provou que o Next 15 não congela route handlers GET
(rota dinâmica por default); o freeze era o contrato de resposta
(public, max-age=3600 sem validadores) permitindo caches compartilhados
reterem o snapshot do primeiro fetch. Fix: force-dynamic explícito +
buildICalFeedResponse com public no-cache, ETag forte sha256, Last-Modified,
304 só por If-None-Match (nunca por data — blind spot de exclusão), dica
X-PUBLISHED-TTL no corpo do VCALENDAR (RFC 7986) e 404 no-store para
revogação fail-closed no CDN. Testes unit/int/e2e (dev + prod build).
Chassis de lista (vale para todas as listas, gate A):
- scrollport: max-md:has-[.campaign-list-omnibox-form]:pt-0 — o containing
block do sticky passa a começar no topo; a barra cola no header (sem vão
de 16px, nada passa por cima no scroll)
- barra densa: campo min-h-8 py-0 + X size-8 → ~40px no total; anel de
foco escopado a md (mobile sem anel de cor; caret é o indicador)
- trailing cluster desktop-only por padrão (edgeToEdge removido)
- pt-4 md:pt-0 nas 6 listas com linha de ações antes do form (incl. organizacoes)
Card mobile (B193 densificado):
- nome 18px leading-tight com território a ~2px
- chips como blocos label-acima (Classe/Tendência/Nível, gate B) via ChipBlock
- avatares numa linha por grupo com overlap proporcional (flex-1 cells,
sem cap/wrap/…, overflow-hidden nas bordas) — prop wrap → overlapRow
- MissingAdvisorBadge com wrap para não clipar na célula do grupo
- densidade vertical gap-2.5/py-3; toques min-h-11 preservados
e2e B196 (6 testes) + regressão B184/B183/B193 verde.
Contact.phone deixa de ser único em enforcement (nunca houve constraint DB):
- remove o hook enforceUniqueContactPhone e os asserts assertContactPhone*
(conflicts/ambiguity) — os 5 fluxos de escrita de pessoa salvam com número
compartilhado; rename contactPhoneInvariant.ts -> contactPhoneLocks.ts
(locks passam a significar serialização, não unicidade)
- findOrCreateContactByPhone: 0 fichas cria, 1 reusa (dedupe C6/C99),
2+ cria ficha nova (a identidade da pessoa em criação é a ficha nova)
- convite autofill/login: ficha ancorada pode compartilhar o phone; o lado
conta permanece fail-closed (username DB-unique; check de 2+ contas)
- import CSV: preview sinaliza telefone_compartilhado (resolução manual);
confirm/bulk abortam fail-closed se um phone ganhar 2+ fichas (race)
- fix de achado de execução: supporter_import_batch.actor_id NOT NULL com
FK ON DELETE set null derrubava o delete de campaignUser com batch
órfão (23502) — CampaignUser.beforeDelete limpa batches + fixture
cleanup descobre/deleta batches dos accounts owned
- requestPermission agora é chamado sincronamente no clique: dentro do
startTransition(async ...) o navegador perdia a user activation e o Chrome
auto-negava silenciosamente ("Permissão de notificação negada" sem prompt)
- CampaignPushOptInToast no shell (app): staff com permission 'default', sem
inscrição ativa, consentimento publicado e VAPID configurado; dismiss em
localStorage; iOS só com app instalado
- cerimônia de ativação/desativação extraída para
src/utilities/campaignPushClient.ts, compartilhada com o card de perfil
- mensagens distintas para denied (reativar nas configurações do site) vs
default (prompt não exibido); pin de top-level registrado
- CHANGELOG-AGENTS.md: entrada única
Merge por Contact (uma linha por pessoa física) de lideranças,
dobradinhas e staff, com capacidades como colunas; omnibox com
capacidade/município/apoio + filtros salvos (2º call site do B18 via
factory createSavedFilterStore); apagar pessoa com manifest de
confirmação e cascata transacional; mobile edge-to-edge (omnibox sem
bordas, cards com divisórias, salvar filtro no header). Sem migration.
Closes#495
- src/lib/sollinhaOpeningQuestions.ts: catálogo puro de chips de abertura
(staff: 4 perguntas ancoradas nas tools atuais; leader: conjunto seguro
sem tool eleitoral — fail-closed B180)
- ChatChipGroup: primitivo presentacional reutilizável (slot compartilhado
com o B192)
- role do usuário flui do layout server pelo shell até o context do chat
- slot acima do input renderiza só com conversa vazia (messages 0 + ready);
tocar envia via sendMessage e os chips somem no primeiro envio
- unit tests do catálogo + e2e (staff desktop 4 / mobile 3 / leader seguro)
- Chassis CampaignListOmnibox (B127): abaixo de md a label some (input carrega
aria-label), o campo perde borda/sombra (md: restaura), o form do filtro vira
barra sticky com linha separadora (sticky no form porque não escapa do
containing block — a coluna do input dentro do wrapper curto não grudava),
o texto Limpar vira X circular dentro do input (chips OU busca; slot sempre
renderizado com invisible; hit target 44px; live region de limpeza), botão
texto Limpar desktop-only. Padrão para as 11 listas via
campaignListOmniboxFormClassName.
- Municípios: Salvar filtro vira icon no header mobile (C94/C95 slot,
presentation='icon', nome de rename = contrato 2.5.3); cards edge-to-edge
com separador de linha (border-b last:border-b-0, -mx-4, gate md:).
- Atualizações: Nova atualização vai para o header mobile (único trailing
mobile-visible que grudaria na barra).
- campaignMobileHeaderIconClassName compartilhada (3º consumidor).
- E2E mobile novo (390x844) com pins: foco/aria-expanded após limpar, salvar
-> renomear no header, cards, desktop invertido.
- D7 registrado no caminho: sticky dos headers da tabela desktop morto.
openCampaignNotifications (lista + marca em 1 round trip) substitui
listCampaignNotifications + markAllCampaignNotificationsRead; markAllNotificationsRead
extraída (owner-scoped, idempotente, cap 200) com int test; bell vira Dialog
desktop / Drawer mobile (par C94), sem título/rodapé/botões, skeleton de
loading, safe-area, focus-visible, role=alert; readAt sai do contrato do item;
P4-D ledger atualizado (batch sem transação agora no hot path, self-healing).
O filtro por tag na omnibox já existia (C94); o C105 completa a superfície da
agenda: campo de tags no overlay de criação inline (chips, sugestões das tags
já usadas), rótulo leve #tag no evento do calendário (oculto no mês/mobile) e
tags via prefill do "Mais detalhes" (params repetidos, parse fail-closed).
- ActivityTagInput extraído do ActivityForm (compartilhado, aria-label novo)
- "Mais detalhes" espelha estado vivo (horários editados + tags)
- popover do sheet com max-h --radix-popper-available-height (shift-before-flip
do Radix clipava os campos do topo em viewports baixos)
- submit lê tags do estado espelho (fonte única), 4 KB cap p/ responsáveisJson
- limites mantidos 20/80 (C14), sem migration/access/Consent
Impl: docs/plans/agenda-tags-eventos-impl.md (aprovado no gate humano)
No mobile (<768px) staff perde o botão de abrir sidebar na top bar
(CampaignMobileTopBar role-gated via useCampaignPageChromeRole +
isStaffCampaignRole; leader mantém) e o sheet de navegação deixa de
montar (CampaignSidebar early-return quando isMobile && staff).
Filtros salvos de Municípios (B18) migram para o drawer Mais
(MunicipalityNavSavedFilters variant 'overflow', mesma store,
apagar/desfazer/sucessão de foco; drawer ganha overflow-y-auto).
Correção de bug pré-existente (D4): o Toaster vivia dentro do wrapper
[contain:layout_paint] do painel da AI, que vira containing block para
position:fixed e stacking context próprio — toasts com ação (ex.
Desfazer) ficavam inalcançáveis sob os portais z-50 de drawer/sheet.
Movido para o root layout (campaign).
Testes: unit top bar (staff sem trigger / leader com trigger) + e2e
(staff sem trigger/dialog, leader com sheet, B18 no drawer completo).
Regra CSS única em styles.css — @media (pointer: coarse) and (hover: none)
+ [data-theme='campaign'] :is(input, select, textarea, [contenteditable]) a
16px (limiar do Safari iOS). Cobre omnibox B127, Command search, native-select
e textareas em qualquer orientação do iPhone; desktop intocado.
E2E campaignIosInputZoom: emulação iPhone 13 pina 16px na omnibox de
/campanha/municipios; control desktop pina 14px.
Closes#501
Motor client-side (standalone-iOS only, no-op em aba/desktop):
heal imediato no install + launch/bfcache/resume/pós-teclado/resize,
scrollTo(0,0) + re-measure nudge, retry ≤3×250ms por episódio,
pinça preservada (janela renovada por gesture*/touch ≥2), debug
global para verificação em aparelho, fallback maximum-scale off.
40 testes unit (jsdom) pinam detecção, guards, retry e teardown.
Troca o datetime-local nativo (AM/PM no idioma do browser, horário pouco
confiável) por ActivityDateTimeField: trigger com rótulo civil
dd/mm/aaaa às hh:mm + popover com Calendar (react-day-picker 9.7,
timeZone America/Bahia, locale pt-BR) e selects Hora 00-23 / Minuto
em passos de 15. Contrato de valor e submit inalterados; prefill do
slot mantido. Novo ui/Calendar.tsx (variante radix-nova), helpers
puros em campaignTime.ts. Sem migration. Closes#482 (PR).
- view=week|day|month|list no contrato URL da agenda (canônico, fail-closed)
- seletor compacto 'Semana ▾' no header desktop e mobile (slot C94/C95)
- FullCalendar headerToolbar sem os 4 botões de modo; escolha vence resize
- effect normaliza a vista também quando a URL perde o param (sem vista órfã)
- useNarrowMeasured(breakpoint) em hooks/use-mobile (settle de hidratação)
- e2e reescrito no seletor + persistência + escolha-vence-resize + estabilização
dos waits de slot (grid lazy do FullCalendar substitui gridcells no load)
The 'Link de import' button was disabled whenever no agenda filter was
active, forcing a filter to subscribe to the full campaign agenda. The
server already supported the empty-filter case (filterMunicipality empty
= entire creator scope via buildFeedWhere/resolveFeedCreatorAccess), so
this unlocks the button and drops the now-dead state/hasFilters props.
The dialog copy is neutralized ('recorte' -> 'agenda') to cover both the
filtered and filterless shapes.
Int tests pin the content acceptance: coordinator filterless feed covers
all municipalities; advisor filterless feed is scoped to the administered
portfolio (nothing out-of-scope leaks); an advisor with no assigned
municipality serves an empty feed (fail-closed). A new component test
asserts the button is enabled with zero filters and the create flow works.
Closes#437
Omnibox de /campanha/municipios ganha as dimensões Dobradinha (nome do
contato), Liderança (nome do contato) e Partido (sigla), com recortes
"Sem dobradinha"/"Sem liderança"/"Sem partido" no precedente de
"Sem nível". Params aditivos stateDeputy/leadership/party na URL
congelada; buildMunicipalityListWhere ganha catálogo request-scoped
fail-closed (municipalityRelationSets) que vira as leituras reversa e
cruzada em where — sem migration, paginação nativa mantida; facets
estendidas e o mapa compartilha o catálogo. Coluna Dobradinhas liberada
ao staff (servidor já scoped B37/B157; só o gate de UI mudou).
Extrato do par sentinela+exists:false em campaignListUrl (5 call sites).
O cache-hit do DeepSeek-V4-Flash custa $0.0028/M contra $0.018/M na
Deep Infra (~6,4x mais barato), e a Sollinha tem prefixo estável
(system prompt + tools + historico), entao o custo efetivo cai. Mesmo
modelo (deepseek-v4-flash), mesmas tools. @ai-sdk/deepseek pinado em
3.0.19 para ficar na ferroviaria provider 4.0.4 / provider-utils
5.0.18 do ai@7.0.47 (o ^ flutuaria a 3.0.26 e duplicaria 4.0.7).
STT (B173) permanece na Deep Infra: whisper-large-v3 a $0.00045/min
de audio e o mais barato verificado (Groq $0.0019, OpenAI $0.006).
.env.example documenta as duas chaves: DEEPSEEK_API_KEY (LLM) e
DEEPINFRA_API_KEY (STT).
Adiciona entrada por voz ao chat: botão de mic no InputGroup grava no
browser (getUserMedia + MediaRecorder), envia o clip multipart para a nova
rota POST /campanha/api/ai-transcribe (auth cookie + rate limit no mesmo
balde + same-origin) e injeta a transcrição como rascunho editável — o
envio segue o fluxo normal do chat. O áudio nunca é persistido.
STT via Deep Infra whisper-large-v3 (DEEPINFRA_API_KEY, mesma do B174);
a costura de rede vive em utilities/ai/deepInfraTranscribe.ts; a máquina
de captura em useMicTranscript. Falha de permissão/provedor degrada a
mensagem em pt-BR sem quebrar o chat por texto.
Closes#448
Troca o provider da Sollinha de @ai-sdk/deepseek (api.deepseek.com) para
@ai-sdk/deepinfra (API OpenAI-compatível da Deep Infra), mantendo o mesmo
modelo deepseek-ai/DeepSeek-V4-Flash e as mesmas tools. Uma chave só
(DEEPINFRA_API_KEY), a mesma dos agentes; rollback = git revert.
Closes#449
- Colunas responsivas ao painel via @container/territory-list (P0→P3), sem scroll horizontal
- Contagem de municípios no nome ("Irecê (20)"); % e votos absorvidos na célula 2022 (hover = válidos)
- Célula 2026 no detalhe de Municípios (valor central + %, faixa de cenários no hover, leitura)
- Colunas de rede só leitura: Assessor, Liderança, Dobradinha (para todo o staff)
- Cobertura oculta por padrão no seletor; labels curtos (2022/2026/Captura/Cobertura)
- loadTerritoryOverview devolve { rows, references }; rollup puro com soma por cenário, hasEstimate e uniões de rede
createCalendarFeedLinkRecord failed with `overrideAccess:false` +
`user: campaignUser` because Payload stripped the required
`secretSlug`/`createdBy` fields (create access was admin-only) -> 400
behind the generic message; revokeCalendarFeedRecord silently no-opped
(revokedAt update access also admin-only).
- canCreateCalendarFeed now enforces advisor filterMunicipality scope
on data (canCreateSupporter precedent) + canSetCalendarFeedSecret /
canSetCalendarFeedRevocation permit staff to mint the secret on create
and mark a feed revoked; secret read stays `() => false` and update
admin-only.
- CalendarFeed stamps createdBy via stampCampaignCreatedBy; secret
create + revokedAt update use the new staff field accesses.
- Action refactored to `*Record` (payload, actor) cores + thin wrappers;
assertFilterMunicipalityInScope (tour precedent); console.error keeps
the real error on the server while the friendly message stays.
- 10 int tests for create/scope/revoke/leader/secret; fixture tracks
calendarFeed cleanup.
Closes#436