Skip to content

feat: add activation rate and user retention widgets to sysadmin dashboard#168

Merged
ManukMinasyan merged 10 commits intomainfrom
feat/sysadmin-activation-retention-widgets
Feb 27, 2026
Merged

feat: add activation rate and user retention widgets to sysadmin dashboard#168
ManukMinasyan merged 10 commits intomainfrom
feat/sysadmin-activation-retention-widgets

Conversation

@ManukMinasyan
Copy link
Copy Markdown
Contributor

Summary

  • Add Activation Rate Stats widget tracking signups, activated users, and activation rate with period-over-period comparison
  • Add User Retention Chart widget showing new vs returning active users as a stacked bar chart over weekly intervals
  • Create dedicated Engagement Dashboard page to house activation and retention widgets separately from the main dashboard
  • Group dashboards under a "Dashboards" navigation group for cleaner panel organization

Test plan

  • ActivationRateWidget renders correctly with test data (3 stat cards)
  • ActivationRateWidget calculates metrics accurately (signups, activated count, rate percentage)
  • UserRetentionChartWidget renders without errors
  • UserRetentionChartWidget displays correct data structure
  • Full test suite passes (737 passed, 0 failures)
  • PHPStan: no errors
  • Rector: no changes suggested

Copilot AI review requested due to automatic review settings February 26, 2026 19:39
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +210 to +217
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)) / ?)';
}
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
$instance = $widget->instance();
$stats = (new ReflectionMethod($instance, 'getStats'))->invoke($instance);

expect($stats)->toHaveCount(3);
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
expect($stats)->toHaveCount(3);
expect($stats)->toHaveCount(3);
$activatedUsersStat = $stats[0];
expect($activatedUsersStat->getValue())->toBe(0);

Copilot uses AI. Check for mistakes.
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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Comment on lines +53 to +60
$days = (int) ($this->pageFilters['period'] ?? 30);
$currentEnd = CarbonImmutable::now();
$currentStart = $currentEnd->subDays($days);
$previousEnd = $currentStart;
$previousStart = $previousEnd->subDays($days);

return [$currentStart, $currentEnd, $previousStart, $previousEnd];
}
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +74
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;
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +71
$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();
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +52
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();
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
- 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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

@@ -34,8 +34,8 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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

Copilot uses AI. Check for mistakes.
Comment on lines +185 to +187
$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]);
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$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,
],
);

Copilot uses AI. Check for mistakes.
Comment on lines +141 to +145
$intervals->push([
'label' => $current->format('M j'),
'start' => $current,
'end' => $weekEnd,
]);
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
$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,
]);

Copilot uses AI. Check for mistakes.
…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.
@ManukMinasyan ManukMinasyan merged commit a3cbe68 into main Feb 27, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants