Skip to content

feat(community): retrospectiva multi-fonte - #437

Merged
danielhe4rt merged 22 commits into
4.xfrom
feature/retrospective-multi-source
Aug 23, 2026
Merged

feat(community): retrospectiva multi-fonte#437
danielhe4rt merged 22 commits into
4.xfrom
feature/retrospective-multi-source

Conversation

@Clintonrocha98

Copy link
Copy Markdown
Member

O que é isso

A retrospectiva é aquela página que conta o que a comunidade fez num período: quem participou, o que rolou, os destaques. É um "resumo do capítulo" da He4rt, feito para ser publicado e compartilhado.

Este é o PR guarda-chuva da feature: ele acompanha o trabalho inteiro. As entregas de verdade vêm em PRs menores, um de cada vez, apontando para esta branch.

O problema hoje

A retrospectiva atual só enxerga o GitHub: pull requests, código, quem contribuiu nos repositórios. Só que a comunidade é muito mais que isso. A vida acontece no Discord (conversas, tempo em call, reações, gente nova chegando), no WhatsApp e em outras plataformas, e nada disso aparece hoje.

O que esta feature entrega

  1. Retrospectiva de várias plataformas, não só GitHub. Cada plataforma vira um bloco na mesma página: o bloco do GitHub, o bloco do Discord, e por aí vai.
  2. Fácil de ligar uma plataforma nova. A ideia é que somar uma fonte nova (hoje Discord e WhatsApp, amanhã o que vier) seja simples, sem reescrever a página toda.
  3. O time monta e publica a edição. Em vez de a página ser só uma tela que muda sozinha, o time (Marketing/Admin) monta a retrô, escolhe o que aparece e publica. Quem visita vê o resultado pronto, tipo um "Retrospectiva do ano" que você assiste.
  4. Edição publicada fica registrada. Ao publicar, os números são congelados. A "Retro de Junho" continua mostrando o que era junho, mesmo tempos depois.

Como vai ser entregue (em fases, uma por vez)

  • Fase 1 — juntar as fontes. Trazer o Discord para a página junto do GitHub e deixar a estrutura pronta para plugar novas plataformas. A parte do GitHub continua igual à de hoje; ganha o bloco do Discord embaixo.
  • Fase 2 — montar e publicar. Poder criar, curar e publicar edições da retrospectiva, com a página pública mostrando a versão publicada.
  • Fase 3 — editor visual. Uma tela para montar a retrô de forma visual, com pré-visualização ao vivo. É melhoria de experiência: a feature já funciona 100% sem ela.
  • Depois — WhatsApp. Somar o WhatsApp como próxima plataforma, com cuidado de privacidade (sem expor telefones).

Para quem quiser o detalhe técnico

O desenho completo, com as decisões e os porquês, está documentado no módulo community:

  • app-modules/community/docs/specs/2026-07-19-retrospectiva-multi-fonte.md
  • app-modules/community/docs/adr/0001-retrospectiva-multi-fonte-via-contrato-de-source.md
  • app-modules/community/docs/adr/0002-retrospectiva-persistida-com-snapshot-ao-publicar.md

PR em draft de propósito: é o ponto de encontro da feature. Sai do draft quando as fases estiverem prontas para virar 4.x.

