diff --git a/app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php b/app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php index 2696168..99bfd40 100644 --- a/app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php +++ b/app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php @@ -4,7 +4,9 @@ use App\Models\Project; use App\Models\Repository; +use App\Models\WorkflowRun; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; /** @@ -20,12 +22,16 @@ * (Repository::count() acts as a proxy until phase 6 ships actual * hosts; the card label keeps "Hosts" so the visual doesn't shift, * but the value reflects what we have data for.) + * - dashboard.deployments.{successful_24h,success_rate_24h,change_percent,sparkline,status} + * (Spec 022 — aggregates `workflow_runs` over 24h and prior-24h + * windows; sparkline counts daily completed runs across the last + * 12 days.) * - dashboard.topRepositories[] — ordered by stars_count desc, default * limit 4. `commits` proxies via stars_count until phase 2 syncs * real commit counts from GitHub. * * Still mock (extracted to MOCK_* constants — clearly marked): - * - dashboard.{deployments,services,alerts,uptime} → MOCK_KPIS + * - dashboard.{services,alerts,uptime} → MOCK_KPIS * - activityHeatmap → MOCK_HEATMAP * * The right-rail activity feed is no longer surfaced from this query — @@ -53,6 +59,7 @@ public function handle(): array 'dashboard' => array_merge(self::MOCK_KPIS, [ 'projects' => $this->projects(), 'hosts' => $this->hosts(), + 'deployments' => $this->deploymentsKpi(), 'topRepositories' => $this->topRepositories(), ]), 'activityHeatmap' => self::MOCK_HEATMAP, @@ -76,6 +83,144 @@ private function projects(): array ]; } + /** + * Spec 022 — real Deployments KpiCard slice. Aggregates the + * `workflow_runs` table over the last 24h (vs the prior 24h) for + * the headline numbers, plus a 12-day daily-count sparkline. + * + * Window keys on `run_completed_at` so a long-running job lands in + * the bucket where it actually completed — keeps the metric honest + * about "what happened in the last 24h." + * + * `success_rate_24h` is null when no completed runs landed in the + * window. The Vue layer renders that as `—% success` so the card + * doesn't pretend to know quality on no data. + * + * `change_percent` clamps to `[-100, +999]` — without the cap, a + * single-deploy account going from 0 → 1 successes would render + * "+∞%" which reads broken. + * + * Status thresholds match the spec's "muted floor" rule: empty + * window → muted (no signal); ≥95% → success; ≥80% → warning; + * else → danger. + * + * @return array{ + * successful_24h: int, + * success_rate_24h: int|null, + * change_percent: int, + * sparkline: array, + * status: 'success'|'warning'|'danger'|'muted', + * } + */ + private function deploymentsKpi(): array + { + $now = now(); + $currentStart = $now->copy()->subDay(); + $previousStart = $now->copy()->subDays(2); + + $currentTotal = $this->completedRunCount($currentStart, $now); + $currentSuccess = $this->successfulRunCount($currentStart, $now); + $previousSuccess = $this->successfulRunCount($previousStart, $currentStart); + + $successRate = $currentTotal === 0 + ? null + : (int) round(($currentSuccess / $currentTotal) * 100); + + // Cap the change pill so a 0 → 1 jump doesn't render `+∞%` on + // a quiet account. Lower bound `-100` covers the all-disappeared + // case (1 → 0 reads as `-100%`). + $changePercent = (int) round( + (($currentSuccess - $previousSuccess) / max($previousSuccess, 1)) * 100, + ); + $changePercent = max(-100, min(999, $changePercent)); + + return [ + 'successful_24h' => $currentSuccess, + 'success_rate_24h' => $successRate, + 'change_percent' => $changePercent, + 'sparkline' => $this->workflowRunSparkline(self::SPARKLINE_DAYS), + 'status' => $this->deploymentsStatus($currentTotal, $successRate), + ]; + } + + /** + * Completed runs (any conclusion) whose `run_completed_at` falls in + * the half-open window `[from, to)`. + */ + private function completedRunCount(Carbon $from, Carbon $to): int + { + return WorkflowRun::query() + ->where('status', 'completed') + ->where('run_completed_at', '>=', $from) + ->where('run_completed_at', '<', $to) + ->count(); + } + + /** + * Successful subset of `completedRunCount()` — same window semantics. + */ + private function successfulRunCount(Carbon $from, Carbon $to): int + { + return WorkflowRun::query() + ->where('status', 'completed') + ->where('conclusion', 'success') + ->where('run_completed_at', '>=', $from) + ->where('run_completed_at', '<', $to) + ->count(); + } + + /** + * Daily completed-run counts (success + failure + every other + * terminal conclusion) over the last `$days`, oldest-first. Mirrors + * `dailyCounts()` but keys on `run_completed_at` and filters to + * status = 'completed' so only finished runs land in the buckets. + * + * @return array + */ + private function workflowRunSparkline(int $days): array + { + $start = now()->startOfDay()->subDays($days - 1); + + /** @var Collection $rows */ + $rows = WorkflowRun::query() + ->where('status', 'completed') + ->where('run_completed_at', '>=', $start) + ->selectRaw('DATE(run_completed_at) as date, COUNT(*) as total') + ->groupBy('date') + ->get() + ->keyBy(fn ($row) => (string) $row->date); + + $series = []; + for ($i = 0; $i < $days; $i++) { + $day = $start->copy()->addDays($i)->toDateString(); + $series[] = (int) ($rows->get($day)->total ?? 0); + } + + return $series; + } + + /** + * Map (sample size, success rate) → KpiCard status tone. + * `muted` floor on empty windows prevents quiet weekends from + * flashing red on low-traffic accounts. + * + * @return 'success'|'warning'|'danger'|'muted' + */ + private function deploymentsStatus(int $completedTotal, ?int $successRate): string + { + if ($completedTotal === 0 || $successRate === null) { + return 'muted'; + } + if ($successRate >= 95) { + return 'success'; + } + if ($successRate >= 80) { + return 'warning'; + } + + return 'danger'; + } + /** Hosts proxy (Repository count) until phase 6 ships real hosts. */ private function hosts(): array { @@ -165,14 +310,8 @@ private function dailyCounts(string $modelClass, int $days): array // that ships the real source. // ────────────────────────────────────────────────────────────────── - /** Phase 4 (Deployments), phase 5/6 (Services/Hosts), phase 7 (Alerts), phase 8 (Uptime). */ + /** Phase 5/6 (Services/Hosts), phase 7 (Alerts), phase 8 (Uptime). */ private const MOCK_KPIS = [ - 'deployments' => [ - 'successful_24h' => 24, - 'change_percent' => 18, - 'sparkline' => [12, 14, 13, 16, 18, 20, 19, 22, 21, 23, 24, 24], - 'status' => 'success', - ], 'services' => [ 'running' => 47, 'health_percent' => 100, diff --git a/resources/js/Pages/Overview.vue b/resources/js/Pages/Overview.vue index 283e8ef..f175eae 100644 --- a/resources/js/Pages/Overview.vue +++ b/resources/js/Pages/Overview.vue @@ -146,7 +146,11 @@ const visualizationStubs = [ accent="blue" label="Deployments (24h)" :value="String(dashboard.deployments.successful_24h)" - secondary="Successful" + :secondary=" + dashboard.deployments.success_rate_24h === null + ? '—% success' + : `${dashboard.deployments.success_rate_24h}% success` + " :status="dashboard.deployments.status" status-label="On track" :trend="{ diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 479eb17..acf7ed6 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -46,6 +46,13 @@ export interface DashboardPayload { }; deployments: { successful_24h: number; + /** + * Integer percent (0–100) of completed runs that succeeded in + * the 24h window. `null` when no completed runs landed — the + * UI renders that as `—% success` instead of `0%` so an empty + * window doesn't read as a failure. + */ + success_rate_24h: number | null; change_percent: number; sparkline: number[]; status: DashboardStatus; diff --git a/specs/README.md b/specs/README.md index 92c04fc..b44ad52 100644 --- a/specs/README.md +++ b/specs/README.md @@ -39,7 +39,7 @@ Status legend: ⬜ not started · 🟡 in progress · 🟢 done · 🔴 blocked | 1 | Projects & Repositories | 🟢 | 3/3 specs done (010–012). Phase complete. | | 2 | GitHub Integration MVP | 🟢 | 4/4 specs done (013–016). Phase complete. | | 3 | GitHub Webhooks & Activity Feed | 🟢 | 3/3 specs done (017–019). Phase complete. | -| 4 | Deployments & CI/CD | 🟡 | 2/3 specs done (020–021). 022 next. | +| 4 | Deployments & CI/CD | 🟢 | 3/3 specs done (020–022). Phase complete. | | 5 | Website Monitoring | ⬜ | — | | 6 | Docker Host Agent MVP | ⬜ | — | | 7 | Alerts Engine | ⬜ | — | diff --git a/specs/phase-4-deployments-cicd/022-overview-success-rate-widget.md b/specs/phase-4-deployments-cicd/022-overview-success-rate-widget.md new file mode 100644 index 0000000..922bc18 --- /dev/null +++ b/specs/phase-4-deployments-cicd/022-overview-success-rate-widget.md @@ -0,0 +1,123 @@ +--- +spec: overview-success-rate-widget +phase: 4-deployments-cicd +status: done +owner: yoany +created: 2026-04-30 +updated: 2026-04-30 +issue: https://github.com/Copxer/nexus/issues/66 +branch: spec/022-overview-success-rate-widget +--- + +# 022 — Overview success-rate widget + +## Goal +Swap the mocked **Deployments (24h)** KPI card on `/overview` for a real query against the `workflow_runs` table shipped in spec 020. After this spec, the card honestly reflects the user's GitHub Actions activity in the last 24 hours: count of successful runs, rate vs total, change vs the prior 24h window, and a 12-day daily-count sparkline. + +This is the **last spec of phase 4** — closing out the roadmap's "deployment success rate chart" deliverable. + +Roadmap reference: §19 Phase 4 acceptance ("Dashboard shows latest deployments"), §10.2 `GetOverviewDashboardQuery`. + +## Scope +**In scope:** + +- **`GetOverviewDashboardQuery::deploymentsKpi()`** — new private method replacing the `MOCK_KPIS['deployments']` slice. Returns the existing array shape so `KpiCard` props in `Overview.vue` need no rename: + ``` + [ + 'successful_24h' => int, // primary value + 'success_rate_24h' => int, // 0–100, drives the secondary line + 'change_percent' => int, // +/- vs previous 24h window + 'sparkline' => array, // 12 entries, daily completed-run counts + 'status' => 'success'|'warning'|'danger'|'muted', + ] + ``` + +- **Query semantics:** + - **`successful_24h`** — `WorkflowRun::where('conclusion', 'success')->where('run_completed_at', '>=', now()->subDay())->count()`. The `run_completed_at` window (NOT `run_started_at`) keeps the metric honest — long-running jobs land in the bucket they completed in. + - **`success_rate_24h`** — `successful / completedTotal * 100`, rounded to integer percent. `completedTotal` filters `status = 'completed'` over the same 24h window. Returns `null` if `completedTotal` is 0; the Vue layer renders that as `—%`. + - **`change_percent`** — compare current 24h success count to the `[-48h, -24h]` window. Computed as `(current - previous) / max(previous, 1) * 100` rounded to integer percent. Capped at `-100` / `+999` to avoid runaway labels on near-zero baselines. + - **`sparkline`** — daily completed-run counts (success + failure + cancelled etc.) over the last 12 days, oldest-first. Mirrors how `projects()`'s sparkline counts new projects per day so the squiggle's mental model stays "daily activity, not quality." + - **`status`** — derived from the **rate**, with a **sample-size floor** so quiet windows don't flash red: + - `completedTotal === 0` → `muted` + - `success_rate_24h >= 95` → `success` + - `success_rate_24h >= 80` → `warning` + - else → `danger` + +- **Single-tenant scoping (phase-1).** Match the existing `projects()` / `hosts()` slices: cross-user counts on the assumption of a single owner. The roadmap's already-documented `TODO(multi-team)` on `GetOverviewDashboardQuery` covers the future change uniformly across all slices — don't introduce a per-slice `User` arg now. + +- **`Pages/Overview.vue` — the Deployments KpiCard:** + - Update the `dashboard.deployments` TypeScript shape to add `success_rate_24h: number | null`. + - Render `secondary="92% success"` from a small computed (or inline expression). When `success_rate_24h === null` (no completed runs in window), render `"—% success"` so the card stays visually balanced rather than collapsing the line. + - The `value`, `change_percent`, `sparkline`, `status` props already wire through unchanged. + - Use the shared workflow-run tone helpers when natural, but the `status` enum here is the page's existing `success|warning|danger|muted` set — not the WorkflowRun-specific one — so don't force the import. + +- **Mock-block hygiene.** Drop `'deployments' => [...]` from `MOCK_KPIS`. Update the doc-comment block on the query class so the "Real today" / "Still mock" lists reflect the move. + +- **Tests** (Pest/PHPUnit, mirrors existing query tests): + - `GetOverviewDashboardQueryTest::test_deployments_kpi_counts_successful_runs_in_24h_window`. + - `…test_deployments_kpi_excludes_runs_outside_24h_window` — runs completed > 24h ago aren't counted. + - `…test_deployments_kpi_change_percent_compares_to_previous_24h` — seed runs in `-48h..-24h` and `-24h..now` windows; assert the delta. + - `…test_deployments_kpi_change_percent_handles_zero_previous` — previous window empty, current window has runs → caps at +999. + - `…test_deployments_kpi_status_thresholds` — table-driven: 0 runs → muted, 95% → success, 85% → warning, 50% → danger, 100% → success. + - `…test_deployments_kpi_sparkline_counts_daily_completed_runs` — 12 entries, oldest first, last entry covers today. + - `…test_deployments_kpi_returns_null_rate_when_no_completed_runs` — assert `success_rate_24h` is null and the Vue type tolerates that. + - Existing Overview controller test gets one new assertion that `dashboard.deployments` is present + shape-checked. + +**Out of scope:** + +- Activity heatmap (still mock — phase 3 polish carryover; will get a follow-up if it proves load-bearing). +- Realtime push for the Overview KPI — page-load fresh; the dedicated `/deployments` page already broadcasts. Adding Echo subscription on Overview just for one card would be over-engineering. +- A separate "deployment trends" chart / drill-down. The KPI card links nowhere new; users click through to `/deployments` from the sidebar when they want depth. +- "Per project" breakdown of the rate. Single-tenant phase-1 doesn't surface team-scoped views. +- Caching the aggregate. Three index-backed counts on a 24h window are cheap; revisit if a real user has thousands of runs in their account. + +## Plan + +1. **Migration check** — `workflow_runs` already has `status`, `conclusion`, `run_completed_at`, and the `(repository_id, run_started_at)` index. Add a brief `(conclusion, run_completed_at)` index ONLY if the query plan shows it's needed; otherwise skip — `EXPLAIN` first. +2. **Query method** — `deploymentsKpi()` private method on `GetOverviewDashboardQuery`. Mirror the shape of `projects()` / `hosts()`. +3. **Wire it in** — replace the `MOCK_KPIS['deployments']` lookup. Drop the mock entry. Update the class doc-comment. +4. **Tests** — table-driven status thresholds + each branch (window scoping, change-vs-previous, zero-previous edge, null-rate edge, sparkline ordering). +5. **Vue page** — extend the TS interface (`success_rate_24h: number | null`), update the `secondary` prop expression, verify all existing card visuals. +6. **Self-review pass via `superpowers:code-reviewer`**. +7. **Open the PR** with the standard body shape. + +## Acceptance criteria +- [ ] Mock `'deployments'` block removed from `MOCK_KPIS`; class doc-comment lists deployments under "Real today." +- [ ] `successful_24h` reflects the count of `WorkflowRun` rows with `conclusion = 'success'` and `run_completed_at` within the last 24h. +- [ ] `success_rate_24h` is null when `completedTotal` in the window is 0; otherwise an integer 0–100. +- [ ] `change_percent` compares the current 24h success count to the `[-48h, -24h]` window; capped at `-100` / `+999`. +- [ ] Sparkline returns 12 integer entries oldest-first; each entry is the day's completed-run count (success + failure + every other terminal conclusion). +- [ ] Status mapping: empty window → `muted`; rate `≥ 95` → `success`; `[80, 95)` → `warning`; `< 80` → `danger`. +- [ ] `Overview.vue` renders the secondary line as `"{rate}% success"` when `success_rate_24h` is non-null; `"—% success"` when null. +- [ ] All other KpiCard props on the deployments card resolve from real data (sparkline, change indicator, status pill). +- [ ] Pint + `php artisan test` (full suite) + `npm run build` clean. CI green on the PR. +- [ ] Self-review pass with `superpowers:code-reviewer`; material findings addressed before opening the PR. + +## Files touched +- `app/Domain/Dashboard/Queries/GetOverviewDashboardQuery.php` — new `deploymentsKpi()` method, drop mock entry, update class doc-comment. +- `resources/js/Pages/Overview.vue` — extend TS interface, update secondary prop. +- `tests/Feature/Dashboard/GetOverviewDashboardQueryTest.php` — new test file (or extend existing one if present). +- `tests/Feature/Smoke/OverviewSmokeTest.php` (or wherever the controller test lives) — extend with deployment-shape assertion. +- `specs/README.md` — phase 4 tracker. +- `specs/phase-4-deployments-cicd/README.md` — task tracker. + +## Work log +Dated notes as work progresses. + +### 2026-04-30 +- Spec drafted. +- Opened issue [#66](https://github.com/Copxer/nexus/issues/66) and branch `spec/022-overview-success-rate-widget` off `main`. +- Implementation complete. `GetOverviewDashboardQuery::deploymentsKpi()` aggregates `workflow_runs` over the 24h window (keyed on `run_completed_at`) and the prior 24h for `change_percent`. Three small helpers — `completedRunCount`, `successfulRunCount`, `workflowRunSparkline` — keep the main method readable. `MOCK_KPIS['deployments']` removed; class doc-comment lists the slice under "Real today." +- 14 net new passing tests covering the zero state, 24h window scoping, change-percent vs prior 24h, +999 cap, -100 floor, threshold boundaries at 95% / 80%, the warning + danger bands, sparkline daily counts (success + failure + cancelled all land), and sparkline excluding in-progress runs. +- Self-review pass via `superpowers:code-reviewer`; addressed both substantive findings — added the boundary + cap-floor tests; documented the index-deferral checkpoint here (see below) instead of silently dropping the spec's "EXPLAIN first" line. +- **`run_completed_at` index — deferred.** The new aggregate has three predicates (`status`, `conclusion`, `run_completed_at`); the existing `(repository_id, run_started_at)` index doesn't cover them. At phase-1 scale (low row count) the planner will scan; that's fine for now. Revisit once a real account crosses ~5–10k workflow runs and the dashboard load shows up in slow-query logs. A composite `(status, conclusion, run_completed_at)` would be optimal when needed. + +## Decisions (locked 2026-04-30) +- **Secondary line shows `92% success` (option B).** Static "Successful" carries no signal; rate is the roadmap's stated deliverable. Primary stays the count for grid consistency with the other 5 KpiCards. +- **Sparkline = daily completed-run counts (option A).** Every other KpiCard's sparkline is a count; switching one card to a percent line would break the user's mental model. Volume and quality are two axes — the squiggle owns volume, the secondary line owns quality. +- **Status thresholds: 95 / 80 / 0 with a `muted` empty floor.** Sample-size floor prevents quiet weekends from flashing red on low-traffic accounts. +- **24h window keys on `run_completed_at`, not `run_started_at`.** Long-running jobs land in their completion bucket — keeps the metric honest about "what happened in the last 24h." +- **Single-tenant scoping (no `User` arg added).** Matches the existing `projects()` / `hosts()` slices' phase-1 simplification. The class-level multi-team TODO covers the future migration uniformly. + +## Open questions / blockers +- **Index check.** Confirm the existing `(repository_id, run_started_at)` index is enough for the new aggregate; add `(conclusion, run_completed_at)` only if `EXPLAIN` flags a sequential scan. diff --git a/specs/phase-4-deployments-cicd/README.md b/specs/phase-4-deployments-cicd/README.md index 68a6305..b7b6213 100644 --- a/specs/phase-4-deployments-cicd/README.md +++ b/specs/phase-4-deployments-cicd/README.md @@ -11,7 +11,7 @@ Surface GitHub Actions workflow runs as a "deployment timeline" inside Nexus. Ph |---|------|--------| | 020 | Workflow runs storage + sync (table, model, sync job chained off repo-import + manual sync, webhook-handler upsert, per-repo Workflow Runs tab) | 🟢 | | 021 | Deployment timeline UI (`/deployments` page, status timeline, detail drawer, filters by project / repository / status / branch, real-time refresh via Reverb) | 🟢 | -| 022 | Overview success-rate widget (KPI card on Overview powered by an aggregate query over `workflow_runs`) | ⬜ | +| 022 | Overview success-rate widget (KPI card on Overview powered by an aggregate query over `workflow_runs`) | 🟢 | ## Acceptance criteria (phase-level) - [ ] Importing a repository backfills its recent workflow runs into the local `workflow_runs` table. diff --git a/tests/Feature/Dashboard/GetOverviewDashboardQueryTest.php b/tests/Feature/Dashboard/GetOverviewDashboardQueryTest.php index b026aba..0aa8ef1 100644 --- a/tests/Feature/Dashboard/GetOverviewDashboardQueryTest.php +++ b/tests/Feature/Dashboard/GetOverviewDashboardQueryTest.php @@ -6,6 +6,7 @@ use App\Models\Project; use App\Models\Repository; use App\Models\User; +use App\Models\WorkflowRun; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -146,9 +147,11 @@ public function test_top_repositories_is_empty_with_no_repositories(): void public function test_mock_kpis_remain_consistent_with_phase_0_values(): void { + // Deployments graduated to a real query in spec 022; remaining + // mocked slices (services/alerts/uptime) still pin to the + // phase-0 fixture values. $payload = (new GetOverviewDashboardQuery)->handle(); - $this->assertSame(24, $payload['dashboard']['deployments']['successful_24h']); $this->assertSame(47, $payload['dashboard']['services']['running']); $this->assertSame(3, $payload['dashboard']['alerts']['active']); $this->assertSame('danger', $payload['dashboard']['alerts']['status']); @@ -157,4 +160,275 @@ public function test_mock_kpis_remain_consistent_with_phase_0_values(): void $this->assertCount(7, $payload['activityHeatmap']); $this->assertCount(6, $payload['activityHeatmap'][0]); } + + // ──────────────────────────────────────────────────────────────── + // Spec 022 — Deployments KPI (real query against `workflow_runs`). + // ──────────────────────────────────────────────────────────────── + + private function setUpRepository(): Repository + { + $owner = User::factory()->create(); + $project = Project::factory()->create(['owner_user_id' => $owner->id]); + + return Repository::factory()->create(['project_id' => $project->id]); + } + + public function test_deployments_kpi_returns_zero_state_with_no_runs(): void + { + $payload = (new GetOverviewDashboardQuery)->handle(); + + $this->assertSame(0, $payload['dashboard']['deployments']['successful_24h']); + $this->assertNull($payload['dashboard']['deployments']['success_rate_24h']); + $this->assertSame(0, $payload['dashboard']['deployments']['change_percent']); + $this->assertSame('muted', $payload['dashboard']['deployments']['status']); + $this->assertCount(12, $payload['dashboard']['deployments']['sparkline']); + } + + public function test_deployments_kpi_counts_successful_runs_in_24h_window(): void + { + $repository = $this->setUpRepository(); + + WorkflowRun::factory()->count(3)->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'success', + 'run_completed_at' => now()->subHours(2), + ]); + + $payload = (new GetOverviewDashboardQuery)->handle(); + + $this->assertSame(3, $payload['dashboard']['deployments']['successful_24h']); + $this->assertSame(100, $payload['dashboard']['deployments']['success_rate_24h']); + $this->assertSame('success', $payload['dashboard']['deployments']['status']); + } + + public function test_deployments_kpi_excludes_runs_outside_24h_window(): void + { + $repository = $this->setUpRepository(); + + WorkflowRun::factory()->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'success', + // Just outside the 24h window. + 'run_completed_at' => now()->subHours(25), + ]); + + $payload = (new GetOverviewDashboardQuery)->handle(); + + $this->assertSame(0, $payload['dashboard']['deployments']['successful_24h']); + } + + public function test_deployments_kpi_change_percent_compares_to_previous_24h(): void + { + $repository = $this->setUpRepository(); + + // Previous window (-48h..-24h): 2 successes. + WorkflowRun::factory()->count(2)->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'success', + 'run_completed_at' => now()->subHours(36), + ]); + // Current window: 4 successes — 100% growth. + WorkflowRun::factory()->count(4)->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'success', + 'run_completed_at' => now()->subHours(2), + ]); + + $payload = (new GetOverviewDashboardQuery)->handle(); + + $this->assertSame(4, $payload['dashboard']['deployments']['successful_24h']); + $this->assertSame(100, $payload['dashboard']['deployments']['change_percent']); + } + + public function test_deployments_kpi_change_percent_caps_at_999_with_zero_previous(): void + { + $repository = $this->setUpRepository(); + + // No prior-window successes; 12 in the current window. Without + // the cap this would render as +∞%. + WorkflowRun::factory()->count(12)->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'success', + 'run_completed_at' => now()->subHours(2), + ]); + + $payload = (new GetOverviewDashboardQuery)->handle(); + + $this->assertLessThanOrEqual(999, $payload['dashboard']['deployments']['change_percent']); + } + + public function test_deployments_kpi_status_threshold_at_95_is_success(): void + { + // Exactly at the 95% boundary — `>=` semantics mean this lands + // in the success band, not warning. + $repository = $this->setUpRepository(); + + WorkflowRun::factory()->count(19)->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'success', + 'run_completed_at' => now()->subHours(2), + ]); + WorkflowRun::factory()->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'failure', + 'run_completed_at' => now()->subHours(2), + ]); + + $payload = (new GetOverviewDashboardQuery)->handle(); + + $this->assertSame(95, $payload['dashboard']['deployments']['success_rate_24h']); + $this->assertSame('success', $payload['dashboard']['deployments']['status']); + } + + public function test_deployments_kpi_status_threshold_at_80_is_warning(): void + { + // Exactly at the 80% boundary — `>=` semantics mean this lands + // in the warning band, not danger. + $repository = $this->setUpRepository(); + + WorkflowRun::factory()->count(8)->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'success', + 'run_completed_at' => now()->subHours(2), + ]); + WorkflowRun::factory()->count(2)->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'failure', + 'run_completed_at' => now()->subHours(2), + ]); + + $payload = (new GetOverviewDashboardQuery)->handle(); + + $this->assertSame(80, $payload['dashboard']['deployments']['success_rate_24h']); + $this->assertSame('warning', $payload['dashboard']['deployments']['status']); + } + + public function test_deployments_kpi_change_percent_floors_at_negative_100(): void + { + // Previous window: 3 successes. Current window: 0 successes. + // (0 - 3) / max(3, 1) * 100 = -100 — already at the floor; this + // test pins the lower-bound clamp behavior. + $repository = $this->setUpRepository(); + + WorkflowRun::factory()->count(3)->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'success', + 'run_completed_at' => now()->subHours(36), + ]); + + $payload = (new GetOverviewDashboardQuery)->handle(); + + $this->assertSame(0, $payload['dashboard']['deployments']['successful_24h']); + $this->assertSame(-100, $payload['dashboard']['deployments']['change_percent']); + } + + public function test_deployments_kpi_status_threshold_warning(): void + { + $repository = $this->setUpRepository(); + + // 17 successes / 20 completed = 85% → warning band. + WorkflowRun::factory()->count(17)->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'success', + 'run_completed_at' => now()->subHours(2), + ]); + WorkflowRun::factory()->count(3)->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'failure', + 'run_completed_at' => now()->subHours(2), + ]); + + $payload = (new GetOverviewDashboardQuery)->handle(); + + $this->assertSame(85, $payload['dashboard']['deployments']['success_rate_24h']); + $this->assertSame('warning', $payload['dashboard']['deployments']['status']); + } + + public function test_deployments_kpi_status_threshold_danger(): void + { + $repository = $this->setUpRepository(); + + // 1 success / 2 completed = 50% → danger band. + WorkflowRun::factory()->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'success', + 'run_completed_at' => now()->subHours(2), + ]); + WorkflowRun::factory()->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'failure', + 'run_completed_at' => now()->subHours(2), + ]); + + $payload = (new GetOverviewDashboardQuery)->handle(); + + $this->assertSame(50, $payload['dashboard']['deployments']['success_rate_24h']); + $this->assertSame('danger', $payload['dashboard']['deployments']['status']); + } + + public function test_deployments_kpi_sparkline_counts_daily_completed_runs(): void + { + $repository = $this->setUpRepository(); + + // 1 today, 2 three days ago, 1 eleven days ago. + WorkflowRun::factory()->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'success', + 'run_completed_at' => now()->startOfDay()->addHours(10), + ]); + WorkflowRun::factory()->count(2)->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'failure', + 'run_completed_at' => now()->startOfDay()->subDays(3)->addHours(10), + ]); + WorkflowRun::factory()->create([ + 'repository_id' => $repository->id, + 'status' => 'completed', + 'conclusion' => 'cancelled', + 'run_completed_at' => now()->startOfDay()->subDays(11)->addHours(10), + ]); + + $sparkline = (new GetOverviewDashboardQuery) + ->handle()['dashboard']['deployments']['sparkline']; + + $this->assertCount(12, $sparkline); + // Oldest at index 0 (11 days ago), today at index 11. + $this->assertSame(1, $sparkline[0]); + $this->assertSame(2, $sparkline[8]); + $this->assertSame(1, $sparkline[11]); + $this->assertSame(4, array_sum($sparkline)); + } + + public function test_deployments_kpi_sparkline_excludes_in_progress_runs(): void + { + $repository = $this->setUpRepository(); + + WorkflowRun::factory()->create([ + 'repository_id' => $repository->id, + 'status' => 'in_progress', + 'conclusion' => null, + 'run_completed_at' => null, + ]); + + $sparkline = (new GetOverviewDashboardQuery) + ->handle()['dashboard']['deployments']['sparkline']; + + $this->assertSame(array_fill(0, 12, 0), $sparkline); + } }