Skip to content

Deck Builder da retrospectiva: 3 colunas, curadoria por contrato (Fase 3) - #474

Open
Clintonrocha98 wants to merge 8 commits into
feature/retrospective-multi-sourcefrom
feature/retro-deck-builder
Open

Deck Builder da retrospectiva: 3 colunas, curadoria por contrato (Fase 3)#474
Clintonrocha98 wants to merge 8 commits into
feature/retrospective-multi-sourcefrom
feature/retro-deck-builder

Conversation

@Clintonrocha98

Copy link
Copy Markdown
Member

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.phpwith* imutáveis (fonte, slide, ordem, exclusions por fonte)
  • Retrospective/DTOs/Period.phpcacheKey() 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.

…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.
@Clintonrocha98

Copy link
Copy Markdown
Member Author

Follow-up: 403 no preview + acabamento do builder

Três commits novos.

fix(portal) — o 403 do preview

O preview devolvia 403 para operador logado. Investiguei antes de mexer, e descartei em ordem: guard divergente (auth.defaults.guard é web), servidor na porta 80 (é um Apache default que dá 404, não 403, no caminho do preview) e host/porta divergente entre a página do painel e o iframe (route() segue o host da requisição — provado com probe).

Root cause: as rotas do portal eram registradas no boot() do PortalServiceProvider sem grupo de middleware — nem web:

comunidade/retrospectiva           -> []
comunidade/retrospectiva/{...}/preview -> []

Sem StartSession não existe sessão, logo auth()->check() no mount() é sempre falso e o abort_unless reprova todo mundo. O modular carrega os arquivos de rota dos módulos sem grupo; declarar web é obrigação de quem registra — identity/routes/authentication-routes.php já fazia certo, o portal nunca fez.

Efeito colateral que estava passando calado: /, /redes e a página pública da retrospectiva são componentes Livewire e estavam sem sessão e sem CSRF.

Por que a suíte não pegou: actingAs() seta o usuário direto no container, sem sessão — passa com ou sem StartSession. Os testes novos (PortalRoutesTest) olham o middleware da rota e o cookie de sessão da resposta, não o corpo.

⚠️ O módulo docs tem o mesmo defeito (docs e docs/{section}/{path?} sem middleware). Deixei fora deste PR por ser outro módulo — vale issue própria.

feat(panel-admin) — status, poll e drift

  • Badge de status no builder (label, cor e ícone do RetrospectiveStatus). Antes o operador não sabia se a edição era rascunho, estava publicando ou já publicada — informação central quando a regra é "exclusion exige republicar".
  • Poll: a tabela ganha poll('10s'); o builder faz poll de 3s apenas enquanto o status é Publishing. Antes, publicar despachava o job e nada refletia o fim dele.
  • Aviso de drift honesto: o snapshot passou a guardar os SourceFilters que o produziram (campo novo no VO, jsonb, sem migration; snapshot antigo reidrata com os padrões). needsRepublish() compara só os filtros congelados com os atuais — ordem e on/off re-derivam 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.

docs(community)

Corrige uma linha de trade-off da spec que eu mesmo quebrei ao rodar prettier: a continuação + config virou item de lista aninhado.

Verificação

Rector, Pint e PHPStan limpos. Suíte: 974 de 977. As 3 falhas seguem sendo as pré-existentes de timezone em integration-github/tests/Feature/BackfillRepositoryTest.php, sem relação com este trabalho.

Aberto (não entrou)

Cache do collect() em draft — a spec pede explicitamente cache por (fonte, período, filtros) com ação "atualizar dado". CompileSnapshot não tem cache, e o builder recarrega o iframe a cada salvamento, ou seja, uma coleta ao vivo sobre messages (~2GB em prod) por clique em Salvar. É a dívida a fechar antes do merge da branch de integração em 4.x.

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.
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.

1 participant