From 2e418e70a8e7016b5c756544a41bcd71e3bee72f Mon Sep 17 00:00:00 2001 From: Clintonrocha98 Date: Mon, 20 Jul 2026 00:03:19 -0300 Subject: [PATCH 1/2] docs(community): plano, spec e ADRs da retrospectiva multi-fonte Registra o design acordado: contrato de Source em community, snapshot ao publicar, fluxo faseado e as divergencias conscientes da spec-base. --- ...tiva-multi-fonte-via-contrato-de-source.md | 82 ++++++++ ...iva-persistida-com-snapshot-ao-publicar.md | 78 ++++++++ .../2026-07-19-retrospectiva-multi-fonte.md | 188 ++++++++++++++++++ 3 files changed, 348 insertions(+) create mode 100644 app-modules/community/docs/adr/0001-retrospectiva-multi-fonte-via-contrato-de-source.md create mode 100644 app-modules/community/docs/adr/0002-retrospectiva-persistida-com-snapshot-ao-publicar.md create mode 100644 app-modules/community/docs/specs/2026-07-19-retrospectiva-multi-fonte.md diff --git a/app-modules/community/docs/adr/0001-retrospectiva-multi-fonte-via-contrato-de-source.md b/app-modules/community/docs/adr/0001-retrospectiva-multi-fonte-via-contrato-de-source.md new file mode 100644 index 00000000..174f7d74 --- /dev/null +++ b/app-modules/community/docs/adr/0001-retrospectiva-multi-fonte-via-contrato-de-source.md @@ -0,0 +1,82 @@ +--- +type: adr +title: 'Retrospectiva multi-fonte via contrato de Source em community' +module: community +status: accepted +date: 2026-07-19 +author: Clintonrocha98 +related: + spec: community/2026-07-19-retrospectiva-multi-fonte +--- + +# ADR-0001: Retrospectiva multi-fonte via contrato de Source em `community` + +**Status:** Accepted +**Date:** 2026-07-19 +**Deciders:** Clintonrocha98 + +## Contexto + +A retrospectiva precisa agregar várias plataformas (GitHub, Discord, WhatsApp, Twitch) num deck único, +mas a regra dura do repo é **domínio nunca importa de Integration**. Além disso, o dado de cada +plataforma tem valor distinto: um PR mergeado e um sub da Twitch não são "+1" equivalentes. + +Não há vínculo de identidade entre plataformas, então **cada fonte é a sua própria verdade** e uma Source +é estruturalmente **cega** às irmãs (não sabe que as outras existem). + +## Decisão + +O **contrato** (Strategy) e a entidade/lifecycle da retrospectiva vivem no módulo de domínio `community`; +cada **implementação** vive no módulo dono do dado. + +``` +interface RetrospectiveSource { + key(): string + collect(Period, SourceFilters): SourceResult +} +``` + +- **`SourceResult` = `HeadlineMetrics` (envelope de chips por fonte) + `Slide[]` (lista ordenada).** + Cada `Slide` é um DTO tipado por `kind` (`github.repos`, `discord.voice_board`...), com classe + concreta no módulo que emite; `community` só define a interface `Slide`. A fonte emite **dado, nunca + markup**; o `portal` mapeia `kind -> Blade`. É isso que deixa um módulo de Integration produzir saída + de retrospectiva sem "possuir a apresentação". +- **`HeadlineMetrics` é uma lista de `Metric` (valor + dica de formato), por fonte.** Não há soma + cruzada: o cover justapõe os chips de cada fonte. Qualquer total realmente cruzado só poderia nascer na + orquestração (`portal`), nunca dentro de uma Source, porque a Source é cega às irmãs. +- **Descoberta por tagged services** (tag `retrospective.source`). Adicionar plataforma = nova classe + + 1 linha de tag no módulo dela; o `portal` nunca muda. +- **Interface mínima, cresce por ISP.** `collect()` é o único método na Fase 1. A curadoria + (`slideCatalog()`, `exclusionCandidates()`) entra na Fase 3 como uma interface **segregada** + (`CuratableSource`), implementada só por fontes curáveis; o admin checa `instanceof`. Cresce por adição + de interface, não por mutação do contrato. +- **Duas camadas de curadoria.** Filtro que mexe no dado (`hideBots`, exclusions) entra no `collect()` + via `SourceFilters`, para o headline sair consistente com os slides. Curadoria de apresentação (ordem, + on/off, título, max itens) fica na orquestração. + +## Considered options + +- **Contrato + impl ambos em `community`** — rejeitado: `community` importaria `integration-*`, violando a + regra de dependência. +- **Tudo no `portal`** — rejeitado: a entidade, o lifecycle e as regras são domínio, não podem morar na + apresentação. +- **Option A: DTO normalizado único** — rejeitado: achata o valor distinto de cada plataforma (PR + mergeado vira "+1" igual a um sub). +- **Option B: payload próprio por fonte, orquestrador conhece cada tipo** — rejeitado: acopla o + orquestrador a toda fonte concreta e perde o cover uniforme. +- **Option C (escolhida): híbrido `SourceResult` = `HeadlineMetrics` + `Slide[]`.** + +## Consequências + +- `integration-github` passa a expor `GithubSource` (normaliza seu dado); o boundary estica um pouco, mas + segue devolvendo **dado, não Blade** (templates ficam no `portal`). +- `DiscordSource` mora no `activity` (domínio), não no `integration-discord`, porque o dado da + retrospectiva (`Voice`/`Message`/`Reaction`/`MembershipEvent`) é modelado no `activity`. Dependência + `activity -> community` (domínio -> domínio, sem ciclo). Como domínio não importa de Integration, se um + slide precisar de nome de canal (que vive no `integration-discord`), a resolução usa o que o `activity` + já denormaliza (ex.: `Voice.channel_name`) ou acontece no `portal`, nunca na `DiscordSource`. +- Slides compõem-se como **blocos de fonte** (todos os slides de uma fonte contíguos) entre `cover` e + `closing` compartilhados. Pessoas contadas por fonte, sem dedup no MVP. +- **Divergência consciente da spec-base:** ela previa "cross-source unified totals" no cover; aqui o + cover é chips por fonte, sem soma. Motivo: sem vínculo de identidade a soma de pessoas mente, e as + unidades (PR, msg, sub, minuto) são incomensuráveis. diff --git a/app-modules/community/docs/adr/0002-retrospectiva-persistida-com-snapshot-ao-publicar.md b/app-modules/community/docs/adr/0002-retrospectiva-persistida-com-snapshot-ao-publicar.md new file mode 100644 index 00000000..c3aea5f9 --- /dev/null +++ b/app-modules/community/docs/adr/0002-retrospectiva-persistida-com-snapshot-ao-publicar.md @@ -0,0 +1,78 @@ +--- +type: adr +title: 'Retrospectiva persistida: operador cura, snapshot ao publicar' +module: community +status: accepted +date: 2026-07-19 +author: Clintonrocha98 +related: + spec: community/2026-07-19-retrospectiva-multi-fonte + adr: community/0001-retrospectiva-multi-fonte-via-contrato-de-source +--- + +# ADR-0002: Retrospectiva persistida, operador cura e snapshot ao publicar + +**Status:** Accepted +**Date:** 2026-07-19 +**Deciders:** Clintonrocha98 + +## Contexto + +Hoje a retrospectiva é um read model ao vivo, filtrado pelo visitante via URL, recomputado a cada +request e a cada mudança de filtro. Queremos que ela vire uma **peça editorial**: o operador +(Marketing/Admin) monta e publica; o visitante só assiste ao resultado curado. Isso exige persistir a +edição e decidir o que acontece com o dado ao publicar. + +## Decisão + +**Operador cura, visitante assiste.** A edição publicada é fixa (estilo "Wrapped"): sem filtros na +página pública. Os antigos filtros do visitante viram **configuração editorial** guardada na edição. + +**Período por datas livres, sem `Cadence`.** A edição guarda `since`/`until` diretos. O contrato +`collect(Period)` já aceita qualquer período, então a entidade fica tão flexível quanto ele. Os presets +("semana", "mês", "tudo") viram açúcar de UI que preenche as datas; a identidade da edição vem do +`title`. + +**Snapshot ao publicar = `SourceResult[]` crus congelados + curadoria separada.** + +``` +DRAFT collect() AO VIVO (operador ve dado fresco enquanto cura) +PUBLICAR congela SourceResult[] no snapshot -> NUMEROS ficam fixos +PUBLICA view = compoe(snapshot congelado, deck_config) [cacheavel, sem query nas fontes] +``` + +- O snapshot guarda os `SourceResult` crus (números fixos no publish). A curadoria (ordem, on/off, + exclusions, textos) mora separada, na própria edição (`deck_config`). +- A view pública compõe `snapshot + deck_config` a cada render (barato: JSON em memória, cacheável). +- Editar uma edição publicada (mexer ordem/texto/exclusion) **re-deriva do snapshot congelado**, então os + números não "andam" sem querer. Recomputar só acontece num refresh explícito de dado. + +**Publicar roda como job na fila.** As tabelas de origem são grandes (`messages` ~2GB em prod, e as demais +equivalentes ou maiores), então computar o snapshot de todas as fontes sobre o período pode estourar o +timeout do request num range grande (retro anual). O publish despacha um job +(`status = publishing` -> computa -> `published`); o draft cacheia o `collect()` enquanto o operador cura. +O custo é proporcional à janela de datas, não ao total, porque há índice na coluna de tempo +(`sent_at`/`occurred_at`); as agregações rodam em SQL, nunca carregando linhas em PHP. + +## Considered options + +- **Sem snapshot, sempre recomputar** — rejeitado: página pública cara e edição publicada não seria + imutável (o dado da fonte muda/some depois). +- **Snapshot do deck final composto (pós-curadoria)** — rejeitado: mais simples de renderizar, mas editar + uma retro publicada exigiria despublicar -> draft ao vivo -> republicar, e republicar recoleta dado + fresco, fazendo um ajuste de texto mudar todos os números sem querer (footgun editorial). +- **Cadence (Weekly/Monthly/Annual)** — rejeitado: rígido para o fluxo editorial real; a flexibilidade + de datas livres cobre qualquer recorte. + +## Consequências + +- Entidade `Retrospective` (tabela `community_retrospectives`) com `deck_config` e `snapshot` como VOs + tipados via cast (jsonb), `status` enum com contratos Filament, colunas `timestamptz`, sem `tenant_id`. +- O admin da **Fase 2** é CRUD Filament **completo em capacidade** (toggles de fonte, repeater de ordem, + select de exclusions, textos, publicar). Feio, mas produz e publica um deck curado de verdade. O Deck + Builder da **Fase 3** é puro upgrade de UX (drag-drop, preview ao vivo, inspector), zero capacidade + nova: se nunca vier, a feature funciona 100%. +- Preview do admin e página pública **compartilham o mesmo render path** (o compositor + `snapshot + config`), então "ver rascunho" bate com o publicado. +- **Divergência consciente da spec-base:** ela era toda "per tenant" e centrada em `Cadence`. Aqui não há + tenancy (removida no #413) e o período é datas livres. diff --git a/app-modules/community/docs/specs/2026-07-19-retrospectiva-multi-fonte.md b/app-modules/community/docs/specs/2026-07-19-retrospectiva-multi-fonte.md new file mode 100644 index 00000000..5f7f2ced --- /dev/null +++ b/app-modules/community/docs/specs/2026-07-19-retrospectiva-multi-fonte.md @@ -0,0 +1,188 @@ +--- +type: spec +title: 'Retrospectiva multi-fonte' +module: community +status: draft +date: 2026-07-19 +author: Clintonrocha98 +related: + adr: community/0001-retrospectiva-multi-fonte-via-contrato-de-source +--- + +# Spec: Retrospectiva multi-fonte + +## Contexto + +A retrospectiva de hoje vive inteira no `portal` (`He4rt\Portal\Retrospective\CommunityRetrospective`), +lê **direto** de `GithubContribution` e monta um read model **ao vivo** a cada request, filtrado por +query params na URL. Ela só conhece o GitHub, não é persistida e não tem noção de edição publicada. + +O projeto detém atividade de outras plataformas (Discord no módulo `activity`, WhatsApp no +`integration-whatsapp`, Twitch no `integration-twitch`), mas nada disso aparece na retrospectiva, e não +existe nenhuma abstração de "fonte". + +Esta spec desenha a evolução: a retrospectiva vira uma peça editorial **multi-fonte**, onde adicionar +ou remover uma fonte é trivial, o operador cura o conteúdo e a edição publicada é imutável. + +Partimos de uma spec-base que foi destrinchada contra o projeto de hoje. Três pontos dela foram +**conscientemente divergidos** (ver seção própria). + +## Goals + +- **Multi-fonte plugável.** Adicionar uma fonte = criar 1 classe no módulo dono do dado + 1 linha de + tag. O `portal` nunca muda. +- **Peça editorial curada.** O operador (Marketing/Admin) monta a edição; o visitante só assiste. +- **Publicação imutável e barata.** Ao publicar, o dado é congelado num snapshot; a página pública lê o + congelado, sem tocar as fontes. + +## Non-goals + +- **Dedup de identidade entre plataformas.** Não há vínculo de usuário cross-plataforma; cada fonte é a + sua própria verdade. Pessoas são contadas por fonte. +- **Soma cruzada de métricas no cover.** PR, mensagem, sub e minuto em call são incomensuráveis; o cover + mostra chips por fonte, sem total unificado. +- **Cadence (Weekly/Monthly/Annual).** A edição usa datas livres `since`/`until`. +- **Multi-tenancy.** A tenancy foi removida no #413. + +## Arquitetura + +### Contrato (Strategy, dono em `community`) + +``` +interface RetrospectiveSource { + key(): string + collect(Period, SourceFilters): SourceResult +} + +SourceResult = HeadlineMetrics (Metric[] chips, por fonte) + Slide[] (DTO tipado por kind) +interface Slide { kind(): string } // classes concretas no modulo que emite + +// Fase 3, adicionado por ISP sem mexer no contrato acima: +interface CuratableSource { slideCatalog(); exclusionCandidates(Period) } +``` + +Cada fonte emite **dado, nunca markup**. O `portal` mapeia `kind` (ex.: `github.repos`, +`discord.voice_board`) para um componente Blade. As fontes são descobertas por **tagged services** +(tag `retrospective.source`); o orquestrador resolve o iterador e monta um mapa `key -> source`. + +### Fluxo + +``` +DRAFT cada fonte.collect() AO VIVO -> operador cura -> preview +PUBLICAR congela SourceResult[] no snapshot (numeros ficam fixos) +PUBLICA view = compoe(snapshot congelado, deck_config) [cacheavel, sem query nas fontes] + cover (chips agregados) -> bloco github.* -> bloco discord.* -> ... -> closing + visitante ASSISTE, sem filtros +``` + +Duas camadas de curadoria: filtro que **mexe no dado** (`hideBots`, exclusions) entra no `collect()` +para o headline sair consistente; curadoria de **apresentação** (ordem, on/off, título, max itens) fica +na orquestração, nunca na fonte. + +### Mapa de módulos + +| Módulo | Papel | Depende de | +|---|---|---| +| `community` (domínio) | contrato, entidade `Retrospective`, VOs `Period`/`SourceFilters`, interface `Slide` | nada novo | +| `integration-github` | `GithubSource` | community (Integration -> Domain) | +| `activity` (domínio) | `DiscordSource` | community (Domain -> Domain, sem ciclo) | +| `integration-whatsapp` | `WhatsAppSource` (posterior) | community (Integration -> Domain) | +| `portal` (apresentação) | orquestrador + blades | community + fontes | +| `panel-admin` (apresentação) | CRUD (F2) + Deck Builder (F3) | community | + +`DiscordSource` fica no `activity` (e não no `integration-discord`) porque o dado da retrospectiva +(`Voice`, `Message`, `Reaction`, `MembershipEvent`) é modelado no `activity`. A regra é "a fonte mora no +módulo dono do dado", não "toda fonte mora em integration". + +### Entidade + +``` +Retrospective (community · tabela community_retrospectives) + id uuid + title string identidade editorial ("Retro de Junho 2026") + since timestamptz + until timestamptz + status RetrospectiveStatus (enum draft|published, contratos Filament) + cover_title string|null copia editorial (so texto, sem os numeros) + cover_intro text|null + closing_text text|null + hide_bots bool (default true) + deck_config DeckConfig VO (jsonb) ordem, on/off por fonte, per-slide, exclusions, textos + snapshot RetrospectiveSnapshot VO|null (jsonb) SourceResult[] congelado; null enquanto draft + published_at timestamptz|null + created_at / updated_at timestamptz +``` + +`deck_config` e `snapshot` são VOs tipados via cast dedicado (regra de typed-json do repo), nunca `array` +solto. A reidratação dos `Slide` tipados usa um registro `kind -> classe` no cast do snapshot. + +### Slides e shell + +`cover` e `closing` são shell compartilhada (source-agnostic); o cover agrega os `Metric[]` de todas as +fontes. Os demais slides pertencem a um bloco de fonte: + +- GitHub (refatorado 1:1 do que existe hoje): `github.panorama`, `github.repos`, `github.highlights`, + `github.core`, `github.community`. +- Discord (novo, 5 slides): `discord.voice_board`, `discord.messages`, `discord.new_members`, + `discord.reactions`, `discord.top_message`. + +A cópia editorial (`cover_title`/`cover_intro`/`closing_text`) guarda **só texto**; números, avatares e +período são dado computado, renderizados à parte. A atribuição fixa "gerado a partir da GitHub API" +existente vira genérica ("gerado a partir de GitHub, Discord..."). + +### Escala e performance + +As tabelas de origem são grandes em prod (`messages` ~2GB; `voice_messages`, `activity_reactions`, +`membership_events` equivalentes ou maiores). Regras que caem disso: + +- **`collect()` agrega em SQL, nunca carrega linhas em PHP.** Cada slide é uma query GROUP BY/COUNT/SUM + escopada pelo período com LIMIT, devolvendo resultado pequeno. `SourceFilters` (hideBots/exclusions) + entra no WHERE. +- **Custo proporcional à janela, não ao total**, porque há índice na coluna de tempo. Os índices já + existem (recriados em `2026_07_15_001258_recreate_activity_post_tenancy_indexes`): `messages(sent_at)`, + `messages(channel_id, sent_at)`, `voice_messages(occurred_at) WHERE state='joined'`, + `membership_events(kind, occurred_at)`, `activity_reactions(emoji_key, created_at)`. +- **Publicar roda como job na fila** (todas as fontes sobre o período de uma vez pode ser pesado num + range anual; job evita timeout e dá UX uniforme "publicando..." -> "publicado"). +- **Draft cacheia o `collect()`** por (fonte, período, filtros) enquanto o operador cura, com "atualizar + dado" para invalidar; senão cada ajuste re-roda queries pesadas. +- **Filtrar sempre por `occurred_at`/`sent_at` (tempo do evento), nunca `created_at`** (dados de prod têm + `created_at` uniforme de backfill, ex.: `2026-05-02`; usar `created_at` daria um retro errado). + +### Notas de dados (Discord) + +- `hideBots` usa `source_kind = 'bot'` (populado em prod); ressalva: linhas históricas podem vir null. +- `messages` não tem nome de canal, só `channel_id`; o nome resolve no `portal` (via `integration-discord`). +- `content` pode conter spam/scam/moderação; exibir conteúdo em público exige curadoria/exclusion. +- `activity_reactions` são agregados sem tempo de evento próprio; escopar por período junta com o + `sent_at` da mensagem reagida ou usa o timestamp do agregado (resolver na implementação do slide). + +## Divergências da spec-base + +1. **Sem soma cruzada no cover** (era "cross-source unified totals"). Chips por fonte; sem dedup de + pessoas. Ver ADR-0001. +2. **Sem `Cadence`** (era o conceito central de período). Datas livres; presets viram açúcar de UI; a + identidade da edição vem do `title`. Ver ADR-0002. +3. **Sem `tenant_id`** (a spec era toda "per tenant"). Forçada pela remoção da tenancy no #413. + +## Entrega faseada (visão) + +Branch de integração `feature/retrospective-multi-source` -> `4.x`, com um PR por fase (cada um verde por +si só): + +- **PR-A (Fase 1):** contrato + DTOs + registro por tag + `GithubSource` (refactor 1:1) + `DiscordSource` + (5 slides) + orquestração. Página ao vivo, multi-fonte. +- **PR-B (Fase 2):** entidade `Retrospective` + datas/textos + snapshot ao publicar + view pública lê + snapshot + **CRUD Filament completo em capacidade** (feio, mas cura tudo). +- **PR-C (Fase 3):** Deck Builder 3 colunas + exclusions com UI. Puro upgrade de UX, zero capacidade + nova (se nunca vier, a feature funciona 100%). +- **PR aditivo (após PR-A):** `WhatsAppSource` (parsing do lake + regra de privacidade: sem telefone). + +## Trade-offs / Alternativas consideradas + +- **Formato do contrato** (option A normalizado / B payload por fonte / C híbrido): escolhido C. Ver + ADR-0001. +- **Conteúdo do snapshot** (deck final composto vs SourceResults crus + config separada): escolhido crus + + config, por segurança editorial (ajustar curadoria não recomputa números). Ver ADR-0002. +- **Período** (cadence vs datas livres): escolhido datas livres. Ver ADR-0002. +- **Descoberta** (tagged services vs config central vs auto-scan): escolhido tagged services. From 9651efa2ad8eb6ad6ed1e05a0c365b4ee7cd05c1 Mon Sep 17 00:00:00 2001 From: Clinton Rocha <50300876+Clintonrocha98@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:59:39 -0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(community):=20retrospectiva=20multi-fo?= =?UTF-8?q?nte=20=E2=80=94=20contrato=20+=20GitHub/Discord=20(Fase=201)=20?= =?UTF-8?q?(#438)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Fase 1 — Juntar as fontes Primeira fatia da [retrospectiva multi-fonte](https://github.com/he4rt/heartdevs.com/pull/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. --- .../activity/src/ActivityServiceProvider.php | 3 + .../src/Retrospective/DiscordSource.php | 321 ++++++++++++++++++ .../Retrospective/Slides/MessagesSlide.php | 43 +++ .../Retrospective/Slides/NewMembersSlide.php | 31 ++ .../Retrospective/Slides/ReactionsSlide.php | 35 ++ .../Retrospective/Slides/TopMessageSlide.php | 34 ++ .../Retrospective/Slides/VoiceBoardSlide.php | 41 +++ .../Retrospective/DiscordSourceTest.php | 187 ++++++++++ .../Contracts/RetrospectiveSource.php | 27 ++ .../src/Retrospective/Contracts/Slide.php | 23 ++ .../Retrospective/DTOs/HeadlineMetrics.php | 24 ++ .../src/Retrospective/DTOs/Metric.php | 19 ++ .../src/Retrospective/DTOs/Period.php | 24 ++ .../src/Retrospective/DTOs/SourceFilters.php | 26 ++ .../src/Retrospective/DTOs/SourceResult.php | 31 ++ .../src/IntegrationGithubServiceProvider.php | 4 + .../src/Retrospective/GithubSource.php} | 179 +++++----- .../Slides/GithubCommunitySlide.php | 34 ++ .../Retrospective/Slides/GithubCoreSlide.php | 34 ++ .../Slides/GithubHighlightsSlide.php | 34 ++ .../Slides/GithubPanoramaSlide.php | 34 ++ .../Retrospective/Slides/GithubRepoSlide.php | 35 ++ .../Retrospective/GithubSourceTest.php | 246 ++++++++++++++ .../views/community-retrospective.blade.php | 37 +- .../views/components/retro/controls.blade.php | 51 +++ .../views/components/retro/deck.blade.php | 2 +- .../views/components/retro/filters.blade.php | 162 --------- .../components/retro/slides/closing.blade.php | 32 +- .../components/retro/slides/cover.blade.php | 35 +- .../retro/slides/discord/messages.blade.php | 30 ++ .../slides/discord/new-members.blade.php | 21 ++ .../retro/slides/discord/reactions.blade.php | 19 ++ .../slides/discord/top-message.blade.php | 19 ++ .../slides/discord/voice-board.blade.php | 20 ++ .../slides/github}/community.blade.php | 1 - .../slides/github}/core.blade.php | 1 - .../slides/github}/highlights.blade.php | 1 - .../slides/github}/panorama.blade.php | 1 - .../slides/github/repos.blade.php} | 1 - .../Livewire/CommunityRetrospectivePage.php | 123 +------ .../portal/src/PortalServiceProvider.php | 13 +- .../src/Retrospective/RetrospectiveDeck.php | 72 ++++ .../Retrospective/RetrospectiveFilters.php | 68 ---- .../CommunityRetrospectivePageTest.php | 57 ++-- .../Feature/CommunityRetrospectiveTest.php | 261 -------------- .../tests/Feature/RetrospectiveDeckTest.php | 73 ++++ .../Feature/RetrospectiveFiltersTest.php | 35 -- .../tests/Feature/RetrospectiveSlidesTest.php | 62 ++++ 48 files changed, 1861 insertions(+), 805 deletions(-) create mode 100644 app-modules/activity/src/Retrospective/DiscordSource.php create mode 100644 app-modules/activity/src/Retrospective/Slides/MessagesSlide.php create mode 100644 app-modules/activity/src/Retrospective/Slides/NewMembersSlide.php create mode 100644 app-modules/activity/src/Retrospective/Slides/ReactionsSlide.php create mode 100644 app-modules/activity/src/Retrospective/Slides/TopMessageSlide.php create mode 100644 app-modules/activity/src/Retrospective/Slides/VoiceBoardSlide.php create mode 100644 app-modules/activity/tests/Feature/Retrospective/DiscordSourceTest.php create mode 100644 app-modules/community/src/Retrospective/Contracts/RetrospectiveSource.php create mode 100644 app-modules/community/src/Retrospective/Contracts/Slide.php create mode 100644 app-modules/community/src/Retrospective/DTOs/HeadlineMetrics.php create mode 100644 app-modules/community/src/Retrospective/DTOs/Metric.php create mode 100644 app-modules/community/src/Retrospective/DTOs/Period.php create mode 100644 app-modules/community/src/Retrospective/DTOs/SourceFilters.php create mode 100644 app-modules/community/src/Retrospective/DTOs/SourceResult.php rename app-modules/{portal/src/Retrospective/CommunityRetrospective.php => integration-github/src/Retrospective/GithubSource.php} (70%) create mode 100644 app-modules/integration-github/src/Retrospective/Slides/GithubCommunitySlide.php create mode 100644 app-modules/integration-github/src/Retrospective/Slides/GithubCoreSlide.php create mode 100644 app-modules/integration-github/src/Retrospective/Slides/GithubHighlightsSlide.php create mode 100644 app-modules/integration-github/src/Retrospective/Slides/GithubPanoramaSlide.php create mode 100644 app-modules/integration-github/src/Retrospective/Slides/GithubRepoSlide.php create mode 100644 app-modules/integration-github/tests/Feature/Retrospective/GithubSourceTest.php create mode 100644 app-modules/portal/resources/views/components/retro/controls.blade.php delete mode 100644 app-modules/portal/resources/views/components/retro/filters.blade.php create mode 100644 app-modules/portal/resources/views/retro/slides/discord/messages.blade.php create mode 100644 app-modules/portal/resources/views/retro/slides/discord/new-members.blade.php create mode 100644 app-modules/portal/resources/views/retro/slides/discord/reactions.blade.php create mode 100644 app-modules/portal/resources/views/retro/slides/discord/top-message.blade.php create mode 100644 app-modules/portal/resources/views/retro/slides/discord/voice-board.blade.php rename app-modules/portal/resources/views/{components/retro/slides => retro/slides/github}/community.blade.php (97%) rename app-modules/portal/resources/views/{components/retro/slides => retro/slides/github}/core.blade.php (96%) rename app-modules/portal/resources/views/{components/retro/slides => retro/slides/github}/highlights.blade.php (99%) rename app-modules/portal/resources/views/{components/retro/slides => retro/slides/github}/panorama.blade.php (99%) rename app-modules/portal/resources/views/{components/retro/slides/repo.blade.php => retro/slides/github/repos.blade.php} (98%) create mode 100644 app-modules/portal/src/Retrospective/RetrospectiveDeck.php delete mode 100644 app-modules/portal/src/Retrospective/RetrospectiveFilters.php delete mode 100644 app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php create mode 100644 app-modules/portal/tests/Feature/RetrospectiveDeckTest.php delete mode 100644 app-modules/portal/tests/Feature/RetrospectiveFiltersTest.php create mode 100644 app-modules/portal/tests/Feature/RetrospectiveSlidesTest.php diff --git a/app-modules/activity/src/ActivityServiceProvider.php b/app-modules/activity/src/ActivityServiceProvider.php index d79f3f9f..c85f936f 100644 --- a/app-modules/activity/src/ActivityServiceProvider.php +++ b/app-modules/activity/src/ActivityServiceProvider.php @@ -6,6 +6,7 @@ use He4rt\Activity\Message\Models\Message; use He4rt\Activity\Moderation\Models\ModerationEvent; +use He4rt\Activity\Retrospective\DiscordSource; use He4rt\Activity\Timeline\Delegated\PostEntry; use He4rt\Activity\Timeline\Listeners\PublishModerationToTimeline; use He4rt\Activity\Timeline\Listeners\ReassignTimelineOwnership; @@ -23,6 +24,8 @@ public function register(): void { $this->mergeConfigFrom(__DIR__.'/../config/activity-tracking.php', 'activity-tracking'); + // Fonte da retrospectiva, descoberta pelo portal via tagged services. + $this->app->tag([DiscordSource::class], 'retrospective.source'); } public function boot(): void diff --git a/app-modules/activity/src/Retrospective/DiscordSource.php b/app-modules/activity/src/Retrospective/DiscordSource.php new file mode 100644 index 00000000..750cb141 --- /dev/null +++ b/app-modules/activity/src/Retrospective/DiscordSource.php @@ -0,0 +1,321 @@ +messages($period, $filters)->count(); + $withReactions = $this->messages($period, $filters)->where('reactions_total', '>', 0)->count(); + $pinned = $this->messages($period, $filters)->where('is_pinned', operator: true)->count(); + $chatters = $this->topChatters($period, $filters); + + $participants = Voice::query() + ->whereBetween('occurred_at', [$period->since, $period->until]) + ->distinct() + ->count('external_identity_id'); + $voiceXp = (int) Voice::query() + ->whereBetween('occurred_at', [$period->since, $period->until]) + ->sum('obtained_experience'); + $channels = $this->topVoiceChannels($period); + + $joins = MembershipEvent::query() + ->whereBetween('occurred_at', [$period->since, $period->until]) + ->where('kind', MembershipEventKind::UserJoin->value) + ->count(); + $boosts = MembershipEvent::query() + ->whereBetween('occurred_at', [$period->since, $period->until]) + ->where('kind', 'like', 'boost%') + ->count(); + + $totalReactions = $this->totalReactions($period, $filters); + $emojis = $this->topEmojis($period, $filters); + $topMessages = $this->topMessages($period, $filters); + + return new SourceResult( + key: $this->key(), + label: 'Discord', + headline: $this->headline($totalMessages, $participants, $joins, $totalReactions), + slides: $this->slides( + participants: $participants, + voiceXp: $voiceXp, + channels: $channels, + totalMessages: $totalMessages, + withReactions: $withReactions, + pinned: $pinned, + chatters: $chatters, + joins: $joins, + boosts: $boosts, + totalReactions: $totalReactions, + emojis: $emojis, + topMessages: $topMessages, + ), + ); + } + + private function headline(int $messages, int $participants, int $joins, int $reactions): HeadlineMetrics + { + $metrics = []; + + if ($messages > 0) { + $metrics[] = new Metric('Mensagens', $messages); + } + + if ($participants > 0) { + $metrics[] = new Metric('Pessoas em call', $participants); + } + + if ($joins > 0) { + $metrics[] = new Metric('Novos membros', $joins); + } + + if ($reactions > 0) { + $metrics[] = new Metric('Reações', $reactions); + } + + return new HeadlineMetrics($metrics); + } + + /** + * @param list $channels + * @param list $chatters + * @param list $emojis + * @param list $topMessages + * @return list + */ + private function slides( + int $participants, + int $voiceXp, + array $channels, + int $totalMessages, + int $withReactions, + int $pinned, + array $chatters, + int $joins, + int $boosts, + int $totalReactions, + array $emojis, + array $topMessages, + ): array { + $slides = []; + + if ($participants > 0) { + $slides[] = new VoiceBoardSlide($participants, $voiceXp, $channels); + } + + if ($totalMessages > 0) { + $slides[] = new MessagesSlide($totalMessages, $withReactions, $pinned, $chatters); + } + + if ($joins > 0 || $boosts > 0) { + $slides[] = new NewMembersSlide($joins, $boosts); + } + + if ($totalReactions > 0) { + $slides[] = new ReactionsSlide($totalReactions, $emojis); + } + + if ($topMessages !== []) { + $slides[] = new TopMessageSlide($topMessages); + } + + return $slides; + } + + /** + * Base de mensagens do recorte. hideBots derruba source_kind='bot' mas mantém + * linhas históricas com source_kind nulo. + * + * @return Builder + */ + private function messages(Period $period, SourceFilters $filters): Builder + { + return Message::query() + ->whereBetween('sent_at', [$period->since, $period->until]) + ->when( + $filters->hideBots, + fn (Builder $query): Builder => $query->where(function (Builder $inner): void { + $inner->whereNull('source_kind') + ->orWhere('source_kind', '!=', MessageSourceKind::Bot->value); + }), + ); + } + + /** + * @return list + */ + private function topChatters(Period $period, SourceFilters $filters): array + { + $rows = $this->messages($period, $filters) + ->groupBy('external_identity_id') + ->orderByRaw('COUNT(*) DESC') + ->limit(8) + ->get(['external_identity_id', DB::raw('COUNT(*) AS messages')]); + + $names = $this->displayNames($this->identityIds($rows)); + + return array_values( + $rows->map(fn (Message $row): array => [ + 'name' => $names[$row->external_identity_id] ?? 'Anônimo', + 'messages' => (int) $row->getAttribute('messages'), + ])->all(), + ); + } + + /** + * @return list + */ + private function topVoiceChannels(Period $period): array + { + return array_values( + Voice::query() + ->whereBetween('occurred_at', [$period->since, $period->until]) + ->whereNotNull('channel_name') + ->groupBy('channel_name') + ->orderByRaw('SUM(obtained_experience) DESC') + ->limit(6) + ->get([ + 'channel_name', + DB::raw('COUNT(*) AS events'), + DB::raw('COALESCE(SUM(obtained_experience), 0) AS xp'), + ]) + ->map(fn (Voice $row): array => [ + 'name' => $row->channel_name, + 'events' => (int) $row->getAttribute('events'), + 'xp' => (int) $row->getAttribute('xp'), + ]) + ->all(), + ); + } + + private function totalReactions(Period $period, SourceFilters $filters): int + { + return (int) Reaction::query() + ->where('reactable_type', 'message') + ->whereIn('reactable_id', $this->messages($period, $filters)->select('id')) + ->sum('count'); + } + + /** + * @return list + */ + private function topEmojis(Period $period, SourceFilters $filters): array + { + return array_values( + Reaction::query() + ->where('reactable_type', 'message') + ->whereIn('reactable_id', $this->messages($period, $filters)->select('id')) + ->groupBy('emoji_key', 'emoji_name') + ->orderByRaw('SUM("count") DESC') + ->limit(10) + ->get([ + 'emoji_key', + 'emoji_name', + DB::raw('MAX(emoji_id) AS emoji_id'), + DB::raw('SUM("count") AS total'), + ]) + ->map(fn (Reaction $row): array => [ + 'name' => $row->emoji_name ?? $row->emoji_key, + 'count' => (int) $row->getAttribute('total'), + 'custom' => $row->getAttribute('emoji_id') !== null, + ]) + ->all(), + ); + } + + /** + * @return list + */ + private function topMessages(Period $period, SourceFilters $filters): array + { + $rows = $this->messages($period, $filters) + ->where('reactions_total', '>', 0) + ->orderByDesc('reactions_total') + ->limit(3) + ->get(['id', 'external_identity_id', 'content', 'reactions_total']); + + $names = $this->displayNames($this->identityIds($rows)); + + return array_values( + $rows->map(fn (Message $row): array => [ + 'content' => (string) str($row->content)->limit(160), + 'author' => $names[$row->external_identity_id] ?? 'Anônimo', + 'reactions' => $row->reactions_total, + ])->all(), + ); + } + + /** + * @param Collection $rows + * @return list + */ + private function identityIds(Collection $rows): array + { + return array_values($rows->map(fn (Message $row): string => $row->external_identity_id)->all()); + } + + /** + * Resolve o nome de exibição do Discord para os external_identity_id do topo + * de um ranking: o username do Discord (metadata), senão o id externo. Só as + * poucas pessoas do topo entram aqui, então a query é barata. + * + * @param list $ids + * @return array + */ + private function displayNames(array $ids): array + { + if ($ids === []) { + return []; + } + + return ExternalIdentity::query() + ->whereIn('id', array_unique($ids)) + ->get() + ->mapWithKeys(function (ExternalIdentity $identity): array { + $metadata = $identity->metadata ?? []; + $username = is_string($metadata['username'] ?? null) ? $metadata['username'] : null; + + return [$identity->id => $username ?? $identity->external_account_id ?? 'Anônimo']; + }) + ->all(); + } +} diff --git a/app-modules/activity/src/Retrospective/Slides/MessagesSlide.php b/app-modules/activity/src/Retrospective/Slides/MessagesSlide.php new file mode 100644 index 00000000..c892d0a8 --- /dev/null +++ b/app-modules/activity/src/Retrospective/Slides/MessagesSlide.php @@ -0,0 +1,43 @@ + $chatters + */ + public function __construct( + private int $total, + private int $withReactions, + private int $pinned, + private array $chatters, + ) {} + + public function kind(): string + { + return 'discord.messages'; + } + + /** + * @return array{total: int, with_reactions: int, pinned: int, chatters: list} + */ + public function toArray(): array + { + return [ + 'total' => $this->total, + 'with_reactions' => $this->withReactions, + 'pinned' => $this->pinned, + 'chatters' => $this->chatters, + ]; + } +} diff --git a/app-modules/activity/src/Retrospective/Slides/NewMembersSlide.php b/app-modules/activity/src/Retrospective/Slides/NewMembersSlide.php new file mode 100644 index 00000000..00b9a816 --- /dev/null +++ b/app-modules/activity/src/Retrospective/Slides/NewMembersSlide.php @@ -0,0 +1,31 @@ + $this->joins, 'boosts' => $this->boosts]; + } +} diff --git a/app-modules/activity/src/Retrospective/Slides/ReactionsSlide.php b/app-modules/activity/src/Retrospective/Slides/ReactionsSlide.php new file mode 100644 index 00000000..47a374b3 --- /dev/null +++ b/app-modules/activity/src/Retrospective/Slides/ReactionsSlide.php @@ -0,0 +1,35 @@ + $emojis + */ + public function __construct( + private int $total, + private array $emojis, + ) {} + + public function kind(): string + { + return 'discord.reactions'; + } + + /** + * @return array{total: int, emojis: list} + */ + public function toArray(): array + { + return ['total' => $this->total, 'emojis' => $this->emojis]; + } +} diff --git a/app-modules/activity/src/Retrospective/Slides/TopMessageSlide.php b/app-modules/activity/src/Retrospective/Slides/TopMessageSlide.php new file mode 100644 index 00000000..1e9d5f5c --- /dev/null +++ b/app-modules/activity/src/Retrospective/Slides/TopMessageSlide.php @@ -0,0 +1,34 @@ + $messages + */ + public function __construct( + private array $messages, + ) {} + + public function kind(): string + { + return 'discord.top_message'; + } + + /** + * @return array{messages: list} + */ + public function toArray(): array + { + return ['messages' => $this->messages]; + } +} diff --git a/app-modules/activity/src/Retrospective/Slides/VoiceBoardSlide.php b/app-modules/activity/src/Retrospective/Slides/VoiceBoardSlide.php new file mode 100644 index 00000000..e41af672 --- /dev/null +++ b/app-modules/activity/src/Retrospective/Slides/VoiceBoardSlide.php @@ -0,0 +1,41 @@ + $channels + */ + public function __construct( + private int $participants, + private int $xp, + private array $channels, + ) {} + + public function kind(): string + { + return 'discord.voice_board'; + } + + /** + * @return array{participants: int, xp: int, channels: list} + */ + public function toArray(): array + { + return [ + 'participants' => $this->participants, + 'xp' => $this->xp, + 'channels' => $this->channels, + ]; + } +} diff --git a/app-modules/activity/tests/Feature/Retrospective/DiscordSourceTest.php b/app-modules/activity/tests/Feature/Retrospective/DiscordSourceTest.php new file mode 100644 index 00000000..360141c5 --- /dev/null +++ b/app-modules/activity/tests/Feature/Retrospective/DiscordSourceTest.php @@ -0,0 +1,187 @@ +since = CarbonImmutable::parse('2026-06-01 00:00:00'); + $this->until = CarbonImmutable::parse('2026-06-07 23:59:59'); + $this->collect = fn (bool $hideBots = true): SourceResult => new DiscordSource()->collect( + Period::of($this->since, $this->until), + new SourceFilters(hideBots: $hideBots), + ); +}); + +/** + * @return array + */ +function dcSlide(SourceResult $result, string $kind): array +{ + foreach ($result->slides as $slide) { + if ($slide->kind() === $kind) { + return $slide->toArray(); + } + } + + return []; +} + +function dcIdentity(string $name): ExternalIdentity +{ + return ExternalIdentity::factory()->create(['metadata' => ['username' => $name]]); +} + +/** + * @param array $attributes + */ +function dcReaction(string $messageId, string $emojiKey, int $count, ?string $emojiId = null): void +{ + $reaction = new Reaction(); + $reaction->reactable_type = 'message'; + $reaction->reactable_id = $messageId; + $reaction->emoji_key = $emojiKey; + $reaction->emoji_name = $emojiKey; + $reaction->emoji_id = $emojiId; + $reaction->count = $count; + $reaction->count_burst = 0; + $reaction->count_normal = $count; + $reaction->save(); +} + +function dcMembership(string $identityId, string $kind, string $occurredAt): void +{ + $event = new MembershipEvent(); + $event->external_identity_id = $identityId; + $event->kind = $kind; + $event->occurred_at = CarbonImmutable::parse($occurredAt); + $event->save(); +} + +it('identifica-se como a fonte discord', function (): void { + expect(new DiscordSource()->key())->toBe('discord'); +}); + +it('devolve resultado vazio sem dado no recorte', function (): void { + expect(($this->collect)()->isEmpty())->toBeTrue(); +}); + +it('conta mensagens do recorte, escondendo bots e mantendo linhas sem source_kind', function (): void { + $alice = dcIdentity('Alice'); + $bob = dcIdentity('Bob'); + + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02']); + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-03']); + Message::factory()->create(['external_identity_id' => $bob->id, 'sent_at' => '2026-06-02']); + Message::factory()->create(['external_identity_id' => $bob->id, 'sent_at' => '2026-06-02', 'source_kind' => MessageSourceKind::Bot]); + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-05-15']); + + $messages = dcSlide(($this->collect)(), 'discord.messages'); + + expect($messages['total'])->toBe(3) + ->and($messages['chatters'][0])->toMatchArray(['name' => 'Alice', 'messages' => 2]) + ->and($messages['chatters'][1])->toMatchArray(['name' => 'Bob', 'messages' => 1]); +}); + +it('mantém bots quando hideBots é falso', function (): void { + $bot = dcIdentity('Robô'); + Message::factory()->create(['external_identity_id' => $bot->id, 'sent_at' => '2026-06-02', 'source_kind' => MessageSourceKind::Bot]); + + expect(dcSlide(($this->collect)(false), 'discord.messages')['total'])->toBe(1) + ->and(($this->collect)()->isEmpty())->toBeTrue(); +}); + +it('escopa mensagens por sent_at, não por created_at', function (): void { + $alice = dcIdentity('Alice'); + // sent_at fora do recorte, created_at = agora (dentro): deve ficar de fora. + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-05-01']); + + expect(($this->collect)()->isEmpty())->toBeTrue(); +}); + +it('destaca mensagens com reação e fixadas, e a mais reagida', function (): void { + $alice = dcIdentity('Alice'); + + $top = Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02', 'reactions_total' => 12, 'content' => 'mensagem campeã']); + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02', 'reactions_total' => 0]); + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-03', 'is_pinned' => true]); + + $result = ($this->collect)(); + $messages = dcSlide($result, 'discord.messages'); + $topMessage = dcSlide($result, 'discord.top_message'); + + expect($messages['with_reactions'])->toBe(1) + ->and($messages['pinned'])->toBe(1) + ->and($topMessage['messages'][0])->toMatchArray(['author' => 'Alice', 'reactions' => 12]) + ->and($topMessage['messages'][0]['content'])->toBe('mensagem campeã'); +}); + +it('agrega o board de voz por participantes, XP e canais', function (): void { + $alice = dcIdentity('Alice'); + $bob = dcIdentity('Bob'); + + Voice::factory()->create(['external_identity_id' => $alice->id, 'channel_name' => 'geral', 'obtained_experience' => 10, 'occurred_at' => '2026-06-02']); + Voice::factory()->create(['external_identity_id' => $alice->id, 'channel_name' => 'geral', 'obtained_experience' => 20, 'occurred_at' => '2026-06-03']); + Voice::factory()->create(['external_identity_id' => $bob->id, 'channel_name' => 'estudos', 'obtained_experience' => 5, 'occurred_at' => '2026-06-02']); + Voice::factory()->create(['external_identity_id' => $bob->id, 'channel_name' => 'geral', 'obtained_experience' => 99, 'occurred_at' => '2026-05-01']); + + $voice = dcSlide(($this->collect)(), 'discord.voice_board'); + + expect($voice['participants'])->toBe(2) + ->and($voice['xp'])->toBe(35) + ->and($voice['channels'][0])->toMatchArray(['name' => 'geral', 'events' => 2, 'xp' => 30]); +}); + +it('conta novos membros e boosts pelo occurred_at', function (): void { + $a = dcIdentity('A'); + $b = dcIdentity('B'); + $c = dcIdentity('C'); + + dcMembership($a->id, 'user_join', '2026-06-02'); + dcMembership($b->id, 'user_join', '2026-06-03'); + dcMembership($c->id, 'boost_tier_1', '2026-06-03'); + dcMembership($a->id, 'user_join', '2026-05-01'); + + $newMembers = dcSlide(($this->collect)(), 'discord.new_members'); + + expect($newMembers['joins'])->toBe(2) + ->and($newMembers['boosts'])->toBe(1); +}); + +it('agrega reações do recorte por emoji, escopando pelas mensagens do período', function (): void { + $alice = dcIdentity('Alice'); + + $inPeriod = Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02', 'reactions_total' => 8]); + $outside = Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-05-01', 'reactions_total' => 50]); + + dcReaction($inPeriod->id, 'fire', 3); + dcReaction($inPeriod->id, 'heart', 5, emojiId: '123'); + dcReaction($outside->id, 'tada', 50); + + $reactions = dcSlide(($this->collect)(), 'discord.reactions'); + + expect($reactions['total'])->toBe(8) + ->and($reactions['emojis'][0])->toMatchArray(['name' => 'heart', 'count' => 5, 'custom' => true]) + ->and($reactions['emojis'][1])->toMatchArray(['name' => 'fire', 'count' => 3, 'custom' => false]); +}); + +it('expõe os chips do cover só para o que teve dado', function (): void { + $alice = dcIdentity('Alice'); + Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02']); + + $result = ($this->collect)(); + $labels = collect($result->headline->metrics)->map(fn ($metric): string => $metric->label)->all(); + + expect($result->label)->toBe('Discord') + ->and($labels)->toBe(['Mensagens']); +}); diff --git a/app-modules/community/src/Retrospective/Contracts/RetrospectiveSource.php b/app-modules/community/src/Retrospective/Contracts/RetrospectiveSource.php new file mode 100644 index 00000000..b52b32c1 --- /dev/null +++ b/app-modules/community/src/Retrospective/Contracts/RetrospectiveSource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(): array; +} diff --git a/app-modules/community/src/Retrospective/DTOs/HeadlineMetrics.php b/app-modules/community/src/Retrospective/DTOs/HeadlineMetrics.php new file mode 100644 index 00000000..83f61231 --- /dev/null +++ b/app-modules/community/src/Retrospective/DTOs/HeadlineMetrics.php @@ -0,0 +1,24 @@ + $metrics + */ + public function __construct( + public array $metrics = [], + ) {} + + public function isEmpty(): bool + { + return $this->metrics === []; + } +} diff --git a/app-modules/community/src/Retrospective/DTOs/Metric.php b/app-modules/community/src/Retrospective/DTOs/Metric.php new file mode 100644 index 00000000..fc9f6423 --- /dev/null +++ b/app-modules/community/src/Retrospective/DTOs/Metric.php @@ -0,0 +1,19 @@ + $exclusions refs escondidos deck-wide, ex.: "pr:142", "actor:login" + */ + public function __construct( + public bool $hideBots = true, + public array $exclusions = [], + ) {} + + public function excludes(string $ref): bool + { + return in_array($ref, $this->exclusions, strict: true); + } +} diff --git a/app-modules/community/src/Retrospective/DTOs/SourceResult.php b/app-modules/community/src/Retrospective/DTOs/SourceResult.php new file mode 100644 index 00000000..17277fbc --- /dev/null +++ b/app-modules/community/src/Retrospective/DTOs/SourceResult.php @@ -0,0 +1,31 @@ + $slides + */ + public function __construct( + public string $key, + public string $label, + public HeadlineMetrics $headline, + public array $slides = [], + ) {} + + public function isEmpty(): bool + { + return $this->slides === [] && $this->headline->isEmpty(); + } +} diff --git a/app-modules/integration-github/src/IntegrationGithubServiceProvider.php b/app-modules/integration-github/src/IntegrationGithubServiceProvider.php index 35853a23..e944a91f 100644 --- a/app-modules/integration-github/src/IntegrationGithubServiceProvider.php +++ b/app-modules/integration-github/src/IntegrationGithubServiceProvider.php @@ -5,6 +5,7 @@ namespace He4rt\IntegrationGithub; use He4rt\IntegrationGithub\Console\BackfillGithubCommand; +use He4rt\IntegrationGithub\Retrospective\GithubSource; use He4rt\IntegrationGithub\Transport\GitHubApiConnector; use He4rt\IntegrationGithub\Transport\GitHubOAuthConnector; use Illuminate\Support\ServiceProvider; @@ -29,6 +30,9 @@ public function register(): void }); $this->app->singleton(GitHubApiConnector::class, fn () => new GitHubApiConnector()); + + // Fonte da retrospectiva, descoberta pelo portal via tagged services. + $this->app->tag([GithubSource::class], 'retrospective.source'); } public function boot(): void diff --git a/app-modules/portal/src/Retrospective/CommunityRetrospective.php b/app-modules/integration-github/src/Retrospective/GithubSource.php similarity index 70% rename from app-modules/portal/src/Retrospective/CommunityRetrospective.php rename to app-modules/integration-github/src/Retrospective/GithubSource.php index 683eb260..8402b7be 100644 --- a/app-modules/portal/src/Retrospective/CommunityRetrospective.php +++ b/app-modules/integration-github/src/Retrospective/GithubSource.php @@ -2,92 +2,134 @@ declare(strict_types=1); -namespace He4rt\Portal\Retrospective; - +namespace He4rt\IntegrationGithub\Retrospective; + +use He4rt\Community\Retrospective\Contracts\RetrospectiveSource; +use He4rt\Community\Retrospective\Contracts\Slide; +use He4rt\Community\Retrospective\DTOs\HeadlineMetrics; +use He4rt\Community\Retrospective\DTOs\Metric; +use He4rt\Community\Retrospective\DTOs\Period; +use He4rt\Community\Retrospective\DTOs\SourceFilters; +use He4rt\Community\Retrospective\DTOs\SourceResult; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; +use He4rt\IntegrationGithub\Retrospective\Slides\GithubCommunitySlide; +use He4rt\IntegrationGithub\Retrospective\Slides\GithubCoreSlide; +use He4rt\IntegrationGithub\Retrospective\Slides\GithubHighlightsSlide; +use He4rt\IntegrationGithub\Retrospective\Slides\GithubPanoramaSlide; +use He4rt\IntegrationGithub\Retrospective\Slides\GithubRepoSlide; use Illuminate\Support\Collection; /** - * Read model for the community presentation. Applies the "filter on read" rules - * (decision #10): excludes only bots, counts by occurred_at within the period, and - * groups by contributor login. Closed-unmerged PRs DO count as participation but are - * broken out (prs_merged / prs_unmerged) so the view can distinguish their outcome. + * Fonte GitHub da retrospectiva. Preserva 1:1 o cálculo do antigo read model do + * portal (filtra bots, conta por occurred_at no período, agrupa por login, + * quebra PRs merged/unmerged) e o empacota em slides tipados. Repos só entram + * como card se tiverem PR no recorte; atividade só de review/issue/comentário + * segue contando em meta/people/highlights. */ -final readonly class CommunityRetrospective +final class GithubSource implements RetrospectiveSource { - public function __construct( - private RetrospectiveFilters $filters, - ) {} + public function key(): string + { + return 'github'; + } - /** - * @return array{ - * period: array{since: string, until: string}, - * meta: array, - * people: list>, - * repos: list>, - * highlights: list>, - * } - */ - public function build(): array + public function collect(Period $period, SourceFilters $filters): SourceResult { /** @var Collection $contributions */ $contributions = GithubContribution::query() - ->whereBetween('occurred_at', [$this->filters->since, $this->filters->until]) + ->whereBetween('occurred_at', [$period->since, $period->until]) ->get() ->when( - $this->filters->hideBots, + $filters->hideBots, fn (Collection $items): Collection => $items->reject(fn (GithubContribution $contribution): bool => $this->isBot($contribution)), ) - ->when( - $this->filters->repos !== [], - fn (Collection $items): Collection => $items->filter(fn (GithubContribution $contribution): bool => in_array($contribution->repo, $this->filters->repos, strict: true)), - ) - ->filter(fn (GithubContribution $contribution): bool => in_array($contribution->type, $this->filters->types, strict: true)) - ->reject(fn (GithubContribution $contribution): bool => $this->filteredOutByOutcome($contribution)) - ->when( - $this->filters->person !== null, - fn (Collection $items): Collection => $items->filter(fn (GithubContribution $contribution): bool => $contribution->actor_login === $this->filters->person), - ) ->values(); + if ($contributions->isEmpty()) { + return new SourceResult($this->key(), 'GitHub', new HeadlineMetrics(), []); + } + /** @var list> $people */ $people = $contributions ->groupBy('actor_login') ->map(fn (Collection $items, string $login): array => $this->person($login, $items)) - ->sortByDesc(fn (array $person): int => match ($this->filters->sort) { - 'prs' => (int) $person['prs'] * 1_000 + (int) $person['total'], - 'lines' => (int) $person['additions'] + (int) $person['deletions'], - default => (int) $person['total'], - }) + ->sortByDesc(fn (array $person): int => (int) $person['total']) ->values() ->all(); $repos = $this->repos($contributions); - - return [ - 'period' => ['since' => $this->filters->since->toDateString(), 'until' => $this->filters->until->toDateString()], - 'meta' => [ - 'people' => count($people), - 'prs' => $this->countType($contributions, ContributionType::Pr), - 'prs_merged' => $this->countMergedPrs($contributions), - 'prs_unmerged' => $this->countUnmergedPrs($contributions), - 'reviews' => $this->countType($contributions, ContributionType::Review), - 'issues' => $this->countType($contributions, ContributionType::Issue), - 'comments' => $this->countType($contributions, ContributionType::Comment), - 'review_comments' => $this->countType($contributions, ContributionType::ReviewComment), - 'commits' => $this->countType($contributions, ContributionType::Commit), - 'additions' => $this->sumMeta($contributions, 'additions'), - 'deletions' => $this->sumMeta($contributions, 'deletions'), - 'changed_files' => $this->sumMeta($contributions, 'changed_files'), - // Repos exibidos = só os com PR no recorte (mesmo universo dos cards). - 'repos' => count($repos), - 'total' => $contributions->count(), - ], - 'people' => $people, - 'repos' => $repos, - 'highlights' => $this->highlights($contributions), + $highlights = $this->highlights($contributions); + + $meta = [ + 'people' => count($people), + 'prs' => $this->countType($contributions, ContributionType::Pr), + 'prs_merged' => $this->countMergedPrs($contributions), + 'prs_unmerged' => $this->countUnmergedPrs($contributions), + 'reviews' => $this->countType($contributions, ContributionType::Review), + 'issues' => $this->countType($contributions, ContributionType::Issue), + 'comments' => $this->countType($contributions, ContributionType::Comment), + 'review_comments' => $this->countType($contributions, ContributionType::ReviewComment), + 'commits' => $this->countType($contributions, ContributionType::Commit), + 'additions' => $this->sumMeta($contributions, 'additions'), + 'deletions' => $this->sumMeta($contributions, 'deletions'), + 'changed_files' => $this->sumMeta($contributions, 'changed_files'), + // Repos exibidos = só os com PR no recorte (mesmo universo dos cards). + 'repos' => count($repos), + 'total' => $contributions->count(), ]; + + return new SourceResult( + key: $this->key(), + label: 'GitHub', + headline: $this->headline($meta), + slides: $this->slides($meta, $repos, $highlights, $people), + ); + } + + /** + * @param array $meta + * @param list> $repos + * @param list> $highlights + * @param list> $people + * @return list + */ + private function slides(array $meta, array $repos, array $highlights, array $people): array + { + $slides = [new GithubPanoramaSlide($meta)]; + + foreach ($repos as $i => $repo) { + $slides[] = new GithubRepoSlide($repo, $i + 1); + } + + if ($highlights !== []) { + $slides[] = new GithubHighlightsSlide($highlights); + } + + if ($people !== []) { + $slides[] = new GithubCoreSlide($people); + + if (count($people) > 5) { + $slides[] = new GithubCommunitySlide($people); + } + } + + return $slides; + } + + /** + * @param array $meta + */ + private function headline(array $meta): HeadlineMetrics + { + return new HeadlineMetrics([ + new Metric('Pessoas', $meta['people']), + new Metric('PRs', $meta['prs']), + new Metric('Reviews', $meta['reviews']), + new Metric('Issues', $meta['issues']), + new Metric('Commits', $meta['commits']), + new Metric('Linhas', $meta['additions'] + $meta['deletions']), + ]); } /** @@ -200,23 +242,6 @@ private function avatar(string $login, ?int $actorId): string : 'https://github.com/'.$login.'.png'; } - private function filteredOutByOutcome(GithubContribution $contribution): bool - { - if ($this->filters->outcome === null || $contribution->type !== ContributionType::Pr) { - return false; - } - - $metadata = $contribution->metadata ?? []; - $merged = ($metadata['merged'] ?? false) === true; - $state = $metadata['state'] ?? null; - - return match ($this->filters->outcome) { - 'merged' => !$merged, - 'open' => $state !== 'open', - 'closed' => $state !== 'closed' || $merged, - }; - } - /** * @return array{num: int, title: string, url: string|null, state: string|null, author_login: string, additions: int, deletions: int, changed_files: int} */ diff --git a/app-modules/integration-github/src/Retrospective/Slides/GithubCommunitySlide.php b/app-modules/integration-github/src/Retrospective/Slides/GithubCommunitySlide.php new file mode 100644 index 00000000..64572c1d --- /dev/null +++ b/app-modules/integration-github/src/Retrospective/Slides/GithubCommunitySlide.php @@ -0,0 +1,34 @@ +> $people + */ + public function __construct( + private array $people, + ) {} + + public function kind(): string + { + return 'github.community'; + } + + /** + * @return array{people: list>} + */ + public function toArray(): array + { + return ['people' => $this->people]; + } +} diff --git a/app-modules/integration-github/src/Retrospective/Slides/GithubCoreSlide.php b/app-modules/integration-github/src/Retrospective/Slides/GithubCoreSlide.php new file mode 100644 index 00000000..6184edd4 --- /dev/null +++ b/app-modules/integration-github/src/Retrospective/Slides/GithubCoreSlide.php @@ -0,0 +1,34 @@ +> $people + */ + public function __construct( + private array $people, + ) {} + + public function kind(): string + { + return 'github.core'; + } + + /** + * @return array{people: list>} + */ + public function toArray(): array + { + return ['people' => $this->people]; + } +} diff --git a/app-modules/integration-github/src/Retrospective/Slides/GithubHighlightsSlide.php b/app-modules/integration-github/src/Retrospective/Slides/GithubHighlightsSlide.php new file mode 100644 index 00000000..bb42a73a --- /dev/null +++ b/app-modules/integration-github/src/Retrospective/Slides/GithubHighlightsSlide.php @@ -0,0 +1,34 @@ +> $highlights + */ + public function __construct( + private array $highlights, + ) {} + + public function kind(): string + { + return 'github.highlights'; + } + + /** + * @return array{highlights: list>} + */ + public function toArray(): array + { + return ['highlights' => $this->highlights]; + } +} diff --git a/app-modules/integration-github/src/Retrospective/Slides/GithubPanoramaSlide.php b/app-modules/integration-github/src/Retrospective/Slides/GithubPanoramaSlide.php new file mode 100644 index 00000000..1409a12d --- /dev/null +++ b/app-modules/integration-github/src/Retrospective/Slides/GithubPanoramaSlide.php @@ -0,0 +1,34 @@ + $meta + */ + public function __construct( + private array $meta, + ) {} + + public function kind(): string + { + return 'github.panorama'; + } + + /** + * @return array{meta: array} + */ + public function toArray(): array + { + return ['meta' => $this->meta]; + } +} diff --git a/app-modules/integration-github/src/Retrospective/Slides/GithubRepoSlide.php b/app-modules/integration-github/src/Retrospective/Slides/GithubRepoSlide.php new file mode 100644 index 00000000..29cd891a --- /dev/null +++ b/app-modules/integration-github/src/Retrospective/Slides/GithubRepoSlide.php @@ -0,0 +1,35 @@ + $repo + */ + public function __construct( + private array $repo, + private int $index, + ) {} + + public function kind(): string + { + return 'github.repos'; + } + + /** + * @return array{repo: array, index: int} + */ + public function toArray(): array + { + return ['repo' => $this->repo, 'index' => $this->index]; + } +} diff --git a/app-modules/integration-github/tests/Feature/Retrospective/GithubSourceTest.php b/app-modules/integration-github/tests/Feature/Retrospective/GithubSourceTest.php new file mode 100644 index 00000000..5d81d9f3 --- /dev/null +++ b/app-modules/integration-github/tests/Feature/Retrospective/GithubSourceTest.php @@ -0,0 +1,246 @@ +since = CarbonImmutable::parse('2026-06-01 00:00:00'); + $this->until = CarbonImmutable::parse('2026-06-07 23:59:59'); + $this->collect = fn (bool $hideBots = true): SourceResult => new GithubSource()->collect( + Period::of($this->since, $this->until), + new SourceFilters(hideBots: $hideBots), + ); +}); + +/** + * @param array $attributes + */ +function ghContribution(array $attributes): GithubContribution +{ + return GithubContribution::factory()->create($attributes); +} + +/** + * @return array + */ +function ghSlide(SourceResult $result, string $kind): array +{ + foreach ($result->slides as $slide) { + if ($slide->kind() === $kind) { + return $slide->toArray(); + } + } + + return []; +} + +/** + * @return array + */ +function ghMeta(SourceResult $result): array +{ + return ghSlide($result, 'github.panorama')['meta'] ?? []; +} + +/** + * @return list> + */ +function ghPeople(SourceResult $result): array +{ + return ghSlide($result, 'github.core')['people'] ?? []; +} + +/** + * @return list> + */ +function ghRepos(SourceResult $result): array +{ + return collect($result->slides) + ->filter(fn ($slide): bool => $slide->kind() === 'github.repos') + ->map(fn ($slide): array => $slide->toArray()['repo']) + ->values() + ->all(); +} + +/** + * @return list> + */ +function ghHighlights(SourceResult $result): array +{ + return ghSlide($result, 'github.highlights')['highlights'] ?? []; +} + +it('identifica-se como a fonte github', function (): void { + expect(new GithubSource()->key())->toBe('github'); +}); + +it('agrega contribuições por pessoa com contagem por tipo e total, ordenado desc', function (): void { + ghContribution(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + ghContribution(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-03']); + ghContribution(['actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-03']); + + $result = ($this->collect)(); + $meta = ghMeta($result); + $people = ghPeople($result); + + expect($meta['people'])->toBe(2) + ->and($meta['total'])->toBe(3) + ->and($people[0]['login'])->toBe('maria') + ->and($people[0]['total'])->toBe(2) + ->and($people[0]['prs'])->toBe(1) + ->and($people[0]['issues'])->toBe(1) + ->and($people[0]['avatar'])->toContain('42'); +}); + +it('exclui bots do ranking', function (): void { + ghContribution(['actor_login' => 'dependabot[bot]', 'type' => ContributionType::Pr, 'external_ref' => 'pr:9', 'occurred_at' => '2026-06-02', 'metadata' => ['is_bot' => true, 'state' => 'open', 'merged' => false]]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + + $result = ($this->collect)(); + + expect(ghMeta($result)['people'])->toBe(1) + ->and(ghPeople($result)[0]['login'])->toBe('maria'); +}); + +it('mantém bots quando hideBots é falso', function (): void { + ghContribution(['actor_login' => 'dependabot[bot]', 'type' => ContributionType::Pr, 'external_ref' => 'pr:9', 'occurred_at' => '2026-06-02', 'metadata' => ['is_bot' => true, 'state' => 'open', 'merged' => false]]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + + expect(ghMeta(($this->collect)(false))['people'])->toBe(2); +}); + +it('inclui PRs fechados sem merge no total, distinguindo por desfecho', function (): void { + ghContribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => false]]); + ghContribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => true]]); + ghContribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); + + $result = ($this->collect)(); + $meta = ghMeta($result); + $person = collect(ghPeople($result))->firstWhere('login', 'a'); + + expect($meta['prs'])->toBe(3) + ->and($meta['prs_merged'])->toBe(1) + ->and($meta['prs_unmerged'])->toBe(1) + ->and($person['prs'])->toBe(3) + ->and($person['prs_merged'])->toBe(1) + ->and($person['prs_unmerged'])->toBe(1) + ->and($person['total'])->toBe(3); +}); + +it('conta comentários de issue e de review separadamente', function (): void { + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Comment, 'external_ref' => 'comment:1', 'occurred_at' => '2026-06-02']); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::ReviewComment, 'external_ref' => 'review_comment:1', 'occurred_at' => '2026-06-02']); + + $result = ($this->collect)(); + $maria = collect(ghPeople($result))->firstWhere('login', 'maria'); + + expect(ghMeta($result)['comments'])->toBe(1) + ->and(ghMeta($result)['review_comments'])->toBe(1) + ->and($maria['comments'])->toBe(1) + ->and($maria['review_comments'])->toBe(1) + ->and($maria['total'])->toBe(2); +}); + +it('respeita a janela de período pelo occurred_at', function (): void { + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-05-30']); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:2', 'occurred_at' => '2026-06-03']); + + $meta = ghMeta(($this->collect)()); + + expect($meta['total'])->toBe(1) + ->and($meta['issues'])->toBe(1); +}); + +it('soma additions/deletions/changed_files de PRs em meta e por pessoa', function (): void { + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'additions' => 100, 'deletions' => 20, 'changed_files' => 5]]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 30, 'deletions' => 4, 'changed_files' => 2]]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); + + $result = ($this->collect)(); + $maria = collect(ghPeople($result))->firstWhere('login', 'maria'); + + expect(ghMeta($result)['additions'])->toBe(130) + ->and(ghMeta($result)['deletions'])->toBe(24) + ->and(ghMeta($result)['changed_files'])->toBe(7) + ->and($maria['additions'])->toBe(130) + ->and($maria['deletions'])->toBe(24); +}); + +it('expõe refs de PR e issue por pessoa para os chips de atividade', function (): void { + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:290', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 'feat: x', 'url' => 'https://github.com/he4rt/heartdevs.com/pull/290']]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:12', 'occurred_at' => '2026-06-03', 'metadata' => ['title' => 'bug: y', 'url' => 'https://github.com/he4rt/heartdevs.com/issues/12']]); + + $maria = collect(ghPeople(($this->collect)()))->firstWhere('login', 'maria'); + + expect($maria['pr_refs'])->toHaveCount(1) + ->and($maria['pr_refs'][0])->toMatchArray(['num' => 290, 'title' => 'feat: x', 'state' => 'merged']) + ->and($maria['pr_refs'][0]['url'])->toContain('/pull/290') + ->and($maria['issue_refs'])->toHaveCount(1) + ->and($maria['issue_refs'][0])->toMatchArray(['num' => 12, 'title' => 'bug: y']); +}); + +it('ordena os refs de PR do mais recente para o mais antigo', function (): void { + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-01', 'metadata' => ['title' => 'antigo', 'url' => 'u1', 'state' => 'merged']]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-05', 'metadata' => ['title' => 'recente', 'url' => 'u2', 'state' => 'open']]); + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-03', 'metadata' => ['title' => 'meio', 'url' => 'u3', 'state' => 'open']]); + + $maria = collect(ghPeople(($this->collect)()))->firstWhere('login', 'maria'); + + expect(array_column($maria['pr_refs'], 'num'))->toBe([2, 3, 1]); +}); + +it('agrupa PRs por repositório e lista destaques por linhas mudadas', function (): void { + ghContribution(['actor_login' => 'maria', 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 'grande', 'url' => 'u1', 'additions' => 500, 'deletions' => 100, 'changed_files' => 10]]); + ghContribution(['actor_login' => 'joao', 'repo' => 'he4rt/bot', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'title' => 'pequeno', 'url' => 'u2', 'additions' => 10, 'deletions' => 2, 'changed_files' => 1]]); + + $result = ($this->collect)(); + $repos = ghRepos($result); + $highlights = ghHighlights($result); + + expect($repos)->toHaveCount(2) + ->and(collect($repos)->firstWhere('full_name', 'he4rt/heartdevs.com')['name'])->toBe('heartdevs.com') + ->and(collect($repos)->firstWhere('full_name', 'he4rt/heartdevs.com')['prs'])->toHaveCount(1) + ->and($highlights[0]['num'])->toBe(1) + ->and($highlights[0]['additions'])->toBe(500) + ->and($highlights[0]['repo'])->toBe('he4rt/heartdevs.com'); +}); + +it('esconde da lista repos sem PR no recorte mas mantém suas contribuições nas stats', function (): void { + ghContribution(['actor_login' => 'maria', 'repo' => 'he4rt/com-pr', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 't', 'url' => 'u', 'additions' => 5, 'deletions' => 1, 'changed_files' => 1]]); + ghContribution(['actor_login' => 'joao', 'repo' => 'he4rt/so-review', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); + + $result = ($this->collect)(); + + expect(collect(ghRepos($result))->pluck('full_name')->all())->toBe(['he4rt/com-pr']) + ->and(ghMeta($result)['reviews'])->toBe(1) + ->and(ghMeta($result)['repos'])->toBe(1); +}); + +it('devolve resultado vazio quando não há contribuições no recorte', function (): void { + expect(($this->collect)()->isEmpty())->toBeTrue(); +}); + +it('monta os chips do cover a partir do meta', function (): void { + ghContribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'additions' => 10, 'deletions' => 5]]); + + $result = ($this->collect)(); + $labels = collect($result->headline->metrics)->map(fn ($metric): string => $metric->label)->all(); + + expect($result->label)->toBe('GitHub') + ->and($labels)->toContain('Pessoas') + ->and($labels)->toContain('PRs') + ->and($labels)->toContain('Linhas'); +}); diff --git a/app-modules/portal/resources/views/community-retrospective.blade.php b/app-modules/portal/resources/views/community-retrospective.blade.php index 4632b897..1d0ed513 100644 --- a/app-modules/portal/resources/views/community-retrospective.blade.php +++ b/app-modules/portal/resources/views/community-retrospective.blade.php @@ -1,37 +1,24 @@ -@php ($noData = count($repoOptions) === 0) +@php ($noData = count($sources) === 0) +{{-- kind -> partial por convenção: "discord.voice_board" => portal::retro.slides.discord.voice-board --}} +@php ($slidePartial = fn (string $kind): string => 'portal::retro.slides.'.str_replace('_', '-', $kind)) @unless ($noData) - + @endunless @if ($noData) @else - - - @if ($byRepo) - @foreach ($data['repos'] as $i => $repo) - + + + @foreach ($sources as $source) + @foreach ($source->slides as $slide) + @include($slidePartial($slide->kind()), $slide->toArray()) @endforeach - @endif - @if ($showHighlights && count($data['highlights'])) - - @endif - @if (count($data['people'])) - - @if (count($data['people']) > 5) - - @endif - @endif - + @endforeach + + @endif diff --git a/app-modules/portal/resources/views/components/retro/controls.blade.php b/app-modules/portal/resources/views/components/retro/controls.blade.php new file mode 100644 index 00000000..8400dbc4 --- /dev/null +++ b/app-modules/portal/resources/views/components/retro/controls.blade.php @@ -0,0 +1,51 @@ +@props(['since', 'until', 'hideBots' => true]) +@php + $toDate = fn ($value): string => $value instanceof \Carbon\CarbonInterface ? $value->format('Y-m-d') : (string) $value; +@endphp +
+
+ +
diff --git a/app-modules/portal/resources/views/components/retro/deck.blade.php b/app-modules/portal/resources/views/components/retro/deck.blade.php index 1ed69fa0..5b2b4aba 100644 --- a/app-modules/portal/resources/views/components/retro/deck.blade.php +++ b/app-modules/portal/resources/views/components/retro/deck.blade.php @@ -77,7 +77,7 @@ class="deck-shell" @endunless diff --git a/app-modules/portal/resources/views/components/retro/filters.blade.php b/app-modules/portal/resources/views/components/retro/filters.blade.php deleted file mode 100644 index 8f78c5ca..00000000 --- a/app-modules/portal/resources/views/components/retro/filters.blade.php +++ /dev/null @@ -1,162 +0,0 @@ -@props ([ - 'repoOptions' => [], - 'repos' => [], - 'types' => [], - 'hideBots' => true, - 'byRepo' => true, - 'showHighlights' => true -]) -@php - $typeOptions = [ - 'pr' => ['PRs', '--t-pr'], - 'review' => ['Reviews', '--t-review'], - 'issue' => ['Issues', '--t-issue'], - 'comment' => ['Comentários', '--t-comment'], - 'review_comment' => ['Coment. de review', '--t-review-comment'], - 'commit' => ['Commits', '--t-commit'], - ]; -@endphp -
-
- -
diff --git a/app-modules/portal/resources/views/components/retro/slides/closing.blade.php b/app-modules/portal/resources/views/components/retro/slides/closing.blade.php index db34d4c2..8c7d66d7 100644 --- a/app-modules/portal/resources/views/components/retro/slides/closing.blade.php +++ b/app-modules/portal/resources/views/components/retro/slides/closing.blade.php @@ -1,29 +1,23 @@ -@props (['meta', 'people', 'period']) +@props(['sources', 'since', 'until']) +@php + $fmt = fn ($d): string => $d instanceof \Carbon\CarbonInterface + ? $d->timezone(config('app.display_timezone'))->format('d/m/Y') + : (string) $d; + $labels = collect($sources)->map(fn ($source): string => $source->label)->implode(', '); +@endphp

Obrigado a quem fez
o coração bater 💜

- {{ $meta['people'] }} pessoas, {{ $meta['total'] }} interações, {{ number_format($meta['additions'], 0, ',', '.') }} linhas. - Toda contribuição manteve a He4rt viva. + Cada PR, cada mensagem, cada call e cada reação manteve a He4rt viva.

- @foreach ($people as $p) - {{ $p['login'] }} + @foreach ($sources as $source) + {{ $source->label }} @endforeach
-

gerado a partir da GitHub API · {{ $period['since'] }} — {{ $period['until'] }}

+

+ gerado a partir de {{ $labels }} · {{ $fmt($since) }} — {{ $fmt($until) }} +

diff --git a/app-modules/portal/resources/views/components/retro/slides/cover.blade.php b/app-modules/portal/resources/views/components/retro/slides/cover.blade.php index f9ee8ea5..109bd62a 100644 --- a/app-modules/portal/resources/views/components/retro/slides/cover.blade.php +++ b/app-modules/portal/resources/views/components/retro/slides/cover.blade.php @@ -1,21 +1,42 @@ -@props (['meta', 'period']) +@props(['sources', 'since', 'until']) +@php + $fmt = fn ($d): string => $d instanceof \Carbon\CarbonInterface + ? $d->timezone(config('app.display_timezone'))->format('d/m/Y') + : (string) $d; +@endphp
He4rt
- RETROSPECTIVA · {{ $period['since'] }} — {{ $period['until'] }} + RETROSPECTIVA · {{ $fmt($since) }} — {{ $fmt($until) }}

