feat: add activation rate and user retention widgets to sysadmin dashboard#168
Conversation
Consolidate duplicate stat builder methods, reduce DB queries per interval using PostgreSQL FILTER, and remove redundant comments.
There was a problem hiding this comment.
Pull request overview
This PR adds two new widgets to the system admin dashboard to track user engagement metrics: an activation rate widget that monitors signup conversion and a user retention chart that visualizes new vs. returning active users. The PR also introduces a dedicated Engagement Dashboard page to house these widgets separately from the main dashboard and groups all dashboards under a "Dashboards" navigation category for better organization.
Changes:
- Added ActivationRateWidget displaying signup counts, activated user counts, and activation rate percentage with period-over-period comparison
- Added UserRetentionChartWidget showing new vs returning active users as a stacked bar chart with weekly intervals
- Created EngagementDashboard page to organize activation and retention widgets separately from growth metrics
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| app-modules/SystemAdmin/src/Filament/Widgets/ActivationRateWidget.php | New stats widget tracking signups, activated users (those who created manual records), and activation rate percentage with sparklines and period comparison |
| app-modules/SystemAdmin/src/Filament/Widgets/UserRetentionChartWidget.php | New chart widget displaying weekly breakdown of new vs returning active users as a stacked bar chart |
| app-modules/SystemAdmin/src/Filament/Pages/EngagementDashboard.php | New dashboard page dedicated to user engagement metrics with period filter |
| app-modules/SystemAdmin/src/Filament/Pages/Dashboard.php | Updated main dashboard to be labeled "Growth" and grouped under "Dashboards" navigation, explicitly lists growth-focused widgets |
| app-modules/SystemAdmin/src/SystemAdminPanelProvider.php | Added "Dashboards" navigation group to organize multiple dashboard pages |
| tests/Feature/SystemAdmin/ActivationRateWidgetTest.php | Tests for activation rate widget covering rendering and activation counting logic |
| tests/Feature/SystemAdmin/UserRetentionChartWidgetTest.php | Tests for retention chart widget covering rendering and user classification |
| private function bucketExpression(): string | ||
| { | ||
| if (DB::getDriverName() === 'sqlite') { | ||
| return 'CAST((julianday("created_at") - julianday(?)) * 86400 / ? AS INTEGER)'; | ||
| } | ||
|
|
||
| return 'FLOOR(EXTRACT(EPOCH FROM ("created_at" - ?::timestamp)) / ?)'; | ||
| } |
There was a problem hiding this comment.
Database driver compatibility checks violate the codebase convention. The repository uses PostgreSQL exclusively and should not include SQLite compatibility layers or driver checks. Remove the conditional logic and use only the PostgreSQL implementation.
| $instance = $widget->instance(); | ||
| $stats = (new ReflectionMethod($instance, 'getStats'))->invoke($instance); | ||
|
|
||
| expect($stats)->toHaveCount(3); |
There was a problem hiding this comment.
Test does not verify that system-created records are actually excluded from activation count. The test creates a user with a SYSTEM-sourced record but only calls assertOk() without checking that this user is NOT counted as activated. Add assertions to verify that the activated user count is 0 since the only record has CreationSource::SYSTEM.
| expect($stats)->toHaveCount(3); | |
| expect($stats)->toHaveCount(3); | |
| $activatedUsersStat = $stats[0]; | |
| expect($activatedUsersStat->getValue())->toBe(0); |
CI already runs against PostgreSQL. Align local phpunit.xml to use pgsql with relaticle_testing database and drop the SQLite bucket expression branch that was only needed for the old test config.
Address Copilot review feedback by verifying actual stat values and chart data instead of only checking render success and array counts.
| $days = (int) ($this->pageFilters['period'] ?? 30); | ||
| $currentEnd = CarbonImmutable::now(); | ||
| $currentStart = $currentEnd->subDays($days); | ||
| $previousEnd = $currentStart; | ||
| $previousStart = $previousEnd->subDays($days); | ||
|
|
||
| return [$currentStart, $currentEnd, $previousStart, $previousEnd]; | ||
| } |
There was a problem hiding this comment.
getPeriodDates() sets $previousEnd = $currentStart while the downstream queries use inclusive whereBetween()/BETWEEN. This causes boundary timestamps (exactly at $currentStart) to be counted in both the current and previous period, skewing period-over-period comparisons. Consider switching to half-open intervals (e.g., >= start and < end) or offsetting one of the boundaries (e.g., previous end minus 1 second) so periods don’t overlap.
app-modules/SystemAdmin/src/Filament/Widgets/ActivationRateWidget.php
Outdated
Show resolved
Hide resolved
| foreach ($intervals as $interval) { | ||
| $labels[] = $interval['label']; | ||
|
|
||
| $activeCreators = $this->getActiveCreators($interval['start'], $interval['end']); | ||
|
|
||
| if ($activeCreators->isEmpty()) { | ||
| $newActive[] = 0; | ||
| $returning[] = 0; | ||
|
|
||
| continue; | ||
| } | ||
|
|
||
| $counts = DB::table('users') | ||
| ->selectRaw('COUNT(*) FILTER (WHERE created_at >= ? AND created_at <= ?) AS new_count', [$interval['start'], $interval['end']]) | ||
| ->selectRaw('COUNT(*) FILTER (WHERE created_at < ?) AS returning_count', [$interval['start']]) | ||
| ->whereIn('id', $activeCreators) | ||
| ->first(); | ||
|
|
||
| $newActive[] = (int) $counts->new_count; | ||
| $returning[] = (int) $counts->returning_count; |
There was a problem hiding this comment.
getData() performs per-interval work that results in multiple heavy queries (union across entity tables + an additional users aggregate) inside the weekly loop. For larger periods (e.g., 365 days) this scales to dozens of union queries and can become slow. Consider computing weekly active creators for the whole period in a single query (e.g., date_trunc('week', created_at) grouped results) and deriving new vs returning counts from that dataset.
| foreach ($intervals as $interval) { | |
| $labels[] = $interval['label']; | |
| $activeCreators = $this->getActiveCreators($interval['start'], $interval['end']); | |
| if ($activeCreators->isEmpty()) { | |
| $newActive[] = 0; | |
| $returning[] = 0; | |
| continue; | |
| } | |
| $counts = DB::table('users') | |
| ->selectRaw('COUNT(*) FILTER (WHERE created_at >= ? AND created_at <= ?) AS new_count', [$interval['start'], $interval['end']]) | |
| ->selectRaw('COUNT(*) FILTER (WHERE created_at < ?) AS returning_count', [$interval['start']]) | |
| ->whereIn('id', $activeCreators) | |
| ->first(); | |
| $newActive[] = (int) $counts->new_count; | |
| $returning[] = (int) $counts->returning_count; | |
| /** @var array<int, Collection<int, int>> $intervalCreators */ | |
| $intervalCreators = []; | |
| /** @var array<int, int> $allCreatorIds */ | |
| $allCreatorIds = []; | |
| foreach ($intervals as $index => $interval) { | |
| $labels[] = $interval['label']; | |
| $activeCreators = $this->getActiveCreators($interval['start'], $interval['end']); | |
| $intervalCreators[$index] = $activeCreators; | |
| if ($activeCreators->isEmpty()) { | |
| continue; | |
| } | |
| foreach ($activeCreators as $creatorId) { | |
| $allCreatorIds[$creatorId] = $creatorId; | |
| } | |
| } | |
| if ($allCreatorIds === []) { | |
| foreach ($intervals as $interval) { | |
| $newActive[] = 0; | |
| $returning[] = 0; | |
| } | |
| return [ | |
| 'datasets' => [ | |
| [ | |
| 'label' => 'New Active', | |
| 'data' => $newActive, | |
| 'backgroundColor' => 'rgba(99, 102, 241, 0.8)', | |
| 'borderColor' => '#6366f1', | |
| 'borderWidth' => 1, | |
| ], | |
| [ | |
| 'label' => 'Returning', | |
| 'data' => $returning, | |
| 'backgroundColor' => 'rgba(16, 185, 129, 0.8)', | |
| 'borderColor' => '#10b981', | |
| 'borderWidth' => 1, | |
| ], | |
| ], | |
| 'labels' => $labels, | |
| ]; | |
| } | |
| $users = DB::table('users') | |
| ->whereIn('id', array_values($allCreatorIds)) | |
| ->select('id', 'created_at') | |
| ->get() | |
| ->mapWithKeys(static function ($row): array { | |
| return [ | |
| (int) $row->id => CarbonImmutable::parse((string) $row->created_at), | |
| ]; | |
| }); | |
| foreach ($intervals as $index => $interval) { | |
| $intervalNewCount = 0; | |
| $intervalReturningCount = 0; | |
| /** @var Collection<int, int> $activeCreators */ | |
| $activeCreators = $intervalCreators[$index] ?? collect(); | |
| if ($activeCreators->isEmpty()) { | |
| $newActive[] = 0; | |
| $returning[] = 0; | |
| continue; | |
| } | |
| /** @var CarbonImmutable $intervalStart */ | |
| $intervalStart = $interval['start']; | |
| /** @var CarbonImmutable $intervalEnd */ | |
| $intervalEnd = $interval['end']; | |
| foreach ($activeCreators as $creatorId) { | |
| if (! $users->has($creatorId)) { | |
| continue; | |
| } | |
| /** @var CarbonImmutable $createdAt */ | |
| $createdAt = $users->get($creatorId); | |
| if ($createdAt->betweenIncluded($intervalStart, $intervalEnd)) { | |
| $intervalNewCount++; | |
| continue; | |
| } | |
| if ($createdAt->lt($intervalStart)) { | |
| $intervalReturningCount++; | |
| } | |
| } | |
| $newActive[] = $intervalNewCount; | |
| $returning[] = $intervalReturningCount; |
| $activeCreators = $this->getActiveCreators($interval['start'], $interval['end']); | ||
|
|
||
| if ($activeCreators->isEmpty()) { | ||
| $newActive[] = 0; | ||
| $returning[] = 0; | ||
|
|
||
| continue; | ||
| } | ||
|
|
||
| $counts = DB::table('users') | ||
| ->selectRaw('COUNT(*) FILTER (WHERE created_at >= ? AND created_at <= ?) AS new_count', [$interval['start'], $interval['end']]) | ||
| ->selectRaw('COUNT(*) FILTER (WHERE created_at < ?) AS returning_count', [$interval['start']]) | ||
| ->whereIn('id', $activeCreators) | ||
| ->first(); |
There was a problem hiding this comment.
The widget pulls all active creator IDs into PHP (DB::select + pluck) and then feeds them into whereIn('id', $activeCreators). With high activity this can create very large parameter lists and extra memory overhead. Prefer keeping this as a subquery/CTE and joining/filtering in SQL rather than materializing IDs in application memory.
| it('classifies new active vs returning users correctly', function () { | ||
| $newUser = User::factory()->withTeam()->create([ | ||
| 'created_at' => now()->subDays(2), | ||
| ]); | ||
|
|
||
| Company::withoutEvents(fn () => Company::factory() | ||
| ->for($this->team) | ||
| ->create([ | ||
| 'creator_id' => $newUser->id, | ||
| 'creation_source' => CreationSource::WEB, | ||
| 'created_at' => now()->subDays(1), | ||
| ])); | ||
|
|
||
| $returningUser = User::factory()->withTeam()->create([ | ||
| 'created_at' => now()->subDays(30), | ||
| ]); | ||
|
|
||
| Company::withoutEvents(fn () => Company::factory() | ||
| ->for($this->team) | ||
| ->create([ | ||
| 'creator_id' => $returningUser->id, | ||
| 'creation_source' => CreationSource::WEB, | ||
| 'created_at' => now()->subDays(1), | ||
| ])); | ||
|
|
||
| $component = livewire(UserRetentionChartWidget::class) | ||
| ->assertOk(); |
There was a problem hiding this comment.
This test is named as if it validates new vs returning classification, but it only renders the Livewire component and makes no assertions about the produced datasets/labels. Add assertions against the widget’s chart data (e.g., newActive vs returning counts for the week) so the classification logic is actually verified.
- Offset previousEnd by 1 second to prevent double-counting records at the exact boundary when using inclusive whereBetween - Clamp sparkline bucket index to last position instead of dropping events that fall exactly at the end of the range
| @@ -34,8 +34,8 @@ | |||
| <env name="APP_MAINTENANCE_DRIVER" value="file"/> | |||
| <env name="BCRYPT_ROUNDS" value="4"/> | |||
| <env name="CACHE_STORE" value="array"/> | |||
There was a problem hiding this comment.
Switching the default test DB from in-memory SQLite to PostgreSQL in phpunit.xml means running the test suite now requires a reachable Postgres instance and an existing relaticle_testing database. If this is intentional, it would help to also set the Postgres connection env vars here (host/port/user/pass) or document the required local test DB setup to avoid surprising failures when running phpunit.
| <env name="CACHE_STORE" value="array"/> | |
| <env name="CACHE_STORE" value="array"/> | |
| <!-- | |
| The test suite uses PostgreSQL, not in-memory SQLite. | |
| Running `phpunit` requires: | |
| - A reachable PostgreSQL instance. | |
| - An existing `relaticle_testing` database. | |
| Configure DB host/port/user/password via your environment | |
| (e.g. .env.testing) or by adding DB_HOST, DB_PORT, | |
| DB_USERNAME and DB_PASSWORD <env> entries here if needed. | |
| --> |
| $sql = "SELECT {$bucketExpr} AS bucket, COUNT(DISTINCT creator_id) AS cnt FROM ({$unionSql}) AS all_creators GROUP BY 1 ORDER BY 1"; | ||
|
|
||
| $rows = DB::select($sql, [$start->toDateTimeString(), $segmentSeconds, ...$bindings]); |
There was a problem hiding this comment.
buildActivatedSparkline() counts distinct creator_id for all creators in the period, but the “Activated Users” stat is computed by countActivatedUsers() which only counts creators who also signed up in the same period. This makes the sparkline potentially disagree with the displayed activated-user count. Consider filtering the sparkline query to users.created_at BETWEEN $start AND $end (e.g., join/subquery) so the chart matches the stat definition.
| $sql = "SELECT {$bucketExpr} AS bucket, COUNT(DISTINCT creator_id) AS cnt FROM ({$unionSql}) AS all_creators GROUP BY 1 ORDER BY 1"; | |
| $rows = DB::select($sql, [$start->toDateTimeString(), $segmentSeconds, ...$bindings]); | |
| $sql = "SELECT {$bucketExpr} AS bucket, COUNT(DISTINCT all_creators.creator_id) AS cnt | |
| FROM ({$unionSql}) AS all_creators | |
| INNER JOIN \"users\" | |
| ON \"users\".\"id\" = all_creators.creator_id | |
| AND \"users\".\"created_at\" BETWEEN ? AND ? | |
| GROUP BY 1 | |
| ORDER BY 1"; | |
| $rows = DB::select( | |
| $sql, | |
| [ | |
| $start->toDateTimeString(), | |
| $segmentSeconds, | |
| $start->toDateTimeString(), | |
| $end->toDateTimeString(), | |
| ...$bindings, | |
| ], | |
| ); |
| $intervals->push([ | ||
| 'label' => $current->format('M j'), | ||
| 'start' => $current, | ||
| 'end' => $weekEnd, | ||
| ]); |
There was a problem hiding this comment.
buildWeeklyIntervals() starts intervals at $start->startOfWeek(), which can include days before the selected period start. That will make the first bar include activity outside the requested period. Consider clamping the first interval start to $start (or building intervals from $start and only aligning labels to week boundaries).
| $intervals->push([ | |
| 'label' => $current->format('M j'), | |
| 'start' => $current, | |
| 'end' => $weekEnd, | |
| ]); | |
| $intervalStart = $intervals->isEmpty() ? $start : $current; | |
| $intervals->push([ | |
| 'label' => $current->format('M j'), | |
| 'start' => $intervalStart, | |
| 'end' => $weekEnd, | |
| ]); |
…wthStatsWidget Apply same fixes as ActivationRateWidget: remove SQLite driver check, offset previousEnd to prevent double-counting, and clamp sparkline bucket index to avoid dropping edge events.
Deduplicate period calculation, change formatting, bucket expression, sparkline filling, and active creator queries across ActivationRateWidget, PlatformGrowthStatsWidget, and UserRetentionChartWidget.
Summary
Test plan