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
69 changes: 56 additions & 13 deletions app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Domain\Dashboard\Queries;

use App\Models\ActivityEvent;
use App\Models\Project;
use App\Models\Repository;
use App\Models\WorkflowRun;
Expand Down Expand Up @@ -29,10 +30,13 @@
* - dashboard.topRepositories[] — ordered by stars_count desc, default
* limit 4. `commits` proxies via stars_count until phase 2 syncs
* real commit counts from GitHub.
* - activityHeatmap — 7 days × 6 four-hour buckets aggregated from
* `activity_events.occurred_at` over the last 90 days. Bucketing
* happens in PHP so the query stays cross-DB without `DAYOFWEEK()` /
* `HOUR()` polyfills.
*
* Still mock (extracted to MOCK_* constants — clearly marked):
* - dashboard.{services,alerts,uptime} → MOCK_KPIS
* - activityHeatmap → MOCK_HEATMAP
*
* The right-rail activity feed is no longer surfaced from this query —
* the AppLayout consumes the shared `activity.recent` Inertia prop
Expand Down Expand Up @@ -62,7 +66,7 @@ public function handle(): array
'deployments' => $this->deploymentsKpi(),
'topRepositories' => $this->topRepositories(),
]),
'activityHeatmap' => self::MOCK_HEATMAP,
'activityHeatmap' => $this->activityHeatmap(),
];
}

Expand Down Expand Up @@ -199,6 +203,56 @@ private function workflowRunSparkline(int $days): array
return $series;
}

/**
* Engineering-rhythm heatmap (7 days × 6 four-hour buckets).
*
* Aggregates `activity_events.occurred_at` over the last 90 days
* into a `[day_of_week][bucket]` grid where:
* - day_of_week: 0=Sun, 1=Mon, …, 6=Sat (Carbon's convention,
* matches the JS `Date#getDay()` axis on the heatmap component)
* - bucket: 0=00:00–04:00, 1=04:00–08:00, …, 5=20:00–24:00
*
* 90-day window is long enough to surface a recurring rhythm but
* recent enough to reflect *current* habits — narrower than "all
* time" (which dilutes signal forever) and wider than "last week"
* (which is noisy on quiet accounts).
*
* **Why bucket in PHP, not SQL:** `DAYOFWEEK()` (MySQL) and
* `strftime('%w', …)` (SQLite) disagree on indexing AND on
* timezone handling, and the test suite runs on SQLite while prod
* uses MySQL. A single `SELECT occurred_at FROM activity_events
* WHERE occurred_at >= ?` is cheap at phase-1 scale (≤90d × low
* webhook traffic), and bucketing in PHP keeps the math obvious.
*
* **Timezone caveat:** the hour bucket is computed in the app's
* configured timezone (today: UTC). Flipping `app.timezone` would
* shift every bucket by N hours — sharper exposure than the
* `dailyCounts()` day-bucket case because a 6-hour TZ shift moves
* events into *different* buckets, not just adjacent days.
*
* @return array<int, array<int, int>>
*/
private function activityHeatmap(): array
{
$grid = array_fill(0, 7, array_fill(0, 6, 0));

ActivityEvent::query()
->where('occurred_at', '>=', now()->subDays(90))
->select('occurred_at')
->get()
->each(function (ActivityEvent $event) use (&$grid) {
$occurredAt = $event->occurred_at;
if ($occurredAt === null) {
return;
}
$day = $occurredAt->dayOfWeek;
$bucket = intdiv($occurredAt->hour, 4);
$grid[$day][$bucket]++;
});

return $grid;
}

/**
* Map (sample size, success rate) → KpiCard status tone.
* `muted` floor on empty windows prevents quiet weekends from
Expand Down Expand Up @@ -331,15 +385,4 @@ private function dailyCounts(string $modelClass, int $days): array
'status' => 'success',
],
];

/** Phase 3 ships the real heatmap on top of activity-event aggregates. */
private const MOCK_HEATMAP = [
[1, 0, 1, 3, 2, 1], // Sun
[2, 1, 4, 7, 6, 3], // Mon
[1, 1, 5, 9, 8, 4], // Tue
[2, 1, 6, 10, 9, 5], // Wed
[2, 1, 5, 9, 7, 4], // Thu
[1, 0, 4, 6, 5, 2], // Fri
[0, 0, 1, 2, 1, 1], // Sat
];
}
127 changes: 127 additions & 0 deletions tests/Feature/Dashboard/GetOverviewDashboardQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
namespace Tests\Feature\Dashboard;

use App\Domain\Dashboard\Queries\GetOverviewDashboardQuery;
use App\Models\ActivityEvent;
use App\Models\Project;
use App\Models\Repository;
use App\Models\User;
use App\Models\WorkflowRun;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;