Quem fez a He4rt bater

-

Participação da comunidade He4rt nos repositórios públicos, em gente, código e contexto. {{ $meta['people'] }} pessoas, {{ $meta['total'] }} interações, {{ number_format($meta['additions'], 0, ',', '.') }} linhas.

+

+ Participação da comunidade He4rt em cada frente — gente, código, conversa e presença. +

+
+ @foreach ($sources as $source) +
+
{{ $source->label }}
+
+ @foreach ($source->headline->metrics as $metric) + + {{ number_format($metric->value, 0, ',', '.') }} + {{ $metric->label }} + + @endforeach +
+
+ @endforeach +
navegue com
diff --git a/app-modules/portal/resources/views/retro/slides/discord/messages.blade.php b/app-modules/portal/resources/views/retro/slides/discord/messages.blade.php new file mode 100644 index 00000000..595059de --- /dev/null +++ b/app-modules/portal/resources/views/retro/slides/discord/messages.blade.php @@ -0,0 +1,30 @@ +
+
+ O papo +

O que rolou no chat

+

+ {{ number_format($total, 0, ',', '.') }} mensagens trocadas no período. +

+
+ {{ number_format($with_reactions, 0, ',', '.') }} com reação + {{ number_format($pinned, 0, ',', '.') }} fixadas +
+ @if (count($chatters)) +
+ @foreach ($chatters as $i => $chatter) +
+ #{{ $i + 1 }} + {{ $chatter['name'] }} + {{ number_format($chatter['messages'], 0, ',', '.') }} msgs +
+ @endforeach +
+ @endif +
+
diff --git a/app-modules/portal/resources/views/retro/slides/discord/new-members.blade.php b/app-modules/portal/resources/views/retro/slides/discord/new-members.blade.php new file mode 100644 index 00000000..c770d751 --- /dev/null +++ b/app-modules/portal/resources/views/retro/slides/discord/new-members.blade.php @@ -0,0 +1,21 @@ +
+
+ Chegando +

Gente nova na He4rt

+

+ Cada pessoa que entrou é uma nova história começando na comunidade. +

+
+
+
{{ number_format($joins, 0, ',', '.') }}
+
Novos membros
+
+ @if ($boosts > 0) +
+
{{ number_format($boosts, 0, ',', '.') }}
+
Boosts
+
+ @endif +
+
+
diff --git a/app-modules/portal/resources/views/retro/slides/discord/reactions.blade.php b/app-modules/portal/resources/views/retro/slides/discord/reactions.blade.php new file mode 100644 index 00000000..153f2b07 --- /dev/null +++ b/app-modules/portal/resources/views/retro/slides/discord/reactions.blade.php @@ -0,0 +1,19 @@ +
+
+ O feedback +

As reações do período

+

+ {{ number_format($total, 0, ',', '.') }} reações distribuídas nas mensagens. +

+ @if (count($emojis)) +
+ @foreach ($emojis as $emoji) + + {{ $emoji['custom'] ? ':'.$emoji['name'].':' : $emoji['name'] }} + {{ number_format($emoji['count'], 0, ',', '.') }} + + @endforeach +
+ @endif +
+
diff --git a/app-modules/portal/resources/views/retro/slides/discord/top-message.blade.php b/app-modules/portal/resources/views/retro/slides/discord/top-message.blade.php new file mode 100644 index 00000000..0b4e0eac --- /dev/null +++ b/app-modules/portal/resources/views/retro/slides/discord/top-message.blade.php @@ -0,0 +1,19 @@ +
+
+ O momento +

