diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100755 new mode 100644 diff --git a/app-modules/activity/config/activity-tracking.php b/app-modules/activity/config/activity-tracking.php new file mode 100644 index 000000000..bc7821718 --- /dev/null +++ b/app-modules/activity/config/activity-tracking.php @@ -0,0 +1,34 @@ + [ + 'article' => ['tier' => 'high', 'coins_min' => 100, 'coins_max' => 300], + 'pr_merged' => ['tier' => 'high', 'coins_min' => 80, 'coins_max' => 250], + 'mentoring' => ['tier' => 'high', 'coins_min' => 50, 'coins_max' => 150], + 'squad_project' => ['tier' => 'high', 'coins_min' => 200, 'coins_max' => 500], + 'referral' => ['tier' => 'medium', 'coins_min' => 20, 'coins_max' => 30], + 'peer_review' => ['tier' => 'medium', 'coins_min' => 10, 'coins_max' => 25], + 'call_participation' => ['tier' => 'medium', 'coins_min' => 15, 'coins_max' => 30], + 'forum_debate' => ['tier' => 'medium', 'coins_min' => 10, 'coins_max' => 20], + 'content_share' => ['tier' => 'low', 'coins_min' => 5, 'coins_max' => 10], + 'engagement' => ['tier' => 'low', 'coins_min' => 1, 'coins_max' => 3], + 'repo_star' => ['tier' => 'low', 'coins_min' => 2, 'coins_max' => 2], + 'message' => ['tier' => 'low', 'coins_min' => 1, 'coins_max' => 2], + 'voice' => ['tier' => 'low', 'coins_min' => 1, 'coins_max' => 3], + ], + + 'auto_approve_tiers' => ['low', 'medium'], + + 'engagement_formula' => [ + 'reactions_multiplier' => 0.5, + 'reactions_cap' => 25, + 'bookmarks_multiplier' => 1.0, + 'bookmarks_cap' => 15, + 'comments_multiplier' => 2.0, + 'comments_cap' => 30, + ], + + 'xp_multiplier' => 1, +]; diff --git a/app-modules/activity/database/factories/InteractionFactory.php b/app-modules/activity/database/factories/InteractionFactory.php index b0644c590..e31dd9b18 100644 --- a/app-modules/activity/database/factories/InteractionFactory.php +++ b/app-modules/activity/database/factories/InteractionFactory.php @@ -4,12 +4,12 @@ namespace He4rt\Activity\Database\Factories; +use He4rt\Activity\Tracking\Enums\ActivityStatus; use He4rt\Activity\Tracking\Enums\ActivityType; -use He4rt\Activity\Tracking\Enums\AttributionMethod; +use He4rt\Activity\Tracking\Enums\ValueTier; use He4rt\Activity\Tracking\Models\Interaction; +use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -19,58 +19,49 @@ final class InteractionFactory extends Factory { protected $model = Interaction::class; - /** - * `fake()->unique()` devolve um gerador novo a cada chamada, então não garante - * nada entre duas interações. O external_ref tem índice único: o contador é o - * que impede a colisão. - */ - private static int $sequence = 0; - public function definition(): array { - $identity = ExternalIdentity::factory() - ->for(User::factory(), 'model') - ->state(['provider' => IdentityProvider::DevTo]); - return [ - 'external_identity_id' => $identity, - 'user_id' => fn (array $attributes): string => ExternalIdentity::query() - ->findOrFail($attributes['external_identity_id']) - ->model_id, + 'character_id' => Character::factory(), 'type' => ActivityType::Article, - 'attributed_by' => AttributionMethod::Owned, - 'external_ref' => fn (): string => 'devto:article:'.$this->nextSequence(), + 'provider' => IdentityProvider::DevTo, + 'value_tier' => ValueTier::High, + 'coins_min' => 100, + 'coins_max' => 300, + 'status' => ActivityStatus::Pending, 'occurred_at' => now(), ]; } - public function forIdentity(ExternalIdentity $identity): self + public function autoApproved(): self { return $this->state([ - 'external_identity_id' => $identity->id, - 'user_id' => $identity->model_id, + 'status' => ActivityStatus::AutoApproved, + 'type' => ActivityType::Engagement, + 'value_tier' => ValueTier::Low, + 'coins_min' => 1, + 'coins_max' => 3, ]); } - public function ofType(ActivityType $type): self + public function approved(): self { return $this->state([ - 'type' => $type, - // Closure, não valor: um state literal é avaliado uma vez e repetiria - // o mesmo ref em toda a leva de um ->count(). - 'external_ref' => fn (): string => 'github:'.$type->value.':he4rt/heartdevs.com:'.$this->nextSequence(), + 'status' => ActivityStatus::Approved, + 'reviewed_at' => now(), ]); } - public function hidden(): self + public function withEngagement(int $reactions = 0, int $comments = 0, int $bookmarks = 0): self { return $this->state([ - 'hidden_at' => now(), + 'metadata' => [ + 'engagement_snapshot' => [ + 'reactions' => $reactions, + 'comments' => $comments, + 'bookmarks' => $bookmarks, + ], + ], ]); } - - private function nextSequence(): int - { - return ++self::$sequence; - } } diff --git a/app-modules/activity/database/factories/MessageFactory.php b/app-modules/activity/database/factories/MessageFactory.php index 4e704a3fb..dd3a9f722 100644 --- a/app-modules/activity/database/factories/MessageFactory.php +++ b/app-modules/activity/database/factories/MessageFactory.php @@ -15,18 +15,12 @@ final class MessageFactory extends Factory { protected $model = Message::class; - /** - * `provider_message_id` tem índice único e dez mil valores possíveis não bastam: - * a colisão aparecia como flake em qualquer teste que criasse mensagens demais. - */ - private static int $sequence = 0; - public function definition(): array { return [ 'id' => fake()->uuid(), 'external_identity_id' => ExternalIdentity::factory(), - 'provider_message_id' => ++self::$sequence, + 'provider_message_id' => fake()->randomNumber(4), 'channel_id' => fake()->randomNumber(4), 'content' => fake()->sentence(), 'sent_at' => now(), diff --git a/app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php b/app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php index dbb3e403d..f230ce374 100644 --- a/app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php +++ b/app-modules/activity/database/migrations/2026_03_18_000000_create_interactions_table.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use He4rt\Activity\Tracking\Enums\ActivityStatus; use He4rt\Activity\Tracking\Enums\ActivityType; +use He4rt\Activity\Tracking\Enums\ValueTier; use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; @@ -17,12 +19,12 @@ public function up(): void $table->foreignUuid('tenant_id')->constrained('tenants'); $table->string('type')->comment(ActivityType::stringifyCases()); $table->string('provider'); - $table->string('value_tier')->comment('high, medium, low'); + $table->string('value_tier')->comment(ValueTier::stringifyCases()); $table->integer('coins_min'); $table->integer('coins_max'); $table->integer('coins_awarded')->nullable(); $table->integer('xp_awarded')->nullable(); - $table->string('status')->default('pending')->comment('pending, auto_approved, in_review, approved, rejected'); + $table->string('status')->default('pending')->comment(ActivityStatus::stringifyCases()); $table->nullableUuidMorphs('source'); $table->string('external_ref')->nullable(); $table->jsonb('metadata')->nullable(); diff --git a/app-modules/activity/src/ActivityServiceProvider.php b/app-modules/activity/src/ActivityServiceProvider.php index 44c1c9dc0..58788b697 100644 --- a/app-modules/activity/src/ActivityServiceProvider.php +++ b/app-modules/activity/src/ActivityServiceProvider.php @@ -11,7 +11,6 @@ use He4rt\Activity\Timeline\Listeners\PublishModerationToTimeline; use He4rt\Activity\Timeline\Listeners\ReassignTimelineOwnership; use He4rt\Activity\Timeline\Timeline; -use He4rt\Activity\Tracking\Listeners\ReassignInteractionOwnership; use He4rt\Activity\Tracking\Listeners\TrackContentContribution; use He4rt\Activity\Voice\Models\Voice; use He4rt\Contents\Articles\Events\ArticlePublished; @@ -25,6 +24,8 @@ class ActivityServiceProvider extends ServiceProvider { 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'); } @@ -43,7 +44,6 @@ public function boot(): void Event::listen(ActionExecuted::class, [PublishModerationToTimeline::class, 'handle']); Event::listen(AccountsMerged::class, [ReassignTimelineOwnership::class, 'handle']); - Event::listen(AccountsMerged::class, [ReassignInteractionOwnership::class, 'handle']); Event::listen(ArticlePublished::class, [TrackContentContribution::class, 'handle']); } } diff --git a/app-modules/activity/src/Retrospective/DiscordSource.php b/app-modules/activity/src/Retrospective/DiscordSource.php index 254588ac3..df4a26add 100644 --- a/app-modules/activity/src/Retrospective/DiscordSource.php +++ b/app-modules/activity/src/Retrospective/DiscordSource.php @@ -18,19 +18,15 @@ use He4rt\Activity\Retrospective\Slides\VoiceBoardSlide; use He4rt\Activity\Voice\Models\Voice; use He4rt\Community\Retrospective\Contracts\CuratableSource; -use He4rt\Community\Retrospective\Contracts\MeasuresPerson; use He4rt\Community\Retrospective\Contracts\RetrospectiveSource; use He4rt\Community\Retrospective\Contracts\Slide; use He4rt\Community\Retrospective\DTOs\ExclusionCandidate; use He4rt\Community\Retrospective\DTOs\HeadlineMetrics; use He4rt\Community\Retrospective\DTOs\Metric; use He4rt\Community\Retrospective\DTOs\Period; -use He4rt\Community\Retrospective\DTOs\PersonAccount; -use He4rt\Community\Retrospective\DTOs\PersonIdentity; use He4rt\Community\Retrospective\DTOs\SlideDescriptor; use He4rt\Community\Retrospective\DTOs\SourceFilters; use He4rt\Community\Retrospective\DTOs\SourceResult; -use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -45,7 +41,7 @@ * poucas pessoas do topo de cada ranking. Filtra por sent_at/occurred_at * (tempo do evento), nunca created_at. */ -final class DiscordSource implements CuratableSource, MeasuresPerson, RetrospectiveSource +final class DiscordSource implements CuratableSource, RetrospectiveSource { /** * Teto das varreduras de curadoria: o picker mostra o topo do recorte, nunca @@ -75,11 +71,10 @@ public function label(): string public function collect(Period $period, SourceFilters $filters): SourceResult { - $messageTotals = $this->messageTotals($period, $filters); + $totalMessages = $this->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); - $messageDays = $this->messagesByWeekday($period, $filters); - $messageHours = $this->messagesByHour($period, $filters); - $messagePeak = $this->peakMessageDay($period, $filters); $voiceTotals = $this->voiceTotals($period, $filters); $participants = $voiceTotals['participants']; @@ -102,18 +97,17 @@ public function collect(Period $period, SourceFilters $filters): SourceResult return new SourceResult( key: $this->key(), label: $this->label(), - headline: $this->headline($messageTotals['total'], $participants, $joins, $totalReactions), + headline: $this->headline($totalMessages, $participants, $joins, $totalReactions), slides: $this->slides( voiceTotals: $voiceTotals, channels: $channels, voicePeople: $voicePeople, voiceHours: $voiceHours, voicePeak: $voicePeak, - messageTotals: $messageTotals, - messagePeak: $messagePeak, + totalMessages: $totalMessages, + withReactions: $withReactions, + pinned: $pinned, chatters: $chatters, - messageDays: $messageDays, - messageHours: $messageHours, joins: $joins, boosts: $boosts, totalReactions: $totalReactions, @@ -137,37 +131,6 @@ public function slideCatalog(): array ]; } - /** - * O que esta fonte sabe sobre UMA pessoa no recorte, para o slide da tag He4rt. - * - * Casa pelo id da identidade do Discord, que é a coluna que messages e voice - * guardam. Sem conta do Discord não há o que medir — e devolver lista vazia é - * a resposta correta, não um erro: a pessoa pode ter chegado ao deck pelo - * GitHub. - * - * Passa pelos MESMOS builders do collect(), então hide_bots e exclusions valem - * aqui também: alguém escondido do deck não reaparece com números no cartão. - * - * @return list - */ - public function measure(PersonIdentity $person, Period $period, SourceFilters $filters): array - { - $account = $person->account(IdentityProvider::Discord->value); - - if (!$account instanceof PersonAccount) { - return []; - } - - /** @var list $metrics */ - $metrics = Cache::remember( - 'retrospective.measure.'.$this->key().'.'.$period->cacheKey().'.'.$this->filtersKey($filters).'.'.$account->identityId, - now()->addMinutes(5), - fn (): array => $this->personMetrics($account->identityId, $period, $filters), - ); - - return $metrics; - } - /** * @return list */ @@ -260,11 +223,7 @@ private function headline(int $messages, int $participants, int $joins, int $rea * @param list $voicePeople * @param list $voiceHours * @param array{date: string, joins: int}|null $voicePeak - * @param array{total: int, with_reactions: int, pinned: int, people: int} $messageTotals - * @param array{date: string, messages: int}|null $messagePeak - * @param list $chatters - * @param list $messageDays - * @param list $messageHours + * @param list $chatters * @param list $emojis * @param list $topMessages * @return list @@ -275,11 +234,10 @@ private function slides( array $voicePeople, array $voiceHours, ?array $voicePeak, - array $messageTotals, - ?array $messagePeak, + int $totalMessages, + int $withReactions, + int $pinned, array $chatters, - array $messageDays, - array $messageHours, int $joins, int $boosts, int $totalReactions, @@ -301,17 +259,8 @@ private function slides( ); } - if ($messageTotals['total'] > 0) { - $slides[] = new MessagesSlide( - total: $messageTotals['total'], - withReactions: $messageTotals['with_reactions'], - pinned: $messageTotals['pinned'], - people: $messageTotals['people'], - peak: $messagePeak, - chatters: $chatters, - days: $messageDays, - hours: $messageHours, - ); + if ($totalMessages > 0) { + $slides[] = new MessagesSlide($totalMessages, $withReactions, $pinned, $chatters); } if ($joins > 0 || $boosts > 0) { @@ -329,56 +278,6 @@ private function slides( return $slides; } - /** - * As três métricas que descrevem uma pessoa no Discord: quanto ela falou, - * quanto tempo ela sustentou call e quanta reação o que ela escreveu puxou. - * - * Métrica zerada não entra: "0 reações" ocupa espaço no cartão para dizer que - * não há o que dizer. - * - * @return list - */ - private function personMetrics(string $identityId, Period $period, SourceFilters $filters): array - { - $messages = $this->messages($period, $filters) - ->where('external_identity_id', $identityId) - ->count(); - - $reactions = (int) Reaction::query() - ->where('reactable_type', 'message') - ->whereIn( - 'reactable_id', - $this->messages($period, $filters)->where('external_identity_id', $identityId)->select('id'), - ) - ->sum('count'); - - $voiceXp = $this->countOf( - $this->voice($period, $filters) - ->where('external_identity_id', $identityId) - ->sum('obtained_experience'), - ); - - $metrics = [ - new Metric('Mensagens', $messages), - new Metric('XP em call', $voiceXp), - new Metric('Reações recebidas', $reactions), - ]; - - return array_values(array_filter( - $metrics, - static fn (Metric $metric): bool => $metric->value > 0, - )); - } - - /** - * Identidade dos filtros para a chave de cache. Sem isso, mexer numa exclusion - * e reabrir o builder em menos de cinco minutos mostraria o número anterior. - */ - private function filtersKey(SourceFilters $filters): string - { - return md5(($filters->hideBots ? '1' : '0').'|'.implode(',', $filters->exclusions)); - } - /** * Base de mensagens do recorte. hideBots derruba source_kind='bot' mas mantém * linhas históricas com source_kind nulo. As exclusions entram aqui (e não na @@ -445,110 +344,7 @@ private function excludedMembers(SourceFilters $filters): array } /** - * Números do topo do painel de conversas numa passada só — mesmo argumento - * do voiceTotals(): messages é a maior tabela do banco e cada agregado a - * mais seria outra varredura do mesmo recorte. - * - * @return array{total: int, with_reactions: int, pinned: int, people: int} - */ - private function messageTotals(Period $period, SourceFilters $filters): array - { - $row = $this->messages($period, $filters) - ->toBase() - ->selectRaw('COUNT(*) AS total') - ->selectRaw('COUNT(*) FILTER (WHERE reactions_total > 0) AS with_reactions') - ->selectRaw('COUNT(*) FILTER (WHERE is_pinned) AS pinned') - ->selectRaw('COUNT(DISTINCT external_identity_id) AS people') - ->first(); - - return [ - 'total' => $this->countOf($row?->total), - 'with_reactions' => $this->countOf($row?->with_reactions), - 'pinned' => $this->countOf($row?->pinned), - 'people' => $this->countOf($row?->people), - ]; - } - - /** - * O dia mais falante do recorte, agrupado no fuso de exibição — ver - * peakVoiceDay() para o porquê do fuso e do GROUP BY posicional. - * - * @return array{date: string, messages: int}|null - */ - private function peakMessageDay(Period $period, SourceFilters $filters): ?array - { - $row = $this->messages($period, $filters) - ->toBase() - ->selectRaw('(sent_at AT TIME ZONE ?)::date AS day', [$this->displayTimezone()]) - ->selectRaw('COUNT(*) AS messages') - ->groupByRaw('1') - ->orderByRaw('2 DESC') - ->first(); - - $messages = $this->countOf($row?->messages); - $day = $row?->day; - - if ($messages === 0 || (!is_string($day) && !$day instanceof DateTimeInterface)) { - return null; - } - - return [ - 'date' => CarbonImmutable::parse($day)->format('d/m'), - 'messages' => $messages, - ]; - } - - /** - * Mensagens por dia da semana, no fuso de exibição, com as 7 posições - * sempre presentes (ISO: 1 = segunda) — dia sem papo é uma barra zerada, - * não uma barra ausente. - * - * @return list - */ - private function messagesByWeekday(Period $period, SourceFilters $filters): array - { - $counts = $this->messages($period, $filters) - ->toBase() - ->selectRaw('EXTRACT(ISODOW FROM sent_at AT TIME ZONE ?)::int AS weekday', [$this->displayTimezone()]) - ->selectRaw('COUNT(*) AS messages') - // Ver peakVoiceDay(): agrupar pela posição evita o segundo placeholder. - ->groupByRaw('1') - ->pluck('messages', 'weekday'); - - return array_map( - static fn (int $weekday): array => ['weekday' => $weekday, 'messages' => (int) ($counts[$weekday] ?? 0)], - range(1, 7), - ); - } - - /** - * Mensagens por hora do dia, no fuso de exibição, com as 24 posições sempre - * presentes — o histograma é irmão do voiceByHour(). - * - * @return list - */ - private function messagesByHour(Period $period, SourceFilters $filters): array - { - $counts = $this->messages($period, $filters) - ->toBase() - ->selectRaw('EXTRACT(HOUR FROM sent_at AT TIME ZONE ?)::int AS hour', [$this->displayTimezone()]) - ->selectRaw('COUNT(*) AS messages') - // Ver peakVoiceDay(): agrupar pela posição evita o segundo placeholder. - ->groupByRaw('1') - ->pluck('messages', 'hour'); - - return array_map( - static fn (int $hour): array => ['hour' => $hour, 'messages' => (int) ($counts[$hour] ?? 0)], - range(0, 23), - ); - } - - /** - * Quem mais conversou: mensagens, XP tirado delas e quanta reação o que a - * pessoa escreveu puxou. Só o topo resolve nome (uma query a mais para 8 - * linhas). - * - * @return list + * @return list */ private function topChatters(Period $period, SourceFilters $filters): array { @@ -556,12 +352,7 @@ private function topChatters(Period $period, SourceFilters $filters): array ->groupBy('external_identity_id') ->orderByRaw('COUNT(*) DESC') ->limit(8) - ->get([ - 'external_identity_id', - DB::raw('COUNT(*) AS messages'), - DB::raw('COALESCE(SUM(obtained_experience), 0) AS xp'), - DB::raw('COALESCE(SUM(reactions_total), 0) AS reactions'), - ]); + ->get(['external_identity_id', DB::raw('COUNT(*) AS messages')]); $names = $this->displayNames($this->identityIds($rows)); @@ -569,8 +360,6 @@ private function topChatters(Period $period, SourceFilters $filters): array $rows->map(fn (Message $row): array => [ 'name' => $names[$row->external_identity_id] ?? 'Anônimo', 'messages' => (int) $row->getAttribute('messages'), - 'xp' => (int) $row->getAttribute('xp'), - 'reactions' => (int) $row->getAttribute('reactions'), ])->all(), ); } diff --git a/app-modules/activity/src/Retrospective/Slides/MessagesSlide.php b/app-modules/activity/src/Retrospective/Slides/MessagesSlide.php index 4cb86a9ff..c892d0a83 100644 --- a/app-modules/activity/src/Retrospective/Slides/MessagesSlide.php +++ b/app-modules/activity/src/Retrospective/Slides/MessagesSlide.php @@ -7,28 +7,20 @@ use He4rt\Community\Retrospective\Contracts\Slide; /** - * Painel de conversas, o irmão de texto do VoiceBoardSlide: os totais do - * recorte, o ritmo da semana, quem mais conversou (nome resolvido em PHP só - * para as N pessoas do ranking) e o histograma de mensagens por hora. + * Panorama de mensagens: total no recorte, quantas renderam reação, quantas + * foram fixadas, e o topo de quem mais conversou (nome resolvido em PHP só para + * as N pessoas do ranking). */ final readonly class MessagesSlide implements Slide { /** - * @param int $people pessoas distintas que mandaram mensagem - * @param array{date: string, messages: int}|null $peak o dia mais falante - * @param list $chatters - * @param list $days sempre as 7 posições (ISO: 1 = segunda) - * @param list $hours sempre as 24 posições + * @param list $chatters */ public function __construct( private int $total, private int $withReactions, private int $pinned, - private int $people, - private ?array $peak, private array $chatters, - private array $days, - private array $hours, ) {} public function kind(): string @@ -37,16 +29,7 @@ public function kind(): string } /** - * @return array{ - * total: int, - * with_reactions: int, - * pinned: int, - * people: int, - * peak: array{date: string, messages: int}|null, - * chatters: list, - * days: list, - * hours: list, - * } + * @return array{total: int, with_reactions: int, pinned: int, chatters: list} */ public function toArray(): array { @@ -54,11 +37,7 @@ public function toArray(): array 'total' => $this->total, 'with_reactions' => $this->withReactions, 'pinned' => $this->pinned, - 'people' => $this->people, - 'peak' => $this->peak, 'chatters' => $this->chatters, - 'days' => $this->days, - 'hours' => $this->hours, ]; } } diff --git a/app-modules/activity/src/Tracking/Actions/ApproveInteraction.php b/app-modules/activity/src/Tracking/Actions/ApproveInteraction.php new file mode 100644 index 000000000..75b4d67b1 --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/ApproveInteraction.php @@ -0,0 +1,63 @@ +status !== ActivityStatus::Pending) { + return $interaction; + } + + return DB::transaction(function () use ($interaction, $peerReviewBase): Interaction { + $locked = Interaction::query() + ->where('id', $interaction->id) + ->where('status', ActivityStatus::Pending) + ->lockForUpdate() + ->first(); + + if ($locked === null) { + return $interaction->fresh(); + } + + $reward = $this->calculateReward->handle($locked, $peerReviewBase); + + $character = Character::query()->findOrFail($locked->character_id); + $wallet = $character->getOrCreateWallet(); + + resolve(Credit::class)->handle(new CreditDTO( + walletId: $wallet->id, + amount: $reward['coins_awarded'], + referenceType: Interaction::class, + referenceId: $locked->id, + description: 'Reward: '.$locked->type->value, + )); + + $character->increment('experience', $reward['xp_awarded']); + + $locked->update([ + 'status' => ActivityStatus::Approved, + 'reviewed_at' => now(), + ]); + + event(new InteractionApproved($locked->fresh())); + + return $locked->fresh(); + }); + } +} diff --git a/app-modules/activity/src/Tracking/Actions/CalculateReward.php b/app-modules/activity/src/Tracking/Actions/CalculateReward.php new file mode 100644 index 000000000..8feb443c6 --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/CalculateReward.php @@ -0,0 +1,60 @@ +metadata ?? []; + $engagementSnapshot = $metadata['engagement_snapshot'] ?? null; + + if ($engagementSnapshot !== null) { + $base = $peerReviewBase ?? (int) (($interaction->coins_min + $interaction->coins_max) / 2); + + $reactionsBonus = min( + ($engagementSnapshot['reactions'] ?? 0) * $engagementFormula['reactions_multiplier'], + $engagementFormula['reactions_cap'] + ); + + $bookmarksBonus = min( + ($engagementSnapshot['bookmarks'] ?? 0) * $engagementFormula['bookmarks_multiplier'], + $engagementFormula['bookmarks_cap'] + ); + + $commentsBonus = min( + ($engagementSnapshot['comments'] ?? 0) * $engagementFormula['comments_multiplier'], + $engagementFormula['comments_cap'] + ); + + $engagementBonus = (int) ($reactionsBonus + $bookmarksBonus + $commentsBonus); + $coinsAwarded = min($base + $engagementBonus, $interaction->coins_max); + } else { + $coinsAwarded = $peerReviewBase !== null + ? min($peerReviewBase, $interaction->coins_max) + : $interaction->coins_min; + } + + $xpAwarded = (int) ($coinsAwarded * $xpMultiplier); + + $interaction->update([ + 'coins_awarded' => $coinsAwarded, + 'xp_awarded' => $xpAwarded, + ]); + + return [ + 'coins_awarded' => $coinsAwarded, + 'xp_awarded' => $xpAwarded, + ]; + } +} diff --git a/app-modules/activity/src/Tracking/Actions/ClassifyActivity.php b/app-modules/activity/src/Tracking/Actions/ClassifyActivity.php new file mode 100644 index 000000000..d57b65f12 --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/ClassifyActivity.php @@ -0,0 +1,34 @@ +value); + + $tier = ValueTier::from($classification['tier']); + $autoApproveTiers = config('activity-tracking.auto_approve_tiers', []); + + $status = in_array($tier->value, $autoApproveTiers, strict: true) + ? ActivityStatus::AutoApproved + : ActivityStatus::Pending; + + return [ + 'tier' => $tier, + 'coins_min' => $classification['coins_min'], + 'coins_max' => $classification['coins_max'], + 'status' => $status, + ]; + } +} diff --git a/app-modules/activity/src/Tracking/Actions/RejectInteraction.php b/app-modules/activity/src/Tracking/Actions/RejectInteraction.php new file mode 100644 index 000000000..1c6063ee9 --- /dev/null +++ b/app-modules/activity/src/Tracking/Actions/RejectInteraction.php @@ -0,0 +1,25 @@ +status !== ActivityStatus::Pending) { + return $interaction; + } + + $interaction->update([ + 'status' => ActivityStatus::Rejected, + 'reviewed_at' => now(), + ]); + + return $interaction->fresh(); + } +} diff --git a/app-modules/activity/src/Tracking/Actions/TrackActivity.php b/app-modules/activity/src/Tracking/Actions/TrackActivity.php index 29d3ec183..14c9bba22 100644 --- a/app-modules/activity/src/Tracking/Actions/TrackActivity.php +++ b/app-modules/activity/src/Tracking/Actions/TrackActivity.php @@ -5,34 +5,68 @@ namespace He4rt\Activity\Tracking\Actions; use He4rt\Activity\Tracking\DTOs\TrackActivityDTO; +use He4rt\Activity\Tracking\Enums\ActivityStatus; use He4rt\Activity\Tracking\Events\InteractionTracked; use He4rt\Activity\Tracking\Models\Interaction; -use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; +use He4rt\Economy\Actions\Credit; +use He4rt\Economy\DTOs\CreditDTO; +use He4rt\Gamification\Character\Models\Character; final readonly class TrackActivity { - public function handle(TrackActivityDTO $dto): Interaction + public function __construct( + private ClassifyActivity $classifyActivity, + private CalculateReward $calculateReward, + ) {} + + public function handle(TrackActivityDTO $dto): ?Interaction { - $identity = ExternalIdentity::query()->findOrFail($dto->externalIdentityId); - - $interaction = Interaction::query()->firstOrCreate( - ['external_ref' => $dto->externalRef], - [ - 'external_identity_id' => $identity->id, - // Derivado da identidade, nunca recebido: o DTO não pode divergir do dono real. - 'user_id' => $identity->model_id, - 'type' => $dto->type, - 'attributed_by' => $dto->attributedBy, - 'source_type' => $dto->sourceType, - 'source_id' => $dto->sourceId, - 'occurred_at' => $dto->occurredAt, - ], - ); - - if ($interaction->wasRecentlyCreated) { - event(new InteractionTracked($interaction)); + if ($dto->externalRef !== null) { + $exists = Interaction::query() + ->where('external_ref', $dto->externalRef) + ->exists(); + + if ($exists) { + return null; + } + } + + $classification = $this->classifyActivity->handle($dto->type); + + $interaction = Interaction::query()->create([ + 'character_id' => $dto->characterId, + 'type' => $dto->type, + 'provider' => $dto->provider, + 'value_tier' => $classification['tier'], + 'coins_min' => $classification['coins_min'], + 'coins_max' => $classification['coins_max'], + 'status' => $classification['status'], + 'source_type' => $dto->sourceType, + 'source_id' => $dto->sourceId, + 'external_ref' => $dto->externalRef, + 'metadata' => $dto->metadata, + 'occurred_at' => $dto->occurredAt, + ]); + + if ($classification['status'] === ActivityStatus::AutoApproved) { + $reward = $this->calculateReward->handle($interaction); + + $character = Character::query()->findOrFail($dto->characterId); + $wallet = $character->getOrCreateWallet(); + + resolve(Credit::class)->handle(new CreditDTO( + walletId: $wallet->id, + amount: $reward['coins_awarded'], + referenceType: Interaction::class, + referenceId: $interaction->id, + description: 'Reward: '.$dto->type->value, + )); + + $character->increment('experience', $reward['xp_awarded']); } + event(new InteractionTracked($interaction)); + return $interaction; } } diff --git a/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php b/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php index 70f700f06..0c73c77cd 100644 --- a/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php +++ b/app-modules/activity/src/Tracking/DTOs/TrackActivityDTO.php @@ -6,17 +6,19 @@ use DateTimeImmutable; use He4rt\Activity\Tracking\Enums\ActivityType; -use He4rt\Activity\Tracking\Enums\AttributionMethod; +use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; final readonly class TrackActivityDTO { public function __construct( - public string $externalIdentityId, + public string $characterId, public ActivityType $type, - public AttributionMethod $attributedBy, + public IdentityProvider $provider, public DateTimeImmutable $occurredAt, - public string $externalRef, + public ?string $externalRef = null, public ?string $sourceType = null, public ?string $sourceId = null, + /** @var array|null */ + public ?array $metadata = null, ) {} } diff --git a/app-modules/activity/src/Tracking/Enums/ActivityStatus.php b/app-modules/activity/src/Tracking/Enums/ActivityStatus.php new file mode 100644 index 000000000..8676d7325 --- /dev/null +++ b/app-modules/activity/src/Tracking/Enums/ActivityStatus.php @@ -0,0 +1,43 @@ + 'Pending', + self::AutoApproved => 'Auto Approved', + self::InReview => 'In Review', + self::Approved => 'Approved', + self::Rejected => 'Rejected', + }; + } + + public function getColor(): array + { + return match ($this) { + self::Pending => Color::Yellow, + self::AutoApproved => Color::Blue, + self::InReview => Color::Orange, + self::Approved => Color::Green, + self::Rejected => Color::Red, + }; + } +} diff --git a/app-modules/activity/src/Tracking/Enums/ActivityType.php b/app-modules/activity/src/Tracking/Enums/ActivityType.php index cfbfec2c4..1716142a0 100644 --- a/app-modules/activity/src/Tracking/Enums/ActivityType.php +++ b/app-modules/activity/src/Tracking/Enums/ActivityType.php @@ -5,70 +5,22 @@ namespace He4rt\Activity\Tracking\Enums; use App\Enums\Concerns\StringifyEnum; -use Filament\Support\Colors\Color; -use Filament\Support\Contracts\HasColor; -use Filament\Support\Contracts\HasDescription; -use Filament\Support\Contracts\HasLabel; -/** - * Cada caso tem um produtor. Não adicione caso sem quem o produza — o enum é lido - * como inventário do que a plataforma rastreia, não como roadmap. - */ -enum ActivityType: string implements HasColor, HasDescription, HasLabel +enum ActivityType: string { use StringifyEnum; case Article = 'article'; - case PrOpened = 'pr_opened'; case PrMerged = 'pr_merged'; - case Review = 'review'; - case ReviewComment = 'review_comment'; - case Comment = 'comment'; - case Commit = 'commit'; - case Issue = 'issue'; - - public function getLabel(): string - { - return match ($this) { - self::Article => 'Artigo', - self::PrOpened => 'PR aberto', - self::PrMerged => 'PR mergeado', - self::Review => 'Review', - self::ReviewComment => 'Comentário de review', - self::Comment => 'Comentário', - self::Commit => 'Commit', - self::Issue => 'Issue', - }; - } - - /** - * @return array - */ - public function getColor(): array - { - return match ($this) { - self::Article => Color::Violet, - self::PrOpened => Color::Sky, - self::PrMerged => Color::Emerald, - self::Review => Color::Amber, - self::ReviewComment => Color::Orange, - self::Comment => Color::Slate, - self::Commit => Color::Cyan, - self::Issue => Color::Rose, - }; - } - - public function getDescription(): string - { - return match ($this) { - self::Article => 'Artigo publicado numa plataforma de conteúdo', - self::PrOpened => 'Pull request aberto num repositório rastreado', - self::PrMerged => 'Pull request incorporado à base', - self::Review => 'Revisão submetida num pull request', - self::ReviewComment => 'Comentário em linha durante uma revisão', - self::Comment => 'Comentário numa issue ou pull request', - self::Commit => 'Commit enviado para um repositório rastreado', - self::Issue => 'Issue aberta num repositório rastreado', - }; - } + case Mentoring = 'mentoring'; + case SquadProject = 'squad_project'; + case Referral = 'referral'; + case PeerReview = 'peer_review'; + case CallParticipation = 'call_participation'; + case ForumDebate = 'forum_debate'; + case ContentShare = 'content_share'; + case Engagement = 'engagement'; + case RepoStar = 'repo_star'; + case Message = 'message'; + case Voice = 'voice'; } diff --git a/app-modules/activity/src/Tracking/Enums/ValueTier.php b/app-modules/activity/src/Tracking/Enums/ValueTier.php new file mode 100644 index 000000000..ad9f48ac8 --- /dev/null +++ b/app-modules/activity/src/Tracking/Enums/ValueTier.php @@ -0,0 +1,37 @@ + 'High', + self::Medium => 'Medium', + self::Low => 'Low', + }; + } + + public function getColor(): array + { + return match ($this) { + self::High => Color::Red, + self::Medium => Color::Yellow, + self::Low => Color::Gray, + }; + } +} diff --git a/app-modules/activity/src/Tracking/Events/InteractionApproved.php b/app-modules/activity/src/Tracking/Events/InteractionApproved.php new file mode 100644 index 000000000..0e4d11d7e --- /dev/null +++ b/app-modules/activity/src/Tracking/Events/InteractionApproved.php @@ -0,0 +1,17 @@ +author?->providers() - ->where('provider', $identityProvider) - ->whereNotNull('connected_at') - ->whereNull('disconnected_at') - ->first(); + $character = $entry->author?->character; + + if ($character === null) { + Log::info('Content contribution skipped: author has no character', [ + 'entry_id' => $entry->id, + 'author_id' => $entry->author_id, + ]); - // Sem identidade conectada não há dono possível — mesma regra de toda fonte. - if ($identity === null) { return; } $this->trackActivity->handle(new TrackActivityDTO( - externalIdentityId: $identity->id, + characterId: (string) $character->id, type: ActivityType::Article, - attributedBy: AttributionMethod::Owned, + provider: $identityProvider, occurredAt: $entry->published_at->toDateTimeImmutable(), externalRef: sprintf('%s:article:%s', $entry->provider->value, $entry->external_id), sourceType: 'content_entry', diff --git a/app-modules/activity/src/Tracking/Models/Interaction.php b/app-modules/activity/src/Tracking/Models/Interaction.php index 251fbd108..91ac563f9 100644 --- a/app-modules/activity/src/Tracking/Models/Interaction.php +++ b/app-modules/activity/src/Tracking/Models/Interaction.php @@ -6,14 +6,12 @@ use Carbon\CarbonInterface; use He4rt\Activity\Database\Factories\InteractionFactory; -use He4rt\Activity\Tracking\Contracts\ContributionDetail; +use He4rt\Activity\Tracking\Enums\ActivityStatus; use He4rt\Activity\Tracking\Enums\ActivityType; -use He4rt\Activity\Tracking\Enums\AttributionMethod; -use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; -use He4rt\Identity\User\Models\User; +use He4rt\Activity\Tracking\Enums\ValueTier; +use He4rt\Gamification\Character\Models\Character; +use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use Illuminate\Database\Eloquent\Attributes\Table; -use Illuminate\Database\Eloquent\Attributes\UseFactory; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -22,24 +20,22 @@ /** * @property string $id - * @property string $external_identity_id - * @property string $user_id + * @property string $character_id * @property ActivityType $type - * @property AttributionMethod $attributed_by + * @property IdentityProvider $provider + * @property ValueTier $value_tier + * @property int $coins_min + * @property int $coins_max + * @property int|null $coins_awarded + * @property int|null $xp_awarded + * @property ActivityStatus $status * @property string|null $source_type * @property string|null $source_id * @property string|null $external_ref + * @property array|null $metadata * @property CarbonInterface $occurred_at - * @property CarbonInterface|null $hidden_at - * @property string|null $hidden_by - * @property CarbonInterface|null $created_at - * @property CarbonInterface|null $updated_at - * @property-read ExternalIdentity $externalIdentity - * @property-read User $user - * @property-read User|null $hiddenByUser - * @property-read Model|null $source + * @property CarbonInterface|null $reviewed_at */ -#[UseFactory(factoryClass: InteractionFactory::class)] #[Table(name: 'interactions')] final class Interaction extends Model { @@ -48,27 +44,11 @@ final class Interaction extends Model use HasUuids; /** - * @return BelongsTo + * @return BelongsTo */ - public function externalIdentity(): BelongsTo + public function character(): BelongsTo { - return $this->belongsTo(ExternalIdentity::class); - } - - /** - * @return BelongsTo - */ - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - /** - * @return BelongsTo - */ - public function hiddenByUser(): BelongsTo - { - return $this->belongsTo(User::class, 'hidden_by'); + return $this->belongsTo(Character::class); } /** @@ -79,47 +59,25 @@ public function source(): MorphTo return $this->morphTo(); } - /** - * Título, contexto e link vivem na origem, não aqui. O morph aponta para uma - * string livre, então a origem pode não honrar o contrato — uma fonte antiga, - * um registro apagado — e nesse caso a contribuição fica sem detalhe em vez - * de derrubar quem a lê. - */ - public function detail(): ?ContributionDetail - { - $source = $this->source; - - return $source instanceof ContributionDetail ? $source : null; - } - - public function isVisible(): bool - { - return $this->hidden_at === null; - } - - /** - * @param Builder<$this> $query - */ - protected function scopeVisible(Builder $query): void - { - $query->whereNull('hidden_at'); - } - - /** - * @param Builder<$this> $query - */ - protected function scopeHidden(Builder $query): void + protected static function newFactory(): InteractionFactory { - $query->whereNotNull('hidden_at'); + return InteractionFactory::new(); } protected function casts(): array { return [ 'type' => ActivityType::class, - 'attributed_by' => AttributionMethod::class, + 'provider' => IdentityProvider::class, + 'value_tier' => ValueTier::class, + 'status' => ActivityStatus::class, + 'coins_min' => 'integer', + 'coins_max' => 'integer', + 'coins_awarded' => 'integer', + 'xp_awarded' => 'integer', + 'metadata' => 'array', 'occurred_at' => 'datetime', - 'hidden_at' => 'datetime', + 'reviewed_at' => 'datetime', ]; } } diff --git a/app-modules/activity/tests/Feature/Retrospective/DiscordSourceTest.php b/app-modules/activity/tests/Feature/Retrospective/DiscordSourceTest.php index b876fa858..8a3a8da01 100644 --- a/app-modules/activity/tests/Feature/Retrospective/DiscordSourceTest.php +++ b/app-modules/activity/tests/Feature/Retrospective/DiscordSourceTest.php @@ -127,58 +127,6 @@ function dcMembership(string $identityId, string $kind, string $occurredAt): voi ->and($topMessage['messages'][0]['content'])->toBe('mensagem campeã'); }); -it('conta as pessoas do papo e aponta o dia mais falante', function (): void { - $alice = dcIdentity('Alice'); - $bob = dcIdentity('Bob'); - - Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02 15:00:00']); - Message::factory()->count(3)->create(['external_identity_id' => $bob->id, 'sent_at' => '2026-06-04 15:00:00']); - - $messages = dcSlide(($this->collect)(), 'discord.messages'); - - expect($messages['people'])->toBe(2) - ->and($messages['peak'])->toMatchArray(['date' => '04/06', 'messages' => 3]); -}); - -it('devolve o ritmo da semana com as 7 posições, no fuso de exibição', function (): void { - $alice = dcIdentity('Alice'); - - // 15h UTC = 12h em São Paulo: terça segue terça (ISO 2). - Message::factory()->count(2)->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02 15:00:00']); - // 1h UTC da terça = 22h de segunda em São Paulo (ISO 1). - Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02 01:00:00']); - - $messages = dcSlide(($this->collect)(), 'discord.messages'); - - expect($messages['days'])->toHaveCount(7) - ->and(array_column($messages['days'], 'weekday'))->toBe(range(1, 7)) - ->and($messages['days'][0])->toMatchArray(['weekday' => 1, 'messages' => 1]) - ->and($messages['days'][1])->toMatchArray(['weekday' => 2, 'messages' => 2]); -}); - -it('devolve as 24 horas do histograma de mensagens, inclusive as vazias', function (): void { - $alice = dcIdentity('Alice'); - - Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02 15:00:00']); - - $messages = dcSlide(($this->collect)(), 'discord.messages'); - - expect($messages['hours'])->toHaveCount(24) - ->and(array_column($messages['hours'], 'hour'))->toBe(range(0, 23)) - ->and(array_sum(array_column($messages['hours'], 'messages')))->toBe(1); -}); - -it('rankeia chatters com o XP e as reações que as mensagens puxaram', function (): void { - $alice = dcIdentity('Alice'); - - Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-02', 'obtained_experience' => 10, 'reactions_total' => 3]); - Message::factory()->create(['external_identity_id' => $alice->id, 'sent_at' => '2026-06-03', 'obtained_experience' => 20, 'reactions_total' => 0]); - - $messages = dcSlide(($this->collect)(), 'discord.messages'); - - expect($messages['chatters'][0])->toMatchArray(['name' => 'Alice', 'messages' => 2, 'xp' => 30, 'reactions' => 3]); -}); - it('agrega o board de voz por participantes, XP e canais', function (): void { $alice = dcIdentity('Alice'); $bob = dcIdentity('Bob'); diff --git a/app-modules/activity/tests/Feature/TrackContentContributionTest.php b/app-modules/activity/tests/Feature/TrackContentContributionTest.php index fec386c89..efe533031 100644 --- a/app-modules/activity/tests/Feature/TrackContentContributionTest.php +++ b/app-modules/activity/tests/Feature/TrackContentContributionTest.php @@ -2,31 +2,17 @@ declare(strict_types=1); +use He4rt\Activity\Tracking\Enums\ActivityStatus; use He4rt\Activity\Tracking\Enums\ActivityType; use He4rt\Activity\Tracking\Models\Interaction; use He4rt\Contents\Articles\Events\ArticlePublished; use He4rt\Contents\Database\Factories\ContentEntryFactory; -use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; +use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\User\Models\User; -function authorWithDevtoIdentity(): User -{ +test('creates an interaction when the article author has a character', function (): void { $user = User::factory()->create(); - - ExternalIdentity::factory()->create([ - 'model_type' => (new User)->getMorphClass(), - 'model_id' => $user->id, - 'provider' => IdentityProvider::DevTo, - 'connected_at' => now(), - 'disconnected_at' => null, - ]); - - return $user; -} - -test('artigo de autor com identidade devto ativa vira contribuição', function (): void { - $user = authorWithDevtoIdentity(); + $character = Character::factory()->create(['user_id' => $user->id]); $entry = ContentEntryFactory::new()->authoredBy($user)->create([ 'external_id' => '123', @@ -42,11 +28,11 @@ function authorWithDevtoIdentity(): User expect($interaction)->not->toBeNull() ->and($interaction->external_ref)->toBe('devto:article:123') ->and($interaction->type)->toBe(ActivityType::Article) - ->and($interaction->user_id)->toBe($user->id) - ->and($interaction->isVisible())->toBeTrue(); + ->and($interaction->status)->toBe(ActivityStatus::Pending) + ->and($interaction->character_id)->toBe($character->id); }); -test('autor sem identidade devto conectada é ignorado', function (): void { +test('skips tracking and never creates a character when the author has none', function (): void { $user = User::factory()->create(); $entry = ContentEntryFactory::new()->authoredBy($user)->create([ @@ -55,24 +41,13 @@ function authorWithDevtoIdentity(): User event(new ArticlePublished($entry->fresh())); - expect(Interaction::query()->where('source_id', $entry->id)->exists())->toBeFalse(); + expect(Interaction::query()->where('source_id', $entry->id)->exists())->toBeFalse() + ->and(Character::query()->where('user_id', $user->id)->exists())->toBeFalse(); }); -test('identidade desconectada não recebe a contribuição', function (): void { - $user = authorWithDevtoIdentity(); - $user->providers()->update(['disconnected_at' => now()]); - - $entry = ContentEntryFactory::new()->authoredBy($user)->create([ - 'external_id' => '999', - ]); - - event(new ArticlePublished($entry->fresh())); - - expect(Interaction::query()->where('source_id', $entry->id)->exists())->toBeFalse(); -}); - -test('evento disparado duas vezes não duplica', function (): void { - $user = authorWithDevtoIdentity(); +test('deduplicates by external_ref when the event fires twice', function (): void { + $user = User::factory()->create(); + Character::factory()->create(['user_id' => $user->id]); $entry = ContentEntryFactory::new()->authoredBy($user)->create([ 'external_id' => '789', diff --git a/app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php b/app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php new file mode 100644 index 000000000..dad6d8ec4 --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/ApproveInteractionTest.php @@ -0,0 +1,41 @@ +create(); + $character = Character::factory()->recycle($user)->create(['experience' => 500]); + + $interaction = Interaction::factory() + ->withEngagement(reactions: 42, comments: 12, bookmarks: 8) + ->recycle($character) + ->create([ + 'coins_min' => 100, + 'coins_max' => 300, + 'status' => ActivityStatus::Pending, + ]); + + $result = resolve(ApproveInteraction::class)->handle($interaction, peerReviewBase: 200); + + expect($result->status)->toBe(ActivityStatus::Approved) + ->and($result->reviewed_at)->not->toBeNull() + ->and($result->coins_awarded)->toBe(253); + + $wallet = $character->fresh()->wallets()->first(); + expect($wallet->balance)->toBe(253); + + Event::assertDispatched(InteractionApproved::class); +}); diff --git a/app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php b/app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php new file mode 100644 index 000000000..fe760c1de --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/CalculateRewardTest.php @@ -0,0 +1,53 @@ +withEngagement(reactions: 42, comments: 12, bookmarks: 8) + ->create([ + 'coins_min' => 100, + 'coins_max' => 300, + ]); + + $result = resolve(CalculateReward::class)->handle($interaction, peerReviewBase: 200); + + // reactions bonus: min(42 * 0.5, 25) = 21 + // bookmarks bonus: min(8 * 1.0, 15) = 8 + // comments bonus: min(12 * 2.0, 30) = 24 + // total engagement: 53 + // coins_awarded: min(200 + 53, 300) = 253 + expect($result['coins_awarded'])->toBe(253) + ->and($result['xp_awarded'])->toBe(253); +}); + +test('caps engagement bonus at coins max', function (): void { + $interaction = Interaction::factory() + ->withEngagement(reactions: 100, comments: 100, bookmarks: 100) + ->create([ + 'coins_min' => 100, + 'coins_max' => 200, + ]); + + $result = resolve(CalculateReward::class)->handle($interaction, peerReviewBase: 180); + + expect($result['coins_awarded'])->toBe(200); +}); + +test('uses coins_min when no engagement and auto approved', function (): void { + $interaction = Interaction::factory()->create([ + 'coins_min' => 5, + 'coins_max' => 10, + 'metadata' => null, + ]); + + $result = resolve(CalculateReward::class)->handle($interaction); + + expect($result['coins_awarded'])->toBe(5); +}); diff --git a/app-modules/activity/tests/Unit/Tracking/ClassifyActivityTest.php b/app-modules/activity/tests/Unit/Tracking/ClassifyActivityTest.php new file mode 100644 index 000000000..28c986fad --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/ClassifyActivityTest.php @@ -0,0 +1,35 @@ +handle(ActivityType::Article); + + expect($result['tier'])->toBe(ValueTier::High) + ->and($result['coins_min'])->toBe(100) + ->and($result['coins_max'])->toBe(300) + ->and($result['status'])->toBe(ActivityStatus::Pending); +}); + +test('classifies medium tier activity as auto approved', function (): void { + $result = resolve(ClassifyActivity::class)->handle(ActivityType::Referral); + + expect($result['tier'])->toBe(ValueTier::Medium) + ->and($result['coins_min'])->toBe(20) + ->and($result['coins_max'])->toBe(30) + ->and($result['status'])->toBe(ActivityStatus::AutoApproved); +}); + +test('classifies low tier activity as auto approved', function (): void { + $result = resolve(ClassifyActivity::class)->handle(ActivityType::Engagement); + + expect($result['tier'])->toBe(ValueTier::Low) + ->and($result['coins_min'])->toBe(1) + ->and($result['coins_max'])->toBe(3) + ->and($result['status'])->toBe(ActivityStatus::AutoApproved); +}); diff --git a/app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php b/app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php new file mode 100644 index 000000000..a2ffd4397 --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/RejectInteractionTest.php @@ -0,0 +1,21 @@ +create([ + 'status' => ActivityStatus::Pending, + ]); + + $result = resolve(RejectInteraction::class)->handle($interaction); + + expect($result->status)->toBe(ActivityStatus::Rejected) + ->and($result->reviewed_at)->not->toBeNull(); +}); diff --git a/app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php b/app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php new file mode 100644 index 000000000..dec7d1e56 --- /dev/null +++ b/app-modules/activity/tests/Unit/Tracking/TrackActivityTest.php @@ -0,0 +1,90 @@ +create(); + $character = Character::factory()->recycle($user)->create(); + + $dto = new TrackActivityDTO( + characterId: $character->id, + type: ActivityType::Article, + provider: IdentityProvider::DevTo, + occurredAt: CarbonImmutable::now(), + externalRef: 'devto:article:123', + ); + + $interaction = resolve(TrackActivity::class)->handle($dto); + + expect($interaction)->not->toBeNull() + ->and($interaction->status)->toBe(ActivityStatus::Pending) + ->and($interaction->value_tier)->toBe(ValueTier::High) + ->and($interaction->coins_min)->toBe(100) + ->and($interaction->coins_max)->toBe(300) + ->and($interaction->coins_awarded)->toBeNull(); + + Event::assertDispatched(InteractionTracked::class); +}); + +test('tracks low tier activity as auto approved and credits economy', function (): void { + Event::fake([InteractionTracked::class]); + + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->create(); + + $dto = new TrackActivityDTO( + characterId: $character->id, + type: ActivityType::Engagement, + provider: IdentityProvider::DevTo, + occurredAt: CarbonImmutable::now(), + ); + + $interaction = resolve(TrackActivity::class)->handle($dto); + + expect($interaction)->not->toBeNull() + ->and($interaction->status)->toBe(ActivityStatus::AutoApproved) + ->and($interaction->value_tier)->toBe(ValueTier::Low) + ->and($interaction->coins_awarded)->toBe(1); + + $wallet = $character->fresh()->wallets()->first(); + expect($wallet)->not->toBeNull() + ->and($wallet->balance)->toBe(1); + + Event::assertDispatched(InteractionTracked::class); +}); + +test('deduplicates by external ref', function (): void { + $user = User::factory()->create(); + $character = Character::factory()->recycle($user)->create(); + + $dto = new TrackActivityDTO( + characterId: $character->id, + type: ActivityType::Article, + provider: IdentityProvider::DevTo, + occurredAt: CarbonImmutable::now(), + externalRef: 'devto:article:456', + ); + + $first = resolve(TrackActivity::class)->handle($dto); + $second = resolve(TrackActivity::class)->handle($dto); + + expect($first)->not->toBeNull() + ->and($second)->toBeNull(); +}); diff --git a/app-modules/community/src/CommunityServiceProvider.php b/app-modules/community/src/CommunityServiceProvider.php index ddc68e85f..0a1e994fe 100644 --- a/app-modules/community/src/CommunityServiceProvider.php +++ b/app-modules/community/src/CommunityServiceProvider.php @@ -5,9 +5,6 @@ namespace He4rt\Community; use He4rt\Community\Retrospective\Actions\CompileSnapshot; -use He4rt\Community\Retrospective\Actions\ComposePromotions; -use He4rt\Community\Retrospective\Actions\ResolvePeople; -use He4rt\Community\Retrospective\Contracts\PersonDirectory; use He4rt\Community\Retrospective\Contracts\RetrospectiveSource; use Illuminate\Contracts\Foundation\Application; use Illuminate\Support\ServiceProvider; @@ -23,18 +20,7 @@ public function register(): void /** @var iterable $sources */ $sources = $app->tagged('retrospective.source'); - return new CompileSnapshot($sources, $app->make(ComposePromotions::class)); - }); - - $this->app->bind(PersonDirectory::class, ResolvePeople::class); - - // Mesma descoberta por tag: a orquestração pergunta a cada fonte o que ela - // sabe sobre uma pessoa, e ignora quem não implementa MeasuresPerson. - $this->app->bind(ComposePromotions::class, static function (Application $app): ComposePromotions { - /** @var iterable $sources */ - $sources = $app->tagged('retrospective.source'); - - return new ComposePromotions($sources, $app->make(PersonDirectory::class)); + return new CompileSnapshot($sources); }); } diff --git a/app-modules/community/src/Retrospective/Actions/CompileSnapshot.php b/app-modules/community/src/Retrospective/Actions/CompileSnapshot.php index 814ca9d75..2b4bbf1fd 100644 --- a/app-modules/community/src/Retrospective/Actions/CompileSnapshot.php +++ b/app-modules/community/src/Retrospective/Actions/CompileSnapshot.php @@ -6,7 +6,6 @@ use He4rt\Community\Retrospective\Contracts\RetrospectiveSource; use He4rt\Community\Retrospective\DTOs\Period; -use He4rt\Community\Retrospective\DTOs\PromotionEntry; use He4rt\Community\Retrospective\DTOs\RetrospectiveSnapshot; use He4rt\Community\Retrospective\DTOs\SourceFilters; @@ -14,10 +13,6 @@ * Coleta todas as fontes registradas para um Period + filtros e empacota o * resultado cru num RetrospectiveSnapshot. É o que o publish congela. * - * As promoções entram aqui pelo mesmo motivo dos números das fontes: são dado - * medido no recorte, e o publish precisa congelá-las para a página pública não - * consultar o banco por pessoa a cada visita. - * * Não ordena nem cura: a ordem e o on/off vivem no DeckConfig e são aplicados * depois pelo ComposeDeck. Só descarta fontes vazias (não há o que congelar). * Adicionar uma fonte não toca esta classe: basta a tag "retrospective.source". @@ -30,20 +25,14 @@ /** * @param iterable $sources */ - public function __construct( - iterable $sources, - private ComposePromotions $promotions, - ) { + public function __construct(iterable $sources) + { $this->sources = array_values( is_array($sources) ? $sources : iterator_to_array($sources, preserve_keys: false), ); } - /** - * @param list $promotions as pessoas escolhidas para o slide - * da tag He4rt; medidas aqui para congelarem junto dos números das fontes - */ - public function execute(Period $period, SourceFilters $filters, array $promotions = []): RetrospectiveSnapshot + public function execute(Period $period, SourceFilters $filters): RetrospectiveSnapshot { $results = []; @@ -57,10 +46,6 @@ public function execute(Period $period, SourceFilters $filters, array $promotion // Os filtros vão junto: o snapshot precisa saber o que o produziu para o // painel detectar exclusion alterada depois de publicar. - return new RetrospectiveSnapshot( - $results, - $filters, - $this->promotions->execute($promotions, $period, $filters), - ); + return new RetrospectiveSnapshot($results, $filters); } } diff --git a/app-modules/community/src/Retrospective/DTOs/DeckConfig.php b/app-modules/community/src/Retrospective/DTOs/DeckConfig.php index c481720e2..8e304378c 100644 --- a/app-modules/community/src/Retrospective/DTOs/DeckConfig.php +++ b/app-modules/community/src/Retrospective/DTOs/DeckConfig.php @@ -4,8 +4,6 @@ namespace He4rt\Community\Retrospective\DTOs; -use He4rt\Community\Retrospective\Enums\PromotionStage; - /** * Curadoria de APRESENTAÇÃO de uma retrospectiva, guardada na edição (jsonb via * AsDeckConfig). Separada do snapshot congelado: mexer aqui em ordem/on-off @@ -21,14 +19,12 @@ * @param list $hiddenSources keys de fonte ocultadas do deck * @param list $hiddenSlides kinds de slide ocultados (ex.: "github.repos") * @param array> $exclusions refs escondidos por key de fonte (ex.: ["github" => ["pr:142"]]) - * @param list $promotions as pessoas do slide da tag He4rt, na ordem de exibição */ public function __construct( public array $order = [], public array $hiddenSources = [], public array $hiddenSlides = [], public array $exclusions = [], - public array $promotions = [], ) {} /** @@ -49,7 +45,6 @@ public static function makeFromPayload(array $payload): self hiddenSources: self::stringList($payload['hidden_sources'] ?? []), hiddenSlides: self::stringList($payload['hidden_slides'] ?? []), exclusions: $exclusions, - promotions: self::promotionList($payload['promotions'] ?? []), ); } @@ -63,10 +58,6 @@ public function toArray(): array 'hidden_sources' => $this->hiddenSources, 'hidden_slides' => $this->hiddenSlides, 'exclusions' => $this->exclusions, - 'promotions' => array_map( - static fn (PromotionEntry $entry): array => $entry->toArray(), - $this->promotions, - ), ]; } @@ -109,7 +100,6 @@ public function withSourceVisible(string $key, bool $visible): self hiddenSources: $this->toggled($this->hiddenSources, $key, hidden: !$visible), hiddenSlides: $this->hiddenSlides, exclusions: $this->exclusions, - promotions: $this->promotions, ); } @@ -124,7 +114,6 @@ public function withSlideVisible(string $kind, bool $visible): self hiddenSources: $this->hiddenSources, hiddenSlides: $this->toggled($this->hiddenSlides, $kind, hidden: !$visible), exclusions: $this->exclusions, - promotions: $this->promotions, ); } @@ -138,7 +127,6 @@ public function withOrder(array $order): self hiddenSources: $this->hiddenSources, hiddenSlides: $this->hiddenSlides, exclusions: $this->exclusions, - promotions: $this->promotions, ); } @@ -164,7 +152,6 @@ public function withExclusionsFor(string $key, array $refs): self hiddenSources: $this->hiddenSources, hiddenSlides: $this->hiddenSlides, exclusions: $exclusions, - promotions: $this->promotions, ); } @@ -186,87 +173,6 @@ public function allExclusions(): array return array_values(array_unique($refs)); } - /** - * As pessoas de um estágio, na ordem em que o operador as deixou. - * - * @return list - */ - public function promotionsFor(PromotionStage $stage): array - { - return array_values(array_filter( - $this->promotions, - static fn (PromotionEntry $entry): bool => $entry->stage === $stage, - )); - } - - /** - * Substitui as pessoas de UM estágio; as do outro ficam intactas. O inspector - * edita um slide por vez (destaques OU a tag), e uma escrita que levasse a - * lista inteira apagaria o estágio que não estava na tela. - * - * Mexe no dado exibido, como as exclusions: quem chama precisa avisar que - * exige republicar. - * - * @param list $entries - */ - public function withPromotionsFor(PromotionStage $stage, array $entries): self - { - $others = array_values(array_filter( - $this->promotions, - static fn (PromotionEntry $entry): bool => $entry->stage !== $stage, - )); - - return new self( - order: $this->order, - hiddenSources: $this->hiddenSources, - hiddenSlides: $this->hiddenSlides, - exclusions: $this->exclusions, - promotions: [...$others, ...$entries], - ); - } - - /** - * As escolhas em forma comparável, para o painel detectar que o publicado - * ficou para trás. - * - * @return list - */ - public function promotionSignatures(): array - { - return array_map( - static fn (PromotionEntry $entry): string => $entry->signature(), - $this->promotions, - ); - } - - /** - * @return list - */ - private static function promotionList(mixed $value): array - { - if (!is_array($value)) { - return []; - } - - $entries = []; - - foreach ($value as $item) { - if (!is_array($item)) { - continue; - } - - $entry = PromotionEntry::makeFromPayload($item); - - // Escolha corrompida (estágio removido, id vazio) some em vez de - // derrubar o deck: o jsonb sobrevive a refactor de enum. - if ($entry instanceof PromotionEntry) { - $entries[] = $entry; - } - } - - return $entries; - } - /** * @return list */ diff --git a/app-modules/community/src/Retrospective/DTOs/RetrospectiveSnapshot.php b/app-modules/community/src/Retrospective/DTOs/RetrospectiveSnapshot.php index 234287045..24b8eaab5 100644 --- a/app-modules/community/src/Retrospective/DTOs/RetrospectiveSnapshot.php +++ b/app-modules/community/src/Retrospective/DTOs/RetrospectiveSnapshot.php @@ -19,12 +19,10 @@ /** * @param list $sources * @param SourceFilters $filters os filtros que PRODUZIRAM estes números - * @param list $promotions as pessoas do slide da tag He4rt, já medidas */ public function __construct( public array $sources = [], public SourceFilters $filters = new SourceFilters(), - public array $promotions = [], ) {} /** @@ -47,11 +45,7 @@ public static function makeFromPayload(array $payload): self ); } - return new self( - $sources, - self::filters($payload['filters'] ?? null), - self::promotions($payload['promotions'] ?? []), - ); + return new self($sources, self::filters($payload['filters'] ?? null)); } /** @@ -67,10 +61,6 @@ public function toArray(): array 'hide_bots' => $this->filters->hideBots, 'exclusions' => $this->filters->exclusions, ], - 'promotions' => array_map( - fn (PromotionCard $card): array => $card->toArray(), - $this->promotions, - ), 'sources' => array_map( fn (SourceResult $source): array => [ 'key' => $source->key, @@ -93,47 +83,7 @@ public function toArray(): array public function isEmpty(): bool { - return $this->sources === [] && $this->promotions === []; - } - - /** - * As escolhas de promoção que produziram estes cartões, para comparar com a - * curadoria atual da edição. - * - * @return list - */ - public function promotionSignatures(): array - { - return array_map( - static fn (PromotionCard $card): string => $card->signature(), - $this->promotions, - ); - } - - /** - * @return list - */ - private static function promotions(mixed $raw): array - { - if (!is_array($raw)) { - return []; - } - - $cards = []; - - foreach ($raw as $card) { - if (!is_array($card)) { - continue; - } - - $hydrated = PromotionCard::makeFromPayload($card); - - if ($hydrated instanceof PromotionCard) { - $cards[] = $hydrated; - } - } - - return $cards; + return $this->sources === []; } /** diff --git a/app-modules/community/src/Retrospective/Models/Retrospective.php b/app-modules/community/src/Retrospective/Models/Retrospective.php index 3cc0f212a..ed711f445 100644 --- a/app-modules/community/src/Retrospective/Models/Retrospective.php +++ b/app-modules/community/src/Retrospective/Models/Retrospective.php @@ -88,10 +88,10 @@ public function isPublished(): bool * A edição publicada está exibindo números que não correspondem mais à curadoria * atual, porque um filtro que MEXE NO DADO mudou depois do publish. * - * Olha os SourceFilters e as promoções: as duas curadorias que MEXEM no dado. - * Ordem e on/off re-derivam do snapshot na composição e nunca pedem - * republicação (ADR-0002). Comparar `updated_at` com `published_at` avisaria - * também nesses casos, apagando justo a distinção que a fase inteira defende. + * Só olha os SourceFilters: ordem e on/off re-derivam do snapshot na composição e + * nunca pedem republicação (ADR-0002). Comparar `updated_at` com `published_at` + * avisaria também nesses casos, apagando justo a distinção que a fase inteira + * defende. */ public function needsRepublish(): bool { @@ -104,10 +104,7 @@ public function needsRepublish(): bool return $frozen->hideBots !== $current->hideBots || array_values(array_diff($frozen->exclusions, $current->exclusions)) !== [] - || array_values(array_diff($current->exclusions, $frozen->exclusions)) !== [] - // Ordem importa: trocar quem aparece primeiro no slide da tag muda o - // que o público vê, então a comparação é posicional, não de conjunto. - || $this->snapshot->promotionSignatures() !== $this->deck_config->promotionSignatures(); + || array_values(array_diff($current->exclusions, $frozen->exclusions)) !== []; } /** diff --git a/app-modules/community/tests/Feature/Retrospective/PublishRetrospectiveTest.php b/app-modules/community/tests/Feature/Retrospective/PublishRetrospectiveTest.php index 2baa76c37..1b11983af 100644 --- a/app-modules/community/tests/Feature/Retrospective/PublishRetrospectiveTest.php +++ b/app-modules/community/tests/Feature/Retrospective/PublishRetrospectiveTest.php @@ -4,7 +4,6 @@ use Carbon\CarbonImmutable; use He4rt\Community\Retrospective\Actions\CompileSnapshot; -use He4rt\Community\Retrospective\Actions\ComposePromotions; use He4rt\Community\Retrospective\Actions\PublishRetrospective; use He4rt\Community\Retrospective\Contracts\RetrospectiveSource; use He4rt\Community\Retrospective\DTOs\HeadlineMetrics; @@ -57,7 +56,7 @@ public function collect(Period $period, SourceFilters $filters): SourceResult } }; - new CompileRetrospectiveSnapshot($retrospective)->handle(new CompileSnapshot([$source], resolve(ComposePromotions::class))); + new CompileRetrospectiveSnapshot($retrospective)->handle(new CompileSnapshot([$source])); $fresh = $retrospective->fresh(); diff --git a/app-modules/community/tests/Feature/Retrospective/RetrospectiveModelTest.php b/app-modules/community/tests/Feature/Retrospective/RetrospectiveModelTest.php index 2ca8573b8..b29727bbb 100644 --- a/app-modules/community/tests/Feature/Retrospective/RetrospectiveModelTest.php +++ b/app-modules/community/tests/Feature/Retrospective/RetrospectiveModelTest.php @@ -6,12 +6,9 @@ use He4rt\Community\Retrospective\DTOs\DeckConfig; use He4rt\Community\Retrospective\DTOs\HeadlineMetrics; use He4rt\Community\Retrospective\DTOs\Metric; -use He4rt\Community\Retrospective\DTOs\PromotionCard; -use He4rt\Community\Retrospective\DTOs\PromotionEntry; use He4rt\Community\Retrospective\DTOs\RetrospectiveSnapshot; use He4rt\Community\Retrospective\DTOs\SourceFilters; use He4rt\Community\Retrospective\DTOs\SourceResult; -use He4rt\Community\Retrospective\Enums\PromotionStage; use He4rt\Community\Retrospective\Enums\RetrospectiveStatus; use He4rt\Community\Retrospective\Models\Retrospective; use He4rt\Community\Retrospective\Slides\FrozenSlide; @@ -134,62 +131,3 @@ expect($retrospective->needsRepublish())->toBeFalse(); }); - -it('pede republicação quando a lista da tag muda depois de publicar', function (): void { - $card = new PromotionCard( - userId: 'u1', - name: 'Fulana', - username: 'fulana', - avatar: 'a.png', - stage: PromotionStage::Promoted, - reason: 'segurou o #ajuda', - ); - - $retrospective = Retrospective::factory() - ->published(new RetrospectiveSnapshot(promotions: [$card])) - ->create([ - 'deck_config' => new DeckConfig(promotions: [ - new PromotionEntry('u1', PromotionStage::Promoted, 'segurou o #ajuda'), - ]), - ]); - - expect($retrospective->needsRepublish())->toBeFalse(); - - // Trocar a pessoa muda número exibido: é dado, não apresentação. - $retrospective->update([ - 'deck_config' => $retrospective->deck_config->withPromotionsFor( - PromotionStage::Promoted, - [new PromotionEntry('u2', PromotionStage::Promoted, 'segurou o #ajuda')], - ), - ]); - - expect($retrospective->fresh()->needsRepublish())->toBeTrue(); -}); - -it('corrigir o motivo também deixa o publicado defasado', function (): void { - $retrospective = Retrospective::factory() - ->published(new RetrospectiveSnapshot(promotions: [ - new PromotionCard('u1', 'Fulana', 'fulana', 'a.png', PromotionStage::Promoted, 'motivo antigo'), - ])) - ->create([ - 'deck_config' => new DeckConfig(promotions: [ - new PromotionEntry('u1', PromotionStage::Promoted, 'motivo novo'), - ]), - ]); - - expect($retrospective->needsRepublish())->toBeTrue(); -}); - -it('ordem e on/off de slide continuam sem pedir republicação', function (): void { - $retrospective = Retrospective::factory() - ->published(new RetrospectiveSnapshot()) - ->create(['deck_config' => new DeckConfig()]); - - $retrospective->update([ - 'deck_config' => $retrospective->deck_config - ->withOrder(['discord', 'github']) - ->withSlideVisible('he4rt.tag', visible: false), - ]); - - expect($retrospective->fresh()->needsRepublish())->toBeFalse(); -}); diff --git a/app-modules/community/tests/Unit/Retrospective/CompileSnapshotTest.php b/app-modules/community/tests/Unit/Retrospective/CompileSnapshotTest.php index 188055738..3e858f12c 100644 --- a/app-modules/community/tests/Unit/Retrospective/CompileSnapshotTest.php +++ b/app-modules/community/tests/Unit/Retrospective/CompileSnapshotTest.php @@ -4,20 +4,12 @@ use Carbon\CarbonImmutable; use He4rt\Community\Retrospective\Actions\CompileSnapshot; -use He4rt\Community\Retrospective\Actions\ComposePromotions; -use He4rt\Community\Retrospective\Contracts\MeasuresPerson; -use He4rt\Community\Retrospective\Contracts\PersonDirectory; use He4rt\Community\Retrospective\Contracts\RetrospectiveSource; use He4rt\Community\Retrospective\DTOs\HeadlineMetrics; use He4rt\Community\Retrospective\DTOs\Metric; use He4rt\Community\Retrospective\DTOs\Period; -use He4rt\Community\Retrospective\DTOs\PersonAccount; -use He4rt\Community\Retrospective\DTOs\PersonIdentity; -use He4rt\Community\Retrospective\DTOs\PromotionEntry; -use He4rt\Community\Retrospective\DTOs\RetrospectiveSnapshot; use He4rt\Community\Retrospective\DTOs\SourceFilters; use He4rt\Community\Retrospective\DTOs\SourceResult; -use He4rt\Community\Retrospective\Enums\PromotionStage; use He4rt\Community\Retrospective\Slides\FrozenSlide; function retroFakeSource(string $key, string $label, bool $empty): RetrospectiveSource @@ -61,7 +53,7 @@ function retroPeriod(): Period $snapshot = new CompileSnapshot([ retroFakeSource('github', 'GitHub', empty: false), retroFakeSource('discord', 'Discord', empty: false), - ], resolve(ComposePromotions::class))->execute(retroPeriod(), new SourceFilters()); + ])->execute(retroPeriod(), new SourceFilters()); expect($snapshot->sources)->toHaveCount(2) ->and(array_map(fn (SourceResult $source): string => $source->key, $snapshot->sources)) @@ -72,76 +64,14 @@ function retroPeriod(): Period $snapshot = new CompileSnapshot([ retroFakeSource('github', 'GitHub', empty: false), retroFakeSource('discord', 'Discord', empty: true), - ], resolve(ComposePromotions::class))->execute(retroPeriod(), new SourceFilters()); + ])->execute(retroPeriod(), new SourceFilters()); expect($snapshot->sources)->toHaveCount(1) ->and($snapshot->sources[0]->key)->toBe('github'); }); it('devolve snapshot vazio quando não há fontes', function (): void { - $snapshot = new CompileSnapshot([], resolve(ComposePromotions::class))->execute(retroPeriod(), new SourceFilters()); + $snapshot = new CompileSnapshot([])->execute(retroPeriod(), new SourceFilters()); expect($snapshot->isEmpty())->toBeTrue(); }); - -it('congela as promoções junto dos números das fontes', function (): void { - $pessoa = new PersonIdentity('u1', 'Fulana', 'fulana', 'a.png', accounts: [ - 'discord' => new PersonAccount('ident-1'), - ]); - - $fonte = new class implements MeasuresPerson, RetrospectiveSource - { - public function key(): string - { - return 'discord'; - } - - public function label(): string - { - return 'Discord'; - } - - public function collect(Period $period, SourceFilters $filters): SourceResult - { - return new SourceResult('discord', 'Discord', new HeadlineMetrics([new Metric('Mensagens', 10)]), []); - } - - /** - * @return Metric[] - */ - public function measure(PersonIdentity $person, Period $period, SourceFilters $filters): array - { - return [new Metric('Mensagens', 8_132)]; - } - }; - - $directory = new readonly class($pessoa) implements PersonDirectory - { - public function __construct(private PersonIdentity $pessoa) {} - - /** - * @return PersonIdentity[] - */ - public function execute(array $userIds): array - { - return ['u1' => $this->pessoa]; - } - }; - - $snapshot = new CompileSnapshot([$fonte], new ComposePromotions([$fonte], $directory)) - ->execute( - retroPeriod(), - new SourceFilters(), - [new PromotionEntry('u1', PromotionStage::Promoted, 'segurou o #ajuda')], - ); - - expect($snapshot->promotions)->toHaveCount(1) - ->and($snapshot->promotions[0]->name)->toBe('Fulana') - ->and($snapshot->promotions[0]->groups[0]->metrics[0]->value)->toBe(8_132); - - // E sobrevive ao round-trip do jsonb: é assim que a página pública lê. - $reidratado = RetrospectiveSnapshot::makeFromPayload($snapshot->toArray()); - - expect($reidratado->promotions)->toEqual($snapshot->promotions) - ->and($reidratado->promotionSignatures())->toBe(['u1|promoted|segurou o #ajuda']); -}); diff --git a/app-modules/community/tests/Unit/Retrospective/DeckConfigTest.php b/app-modules/community/tests/Unit/Retrospective/DeckConfigTest.php index 4aad84908..b06ffef64 100644 --- a/app-modules/community/tests/Unit/Retrospective/DeckConfigTest.php +++ b/app-modules/community/tests/Unit/Retrospective/DeckConfigTest.php @@ -3,8 +3,6 @@ declare(strict_types=1); use He4rt\Community\Retrospective\DTOs\DeckConfig; -use He4rt\Community\Retrospective\DTOs\PromotionEntry; -use He4rt\Community\Retrospective\Enums\PromotionStage; it('faz round-trip do payload sem perder curadoria', function (): void { $config = new DeckConfig( @@ -126,53 +124,3 @@ expect($updated->exclusionsFor('github'))->toBe(['pr:1', 'pr:2']); }); - -it('faz round-trip das promoções e descarta escolha corrompida', function (): void { - $config = new DeckConfig(promotions: [ - new PromotionEntry('u1', PromotionStage::Spotlight, 'segurou o #ajuda'), - new PromotionEntry('u2', PromotionStage::Promoted), - ]); - - expect(DeckConfig::makeFromPayload($config->toArray()))->toEqual($config); - - $sujo = DeckConfig::makeFromPayload([ - 'promotions' => [ - ['user_id' => 'u1', 'stage' => 'promoted'], - ['user_id' => '', 'stage' => 'promoted'], - ['user_id' => 'u3', 'stage' => 'estagio-que-nao-existe'], - 'nem array', - ], - ]); - - expect($sujo->promotions)->toHaveCount(1) - ->and($sujo->promotions[0]->userId)->toBe('u1'); -}); - -it('substitui um estágio sem tocar o outro', function (): void { - $config = new DeckConfig(promotions: [ - new PromotionEntry('u1', PromotionStage::Spotlight), - new PromotionEntry('u2', PromotionStage::Promoted), - ]); - - $novo = $config->withPromotionsFor(PromotionStage::Promoted, [new PromotionEntry('u9', PromotionStage::Promoted)]); - - expect($novo->promotionsFor(PromotionStage::Promoted))->toHaveCount(1) - ->and($novo->promotionsFor(PromotionStage::Promoted)[0]->userId)->toBe('u9') - ->and($novo->promotionsFor(PromotionStage::Spotlight))->toHaveCount(1) - ->and($novo->promotionsFor(PromotionStage::Spotlight)[0]->userId)->toBe('u1') - // A cópia original segue intacta. - ->and($config->promotionsFor(PromotionStage::Promoted)[0]->userId)->toBe('u2'); -}); - -it('preserva as promoções ao mexer em ordem, on/off e exclusions', function (): void { - $config = new DeckConfig(promotions: [new PromotionEntry('u1', PromotionStage::Promoted)]); - - $mexido = $config - ->withOrder(['discord']) - ->withSourceVisible('discord', visible: false) - ->withSlideVisible('github.repos', visible: false) - ->withExclusionsFor('github', ['pr:1']); - - expect($mexido->promotions)->toHaveCount(1) - ->and($mexido->promotions[0]->userId)->toBe('u1'); -}); diff --git a/app-modules/community/tests/Unit/Retrospective/RetrospectiveSnapshotTest.php b/app-modules/community/tests/Unit/Retrospective/RetrospectiveSnapshotTest.php index cd5a938ea..5fba5ca91 100644 --- a/app-modules/community/tests/Unit/Retrospective/RetrospectiveSnapshotTest.php +++ b/app-modules/community/tests/Unit/Retrospective/RetrospectiveSnapshotTest.php @@ -2,14 +2,11 @@ declare(strict_types=1); -use Carbon\CarbonImmutable; use He4rt\Community\Retrospective\DTOs\HeadlineMetrics; use He4rt\Community\Retrospective\DTOs\Metric; -use He4rt\Community\Retrospective\DTOs\PromotionCard; use He4rt\Community\Retrospective\DTOs\RetrospectiveSnapshot; use He4rt\Community\Retrospective\DTOs\SourceFilters; use He4rt\Community\Retrospective\DTOs\SourceResult; -use He4rt\Community\Retrospective\Enums\PromotionStage; use He4rt\Community\Retrospective\Slides\FrozenSlide; it('faz round-trip e reidrata slides como FrozenSlide', function (): void { @@ -80,29 +77,3 @@ expect($restored->filters->hideBots)->toBeTrue() ->and($restored->filters->exclusions)->toBeEmpty(); }); - -it('congela e reidrata o member_since dos cartões da tag', function (): void { - $card = new PromotionCard( - userId: 'u1', - name: 'Fulana', - username: 'fulana', - avatar: 'a.png', - stage: PromotionStage::Promoted, - memberSince: CarbonImmutable::parse('2020-03-01T00:00:00+00:00'), - ); - - $payload = $card->toArray(); - $hydrated = PromotionCard::makeFromPayload($payload); - - expect($payload['member_since'])->toBe('2020-03-01T00:00:00+00:00') - ->and($hydrated?->memberSince?->toIso8601String())->toBe('2020-03-01T00:00:00+00:00'); -}); - -it('cartão congelado antes do member_since reidrata sem a data', function (): void { - $hydrated = PromotionCard::makeFromPayload([ - 'user_id' => 'u1', - 'stage' => 'promoted', - ]); - - expect($hydrated?->memberSince)->toBeNull(); -}); diff --git a/app-modules/contents/src/Models/ContentEntry.php b/app-modules/contents/src/Models/ContentEntry.php index b50d1dddc..cd09d6f34 100644 --- a/app-modules/contents/src/Models/ContentEntry.php +++ b/app-modules/contents/src/Models/ContentEntry.php @@ -5,7 +5,6 @@ namespace He4rt\Contents\Models; use Carbon\CarbonInterface; -use He4rt\Activity\Tracking\Contracts\ContributionDetail; use He4rt\Contents\Casts\AsTagList; use He4rt\Contents\Data\TagList; use He4rt\Contents\Database\Factories\ContentEntryFactory; @@ -41,7 +40,7 @@ */ #[UseFactory(factoryClass: ContentEntryFactory::class)] #[Table(name: 'content_entries')] -final class ContentEntry extends Model implements ContributionDetail +final class ContentEntry extends Model { /** @use HasFactory */ use HasFactory; @@ -77,21 +76,6 @@ public function author(): BelongsTo return $this->belongsTo(User::class, 'author_id'); } - public function contributionTitle(): string - { - return $this->title; - } - - public function contributionContext(): string - { - return $this->provider->getLabel(); - } - - public function contributionUrl(): string - { - return $this->url; - } - protected static function newFactory(): ContentEntryFactory { return ContentEntryFactory::new(); diff --git a/app-modules/gamification/src/Character/Models/Character.php b/app-modules/gamification/src/Character/Models/Character.php index fe27e96a7..a6b6efe00 100644 --- a/app-modules/gamification/src/Character/Models/Character.php +++ b/app-modules/gamification/src/Character/Models/Character.php @@ -5,6 +5,7 @@ namespace He4rt\Gamification\Character\Models; use Carbon\CarbonInterface; +use He4rt\Activity\Tracking\Concerns\HasInteractions; use He4rt\Economy\Concerns\HasWallet; use He4rt\Gamification\Badge\Models\Badge; use He4rt\Gamification\Database\Factories\CharacterFactory; @@ -40,6 +41,7 @@ final class Character extends Model { /** @use HasFactory */ use HasFactory; + use HasInteractions; use HasUuids; use HasWallet; diff --git a/app-modules/he4rt/resources/css/index.css b/app-modules/he4rt/resources/css/index.css index d733865fd..d7203c9fa 100644 --- a/app-modules/he4rt/resources/css/index.css +++ b/app-modules/he4rt/resources/css/index.css @@ -17,7 +17,6 @@ @import './components/text.css' layer(components); @import './components/icon.css' layer(components); @import './components/ticket.css' layer(components); -@import './components/contribution-panorama.css' layer(components); @import './components/partials/author.css' layer(components); @import './components/partials/footer.css' layer(components); diff --git a/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php b/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php index f270932e8..3357ed60b 100644 --- a/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php +++ b/app-modules/identity/src/Auth/Actions/MergeAccountsAction.php @@ -27,6 +27,8 @@ public function execute(User $currentUser, User $oldUser): void event(new AccountsMerged($oldUser->id, $currentUser->id)); + event(new AccountsMerged($oldUser->id, $currentUser->id)); + $currentUser->delete(); $this->enrichOldUser($currentUser, $oldUser); diff --git a/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php b/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php index 1a0cffbbb..d53ead97b 100644 --- a/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php +++ b/app-modules/identity/src/ExternalIdentity/Models/ExternalIdentity.php @@ -15,7 +15,6 @@ use He4rt\Identity\User\Models\User; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Table; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -93,21 +92,6 @@ protected static function newFactory(): ExternalIdentityFactory return ExternalIdentityFactory::new(); } - /** - * Identidade que uma pessoa realmente conectou, e não um registro criado por - * ingestão. As datas sozinhas não separam os dois: a ETL também preenche - * connected_at. O que só existe no fluxo OAuth é a credencial. - * - * @param Builder<$this> $query - */ - protected function scopeActivelyConnected(Builder $query): void - { - $query - ->whereNotNull('connected_at') - ->whereNull('disconnected_at') - ->whereRaw("coalesce(credentials::jsonb->>'access_token', '') <> ''"); - } - protected function getMessagesCountAttribute(): int { return $this->messages()->count(); diff --git a/app-modules/identity/src/User/Models/User.php b/app-modules/identity/src/User/Models/User.php index 135a18343..86484f368 100644 --- a/app-modules/identity/src/User/Models/User.php +++ b/app-modules/identity/src/User/Models/User.php @@ -9,7 +9,6 @@ use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasName; use Filament\Panel; -use He4rt\Activity\Tracking\Concerns\HasInteractions; use He4rt\Gamification\Character\Models\Character; use He4rt\Identity\Database\Factories\UserFactory; use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; @@ -53,7 +52,6 @@ final class User extends Authenticatable implements FilamentUser, HasMedia, HasN use HasAddress; /** @use HasFactory */ use HasFactory; - use HasInteractions; use HasUuids; use InteractsWithMedia; use Notifiable; diff --git a/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php b/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php index e1c2956e2..f75d4cb39 100644 --- a/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php +++ b/app-modules/integration-discord/src/IntegrationDiscordServiceProvider.php @@ -4,13 +4,11 @@ namespace He4rt\IntegrationDiscord; -use He4rt\Community\Retrospective\Contracts\MembershipDates; use He4rt\IntegrationDiscord\ETL\Console\BackfillVoiceLogsCommand; use He4rt\IntegrationDiscord\ETL\Console\ImportDiscordMessagesCommand; use He4rt\IntegrationDiscord\ETL\Console\ImportDiscordProfilesCommand; use He4rt\IntegrationDiscord\ETL\Console\MergeDuplicateDiscordProfilesCommand; use He4rt\IntegrationDiscord\Models\DiscordEventLog; -use He4rt\IntegrationDiscord\Retrospective\DiscordMembershipDates; use He4rt\IntegrationDiscord\Sync\Console\PurgeUnusedInvitesCommand; use He4rt\IntegrationDiscord\Sync\Console\SyncDiscordGuildCommand; use He4rt\IntegrationDiscord\Sync\Observers\DiscordEventLogObserver; @@ -22,10 +20,6 @@ class IntegrationDiscordServiceProvider extends ServiceProvider { public function register(): void { - // Quem responde "desde quando essa pessoa está na comunidade" para o - // slide da tag: a data mora em discord_members, então o dono dela é aqui. - $this->app->bind(MembershipDates::class, DiscordMembershipDates::class); - $this->app->singleton(DiscordConnector::class, fn (): DiscordConnector => new DiscordConnector( botToken: config()->string('discord.token'), )); diff --git a/app-modules/integration-github/src/Backfill/BackfillRepository.php b/app-modules/integration-github/src/Backfill/BackfillRepository.php index bba69d335..ce2230625 100644 --- a/app-modules/integration-github/src/Backfill/BackfillRepository.php +++ b/app-modules/integration-github/src/Backfill/BackfillRepository.php @@ -268,7 +268,7 @@ function (array $commit) use ($repo, $onProgress): void { */ private function record(NewContributionDTO $contribution, ?callable $onProgress): void { - $recorded = $this->recorder->execute($contribution, emit: true); + $recorded = $this->recorder->execute($contribution); if ($onProgress !== null) { $onProgress($contribution, $recorded->wasRecentlyCreated); diff --git a/app-modules/integration-github/src/Contributions/RecordContribution.php b/app-modules/integration-github/src/Contributions/RecordContribution.php index 089ed1652..8c2bf16d4 100644 --- a/app-modules/integration-github/src/Contributions/RecordContribution.php +++ b/app-modules/integration-github/src/Contributions/RecordContribution.php @@ -5,31 +5,18 @@ namespace He4rt\IntegrationGithub\Contributions; use He4rt\IntegrationGithub\Contributions\DTOs\NewContributionDTO; -use He4rt\IntegrationGithub\Enums\ContributionType; -use He4rt\IntegrationGithub\Events\GithubContributionChanged; use He4rt\IntegrationGithub\Events\GithubContributionRecorded; use He4rt\IntegrationGithub\Models\GithubContribution; /** - * Idempotent writer for contributions, shared by backfill and webhook ingestion. - * Convergence is guaranteed by the unique (repo, type, external_ref) key. - * - * Ambos os caminhos emitem: um registro que entra pelo backfill precisa alcançar - * o Tracking igual ao que entra pelo webhook, senão recuperar webhook perdido - * deixa o hub cego para tudo que o backfill trouxer. + * Idempotent writer for contributions, shared by backfill (bulk, silent) and + * webhook ingestion (live, emits the seam event). Convergence is guaranteed by + * the unique (repo, type, external_ref) key. */ final class RecordContribution { public function execute(NewContributionDTO $contribution, bool $emit = false): GithubContribution { - $existing = GithubContribution::query() - ->where('repo', $contribution->repo) - ->where('type', $contribution->type) - ->where('external_ref', $contribution->externalRef) - ->first(); - - $wasMerged = $this->isMerged($existing?->metadata); - $recorded = GithubContribution::query()->updateOrCreate( [ 'repo' => $contribution->repo, @@ -45,39 +32,13 @@ public function execute(NewContributionDTO $contribution, bool $emit = false): G ], ); - if (!$emit) { - return $recorded; - } - - if ($recorded->wasRecentlyCreated) { + // Só emite na criação. Webhooks de edição/replay reprocessam a mesma + // contribuição (updateOrCreate atualiza a linha) e não devem re-disparar a + // seam — evita recompensas duplicadas em listeners downstream. + if ($emit && $recorded->wasRecentlyCreated) { event(new GithubContributionRecorded($recorded)); - - return $recorded; - } - - if ($this->justMerged($recorded, $wasMerged)) { - event(new GithubContributionChanged($recorded)); } return $recorded; } - - /** - * Única transição que vira fato novo. Edição de título, synchronize e mudança - * de state seguem mudando a linha em silêncio. - */ - private function justMerged(GithubContribution $recorded, bool $wasMerged): bool - { - return $recorded->type === ContributionType::Pr - && !$wasMerged - && $this->isMerged($recorded->metadata); - } - - /** - * @param array|null $metadata - */ - private function isMerged(?array $metadata): bool - { - return ($metadata['merged'] ?? false) === true; - } } diff --git a/app-modules/integration-github/src/Enums/ContributionType.php b/app-modules/integration-github/src/Enums/ContributionType.php index eb8717079..921bb9d89 100644 --- a/app-modules/integration-github/src/Enums/ContributionType.php +++ b/app-modules/integration-github/src/Enums/ContributionType.php @@ -25,17 +25,4 @@ public function ref(int|string $id): string { return $this->value.':'.$id; } - - /** - * Se a própria referência da contribuição é o número que a identifica no repo. - * Comentários e reviews penduram-se num número alheio, guardado em target_ref; - * commit não tem número nenhum. - */ - public function carriesNumber(): bool - { - return match ($this) { - self::Pr, self::Issue => true, - self::Review, self::Comment, self::ReviewComment, self::Commit => false, - }; - } } diff --git a/app-modules/integration-github/src/Events/GithubContributionRecorded.php b/app-modules/integration-github/src/Events/GithubContributionRecorded.php index b5a4fead0..390b70666 100644 --- a/app-modules/integration-github/src/Events/GithubContributionRecorded.php +++ b/app-modules/integration-github/src/Events/GithubContributionRecorded.php @@ -9,9 +9,9 @@ use Illuminate\Queue\SerializesModels; /** - * Seam de criação: emitida quando uma contribuição inédita é registrada, tanto pelo - * webhook quanto pelo backfill. O Tracking a escuta para resolver a identidade - * conectada do contribuidor e registrar a contribuição canônica. + * Seam for downstream gamification: emitted when a live GitHub contribution is + * recorded. A future listener (in activity/economy) can resolve the contributor's + * Character via ExternalIdentity and award coins/xp. integration-github stays decoupled. */ final readonly class GithubContributionRecorded { diff --git a/app-modules/integration-github/src/IntegrationGithubServiceProvider.php b/app-modules/integration-github/src/IntegrationGithubServiceProvider.php index 7bf01d9ee..e944a91f9 100644 --- a/app-modules/integration-github/src/IntegrationGithubServiceProvider.php +++ b/app-modules/integration-github/src/IntegrationGithubServiceProvider.php @@ -4,19 +4,10 @@ namespace He4rt\IntegrationGithub; -use He4rt\Identity\ExternalIdentity\Events\ExternalIdentityConnected; use He4rt\IntegrationGithub\Console\BackfillGithubCommand; -use He4rt\IntegrationGithub\Console\ProjectGithubContributionsCommand; -use He4rt\IntegrationGithub\Contributions\Listeners\QueueContributionAdoption; -use He4rt\IntegrationGithub\Contributions\TrackGithubContribution; -use He4rt\IntegrationGithub\Events\GithubContributionChanged; -use He4rt\IntegrationGithub\Events\GithubContributionRecorded; -use He4rt\IntegrationGithub\Models\GithubContribution; use He4rt\IntegrationGithub\Retrospective\GithubSource; use He4rt\IntegrationGithub\Transport\GitHubApiConnector; use He4rt\IntegrationGithub\Transport\GitHubOAuthConnector; -use Illuminate\Database\Eloquent\Relations\Relation; -use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use RuntimeException; @@ -46,19 +37,8 @@ public function register(): void public function boot(): void { - Relation::morphMap([ - 'github_contribution' => GithubContribution::class, - ]); - - Event::listen(GithubContributionRecorded::class, [TrackGithubContribution::class, 'onRecorded']); - Event::listen(GithubContributionChanged::class, [TrackGithubContribution::class, 'onChanged']); - Event::listen(ExternalIdentityConnected::class, [QueueContributionAdoption::class, 'handle']); - if ($this->app->runningInConsole()) { - $this->commands([ - BackfillGithubCommand::class, - ProjectGithubContributionsCommand::class, - ]); + $this->commands([BackfillGithubCommand::class]); } } } diff --git a/app-modules/integration-github/src/Models/GithubContribution.php b/app-modules/integration-github/src/Models/GithubContribution.php index 4a79d3e59..7f4234825 100644 --- a/app-modules/integration-github/src/Models/GithubContribution.php +++ b/app-modules/integration-github/src/Models/GithubContribution.php @@ -5,7 +5,6 @@ namespace He4rt\IntegrationGithub\Models; use Carbon\CarbonInterface; -use He4rt\Activity\Tracking\Contracts\ContributionDetail; use He4rt\IntegrationGithub\Database\Factories\GithubContributionFactory; use He4rt\IntegrationGithub\Enums\ContributionType; use Illuminate\Database\Eloquent\Attributes\Table; @@ -27,51 +26,12 @@ * @property CarbonInterface|null $updated_at */ #[Table(name: 'github_contributions')] -final class GithubContribution extends Model implements ContributionDetail +final class GithubContribution extends Model { /** @use HasFactory */ use HasFactory; use HasUuids; - /** - * O lake só guarda título para PR e issue. Para o resto, o nome legível é a - * própria referência — inventar um título aqui seria mentir com mais letras. - */ - public function contributionTitle(): string - { - $title = $this->metadata['title'] ?? null; - - if (is_string($title) && $title !== '') { - return $title; - } - - return match ($this->type) { - ContributionType::Commit => 'Commit '.mb_substr($this->localRef(), 0, 7), - ContributionType::Review => 'Revisão submetida', - ContributionType::ReviewComment => 'Comentário em linha', - ContributionType::Comment => 'Comentário', - default => $this->localRef(), - }; - } - - public function contributionContext(): string - { - $target = $this->target_ref ?? ($this->type->carriesNumber() ? $this->external_ref : null); - - if ($target === null) { - return $this->repo; - } - - return $this->repo.' #'.$this->numberFrom($target); - } - - public function contributionUrl(): ?string - { - $url = $this->metadata['url'] ?? null; - - return is_string($url) && $url !== '' ? $url : null; - } - protected static function newFactory(): GithubContributionFactory { return GithubContributionFactory::new(); @@ -89,14 +49,4 @@ protected function casts(): array 'metadata' => 'array', ]; } - - private function localRef(): string - { - return explode(':', $this->external_ref, 2)[1] ?? $this->external_ref; - } - - private function numberFrom(string $ref): string - { - return explode(':', $ref, 2)[1] ?? $ref; - } } diff --git a/app-modules/integration-github/src/Retrospective/GithubSource.php b/app-modules/integration-github/src/Retrospective/GithubSource.php index c0365b658..fa41b47e7 100644 --- a/app-modules/integration-github/src/Retrospective/GithubSource.php +++ b/app-modules/integration-github/src/Retrospective/GithubSource.php @@ -8,19 +8,15 @@ use Carbon\CarbonInterface; use Carbon\Exceptions\InvalidFormatException; use He4rt\Community\Retrospective\Contracts\CuratableSource; -use He4rt\Community\Retrospective\Contracts\MeasuresPerson; use He4rt\Community\Retrospective\Contracts\RetrospectiveSource; use He4rt\Community\Retrospective\Contracts\Slide; use He4rt\Community\Retrospective\DTOs\ExclusionCandidate; use He4rt\Community\Retrospective\DTOs\HeadlineMetrics; use He4rt\Community\Retrospective\DTOs\Metric; use He4rt\Community\Retrospective\DTOs\Period; -use He4rt\Community\Retrospective\DTOs\PersonAccount; -use He4rt\Community\Retrospective\DTOs\PersonIdentity; use He4rt\Community\Retrospective\DTOs\SlideDescriptor; use He4rt\Community\Retrospective\DTOs\SourceFilters; use He4rt\Community\Retrospective\DTOs\SourceResult; -use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; use He4rt\IntegrationGithub\Enums\ContributionType; use He4rt\IntegrationGithub\Models\GithubContribution; use He4rt\IntegrationGithub\Retrospective\Slides\GithubCommunitySlide; @@ -28,7 +24,6 @@ use He4rt\IntegrationGithub\Retrospective\Slides\GithubHighlightsSlide; use He4rt\IntegrationGithub\Retrospective\Slides\GithubPanoramaSlide; use He4rt\IntegrationGithub\Retrospective\Slides\GithubRepoSlide; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; @@ -40,7 +35,7 @@ * como card se tiverem PR no recorte; atividade só de review/issue/comentário * segue contando em meta/people/highlights. */ -final class GithubSource implements CuratableSource, MeasuresPerson, RetrospectiveSource +final class GithubSource implements CuratableSource, RetrospectiveSource { /** * Teto das varreduras de curadoria: o picker do Deck Builder mostra os itens @@ -60,7 +55,24 @@ public function label(): string public function collect(Period $period, SourceFilters $filters): SourceResult { - $contributions = $this->contributions($period, $filters); + // Instante do merge por PR (repo + external_ref => merged_at), montado de uma + // query própria porque o PR-alvo pode ter mesclado FORA do período do recorte. + $mergedAt = $this->mergedAtIndex(); + + /** @var Collection $contributions */ + $contributions = GithubContribution::query() + ->whereBetween('occurred_at', [$period->since, $period->until]) + ->get() + ->when( + $filters->hideBots, + fn (Collection $items): Collection => $items->reject(fn (GithubContribution $contribution): bool => $this->isBot($contribution)), + ) + // Exclusion mexe no dado (ADR-0001): o item sai dos slides e também + // dos números, então é derrubado aqui, antes de qualquer agregação. + ->reject(fn (GithubContribution $contribution): bool => $this->isExcluded($contribution, $filters)) + ->reject(fn (GithubContribution $contribution): bool => $this->isEmptyTestPr($contribution)) + ->reject(fn (GithubContribution $contribution): bool => $this->isPostMergeNoise($contribution, $mergedAt)) + ->values(); if ($contributions->isEmpty()) { return new SourceResult($this->key(), $this->label(), new HeadlineMetrics(), []); @@ -93,7 +105,6 @@ public function collect(Period $period, SourceFilters $filters): SourceResult // Repos exibidos = só os com PR no recorte (mesmo universo dos cards). 'repos' => count($repos), 'total' => $contributions->count(), - 'days' => max(1, (int) ceil($period->since->diffInDays($period->until))), ]; return new SourceResult( @@ -104,36 +115,6 @@ public function collect(Period $period, SourceFilters $filters): SourceResult ); } - /** - * O que esta fonte sabe sobre UMA pessoa no recorte, para o slide da tag He4rt. - * - * Casa por `actor_id` e não por login: o número é estável quando alguém - * renomeia a conta, e é a mesma chave que o identity guarda em - * `external_account_id` — é ela que liga o Discord ao GitHub sem tabela de - * tradução nenhuma. - * - * @return list - */ - public function measure(PersonIdentity $person, Period $period, SourceFilters $filters): array - { - $account = $person->account(IdentityProvider::GitHub->value); - - if (!$account instanceof PersonAccount || !is_numeric($account->accountId)) { - return []; - } - - $actorId = (int) $account->accountId; - - /** @var list $metrics */ - $metrics = Cache::remember( - 'retrospective.measure.'.$this->key().'.'.$period->cacheKey().'.'.$this->filtersKey($filters).'.'.$actorId, - now()->addMinutes(5), - fn (): array => $this->personMetrics($actorId, $period, $filters), - ); - - return $metrics; - } - /** * @return list */ @@ -163,71 +144,6 @@ public function exclusionCandidates(Period $period): array return $candidates; } - /** - * As contribuições do recorte já limpas: bots fora, exclusions fora, ruído de - * pós-merge fora. Um dono só do pipeline — o cartão de uma pessoa precisa - * contar exatamente o que os slides contaram, senão o mesmo PR apareceria - * escondido num lugar e somado no outro. - * - * @return Collection - */ - private function contributions(Period $period, SourceFilters $filters, ?int $actorId = null): Collection - { - // Instante do merge por PR (repo + external_ref => merged_at), montado de uma - // query própria porque o PR-alvo pode ter mesclado FORA do período do recorte. - $mergedAt = $this->mergedAtIndex(); - - /** @var Collection $contributions */ - $contributions = GithubContribution::query() - ->whereBetween('occurred_at', [$period->since, $period->until]) - ->when($actorId !== null, fn (Builder $query): Builder => $query->where('actor_id', $actorId)) - ->get() - ->when( - $filters->hideBots, - fn (Collection $items): Collection => $items->reject(fn (GithubContribution $contribution): bool => $this->isBot($contribution)), - ) - // Exclusion mexe no dado (ADR-0001): o item sai dos slides e também - // dos números, então é derrubado aqui, antes de qualquer agregação. - ->reject(fn (GithubContribution $contribution): bool => $this->isExcluded($contribution, $filters)) - ->reject(fn (GithubContribution $contribution): bool => $this->isEmptyTestPr($contribution)) - ->reject(fn (GithubContribution $contribution): bool => $this->isPostMergeNoise($contribution, $mergedAt)) - ->values(); - - return $contributions; - } - - /** - * Código entregue, código revisado e o tamanho do que mexeu — a leitura mais - * curta de "essa pessoa sustentou os repositórios". Métrica zerada não entra: - * "0 reviews" ocupa espaço no cartão para dizer que não há o que dizer. - * - * @return list - */ - private function personMetrics(int $actorId, Period $period, SourceFilters $filters): array - { - $contributions = $this->contributions($period, $filters, $actorId); - - $metrics = [ - new Metric('PRs', $this->countType($contributions, ContributionType::Pr)), - new Metric('Reviews', $this->countType($contributions, ContributionType::Review)), - new Metric('Linhas somadas', $this->sumMeta($contributions, 'additions')), - ]; - - return array_values(array_filter( - $metrics, - static fn (Metric $metric): bool => $metric->value > 0, - )); - } - - /** - * Identidade dos filtros na chave de cache. Sem isso, mexer numa exclusion e - * reabrir o builder em menos de cinco minutos mostraria o número anterior. - */ - private function filtersKey(SourceFilters $filters): string - { - return md5(($filters->hideBots ? '1' : '0').'|'.implode(',', $filters->exclusions)); - } - /** * PRs e issues do recorte, os maiores primeiro (o que aparece nos cards de * repositório e nos destaques é justamente o que o operador quer poder @@ -495,23 +411,12 @@ private function repos(Collection $contributions): array ->sortByDesc(fn (array $pr): int => $pr['additions'] + $pr['deletions']) ->values() ->all(), - // Quem mais abriu PR primeiro; presença sem PR (review, - // issue, comentário) fica no fim com métricas zeradas — o - // trilho do slide separa os dois grupos por esse critério. 'people' => $items ->groupBy('actor_login') - ->map(function (Collection $group, string $login): array { - $authored = $group->filter(fn (GithubContribution $contribution): bool => $contribution->type === ContributionType::Pr); - - return [ - 'login' => $login, - 'avatar' => $this->avatar($login, $group->first()?->actor_id), - 'prs' => $authored->count(), - 'additions' => $this->sumMeta($authored, 'additions'), - 'deletions' => $this->sumMeta($authored, 'deletions'), - ]; - }) - ->sort(fn (array $a, array $b): int => [$b['prs'], $b['additions'] + $b['deletions']] <=> [$a['prs'], $a['additions'] + $a['deletions']]) + ->map(fn (Collection $group, string $login): array => [ + 'login' => $login, + 'avatar' => $this->avatar($login, $group->first()?->actor_id), + ]) ->values() ->all(), 'metrics' => [ diff --git a/app-modules/integration-github/tests/Feature/Retrospective/GithubSourceTest.php b/app-modules/integration-github/tests/Feature/Retrospective/GithubSourceTest.php index 67c1fb9fe..cc5f0601f 100644 --- a/app-modules/integration-github/tests/Feature/Retrospective/GithubSourceTest.php +++ b/app-modules/integration-github/tests/Feature/Retrospective/GithubSourceTest.php @@ -99,7 +99,6 @@ function ghHighlights(SourceResult $result): array expect($meta['people'])->toBe(2) ->and($meta['total'])->toBe(3) - ->and($meta['days'])->toBe(7) ->and($people[0]['login'])->toBe('maria') ->and($people[0]['total'])->toBe(2) ->and($people[0]['prs'])->toBe(1) @@ -220,20 +219,6 @@ function ghHighlights(SourceResult $result): array ->and($highlights[0]['repo'])->toBe('he4rt/heartdevs.com'); }); -it('quebra as pessoas do repo por PRs e churn, autores antes de quem só esteve por perto', function (): void { - ghContribution(['actor_login' => 'joao', 'repo' => 'he4rt/x', '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]]); - ghContribution(['actor_login' => 'ana', 'repo' => 'he4rt/x', 'type' => ContributionType::Review, 'external_ref' => 'review:1', 'occurred_at' => '2026-06-02']); - ghContribution(['actor_login' => 'maria', 'repo' => 'he4rt/x', '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' => 5]]); - - $people = ghRepos(($this->collect)())[0]['people']; - - expect(array_column($people, 'login'))->toBe(['maria', 'joao', 'ana']) - ->and($people[0]['prs'])->toBe(1) - ->and($people[0]['additions'])->toBe(500) - ->and($people[0]['deletions'])->toBe(100) - ->and($people[2]['prs'])->toBe(0); -}); - 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']); diff --git a/app-modules/panel-admin/resources/views/retrospective/build-deck.blade.php b/app-modules/panel-admin/resources/views/retrospective/build-deck.blade.php index 53e81ada9..8b2a3322d 100644 --- a/app-modules/panel-admin/resources/views/retrospective/build-deck.blade.php +++ b/app-modules/panel-admin/resources/views/retrospective/build-deck.blade.php @@ -191,8 +191,7 @@ class="mx-auto min-w-0 max-w-[var(--builder-max-width)]" @php $deck = $this->deck; $groups = $this->filmstrip; - $closingIndex = $this->closingIndex(); - $promotions = $this->promotionStrip(); + $closingIndex = $this->composedOffset() + count($this->composedKinds); $about = \He4rt\Portal\Retrospective\AboutSection::slides(); @endphp @@ -280,21 +279,6 @@ class="flex aspect-video shrink-0 items-center justify-center rounded-lg border @endforeach - {{-- O ritual da tag: posição fixa antes do fecho, então o bloco não - tem setas de ordem — só o on/off de cada slide, no inspector. --}} - - @foreach ($promotions as $slide) - - @endforeach - - {{-- Fecho: sempre o último slide do deck. --}} seleção precisa da - * FORMA do deck, e resolvê-la a cada clique pagaria uma coleta ao vivo. - * - * @var list - */ - #[Locked] - public array $promotionKinds = []; - /** * Versão do preview, que entra na key do deck. O `updated_at` sozinho não basta: * dois salvamentos no mesmo segundo dariam a mesma key e o Livewire morfaria o @@ -130,7 +112,6 @@ public function mount(int|string $record): void $this->record = $this->resolveRecord($record); $this->composedKinds = $this->composeKinds(); - $this->promotionKinds = $this->composePromotionKinds(); $this->fillInspector(); } @@ -146,21 +127,7 @@ public function getTitle(): string */ public function slideTotal(): int { - return $this->closingIndex() + 1; - } - - /** - * Onde começa o ritual da tag: depois de todos os slides de fonte. Dono único - * do deslocamento do fim do deck, como composedOffset é do começo. - */ - public function promotionOffset(): int - { - return $this->composedOffset() + count($this->composedKinds); - } - - public function closingIndex(): int - { - return $this->promotionOffset() + count($this->promotionKinds); + return $this->composedOffset() + count($this->composedKinds) + 1; } /** @@ -188,7 +155,6 @@ public function selectionLabel(): string InspectorMode::About => $this->aboutLabel($selection->requireTarget()), InspectorMode::Source => $this->sourceLabel($selection->requireTarget()), InspectorMode::Slide => $this->slideLabelWithSource($selection->requireTarget()), - InspectorMode::Promotion => $this->promotionLabel($selection->requireTarget()), }; } @@ -289,7 +255,6 @@ public function save(): void InspectorMode::Closing => $this->saveClosing($record, $data), InspectorMode::Source => $this->saveSource($record, $selection->requireTarget(), $data), InspectorMode::Slide => $this->saveSlide($record, $selection->requireTarget(), $data), - InspectorMode::Promotion => $this->savePromotion($record, $selection->requireTarget(), $data), }; $this->refreshPreview(); @@ -399,40 +364,6 @@ public function filmstrip(): array $this->deckSnapshot(), $this->getRetrospective()->deck_config, $this->composedKinds, - $this->composedOffset(), - ); - } - - /** - * As miniaturas do ritual da tag, no mesmo formato dos slides de fonte. - * - * Sai do CATÁLOGO e do snapshot cru, como o resto da tira: um slide desligado - * continua aqui, apagado, porque é nesta célula que mora o caminho de volta. - * Sem ninguém escolhido não há miniatura — mas o cartão fica, com o rótulo - * dizendo que o slide existe e está vazio. - * - * @return list - */ - public function promotionStrip(): array - { - $cards = $this->deckSnapshot()->promotions; - $config = $this->getRetrospective()->deck_config; - - return array_map( - function (PromotionSlide $slide) use ($cards, $config): FilmstripSlide { - $filled = $slide->withCards(PromotionSection::cardsFor($cards, $slide->stage)); - $position = array_search($slide->kind, $this->promotionKinds, strict: true); - - return new FilmstripSlide( - kind: $slide->kind, - label: $slide->label, - visible: $config->showsSlide($slide->kind), - view: $filled->isEmpty() ? null : $filled->view(), - props: ['cards' => $filled->cards], - index: $position === false ? null : $this->promotionOffset() + $position, - ); - }, - PromotionSection::catalog(), ); } @@ -442,25 +373,11 @@ function (PromotionSlide $slide) use ($cards, $config): FilmstripSlide { * própria composição, sem o markup precisar anunciar o kind. */ public function previewIndex(): int - { - return $this->previewTarget() ?? 0; - } - - /** - * O mesmo índice, mas honesto sobre a ausência: null quando o que está - * selecionado NÃO está no deck — slide desligado, kind sem dado no recorte, - * ritual sem ninguém escolhido. - * - * A distinção existe por causa do clique numa miniatura vazia: tratar isso - * como "índice 0" mandava o preview e a tira de volta para a capa, arrastando - * o operador para longe justamente quando ele foi preencher o slide. - */ - public function previewTarget(): ?int { $kinds = $this->composedKinds; if ($kinds === []) { - return null; + return 0; } $selection = $this->selection(); @@ -469,8 +386,7 @@ public function previewTarget(): ?int InspectorMode::Cover => 0, InspectorMode::About => $this->aboutIndex($selection->requireTarget()), // O fecho é o último slide, depois de tudo. - InspectorMode::Closing => $this->closingIndex(), - InspectorMode::Promotion => $this->promotionIndex($selection->requireTarget()), + InspectorMode::Closing => $this->composedOffset() + count($kinds), InspectorMode::Slide => $this->slideIndex($kinds, fn (array $slide): bool => $slide['kind'] === $selection->requireTarget()), InspectorMode::Source => $this->slideIndex($kinds, fn (array $slide): bool => $slide['source'] === $selection->requireTarget()), }; @@ -537,184 +453,9 @@ private function selectionAtIndex(int $index): InspectorSelection // A capa e a seção fixa ocupam o começo do deck; o resto são os compostos. $slide = $kinds[$index - $this->composedOffset()] ?? null; - if ($slide !== null) { - return new InspectorSelection(InspectorMode::Slide, $slide['kind']); - } - - $promotion = $this->promotionKinds[$index - $this->promotionOffset()] ?? null; - - return $promotion === null + return $slide === null ? new InspectorSelection(InspectorMode::Closing) - : new InspectorSelection(InspectorMode::Promotion, $promotion); - } - - /** - * Os slides do ritual que o deck está desenhando, na ordem. Sai do MESMO - * PromotionSection que o portal usa para desenhar — se o painel recontasse a - * regra de "tem gente e está ligado", uma divergência mandaria o preview para - * o slide errado. - * - * @return list - */ - private function composePromotionKinds(): array - { - /** @var list $slides */ - $slides = $this->deck()['promotions']; - - return array_map(static fn (PromotionSlide $slide): string => $slide->kind, $slides); - } - - /** - * Onde o slide do ritual caiu no deck. Um kind fora da composição (desligado, - * ou sem ninguém escolhido) não tem posição: a capa é o fallback honesto, - * igual ao slideIndex(). - */ - private function promotionIndex(string $kind): ?int - { - $position = array_search($kind, $this->promotionKinds, strict: true); - - return $position === false ? null : $this->promotionOffset() + $position; - } - - private function promotionLabel(string $kind): string - { - $slide = PromotionSection::find($kind); - - return $slide instanceof PromotionSlide - ? InspectorMode::Promotion->getLabel().' / '.$slide->label - : InspectorMode::Promotion->getLabel(); - } - - /** - * O estágio que o slide selecionado edita. Um kind desconhecido (token velho - * na wire) cai em destaque — o estágio que não entrega tag a ninguém. - */ - private function promotionStage(string $kind): PromotionStage - { - return PromotionSection::find($kind)->stage ?? PromotionStage::Spotlight; - } - - /** - * Inspector do ritual: o on/off do slide e a lista de pessoas daquele estágio. - * - * O estágio vem do SLIDE, não de um campo: no slide de destaques o operador - * edita destaques, no da tag edita quem recebeu. Um seletor de estágio dentro - * da lista permitiria mover alguém para um slide que não está na tela. - * - * @return array - */ - private function promotionComponents(string $kind): array - { - $slide = PromotionSection::find($kind); - $stage = $this->promotionStage($kind); - - return [ - Section::make($slide instanceof PromotionSlide ? $slide->label : InspectorMode::Promotion->getLabel()) - ->compact() - ->icon($stage->getIcon()) - ->description($slide instanceof PromotionSlide ? $slide->hint : $stage->getDescription()) - ->schema([ - Toggle::make('visible') - ->label('Exibir no deck') - ->helperText('Sem ninguém na lista o slide não é desenhado, mesmo ligado.'), - - Repeater::make('people') - ->label($stage->getLabel()) - ->helperText('Os números saem do recorte; aqui só se escolhe quem aparece e por quê.') - ->addActionLabel('Adicionar pessoa') - ->reorderable() - ->collapsible() - ->itemLabel(function (array $state): ?string { - $userId = $state['user_id'] ?? null; - - return is_string($userId) ? PromotablePeople::labelFor($userId) : null; - }) - ->schema([ - Select::make('user_id') - ->label('Pessoa') - ->required() - ->distinct() - ->searchable() - ->helperText('Só aparece quem tem Discord ou GitHub vinculado — sem conta não há número para mostrar.') - ->getSearchResultsUsing(fn (string $search): array => PromotablePeople::search($search)) - ->getOptionLabelUsing(fn (?string $value): ?string => PromotablePeople::labelFor($value)), - - TextInput::make('reason') - ->label('Motivo') - ->placeholder('segurou o #ajuda o ano inteiro') - ->maxLength(160), - ]), - ]), - ]; - } - - /** - * @param array $data - */ - private function savePromotion(Retrospective $record, string $kind, array $data): void - { - $stage = $this->promotionStage($kind); - $config = $record->deck_config->withSlideVisible($kind, (bool) ($data['visible'] ?? true)); - - // Compara ASSINATURAS e não os DTOs: são objetos novos a cada leitura do - // jsonb, então `!==` seria sempre verdadeiro e o aviso de republicar - // apareceria mesmo quando nada mudou. - $before = $this->signaturesOf($config->promotionsFor($stage)); - $config = $config->withPromotionsFor($stage, $this->submittedPeople($stage, $data)); - - $record->update(['deck_config' => $config]); - - if ($this->signaturesOf($config->promotionsFor($stage)) !== $before) { - $this->warnAboutPromotions(); - } - } - - /** - * @param list $entries - * @return list - */ - private function signaturesOf(array $entries): array - { - return array_map( - static fn (PromotionEntry $entry): string => $entry->signature(), - $entries, - ); - } - - /** - * @param array $data - * @return list - */ - private function submittedPeople(PromotionStage $stage, array $data): array - { - $rows = is_array($data['people'] ?? null) ? $data['people'] : []; - $entries = []; - - foreach ($rows as $row) { - if (!is_array($row)) { - continue; - } - - $entry = PromotionEntry::makeFromPayload([...$row, 'stage' => $stage->value]); - - // Linha em branco (o Repeater cria uma antes de o operador escolher - // alguém) some em silêncio: não é erro, é uma escolha inacabada. - if ($entry instanceof PromotionEntry) { - $entries[] = $entry; - } - } - - return $entries; - } - - private function warnAboutPromotions(): void - { - Notification::make() - ->warning() - ->title('Lista da tag alterada') - ->body('Os números de quem aparece são medidos no recorte. Republique a edição para recompilar o snapshot.') - ->persistent() - ->send(); + : new InspectorSelection(InspectorMode::Slide, $slide['kind']); } /** @@ -744,7 +485,7 @@ private function composeKinds(): array * @param list $kinds * @param callable(array{kind: string, source: string}): bool $matches */ - private function slideIndex(array $kinds, callable $matches): ?int + private function slideIndex(array $kinds, callable $matches): int { foreach ($kinds as $position => $slide) { if ($matches($slide)) { @@ -752,9 +493,9 @@ private function slideIndex(array $kinds, callable $matches): ?int } } - // Selecionado algo que o deck não está mostrando (desligado, ou sem dado - // no recorte): não há para onde levar o preview. - return null; + // Selecionado algo que o deck não está mostrando (desligado, ou sem dado no + // recorte): a capa é o fallback honesto. + return 0; } /** @@ -770,7 +511,6 @@ private function inspectorComponents(): array InspectorMode::Closing => $this->closingComponents(), InspectorMode::Source => $this->sourceComponents($selection->requireTarget()), InspectorMode::Slide => $this->slideComponents($selection->requireTarget()), - InspectorMode::Promotion => $this->promotionComponents($selection->requireTarget()), }; } @@ -791,11 +531,11 @@ private function aboutLabel(string $key): string /** * Onde o slide fixo caiu no deck: logo depois da capa, na ordem da seção. */ - private function aboutIndex(string $key): ?int + private function aboutIndex(string $key): int { $position = AboutSection::positionOf($key); - return $position === null ? null : $position + 1; + return $position === null ? 0 : $position + 1; } /** @@ -993,16 +733,6 @@ private function inspectorState(): array InspectorMode::Slide => [ 'visible' => $config->showsSlide($selection->requireTarget()), ], - InspectorMode::Promotion => [ - 'visible' => $config->showsSlide($selection->requireTarget()), - 'people' => array_map( - static fn (PromotionEntry $entry): array => [ - 'user_id' => $entry->userId, - 'reason' => $entry->reason, - ], - $config->promotionsFor($this->promotionStage($selection->requireTarget())), - ), - ], }; } @@ -1186,7 +916,6 @@ private function refreshPreview(): void unset($this->deck, $this->filmstrip); $this->composedKinds = $this->composeKinds(); - $this->promotionKinds = $this->composePromotionKinds(); $this->previewVersion++; @@ -1213,18 +942,9 @@ private function refreshPreview(): void */ private function showSelectedSlide(): void { - $index = $this->previewTarget(); - - // Nada a mostrar: o operador clicou num slide que o deck não desenha. O - // preview fica onde está — mandá-lo para a capa desfaria o scroll da tira - // no exato momento em que ele foi preencher aquele slide. - if ($index === null) { - return; - } - $this->js(sprintf( "requestAnimationFrame(() => window.dispatchEvent(new CustomEvent('retro-goto', { detail: { index: %d } })))", - $index, + $this->previewIndex(), )); } } diff --git a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/DeckFilmstrip.php b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/DeckFilmstrip.php index dbc3f9c43..e31f3d870 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/DeckFilmstrip.php +++ b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/DeckFilmstrip.php @@ -25,13 +25,9 @@ final class DeckFilmstrip /** * @param list $composedKinds a forma do deck * renderizado, para casar cada miniatura com o slide que ela representa - * @param int $offset quantos slides o deck desenha ANTES do primeiro composto - * (capa + seção fixa). Vem de fora porque quem conta isso é a Page, - * que já é dona do deslocamento — recontar aqui foi como a tira - * ficou três posições adiantada quando a seção "A He4rt" entrou. * @return list */ - public static function groups(RetrospectiveSnapshot $snapshot, DeckConfig $config, array $composedKinds = [], int $offset = 1): array + public static function groups(RetrospectiveSnapshot $snapshot, DeckConfig $config, array $composedKinds = []): array { $slidesBySource = []; @@ -39,7 +35,7 @@ public static function groups(RetrospectiveSnapshot $snapshot, DeckConfig $confi $slidesBySource[$source->key] = $source->slides; } - $indices = self::indices($composedKinds, $offset); + $indices = self::indices($composedKinds); return array_map( static fn (SourceBlock $block): FilmstripGroup => new FilmstripGroup( @@ -65,12 +61,13 @@ public static function groups(RetrospectiveSnapshot $snapshot, DeckConfig $confi * @param list $composedKinds * @return array> */ - private static function indices(array $composedKinds, int $offset): array + private static function indices(array $composedKinds): array { $indices = []; foreach ($composedKinds as $position => $entry) { - $indices[$entry['source'].'|'.$entry['kind']][] = $position + $offset; + // +1: a capa ocupa o índice 0 do deck. + $indices[$entry['source'].'|'.$entry['kind']][] = $position + 1; } return $indices; diff --git a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/InspectorMode.php b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/InspectorMode.php index 72e966462..8e7c71853 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/InspectorMode.php +++ b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/InspectorMode.php @@ -15,9 +15,6 @@ * distintos de seleção, cada um escrevendo onde a Fase 2 já escrevia (ADR-0002) * —, então cada caso tem cor própria, sem rampa. * - * `Promotion` é o único que escreve PESSOAS: a curadoria dele mexe no dado - * exibido, como as exclusions, então salvar ali avisa que é preciso republicar. - * * `About` é o caso que NÃO edita: a seção sobre a He4rt é copy fixa no portal. * Ele existe mesmo assim porque a tira precisa de um alvo para aqueles slides — * sem modo próprio, clicar na miniatura mandaria o preview para a capa. @@ -28,7 +25,6 @@ enum InspectorMode: string implements HasColor, HasDescription, HasIcon, HasLabe case About = 'about'; case Source = 'source'; case Slide = 'slide'; - case Promotion = 'promotion'; case Closing = 'closing'; public function getLabel(): string @@ -38,7 +34,6 @@ public function getLabel(): string self::About => 'A He4rt', self::Source => 'Bloco de fonte', self::Slide => 'Slide', - self::Promotion => 'A tag He4rt', self::Closing => 'Fecho', }; } @@ -50,7 +45,6 @@ public function getColor(): string self::About => 'warning', self::Source => 'info', self::Slide => 'success', - self::Promotion => 'danger', self::Closing => 'gray', }; } @@ -62,7 +56,6 @@ public function getDescription(): string self::About => 'Apresentação fixa da comunidade. Copy no portal, não na edição.', self::Source => 'Exibir a fonte e curar o que ela esconde do deck.', self::Slide => 'Exibir este tipo de slide. O toggle vale para o kind inteiro.', - self::Promotion => 'Quem aparece no ritual da tag. Mexe nos números: exige republicar.', self::Closing => 'A mensagem que fecha o deck.', }; } @@ -74,7 +67,7 @@ public function getDescription(): string public function editable(): bool { return match ($this) { - self::Cover, self::Source, self::Slide, self::Promotion, self::Closing => true, + self::Cover, self::Source, self::Slide, self::Closing => true, self::About => false, }; } @@ -86,7 +79,6 @@ public function getIcon(): Heroicon self::About => Heroicon::OutlinedHeart, self::Source => Heroicon::OutlinedSquares2x2, self::Slide => Heroicon::OutlinedRectangleGroup, - self::Promotion => Heroicon::OutlinedHeart, self::Closing => Heroicon::OutlinedChatBubbleBottomCenterText, }; } diff --git a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/InspectorSelection.php b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/InspectorSelection.php index 49ca2ba08..abc93fefe 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/InspectorSelection.php +++ b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/InspectorSelection.php @@ -33,9 +33,8 @@ public static function parse(string $token): self return match (true) { $mode === InspectorMode::Cover, $mode === InspectorMode::Closing => new self($mode), - // Bloco, slide, seção fixa e promoção são inúteis sem alvo: sem ele, - // cai para a capa. - (in_array($mode, [InspectorMode::About, InspectorMode::Source, InspectorMode::Slide, InspectorMode::Promotion], strict: true)) && $target !== null && $target !== '' => new self($mode, $target), + // Bloco, slide e seção fixa são inúteis sem alvo: sem ele, cai para a capa. + (in_array($mode, [InspectorMode::About, InspectorMode::Source, InspectorMode::Slide], strict: true)) && $target !== null && $target !== '' => new self($mode, $target), default => self::cover(), }; } diff --git a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/InspectorViewPath.php b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/InspectorViewPath.php index 441f9abcf..dc57a8784 100644 --- a/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/InspectorViewPath.php +++ b/app-modules/panel-admin/src/Filament/Resources/Retrospectives/Support/InspectorViewPath.php @@ -24,7 +24,7 @@ public static function for(InspectorSelection $selection): ?string InspectorMode::Cover => SlideView::cover(), InspectorMode::About => SlideView::about($selection->requireTarget()), InspectorMode::Closing => SlideView::closing(), - InspectorMode::Slide, InspectorMode::Promotion => SlideView::kind($selection->requireTarget()), + InspectorMode::Slide => SlideView::kind($selection->requireTarget()), InspectorMode::Source => null, }; diff --git a/app-modules/panel-admin/src/PanelAdminServiceProvider.php b/app-modules/panel-admin/src/PanelAdminServiceProvider.php index 45ee5059e..975218700 100644 --- a/app-modules/panel-admin/src/PanelAdminServiceProvider.php +++ b/app-modules/panel-admin/src/PanelAdminServiceProvider.php @@ -8,15 +8,11 @@ use Filament\Navigation\NavigationGroup; use Filament\Navigation\NavigationItem; use Filament\Panel; -use Filament\Support\Assets\AlpineComponent; -use Filament\Support\Facades\FilamentAsset; -use He4rt\PanelAdmin\Contributions\Widgets\ActivityTimelineWidget; use He4rt\PanelAdmin\Discord\DiscordCluster; use He4rt\PanelAdmin\Enums\NavigationGroup as NavGroup; use He4rt\PanelAdmin\Filament\Resources\ContentEntries\ContentEntryResource; use He4rt\PanelAdmin\Filament\Resources\Events\EventResource; use He4rt\PanelAdmin\Filament\Resources\ExternalIdentities\ExternalIdentityResource; -use He4rt\PanelAdmin\Filament\Resources\Interactions\InteractionResource; use He4rt\PanelAdmin\Filament\Resources\Profiles\ProfileResource; use He4rt\PanelAdmin\Filament\Resources\Retrospectives\RetrospectiveResource; use He4rt\PanelAdmin\Filament\Resources\Skills\SkillResource; @@ -52,9 +48,6 @@ public function register(): void DiscordCluster::class, ]) ->navigation($this->buildNavigation(...)) - ->widgets([ - ActivityTimelineWidget::class, - ]) ->resources([ ExternalIdentityResource::class, EventResource::class, @@ -62,7 +55,6 @@ public function register(): void ProfileResource::class, SkillResource::class, ContentEntryResource::class, - InteractionResource::class, RetrospectiveResource::class, ]) ->discoverResources( @@ -109,13 +101,6 @@ public function boot(): void $this->loadViewsFrom(__DIR__.'/../resources/views', 'panel-admin'); $this->loadTranslationsFrom(__DIR__.'/../lang', 'panel-admin'); - FilamentAsset::register([ - AlpineComponent::make( - 'activity-timeline', - __DIR__.'/../resources/js/components/activity-timeline.js', - ), - ], package: 'he4rt/panel-admin'); - Livewire::component('moderation-queue', ModerationQueue::class); Livewire::component('appeal-queue', AppealQueue::class); Livewire::component('moderation-dashboard', ModerationDashboardLivewire::class); @@ -174,7 +159,6 @@ private function defaultNavigation(NavigationBuilder $builder): NavigationBuilde ->icon(NavGroup::Content->getIcon()) ->items([ ...ContentEntryResource::getNavigationItems(), - ...InteractionResource::getNavigationItems(), ...RetrospectiveResource::getNavigationItems(), ]), ]); diff --git a/app-modules/panel-admin/tests/Feature/NavigationGroupsTest.php b/app-modules/panel-admin/tests/Feature/NavigationGroupsTest.php index 590d41376..a1d1f931d 100644 --- a/app-modules/panel-admin/tests/Feature/NavigationGroupsTest.php +++ b/app-modules/panel-admin/tests/Feature/NavigationGroupsTest.php @@ -65,7 +65,7 @@ function navigationItemLabels(NavigationGroup $group): array test('a navegação padrão expõe o grupo Conteúdo com o que a comunidade publica', function (): void { $content = adminNavigationGroup(NavGroup::Content->getLabel()); - expect(navigationItemLabels($content))->toBe(['Artigos', 'Contribuições', 'Retrospectivas']); + expect(navigationItemLabels($content))->toBe(['Artigos', 'Retrospectivas']); }); test('os clusters seguem como itens de topo, fora de grupo', function (): void { diff --git a/app-modules/panel-admin/tests/Feature/Retrospective/BuildDeckTest.php b/app-modules/panel-admin/tests/Feature/Retrospective/BuildDeckTest.php index 15b2afaf7..7181dd68d 100644 --- a/app-modules/panel-admin/tests/Feature/Retrospective/BuildDeckTest.php +++ b/app-modules/panel-admin/tests/Feature/Retrospective/BuildDeckTest.php @@ -8,29 +8,20 @@ use He4rt\Community\Retrospective\Actions\CompileSnapshot; use He4rt\Community\Retrospective\Contracts\Slide; use He4rt\Community\Retrospective\DTOs\DeckConfig; -use He4rt\Community\Retrospective\DTOs\Metric; use He4rt\Community\Retrospective\DTOs\Period; -use He4rt\Community\Retrospective\DTOs\PromotionCard; -use He4rt\Community\Retrospective\DTOs\PromotionEntry; -use He4rt\Community\Retrospective\DTOs\PromotionMetricGroup; use He4rt\Community\Retrospective\DTOs\RetrospectiveSnapshot; use He4rt\Community\Retrospective\DTOs\SourceFilters; use He4rt\Community\Retrospective\DTOs\SourceResult; -use He4rt\Community\Retrospective\Enums\PromotionStage; use He4rt\Community\Retrospective\Enums\RetrospectiveStatus; use He4rt\Community\Retrospective\Jobs\CompileRetrospectiveSnapshot; use He4rt\Community\Retrospective\Models\Retrospective; -use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider; -use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity; use He4rt\Identity\User\Models\User; use He4rt\IntegrationGithub\Models\GithubContribution; use He4rt\PanelAdmin\Filament\Resources\Retrospectives\Pages\BuildDeck; use He4rt\PanelAdmin\Filament\Resources\Retrospectives\RetrospectiveResource; use He4rt\PanelAdmin\Filament\Resources\Retrospectives\Support\InspectorMode; -use He4rt\PanelAdmin\Filament\Resources\Retrospectives\Support\PromotablePeople; use He4rt\Portal\Retrospective\AboutSection; use He4rt\Portal\Retrospective\DeckPresentation; -use He4rt\Portal\Retrospective\PromotionSection; use Illuminate\Support\Facades\Bus; use Tests\Support\Retrospective\PlainRetrospectiveSource; @@ -625,194 +616,3 @@ function contributionWithin(Retrospective $retrospective, string $ref): GithubCo expect($islands[0])->not->toBeEmpty()->each->not->toContain('wire:click'); }); - -/** - * Snapshot com o ritual da tag dentro, já medido. Monta os cartões à mão em vez - * de passar pelo ComposePromotions: o que estes testes verificam é o - * POSICIONAMENTO e a curadoria, não a medição — que tem teste próprio no - * community. - */ -function publishedRetrospectiveWithPromotions(): Retrospective -{ - $since = CarbonImmutable::parse('2026-06-01 00:00:00'); - $until = CarbonImmutable::parse('2026-06-30 23:59:59'); - - // Com uma fonte de verdade dentro: sem nenhuma o portal desenha só o slide - // "sem dado", e um deck vazio não teria índice para o ritual ocupar. - GithubContribution::factory()->create([ - 'actor_login' => 'maria', - 'external_ref' => 'pr:1', - 'occurred_at' => '2026-06-02', - 'metadata' => ['title' => 'Um PR', 'state' => 'open', 'merged' => false, 'additions' => 10], - ]); - - $people = User::factory()->count(3)->create(); - - $card = fn (string $id, PromotionStage $stage): PromotionCard => new PromotionCard( - userId: $id, - name: 'Fulana '.$id, - username: 'fulana'.$id, - avatar: 'https://example.test/'.$id.'.png', - stage: $stage, - reason: 'segurou o #ajuda', - groups: [new PromotionMetricGroup('discord', 'Discord', [new Metric('Mensagens', 8_132)])], - ); - - $collected = resolve(CompileSnapshot::class)->execute(Period::of($since, $until), new SourceFilters()); - - $snapshot = new RetrospectiveSnapshot( - sources: $collected->sources, - filters: new SourceFilters(), - promotions: [ - $card($people[0]->id, PromotionStage::Spotlight), - $card($people[1]->id, PromotionStage::Promoted), - $card($people[2]->id, PromotionStage::Promoted), - ], - ); - - return Retrospective::factory()->published($snapshot)->create([ - 'since' => $since, - 'until' => $until, - 'deck_config' => new DeckConfig( - order: ['github', 'discord'], - promotions: [ - new PromotionEntry($people[0]->id, PromotionStage::Spotlight, 'segurou o #ajuda'), - new PromotionEntry($people[1]->id, PromotionStage::Promoted, 'segurou o #ajuda'), - new PromotionEntry($people[2]->id, PromotionStage::Promoted, 'segurou o #ajuda'), - ], - ), - ]); -} - -it('seleciona um slide do ritual pela tira, mesmo sem ninguém escolhido ainda', function (): void { - $retrospective = retrospectiveWithOrder(); - - $page = livewire(BuildDeck::class, ['record' => $retrospective->getKey()]) - ->call('select', InspectorMode::Promotion->value.':'.PromotionSection::SPOTLIGHT) - ->assertSet('selection', 'promotion:'.PromotionSection::SPOTLIGHT) - ->assertHasNoErrors(); - - // Sem gente escolhida o slide não existe no deck: não há para onde levar o - // preview, e é isso que impede a tira de rolar de volta para a capa quando o - // operador clica na miniatura vazia para preenchê-la. - expect($page->instance()->previewTarget())->toBeNull() - ->and($page->instance()->selectionLabel())->toContain('Destaques'); -}); - -it('não mexe no preview ao selecionar um slide que o deck não desenha', function (): void { - $retrospective = publishedRetrospectiveWithGithub(); - - $page = livewire(BuildDeck::class, ['record' => $retrospective->getKey()]); - - // Kind existente no catálogo do GitHub, mas sem dado neste recorte. - $page->call('select', InspectorMode::Slide->value.':discord.voice_board'); - - expect($page->instance()->previewTarget())->toBeNull(); - - $page->assertNotDispatched('retro-goto'); -}); - -it('escreve as pessoas do estágio do slide selecionado, sem tocar o outro estágio', function (): void { - $retrospective = retrospectiveWithOrder(); - $user = User::factory()->create(['username' => 'fulana']); - - livewire(BuildDeck::class, ['record' => $retrospective->getKey()]) - ->call('select', InspectorMode::Promotion->value.':'.PromotionSection::TAG) - ->fillForm([ - 'visible' => true, - 'people' => [['user_id' => $user->id, 'reason' => 'revisou PR de todo mundo']], - ]) - ->call('save') - ->assertHasNoErrors(); - - $config = $retrospective->fresh()->deck_config; - - expect($config->promotionsFor(PromotionStage::Promoted))->toHaveCount(1) - ->and($config->promotionsFor(PromotionStage::Promoted)[0]->userId)->toBe($user->id) - ->and($config->promotionsFor(PromotionStage::Promoted)[0]->reason)->toBe('revisou PR de todo mundo') - ->and($config->promotionsFor(PromotionStage::Spotlight))->toBeEmpty(); -}); - -it('põe o ritual entre os slides das fontes e o fecho', function (): void { - $retrospective = publishedRetrospectiveWithPromotions(); - - $page = livewire(BuildDeck::class, ['record' => $retrospective->getKey()]); - $instance = $page->instance(); - - $offset = $instance->composedOffset() + count($instance->composedKinds); - - expect($instance->promotionOffset())->toBe($offset) - ->and($instance->promotionKinds)->toBe([PromotionSection::SPOTLIGHT, PromotionSection::TAG]) - ->and($instance->closingIndex())->toBe($offset + 2) - ->and($instance->slideTotal())->toBe($offset + 3); - - $page->call('select', InspectorMode::Promotion->value.':'.PromotionSection::TAG); - - expect($page->instance()->previewIndex())->toBe($offset + 1); -}); - -it('navegar até o slide do ritual dentro do deck seleciona o ritual, não o fecho', function (): void { - $retrospective = publishedRetrospectiveWithPromotions(); - - $page = livewire(BuildDeck::class, ['record' => $retrospective->getKey()]); - $offset = $page->instance()->promotionOffset(); - - $page->call('selectByIndex', $offset) - ->assertSet('selection', 'promotion:'.PromotionSection::SPOTLIGHT) - ->call('selectByIndex', $offset + 1) - ->assertSet('selection', 'promotion:'.PromotionSection::TAG) - ->call('selectByIndex', $offset + 2) - ->assertSet('selection', InspectorMode::Closing->value); -}); - -it('a tira numera as miniaturas com o MESMO deslocamento do deck', function (): void { - $retrospective = publishedRetrospectiveWithGithub(); - - $instance = livewire(BuildDeck::class, ['record' => $retrospective->getKey()])->instance(); - - $indices = []; - - foreach ($instance->filmstrip() as $group) { - foreach ($group->slides as $slide) { - if ($slide->index !== null) { - $indices[] = $slide->index; - } - } - } - - // A primeira miniatura navegável cai logo depois da capa e da seção fixa — - // não em 1. Quando isto quebrou, clicar num slide acendia o vizinho três - // posições à frente. - expect($indices)->not->toBeEmpty() - ->and(min($indices))->toBe($instance->composedOffset()) - ->and($indices)->toBe(array_unique($indices)); -}); - -test('person search only offers who has a linked account', function (): void { - $linked = User::factory()->create(['name' => 'Ada Lovelace', 'username' => 'ada']); - $loose = User::factory()->create(['name' => 'Ada Sem Conta', 'username' => 'ada-solta']); - - ExternalIdentity::factory()->create([ - 'model_type' => (new User)->getMorphClass(), - 'model_id' => $linked->id, - 'provider' => IdentityProvider::Discord, - 'disconnected_at' => null, - ]); - - ExternalIdentity::factory()->create([ - 'model_type' => (new User)->getMorphClass(), - 'model_id' => $loose->id, - 'provider' => IdentityProvider::Discord, - 'disconnected_at' => now(), - ]); - - $results = PromotablePeople::search('ada'); - - expect($results)->toHaveKey($linked->id) - ->and($results[$linked->id])->toBe('Ada Lovelace (@ada)') - ->and($results)->not->toHaveKey($loose->id); -}); - -test('person label ignores an id that is not a uuid', function (): void { - expect(PromotablePeople::labelFor('u2'))->toBeNull(); -}); diff --git a/app-modules/portal/resources/css/retrospective.css b/app-modules/portal/resources/css/retrospective.css index f85ccbe44..5bf7c9b0a 100644 --- a/app-modules/portal/resources/css/retrospective.css +++ b/app-modules/portal/resources/css/retrospective.css @@ -114,8 +114,7 @@ --t-issue: #f6b73c; --t-commit: #b39bff; --t-review: #2dd4bf; - /* Azul mais escuro que o lilás de commit: separa o par sob protanopia (ΔE 12.2). */ - --t-comment: #3f7ce0; + --t-comment: #62a6ff; --t-review-comment: #7c83ff; --st-merged: #a06bff; --st-open: #34d399; @@ -503,185 +502,6 @@ margin-top: 9px; } -/* panorama do github */ -.retro .faint { - color: var(--faint); -} -.retro .pan-hero { - font-family: 'Fraunces', serif; - font-weight: 600; - font-size: clamp(4rem, 11cqw, 7.2rem); - line-height: 0.95; - letter-spacing: -0.02em; - color: var(--brand-soft); - margin-top: 0.18em; -} -.retro .pan-lead { - font-family: 'JetBrains Mono', monospace; - font-size: 1.02rem; - color: var(--muted); - margin: 14px 0 0; -} -.retro .pan-lead b { - color: var(--text); - font-weight: 600; -} -.retro .pan-grid { - display: grid; - grid-template-columns: 1.35fr 1fr; - gap: 18px 56px; - align-items: start; - margin-top: 8px; -} -.retro .pan-sec { - display: flex; - align-items: center; - gap: 10px; - font-family: 'JetBrains Mono', monospace; - font-size: 0.76rem; - letter-spacing: 0.22em; - text-transform: uppercase; - color: var(--brand-soft); - margin: 30px 0 14px; -} -.retro .pan-sec::before, -.retro .pan-sec::after { - content: ''; - height: 1px; - background: var(--border); - width: 26px; -} -.retro .pan-sec::after { - flex: 0 1 90px; -} -.retro .pan-rows { - display: flex; - flex-direction: column; - gap: 12px; -} -.retro .pan-row { - display: grid; - grid-template-columns: 12.5rem 3.6rem minmax(0, 1fr); - align-items: center; - gap: 14px; -} -.retro .pan-row .lbl { - font-family: 'JetBrains Mono', monospace; - font-size: 0.96rem; - color: var(--muted); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.retro .pan-row .num { - font-family: 'JetBrains Mono', monospace; - font-variant-numeric: tabular-nums; - font-size: 1.02rem; - font-weight: 700; - color: var(--text); - text-align: right; -} -.retro .pan-bar { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; - border-left: 1px solid var(--border); - padding-left: 1px; -} -.retro .pan-bar .pct { - font-family: 'JetBrains Mono', monospace; - font-variant-numeric: tabular-nums; - font-size: 0.88rem; - color: var(--faint); - white-space: nowrap; -} -.retro .pan-fill { - display: block; - height: 14px; - border-radius: 0 4px 4px 0; - flex-shrink: 0; -} -.retro .pan-prs-total { - font-family: 'JetBrains Mono', monospace; - font-size: 0.96rem; - color: var(--muted); - margin: 0 0 12px; -} -.retro .pan-prs-total b { - color: var(--text); - font-size: 1.08rem; -} -.retro .pan-stack { - height: 14px; - border-radius: 4px; - overflow: hidden; - display: flex; - gap: 2px; -} -.retro .pan-stack span { - height: 100%; -} -.retro .pan-states { - display: flex; - flex-wrap: wrap; - gap: 8px 18px; - font-family: 'JetBrains Mono', monospace; - font-size: 0.96rem; - color: var(--muted); - margin: 12px 0 0; -} -.retro .pan-states .key { - display: inline-flex; - align-items: center; - gap: 7px; - white-space: nowrap; -} -.retro .pan-states .key i { - width: 9px; - height: 9px; - border-radius: 3px; - flex-shrink: 0; -} -.retro .pan-states .key b { - color: var(--text); - font-variant-numeric: tabular-nums; -} -.retro .pan-diff { - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; - margin-top: 16px; - font-size: 0.9rem; -} -.retro .pan-diff .bdg { - font-size: 0.92rem; - padding: 6px 13px; -} -.retro .pan-insight { - font-size: 1.18rem; - color: var(--muted); - max-width: 82ch; - margin: 34px 0 0; - padding-top: 18px; - border-top: 1px solid var(--border); -} -.retro .pan-insight b { - color: var(--text); -} - -@media (max-width: 900px) { - .retro .pan-grid { - grid-template-columns: minmax(0, 1fr); - gap: 0; - } - .retro .pan-row { - grid-template-columns: 8.5rem 3rem minmax(0, 1fr) 2.6rem; - gap: 8px; - } -} - /* card */ .retro .card { background: var(--surface); @@ -713,10 +533,7 @@ } .retro .pcar-track { display: flex; - /* stretch + régua pregada no rodapé (abaixo): com a anatomia fixa do card - (chips de PR + uma régua de contadores), esticar pela altura do maior é o - que alinha o fundo da fileira — flex-start deixava a borda serrilhada */ - align-items: stretch; + align-items: flex-start; gap: 16px; overflow-x: auto; scroll-snap-type: x proximity; @@ -752,21 +569,6 @@ .retro .pslide { flex: 0 0 clamp(300px, 30%, 460px); scroll-snap-align: start; - display: flex; -} -.retro .pslide .card { - flex: 1; - display: flex; - flex-direction: column; -} -.retro .pslide .card .acts { - flex: 1; - display: flex; - flex-direction: column; -} -.retro .pslide .cstats.strip { - margin-top: auto; - padding-top: 14px; } .retro .pcar-arrow { position: absolute; @@ -817,23 +619,12 @@ display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 14px; - /* stretch: com a anatomia fixa (cabeçalho + barra + pills no rodapé), o que - alinha a linha é esticar pela altura do mais alto e pregar as pills - embaixo — start deixava o fundo da fileira serrilhado */ - align-items: stretch; -} -.retro .pgrid > div { - display: flex; + /* start (não stretch): o card abraça o conteúdo em vez de esticar até a + altura do mais alto da linha — sem "vazião" embaixo dos cards leves */ + align-items: start; } .retro .pgrid .card { padding: 16px 17px 17px; - flex: 1; - display: flex; - flex-direction: column; -} -.retro .pgrid .card .cstats { - margin-top: auto; - padding-top: 13px; } /* badges */ @@ -946,152 +737,7 @@ display: inline-block; } -/* repo — trilho de identidade + grid de PRs. - A lista de PRs não tem teto (todo PR do recorte entra), então uma coluna só - vira um poço de rolagem com metade da tela vazia. O trilho fixa a identidade - do repo à esquerda enquanto os PRs correm em duas colunas; o conjunto sai da - caixa de 1120px pelo mesmo truque do carrossel de pessoas. */ -.retro .repo-layout { - display: grid; - grid-template-columns: 300px minmax(0, 1fr); - gap: 34px; - align-items: start; - width: min(96cqw, 1400px); - margin-left: 50%; - transform: translateX(-50%); -} -.retro .repo-rail { - position: sticky; - top: 84px; -} -.retro .repo-prs { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - align-items: start; -} -/* repo pequeno (≤4 PRs): coluna única com cards ricos em vez de um grid manco; - estreita e centraliza pra não virar três réguas de borda a borda */ -.retro .repo-prs.is-hero { - grid-template-columns: minmax(0, 720px); - justify-content: center; - gap: 14px; - align-self: center; -} -.retro .tpr.is-hero { - padding: 20px 22px; - border-radius: 16px; -} -.retro .tpr.is-hero .d { - font-size: 1.14rem; - line-height: 1.35; -} -.retro .tpr.is-hero .rn { - font-size: 1rem; -} -.retro .st-label { - font-family: 'JetBrains Mono', monospace; - font-size: 0.74rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; -} -@media (max-width: 980px) { - .retro .repo-layout { - grid-template-columns: minmax(0, 1fr); - gap: 22px; - } - .retro .repo-rail { - position: static; - } - .retro .repo-prs { - grid-template-columns: minmax(0, 1fr); - } -} - -/* quem mexeu — quebra por pessoa no trilho. A barra escala pelo maior churn DO - REPO (não da pessoa), então largura é comparação real entre as linhas; dentro - dela o verde/vermelho divide adições e remoções. */ -.retro .rp-list { - margin-top: 24px; - display: flex; - flex-direction: column; - gap: 13px; -} -.retro .rp-title { - font-family: 'JetBrains Mono', monospace; - font-size: 0.68rem; - letter-spacing: 0.2em; - text-transform: uppercase; - color: var(--faint); -} -.retro .rp-id { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} -.retro .rp-login { - font-family: 'JetBrains Mono', monospace; - font-size: 0.8rem; - color: var(--text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.retro .rp-prs { - margin-left: auto; - flex: none; - font-family: 'JetBrains Mono', monospace; - font-size: 0.74rem; - font-weight: 700; - color: var(--brand-soft); -} -.retro .rp-meter { - display: flex; - align-items: center; - gap: 8px; - margin-top: 6px; - /* alinha com o texto: avatar de 24px + gap de 8px */ - padding-left: 32px; -} -.retro .rp-bar { - height: 6px; - min-width: 8px; - border-radius: 999px; - overflow: hidden; - display: inline-flex; - background: var(--surface-2); - border: 1px solid var(--border); -} -.retro .rp-bar .a { - background: var(--add); - height: 100%; -} -.retro .rp-bar .d { - background: var(--del); - height: 100%; -} -.retro .rp-churn { - font-family: 'JetBrains Mono', monospace; - font-size: 0.72rem; - color: #7ef0a3; - flex: none; -} -.retro .rp-churn.is-del { - color: #ff9a9a; -} -.retro .rp-present { - margin-top: 18px; - display: flex; - align-items: center; - gap: 10px; -} -.retro .rp-present-label { - font-size: 0.74rem; - color: var(--faint); -} - +/* repo */ .retro .repo-ic { width: 54px; height: 54px; @@ -1130,21 +776,6 @@ .retro .avstack .mini:first-child { margin-left: 0; } -.retro .closing-wall { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 8px; - margin-top: 30px; -} -.retro .closing-wall .mini { - width: 46px; - height: 46px; - transition: transform 0.25s cubic-bezier(0.2, 0.7, 0.2, 1); -} -.retro .closing-wall .mini:hover { - transform: scale(1.18); -} /* pessoa */ .retro .phead { @@ -1319,19 +950,6 @@ .retro .cstat-n { color: var(--text); } -.retro .cstat-l { - margin-left: 5px; - font-weight: 400; - font-size: 0.72rem; - color: var(--muted); -} -/* régua de contadores do card cheio: fecha o card com uma linha só, no lugar - das linhas rotuladas por tipo (que faziam as alturas divergirem no trilho) */ -.retro .cstats.strip { - margin-top: 14px; - padding-top: 12px; - border-top: 1px solid var(--border); -} /* navbar do deck */ .retro .navbar { @@ -2159,561 +1777,41 @@ color: var(--muted); } -/* - | Canais — constelação. As estrelas dividem a MESMA órbita, equidistantes do - | núcleo: a igualdade entre os canais é geométrica, não declarada. Posição de - | cada estrela (`--sx`/`--sy`) e ângulo de cada raio vêm da partial, calculados - | sobre o total de canais do config — a órbita se redistribui sozinha. - */ -.retro .join-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 440px); - gap: 56px; - align-items: center; -} -.retro .const { - position: relative; - width: 100%; - aspect-ratio: 1; -} -.retro .const-ring { - position: absolute; - top: 50%; - left: 50%; - width: 82%; - height: 82%; - margin: -41% 0 0 -41%; - border: 1px dashed rgba(182, 155, 255, 0.22); - border-radius: 50%; -} -.retro .slide.active .const-ring { - animation: orb-spin 90s linear infinite; -} -.retro .const-spoke { - position: absolute; - top: 50%; - left: 50%; - width: 41%; - height: 1px; - transform-origin: left center; - background: linear-gradient(90deg, rgba(120, 43, 241, 0.35), transparent 85%); -} -/* A faixa de energia do raio: invisível até a vez da estrela dele no ciclo. */ -.retro .const-spoke::after { - content: ''; - position: absolute; - inset: -1px 0; - background: linear-gradient(90deg, var(--ac, var(--brand-2)), transparent 92%); - opacity: 0; -} -.retro .slide.active .const-spoke::after { - animation: const-feed calc(var(--n, 7) * 2.5s) linear infinite; - animation-delay: calc(var(--i, 0) * 2.5s); -} -.retro .const-core { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 104px; - height: 104px; - display: grid; - place-items: center; - border-radius: 50%; - background: radial-gradient(circle at 38% 30%, #2a2140, var(--surface)); - border: 1px solid rgba(120, 43, 241, 0.55); - box-shadow: 0 0 34px -8px rgba(120, 43, 241, 0.6); - font-family: 'Fraunces', serif; - font-style: italic; - font-weight: 600; - font-size: 2rem; - color: var(--brand-soft); -} -.retro .slide.active .const-core { - animation: const-beat 2.4s ease-in-out infinite; -} -@keyframes const-beat { - 0%, - 42%, - 100% { - box-shadow: 0 0 34px -8px rgba(120, 43, 241, 0.6); - } - 16% { - box-shadow: 0 0 44px -6px rgba(196, 75, 255, 0.75); - } -} -.retro .const-star { - position: absolute; - top: var(--sy); - left: var(--sx); - transform: translate(-50%, -50%); +/* Canais — cartão compacto com ícone, nome e domínio. */ +.retro .chan { display: flex; - flex-direction: column; align-items: center; - gap: 7px; - width: 104px; + gap: 13px; text-decoration: none; - color: var(--text); - text-align: center; + color: inherit; } -.retro .const-star-ic { +.retro .chan-ic { display: inline-flex; align-items: center; justify-content: center; - width: 56px; - height: 56px; - border-radius: 50%; - background: var(--surface); - border: 1px solid var(--border); + width: 38px; + height: 38px; + flex: none; + border-radius: 11px; + background: var(--surface-2); color: var(--brand-soft); - transition: - border-color 0.25s ease, - background 0.25s ease, - color 0.25s ease, - transform 0.25s ease, - box-shadow 0.25s ease; -} -.retro .const-star:hover .const-star-ic { - border-color: var(--ac, var(--brand)); - background: var(--ac, var(--brand)); - /* A cor do accent varia do blurple ao branco puro; o ícone assume a cor do - fundo do deck, que contrasta com qualquer uma delas. */ - color: var(--bg); - transform: scale(1.12); - box-shadow: 0 0 34px -6px var(--ac, var(--brand)); -} -.retro .const-star-ic svg { - width: 23px; - height: 23px; -} -/* - | O ciclo de energia: a cada tique de 2,5s a estrela seguinte "recebe o hover" - | por conta própria — um anel sólido na cor do canal, sem desfoque, contorna o - | ícone enquanto o raio dela alimenta o núcleo. Realce por contraste, não por - | brilho: a forma continua nítida para quem enxerga pouco e não vira fonte de - | luz pulsante. O anel vive num pseudo-elemento para o :hover real, que anima - | as mesmas propriedades no ícone, continuar respondendo por cima. - */ -.retro .const-star-ic::after { - content: ''; - position: absolute; - inset: -6px; - border-radius: 50%; - border: 2px solid var(--ac, var(--brand)); - opacity: 0; -} -.retro .const-star-ic { - position: relative; -} -.retro .slide.active .const-star-ic::after { - animation: const-feed calc(var(--n, 7) * 2.5s) linear infinite; - animation-delay: calc(var(--i, 0) * 2.5s); -} -/* Janela de ~1/N do ciclo acesa (≈2,5s), com subida e descida suaves; o resto - do tempo a estrela espera a volta completar. */ -@keyframes const-feed { - 0% { - opacity: 0; - } - 6% { - opacity: 1; - } - 16%, - 100% { - opacity: 0; - } -} -/* Navegação por teclado recebe o mesmo realce do hover: a estrela focada não - pode depender do anel do ciclo para ser encontrada. */ -.retro .const-star:focus-visible { - outline: none; } -.retro .const-star:focus-visible .const-star-ic { - border-color: var(--ac, var(--brand)); - background: var(--ac, var(--brand)); - color: var(--bg); - transform: scale(1.12); -} -.retro .const-star-name { - display: block; - font-weight: 600; - font-size: 0.82rem; - line-height: 1.2; +.retro .chan-ic svg { + width: 19px; + height: 19px; } -.retro .const-star-host { +.retro .chan-name { display: block; - font-family: 'JetBrains Mono', monospace; - font-size: 0.58rem; - color: var(--muted); -} - -@media (max-width: 900px) { - /* Sem largura para a órbita, a constelação vira lista: as estrelas voltam - ao fluxo como chips e núcleo, anel e raios — que só existem para desenhar - a geometria — saem junto. */ - .retro .join-grid { - grid-template-columns: minmax(0, 1fr); - gap: 30px; - } - .retro .const { - aspect-ratio: auto; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 12px; - } - .retro .const-ring, - .retro .const-spoke, - .retro .const-core { - display: none; - } - .retro .const-star { - position: static; - transform: none; - flex-direction: row; - width: auto; - gap: 11px; - text-align: left; - border: 1px solid var(--border); - border-radius: 14px; - background: var(--surface); - padding: 10px 12px; - } - .retro .const-star-ic { - width: 38px; - height: 38px; - flex: none; - } - .retro .const-star-ic svg { - width: 18px; - height: 18px; - } -} - -/* ── ritual da tag He4rt ──────────────────────────────────────────────────── - | - | Revelação por passo. O deck marca `.shown` em cada `[data-reveal]` conforme - | quem apresenta avança, então a transição mora aqui e a decisão de QUANDO mora - | no JS do deck — nenhum dos dois precisa conhecer o conteúdo do outro. - */ -.retro .slide [data-reveal] { - opacity: 0; - transform: translateY(12px); - transition: - opacity 0.5s ease, - transform 0.5s ease; -} -.retro .slide [data-reveal].shown { - opacity: 1; - transform: none; -} - -/* A lista de destaques não tem teto (decisão editorial: ninguém fica de fora), - então ela — e não a página — é quem rola. */ -.retro .promo-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); - gap: 14px; - margin-top: 22px; - max-height: 58cqh; - overflow-y: auto; - padding-right: 4px; -} - -.retro .promo-card { - border: 1px solid var(--border); - border-radius: 14px; - background: var(--surface); - padding: 14px 16px; - text-align: left; -} - -.retro .promo-head { - display: flex; - align-items: center; - gap: 12px; -} - -.retro .promo-avatar { - border-radius: 50%; - object-fit: cover; - box-shadow: - 0 0 0 2px var(--surface), - 0 0 0 4px rgba(120, 43, 241, 0.45); -} -.retro .promo-avatar.big { - box-shadow: - 0 0 0 3px var(--surface), - 0 0 0 6px var(--brand); -} - -.retro .promo-name { - font-weight: 700; - font-size: 1.05rem; - line-height: 1.2; -} -.retro .promo-name.big { - font-family: 'Fraunces', serif; - font-size: 2.4rem; - margin-top: 14px; -} - -.retro .promo-handle { - font-family: 'JetBrains Mono', monospace; - font-size: 0.78rem; - color: var(--muted); -} - -/* Uma faixa por fonte. O nome da plataforma fica colado nos números de propósito: - é o que impede a leitura de somar mensagem com PR (ADR-0001). */ -.retro .promo-source { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 6px 10px; - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--border); -} - -.retro .promo-source-name { - font-family: 'JetBrains Mono', monospace; - font-size: 0.68rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--faint); -} - -.retro .promo-metrics { - display: flex; - flex-wrap: wrap; - gap: 4px 14px; -} - -.retro .promo-metric { - font-size: 0.84rem; - color: var(--muted); -} -.retro .promo-metric b { - color: var(--text); - font-size: 1.02rem; font-weight: 700; } - -.retro .promo-reason { - margin: 12px 0 0; - font-size: 0.92rem; - color: var(--brand-soft); - font-style: italic; -} -.retro .promo-reason.big { - font-size: 1.25rem; - max-width: 46ch; - margin-inline: auto; -} - -/* O palco da entrega: um viewport com profundidade. Os cards empilham na mesma - célula do grid (o mais alto define a altura) e vivem em planos de Z — quem - dita onde cada um está é a coreografia do dolly, mais abaixo. */ -.retro .promo-stage { - display: grid; - place-items: start center; - margin-top: 30px; - perspective: 1400px; - perspective-origin: 50% 40%; -} - -.retro .promo-hero { - grid-area: 1 / 1; - width: min(100%, 520px); -} - -.retro .promo-finale { - display: none; -} - -.retro .promo-hero-metrics { - margin-top: 14px; -} -.retro .promo-hero .promo-source { - justify-content: center; -} - -/* ── coreografia da entrega da tag: o dolly ───────────────────────────────── - | - | O deck só liga `.shown`; a encenação mora aqui. O palco tem profundidade e a - | câmera viaja: quem ainda não foi anunciado espera longe, fora de foco; no seu - | passo, o card chega ao plano focal (rack focus — o blur abre junto com a - | viagem); quando o próximo é anunciado, o anterior sai POR TRÁS da câmera. O - | passo final é o pull-back: todos voltam, lado a lado, para a foto oficial. - | - | "Quem já passou" e "é o finale" são lidos do próprio DOM via :has() — o deck - | não sabe de nada disso, continua só ligando `.shown` na ordem dos passos. - | - | A câmera transita nos DOIS sentidos (voltar um passo desfaz a viagem); os - | gestos internos do card transitam só no `.shown`, para o retorno ser limpo. - */ -.retro .promo-tag .promo-hero[data-reveal] { - opacity: 0; - transform: translateZ(-980px) translateY(12px); - filter: blur(12px); - transition: - opacity 1.1s cubic-bezier(0.72, 0.02, 0.18, 0.99), - transform 1.1s cubic-bezier(0.72, 0.02, 0.18, 0.99), - filter 1.1s cubic-bezier(0.72, 0.02, 0.18, 0.99); -} -.retro .promo-tag .promo-hero[data-reveal].shown { - opacity: 1; - transform: translateZ(0); - filter: blur(0); -} - -/* Já apresentado: com o próximo em cena, o anterior ultrapassa a câmera. */ -.retro .promo-tag .promo-hero[data-reveal].shown:has(~ .promo-hero.shown) { - opacity: 0; - transform: translateZ(430px) translateY(-3%); - filter: blur(14px); - pointer-events: none; -} - -/* O finale: a câmera recua e a fila inteira volta ao plano geral. `--shift` - (posição relativa ao centro) vem da view, que sabe quantos cards existem. */ -.retro .promo-tag:has(.promo-finale.shown) .promo-hero[data-reveal].shown, -.retro .promo-tag:has(.promo-finale.shown) .promo-hero[data-reveal].shown:has(~ .promo-hero.shown) { - opacity: 1; - transform: translateZ(-300px) translateX(calc(var(--shift, 0) * min(42cqw, 480px))) scale(0.94); - filter: blur(0); - pointer-events: auto; -} - -.retro .promo-tag .promo-avatar-ring { - position: relative; - display: inline-block; - border-radius: 50%; -} -.retro .promo-tag .promo-avatar-ring::after { - content: ''; - position: absolute; - inset: -9px; - border-radius: 50%; - border: 2px solid var(--brand-2); - opacity: 0; - pointer-events: none; -} -.retro .promo-tag .promo-hero.shown .promo-avatar-ring::after { - animation: retro-ping 2.6s ease-out infinite; - animation-delay: 1.3s; -} - -.retro .promo-tag .promo-hero .promo-avatar.big { - opacity: 0; - transform: scale(0.72) translateY(14px); - filter: blur(5px); -} -.retro .promo-tag .promo-hero.shown .promo-avatar.big { - opacity: 1; - transform: none; - filter: blur(0); - transition: - opacity 0.5s ease, - transform 0.6s cubic-bezier(0.16, 1.1, 0.3, 1.15), - filter 0.5s ease; - transition-delay: 0.45s; -} - -.retro .promo-tag .promo-hero .promo-name.big, -.retro .promo-tag .promo-hero .promo-handle, -.retro .promo-tag .promo-hero .promo-since { - opacity: 0; - transform: translateY(14px); -} -.retro .promo-tag .promo-hero.shown .promo-name.big, -.retro .promo-tag .promo-hero.shown .promo-handle, -.retro .promo-tag .promo-hero.shown .promo-since { - opacity: 1; - transform: none; - transition: - opacity 0.5s ease, - transform 0.5s cubic-bezier(0.2, 0.7, 0.2, 1); -} -.retro .promo-tag .promo-hero.shown .promo-name.big { - transition-delay: 0.6s; -} -.retro .promo-tag .promo-hero.shown .promo-handle { - transition-delay: 0.72s; -} -.retro .promo-tag .promo-hero.shown .promo-since { - transition-delay: 0.82s; -} - -/* Os números: cada fonte entra na sua vez (`--promo-i` vem da view), e o valor - estoura um tique depois da faixa — o olho lê a plataforma antes do número. */ -.retro .promo-tag .promo-hero-metrics .promo-source { - opacity: 0; - transform: translateY(12px); -} -.retro .promo-tag .promo-hero-metrics.shown .promo-source { - opacity: 1; - transform: none; - transition: - opacity 0.45s ease, - transform 0.45s ease; - transition-delay: calc(var(--promo-i, 0) * 0.16s); -} -.retro .promo-tag .promo-hero-metrics .promo-metric b { - display: inline-block; - opacity: 0; - transform: scale(0.55); -} -.retro .promo-tag .promo-hero-metrics.shown .promo-metric b { - opacity: 1; - transform: none; - transition: - opacity 0.4s ease, - transform 0.5s cubic-bezier(0.2, 0.9, 0.3, 1.4); - transition-delay: calc(var(--promo-i, 0) * 0.16s + 0.14s); -} - -.retro .promo-tag .promo-reason.big[data-reveal] { - transform: translateY(10px) scale(0.98); - letter-spacing: 0.02em; -} -.retro .promo-tag .promo-reason.big[data-reveal].shown { - transform: none; - letter-spacing: normal; - transition: - opacity 0.7s ease, - transform 0.7s ease, - letter-spacing 0.7s ease; -} - -/* Desde quando a pessoa está aqui — a régua que dá peso ao resto do cartão. */ -.retro .promo-since { - margin-top: 9px; +.retro .chan-host { + display: block; font-family: 'JetBrains Mono', monospace; - font-size: 0.74rem; - letter-spacing: 0.06em; + font-size: 0.71rem; color: var(--muted); } -.retro .promo-since b { - color: var(--brand-soft); - font-weight: 600; -} @media (max-width: 900px) { - /* Sem largura para o plano geral, o finale abre mão do 3D e empilha os - cards — a foto oficial vira coluna, todo mundo continua na tela. */ - .retro .promo-tag:has(.promo-finale.shown) .promo-stage { - display: flex; - flex-direction: column; - align-items: center; - gap: 28px; - perspective: none; - } - .retro .promo-tag:has(.promo-finale.shown) .promo-hero[data-reveal].shown, - .retro .promo-tag:has(.promo-finale.shown) .promo-hero[data-reveal].shown:has(~ .promo-hero.shown) { - transform: none; - } - /* Sem largura para a onda, a timeline vira lista — ano e texto já andam juntos na mesma coluna, então basta soltar a quebra e fechar o vão. */ .retro .tl-line { diff --git a/app-modules/portal/resources/views/community-retrospective.blade.php b/app-modules/portal/resources/views/community-retrospective.blade.php index c8ca14559..cd17e1b43 100644 --- a/app-modules/portal/resources/views/community-retrospective.blade.php +++ b/app-modules/portal/resources/views/community-retrospective.blade.php @@ -6,6 +6,7 @@ @else view(), ['cards' => $promotion->cards]) - @endforeach - {{-- navegação + CTAs alinhados à direita (desktop) --}} -