Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
/storage/*.key
/storage/debugbar
/storage/pail
/storage/private/dumps
/vendor
Homestead.json
Homestead.yaml
Expand Down
1 change: 1 addition & 0 deletions CONTEXT-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This is a modular monorepo (`internachi/modular`). Each bounded context lives un
| Bot Discord | `app-modules/bot-discord/` | Discord bot runtime (Laracord websocket, slash commands, event handlers) |
| Integration Discord | `app-modules/integration-discord/` | Discord platform transport (REST API via Saloon), OAuth, ETL |
| Identity | `app-modules/identity/` | Users, tenants, external identities, authentication |
| Panel Admin | `app-modules/panel-admin/` | Filament admin panel — dashboards, resources, moderation UI, marketing |

## Relationships

Expand Down
6 changes: 3 additions & 3 deletions app-modules/bot-discord/src/Events/RawGatewayEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ public function handle(object $payload): void
DiscordEventLog::query()->create([
'event_type' => $payload->t,
'guild_id' => $payload->d->guild_id ?? null,
'user_id' => $payload->d->user_id ?? $payload->d->author->id ?? $payload->d->user->id ?? null,
'channel_id' => $payload->d->channel_id ?? $payload->d->id ?? null,
'payload' => (array) $payload,
'user_id' => $payload->d->user_id ?? $payload->d->author->id ?? null,
Comment thread
danielhe4rt marked this conversation as resolved.
'channel_id' => $payload->d->channel_id ?? null,
'payload' => json_decode(json_encode($payload->d), true),
]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
->and($log->guild_id)->toBe('123456789')
->and($log->user_id)->toBe('987654321')
->and($log->payload)->toBeArray()
->and($log->payload['d']['roles'])->toBe([]);
->and($log->payload['roles'])->toBe([]);
});

test('extracts user_id from author when user_id is absent', function (): void {
Expand All @@ -54,45 +54,6 @@
->and($log->channel_id)->toBe('111222333');
});

test('extracts user_id from user object when user_id and author are absent', function (): void {
$payload = (object) [
'op' => 0,
't' => 'GUILD_MEMBER_UPDATE',
's' => 25,
'd' => (object) [
'guild_id' => '123456789',
'user' => (object) ['id' => '444555666'],
'roles' => [],
],
];

(new RawGatewayEvent)->handle($payload);

$log = DiscordEventLog::query()->first();

expect($log->user_id)->toBe('444555666');
});

test('falls back to id for channel_id when channel_id is absent', function (): void {
$payload = (object) [
'op' => 0,
't' => 'VOICE_CHANNEL_STATUS_UPDATE',
's' => 5,
'd' => (object) [
'id' => '1501350540737646784',
'status' => null,
'guild_id' => '123456789',
],
];

(new RawGatewayEvent)->handle($payload);

$log = DiscordEventLog::query()->first();

expect($log->channel_id)->toBe('1501350540737646784')
->and($log->guild_id)->toBe('123456789');
});

test('skips non-dispatch events without type', function (): void {
$payload = (object) [
'op' => 11,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
@props (['items', 'max' => null, 'ranked' => true, 'labelWidth' => 'w-24', 'order' => 'desc'])
@props ([
'items',
'max' => null,
'ranked' => true,
'labelWidth' => 'w-24',
'order' => 'desc',
'stacked' => false,
'legend' => null
])

@php
$collection = collect($items);
Expand All @@ -19,6 +27,7 @@
$pct = round(($item['value'] / $maxVal) * 100);
$suffix = $item['suffix'] ?? '%';
$display = $item['value'] . $suffix;
$segments = $stacked ? $item['segments'] ?? [] : [];
@endphp
<div class="flex items-center gap-3">
@if ($ranked)
Expand All @@ -32,12 +41,40 @@ class="shrink-0 truncate text-sm font-medium dark:text-zinc-200 {{ $labelWidth }
>{{ $item['label'] }}</span
>
<div class="h-5 min-w-0 flex-1 overflow-hidden rounded bg-zinc-100 dark:bg-zinc-700">
<div
class="h-full rounded transition-all duration-700"
style="width:{{ max($pct, 2) }}%;background:{{ $item['color'] }}"
></div>
@if ($stacked && count($segments) > 0)
<div class="flex h-full" style="width:{{ max($pct, 2) }}%">
@foreach ($segments as $seg)
@php
$segPct = $item['value'] > 0 ? ($seg['value'] / $item['value']) * 100 : 0;
@endphp
@if ($segPct > 0)
<div
class="h-full transition-all duration-700 first:rounded-l last:rounded-r"
style="width:{{ $segPct }}%;background:{{ $seg['color'] }}"
title="{{ $seg['label'] ?? '' }}: {{ $seg['value'] }}"
></div>
@endif
@endforeach
</div>
@else
<div
class="h-full rounded transition-all duration-700"
style="width:{{ max($pct, 2) }}%;background:{{ $item['color'] }}"
></div>
@endif
</div>
<span class="w-10 shrink-0 text-right font-mono text-xs font-semibold text-zinc-400">{{ $display }}</span>
</div>
@endforeach

@if ($stacked && $legend)
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1">
@foreach ($legend as $leg)
<div class="flex items-center gap-1.5">
<div class="h-2.5 w-2.5 rounded-sm" style="background:{{ $leg['color'] }}"></div>
<span class="text-xs font-medium dark:text-zinc-300">{{ $leg['label'] }}</span>
</div>
@endforeach
</div>
@endif
</div>
64 changes: 64 additions & 0 deletions app-modules/panel-admin/CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Panel Admin — Context

The admin panel (`/admin`) is the operational hub for the He4rt Developers community. It is a Filament v5 panel that provides dashboards, resource management, and moderation tools for community administrators.

## Glossary

| Term | Definition | Not to be confused with |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| **Cluster** | A Filament navigation group that acts as a sub-panel with its own sidebar. Implemented as a `Cluster` class with `$slug` and `$shouldRegisterSubNavigation = false`. | Filament NavigationGroup (simpler, no sub-navigation) |
| **Marketing** | Cluster focused on community growth analytics — Discord activity dashboards, meeting showcase. Slug: `marketing`. | The marketing team (people); here it's a panel section |
| **Discord Dashboard** | A Filament Page under Marketing that surfaces Discord community metrics (messages, voice, users) with configurable time ranges and rolling comparisons. | The Discord bot itself (`bot-discord` module) |
| **Meeting Showcase** | A Filament Page under Marketing that generates visual cards of meeting participants for social media sharing. | The `meeting` domain module (data layer) |
| **Rolling comparison** | The pattern of subdividing a selected time range into equal sub-periods to show evolution. 14d→2×7d, 30d→4×7d, 90d→3×30d, etc. | Quarter-based comparison (fixed calendar periods) |
| **Period breakdown** | A widget showing sub-period metrics side by side with multiple visualization modes (summary, table, cards, bars, donut). | The timeline chart (shows daily granularity) |
| **Activity by DOW** | Aggregated activity per day-of-week (Mon→Sun) with toggle between All/Messages/Voice. Uses stacked bar chart in "All" mode. | Heatmap (shows hour×day matrix) |

## Module boundaries

Panel Admin is a **view layer** module. It:

- **Reads from** `activity` (messages, voice), `identity` (external identities), `moderation` (cases, appeals, actions)
- **Never writes** domain data — only reads for display
- **Owns** its Filament Pages, Widgets, Resources, and Blade views
- **Does not** contain domain logic, models, or migrations

## Structure

```
panel-admin/
├── src/
│ ├── Marketing/
│ │ ├── MarketingCluster.php
│ │ ├── Pages/
│ │ │ ├── DiscordDashboard.php
│ │ │ └── MeetingShowcasePage.php
│ │ └── Widgets/
│ │ └── DiscordStatsWidget.php
│ ├── Moderation/
│ │ ├── ModerationCluster.php
│ │ ├── Pages/
│ │ ├── Resources/
│ │ ├── Widgets/
│ │ └── Livewire/
│ ├── Filament/Resources/
│ ├── Pages/
│ │ └── Dashboard.php
│ └── PanelAdminServiceProvider.php
├── resources/views/
│ ├── marketing/
│ └── moderation/
├── lang/{en,pt_BR}/
├── config/panel-admin.php
└── docs/adr/
```
Comment thread
danielhe4rt marked this conversation as resolved.

## Navigation pattern

Each cluster has a dedicated navigation builder method in `PanelAdminServiceProvider`. When the URL path contains the cluster slug (e.g. `marketing/`), the sidebar switches to show a "Back to Admin" link + the cluster's sub-navigation. Default navigation shows all clusters as top-level items.

## Architectural decisions

Recorded in [`docs/adr/`](docs/adr/):

- [ADR-0001](docs/adr/0001-discord-dashboard-architecture.md) — Discord Dashboard: widget structure, rolling comparison pattern, query layer, timezone handling, component extensions
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# ADR-0001: Discord Dashboard Architecture

**Status:** Accepted
**Date:** 2026-05-19
**Deciders:** danielhe4rt

## Context

The He4rt Developers community needs visibility into Discord activity patterns for two purposes:

1. **Scheduling events** — knowing when the community is most active (day-of-week × hour) to pick optimal times for lives, workshops, and meetings
2. **Measuring community health** — tracking whether engagement (messages, voice, unique users) is growing or declining over time

The data already exists in the `messages` and `voice_messages` tables (from the `activity` module), but there is no way to visualize trends, compare periods, or identify peak hours without running raw SQL.

## Decision

### Dashboard structure

A single Filament Page (`DiscordDashboard`) under a new `MarketingCluster`, with the following widgets top-to-bottom:

| Widget | Purpose | Implementation |
| ----------------- | -------------------------------------- | ------------------------------------------------------------------------- |
| Stats overview | Quick KPIs with sparklines | Filament `StatsOverviewWidget` with `->chart()` |
| Period breakdown | Rolling comparison between sub-periods | Custom Blade with 5 switchable views (summary, table, cards, bars, donut) |
| Activity timeline | Daily trend over the full range | Chart.js line chart (msgs + voice + users) |
| Heatmap | Peak hours identification | `x-he4rt::dashboard.heatmap` component (SVG, week variant) |
| Activity by DOW | Best day-of-week for events | `x-he4rt::dashboard.bar-chart` with stacked mode toggle (All/Msgs/Voice) |
| Top channels | Most active channels | `x-he4rt::dashboard.bar-chart` ranked |

### Rolling comparison pattern

Instead of fixed calendar quarters, the selected time range is subdivided into equal sub-periods:

| Range | Subdivision |
| ----- | ----------- |
| 90d | 3 × 30d |
| 60d | 2 × 30d |
| 30d | 4 × ~7d |
| 14d | 2 × 7d |
| 7d | 7 × 1d |
| 1d | 24 × 1h |

**Why:** Rolling comparison adapts to any range and avoids the problem of incomplete quarters. Stats cards show last sub-period vs previous; period breakdown shows all sub-periods.

### Query layer

Each widget has a dedicated `final readonly` query class with a `builder()` method, located in `Marketing/Discord/Queries/`. The Page instantiates queries and passes results to the view. Heatmap data for messages and voice comes from separate query classes — the Page combines them.
Comment thread
danielhe4rt marked this conversation as resolved.

`PeriodStats` is a single class with private methods per metric to keep cohesion while avoiding class explosion.

### Summary view design

The period breakdown's "summary" mode uses a 50/50 layout:

- **Left:** Line chart comparing sub-periods overlaid on the same axis (e.g. Mon→Sun with 4 lines: Msgs W1, Msgs W2, Voice W1, Voice W2). Solid = previous, dashed = current.
- **Right:** Narrative text blocks with trend icons, absolute values, and a contextual recommendation box that adapts based on the data pattern.

### Channel names deferred

Channel IDs are stored as Discord snowflakes with no name resolution. Dedicated Discord entity tables (channels, roles, members) are a separate future initiative. The dashboard shows channel IDs for now.

### Timezone

Fixed to `America/Sao_Paulo`. The community is Brazilian and there is no use case for other timezones. Consistent with the existing Meeting Showcase page.

### Component extensions

The `x-he4rt::dashboard.bar-chart` Blade component was extended with backward-compatible props:

- `stacked` (bool) — enables segmented bars
- `segments` (array per item) — defines bar segments with label, value, color
- `legend` (array) — renders a legend below the chart

All existing usages remain unaffected.

## Alternatives Considered

### A — Filament Widgets for everything

Use `ChartWidget` and `StatsOverviewWidget` for all visualizations. Rejected because: heatmap has no native widget, period breakdown needs 5 switchable views that don't map to any widget, and mixing native widgets with custom Blade creates inconsistent layout control.

### B — Fixed quarter comparison

Compare Q1 vs Q2 vs Q3 with calendar-aligned periods. Rejected because: the current quarter is always incomplete, making the comparison misleading. Rolling comparison is more flexible and avoids this problem.

### C — He4rt design system components for stats

Use `x-he4rt::dashboard.stat-card` instead of Filament's `StatsOverviewWidget`. Rejected because: Filament's widget has built-in sparklines via `->chart()`, lazy loading, and polling — reimplementing these in custom Blade would be wasted effort.

## Consequences

### Positive

- Community managers can identify peak hours at a glance (heatmap + DOW chart)
- Rolling comparison makes weekly/monthly trends visible without manual SQL
- The summary view with narrative text makes data accessible to non-technical team members
- Stacked bar-chart extension is reusable across other dashboards
- No new domain modules or models required — reads from existing tables

### Negative

- Chart.js is loaded via CDN (`@assets`) — adds an external dependency to the admin panel
- Charts inside `x-show` containers require `x-intersect` workarounds for Chart.js canvas sizing
- Channel IDs instead of names degrades UX until Discord entity tables are built

### Risks

- Voice data may be sparse or missing for some periods (observed: zero voice events in recent 14 days). The dashboard must handle empty datasets gracefully.
- Large time ranges (90d) on high-volume servers may produce slow heatmap queries. Mitigated by: indexed columns (`tenant_id, sent_at`), and query classes can add caching later without view changes.
13 changes: 13 additions & 0 deletions app-modules/panel-admin/lang/en/marketing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

return [
'navigation' => [
'cluster' => 'Marketing',
'cluster_breadcrumb' => 'Marketing',
'back_to_admin' => 'Back to Admin',
'meeting_showcase' => 'Meeting Showcase',
'discord_dashboard' => 'Discord Dashboard',
],
];
13 changes: 13 additions & 0 deletions app-modules/panel-admin/lang/pt_BR/marketing.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

return [
'navigation' => [
'cluster' => 'Marketing',
'cluster_breadcrumb' => 'Marketing',
'back_to_admin' => 'Voltar pro Admin',
'meeting_showcase' => 'Meeting Showcase',
'discord_dashboard' => 'Discord Dashboard',
],
];
Loading