From b16c0a9b731565fb0b6e70d30f861fbe50347907 Mon Sep 17 00:00:00 2001 From: Copxer Date: Thu, 30 Apr 2026 18:17:53 -0700 Subject: [PATCH] fix(overview): wire activity heatmap to real activity_events aggregate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Overview dashboard's activityHeatmap slice was a static MOCK_HEATMAP constant carried over from phase 0 — phase 3 shipped activity_events but never wired the heatmap to it. Replace the mock with a real query. - New GetOverviewDashboardQuery::activityHeatmap() aggregates activity_events.occurred_at over the last 90 days into a 7×6 grid ([day_of_week][four_hour_bucket]). - Bucketing happens 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. At phase-1 row counts (≤90d × low webhook traffic) loading the timestamps and bucketing in PHP is sub-millisecond. - 90-day window is long enough to surface a recurring rhythm but recent enough to reflect current habits. - MOCK_HEATMAP constant removed; class docblock graduates the slice from "still mock" to "real today" with the timezone caveat called out explicitly (hour buckets are sharper exposure to app.timezone than the existing day-bucket case). Tests: 7 new tests covering empty grid, day-and-hour placement, multi-event accumulation, 90-day cutoff (just inside / just outside), fixed 7×6 shape, and a bucket-boundary contract pinning 00:00 / 03:59 / 04:00 / 11:59 / 12:00 / 23:59 to their canonical buckets. Self-review pass via superpowers:code-reviewer; addressed both recommendations (boundary test added, timezone caveat documented). Sparse-account UX (a fresh account renders mostly muted) is intentional phase-1 behavior — fix is a component-level empty state, not a query change, if it ever becomes a friction point. --- .../Queries/GetOverviewDashboardQuery.php | 69 ++++++++-- .../GetOverviewDashboardQueryTest.php | 127 ++++++++++++++++++ 2 files changed, 183 insertions(+), 13 deletions(-) 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))); + } }