As mensagens mais reagidas

+
+ @foreach ($messages as $message) +
+
{{ $message['content'] }}
+
+ {{ '@'.$message['author'] }} + {{ number_format($message['reactions'], 0, ',', '.') }} reações +
+
+ @endforeach +
+
+
diff --git a/app-modules/portal/resources/views/retro/slides/discord/voice-board.blade.php b/app-modules/portal/resources/views/retro/slides/discord/voice-board.blade.php new file mode 100644 index 00000000..84f3f939 --- /dev/null +++ b/app-modules/portal/resources/views/retro/slides/discord/voice-board.blade.php @@ -0,0 +1,20 @@ +
+
+ As calls +

Quem viveu no voice

+

+ {{ number_format($participants, 0, ',', '.') }} pessoas passaram pelas calls, + somando {{ number_format($xp, 0, ',', '.') }} XP de presença. +

+ @if (count($channels)) +
+ @foreach ($channels as $channel) +
+
{{ number_format($channel['xp'], 0, ',', '.') }}
+
{{ $channel['name'] }}
+
+ @endforeach +
+ @endif +
+
diff --git a/app-modules/portal/resources/views/components/retro/slides/community.blade.php b/app-modules/portal/resources/views/retro/slides/github/community.blade.php similarity index 97% rename from app-modules/portal/resources/views/components/retro/slides/community.blade.php rename to app-modules/portal/resources/views/retro/slides/github/community.blade.php index 1ef8ee2a..996ba762 100644 --- a/app-modules/portal/resources/views/components/retro/slides/community.blade.php +++ b/app-modules/portal/resources/views/retro/slides/github/community.blade.php @@ -1,4 +1,3 @@ -@props (['people']) @php ($tail = array_slice($people, 5))
diff --git a/app-modules/portal/resources/views/components/retro/slides/core.blade.php b/app-modules/portal/resources/views/retro/slides/github/core.blade.php similarity index 96% rename from app-modules/portal/resources/views/components/retro/slides/core.blade.php rename to app-modules/portal/resources/views/retro/slides/github/core.blade.php index 4eac37b6..9f2918a8 100644 --- a/app-modules/portal/resources/views/components/retro/slides/core.blade.php +++ b/app-modules/portal/resources/views/retro/slides/github/core.blade.php @@ -1,4 +1,3 @@ -@props (['people']) @php ($top = array_slice($people, 0, 5))
diff --git a/app-modules/portal/resources/views/components/retro/slides/highlights.blade.php b/app-modules/portal/resources/views/retro/slides/github/highlights.blade.php similarity index 99% rename from app-modules/portal/resources/views/components/retro/slides/highlights.blade.php rename to app-modules/portal/resources/views/retro/slides/github/highlights.blade.php index d80ed214..b58daf06 100644 --- a/app-modules/portal/resources/views/components/retro/slides/highlights.blade.php +++ b/app-modules/portal/resources/views/retro/slides/github/highlights.blade.php @@ -1,4 +1,3 @@ -@props (['highlights']) @php ($stateColor = fn($state) => [ 'merged' => 'var(--st-merged)', 'open' => 'var(--st-open)', 'closed' => 'var(--st-closed)' ][$state ?? ''] ?? 'var(--st-open)')
diff --git a/app-modules/portal/resources/views/components/retro/slides/panorama.blade.php b/app-modules/portal/resources/views/retro/slides/github/panorama.blade.php similarity index 99% rename from app-modules/portal/resources/views/components/retro/slides/panorama.blade.php rename to app-modules/portal/resources/views/retro/slides/github/panorama.blade.php index bb92585b..4ece2827 100644 --- a/app-modules/portal/resources/views/components/retro/slides/panorama.blade.php +++ b/app-modules/portal/resources/views/retro/slides/github/panorama.blade.php @@ -1,4 +1,3 @@ -@props (['meta'])
O panorama diff --git a/app-modules/portal/resources/views/components/retro/slides/repo.blade.php b/app-modules/portal/resources/views/retro/slides/github/repos.blade.php similarity index 98% rename from app-modules/portal/resources/views/components/retro/slides/repo.blade.php rename to app-modules/portal/resources/views/retro/slides/github/repos.blade.php index 9eedb7d4..8b997cc6 100644 --- a/app-modules/portal/resources/views/components/retro/slides/repo.blade.php +++ b/app-modules/portal/resources/views/retro/slides/github/repos.blade.php @@ -1,4 +1,3 @@ -@props (['repo', 'index' => 1])
diff --git a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php index 9fe0df7c..327d4d08 100644 --- a/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php +++ b/app-modules/portal/src/Livewire/CommunityRetrospectivePage.php @@ -6,10 +6,9 @@ use Carbon\CarbonImmutable; use Carbon\CarbonInterface; -use He4rt\IntegrationGithub\Enums\ContributionType; -use He4rt\IntegrationGithub\Models\GithubContribution; -use He4rt\Portal\Retrospective\CommunityRetrospective; -use He4rt\Portal\Retrospective\RetrospectiveFilters; +use He4rt\Community\Retrospective\DTOs\Period; +use He4rt\Community\Retrospective\DTOs\SourceFilters; +use He4rt\Portal\Retrospective\RetrospectiveDeck; use Illuminate\Contracts\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -26,47 +25,16 @@ final class CommunityRetrospectivePage extends Component #[Url] public ?string $until = null; - /** @var list */ - #[Url] - public array $repos = []; - - /** @var list */ - #[Url] - public array $types = []; - - #[Url] - public ?string $outcome = null; - - #[Url] - public ?string $person = null; - #[Url] public bool $hideBots = true; - #[Url] - public string $sort = 'total'; - - #[Url] - public bool $byRepo = true; - - #[Url] - public bool $showHighlights = true; - - public function toggleType(string $type): void - { - $this->types = $this->toggleAware($this->types, $this->allTypes(), $type); - } - - public function toggleRepo(string $repo): void - { - $this->repos = $this->toggleAware($this->repos, $this->allRepos(), $repo); - } - public function setPreset(string $preset): void { $this->since = match ($preset) { 'mes' => CarbonImmutable::now()->subMonth()->toDateString(), - 'tudo' => $this->firstContributionDate(), + // "Tudo" = desde antes da fundação da comunidade; cobre todo o histórico + // sem acoplar o portal à menor data de nenhuma fonte específica. + 'tudo' => CarbonImmutable::create(2_015, 1, 1)->toDateString(), default => CarbonImmutable::now()->startOfWeek(CarbonInterface::MONDAY)->subWeek()->toDateString(), }; @@ -83,78 +51,17 @@ public function render(): View ? CarbonImmutable::parse($this->until)->endOfDay() : CarbonImmutable::now(); - $filters = RetrospectiveFilters::make($since, $until, $this->repos, $this->types, $this->outcome, $this->person, $this->hideBots, $this->sort); - $data = new CommunityRetrospective($filters)->build(); - - $repoOptions = collect($this->allRepos()) - ->mapWithKeys(fn (string $repo): array => [$repo => (string) str($repo)->afterLast('/')]) - ->all(); + $sources = resolve(RetrospectiveDeck::class)->compose( + Period::of($since, $until), + new SourceFilters(hideBots: $this->hideBots), + ); return view('portal::community-retrospective', [ - 'data' => $data, - 'repoOptions' => $repoOptions, - 'stateKey' => md5((string) json_encode([ - $this->since, $this->until, $this->repos, $this->types, $this->outcome, - $this->person, $this->hideBots, $this->sort, $this->byRepo, $this->showHighlights, - ])), + 'sources' => $sources, + 'since' => $since, + 'until' => $until, + 'hideBots' => $this->hideBots, + 'stateKey' => md5((string) json_encode([$this->since, $this->until, $this->hideBots])), ]); } - - /** - * @return list - */ - private function allTypes(): array - { - return array_map(fn (ContributionType $type): string => $type->value, ContributionType::cases()); - } - - /** - * Toggle "ciente de todos": com a lista vazia (= todos), clicar desliga apenas - * aquele item; voltar a ter todos selecionados normaliza de volta para vazio. - * - * @param list $selected - * @param list $all - * @return list - */ - private function toggleAware(array $selected, array $all, string $value): array - { - $current = $selected === [] ? $all : $selected; - - $next = in_array($value, $current, strict: true) - ? array_values(array_diff($current, [$value])) - : array_values(array_unique([...$current, $value])); - - return count($next) === count($all) ? [] : $next; - } - - /** - * Data da contribuição mais antiga dentro do escopo atual (repos - * selecionados), para o preset "tudo" cobrir o histórico real do que está - * sendo apresentado. Sem registros, cai no mesmo default semanal do render(). - */ - private function firstContributionDate(): string - { - $first = GithubContribution::query() - ->when($this->repos !== [], fn ($query) => $query->whereIn('repo', $this->repos)) - ->min('occurred_at'); - - return is_string($first) && $first !== '' - ? CarbonImmutable::parse($first)->toDateString() - : CarbonImmutable::now()->startOfWeek(CarbonInterface::MONDAY)->subWeek()->toDateString(); - } - - /** - * @return list - */ - private function allRepos(): array - { - /** @var list $repos */ - $repos = GithubContribution::query() - ->distinct() - ->orderBy('repo') - ->pluck('repo') - ->all(); - - return $repos; - } } diff --git a/app-modules/portal/src/PortalServiceProvider.php b/app-modules/portal/src/PortalServiceProvider.php index 2bcdc9d3..267cb277 100644 --- a/app-modules/portal/src/PortalServiceProvider.php +++ b/app-modules/portal/src/PortalServiceProvider.php @@ -4,17 +4,28 @@ namespace He4rt\Portal; +use He4rt\Community\Retrospective\Contracts\RetrospectiveSource; use He4rt\Portal\Livewire\CommunityRetrospectivePage; use He4rt\Portal\Livewire\HeroSection; use He4rt\Portal\Livewire\Homepage; use He4rt\Portal\Livewire\SocialLinksPage; +use He4rt\Portal\Retrospective\RetrospectiveDeck; +use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; class PortalServiceProvider extends ServiceProvider { - public function register(): void {} + public function register(): void + { + $this->app->bind(RetrospectiveDeck::class, static function (Application $app): RetrospectiveDeck { + /** @var iterable $sources */ + $sources = $app->tagged('retrospective.source'); + + return new RetrospectiveDeck($sources); + }); + } public function boot(): void { diff --git a/app-modules/portal/src/Retrospective/RetrospectiveDeck.php b/app-modules/portal/src/Retrospective/RetrospectiveDeck.php new file mode 100644 index 00000000..bdf263b9 --- /dev/null +++ b/app-modules/portal/src/Retrospective/RetrospectiveDeck.php @@ -0,0 +1,72 @@ + + */ + private const array ORDER = ['github', 'discord']; + + /** @var list */ + private array $sources; + + /** + * @param iterable $sources + */ + public function __construct(iterable $sources) + { + $this->sources = array_values( + is_array($sources) ? $sources : iterator_to_array($sources, preserve_keys: false), + ); + } + + /** + * @return list + */ + public function compose(Period $period, SourceFilters $filters): array + { + $results = []; + + foreach ($this->sources as $source) { + $result = $source->collect($period, $filters); + + if (!$result->isEmpty()) { + $results[] = $result; + } + } + + usort( + $results, + fn (SourceResult $a, SourceResult $b): int => $this->position($a->key) <=> $this->position($b->key), + ); + + return $results; + } + + private function position(string $key): int + { + $index = array_search($key, self::ORDER, strict: true); + + return $index === false ? count(self::ORDER) : $index; + } +} diff --git a/app-modules/portal/src/Retrospective/RetrospectiveFilters.php b/app-modules/portal/src/Retrospective/RetrospectiveFilters.php deleted file mode 100644 index f8713fa0..00000000 --- a/app-modules/portal/src/Retrospective/RetrospectiveFilters.php +++ /dev/null @@ -1,68 +0,0 @@ - $repos full_names; vazio = todos - * @param list $types - * @param 'merged'|'open'|'closed'|null $outcome - * @param 'total'|'prs'|'lines' $sort - */ - public function __construct( - public CarbonInterface $since, - public CarbonInterface $until, - public array $repos = [], - public array $types = [], - public ?string $outcome = null, - public ?string $person = null, - public bool $hideBots = true, - public string $sort = 'total', - ) {} - - public static function period(CarbonInterface $since, CarbonInterface $until): self - { - return new self($since, $until, types: ContributionType::cases()); - } - - /** - * @param list $repos - * @param list $types - */ - public static function make( - CarbonInterface $since, - CarbonInterface $until, - array $repos = [], - array $types = [], - ?string $outcome = null, - ?string $person = null, - bool $hideBots = true, - string $sort = 'total', - ): self { - $parsedTypes = array_values(array_filter(array_map( - ContributionType::tryFrom(...), - $types, - ))); - - return new self( - since: $since, - until: $until, - repos: array_values(array_filter($repos)), - types: $parsedTypes === [] ? ContributionType::cases() : $parsedTypes, - outcome: in_array($outcome, ['merged', 'open', 'closed'], strict: true) ? $outcome : null, - person: ($person === null || $person === '') ? null : $person, - hideBots: $hideBots, - sort: in_array($sort, ['total', 'prs', 'lines'], strict: true) ? $sort : 'total', - ); - } -} diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php index 38bc9b70..650aea11 100644 --- a/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php +++ b/app-modules/portal/tests/Feature/CommunityRetrospectivePageTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Carbon\CarbonImmutable; +use He4rt\Activity\Message\Models\Message; +use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use He4rt\Portal\Livewire\CommunityRetrospectivePage; @@ -38,6 +40,22 @@ test()->get('/comunidade/retrospectiva')->assertOk(); }); +it('junta GitHub e Discord no mesmo deck', function (): void { + GithubContribution::factory()->create([ + 'actor_login' => 'maria', 'type' => ContributionType::Pr, + 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false], + ]); + + $identity = ExternalIdentity::factory()->create(); + Message::factory()->create(['external_identity_id' => $identity->id, 'sent_at' => '2026-06-02']); + + livewire(CommunityRetrospectivePage::class, ['since' => '2026-06-01', 'until' => '2026-06-07']) + ->assertOk() + ->assertSee('GitHub') + ->assertSee('Discord') + ->assertSee('O que rolou no chat'); +}); + it('inclui e marca contribuidor cujo único PR foi fechado sem merge', function (): void { GithubContribution::factory()->create([ 'actor_login' => 'rejeitada', 'actor_id' => 99, 'type' => ContributionType::Pr, @@ -63,7 +81,7 @@ }); it('mostra o convite pra reunião quando não há nenhuma contribuição', function (): void { - // banco zerado: nenhum repositório/estatística → estado vazio com CTA, sem o deck normal + // banco zerado: nenhuma fonte com dado → estado vazio com CTA, sem o deck normal test()->get('/comunidade/retrospectiva') ->assertOk() ->assertSee('Métricas') @@ -73,25 +91,13 @@ ->assertDontSee('Filtros'); }); -it('filtra por tipo ao alternar um tipo de contribuição', function (): void { - GithubContribution::factory()->create([ - 'actor_login' => 'soreview', 'type' => ContributionType::Review, - 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02', - ]); - - livewire(CommunityRetrospectivePage::class, ['since' => '2026-06-01', 'until' => '2026-06-07']) - ->assertSee('soreview') - ->call('toggleType', 'review') - ->assertDontSee('soreview'); -}); - -it('mantém o estado dos filtros (toggle de bots)', function (): void { +it('mantém o estado do toggle de bots', function (): void { livewire(CommunityRetrospectivePage::class) ->set('hideBots', value: false) ->assertSet('hideBots', value: false); }); -it('preset "tudo" ancora o período na primeira contribuição e traz o histórico inteiro', function (): void { +it('preset "tudo" traz o histórico inteiro', function (): void { $this->travelTo(CarbonImmutable::parse('2026-06-04 10:00:00')); GithubContribution::factory()->create([ @@ -105,27 +111,6 @@ livewire(CommunityRetrospectivePage::class) ->call('setPreset', 'tudo') - ->assertSet('since', '2020-03-30') ->assertSee('pioneira') ->assertSee('recente'); }); - -it('preset "tudo" com repos filtrados ancora na 1ª contribuição daqueles repos', function (): void { - $this->travelTo(CarbonImmutable::parse('2026-06-04 10:00:00')); - - GithubContribution::factory()->create([ - 'repo' => 'he4rt/antigo', 'actor_login' => 'veterano', 'actor_id' => 1, 'type' => ContributionType::Commit, - 'external_ref' => 'commit:old', 'occurred_at' => '2018-01-01 00:00:00', - ]); - GithubContribution::factory()->create([ - 'repo' => 'he4rt/4noobs', 'actor_login' => 'pioneira', 'actor_id' => 2, 'type' => ContributionType::Commit, - 'external_ref' => 'commit:abc', 'occurred_at' => '2020-03-30 02:13:45', - ]); - - livewire(CommunityRetrospectivePage::class) - ->set('repos', ['he4rt/4noobs']) - ->call('setPreset', 'tudo') - ->assertSet('since', '2020-03-30') - ->assertSee('pioneira') - ->assertDontSee('veterano'); -}); diff --git a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php b/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php deleted file mode 100644 index 83018a4c..00000000 --- a/app-modules/portal/tests/Feature/CommunityRetrospectiveTest.php +++ /dev/null @@ -1,261 +0,0 @@ -since = CarbonImmutable::parse('2026-06-01 00:00:00'); - $this->until = CarbonImmutable::parse('2026-06-07 23:59:59'); - $this->build = fn (?RetrospectiveFilters $filters = null): array => new CommunityRetrospective( - $filters ?? RetrospectiveFilters::period($this->since, $this->until), - )->build(); -}); - -/** - * @param array $attributes - */ -function contribution(array $attributes): GithubContribution -{ - return GithubContribution::factory()->create($attributes); -} - -it('agrega contribuições por pessoa com contagem por tipo e total, ordenado desc', function (): void { - contribution(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - contribution(['actor_login' => 'maria', 'actor_id' => 42, 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-06-03']); - contribution(['actor_login' => 'joao', 'actor_id' => 7, 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-03']); - - $data = ($this->build)(); - - expect($data['meta']['people'])->toBe(2) - ->and($data['meta']['total'])->toBe(3) - ->and($data['people'][0]['login'])->toBe('maria') - ->and($data['people'][0]['total'])->toBe(2) - ->and($data['people'][0]['prs'])->toBe(1) - ->and($data['people'][0]['issues'])->toBe(1) - ->and($data['people'][0]['avatar'])->toContain('42'); -}); - -it('exclui bots do ranking', function (): void { - contribution(['actor_login' => 'dependabot[bot]', 'type' => ContributionType::Pr, 'external_ref' => 'pr:9', 'occurred_at' => '2026-06-02', 'metadata' => ['is_bot' => true, 'state' => 'open', 'merged' => false]]); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - - $data = ($this->build)(); - - expect($data['meta']['people'])->toBe(1) - ->and($data['people'][0]['login'])->toBe('maria'); -}); - -it('inclui PRs fechados sem merge no total, distinguindo por desfecho', function (): void { - contribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => false]]); - contribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'closed', 'merged' => true]]); - contribution(['actor_login' => 'a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - - $data = ($this->build)(); - - $person = collect($data['people'])->firstWhere('login', 'a'); - - expect($data['meta']['prs'])->toBe(3) - ->and($data['meta']['prs_merged'])->toBe(1) - ->and($data['meta']['prs_unmerged'])->toBe(1) - ->and($person['prs'])->toBe(3) - ->and($person['prs_merged'])->toBe(1) - ->and($person['prs_unmerged'])->toBe(1) - ->and($person['total'])->toBe(3); -}); - -it('conta comentários de issue e de review separadamente', function (): void { - contribution(['actor_login' => 'maria', 'type' => ContributionType::Comment, 'external_ref' => 'comment:1', 'occurred_at' => '2026-06-02']); - contribution(['actor_login' => 'maria', 'type' => ContributionType::ReviewComment, 'external_ref' => 'review_comment:1', 'occurred_at' => '2026-06-02']); - - $data = ($this->build)(); - $maria = collect($data['people'])->firstWhere('login', 'maria'); - - expect($data['meta']['comments'])->toBe(1) - ->and($data['meta']['review_comments'])->toBe(1) - ->and($maria['comments'])->toBe(1) - ->and($maria['review_comments'])->toBe(1) - ->and($maria['total'])->toBe(2); -}); - -it('respeita a janela de período pelo occurred_at', function (): void { - contribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:1', 'occurred_at' => '2026-05-30']); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:2', 'occurred_at' => '2026-06-03']); - - $data = ($this->build)(); - - expect($data['meta']['total'])->toBe(1) - ->and($data['meta']['issues'])->toBe(1); -}); - -it('soma additions/deletions/changed_files de PRs em meta e por pessoa', function (): void { - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'additions' => 100, 'deletions' => 20, 'changed_files' => 5]]); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 30, 'deletions' => 4, 'changed_files' => 2]]); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); - - $data = ($this->build)(); - $maria = collect($data['people'])->firstWhere('login', 'maria'); - - expect($data['meta']['additions'])->toBe(130) - ->and($data['meta']['deletions'])->toBe(24) - ->and($data['meta']['changed_files'])->toBe(7) - ->and($maria['additions'])->toBe(130) - ->and($maria['deletions'])->toBe(24); -}); - -it('expõe refs de PR e issue por pessoa para os chips de atividade', function (): void { - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:290', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 'feat: x', 'url' => 'https://github.com/he4rt/heartdevs.com/pull/290']]); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Issue, 'external_ref' => 'issue:12', 'occurred_at' => '2026-06-03', 'metadata' => ['title' => 'bug: y', 'url' => 'https://github.com/he4rt/heartdevs.com/issues/12']]); - - $data = ($this->build)(); - $maria = collect($data['people'])->firstWhere('login', 'maria'); - - expect($maria['pr_refs'])->toHaveCount(1) - ->and($maria['pr_refs'][0])->toMatchArray(['num' => 290, 'title' => 'feat: x', 'state' => 'merged']) - ->and($maria['pr_refs'][0]['url'])->toContain('/pull/290') - ->and($maria['issue_refs'])->toHaveCount(1) - ->and($maria['issue_refs'][0])->toMatchArray(['num' => 12, 'title' => 'bug: y']); -}); - -it('ordena os refs de PR do mais recente para o mais antigo (para o "últimos N")', function (): void { - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-01', 'metadata' => ['title' => 'antigo', 'url' => 'u1', 'state' => 'merged']]); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-05', 'metadata' => ['title' => 'recente', 'url' => 'u2', 'state' => 'open']]); - contribution(['actor_login' => 'maria', 'type' => ContributionType::Pr, 'external_ref' => 'pr:3', 'occurred_at' => '2026-06-03', 'metadata' => ['title' => 'meio', 'url' => 'u3', 'state' => 'open']]); - - $maria = collect(($this->build)()['people'])->firstWhere('login', 'maria'); - - expect(array_column($maria['pr_refs'], 'num'))->toBe([2, 3, 1]); -}); - -it('na view, mostra só os 3 primeiros refs e um chip "mais X…"', function (): void { - $person = [ - 'pr_refs' => array_map( - fn (int $n): array => ['num' => $n, 'title' => 'pr '.$n, 'url' => null, 'state' => 'open'], - [10, 9, 8, 7, 6], - ), - 'issue_refs' => [], - 'reviews' => 0, - 'comments' => 0, - 'review_comments' => 0, - 'commits' => 0, - ]; - - $html = Blade::render('', ['person' => $person]); - - expect($html)->toContain('#10') - ->and($html)->toContain('#9') - ->and($html)->toContain('#8') - ->and($html)->not->toContain('#7') - ->and($html)->not->toContain('#6') - ->and($html)->toContain('mais 2…'); -}); - -it('o card compacto da comunidade mostra só ícone + somatória dos tipos com contribuição', function (): void { - $person = [ - 'pr_refs' => [ - ['num' => 1, 'title' => 'feat: a', 'url' => null, 'state' => 'open'], - ['num' => 2, 'title' => 'feat: b', 'url' => null, 'state' => 'merged'], - ], - 'issue_refs' => [], - 'reviews' => 5, - 'comments' => 0, - 'review_comments' => 3, - 'commits' => 0, - ]; - - $html = Blade::render('', ['person' => $person]); - - // 3 tipos com n>0: PR(2), review(5), review_comment(3) → 3 pills, sem zeros - expect(mb_substr_count($html, 'class="cstat '))->toBe(3) - ->and($html)->toContain('2') - ->and($html)->toContain('5') - ->and($html)->toContain('3') - ->and($html)->not->toContain('feat:'); // sem títulos de PR -}); - -it('agrupa PRs por repositório e lista destaques por linhas mudadas', function (): void { - contribution(['actor_login' => 'maria', 'repo' => 'he4rt/heartdevs.com', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 'grande', 'url' => 'u1', 'additions' => 500, 'deletions' => 100, 'changed_files' => 10]]); - contribution(['actor_login' => 'joao', 'repo' => 'he4rt/bot', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'title' => 'pequeno', 'url' => 'u2', 'additions' => 10, 'deletions' => 2, 'changed_files' => 1]]); - - $data = ($this->build)(); - - expect($data['repos'])->toHaveCount(2) - ->and(collect($data['repos'])->firstWhere('full_name', 'he4rt/heartdevs.com')['name'])->toBe('heartdevs.com') - ->and(collect($data['repos'])->firstWhere('full_name', 'he4rt/heartdevs.com')['prs'])->toHaveCount(1) - ->and($data['highlights'][0]['num'])->toBe(1) - ->and($data['highlights'][0]['additions'])->toBe(500) - ->and($data['highlights'][0]['repo'])->toBe('he4rt/heartdevs.com'); -}); - -it('esconde da lista repos sem PR no recorte mas mantém suas contribuições nas stats', function (): void { - contribution(['actor_login' => 'maria', 'repo' => 'he4rt/com-pr', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true, 'title' => 't', 'url' => 'u', 'additions' => 5, 'deletions' => 1, 'changed_files' => 1]]); - contribution(['actor_login' => 'joao', 'repo' => 'he4rt/so-review', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); - - $data = ($this->build)(); - - expect(collect($data['repos'])->pluck('full_name')->all())->toBe(['he4rt/com-pr']) - ->and($data['meta']['reviews'])->toBe(1) // review segue contando nas stats gerais - ->and($data['meta']['repos'])->toBe(1); // panorama conta só repos com PR -}); - -it('o panorama pluraliza repositórios conforme a contagem', function (int $repos, string $expected): void { - $meta = ['people' => 1, 'total' => 1, 'prs' => 1, 'prs_merged' => 1, 'prs_unmerged' => 0, 'reviews' => 0, 'issues' => 0, 'comments' => 0, 'review_comments' => 0, 'commits' => 0, 'additions' => 1, 'deletions' => 0, 'changed_files' => 1, 'repos' => $repos]; - - $html = Blade::render('', ['meta' => $meta]); - - expect($html)->toContain($expected); -})->with([ - 'singular' => [1, 'em 1 repositório.'], - 'plural' => [3, 'em 3 repositórios.'], -]); - -it('aplica filtros de tipo, repo, desfecho e pessoa', function (): void { - contribution(['actor_login' => 'maria', 'repo' => 'he4rt/a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'merged', 'merged' => true]]); - contribution(['actor_login' => 'maria', 'repo' => 'he4rt/a', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false]]); - contribution(['actor_login' => 'joao', 'repo' => 'he4rt/b', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); - - $filters = RetrospectiveFilters::make($this->since, $this->until, repos: ['he4rt/a'], types: ['pr'], outcome: 'merged', person: 'maria'); - $data = ($this->build)($filters); - - expect($data['meta']['total'])->toBe(1) - ->and($data['meta']['people'])->toBe(1) - ->and($data['people'][0]['login'])->toBe('maria') - ->and($data['people'][0]['prs'])->toBe(1); -}); - -it('keeps only truly-closed PRs under outcome=closed: !(A ∧ ¬B) ≡ ¬A ∨ B', function (string $state, bool $merged, bool $shouldKeep): void { - contribution([ - 'actor_login' => 'a', - 'type' => ContributionType::Pr, - 'external_ref' => 'pr:1', - 'occurred_at' => '2026-06-02', - 'metadata' => ['state' => $state, 'merged' => $merged], - ]); - - $filters = RetrospectiveFilters::make($this->since, $this->until, outcome: 'closed'); - $data = ($this->build)($filters); - - // meta.prs counts PRs surviving reject(filteredOutByOutcome). - expect($data['meta']['prs'])->toBe($shouldKeep ? 1 : 0); -})->with([ - // state, merged, keep? - 'closed + unmerged → kept (the only true "closed")' => ['closed', false, true], - 'closed + merged → dropped (it is a merge, not a pure close)' => ['closed', true, false], - 'open + unmerged → dropped (state !== closed)' => ['open', false, false], - 'open + merged → dropped (state !== closed)' => ['open', true, false], -]); - -it('ordena o ranking por linhas quando sort=lines', function (): void { - contribution(['actor_login' => 'poucas', 'type' => ContributionType::Pr, 'external_ref' => 'pr:1', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 10, 'deletions' => 0]]); - contribution(['actor_login' => 'muitas', 'type' => ContributionType::Pr, 'external_ref' => 'pr:2', 'occurred_at' => '2026-06-02', 'metadata' => ['state' => 'open', 'merged' => false, 'additions' => 900, 'deletions' => 50]]); - - $filters = RetrospectiveFilters::make($this->since, $this->until, sort: 'lines'); - $data = ($this->build)($filters); - - expect($data['people'][0]['login'])->toBe('muitas'); -}); diff --git a/app-modules/portal/tests/Feature/RetrospectiveDeckTest.php b/app-modules/portal/tests/Feature/RetrospectiveDeckTest.php new file mode 100644 index 00000000..2c1464b3 --- /dev/null +++ b/app-modules/portal/tests/Feature/RetrospectiveDeckTest.php @@ -0,0 +1,73 @@ +sourceKey; + } + + public function collect(Period $period, SourceFilters $filters): SourceResult + { + return new SourceResult( + $this->sourceKey, + $this->label, + new HeadlineMetrics($this->empty ? [] : [new Metric('Itens', 1)]), + ); + } + }; +} + +function composeWith(RetrospectiveSource ...$sources): array +{ + $period = Period::of(CarbonImmutable::parse('2026-06-01'), CarbonImmutable::parse('2026-06-07')); + + return new RetrospectiveDeck($sources)->compose($period, new SourceFilters()); +} + +it('ordena os blocos como github, depois discord, independentemente do registro', function (): void { + $results = composeWith( + fakeSource('discord', 'Discord', empty: false), + fakeSource('github', 'GitHub', empty: false), + ); + + expect(array_map(fn (SourceResult $result): string => $result->key, $results))->toBe(['github', 'discord']); +}); + +it('joga fontes desconhecidas para o fim', function (): void { + $results = composeWith( + fakeSource('twitch', 'Twitch', empty: false), + fakeSource('github', 'GitHub', empty: false), + ); + + expect(array_map(fn (SourceResult $result): string => $result->key, $results))->toBe(['github', 'twitch']); +}); + +it('descarta fontes sem dado no recorte', function (): void { + $results = composeWith( + fakeSource('github', 'GitHub', empty: false), + fakeSource('discord', 'Discord', empty: true), + ); + + expect($results)->toHaveCount(1) + ->and($results[0]->key)->toBe('github'); +}); diff --git a/app-modules/portal/tests/Feature/RetrospectiveFiltersTest.php b/app-modules/portal/tests/Feature/RetrospectiveFiltersTest.php deleted file mode 100644 index b9166c00..00000000 --- a/app-modules/portal/tests/Feature/RetrospectiveFiltersTest.php +++ /dev/null @@ -1,35 +0,0 @@ -repos)->toBeEmpty() - ->and($filters->types)->toBe(ContributionType::cases()) - ->and($filters->outcome)->toBeNull() - ->and($filters->person)->toBeNull() - ->and($filters->hideBots)->toBeTrue() - ->and($filters->sort)->toBe('total'); -}); - -it('reconhece um tipo selecionado e ignora valores inválidos', function (): void { - $filters = RetrospectiveFilters::make( - CarbonImmutable::parse('2026-06-01'), - CarbonImmutable::parse('2026-06-07'), - types: ['pr', 'invalido', 'review'], - outcome: 'banana', - sort: 'lines', - ); - - expect($filters->types)->toBe([ContributionType::Pr, ContributionType::Review]) - ->and($filters->outcome)->toBeNull() - ->and($filters->sort)->toBe('lines'); -}); diff --git a/app-modules/portal/tests/Feature/RetrospectiveSlidesTest.php b/app-modules/portal/tests/Feature/RetrospectiveSlidesTest.php new file mode 100644 index 00000000..e503f893 --- /dev/null +++ b/app-modules/portal/tests/Feature/RetrospectiveSlidesTest.php @@ -0,0 +1,62 @@ + array_map( + fn (int $n): array => ['num' => $n, 'title' => 'pr '.$n, 'url' => null, 'state' => 'open'], + [10, 9, 8, 7, 6], + ), + 'issue_refs' => [], + 'reviews' => 0, + 'comments' => 0, + 'review_comments' => 0, + 'commits' => 0, + ]; + + $html = Blade::render('', ['person' => $person]); + + expect($html)->toContain('#10') + ->and($html)->toContain('#9') + ->and($html)->toContain('#8') + ->and($html)->not->toContain('#7') + ->and($html)->not->toContain('#6') + ->and($html)->toContain('mais 2…'); +}); + +it('o card compacto da comunidade mostra só ícone + somatória dos tipos com contribuição', function (): void { + $person = [ + 'pr_refs' => [ + ['num' => 1, 'title' => 'feat: a', 'url' => null, 'state' => 'open'], + ['num' => 2, 'title' => 'feat: b', 'url' => null, 'state' => 'merged'], + ], + 'issue_refs' => [], + 'reviews' => 5, + 'comments' => 0, + 'review_comments' => 3, + 'commits' => 0, + ]; + + $html = Blade::render('', ['person' => $person]); + + // 3 tipos com n>0: PR(2), review(5), review_comment(3) → 3 pills, sem zeros + expect(mb_substr_count($html, 'class="cstat '))->toBe(3) + ->and($html)->toContain('2') + ->and($html)->toContain('5') + ->and($html)->toContain('3') + ->and($html)->not->toContain('feat:'); +}); + +it('o panorama do github pluraliza repositórios conforme a contagem', function (int $repos, string $expected): void { + $meta = ['people' => 1, 'total' => 1, 'prs' => 1, 'prs_merged' => 1, 'prs_unmerged' => 0, 'reviews' => 0, 'issues' => 0, 'comments' => 0, 'review_comments' => 0, 'commits' => 0, 'additions' => 1, 'deletions' => 0, 'changed_files' => 1, 'repos' => $repos]; + + $html = view('portal::retro.slides.github.panorama', ['meta' => $meta])->render(); + + expect($html)->toContain($expected); +})->with([ + 'singular' => [1, 'em 1 repositório.'], + 'plural' => [3, 'em 3 repositórios.'], +]);