diff --git a/app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php b/app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php index 99bfd40..2864121 100644 --- a/app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php +++ b/app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php @@ -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; @@ -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 @@ -62,7 +66,7 @@ public function handle(): array 'deployments' => $this->deploymentsKpi(), 'topRepositories' => $this->topRepositories(), ]), - 'activityHeatmap' => self::MOCK_HEATMAP, + 'activityHeatmap' => $this->activityHeatmap(), ]; } @@ -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> + */ + 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 @@ -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 - ]; } diff --git a/tests/Feature/Dashboard/GetOverviewDashboardQueryTest.php b/tests/Feature/Dashboard/GetOverviewDashboardQueryTest.php index 0aa8ef1..53eccd1 100644 --- a/tests/Feature/Dashboard/GetOverviewDashboardQueryTest.php +++ b/tests/Feature/Dashboard/GetOverviewDashboardQueryTest.php @@ -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 @@ -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))); + } }