class GetOverviewDashboardQueryTest extends TestCase
Expand Down Expand Up @@ -431,4 +433,129 @@ public function test_deployments_kpi_sparkline_excludes_in_progress_runs(): void

$this->assertSame(array_fill(0, 12, 0), $sparkline);
}

// ────────────────────────────────────────────────────────────────
// fix/activity-heatmap — real `activity_events` aggregate.
// ────────────────────────────────────────────────────────────────

public function test_activity_heatmap_is_all_zeros_with_no_events(): void
{
$heatmap = (new GetOverviewDashboardQuery)->handle()['activityHeatmap'];

$this->assertSame(array_fill(0, 7, array_fill(0, 6, 0)), $heatmap);
}

public function test_activity_heatmap_buckets_event_by_day_and_hour(): void
{
$repository = $this->setUpRepository();

// Wednesday at 14:30 → day=3, hour=14, bucket=3 (12:00–16:00).
ActivityEvent::factory()->create([
'repository_id' => $repository->id,
'occurred_at' => Carbon::parse('2026-04-29 14:30:00', 'UTC'), // 2026-04-29 was a Wed
]);

$heatmap = (new GetOverviewDashboardQuery)->handle()['activityHeatmap'];

$this->assertSame(1, $heatmap[3][3]);
// Every other cell is zero.
$this->assertSame(1, array_sum(array_map('array_sum', $heatmap)));
}

public function test_activity_heatmap_accumulates_multiple_events_in_same_bucket(): void
{
$repository = $this->setUpRepository();

// Three events all on Monday at 09:00–10:30 → day=1, bucket=2 (08:00–12:00).
foreach (['2026-04-27 09:00:00', '2026-04-27 09:45:00', '2026-04-27 10:30:00'] as $iso) {
ActivityEvent::factory()->create([
'repository_id' => $repository->id,
'occurred_at' => Carbon::parse($iso, 'UTC'), // 2026-04-27 was a Mon
]);
}

$heatmap = (new GetOverviewDashboardQuery)->handle()['activityHeatmap'];

$this->assertSame(3, $heatmap[1][2]);
}

public function test_activity_heatmap_excludes_events_older_than_90_days(): void
{
$repository = $this->setUpRepository();

ActivityEvent::factory()->create([
'repository_id' => $repository->id,
'occurred_at' => now()->subDays(91)->startOfDay()->addHours(10),
]);

$heatmap = (new GetOverviewDashboardQuery)->handle()['activityHeatmap'];

$this->assertSame(array_fill(0, 7, array_fill(0, 6, 0)), $heatmap);
}

public function test_activity_heatmap_includes_events_inside_90_day_window(): void
{
$repository = $this->setUpRepository();

ActivityEvent::factory()->create([
'repository_id' => $repository->id,
'occurred_at' => now()->subDays(89)->startOfDay()->addHours(10),
]);

$heatmap = (new GetOverviewDashboardQuery)->handle()['activityHeatmap'];

$this->assertSame(1, array_sum(array_map('array_sum', $heatmap)));
}

public function test_activity_heatmap_returns_seven_by_six_grid(): void
{
$heatmap = (new GetOverviewDashboardQuery)->handle()['activityHeatmap'];

$this->assertCount(7, $heatmap);
foreach ($heatmap as $day) {
$this->assertCount(6, $day);
}
}

/**
* Pin the four-hour bucket boundary contract: 00:00 → bucket 0,
* 03:59 → bucket 0, 04:00 → bucket 1, 23:59 → bucket 5. A boundary
* regression here would silently misbucket a chunk of every
* account's events.
*/
public function test_activity_heatmap_bucket_boundary_contract(): void
{
$repository = $this->setUpRepository();

// 2026-04-26 was a Sunday → day-of-week index 0. Seed one event
// per boundary so each (time, expected bucket) row asserts in
// isolation against the heatmap's grand total.
$cases = [
'00:00:00' => 0,
'03:59:59' => 0,
'04:00:00' => 1,
'11:59:59' => 2,
'12:00:00' => 3,
'23:59:59' => 5,
];

foreach (array_keys($cases) as $time) {
ActivityEvent::factory()->create([
'repository_id' => $repository->id,
'occurred_at' => Carbon::parse("2026-04-26 {$time}", 'UTC'),
]);
}

$heatmap = (new GetOverviewDashboardQuery)->handle()['activityHeatmap'];

// Aggregate the expected counts per bucket from the case map.
$expected = array_fill(0, 6, 0);
foreach ($cases as $bucket) {
$expected[$bucket]++;
}

$this->assertSame($expected, $heatmap[0]);
// Other day rows are untouched.
$this->assertSame(count($cases), array_sum(array_map('array_sum', $heatmap)));
}
}
Loading