Registra o design acordado: contrato de Source em community, snapshot ao publicar, fluxo faseado e as divergencias conscientes da spec-base.
…d (Fase 1) (#438)

## Fase 1 — Juntar as fontes

Primeira fatia da [retrospectiva
multi-fonte](#437).
Transforma a retrospectiva (hoje só GitHub, read model ao vivo no
`portal`) numa peça **plugável por fonte**: adicionar uma plataforma
passa a ser 1 classe + 1 tag no módulo dono do dado, sem tocar o
`portal`.

Aponta para a branch de integração `feature/retrospective-multi-source`.

### O que entra

**`community` (domínio) — o contrato**
- `RetrospectiveSource`: `key()` + `collect(Period, SourceFilters):
SourceResult`.
- DTOs tipados: `Period`, `SourceFilters` (hideBots + exclusions),
`Metric`, `HeadlineMetrics`, `SourceResult`, e a interface `Slide`
(`kind()` + `toArray()`).
- Nenhum import de Integration: o contrato mora no domínio, as fontes o
implementam.

**`integration-github` — `GithubSource`**
- Refatora o antigo `CommunityRetrospective` do portal preservando o
cálculo **1:1** (bots, PRs merged/unmerged, repos só com PR no recorte,
destaques por linhas).
- Empacota a saída em slides tipados (`github.panorama`, `github.repos`,
`github.highlights`, `github.core`, `github.community`).

**`activity` — `DiscordSource`** (mora aqui porque o dado — `Voice`,
`Message`, `Reaction`, `MembershipEvent` — é do `activity`)
- 5 slides: `discord.voice_board`, `discord.messages`,
`discord.new_members`, `discord.reactions`, `discord.top_message`.
- Agrega **tudo em SQL** escopado pela janela (as tabelas são grandes em
prod; `messages` ~2GB). Filtra por `sent_at`/`occurred_at` (tempo do
evento), nunca `created_at`. `hideBots` via `source_kind`. Nome de
exibição resolvido só para o topo de cada ranking.

**`portal` — orquestração**
- `RetrospectiveDeck` resolve as fontes por **tagged service**
(`retrospective.source`), ordena (github → discord) e descarta as sem
dado.
- A página compõe: cover (chips por fonte, sem soma cruzada) → bloco de
cada fonte (`kind` → componente Blade por convenção) → closing.
- Os filtros ricos do visitante (repos/tipos/desfecho/pessoa/ordenação)
foram **aposentados**: viram configuração editorial na Fase 2. Restam
recorte de período e ocultar bots.

### Fora de escopo (fica pra depois)
- Persistência/snapshot da edição e curadoria (Fase 2).
- Deck Builder visual (Fase 3).
- WhatsApp (PR aditivo).

### Notas
- **Sem migrations** nesta fase (nenhuma tabela nova).
- Testes: `GithubSource` (golden 1:1, herdado do read model antigo),
`DiscordSource` (agregações + escopo temporal + hideBots),
`RetrospectiveDeck` (ordem/descarte) e página multi-fonte.
- Na suíte completa local há 3 falhas em `BackfillRepositoryTest`
(timezone, `+00:00` vs `-03:00`) e alguns erros de `out of shared
memory` no `DROP` sob paralelismo. **Ambos são
pré-existentes/ambientais** (confirmado: o backfill falha igual na base,
e os testes de `activity` passam isolados) — não têm relação com esta
mudança.
Clintonrocha98 and others added 19 commits August 2, 2026 13:49
…ase 2) (#444)

## Fase 2 — Persistir e publicar

Segunda fatia da [retrospectiva
multi-fonte](#437). A Fase 1
tornou a retrospectiva plugável por fonte, mas ao vivo. Esta fase a
transforma numa **peça editorial persistida**: o operador cura e
publica; ao publicar, o dado é **congelado num snapshot**; a página
pública lê o congelado, sem tocar as fontes.

Aponta para a branch de integração `feature/retrospective-multi-source`.

### O que entra

**`community` (domínio) — a entidade e o ciclo**
- Entidade `Retrospective` (`community_retrospectives`, sem `tenant_id`)
+ enum `RetrospectiveStatus` (`draft|publishing|published`, contratos
Filament).
- VOs `DeckConfig` (curadoria: ordem, on/off, exclusions) e
`RetrospectiveSnapshot` (`SourceResult[]` congelados) via casts tipados
(`AsDeckConfig`, `AsRetrospectiveSnapshot`) — nunca `array` solto.
- `FrozenSlide`: reidrata o snapshot honrando o contrato `Slide` sem o
domínio importar as classes de slide de `integration-github` (Domain →
Integration é proibido).
- Actions `CompileSnapshot` (coleta + congela) e `ComposeDeck` (aplica a
curadoria sobre o snapshot), `PublishRetrospective` + job
`CompileRetrospectiveSnapshot` (congela na fila).

**`portal` — leitura do congelado**
- `RetrospectiveDeck` removido; a orquestração migrou para o domínio.
- A página pública lê a **edição publicada mais recente** (sem filtros
do visitante). Rota de preview
`/comunidade/retrospectiva/{retrospective}/preview` para o operador
(rascunho coletado ao vivo), com guard `403` no `mount`.

**`panel-admin` — o CRUD editorial**
- `RetrospectiveResource`: **completo em capacidade** (ordem/on-off de
fonte por repeater, exclusions, textos de capa/fecho, publicar). Feio de
propósito — o Deck Builder visual é a Fase 3.
- Preview do admin e página pública compartilham o **mesmo render path**
(`ComposeDeck`), então "ver preview" bate com o publicado.

### Refinamentos registrados nos docs (spec + ADR-0002)
- `label()` novo no contrato `RetrospectiveSource` (identidade estática
das fontes, para o CRUD listar sem coletar).
- `FrozenSlide` no lugar de um registro `kind → classe` (forçado pela
regra Domain → Integration).
- Exclusions **recompilam** o snapshot (mexem no dado); ordem/on-off
**re-derivam** dele.

### Fora de escopo (Fase 3)
- Deck Builder 3 colunas (drag-drop, preview ao vivo, inspector) — puro
upgrade de UX.

### Notas
- Migration nova: `community_retrospectives` (colunas `timestamptz`,
jsonb para `deck_config`/`snapshot`).
- Testes: 63 novos/reescritos (VOs round-trip, casts,
`CompileSnapshot`/`ComposeDeck`, publish+job, página pública lê
snapshot, preview autenticado, CRUD Filament). Bateria completa: 920/923
verdes — as 3 falhas são o `BackfillRepositoryTest` (timezone `+00:00`
vs `-03:00`), **pré-existentes/ambientais**, sem relação com esta
mudança.
…ospectiva (Fase 3, parte 2)

A Fase 2 entregou capacidade editorial completa, mas sem noção do resultado: o
operador editava um repeater e só descobria o que fez abrindo o preview em outra
aba. Esta parte é o upgrade de UX desse mesmo poder — montar o deck vendo o deck
—, sem inventar capacidade nova (ADR-0002 do panel-admin).

O builder OCUPA a chave `edit` do resource com rota `/{record}/deck`: a chave
preserva o clique na tabela e o `getUrl('edit')`, a rota deixa a URL honesta.
`EditRetrospective`, `RetrospectiveForm` e `DeckConfigForm` saem — duas telas
editando o mesmo `deck_config` seriam duas fontes de verdade de curadoria.

Três colunas: estrutura seleciona (capa, blocos de fonte com chips de slide,
fecho), preview só lê, inspector edita. O preview é um iframe da MESMA rota
pública de preview, que passa pelo mesmo `ComposeDeck` da página publicada — a
única garantia de que ele não mente é ser literalmente a mesma coisa.

O inspector tem quatro modos e cada um escreve onde a Fase 2 já escrevia; nenhuma
coluna nova, nenhuma migration (`hidden_slides` já persistia sem UI que o
editasse). O picker de exclusions é alimentado por `exclusionCandidates()` e diz
em voz alta que exclusion exige republicar, porque mexe no dado. Refs que caíram
fora do teto da varredura são preservados no salvamento: a UI não pode derrubar
aquilo que não consegue exibir.

Reordenação por botões subir/desce — drag and drop exigiria dependência de
frontend nova (o SortableJS do Filament é interno) para ordenar entre 2 e 5
blocos. Fica como incremento, sem mexer no formato persistido.

Curadoria entra por `instanceof CuratableSource`: fonte que não cura aparece na
timeline com ordem e on/off, sem catálogo nem picker, e o deck segue montando.

- `DeckConfig` ganha `with*` imutáveis (fonte, slide, ordem, exclusions por fonte)
- `DeckStructure` monta a timeline pelo mesmo `position()` do `ComposeDeck`
- `InspectorMode`/`InspectorSelection` tipam a seleção que viaja pela wire
- `ExclusionPicker` agrupa candidatos por kind e isola os refs órfãos
- spec, ADR-0001 do community e CONTEXT.md do panel-admin atualizados
… spec

O prettier reinterpretou a continuacao "+ config" como item de lista aninhado.
Reescrito em prosa para o formatador nao ter o que remontar.
… preview)

O preview do Deck Builder devolvia 403 para operador logado. Root cause: as rotas
do portal eram registradas no boot() do ServiceProvider SEM grupo de middleware —
nem `web`. Sem StartSession não há sessão, então `auth()->check()` no mount do
CommunityRetrospectivePage é sempre falso e o abort_unless reprova todo mundo.

O modular carrega os arquivos de rota dos módulos sem grupo, e declarar `web` é
obrigação de quem registra (identity/routes/authentication-routes.php já fazia
certo). O portal nunca fez. Efeito colateral silencioso: a página pública da
retrospectiva, `/` e `/redes` são componentes Livewire e estavam sem sessão nem
CSRF.

Por que os testes não pegaram: `actingAs()` seta o usuário direto no container,
sem passar por sessão — passa com ou sem StartSession. Os testes novos olham o
middleware da rota e o cookie de sessão da resposta, não o corpo da resposta.

O módulo `docs` tem o mesmo defeito (`docs`, `docs/{section}/{path?}` sem
middleware); fica fora deste PR por ser outro módulo.
…ift no builder

Três lacunas de acabamento do Deck Builder, todas de leitura do estado editorial:

**Status visível.** O builder mostrava só o título; o operador não sabia se a
edição era rascunho, estava publicando ou já estava publicada — informação central
quando a regra é "exclusion exige republicar". Agora há badge com label, cor e
ícone do RetrospectiveStatus.

**Publicação acompanhada.** Publicar despacha um job, mas nada refletia o fim
dele: a tabela e o builder ficavam em "Publicando" até recarregar na mão. A tabela
ganha `poll('10s')`; o builder faz poll de 3s apenas enquanto o status é
Publishing, chamando refreshStatus().

**Aviso de drift honesto.** Para dizer "republique" só quando importa, o snapshot
passou a guardar os SourceFilters que o produziram (campo novo no VO, jsonb, sem
migration; snapshot antigo reidrata com os filtros padrão). `needsRepublish()`
compara os filtros congelados com os atuais — e só eles: ordem e on/off re-derivam
na composição e nunca pedem republicação. Comparar `updated_at` com `published_at`
avisaria também nesses casos, apagando justo a distinção que a fase defende.

Também renomeia a descrição do preview de "Rascunho ao vivo" para "Prévia ao vivo":
"Rascunho" colidia com o label do enum e tornava vacuosa a asserção de status.
As três colunas do ADR-0002 não caem bem no 7xl padrão do painel: o preview do
meio é um deck inteiro, não um card. `maxContentWidth = Width::Full` nesta página
apenas — o resto do painel segue no default.
Troca a proporção 3/6/3 por laterais fixas (16rem estrutura, 18rem inspector) com
o preview em 1fr. A estrutura é uma lista e o inspector é um formulário: o que
ambos precisam não cresce com a tela. O preview é um deck inteiro, então passa a
absorver cada pixel extra de monitor em vez de ficar preso a metade da largura.

`min-w-0` nas três colunas para conteúdo largo (chips, campos) não estourar a
grade em vez de truncar.
A coluna do inspector tinha um cabeçalho com o label e a descrição do
InspectorMode ("Bloco de fonte" / "Exibir a fonte e curar...") logo acima da
Section do formulário, que já nomeia o alvo de forma específica ("Bloco: Discord")
e explica o efeito concreto. Dois títulos para a mesma coisa, o de cima mais vago.

Sai o cabeçalho; o ícone do modo passa para a própria Section (Filament suporta
`->icon()`), então a âncora visual continua — sem duplicar texto. A Section de
Exclusions ganha ícone próprio.
Um `migrate:fresh --seed` passa a entregar 13 meses de atividade de GitHub e
Discord mais uma edição de retrospectiva para cada estado que o Deck Builder
precisa saber renderizar, para dar como testar a fase 3 sem depender de backfill
de produção.

Cada módulo semeia apenas o dado que possui e a composição cross-module mora na
raiz: `community` é domínio e tem `"require": {}`, então não pode conhecer
`integration-github` nem `activity`. O orquestrador é o único dono da linha do
tempo e passa a janela para os três, além de repassar às edições os refs
plantados por cada fonte (`exclusionBaits()`) — é o que faz o picker de
exclusions e o aviso de republicar terem conteúdo real.

Volume vai por insert em lote (~6.000 linhas em ~3s); factory por linha
arrastaria uma ExternalIdentity e dois User a cada mensagem.

O que o dado exercita de propósito:

- bots pelos dois caminhos de detecção (sufixo `[bot]` e `metadata.is_bot`) e
  ~12% das mensagens sem `source_kind`, que o filtro de bots precisa MANTER;
- repositórios sem PR no recorte, que não viram card mas seguem contando em
  pessoas/issues/comentários;
- commits sem `additions`/`deletions`, porque o GithubSource soma essas chaves
  sobre todas as contribuições e repeti-las inflaria o total de linhas;
- reações construídas junto das mensagens, para `reactions_total` e
  `reactions_count` baterem com as linhas de `activity_reactions`;
- um PR de +18.420/-9.310, um ator de spam e uma mensagem de golpe no topo por
  reação: os quatro casos que motivaram a curadoria existir.

Datas sempre via Carbon, nunca strings ISO-8601 misturadas: o Postgres de dev
roda em America/Sao_Paulo e o Eloquent grava sem offset, então misturar os dois
estilos apartaria os dados em 3h e jogaria linhas fora do recorte na borda.

Testes prendem o que falha calado: o picker tem de oferecer de volta exatamente
as iscas que a edição curada já esconde (uma janela deslocada em um mês abre o
playground vazio sem erro nenhum), e o primeiro teste passa pelo DatabaseSeeder
inteiro, único caminho que exercita a convivência com o BaseSeeder — que cria
`danielhe4rt` antes, com o mesmo username que a lista de membros reivindica.
…e 3) (#474)

Fecha a **Fase 3** da retrospectiva multi-fonte. Dois commits, cada um
verde por si só:
contrato de curadoria (parte 1) e o Deck Builder que o consome (parte
2).

## Problema

A Fase 2 entregou um CRUD Filament completo **em capacidade**: dava para
ordenar fontes, ligar e
desligar blocos, escrever os textos, listar exclusions e publicar. O que
ele não dava era **noção
do resultado** — o operador editava um repeater de linhas e só descobria
o que fez abrindo o
preview em outra aba, sem relação visual entre o campo que mexeu e o
slide que mudou.

Ao implementar a curadoria apareceu um buraco herdado:
`SourceFilters::excludes()` existia desde a
Fase 1 e o `deck_config` já gravava os refs, mas **nenhuma fonte chamava
o método**. Exclusion era
campo morto. Um picker em cima disso seria UI para um botão que não faz
nada.

## Solução

### Curadoria entra por interface segregada

`CuratableSource` (`slideCatalog()` + `exclusionCandidates(Period)`) no
`community`, implementada
por `GithubSource` e `DiscordSource`. O `RetrospectiveSource` **não
muda** — é o crescimento por
adição de interface que o ADR-0001 já previa. O builder checa
`instanceof`: fonte que não cura
aparece na timeline com ordem e on/off, sem catálogo de slides nem
picker, e o deck segue montando.

`slideCatalog()` é estático, resolvido sem tocar o banco.
`exclusionCandidates()` varre dado, então
é obrigação da implementação escopar pelo `Period`, aplicar `LIMIT` (30
no GitHub, 20 no Discord) e
cachear por `(fonte, período)`.

### Exclusion passa a valer de verdade

Cada fonte aplica os refs dentro do `collect()`, **antes de qualquer
agregação**: o que é excluído
some dos slides e também dos números. Não é capacidade nova da Fase 3, é
a Fase 1 sendo completada
— o ADR-0001 já definia exclusion como filtro que mexe no dado.

Consequência editorial que a UI diz em voz alta: **mexer em exclusion
exige republicar**, porque
recompila o snapshot. Ordem e on/off não, esses re-derivam.

Os refs são namespaced por prefixo (`pr:`, `issue:`, `actor:` no GitHub;
`message:`, `member:` no
Discord). `DeckConfig::allExclusions()` achata tudo numa lista só antes
de virar `SourceFilters`,
então o prefixo distinto é o que faz cada fonte reconhecer apenas o que
emite — sem disputa de ref
e sem tabela de tradução.

### O builder substitui a página de edição

`EditRetrospective`, `RetrospectiveForm` e `DeckConfigForm` saem; entra
uma `Page` de resource
registrada na chave `edit` com rota `/{record}/deck`. A chave preserva o
clique na tabela e o
`getUrl('edit')`; a rota deixa a URL honesta. `List` e `Create`
continuam padrão — criar uma edição
é preencher título e período, não montar deck.

Duas telas editando o mesmo `deck_config` seriam duas fontes de verdade
de curadoria, com risco de
uma sobrescrever a outra.

```
┌──────────────────┬───────────────────────────┬───────────────────────┐
│ [Estrutura]      │ [Preview]                 │ [Inspector]           │
│ capa             │  iframe da rota de         │  formulário do que    │
│ blocos de fonte  │  preview da Fase 2         │  está selecionado     │
│   chips de slide │  (mesmo ComposeDeck)       │  4 modos              │
│ fecho            │                            │                       │
└──────────────────┴───────────────────────────┴───────────────────────┘
   seleciona            só leitura                 edita e salva
```

O inspector é contextual e cada modo escreve **onde a Fase 2 já
escrevia**:

| Seleção | Edita | Persiste em |
| --- | --- | --- |
| Capa | título, período, ocultar bots, título e introdução da capa |
colunas da edição |
| Bloco (fonte) | exibir, exclusions daquela fonte | `hidden_sources`,
`exclusions`, `order` |
| Slide | exibir | `hidden_slides` |
| Fecho | mensagem de fecho | coluna `closing_text` |

**Nenhuma coluna nova, nenhuma migration**: o `DeckConfig` da Fase 2 já
tinha `hidden_slides`
persistindo sem UI que o editasse.

### O preview é um iframe da rota pública de preview

Nada de reimplementar o deck dentro do painel. O centro aponta para
`/comunidade/retrospectiva/{id}/preview`, a mesma rota que o operador já
abria em outra aba, que
passa pelo mesmo `ComposeDeck` da página pública. Preview que mente é
pior que preview nenhum, e a
única garantia de que ele não mente é ser literalmente a mesma coisa.

Custo aceito: o iframe recarrega inteiro ao salvar, em vez de atualizar
o slide alterado no lugar.
O buster é `?v={updated_at}-{contador}` — só o `updated_at` não
bastaria, porque dois salvamentos
no mesmo segundo dariam o mesmo token.

### Decisões de escopo (adiadas por escolha, não esquecidas)

- **Reordenar por botões, não por drag.** DnD exigiria dependência de
frontend nova (o SortableJS
que o Filament usa é interno, não é API pública) para ordenar entre 2 e
5 blocos. Fica como
  incremento posterior, sem mexer no formato persistido.
- **On/off é por kind, não por instância de slide.** `github.repos`
rende um slide por repositório;
o toggle esconde o bloco inteiro. Ligar e desligar repo a repo exigiria
identidade estável por
  instância, que o snapshot congelado não carrega.
- **Não editamos cada slide** (título, máximo de itens, ordenação
interna): obrigaria o
  `ComposeDeck` a conhecer a semântica de cada kind.

### Detalhe de correção que vale destacar

O picker mostra o **topo** do recorte, nunca a tabela inteira. Um ref já
excluído que caiu fora do
teto da varredura não aparece em opção nenhuma — então
`ExclusionPicker::orphans()` o isola e o
salvamento o reescreve. A UI não pode derrubar por omissão aquilo que
não consegue exibir.

## Arquivos Alterados

**Contrato (community):**
- `Retrospective/Contracts/CuratableSource.php` — interface segregada
- `Retrospective/DTOs/{SlideDescriptor,ExclusionCandidate}.php`,
`Enums/ExclusionKind.php`
- `Retrospective/DTOs/DeckConfig.php` — `with*` imutáveis (fonte, slide,
ordem, exclusions por fonte)
- `Retrospective/DTOs/Period.php` — `cacheKey()` para cachear as
varreduras por recorte

**Fontes:** `integration-github/.../GithubSource.php` e
`activity/.../DiscordSource.php` —
implementam `CuratableSource` e aplicam exclusions dentro do
`collect()`.

**Builder (panel-admin):**
- `Pages/BuildDeck.php` +
`resources/views/retrospective/build-deck.blade.php`
- `Support/DeckStructure.php` — timeline pelo mesmo `position()` que o
`ComposeDeck` usa
- `Support/{InspectorMode,InspectorSelection}.php` — tipam a seleção que
viaja pela wire
- `Support/ExclusionPicker.php` — agrupa candidatos por kind, isola
órfãos
- removidos: `EditRetrospective`, `RetrospectiveForm`, `DeckConfigForm`

**Docs:** ADR-0002 do `panel-admin` (novo), ADR-0001 e spec do
`community` atualizados
(`CuratableSource` saiu de "futuro" para "implementado"), glossário do
`CONTEXT.md` do `panel-admin`.

## Testes

`BuildDeckTest` (17 casos, feature):
- `o builder atende na chave edit com a rota /deck` — a chave e a URL, e
a página responde 200
- `abre com a timeline das fontes e o iframe do preview`
- `desliga e religa uma fonte pelo inspector` — ida e volta, incluindo o
estado pré-preenchido
- `desliga um kind de slide sem tocar os outros kinds da fonte`
- `sobe e desce um bloco na ordem editorial`
- `reordenar não mexe em on/off nem em exclusions` — a garantia de que
os eixos são independentes
- `o picker oferece os candidatos que a fonte varreu no recorte`
- `salva no deck_config as exclusions escolhidas no picker`
- `preserva refs já excluídos que ficaram fora do teto do picker` — o
caso dos órfãos
- `avisa que exclusion exige republicar, e só quando ela muda` — cobre
os dois lados
- `a fonte que não cura entra na timeline com on/off, mas sem picker` —
via duplo em `tests/Support`
- `salva capa e período nas colunas da edição`, `a capa exige título`,
`salva a mensagem de fecho`
- `publicar pelo builder marca publicando e enfileira o job`, `apagar
pelo builder volta para a lista`
- `o preview fura cache com a versão do registro`

`DeckStructureTest` (6, unit): ordem curada com fontes desconhecidas no
fim, projeção de on/off,
catálogo só de quem cura, fonte crua aceita, deslocamento e recusa nas
pontas.

`DeckConfigTest` (+7): cada `with*` imutável, incluindo não duplicar o
já escondido, remover a chave
da fonte quando a lista esvazia e normalizar refs (sem vazios nem
repetidos).

`GithubSourceTest` / `DiscordSourceTest` (+): catálogo estático,
candidatos escopados pelo período e
exclusion derrubando item e número.

### Verificação

`rector --dry-run`, `pint --test` e `phpstan` limpos. Suíte completa:
**958 de 961 passando**.

As 3 falhas são **pré-existentes** nesta branch de integração e não vêm
deste trabalho —
`integration-github/tests/Feature/BackfillRepositoryTest.php`, todas por
offset de timezone
(`+00:00` esperado vs `-03:00` obtido) em asserções de `occurred_at`.
Confirmado rodando o arquivo
com as mudanças deste PR guardadas: mesmas 3 falhas, mesmas linhas.
…multi-source

# Conflicts:
#	app-modules/integration-github/src/Retrospective/CommunityRetrospectivePage.php
#	app-modules/integration-github/src/Retrospective/GithubSource.php
#	app-modules/panel-admin/src/PanelAdminServiceProvider.php
#	app-modules/portal/src/PortalServiceProvider.php
#	app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php
#	app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php
#	database/seeders/DatabaseSeeder.php
O slide de voz mostrava só participantes, XP e uma lista de canais por
evento — um recorte estreito de um dado que a comunidade vive todo dia.

Agora ele carrega o recorte inteiro: totais (pessoas em call, entradas, XP,
quem tirou XP e o dia de pico), as arenas por entrada com as salas
temporárias de mesmo nome agrupadas (×N), quem mais viveu no voice e o
histograma de entradas por hora no fuso de exibição.

- topVoiceChannels agora ordena por entradas (state = 'joined') e conta
  COUNT(DISTINCT channel_id) para revelar as salas repetidas
- voiceTotals resolve participantes, entradas, XP e ganhadores numa query
- peakVoiceDay, topVoicePeople e voiceByHour (24 posições, sempre)
- o histograma agrupa por posição, não pelo mesmo expressão com binding
  repetido: dois placeholders com o mesmo valor não são iguais para o
  Postgres e quebravam com "must appear in the GROUP BY clause"
- a view ganha a variante compacta (.slide-inner.is-dense) para tudo caber
  sem rolagem, e defaults para snapshots congelados antes destes campos
O deck montava o nome da view com str_replace inline e o Deck Builder
precisava do mesmo caminho para dizer ao operador qual arquivo abrir. Dois
lugares derivando a mesma convenção é convite para discordarem.

SlideView passa a ser o único dono: kind() para o partial do slide, cover()
e closing() para os fixos, e path() resolvendo o arquivo pelo finder do
Blade — devolve null quando o partial não existe, em vez de um caminho que
mente (snapshot congelado antes da view existir, ou view renomeada depois).

InspectorViewPath traduz a seleção do inspector para esse caminho. Bloco de
fonte devolve null de propósito: fonte não tem view, ela emite slides.

Um teste percorre o catálogo de todas as fontes registradas e falha se
algum kind ficar sem partial.
… e caminho da view

O Deck Builder abandona o iframe: o deck renderiza no próprio DOM do painel
pelo MESMO render path da página pública (DeckPresentation), dentro de um
island do Livewire — os renders do inspector não morfam o deck e a navegação
por dentro dele (retro-moved) move a seleção da estrutura junto.

A tira de miniaturas (filmstrip) mostra o deck de verdade em escala no rodapé,
com on/off e ordem por fonte. As ações da tira e do deck despacham eventos que
borbulham para FORA dos islands antes de virar chamada Livewire: uma ação
disparada de dentro vira chamada escopada ao island, o inspector não atualiza
no mesmo roundtrip e cada clique pagaria o re-render da tira inteira — teste
de regressão impede o wire:click de voltar para dentro dos islands.

O cabeçalho do preview mostra o arquivo da view do slide selecionado
(SlideView, dona única da convenção kind -> partial) com botão de copiar,
encurtando o caminho entre "não gostei disso" e o editor aberto no arquivo
certo.

ADR-0002 emendado: o preview divide o render path com a página pública;
a alternativa "renderizar no painel" passa a ser a aceita.
`.retro-thumb` declarava `--retro-thumb-width` em si mesma, então a miniatura
era imune ao container e cada tamanho novo viraria uma classe nova.

Agora a variável é lida com fallback (`var(--retro-thumb-width, 208)`) e quem
monta a tira a define uma vez no container: todas as células acompanham,
inclusive rótulos e o cartão de "sem dado". O padrão de 208px não muda.
O builder é uma ferramenta, não um documento: rolar a página para alcançar a
tira ou o inspector era fricção paga a cada slide. Agora as três áreas dividem
uma altura fixa e cada uma rola por dentro.

O que devolveu altura:

- subheading da página e as Sections de "Preview" e "Estrutura" saíram; no lugar
  do cabeçalho do preview entra uma barra de uma linha que diz onde o operador
  está (fonte / slide, posição no deck) e qual arquivo desenha aquilo;
- Sections do inspector em `->compact()`;
- miniaturas em 164px, herdando `--retro-thumb-width` da tira;
- o deck troca `58vh` fixo por `h-full` e recebe o que sobra da linha do grid.

Preview, barra e tira compartilham `--builder-max-width`, declarado uma vez no
grid: as três terminam no mesmo x, e ajustar é mudar um número só.

Dois defeitos corrigidos no caminho:

- a tira era um item de grid sem `min-w-0` num nível intermediário, então não
  encolhia abaixo do conteúdo (~2600px de miniaturas), estourava a coluna e
  empurrava deck e inspector para fora do viewport;
- `DeckFilmstrip` usava `blank($indices[$queue])`, que avalia o índice antes de
  testá-lo e disparava "Undefined array key" em todo slide desligado — o caso
  comum, não a exceção. Agora é `isset()`.

O snapshot do deck deixa de ser `#[Computed]` e vira memo numa propriedade
privada tipada: o cache do Computed só existe no acesso mágico, que a análise
estática não enxerga (o retorno virava mixed) e que esconde a armadilha de
chamar o método e pagar a coleta ao vivo de novo em silêncio.
O deck abria com a capa e caía direto em pull requests e mensagens. Quem chegava
de fora via os números sem saber de quem eram; quem já era de dentro não via a
comunidade em lugar nenhum. Três slides entre a capa e as fontes resolvem isso.

Fica FORA do snapshot de propósito: quem a He4rt é não muda a cada recorte, e o
snapshot existe para congelar o que muda. Nenhuma curadoria a desliga — ela é o
contexto que faz o resto significar algo. AboutSection é a dona única da lista e
da ordem, porque o Deck Builder conta por ela para saber em que índice cada slide
caiu.

A He4rt: a linha do tempo é desenhada como batimento e cada marco É um pico da
onda. O path do SVG é gerado a partir dos marcos, não escrito à mão — um ano novo
redesenha a onda sozinho. Os pontos são HTML por cima, porque a onda estica na
horizontal e um <circle> viraria elipse. A timeline para no `until` da edição: uma
retro de 2021 não anuncia o meetup de 2022.

Iniciativas: o diagrama carrega dado. O raio da órbita é a raridade do encontro e
a velocidade da volta é a frequência — o semanal gira por dentro e rápido, o que
só acontece quando surge o tema orbita longe e devagar. A cor do ponto é a mesma
do marcador do item, que é o que liga a figura à lista.

Onde entrar: os canais saem de `he4rt.social_media`, a mesma fonte da página
/redes — um link novo aparece no deck sem ninguém lembrar de vir editar aqui. O
que este recorte mediu leva selo, que é o que amarra a apresentação aos slides
seguintes em vez de deixar uma lista de links solta.

Corrige de carona um defeito antigo das miniaturas: traço que só aparece ao ser
desenhado ficava INVISÍVEL no filmstrip, onde nada anima. Valia para o ECG da capa
também.
A seção sobre a He4rt empurra todo slide composto três casas para a direita. Sem
isso o builder erra por três: clicar numa miniatura leva o preview para o vizinho,
e o "4 / 12" da barra mente.

O deslocamento passa a ter dono único (composedOffset), que pergunta a contagem
ao portal — quem desenha a seção é quem sabe o tamanho dela. Os testes que fixavam
o "+1" da capa na mão passaram a perguntar por ele: acrescentar um slide à seção
não obriga mais a corrigir teste.

InspectorMode ganha o caso que NÃO edita. Ele existe porque a tira precisa de um
alvo para aqueles slides — sem modo próprio, clicar na miniatura mandaria o preview
para a capa. O inspector só diz o que a seção é e por que não há campo ali, e o
botão Salvar some: um botão que não escreve nada promete uma edição que não existe.
O caminho do blade continua na barra do preview, que é o que o operador precisa
para mexer na copy.
@danielhe4rt
danielhe4rt marked this pull request as ready for review August 23, 2026 23:16
@danielhe4rt
danielhe4rt requested a review from a team August 23, 2026 23:16
Os totais de voz saem de uma query base, e `first()` devolve stdClass: toda
propriedade dele é mixed, porque o driver não promete tipo nenhum — o Postgres
manda COUNT e SUM como string, e recorte sem linha manda null. `(int)` em cima
disso escondia a ausência de dado em vez de tratá-la.

Junto vinha uma armadilha: `(string) $row->day` num dia ilegível vira string
vazia, e `CarbonImmutable::parse('')` devolve AGORA. O painel anunciaria HOJE
como o dia mais movimentado do recorte. O dia agora precisa ser legível para
virar pico.

O teste novo mira o portão que tudo isso sustenta: só entra painel de voz no deck
se os agregados lerem participante de verdade. Lidos como qualquer outra coisa, o
slide entraria vazio, com um dia de pico que ninguém viveu.
@danielhe4rt
danielhe4rt merged commit cf4637a into 4.x Aug 23, 2026
9 checks passed
@danielhe4rt
danielhe4rt deleted the feature/retrospective-multi-source branch August 23, 2026 23:45

@sirelves sirelves left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM TO AAAAAAAA

@buzinei-bibi buzinei-bibi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm !!

@DavyDevcosmo

Copy link
Copy Markdown

lgtm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants