diff --git a/app/Http/Controllers/MonitorsController.php b/app/Http/Controllers/MonitorsController.php index e4dbb74..69392a0 100644 --- a/app/Http/Controllers/MonitorsController.php +++ b/app/Http/Controllers/MonitorsController.php @@ -99,93 +99,62 @@ public function store(MonitorRequest $request) */ public function show(Request $request, Monitor $monitor) { - $history = null; + $graph = null; + $filters = null; + $summary = null; + $recentChecks = null; if (config('monitor-history.enabled')) { - $range = $this->resolveHistoryRange($request, $monitor); + // The monitor's earliest check feeds the 'all' preset range, the + // available-years list and the summary's first_checked_at. Resolve it + // once here and thread it through, rather than re-running the same + // MIN(checked_at) lookup inside each consumer. + $firstCheckedAt = $monitor->checkLogs()->orderBy('checked_at')->value('checked_at'); + + $range = $this->resolveHistoryRange($request, $firstCheckedAt); $fromUtc = $range['from']->copy()->startOfDay()->utc(); $toUtc = $range['to']->copy()->endOfDay()->utc(); $timezone = $range['timezone']; + $availableYears = $this->availableYears($timezone, $firstCheckedAt); + $graphYear = $this->resolveGraphYear($request, $availableYears); + $graph = $this->buildGraphPayload($monitor, $graphYear, $timezone, $availableYears); + $selectedRangeQuery = $monitor->checkLogs() ->whereBetween('checked_at', [$fromUtc, $toUtc]); $allTimeSummary = $this->buildSummary($monitor->checkLogs()); $selectedRangeSummary = $this->buildSummary($selectedRangeQuery); - $dailyMetrics = $monitor->dailyCheckMetrics() - ->forTimezone($timezone) - ->betweenDates($range['from']->toDateString(), $range['to']->toDateString()) - ->orderBy('date') - ->get() - ->groupBy('check_type') - ->map(function ($rows) { - return $rows->map(function ($row) { - return [ - 'date' => $row->date->toDateString(), - 'total_checks' => $row->total_checks, - 'successful_checks' => $row->successful_checks, - 'warning_checks' => $row->warning_checks, - 'failed_checks' => $row->failed_checks, - 'success_ratio' => (float) $row->success_ratio, - 'worst_status' => $row->worst_status, - 'avg_response_time_ms' => $row->avg_response_time_ms, - 'p95_response_time_ms' => $row->p95_response_time_ms, - ]; - })->values(); - }); - - $recentChecks = $monitor->checkLogs() - ->whereBetween('checked_at', [$fromUtc, $toUtc]) - ->latest('checked_at') - ->limit((int) config('monitor-history.recent_checks_limit', 50)) - ->get() - ->map(function (MonitorCheckLog $log) use ($timezone) { - return [ - 'id' => $log->id, - 'check_type' => $log->check_type, - 'status' => $log->status, - 'checked_at' => $log->checked_at->timezone($timezone)->toDateTimeString(), - 'message' => $log->message, - 'failure_reason' => $log->failure_reason, - 'response_time_ms' => $log->response_time_ms, - 'metadata' => $log->metadata, - ]; - }); - - $history = [ - 'range' => [ - 'preset' => $range['preset'], - 'from' => $range['from']->toDateString(), - 'to' => $range['to']->toDateString(), - 'timezone' => $timezone, - ], - 'check_types' => [ - [ - 'type' => MonitorCheckLogService::CHECK_TYPE_UPTIME, - 'enabled' => (bool) $monitor->uptime_check_enabled, - ], - [ - 'type' => MonitorCheckLogService::CHECK_TYPE_DOMAIN, - 'enabled' => (bool) $monitor->domain_check_enabled, - ], - [ - 'type' => MonitorCheckLogService::CHECK_TYPE_CERTIFICATE, - 'enabled' => (bool) $monitor->certificate_check_enabled, - ], - ], - 'summary' => [ - 'all_time' => $allTimeSummary, - 'selected_range' => $selectedRangeSummary, - ], - 'daily_metrics' => $dailyMetrics, - 'recent_checks' => $recentChecks, + $filters = [ + 'preset' => $range['preset'], + 'from' => $range['from']->toDateString(), + 'to' => $range['to']->toDateString(), + 'timezone' => $timezone, + ]; + + $summary = [ + 'all_time' => $allTimeSummary, + 'selected_range' => $selectedRangeSummary, + 'first_checked_at' => $firstCheckedAt + ? Carbon::parse($firstCheckedAt)->timezone($timezone)->toDateTimeString() + : null, ]; + + $recentType = $request->string('recent_type')->toString() ?: MonitorCheckLogService::CHECK_TYPE_UPTIME; + if (! in_array($recentType, [MonitorCheckLogService::CHECK_TYPE_UPTIME, MonitorCheckLogService::CHECK_TYPE_DOMAIN], true)) { + $recentType = MonitorCheckLogService::CHECK_TYPE_UPTIME; + } + + $recentChecks = $this->buildRecentChecks($monitor, $recentType, $fromUtc, $toUtc, $timezone); } return Inertia::render('Monitors/Show', [ 'monitor' => $monitor, - 'history' => $history, + 'graph' => $graph, + 'filters' => $filters, + 'summary' => $summary, + 'recentChecks' => $recentChecks, ]); } @@ -242,7 +211,7 @@ public function destroy(Monitor $monitor) return redirect()->route('monitors.index'); } - protected function resolveHistoryRange(Request $request, Monitor $monitor): array + protected function resolveHistoryRange(Request $request, $firstCheckedAt = null): array { // The daily metrics are aggregated server-side under this single timezone, // so the detail page must read them back under the same one. We deliberately @@ -256,8 +225,6 @@ protected function resolveHistoryRange(Request $request, Monitor $monitor): arra $preset = $request->string('preset')->toString() ?: '30d'; if ($preset === 'all') { - $firstCheckedAt = $monitor->checkLogs()->orderBy('checked_at')->value('checked_at'); - $from = $firstCheckedAt ? Carbon::parse($firstCheckedAt)->timezone($timezone)->startOfDay() : Carbon::now($timezone)->subDays(30)->startOfDay(); @@ -376,4 +343,172 @@ protected function buildSummary($query): array return $summary; } + + protected function graphCheckTypes(Monitor $monitor): array + { + return [ + [ + 'type' => MonitorCheckLogService::CHECK_TYPE_UPTIME, + 'enabled' => (bool) $monitor->uptime_check_enabled, + ], + [ + 'type' => MonitorCheckLogService::CHECK_TYPE_DOMAIN, + 'enabled' => (bool) $monitor->domain_check_enabled, + ], + ]; + } + + protected function availableYears(string $timezone, $firstCheckedAt = null): array + { + $currentYear = (int) Carbon::now($timezone)->format('Y'); + + if (! $firstCheckedAt) { + return [$currentYear]; + } + + $minYear = (int) Carbon::parse($firstCheckedAt)->timezone($timezone)->format('Y'); + + if ($minYear > $currentYear) { + $minYear = $currentYear; + } + + return range($minYear, $currentYear); + } + + protected function resolveGraphYear(Request $request, array $availableYears): int + { + $default = end($availableYears) ?: (int) Carbon::now('UTC')->format('Y'); + + $requested = $request->integer('year') ?: $default; + + if (! in_array((int) $requested, $availableYears, true)) { + return (int) $default; + } + + return (int) $requested; + } + + protected function buildGraphPayload(Monitor $monitor, int $year, string $timezone, array $availableYears): array + { + $checkTypes = $this->graphCheckTypes($monitor); + $recentChecksLimit = (int) config('monitor-history.recent_checks_limit', 150); + + $yearStartUtc = Carbon::create($year, 1, 1, 0, 0, 0, $timezone)->startOfDay()->utc(); + $yearEndUtc = Carbon::create($year, 12, 31, 0, 0, 0, $timezone)->endOfDay()->utc(); + $yearStartDate = Carbon::create($year, 1, 1, 0, 0, 0, $timezone)->toDateString(); + $yearEndDate = Carbon::create($year, 12, 31, 0, 0, 0, $timezone)->toDateString(); + + $dailyMetricsByType = $monitor->dailyCheckMetrics() + ->forTimezone($timezone) + ->betweenDates($yearStartDate, $yearEndDate) + ->orderBy('date') + ->get() + ->groupBy('check_type') + ->map(function ($rows) { + return $rows->map(function ($row) { + return [ + 'date' => $row->date->toDateString(), + 'total_checks' => $row->total_checks, + 'successful_checks' => $row->successful_checks, + 'warning_checks' => $row->warning_checks, + 'failed_checks' => $row->failed_checks, + 'success_ratio' => (float) $row->success_ratio, + 'worst_status' => $row->worst_status, + 'avg_response_time_ms' => $row->avg_response_time_ms, + 'p95_response_time_ms' => $row->p95_response_time_ms, + ]; + })->values(); + }); + + $series = []; + + foreach ($checkTypes as $checkType) { + $type = $checkType['type']; + + $typeSummary = $this->buildSummary( + $monitor->checkLogs() + ->where('check_type', $type) + ->whereBetween('checked_at', [$yearStartUtc, $yearEndUtc]) + ); + + $series[$type] = [ + 'summary' => [ + 'total_checks' => $typeSummary['by_type'][$type]['total_checks'] ?? 0, + 'success_ratio' => (float) ($typeSummary['by_type'][$type]['success_ratio'] ?? 0), + 'status_totals' => $typeSummary['by_type'][$type]['status_totals'] ?? [ + MonitorCheckLogService::STATUS_SUCCESS => 0, + MonitorCheckLogService::STATUS_WARNING => 0, + MonitorCheckLogService::STATUS_FAILED => 0, + MonitorCheckLogService::STATUS_UNKNOWN => 0, + ], + ], + 'daily_metrics' => $dailyMetricsByType->get($type, collect())->values()->all(), + 'latest_checks' => $this->buildLatestChecks($monitor, $type, $timezone, $recentChecksLimit), + ]; + } + + return [ + 'year' => $year, + 'available_years' => $availableYears, + 'timezone' => $timezone, + 'today_iso' => Carbon::now($timezone)->toDateString(), + 'check_types' => $checkTypes, + 'recent_checks_limit' => $recentChecksLimit, + 'series' => $series, + ]; + } + + protected function buildRecentChecks(Monitor $monitor, string $type, Carbon $fromUtc, Carbon $toUtc, string $timezone): array + { + $paginator = $monitor->checkLogs() + ->where('check_type', $type) + ->whereBetween('checked_at', [$fromUtc, $toUtc]) + ->latest('checked_at') + ->paginate(25, ['*'], 'recent_page'); + + $data = collect($paginator->items()) + ->map(function (MonitorCheckLog $log) use ($timezone) { + return [ + 'id' => $log->id, + 'check_type' => $log->check_type, + 'status' => $log->status, + 'checked_at' => $log->checked_at->timezone($timezone)->toDateTimeString(), + 'message' => $log->message, + 'failure_reason' => $log->failure_reason, + 'response_time_ms' => $log->response_time_ms, + ]; + }) + ->all(); + + return [ + 'type' => $type, + 'data' => $data, + 'pagination' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + ]; + } + + protected function buildLatestChecks(Monitor $monitor, string $checkType, string $timezone, int $limit): array + { + return $monitor->checkLogs() + ->where('check_type', $checkType) + ->latest('checked_at') + ->limit($limit) + ->get() + ->map(function (MonitorCheckLog $log) use ($timezone) { + return [ + 'id' => $log->id, + 'checked_at' => $log->checked_at->timezone($timezone)->toDateTimeString(), + 'status' => $log->status, + 'message' => $log->message, + 'failure_reason' => $log->failure_reason, + 'response_time_ms' => $log->response_time_ms, + ]; + }) + ->all(); + } } diff --git a/config/monitor-history.php b/config/monitor-history.php index 72879f1..e3ea9e9 100644 --- a/config/monitor-history.php +++ b/config/monitor-history.php @@ -23,9 +23,13 @@ ], /* - * Maximum recent check rows to return on monitor detail page. + * Maximum recent checks surfaced on the monitor detail page's recent strip. + * Single source of truth: the controller caps `latest_checks` to this value + * AND ships it in the graph payload, and the frontend strip uses the same + * number as its slot cap (MonitorRecentStrip maxSlots) — so the backend and + * frontend caps cannot drift apart. */ - 'recent_checks_limit' => 50, + 'recent_checks_limit' => 150, /* * How many days of raw logs to keep before pruning. diff --git a/package-lock.json b/package-lock.json index 9946c87..4049544 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,8 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "tailwindcss": "^4.1.18", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^3.0.0" } }, "node_modules/@alloc/quick-lru": { @@ -1927,6 +1928,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1972,6 +1991,131 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2094,6 +2238,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2146,6 +2300,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2194,6 +2375,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2270,6 +2461,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2351,6 +2549,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2929,6 +3147,13 @@ "dev": true, "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3045,6 +3270,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3366,6 +3608,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3376,6 +3625,40 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -3417,6 +3700,20 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3434,6 +3731,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3557,6 +3884,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-full-reload": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", @@ -3581,6 +3931,96 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 628801f..3c364b7 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "private": true, "scripts": { "dev": "vite", - "build": "vite build" + "build": "vite build", + "test:js": "vitest run" }, "devDependencies": { "@babel/preset-react": "^7.13.13", @@ -25,7 +26,8 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "tailwindcss": "^4.1.18", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^3.0.0" }, "dependencies": { "@tanstack/react-query": "^5.90.20" diff --git a/plans/2026-06-25-monitor-history-recent-strip-plan.md b/plans/2026-06-25-monitor-history-recent-strip-plan.md new file mode 100644 index 0000000..e2ea52c --- /dev/null +++ b/plans/2026-06-25-monitor-history-recent-strip-plan.md @@ -0,0 +1,418 @@ +# Monitor History — Recent-checks Strip Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn the "Today" bar into a rolling **Recent checks** strip — the last 50 checks per type (may span days), newest on the right, gray-padded on the left — backed by a small last-50 backend change. + +**Architecture:** Backend `buildGraphPayload` swaps its today-only `today_checks` series field for a `latest_checks` field (last 50 of that type, newest-first, no date bound). A pure `stripSlots(checks, capacity)` util computes the right-aligned + gray-padded slot array. The `MonitorTodayBar` component is renamed `MonitorRecentStrip`, rendering those slots with single-solid-color bars (shared palette) and a portal tooltip per real bar. + +**Tech Stack:** Laravel 12, Inertia v2, React 19, Tailwind v4, PHPUnit 11 (MySQL `monitor_test`), Vitest (Node 22). + +## Global Constraints + +- Branch `feat/monitor-history-ui` (already checked out; PR #81). Commit per task. +- **JS tooling requires Node 22** — the default shell node is v12 and CANNOT run Vite 7 / Vitest 3. Prepend it to PATH in every npm command: `export PATH="/Users/vaibhav/.nvm/versions/node/v22.22.2/bin:$PATH" && npm run …` (a `node:fs/promises` ERR_UNKNOWN_BUILTIN_MODULE error = wrong node, not a code bug). +- **Backend tests:** MySQL `monitor_test`; run `php artisan config:clear` first; `php artisan test --filter `. Run `vendor/bin/pint --dirty` before committing PHP. +- **Frontend pure utils:** Vitest TDD (`npm run test:js`). **Components:** no DOM test runner — verify with `npm run build`. +- **Shared single-status solid palette** (already in `resources/js/Utils/heatmapCell.js` as `SINGLE_STATUS_CLASS`): success `bg-green-600 border-green-600`, warning `bg-orange-400 border-orange-400`, failed `bg-red-600 border-red-600`, unknown `bg-gray-400 border-gray-400`. The strip bars reuse it (consistency with domain/cert heatmap cells). +- Gray placeholder slot = `bg-gray-100 border-gray-200` (the heatmap "no checks" color). +- **Already implemented in round 1 (no task here; verify after build):** heatmap today cell = solid `bg-indigo-500`; tooltip portal (makes strip tooltips visible); per-metric heatmap tooltip/legend; headline stat. + +## File Structure + +- `app/Http/Controllers/MonitorsController.php` — rename `buildTodayChecks` → `buildLatestChecks` (last 50, no date bound); series field `today_checks` → `latest_checks` (Task 1). +- `tests/Feature/MonitorHistory/MonitorHistoryGraphTest.php` — update the today_checks test to latest_checks behavior (Task 1). +- `resources/js/Utils/recentStrip.js` + `resources/js/Utils/recentStrip.test.js` — pure `stripSlots` (Task 2). +- `resources/js/Components/MonitorRecentStrip.jsx` (renamed from `MonitorTodayBar.jsx`) — strip rendering (Task 3). +- `resources/js/Pages/Monitors/Show.jsx` — import/usage rename + `latest_checks` prop (Task 3). + +## Interfaces + +- `buildLatestChecks(Monitor $monitor, string $checkType, string $timezone, int $limit = 50): array` — newest-first rows `{id, checked_at:'Y-m-d H:i:s' (tz), status, message, failure_reason, response_time_ms}`. Graph series field: `series..latest_checks`. +- `stripSlots(checks, capacity)` — `checks` newest-first; returns an array of length `capacity`, left→right, with leading `null`s (gray placeholders) when `checks.length < capacity` and the most-recent checks trailing with the newest at the **last (right)** index. +- `MonitorRecentStrip({ checkType, checks })` — `checks` = `series[type].latest_checks` (newest-first). + +--- + +### Task 1: Backend — `latest_checks` (last 50, spans days) + +**Files:** +- Modify: `app/Http/Controllers/MonitorsController.php` (`buildTodayChecks`→`buildLatestChecks` at ~line 494; call site ~line 446) +- Test: `tests/Feature/MonitorHistory/MonitorHistoryGraphTest.php` (replace `test_today_checks_contain_only_todays_rows_newest_first`, ~lines 197–223) + +**Interfaces:** +- Produces: `buildLatestChecks(Monitor, string $checkType, string $timezone, int $limit = 50): array`; graph series field `latest_checks`. +- Consumes: existing `$monitor->checkLogs()`, `MonitorCheckLog`. + +- [ ] **Step 1: Replace the today_checks test with a latest_checks test.** In `MonitorHistoryGraphTest.php`, replace the whole `test_today_checks_contain_only_todays_rows_newest_first` method with: + +```php + public function test_latest_checks_are_newest_first_and_span_days(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $todayMorning = Carbon::now('UTC')->startOfDay()->addHours(8); + $todayNoon = Carbon::now('UTC')->startOfDay()->addHours(12); + $yesterday = Carbon::now('UTC')->subDay()->setTime(10, 0); + + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_SUCCESS, $todayMorning->toDateTimeString()); + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_FAILED, $todayNoon->toDateTimeString()); + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_SUCCESS, $yesterday->toDateTimeString()); + + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'year' => (int) Carbon::now('UTC')->format('Y'), + ])); + + // All three rows (incl. yesterday) — not today-bounded — newest first. + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->has('graph.series.uptime.latest_checks', 3) + ->where('graph.series.uptime.latest_checks.0.status', MonitorCheckLogService::STATUS_FAILED) + ->where('graph.series.uptime.latest_checks.1.status', MonitorCheckLogService::STATUS_SUCCESS) + ->where('graph.series.uptime.latest_checks.2.status', MonitorCheckLogService::STATUS_SUCCESS) + ); + } + + public function test_latest_checks_are_capped_at_fifty(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $base = Carbon::now('UTC')->startOfDay()->addHours(1); + for ($i = 0; $i < 60; $i++) { + $this->seedUptimeLog( + $monitor, + MonitorCheckLogService::STATUS_SUCCESS, + $base->copy()->addMinutes($i)->toDateTimeString() + ); + } + + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'year' => (int) Carbon::now('UTC')->format('Y'), + ])); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->has('graph.series.uptime.latest_checks', 50) + ); + } +``` + +- [ ] **Step 2: Run the tests — expect FAIL.** + +Run: `php artisan config:clear && php artisan test --filter MonitorHistoryGraphTest` +Expected: FAIL — `graph.series.uptime.latest_checks` does not exist (the payload still has `today_checks`), and the span-days count would be 2 not 3. + +- [ ] **Step 3: Rename the call site.** In `MonitorsController.php` `buildGraphPayload`, change the series line: + +```php + 'latest_checks' => $this->buildLatestChecks($monitor, $type, $timezone), +``` + +- [ ] **Step 4: Replace `buildTodayChecks` with `buildLatestChecks`.** Replace the whole `buildTodayChecks(...)` method with: + +```php + protected function buildLatestChecks(Monitor $monitor, string $checkType, string $timezone, int $limit = 50): array + { + return $monitor->checkLogs() + ->where('check_type', $checkType) + ->latest('checked_at') + ->limit($limit) + ->get() + ->map(function (MonitorCheckLog $log) use ($timezone) { + return [ + 'id' => $log->id, + 'checked_at' => $log->checked_at->timezone($timezone)->toDateTimeString(), + 'status' => $log->status, + 'message' => $log->message, + 'failure_reason' => $log->failure_reason, + 'response_time_ms' => $log->response_time_ms, + ]; + }) + ->all(); + } +``` + +- [ ] **Step 5: Run the tests — expect PASS.** + +Run: `php artisan test --filter MonitorHistoryGraphTest` +Expected: PASS (incl. the two new latest_checks tests). Then `php artisan test --filter MonitorHistory` — all green, no regressions. + +- [ ] **Step 6: Pint + commit.** + +```bash +vendor/bin/pint --dirty +git add app/Http/Controllers/MonitorsController.php tests/Feature/MonitorHistory/MonitorHistoryGraphTest.php +git commit -m "Graph: latest_checks = last 50 per type (newest-first, spans days)" +``` + +--- + +### Task 2: `stripSlots` util (Vitest TDD) + +**Files:** +- Create: `resources/js/Utils/recentStrip.js` +- Test: `resources/js/Utils/recentStrip.test.js` + +**Interfaces:** +- Produces: `export function stripSlots(checks, capacity)` — `checks` newest-first; returns length-`capacity` array, left→right, leading `null`s when under-filled, most-recent checks trailing with the **newest at the last index**. + +- [ ] **Step 1: Write the failing test.** Create `resources/js/Utils/recentStrip.test.js`: + +```js +import { describe, it, expect } from "vitest"; +import { stripSlots } from "@/Utils/recentStrip"; + +const c = (id) => ({ id }); + +describe("stripSlots", () => { + it("returns all-gray (null) when there are no checks", () => { + expect(stripSlots([], 3)).toEqual([null, null, null]); + }); + + it("right-aligns checks (newest last) and gray-pads the left", () => { + // newest-first input: c3 newest, c1 oldest + expect(stripSlots([c(3), c(2), c(1)], 5)).toEqual([ + null, + null, + c(1), + c(2), + c(3), + ]); + }); + + it("fills exactly with no padding when checks === capacity", () => { + expect(stripSlots([c(3), c(2), c(1)], 3)).toEqual([c(1), c(2), c(3)]); + }); + + it("keeps only the most-recent `capacity` checks (newest on the right)", () => { + const five = [c(5), c(4), c(3), c(2), c(1)]; // newest-first + expect(stripSlots(five, 3)).toEqual([c(3), c(4), c(5)]); + }); + + it("handles zero/negative capacity safely", () => { + expect(stripSlots([c(1)], 0)).toEqual([]); + expect(stripSlots([c(1)], -2)).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run — expect FAIL.** + +Run: `export PATH="/Users/vaibhav/.nvm/versions/node/v22.22.2/bin:$PATH" && npm run test:js -- resources/js/Utils/recentStrip.test.js` +Expected: FAIL — module `@/Utils/recentStrip` not found. + +- [ ] **Step 3: Implement.** Create `resources/js/Utils/recentStrip.js`: + +```js +// Build the strip's slot array from a newest-first `checks` list. +// Returns an array of length `capacity` (left -> right): leading nulls are gray +// placeholders when there are fewer checks than slots; the most-recent checks +// trail with the NEWEST at the last (right-most) index. +export function stripSlots(checks, capacity) { + const cap = Math.max(0, capacity); + const recent = checks.slice(0, cap); // most-recent `cap`, still newest-first + const ordered = recent.slice().reverse(); // oldest -> newest (newest last) + const padCount = Math.max(0, cap - ordered.length); + + return [...Array(padCount).fill(null), ...ordered]; +} +``` + +- [ ] **Step 4: Run — expect PASS.** + +Run: `npm run test:js -- resources/js/Utils/recentStrip.test.js` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit.** + +```bash +git add resources/js/Utils/recentStrip.js resources/js/Utils/recentStrip.test.js +git commit -m "Add stripSlots: right-aligned, gray-padded recent-check slots" +``` + +--- + +### Task 3: `MonitorRecentStrip` component + wire into Show + +**Files:** +- Create: `resources/js/Components/MonitorRecentStrip.jsx` (renamed from `MonitorTodayBar.jsx`) +- Delete: `resources/js/Components/MonitorTodayBar.jsx` +- Modify: `resources/js/Pages/Monitors/Show.jsx` (import + usage + prop) + +**Interfaces:** +- Consumes: `stripSlots` (Task 2); `SINGLE_STATUS_CLASS` (`@/Utils/heatmapCell`); `formatDateTimeUTC`, `getCheckStatusMeta`, `normalizeCheckStatus`, `statusesForCheckType` (`@/Utils/checkStatusSeverity`); `Tooltip`; `series..latest_checks` (Task 1). +- Produces: `MonitorRecentStrip({ checkType, checks })`. + +- [ ] **Step 1: Create `resources/js/Components/MonitorRecentStrip.jsx`** with the full content: + +```jsx +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { formatDateTimeUTC } from "@/Utils/formatDate"; +import { + getCheckStatusMeta, + normalizeCheckStatus, + statusesForCheckType, +} from "@/Utils/checkStatusSeverity"; +import { SINGLE_STATUS_CLASS } from "@/Utils/heatmapCell"; +import { stripSlots } from "@/Utils/recentStrip"; +import Tooltip from "@/Components/Tooltip"; + +const SEGMENT_WIDTH = 8; +const SEGMENT_GAP = 2; +const MAX_SLOTS = 50; + +function buildSegmentTooltip(check) { + return [ + formatDateTimeUTC(check.checked_at), + `Status: ${getCheckStatusMeta(check.status).label}`, + check.message ? `Message: ${check.message}` : null, + check.response_time_ms !== null && check.response_time_ms !== undefined + ? `Response: ${check.response_time_ms}ms` + : null, + check.failure_reason ? `Failure: ${check.failure_reason}` : null, + ] + .filter(Boolean) + .join("\n"); +} + +export default function MonitorRecentStrip({ checkType, checks = [] }) { + const containerRef = useRef(null); + const [capacity, setCapacity] = useState(MAX_SLOTS); + + // How many fixed-width slots fit, capped at MAX_SLOTS. + useEffect(() => { + const element = containerRef.current; + if (!element) { + return undefined; + } + + const measure = () => { + const width = element.clientWidth; + const perSegment = SEGMENT_WIDTH + SEGMENT_GAP; + const fit = Math.max( + 1, + Math.floor((width + SEGMENT_GAP) / perSegment) + ); + setCapacity(Math.min(MAX_SLOTS, fit)); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [checks.length]); + + const slots = useMemo( + () => stripSlots(checks, capacity), + [checks, capacity] + ); + const shown = slots.filter(Boolean).length; + const legendStatuses = statusesForCheckType(checkType); + + return ( +
+
+ + Recent checks + + + last {shown} + +
+ +
+
+ {slots.map((check, index) => + check ? ( + +
+ + ) : ( + +
+ +
+ {legendStatuses.map((status) => ( + + + {getCheckStatusMeta(status).label} + + ))} +
+
+ ); +} +``` + +- [ ] **Step 2: Delete the old component.** + +```bash +git rm resources/js/Components/MonitorTodayBar.jsx +``` + +- [ ] **Step 3: Update `Show.jsx` import.** Replace `import MonitorTodayBar from "@/Components/MonitorTodayBar";` with: + +```jsx +import MonitorRecentStrip from "@/Components/MonitorRecentStrip"; +``` + +- [ ] **Step 4: Update the usage in `Show.jsx`.** Replace the `` element with: + +```jsx + +``` + +- [ ] **Step 5: Build — expect success.** + +Run: `export PATH="/Users/vaibhav/.nvm/versions/node/v22.22.2/bin:$PATH" && npm run build` +Expected: built successfully, no errors, no remaining reference to `MonitorTodayBar` (grep: `grep -rn "MonitorTodayBar\|today_checks" resources/js` returns nothing). + +- [ ] **Step 6: Commit.** + +```bash +git add resources/js/Components/MonitorRecentStrip.jsx resources/js/Pages/Monitors/Show.jsx +git commit -m "Recent strip: rolling last-50 bars, gray-padded, single-color, no hover-grow" +``` + +--- + +## Self-Review + +1. **Spec coverage:** Item 4 (rolling last-50, responsive cap-50, gray-pad left, static) → Tasks 1+2+3. Item 1 (remove hover shadow/grow) → Task 3 (drop `hover:scale-y-110` + `overflow-hidden`). Item 2 (tooltip date+time+details) → Task 3 (`buildSegmentTooltip` kept) + round-1 portal. Item 3 (consistency) → shared `SINGLE_STATUS_CLASS` in Task 3 + backend in Task 1. Item 5 (today solid indigo) → already shipped round 1 (verify after build). Relabel → Task 3. +2. **Placeholder scan:** none — every step has exact code/commands. +3. **Type consistency:** `buildLatestChecks` / `latest_checks` / `stripSlots` / `SINGLE_STATUS_CLASS` / `MonitorRecentStrip({checkType, checks})` used consistently across tasks. diff --git a/plans/2026-06-25-monitor-history-ui-enhancements-plan.md b/plans/2026-06-25-monitor-history-ui-enhancements-plan.md new file mode 100644 index 0000000..feaa3ed --- /dev/null +++ b/plans/2026-06-25-monitor-history-ui-enhancements-plan.md @@ -0,0 +1,4002 @@ +# Monitor History UI Enhancements Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restructure the monitor detail page's Monitor History UI — decouple the heatmaps from the filters into a full-calendar-year view with year navigation, add a per-check "today" bar, tabbed+paginated recent checks, styled tooltips, per-metric legends, readable dates, and a set of high-value enhancements that surface data the backend already computes. + +**Architecture:** Backend (`MonitorsController@show`) splits its Inertia payload into independently-reloadable props (`graph`, `filters`, `summary`, `recentChecks`) so year-nav, filter changes, tab switches, and pagination each refresh only their slice via Inertia partial visits. Frontend extracts pure date/calendar/status logic into unit-tested utilities, rebuilds the heatmap as a responsive full-year grid, and composes focused components. The feature stays behind the existing `config('monitor-history.enabled')` flag. + +**Tech Stack:** Laravel 12, Inertia v2 (`@inertiajs/react` ^2.3.8), React 19, Tailwind v4, `@headlessui/react`, `@heroicons/react`, PHPUnit 11 (MySQL `monitor_test`), Vitest (added Phase 1, for pure JS utils only). + +## Global Constraints + +- **Branch:** create `feat/monitor-history-ui` off `main` before any task (we are currently on `main`). +- **Feature flag:** all history UI/payload remains gated by `config('monitor-history.enabled')`; do not change gating. When disabled, `graph`/`filters`/`summary`/`recentChecks` are all `null`. +- **Timezone:** every date/time is resolved and formatted in the server timezone `config('monitor-history.timezone') ?: config('app.timezone','UTC')` — never the browser timezone. JS formatters MUST use `timeZone: 'UTC'` on already-tz-shifted ISO strings. +- **Status palette (unchanged), single source of truth `resources/js/Utils/checkStatusSeverity.js`:** healthy `green-300/500/700`, warning `yellow-300`/`orange-400`, failed `red-300/500/700`, no-checks `gray-100`, unknown `gray-300`. Brand accent indigo/purple (`purple-600`). +- **Numbers:** `tabular-nums` on all counts, ratios, latencies, timestamps. +- **Motion:** transitions 150ms ease-out; every transition carries `motion-reduce:transition-none motion-reduce:transform-none`; gate JS animation behind `matchMedia('(prefers-reduced-motion: reduce)')`. +- **Accessibility:** contrast ≥4.5:1 (chrome text min `gray-600`); never color-alone (text/icon/aria); `focus-visible` rings on all interactive elements; interactive cells keyboard-reachable. +- **Locked decisions (approved):** numbered pager, page size **25**; tabs **Uptime + Domain only**; date format **`27 Mar 2026`**; **Back inline-left** of title; **Certificate deferred** — omit certificate from `graph.check_types`, the today-bar, tabs, and legends (do not render cert sections); include **all Tier-1 enhancements** (E1–E9), defer Tier-2. +- **Backend tests:** PHPUnit against MySQL `monitor_test`. Before running: `php artisan config:clear` and ensure the DB exists (`mysql -u root -e "CREATE DATABASE IF NOT EXISTS monitor_test;"`). Pattern: feature tests on `monitors.show` asserting Inertia props via `assertInertia`. Run a single test with `php artisan test --filter `. +- **Frontend pure utils:** TDD with Vitest (`npm run test:js`). **Frontend components:** no DOM test runner is added — verify with `npm run build` (must succeed) plus the backend feature tests that assert payload shape. This is a deliberate platform constraint, not skipped testing. +- **Per task:** run `vendor/bin/pint --dirty` for PHP changes; `npm run build` for JS component changes; commit at the end of each task. +- **Inertia partial-visit contract (used by every interactive history control):** match the existing pattern in `Show.jsx`: + ```js + router.get(route('monitors.show', monitor.id), params, { + only: [/* props to refresh */], preserveState: true, preserveScroll: true, replace: true, + }) + ``` + `params` = the full current history param set `{ year, preset, from, to, recent_type, recent_page }` merged with the control's change (use the helper `buildHistoryParams(current, overrides)` from Task 3.x). Targets per control: + - **Year nav** → `only: ['graph']`, override `{ year }`. + - **Preset / Apply filter** → `only: ['filters','summary','recentChecks']`, override `{ preset|from|to, recent_page: 1 }`. + - **Tab change** → `only: ['recentChecks']`, override `{ recent_type, recent_page: 1 }`. + - **Page change** → `only: ['recentChecks']`, override `{ recent_page }`. + - Each control sets a local pending flag on `onStart`/`onFinish` → disable + `aria-busy` + skeleton if >300ms. + +--- + +## Interfaces & Contracts (authoritative — every task consumes these names verbatim) + +### Inertia payload from `MonitorsController@show` (when flag enabled) + +``` +monitor: // existing model; fields the UI reads: + name, url, raw_url, uptime_status, uptime_last_check_date, + uptime_check_failure_reason, domain_expires_at, + uptime_check_enabled, domain_check_enabled, certificate_check_enabled +features: { monitorHistory: bool } // shared (HandleInertiaRequests) + +graph: // NOT filter-driven; driven by ?year + year: int + available_years: int[] // [earliestDataYear .. currentYear], ascending + timezone: string + check_types: [{ type: 'uptime'|'domain', enabled: bool }] // cert omitted + series: { + : { + summary: { total_checks:int, success_ratio:float, + status_totals: { success:int, warning:int, failed:int, unknown:int } }, + daily_metrics: [ { date:'YYYY-MM-DD', total_checks:int, successful_checks:int, + warning_checks:int, failed_checks:int, success_ratio:float, + worst_status:string, avg_response_time_ms:int|null, + p95_response_time_ms:int|null } ], // only days that have data + today_checks: [ { id:int, checked_at:'YYYY-MM-DD HH:mm:ss', status:string, + message:string|null, failure_reason:string|null, + response_time_ms:int|null } ], // today only, cap 200, newest first + } + } + +filters: { preset:string, from:'YYYY-MM-DD', to:'YYYY-MM-DD', timezone:string } + +summary: // filter-driven (selected_range) + all_time + all_time: { total_checks, success_ratio, status_totals, by_type } // buildSummary() shape + selected_range:{ total_checks, success_ratio, status_totals, by_type } + first_checked_at: 'YYYY-MM-DD HH:mm:ss' | null + +recentChecks: // filter-driven + tab + paginated + type: 'uptime'|'domain' + data: [ { id, check_type, status, checked_at:'YYYY-MM-DD HH:mm:ss', + message, failure_reason, response_time_ms } ] + pagination: { current_page:int, last_page:int, per_page:int, total:int } +``` +When the flag is disabled, `graph`/`filters`/`summary`/`recentChecks` are `null`. +`buildSummary($query)` already returns `{ total_checks, status_totals{success,warning,failed,unknown}, by_type{:{total_checks,status_totals,success_ratio}}, success_ratio }` — reuse as-is. + +### Backend helper signatures (private methods on `MonitorsController`) + +```php +protected function resolveGraphYear(Request $request, array $availableYears): int // ?year, default current year (tz), clamped into availableYears +protected function availableYears(Monitor $monitor, string $timezone): array // [minYear..currentYear] ascending; [currentYear] if no data +protected function graphCheckTypes(Monitor $monitor): array // [{type:'uptime',enabled},{type:'domain',enabled}] — cert omitted +protected function buildGraphPayload(Monitor $monitor, int $year, string $timezone): array // {year,available_years,timezone,check_types,series} +protected function buildTodayChecks(Monitor $monitor, string $checkType, string $timezone): array // today only, ->limit(200), newest first, mapped rows +protected function buildRecentChecks(Monitor $monitor, string $type, Carbon $fromUtc, Carbon $toUtc, string $timezone): array // {type,data,pagination} via ->paginate(25) +// existing reused: resolveHistoryRange(), buildSummary(), parseDateInput() +``` +Request params read by `show()`: `year` (graph), `preset|from|to` (filters/summary/recentChecks range), `recent_type` (default `'uptime'`), `recent_page` (default 1). + +### JS utility signatures (exact exports) + +```js +// resources/js/Utils/formatDate.js +export function formatDateUTC(iso) // 'YYYY-MM-DD'|ISO -> '27 Mar 2026' +export function formatDateTimeUTC(iso) // -> '27 Mar 2026, 15:00' +export function formatRelative(iso, nowMs)// -> 'just now' | '5m ago' | '3h ago' | '2d ago' + +// resources/js/Utils/heatmapCalendar.js +export function buildYearGrid(year) // -> { weeks: Array> } ; Sun..Sat rows, full Jan1..Dec31 padded to whole weeks (pad days inYear:false) +export function monthLabelColumns(weeks) // -> Array<{ label:'Jan'..'Dec', colIndex:number }> aligned to first week-column containing each month's day 1; drops a label if <3 cols from previous +export function computeCellSize(containerWidth, weekCount, opts) // opts={gap,min,max} -> integer px cell size that fits width, clamped [min,max] + +// resources/js/Utils/checkStatusSeverity.js (ADD to existing file) +export const CHECK_TYPE_STATUSES = { uptime:['success','failed','unknown'], domain:['success','warning','failed','unknown'], certificate:['success','failed','unknown'] } +export function statusesForCheckType(checkType) // -> string[] (all four if unknown type) +``` +Existing `checkStatusSeverity.js` exports remain: `CHECK_STATUS`, `normalizeCheckStatus`, `getCheckStatusMeta`, `getCheckStatusBadgeColor`, `mapUptimeStatusToCheckStatus`. `CHECK_STATUS_META[status].heatmapClass` is the solid per-status color used by the today-bar. + +### Component contracts (props) + +``` +Tooltip({ content, children, className }) // hover+focus; role=tooltip; aria-describedby wired; content = string|node +MonitorHistoryHeatmap({ checkType, title, description, year, points, todayIso }) + // points = series[type].daily_metrics; full-year grid; month axis; today ring; per-metric legend; focusable cells + Tooltip +MonitorTodayBar({ checkType, checks }) // checks = series[type].today_checks; thin segments newest->oldest, most-recent-that-fit; per-segment Tooltip +MonitorHistoryFilters({ filters, pending, onApply }) // onApply({preset}|{preset:'custom',from,to}); inline row; matched h-9; aria-pressed presets; focus rings +SummaryStats({ summary }) // reliability % lead card + supporting success/warning/failed/unknown + all-time compare + empty-state branch +RecentChecksPanel({ recentChecks, checkTypes, pending, onTabChange, onPageChange }) + // tabs uptime/domain; table Time/Type/Status/Message/Response(ms); numbered pager +MonitorLiveStatus({ monitor }) // UP/DOWN/PENDING pill + 'last checked X ago' + failure reason when down +``` + +### Show.jsx param helper (Task 3.x, consumed everywhere) +```js +// current = { year, preset, from, to, recent_type, recent_page } derived from props +export function buildHistoryParams(current, overrides) // -> merged plain object for router.get params +``` + +--- + +## Phases (each ends with a working, reviewable deliverable) + +- **Phase 1 — Foundations & pure utils (Vitest TDD):** Vitest setup; `checkStatusSeverity` per-type status map; `formatDate`; `heatmapCalendar`; `Tooltip` component. No page wiring yet. +- **Phase 2 — Backend payload restructure (PHPUnit TDD):** `show()` returns `graph`/`filters`/`summary`/`recentChecks`; `year`+`available_years`; `today_checks`; recent checks `paginate(25)` + `recent_type`; `first_checked_at`; cert omitted from `check_types`. +- **Phase 3 — Graphs section (build-verified):** rebuild `MonitorHistoryHeatmap` (full-year, month axis, today highlight, a11y, styled tooltips, per-metric legend); `MonitorTodayBar`; year nav; per-type headline; wire into `Show.jsx` graphs section (position 1, decoupled). +- **Phase 4 — Filters + Summary (build-verified):** `MonitorHistoryFilters` inline row (position 2, filter-driven only); `SummaryStats` reliability-led KPIs with unknown reconciliation, all-time compare, empty-state disambiguation (position 3); timezone label. +- **Phase 5 — Recent Checks panel (build-verified; backend in P2):** `RecentChecksPanel` tabs + numbered pagination + Response column + row hover + badge icons (position 4). +- **Phase 6 — Header, live status & polish:** Back inline-left; `MonitorLiveStatus` hero; 2-tier headers; flatten nested cards; compact snapshot strip; de-emphasize disabled checks; reduced-motion + final a11y/contrast pass. + +## Known deferrals (not tasked here) + +- **E3 range-level avg/p95 latency figure** near the uptime heatmap. The Response (ms) column (Phase 5) and the per-day tooltip avg/p95 (Phase 3) cover latency; a *range-level* figure would need a dedicated backend rollup (averaging daily averages / p95s is statistically unsound), so it is deferred rather than approximated. +- **Tier-2 enhancements** (per the approved §G of the spec): certificate enable-path wiring + SSL/domain expiry badges, outage/incident timeline (last/longest outage, MTTR), uptime/downtime streaks, custom-range validation feedback, CSV export. Tracked in `plans/monitor-history-ui-enhancements-spec.md` §E Tier 2. + +--- + +## Phase 1 — Foundations & pure utils (Vitest TDD) + +**Deliverable:** Vitest wired into the project (`npm run test:js`), `checkStatusSeverity.js` extended with a per-check-type status map, two new fully unit-tested pure utilities (`formatDate.js`, `heatmapCalendar.js`), and a reusable build-verified `Tooltip.jsx` — all with no page wiring yet. + +### Task 1.1: Add Vitest tooling, config, and smoke test + +**Files:** +- Modify: `/Users/vaibhav/projects/coloredcow/monitor/package.json` +- Create: `/Users/vaibhav/projects/coloredcow/monitor/vitest.config.js` +- Create (test): `/Users/vaibhav/projects/coloredcow/monitor/resources/js/Utils/__tests__/smoke.test.js` + +**Interfaces:** +- Produces: `npm run test:js` script (`"test:js": "vitest run"`) consumed by every later frontend-util task in the spine. +- Consumes: existing `@vitejs/plugin-react` devDependency (already present in `package.json`); the `@` alias already used across `resources/js` source (e.g. `@/Utils/checkStatusSeverity`). + +- [ ] **Step 1: Install Vitest as a devDependency.** +```bash +npm install --save-dev vitest@^2.1.9 +``` + +- [ ] **Step 2: Add the `test:js` script to `package.json`.** Replace the `scripts` block: +```json + "scripts": { + "dev": "vite", + "build": "vite build", + "test:js": "vitest run" + }, +``` + +- [ ] **Step 3: Create `vitest.config.js`** (pure JS utils — no jsdom; resolve the `@` alias so utils can import siblings): +```js +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./resources/js", import.meta.url)), + }, + }, + test: { + environment: "node", + include: ["resources/js/**/*.{test,spec}.{js,jsx}"], + globals: false, + }, +}); +``` + +- [ ] **Step 4: Create the smoke test** at `resources/js/Utils/__tests__/smoke.test.js`: +```js +import { describe, it, expect } from "vitest"; + +describe("vitest smoke", () => { + it("runs the test runner", () => { + expect(1 + 1).toBe(2); + }); +}); +``` + +- [ ] **Step 5: Verify** — run: +```bash +npm run test:js -- resources/js/Utils/__tests__/smoke.test.js +``` +Expected: PASS — `1 passed` (1 test), exit code 0. + +- [ ] **Step 6: Commit.** +```bash +git add package.json package-lock.json vitest.config.js resources/js/Utils/__tests__/smoke.test.js && git commit -m "Add Vitest tooling, config, and smoke test for pure JS utils" +``` + +### Task 1.2: Add per-check-type status map to `checkStatusSeverity.js` (TDD) + +**Files:** +- Create (test): `/Users/vaibhav/projects/coloredcow/monitor/resources/js/Utils/__tests__/checkStatusSeverity.test.js` +- Modify: `/Users/vaibhav/projects/coloredcow/monitor/resources/js/Utils/checkStatusSeverity.js` + +**Interfaces:** +- Produces (exact, per spine): + - `export const CHECK_TYPE_STATUSES = { uptime:['success','failed','unknown'], domain:['success','warning','failed','unknown'], certificate:['success','failed','unknown'] }` + - `export function statusesForCheckType(checkType)` → `string[]` (all four statuses for an unknown type). +- Consumes: existing `CHECK_STATUS` export already in this file (`success`/`warning`/`failed`/`unknown` string values). Existing exports (`CHECK_STATUS`, `normalizeCheckStatus`, `getCheckStatusMeta`, `getCheckStatusBadgeColor`, `mapUptimeStatusToCheckStatus`) MUST remain unchanged. + +- [ ] **Step 1: Write the failing test** at `resources/js/Utils/__tests__/checkStatusSeverity.test.js`: +```js +import { describe, it, expect } from "vitest"; +import { + CHECK_TYPE_STATUSES, + statusesForCheckType, +} from "@/Utils/checkStatusSeverity"; + +describe("CHECK_TYPE_STATUSES", () => { + it("uptime omits warning", () => { + expect(CHECK_TYPE_STATUSES.uptime).toEqual([ + "success", + "failed", + "unknown", + ]); + }); + + it("certificate omits warning", () => { + expect(CHECK_TYPE_STATUSES.certificate).toEqual([ + "success", + "failed", + "unknown", + ]); + }); + + it("domain includes warning", () => { + expect(CHECK_TYPE_STATUSES.domain).toEqual([ + "success", + "warning", + "failed", + "unknown", + ]); + }); +}); + +describe("statusesForCheckType", () => { + it("returns the uptime list for 'uptime'", () => { + expect(statusesForCheckType("uptime")).toEqual([ + "success", + "failed", + "unknown", + ]); + }); + + it("returns the domain list for 'domain'", () => { + expect(statusesForCheckType("domain")).toEqual([ + "success", + "warning", + "failed", + "unknown", + ]); + }); + + it("returns the certificate list for 'certificate'", () => { + expect(statusesForCheckType("certificate")).toEqual([ + "success", + "failed", + "unknown", + ]); + }); + + it("falls back to all four statuses for an unknown type", () => { + expect(statusesForCheckType("bogus")).toEqual([ + "success", + "warning", + "failed", + "unknown", + ]); + }); + + it("falls back for undefined input", () => { + expect(statusesForCheckType(undefined)).toEqual([ + "success", + "warning", + "failed", + "unknown", + ]); + }); + + it("returns a copy, not the shared array reference", () => { + const result = statusesForCheckType("uptime"); + result.push("mutated"); + expect(CHECK_TYPE_STATUSES.uptime).toEqual([ + "success", + "failed", + "unknown", + ]); + }); +}); +``` + +- [ ] **Step 2: Run the test, expect FAIL.** +```bash +npm run test:js -- resources/js/Utils/__tests__/checkStatusSeverity.test.js +``` +Expected: FAIL — import resolves but `CHECK_TYPE_STATUSES`/`statusesForCheckType` are `undefined` (e.g. "Cannot read properties of undefined" / `statusesForCheckType is not a function`). + +- [ ] **Step 3: Append the new exports** to `resources/js/Utils/checkStatusSeverity.js` (after the existing `mapUptimeStatusToCheckStatus` function, at end of file): +```js +export const CHECK_TYPE_STATUSES = Object.freeze({ + uptime: [CHECK_STATUS.SUCCESS, CHECK_STATUS.FAILED, CHECK_STATUS.UNKNOWN], + domain: [ + CHECK_STATUS.SUCCESS, + CHECK_STATUS.WARNING, + CHECK_STATUS.FAILED, + CHECK_STATUS.UNKNOWN, + ], + certificate: [ + CHECK_STATUS.SUCCESS, + CHECK_STATUS.FAILED, + CHECK_STATUS.UNKNOWN, + ], +}); + +const ALL_CHECK_STATUSES = [ + CHECK_STATUS.SUCCESS, + CHECK_STATUS.WARNING, + CHECK_STATUS.FAILED, + CHECK_STATUS.UNKNOWN, +]; + +export function statusesForCheckType(checkType) { + const statuses = CHECK_TYPE_STATUSES[checkType] || ALL_CHECK_STATUSES; + + return [...statuses]; +} +``` + +- [ ] **Step 4: Run the test, expect PASS.** +```bash +npm run test:js -- resources/js/Utils/__tests__/checkStatusSeverity.test.js +``` +Expected: PASS — `9 passed`, exit code 0. + +- [ ] **Step 5: Commit.** +```bash +git add resources/js/Utils/checkStatusSeverity.js resources/js/Utils/__tests__/checkStatusSeverity.test.js && git commit -m "Add CHECK_TYPE_STATUSES and statusesForCheckType per check type" +``` + +### Task 1.3: Create `formatDate.js` UTC formatters (TDD) + +**Files:** +- Create (test): `/Users/vaibhav/projects/coloredcow/monitor/resources/js/Utils/__tests__/formatDate.test.js` +- Create: `/Users/vaibhav/projects/coloredcow/monitor/resources/js/Utils/formatDate.js` + +**Interfaces:** +- Produces (exact, per spine): + - `export function formatDateUTC(iso)` → `'27 Mar 2026'` + - `export function formatDateTimeUTC(iso)` → `'27 Mar 2026, 15:00'` + - `export function formatRelative(iso, nowMs)` → `'just now' | '5m ago' | '3h ago' | '2d ago'` +- Consumes: ISO strings already shifted to the server timezone (Global Constraint: JS formatters MUST use `timeZone: 'UTC'`). Accepts both `'YYYY-MM-DD'` and `'YYYY-MM-DD HH:mm:ss'` (the `checked_at` payload shape). `nowMs` is a millisecond epoch (e.g. `Date.now()`). + +- [ ] **Step 1: Write the failing test** at `resources/js/Utils/__tests__/formatDate.test.js`: +```js +import { describe, it, expect } from "vitest"; +import { + formatDateUTC, + formatDateTimeUTC, + formatRelative, +} from "@/Utils/formatDate"; + +describe("formatDateUTC", () => { + it("formats a date-only string as '27 Mar 2026'", () => { + expect(formatDateUTC("2026-03-27")).toBe("27 Mar 2026"); + }); + + it("formats a datetime string using its date part", () => { + expect(formatDateUTC("2026-03-27 15:00:00")).toBe("27 Mar 2026"); + }); + + it("formats a leap day correctly", () => { + expect(formatDateUTC("2024-02-29")).toBe("29 Feb 2024"); + }); + + it("does not shift across days at UTC midnight", () => { + expect(formatDateUTC("2026-01-01 00:00:00")).toBe("01 Jan 2026"); + }); + + it("returns empty string for null/empty input", () => { + expect(formatDateUTC(null)).toBe(""); + expect(formatDateUTC("")).toBe(""); + }); +}); + +describe("formatDateTimeUTC", () => { + it("formats a datetime as '27 Mar 2026, 15:00'", () => { + expect(formatDateTimeUTC("2026-03-27 15:00:00")).toBe( + "27 Mar 2026, 15:00" + ); + }); + + it("pads single-digit hours and minutes", () => { + expect(formatDateTimeUTC("2026-03-27 09:05:00")).toBe( + "27 Mar 2026, 09:05" + ); + }); + + it("renders midnight as 00:00 (24h, no shift)", () => { + expect(formatDateTimeUTC("2026-01-01 00:00:00")).toBe( + "01 Jan 2026, 00:00" + ); + }); + + it("returns empty string for null input", () => { + expect(formatDateTimeUTC(null)).toBe(""); + }); +}); + +describe("formatRelative", () => { + const base = Date.UTC(2026, 2, 27, 15, 0, 0); // 2026-03-27 15:00:00 UTC + + it("returns 'just now' for under a minute", () => { + expect(formatRelative("2026-03-27 14:59:30", base)).toBe("just now"); + }); + + it("returns minutes for under an hour", () => { + expect(formatRelative("2026-03-27 14:55:00", base)).toBe("5m ago"); + }); + + it("returns hours for under a day", () => { + expect(formatRelative("2026-03-27 12:00:00", base)).toBe("3h ago"); + }); + + it("returns days for a day or more", () => { + expect(formatRelative("2026-03-25 15:00:00", base)).toBe("2d ago"); + }); + + it("clamps future timestamps to 'just now'", () => { + expect(formatRelative("2026-03-27 15:00:30", base)).toBe("just now"); + }); + + it("returns empty string for null input", () => { + expect(formatRelative(null, base)).toBe(""); + }); +}); +``` + +- [ ] **Step 2: Run the test, expect FAIL.** +```bash +npm run test:js -- resources/js/Utils/__tests__/formatDate.test.js +``` +Expected: FAIL — module `@/Utils/formatDate` cannot be resolved ("Failed to resolve import" / "Cannot find module"). + +- [ ] **Step 3: Create `resources/js/Utils/formatDate.js`:** +```js +// All formatting is done in UTC by design. The backend ships ISO strings that +// have already been shifted into the configured server timezone, so we must NOT +// re-shift them into the browser timezone (Global Constraint). + +function toUTCDate(iso) { + if (!iso) { + return null; + } + + // Accept 'YYYY-MM-DD' and 'YYYY-MM-DD HH:mm:ss' (the checked_at payload shape). + const normalized = String(iso).trim().replace(" ", "T"); + const [datePart, timePart = "00:00:00"] = normalized.split("T"); + const [year, month, day] = datePart.split("-").map(Number); + const [hour = 0, minute = 0, second = 0] = timePart + .split(":") + .map(Number); + + if (!year || !month || !day) { + return null; + } + + return new Date(Date.UTC(year, month - 1, day, hour, minute, second)); +} + +const DATE_FORMATTER = new Intl.DateTimeFormat("en-GB", { + timeZone: "UTC", + day: "2-digit", + month: "short", + year: "numeric", +}); + +const TIME_FORMATTER = new Intl.DateTimeFormat("en-GB", { + timeZone: "UTC", + hour: "2-digit", + minute: "2-digit", + hour12: false, +}); + +export function formatDateUTC(iso) { + const date = toUTCDate(iso); + + if (!date) { + return ""; + } + + // en-GB '2-digit/short/numeric' yields '27 Mar 2026'. + return DATE_FORMATTER.format(date); +} + +export function formatDateTimeUTC(iso) { + const date = toUTCDate(iso); + + if (!date) { + return ""; + } + + return `${DATE_FORMATTER.format(date)}, ${TIME_FORMATTER.format(date)}`; +} + +export function formatRelative(iso, nowMs) { + const date = toUTCDate(iso); + + if (!date) { + return ""; + } + + const deltaMs = Number(nowMs) - date.getTime(); + const deltaSeconds = Math.floor(deltaMs / 1000); + + if (deltaSeconds < 60) { + return "just now"; + } + + const deltaMinutes = Math.floor(deltaSeconds / 60); + if (deltaMinutes < 60) { + return `${deltaMinutes}m ago`; + } + + const deltaHours = Math.floor(deltaMinutes / 60); + if (deltaHours < 24) { + return `${deltaHours}h ago`; + } + + const deltaDays = Math.floor(deltaHours / 24); + return `${deltaDays}d ago`; +} +``` + +- [ ] **Step 4: Run the test, expect PASS.** +```bash +npm run test:js -- resources/js/Utils/__tests__/formatDate.test.js +``` +Expected: PASS — `15 passed`, exit code 0. + +- [ ] **Step 5: Commit.** +```bash +git add resources/js/Utils/formatDate.js resources/js/Utils/__tests__/formatDate.test.js && git commit -m "Add UTC date/time/relative formatters in formatDate util" +``` + +### Task 1.4: Create `heatmapCalendar.js` grid/label/sizing utils (TDD) + +**Files:** +- Create (test): `/Users/vaibhav/projects/coloredcow/monitor/resources/js/Utils/__tests__/heatmapCalendar.test.js` +- Create: `/Users/vaibhav/projects/coloredcow/monitor/resources/js/Utils/heatmapCalendar.js` + +**Interfaces:** +- Produces (exact, per spine): + - `export function buildYearGrid(year)` → `{ weeks: Array> }`; Sun..Sat rows, full Jan1..Dec31 padded to whole weeks (pad days `inYear:false`). + - `export function monthLabelColumns(weeks)` → `Array<{ label:'Jan'..'Dec', colIndex:number }>` aligned to the first week-column containing each month's day 1; drops a label if `<3` cols from the previous kept label. + - `export function computeCellSize(containerWidth, weekCount, opts)` with `opts={gap,min,max}` → integer px cell size that fits width, clamped `[min,max]`. +- Consumes: nothing external (pure date math via `Date.UTC`). + +- [ ] **Step 1: Write the failing test** at `resources/js/Utils/__tests__/heatmapCalendar.test.js`: +```js +import { describe, it, expect } from "vitest"; +import { + buildYearGrid, + monthLabelColumns, + computeCellSize, +} from "@/Utils/heatmapCalendar"; + +describe("buildYearGrid", () => { + it("starts each week on Sunday and ends on Saturday", () => { + const { weeks } = buildYearGrid(2026); + for (const week of weeks) { + expect(week).toHaveLength(7); + } + // 2026-01-01 is a Thursday, so the first week's Sunday is 2025-12-28. + expect(weeks[0][0].iso).toBe("2025-12-28"); + expect(weeks[0][0].inYear).toBe(false); + }); + + it("includes Jan 1 and Dec 31 of the target year as inYear cells", () => { + const { weeks } = buildYearGrid(2026); + const flat = weeks.flat(); + const jan1 = flat.find((c) => c.iso === "2026-01-01"); + const dec31 = flat.find((c) => c.iso === "2026-12-31"); + expect(jan1.inYear).toBe(true); + expect(dec31.inYear).toBe(true); + }); + + it("marks padding days as inYear:false", () => { + const { weeks } = buildYearGrid(2026); + const flat = weeks.flat(); + const padBefore = flat.find((c) => c.iso === "2025-12-31"); + expect(padBefore.inYear).toBe(false); + }); + + it("counts exactly 365 in-year days for a common year", () => { + const { weeks } = buildYearGrid(2026); + const inYearCount = weeks.flat().filter((c) => c.inYear).length; + expect(inYearCount).toBe(365); + }); + + it("counts exactly 366 in-year days for the 2024 leap year", () => { + const { weeks } = buildYearGrid(2024); + const flat = weeks.flat(); + const inYearCount = flat.filter((c) => c.inYear).length; + expect(inYearCount).toBe(366); + expect(flat.find((c) => c.iso === "2024-02-29").inYear).toBe(true); + }); + + it("pads to whole weeks (total cells divisible by 7)", () => { + const { weeks } = buildYearGrid(2024); + expect(weeks.flat().length % 7).toBe(0); + }); +}); + +describe("monthLabelColumns", () => { + it("returns 12 month labels for a full year by default spacing", () => { + const { weeks } = buildYearGrid(2026); + const labels = monthLabelColumns(weeks); + expect(labels[0].label).toBe("Jan"); + expect(labels.every((l) => /^[A-Z][a-z]{2}$/.test(l.label))).toBe( + true + ); + // colIndex must be a valid, ascending week column. + for (let i = 1; i < labels.length; i += 1) { + expect(labels[i].colIndex).toBeGreaterThan(labels[i - 1].colIndex); + expect(labels[i].colIndex).toBeLessThan(weeks.length); + } + }); + + it("drops a label that is fewer than 3 columns from the previous kept one", () => { + // Two months whose day-1 columns are only 2 apart -> second is dropped. + const weeks = [ + [{ iso: "2026-01-01", inYear: true }], + [{ iso: "2026-01-08", inYear: true }], + [{ iso: "2026-02-01", inYear: true }], + ]; + const labels = monthLabelColumns(weeks); + expect(labels).toHaveLength(1); + expect(labels[0]).toEqual({ label: "Jan", colIndex: 0 }); + }); + + it("keeps a label that is at least 3 columns from the previous kept one", () => { + const weeks = [ + [{ iso: "2026-01-01", inYear: true }], + [{ iso: "2026-01-08", inYear: true }], + [{ iso: "2026-01-15", inYear: true }], + [{ iso: "2026-02-01", inYear: true }], + ]; + const labels = monthLabelColumns(weeks); + expect(labels).toEqual([ + { label: "Jan", colIndex: 0 }, + { label: "Feb", colIndex: 3 }, + ]); + }); +}); + +describe("computeCellSize", () => { + const opts = { gap: 4, min: 8, max: 16 }; + + it("fits the cells plus gaps inside the container width", () => { + // 53 weeks, container 900px: (size+gap)*weeks - gap <= width. + const size = computeCellSize(900, 53, opts); + expect(size).toBeGreaterThanOrEqual(opts.min); + expect(size).toBeLessThanOrEqual(opts.max); + expect((size + opts.gap) * 53 - opts.gap).toBeLessThanOrEqual(900); + }); + + it("clamps to max when the container is very wide", () => { + expect(computeCellSize(5000, 53, opts)).toBe(16); + }); + + it("clamps to min when the container is very narrow", () => { + expect(computeCellSize(100, 53, opts)).toBe(8); + }); + + it("returns an integer", () => { + expect(Number.isInteger(computeCellSize(763, 53, opts))).toBe(true); + }); + + it("falls back to min for non-positive width", () => { + expect(computeCellSize(0, 53, opts)).toBe(8); + }); +}); +``` + +- [ ] **Step 2: Run the test, expect FAIL.** +```bash +npm run test:js -- resources/js/Utils/__tests__/heatmapCalendar.test.js +``` +Expected: FAIL — module `@/Utils/heatmapCalendar` cannot be resolved ("Failed to resolve import"). + +- [ ] **Step 3: Create `resources/js/Utils/heatmapCalendar.js`:** +```js +const MONTH_LABELS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function isoFromUTCDate(date) { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function buildYearGrid(year) { + const yearStart = new Date(Date.UTC(year, 0, 1)); + const yearEnd = new Date(Date.UTC(year, 11, 31)); + + // Pad back to the Sunday on/before Jan 1, forward to the Saturday on/after Dec 31. + const gridStart = new Date( + yearStart.getTime() - yearStart.getUTCDay() * MS_PER_DAY + ); + const gridEnd = new Date( + yearEnd.getTime() + (6 - yearEnd.getUTCDay()) * MS_PER_DAY + ); + + const weeks = []; + let currentWeek = []; + + for ( + let cursor = gridStart.getTime(); + cursor <= gridEnd.getTime(); + cursor += MS_PER_DAY + ) { + const date = new Date(cursor); + currentWeek.push({ + iso: isoFromUTCDate(date), + inYear: date.getUTCFullYear() === year, + }); + + if (currentWeek.length === 7) { + weeks.push(currentWeek); + currentWeek = []; + } + } + + return { weeks }; +} + +export function monthLabelColumns(weeks) { + const labels = []; + let lastKeptColIndex = -Infinity; + const seenMonths = new Set(); + + weeks.forEach((week, colIndex) => { + for (const cell of week) { + if (!cell.inYear) { + continue; + } + + const [, monthStr, dayStr] = cell.iso.split("-"); + if (dayStr !== "01") { + continue; + } + + const monthIndex = Number(monthStr) - 1; + if (seenMonths.has(monthIndex)) { + continue; + } + seenMonths.add(monthIndex); + + if (colIndex - lastKeptColIndex < 3 && labels.length > 0) { + continue; + } + + labels.push({ label: MONTH_LABELS[monthIndex], colIndex }); + lastKeptColIndex = colIndex; + } + }); + + return labels; +} + +export function computeCellSize(containerWidth, weekCount, opts) { + const { gap, min, max } = opts; + + if (!containerWidth || containerWidth <= 0 || weekCount <= 0) { + return min; + } + + // Total width = (size + gap) * weekCount - gap; solve for size. + const raw = Math.floor((containerWidth + gap) / weekCount) - gap; + + return Math.max(min, Math.min(max, raw)); +} +``` + +- [ ] **Step 4: Run the test, expect PASS.** +```bash +npm run test:js -- resources/js/Utils/__tests__/heatmapCalendar.test.js +``` +Expected: PASS — `13 passed`, exit code 0. + +- [ ] **Step 5: Commit.** +```bash +git add resources/js/Utils/heatmapCalendar.js resources/js/Utils/__tests__/heatmapCalendar.test.js && git commit -m "Add heatmapCalendar grid, month-label, and cell-size utils" +``` + +### Task 1.5: Create reusable `Tooltip.jsx` (build-verified) + +**Files:** +- Create: `/Users/vaibhav/projects/coloredcow/monitor/resources/js/Components/Tooltip.jsx` + +**Interfaces:** +- Produces (exact, per spine component contract): `Tooltip({ content, children, className })` — hover+focus; `role="tooltip"`; `aria-describedby` wired; positioned; `content = string|node`. +- Consumes: React 19 (already a dependency). No DOM test runner exists for `.jsx` — verification is `npm run build` plus the Phase 3 backend feature tests that assert the heatmap/today-bar payload this component will later render against. Must honor Global Constraints: `motion-reduce:transition-none`, `focus-visible` reachable wrapper, `tabular-nums` left to callers. + +- [ ] **Step 1: Create `resources/js/Components/Tooltip.jsx`:** +```jsx +import React, { useId, useState } from "react"; + +// Reusable hover + keyboard-focus tooltip. +// - role="tooltip" with aria-describedby wired to the trigger for screen readers. +// - Shows on mouse hover AND keyboard focus (focus-visible reachable trigger). +// - Positioned above the trigger, centered; pointer-events disabled so it never +// steals hover. Motion is gated with motion-reduce:* per Global Constraints. +export default function Tooltip({ content, children, className = "" }) { + const tooltipId = useId(); + const [open, setOpen] = useState(false); + + const show = () => setOpen(true); + const hide = () => setOpen(false); + + if (content === null || content === undefined || content === "") { + return children; + } + + return ( + + + {children} + + + + {content} + + + ); +} +``` + +- [ ] **Step 2: Verify the build compiles.** +```bash +npm run build +``` +Expected: build succeeds ("built in ... ms", exit code 0), no Vite/React errors referencing `Tooltip.jsx`. Note: this component has no runtime wiring yet; Phase 3's backend feature tests (`MonitorHistoryShowTest` assertions on `graph.series.*.daily_metrics` / `today_checks`) cover the payload it will render once consumed by `MonitorHistoryHeatmap` and `MonitorTodayBar`. + +- [ ] **Step 3: Commit.** +```bash +git add resources/js/Components/Tooltip.jsx && git commit -m "Add reusable accessible Tooltip component (hover + focus)" +``` + +--- + +Phase 1 authored and appended-ready. Key file paths produced by this phase: +- `/Users/vaibhav/projects/coloredcow/monitor/vitest.config.js` +- `/Users/vaibhav/projects/coloredcow/monitor/resources/js/Utils/formatDate.js` +- `/Users/vaibhav/projects/coloredcow/monitor/resources/js/Utils/heatmapCalendar.js` +- `/Users/vaibhav/projects/coloredcow/monitor/resources/js/Utils/checkStatusSeverity.js` (extended) +- `/Users/vaibhav/projects/coloredcow/monitor/resources/js/Components/Tooltip.jsx` +- Tests under `/Users/vaibhav/projects/coloredcow/monitor/resources/js/Utils/__tests__/` + +Notes for the orchestrator: (1) Vitest is pinned to `^2.1.9` (Vite 7-compatible, Node 24 confirmed present). (2) The `@` alias is re-declared in `vitest.config.js` because Vitest does not inherit Laravel's Vite alias resolution. (3) `formatDateUTC` relies on `Intl.DateTimeFormat('en-GB', …)` to emit the locked `27 Mar 2026` format; if a future Node/ICU build alters en-GB spacing the `formatDate.test.js` assertions will catch it. + + +--- + +## Phase 2 — Backend payload restructure (PHPUnit TDD) + +**Deliverable:** `MonitorsController@show` returns top-level `graph`/`filters`/`summary`/`recentChecks` props (each `null` when the feature flag is off) per the spine schema, with `availableYears()`, `resolveGraphYear()`, `graphCheckTypes()`, `buildGraphPayload()`, `buildTodayChecks()`, `buildRecentChecks()` helpers implemented and `summary.first_checked_at` added; all behaviors covered by PHPUnit feature tests on `monitors.show`. + +### Task 2.1: Add per-type graph payload helpers (`graphCheckTypes`, `availableYears`, `resolveGraphYear`) + +**Files:** +- modify: `app/Http/Controllers/MonitorsController.php` +- test: `tests/Feature/MonitorHistory/MonitorHistoryGraphTest.php` (create) + +**Interfaces:** +- Produces `protected function graphCheckTypes(Monitor $monitor): array` → `[{type:'uptime',enabled:bool},{type:'domain',enabled:bool}]` (certificate omitted) +- Produces `protected function availableYears(Monitor $monitor, string $timezone): array` → ascending `[minYear..currentYear]`, or `[currentYear]` when no data +- Produces `protected function resolveGraphYear(Request $request, array $availableYears): int` → reads `?year`, defaults to current year (in `$timezone` via caller), clamped into `$availableYears` +- Consumes existing `MonitorCheckLogService::CHECK_TYPE_UPTIME`, `CHECK_TYPE_DOMAIN`; `$monitor->checkLogs()` + +- [ ] **Step 1: Create the failing test file with graph-type and available-years cases.** +```php + true]); + } + + private function makeMonitor(array $attributes = []): Monitor + { + return Monitor::create(array_merge([ + 'url' => 'https://example-'.uniqid().'.com', + 'uptime_check_enabled' => true, + 'domain_check_enabled' => false, + 'certificate_check_enabled' => false, + ], $attributes)); + } + + private function seedUptimeLog(Monitor $monitor, string $status, string $checkedAt): void + { + app(MonitorCheckLogService::class)->logCheck( + monitor: $monitor, + checkType: MonitorCheckLogService::CHECK_TYPE_UPTIME, + status: $status, + checkedAt: Carbon::parse($checkedAt), + ); + } + + public function test_graph_check_types_exclude_certificate(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor([ + 'uptime_check_enabled' => true, + 'domain_check_enabled' => true, + 'certificate_check_enabled' => true, + ]); + + $response = $this->actingAs($user)->get(route('monitors.show', $monitor->id)); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('graph.check_types', fn ($types) => collect($types)->pluck('type')->all() === ['uptime', 'domain'] + && collect($types)->firstWhere('type', 'uptime')['enabled'] === true + && collect($types)->firstWhere('type', 'domain')['enabled'] === true + ) + ); + } + + public function test_graph_year_defaults_to_current_year_when_no_param(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $response = $this->actingAs($user)->get(route('monitors.show', $monitor->id)); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('graph.year', (int) Carbon::now('UTC')->format('Y')) + ); + } + + public function test_graph_year_param_overrides_default(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_SUCCESS, '2024-05-10 10:00:00'); + + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'year' => 2024, + ])); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('graph.year', 2024) + ); + } + + public function test_available_years_span_earliest_data_year_through_current_year(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_SUCCESS, '2024-01-15 10:00:00'); + + $currentYear = (int) Carbon::now('UTC')->format('Y'); + $expected = range(2024, $currentYear); + + $response = $this->actingAs($user)->get(route('monitors.show', $monitor->id)); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('graph.available_years', $expected) + ); + } + + public function test_available_years_falls_back_to_current_year_when_no_data(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $response = $this->actingAs($user)->get(route('monitors.show', $monitor->id)); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('graph.available_years', [(int) Carbon::now('UTC')->format('Y')]) + ); + } +} +``` + +- [ ] **Step 2: Run the new test and confirm it fails.** +``` +php artisan config:clear && mysql -u root -e "CREATE DATABASE IF NOT EXISTS monitor_test;" && php artisan test --filter MonitorHistoryGraphTest +``` +Expected: FAIL (props `graph.check_types` / `graph.year` / `graph.available_years` are missing — `graph` does not exist yet). + +- [ ] **Step 3: Add the `graphCheckTypes()` helper after `buildSummary()` in `MonitorsController`.** +```php + protected function graphCheckTypes(Monitor $monitor): array + { + return [ + [ + 'type' => MonitorCheckLogService::CHECK_TYPE_UPTIME, + 'enabled' => (bool) $monitor->uptime_check_enabled, + ], + [ + 'type' => MonitorCheckLogService::CHECK_TYPE_DOMAIN, + 'enabled' => (bool) $monitor->domain_check_enabled, + ], + ]; + } +``` + +- [ ] **Step 4: Add the `availableYears()` helper below `graphCheckTypes()`.** +```php + protected function availableYears(Monitor $monitor, string $timezone): array + { + $currentYear = (int) Carbon::now($timezone)->format('Y'); + + $firstCheckedAt = $monitor->checkLogs()->orderBy('checked_at')->value('checked_at'); + + if (! $firstCheckedAt) { + return [$currentYear]; + } + + $minYear = (int) Carbon::parse($firstCheckedAt)->timezone($timezone)->format('Y'); + + if ($minYear > $currentYear) { + $minYear = $currentYear; + } + + return range($minYear, $currentYear); + } +``` + +- [ ] **Step 5: Add the `resolveGraphYear()` helper below `availableYears()`.** +```php + protected function resolveGraphYear(Request $request, array $availableYears): int + { + $default = end($availableYears) ?: (int) Carbon::now('UTC')->format('Y'); + + $requested = $request->integer('year') ?: $default; + + if (! in_array((int) $requested, $availableYears, true)) { + return (int) $default; + } + + return (int) $requested; + } +``` + +- [ ] **Step 6: Temporarily expose a minimal `graph` prop in `show()` so Step 1 tests pass — replace the `'history' => $history,` Inertia render array (this is wired fully in Task 2.4, this step only adds the partial graph slice).** Inside the `if (config('monitor-history.enabled'))` block, after `$timezone = $range['timezone'];`, add: +```php + $availableYears = $this->availableYears($monitor, $timezone); + $graphYear = $this->resolveGraphYear($request, $availableYears); + $graph = [ + 'year' => $graphYear, + 'available_years' => $availableYears, + 'timezone' => $timezone, + 'check_types' => $this->graphCheckTypes($monitor), + 'series' => [], + ]; +``` +Then add `$graph = null;` next to `$history = null;` at the top of `show()`, and add `'graph' => $graph,` to the `Inertia::render('Monitors/Show', [...])` array. + +- [ ] **Step 7: Run the test and confirm it passes.** +``` +php artisan test --filter MonitorHistoryGraphTest +``` +Expected: PASS (all 5 tests green). + +- [ ] **Step 8: Run Pint on changed files.** +``` +vendor/bin/pint --dirty +``` +Expected: no style errors reported. + +- [ ] **Step 9: Commit.** +``` +git add app/Http/Controllers/MonitorsController.php tests/Feature/MonitorHistory/MonitorHistoryGraphTest.php && git commit -m "Add graph check-type, available-years and year-resolution helpers" +``` + +### Task 2.2: Build per-type graph series — `daily_metrics` (full calendar year) + per-type `summary` + +**Files:** +- modify: `app/Http/Controllers/MonitorsController.php` +- test: `tests/Feature/MonitorHistory/MonitorHistoryGraphTest.php` + +**Interfaces:** +- Produces `protected function buildGraphPayload(Monitor $monitor, int $year, string $timezone): array` → `{year, available_years, timezone, check_types, series}` where `series. = { summary, daily_metrics, today_checks }` +- `daily_metrics` is scoped to the full calendar year `Jan1..Dec31` of `$year` (decoupled from the filter range), only days with data, mapped per the spine schema +- per-type `summary` via `buildSummary()` on that type's logs returning `{ total_checks, success_ratio, status_totals }` (read from `by_type`) +- Consumes existing `$monitor->dailyCheckMetrics()->forTimezone()->betweenDates()`, `buildSummary()`, `availableYears()`, `graphCheckTypes()` + +- [ ] **Step 1: Add failing tests for year-scoped daily_metrics decoupling and per-type summary to `MonitorHistoryGraphTest`.** Append these methods inside the class: +```php + public function test_graph_daily_metrics_are_scoped_to_the_graph_year_not_the_filter_range(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_SUCCESS, '2024-02-01 10:00:00'); + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_SUCCESS, '2024-11-20 10:00:00'); + + $this->artisan('monitor:aggregate-check-metrics', [ + '--from' => '2024-02-01', + '--to' => '2024-02-01', + ])->assertSuccessful(); + $this->artisan('monitor:aggregate-check-metrics', [ + '--from' => '2024-11-20', + '--to' => '2024-11-20', + ])->assertSuccessful(); + + // Filter range (preset/from/to) is narrow and in a different year — graph must ignore it. + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'year' => 2024, + 'preset' => 'custom', + 'from' => '2026-03-01', + 'to' => '2026-03-31', + ])); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->has('graph.series.uptime.daily_metrics', 2) + ->where('graph.series.uptime.daily_metrics.0.date', '2024-02-01') + ->where('graph.series.uptime.daily_metrics.1.date', '2024-11-20') + ); + } + + public function test_graph_per_type_summary_counts_only_that_years_logs(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_SUCCESS, '2024-04-10 10:00:00'); + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_FAILED, '2024-04-11 10:00:00'); + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_SUCCESS, '2025-04-10 10:00:00'); + + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'year' => 2024, + ])); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('graph.series.uptime.summary.total_checks', 2) + ->where('graph.series.uptime.summary.status_totals.success', 1) + ->where('graph.series.uptime.summary.status_totals.failed', 1) + ->where('graph.series.uptime.summary.success_ratio', 50.0) + ); + } +``` + +- [ ] **Step 2: Run the test and confirm the new cases fail.** +``` +php artisan test --filter MonitorHistoryGraphTest +``` +Expected: FAIL (`graph.series.uptime.*` does not exist — `series` is still `[]`). + +- [ ] **Step 3: Add the `buildGraphPayload()` helper below `resolveGraphYear()` in `MonitorsController`.** +```php + protected function buildGraphPayload(Monitor $monitor, int $year, string $timezone): array + { + $availableYears = $this->availableYears($monitor, $timezone); + $checkTypes = $this->graphCheckTypes($monitor); + + $yearStartUtc = Carbon::create($year, 1, 1, 0, 0, 0, $timezone)->startOfDay()->utc(); + $yearEndUtc = Carbon::create($year, 12, 31, 0, 0, 0, $timezone)->endOfDay()->utc(); + $yearStartDate = Carbon::create($year, 1, 1, 0, 0, 0, $timezone)->toDateString(); + $yearEndDate = Carbon::create($year, 12, 31, 0, 0, 0, $timezone)->toDateString(); + + $dailyMetricsByType = $monitor->dailyCheckMetrics() + ->forTimezone($timezone) + ->betweenDates($yearStartDate, $yearEndDate) + ->orderBy('date') + ->get() + ->groupBy('check_type') + ->map(function ($rows) { + return $rows->map(function ($row) { + return [ + 'date' => $row->date->toDateString(), + 'total_checks' => $row->total_checks, + 'successful_checks' => $row->successful_checks, + 'warning_checks' => $row->warning_checks, + 'failed_checks' => $row->failed_checks, + 'success_ratio' => (float) $row->success_ratio, + 'worst_status' => $row->worst_status, + 'avg_response_time_ms' => $row->avg_response_time_ms, + 'p95_response_time_ms' => $row->p95_response_time_ms, + ]; + })->values(); + }); + + $series = []; + + foreach ($checkTypes as $checkType) { + $type = $checkType['type']; + + $typeSummary = $this->buildSummary( + $monitor->checkLogs() + ->where('check_type', $type) + ->whereBetween('checked_at', [$yearStartUtc, $yearEndUtc]) + ); + + $series[$type] = [ + 'summary' => [ + 'total_checks' => $typeSummary['by_type'][$type]['total_checks'] ?? 0, + 'success_ratio' => $typeSummary['by_type'][$type]['success_ratio'] ?? 0, + 'status_totals' => $typeSummary['by_type'][$type]['status_totals'] ?? [ + MonitorCheckLogService::STATUS_SUCCESS => 0, + MonitorCheckLogService::STATUS_WARNING => 0, + MonitorCheckLogService::STATUS_FAILED => 0, + MonitorCheckLogService::STATUS_UNKNOWN => 0, + ], + ], + 'daily_metrics' => $dailyMetricsByType->get($type, collect())->values()->all(), + 'today_checks' => $this->buildTodayChecks($monitor, $type, $timezone), + ]; + } + + return [ + 'year' => $year, + 'available_years' => $availableYears, + 'timezone' => $timezone, + 'check_types' => $checkTypes, + 'series' => $series, + ]; + } +``` + +- [ ] **Step 4: Add a temporary stub `buildTodayChecks()` (fully implemented in Task 2.3) so `buildGraphPayload()` resolves.** Add below `buildGraphPayload()`: +```php + protected function buildTodayChecks(Monitor $monitor, string $checkType, string $timezone): array + { + return []; + } +``` + +- [ ] **Step 5: Replace the inline `$graph` array from Task 2.1 Step 6 with a `buildGraphPayload()` call.** In `show()`, replace the block: +```php + $availableYears = $this->availableYears($monitor, $timezone); + $graphYear = $this->resolveGraphYear($request, $availableYears); + $graph = [ + 'year' => $graphYear, + 'available_years' => $availableYears, + 'timezone' => $timezone, + 'check_types' => $this->graphCheckTypes($monitor), + 'series' => [], + ]; +``` +with: +```php + $availableYears = $this->availableYears($monitor, $timezone); + $graphYear = $this->resolveGraphYear($request, $availableYears); + $graph = $this->buildGraphPayload($monitor, $graphYear, $timezone); +``` + +- [ ] **Step 6: Run the test and confirm it passes.** +``` +php artisan test --filter MonitorHistoryGraphTest +``` +Expected: PASS (all 7 tests green, including decoupling + per-type summary). + +- [ ] **Step 7: Run Pint.** +``` +vendor/bin/pint --dirty +``` +Expected: no style errors reported. + +- [ ] **Step 8: Commit.** +``` +git add app/Http/Controllers/MonitorsController.php tests/Feature/MonitorHistory/MonitorHistoryGraphTest.php && git commit -m "Build per-type graph series with year-scoped daily metrics and summary" +``` + +### Task 2.3: Implement `buildTodayChecks()` (today only, cap 200, newest first) + +**Files:** +- modify: `app/Http/Controllers/MonitorsController.php` +- test: `tests/Feature/MonitorHistory/MonitorHistoryGraphTest.php` + +**Interfaces:** +- Produces `protected function buildTodayChecks(Monitor $monitor, string $checkType, string $timezone): array` → today-only rows (in `$timezone`), `->limit(200)`, newest first, each `{ id, checked_at:'YYYY-MM-DD HH:mm:ss', status, message, failure_reason, response_time_ms }` +- Consumes `$monitor->checkLogs()`, `MonitorCheckLog` + +- [ ] **Step 1: Add a failing test that today_checks contains only today's rows, newest first.** Append to `MonitorHistoryGraphTest`: +```php + public function test_today_checks_contain_only_todays_rows_newest_first(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $todayMorning = Carbon::now('UTC')->startOfDay()->addHours(8); + $todayNoon = Carbon::now('UTC')->startOfDay()->addHours(12); + $yesterday = Carbon::now('UTC')->subDay()->setTime(10, 0); + + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_SUCCESS, $todayMorning->toDateTimeString()); + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_FAILED, $todayNoon->toDateTimeString()); + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_SUCCESS, $yesterday->toDateTimeString()); + + $currentYear = (int) Carbon::now('UTC')->format('Y'); + + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'year' => $currentYear, + ])); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->has('graph.series.uptime.today_checks', 2) + ->where('graph.series.uptime.today_checks.0.status', MonitorCheckLogService::STATUS_FAILED) + ->where('graph.series.uptime.today_checks.1.status', MonitorCheckLogService::STATUS_SUCCESS) + ); + } +``` + +- [ ] **Step 2: Run the test and confirm it fails.** +``` +php artisan test --filter MonitorHistoryGraphTest +``` +Expected: FAIL (stub `buildTodayChecks()` returns `[]`, so `today_checks` count is 0, not 2). + +- [ ] **Step 3: Replace the stub `buildTodayChecks()` with the real implementation.** Replace the method body added in Task 2.2 Step 4: +```php + protected function buildTodayChecks(Monitor $monitor, string $checkType, string $timezone): array + { + $todayStartUtc = Carbon::now($timezone)->startOfDay()->utc(); + $todayEndUtc = Carbon::now($timezone)->endOfDay()->utc(); + + return $monitor->checkLogs() + ->where('check_type', $checkType) + ->whereBetween('checked_at', [$todayStartUtc, $todayEndUtc]) + ->latest('checked_at') + ->limit(200) + ->get() + ->map(function (MonitorCheckLog $log) use ($timezone) { + return [ + 'id' => $log->id, + 'checked_at' => $log->checked_at->timezone($timezone)->toDateTimeString(), + 'status' => $log->status, + 'message' => $log->message, + 'failure_reason' => $log->failure_reason, + 'response_time_ms' => $log->response_time_ms, + ]; + }) + ->all(); + } +``` + +- [ ] **Step 4: Run the test and confirm it passes.** +``` +php artisan test --filter MonitorHistoryGraphTest +``` +Expected: PASS (8 tests green; today_checks count is 2, failed row first). + +- [ ] **Step 5: Run Pint.** +``` +vendor/bin/pint --dirty +``` +Expected: no style errors reported. + +- [ ] **Step 6: Commit.** +``` +git add app/Http/Controllers/MonitorsController.php tests/Feature/MonitorHistory/MonitorHistoryGraphTest.php && git commit -m "Implement buildTodayChecks for today-only newest-first per-type rows" +``` + +### Task 2.4: Add `filters`/`summary` props and `summary.first_checked_at` + +**Files:** +- modify: `app/Http/Controllers/MonitorsController.php` +- test: `tests/Feature/MonitorHistory/MonitorHistorySummaryTest.php` (create) + +**Interfaces:** +- Produces top-level `filters: { preset, from, to, timezone }` +- Produces top-level `summary: { all_time, selected_range, first_checked_at }` — `all_time`/`selected_range` are `buildSummary()` shape; `first_checked_at` is `'YYYY-MM-DD HH:mm:ss'|null` in `$timezone` +- Consumes existing `resolveHistoryRange()`, `buildSummary()`, `$monitor->checkLogs()` + +- [ ] **Step 1: Create the failing summary/filters test file.** +```php + true]); + } + + private function makeMonitor(array $attributes = []): Monitor + { + return Monitor::create(array_merge([ + 'url' => 'https://example-'.uniqid().'.com', + 'uptime_check_enabled' => true, + 'domain_check_enabled' => false, + 'certificate_check_enabled' => false, + ], $attributes)); + } + + private function seedUptimeLog(Monitor $monitor, string $status, string $checkedAt): void + { + app(MonitorCheckLogService::class)->logCheck( + monitor: $monitor, + checkType: MonitorCheckLogService::CHECK_TYPE_UPTIME, + status: $status, + checkedAt: Carbon::parse($checkedAt), + ); + } + + public function test_filters_prop_reports_resolved_preset_and_range(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'preset' => 'custom', + 'from' => '2026-03-01', + 'to' => '2026-03-31', + ])); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('filters.preset', 'custom') + ->where('filters.from', '2026-03-01') + ->where('filters.to', '2026-03-31') + ->has('filters.timezone') + ); + } + + public function test_summary_selected_range_differs_from_all_time(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_SUCCESS, '2026-03-10 10:00:00'); + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_FAILED, '2025-01-01 10:00:00'); + + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'preset' => 'custom', + 'from' => '2026-03-01', + 'to' => '2026-03-31', + ])); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('summary.all_time.total_checks', 2) + ->where('summary.selected_range.total_checks', 1) + ->where('summary.selected_range.by_type.uptime.total_checks', 1) + ); + } + + public function test_summary_first_checked_at_is_earliest_log_in_timezone(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_SUCCESS, '2025-01-01 10:00:00'); + $this->seedUptimeLog($monitor, MonitorCheckLogService::STATUS_SUCCESS, '2026-03-10 10:00:00'); + + $response = $this->actingAs($user)->get(route('monitors.show', $monitor->id)); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('summary.first_checked_at', '2025-01-01 10:00:00') + ); + } + + public function test_summary_first_checked_at_is_null_when_no_logs(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $response = $this->actingAs($user)->get(route('monitors.show', $monitor->id)); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('summary.first_checked_at', null) + ); + } +} +``` + +- [ ] **Step 2: Run the test and confirm it fails.** +``` +php artisan test --filter MonitorHistorySummaryTest +``` +Expected: FAIL (top-level `filters` / `summary` props do not exist yet). + +- [ ] **Step 3: In `show()`, add `$filters = null;` and `$summary = null;` alongside `$history = null;` / `$graph = null;` at the top of the method.** +```php + $history = null; + $graph = null; + $filters = null; + $summary = null; +``` + +- [ ] **Step 4: Inside the flag block, build the `$filters` and `$summary` props (reusing the already-computed `$range`, `$allTimeSummary`, `$selectedRangeSummary`).** After the existing `$selectedRangeSummary = $this->buildSummary($selectedRangeQuery);` line, add: +```php + $firstCheckedAt = $monitor->checkLogs()->orderBy('checked_at')->value('checked_at'); + + $filters = [ + 'preset' => $range['preset'], + 'from' => $range['from']->toDateString(), + 'to' => $range['to']->toDateString(), + 'timezone' => $timezone, + ]; + + $summary = [ + 'all_time' => $allTimeSummary, + 'selected_range' => $selectedRangeSummary, + 'first_checked_at' => $firstCheckedAt + ? Carbon::parse($firstCheckedAt)->timezone($timezone)->toDateTimeString() + : null, + ]; +``` + +- [ ] **Step 5: Add `'filters' => $filters,` and `'summary' => $summary,` to the `Inertia::render('Monitors/Show', [...])` array.** +```php + return Inertia::render('Monitors/Show', [ + 'monitor' => $monitor, + 'graph' => $graph, + 'filters' => $filters, + 'summary' => $summary, + 'history' => $history, + ]); +``` + +- [ ] **Step 6: Run the test and confirm it passes.** +``` +php artisan test --filter MonitorHistorySummaryTest +``` +Expected: PASS (4 tests green). + +- [ ] **Step 7: Run Pint.** +``` +vendor/bin/pint --dirty +``` +Expected: no style errors reported. + +- [ ] **Step 8: Commit.** +``` +git add app/Http/Controllers/MonitorsController.php tests/Feature/MonitorHistory/MonitorHistorySummaryTest.php && git commit -m "Add filters and summary props with first_checked_at" +``` + +### Task 2.5: Implement `buildRecentChecks()` (paginate 25, `recent_type` + range filter) and wire `recentChecks` prop + +**Files:** +- modify: `app/Http/Controllers/MonitorsController.php` +- test: `tests/Feature/MonitorHistory/MonitorHistoryRecentChecksTest.php` (create) + +**Interfaces:** +- Produces `protected function buildRecentChecks(Monitor $monitor, string $type, Carbon $fromUtc, Carbon $toUtc, string $timezone): array` → `{ type, data:[{id,check_type,status,checked_at,message,failure_reason,response_time_ms}], pagination:{current_page,last_page,per_page,total} }` via `->paginate(25)`, newest first +- Produces top-level `recentChecks` prop; request reads `recent_type` (default `'uptime'`) and `recent_page` (default 1) +- Consumes `$monitor->checkLogs()`, `MonitorCheckLog`, `resolveHistoryRange()` + +- [ ] **Step 1: Create the failing recent-checks test file.** +```php + true]); + } + + private function makeMonitor(array $attributes = []): Monitor + { + return Monitor::create(array_merge([ + 'url' => 'https://example-'.uniqid().'.com', + 'uptime_check_enabled' => true, + 'domain_check_enabled' => true, + 'certificate_check_enabled' => false, + ], $attributes)); + } + + private function seedLog(Monitor $monitor, string $checkType, string $status, string $checkedAt, ?int $responseTimeMs = null): void + { + app(MonitorCheckLogService::class)->logCheck( + monitor: $monitor, + checkType: $checkType, + status: $status, + checkedAt: Carbon::parse($checkedAt), + responseTimeMs: $responseTimeMs, + ); + } + + public function test_recent_checks_pagination_shape_uses_page_size_25(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + for ($i = 0; $i < 30; $i++) { + $this->seedLog( + $monitor, + MonitorCheckLogService::CHECK_TYPE_UPTIME, + MonitorCheckLogService::STATUS_SUCCESS, + Carbon::parse('2026-03-10 00:00:00')->addMinutes($i)->toDateTimeString(), + 120, + ); + } + + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'preset' => 'custom', + 'from' => '2026-03-01', + 'to' => '2026-03-31', + ])); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('recentChecks.type', 'uptime') + ->has('recentChecks.data', 25) + ->where('recentChecks.pagination.per_page', 25) + ->where('recentChecks.pagination.current_page', 1) + ->where('recentChecks.pagination.last_page', 2) + ->where('recentChecks.pagination.total', 30) + ->where('recentChecks.data.0.response_time_ms', 120) + ); + } + + public function test_recent_checks_respect_recent_page(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + for ($i = 0; $i < 30; $i++) { + $this->seedLog( + $monitor, + MonitorCheckLogService::CHECK_TYPE_UPTIME, + MonitorCheckLogService::STATUS_SUCCESS, + Carbon::parse('2026-03-10 00:00:00')->addMinutes($i)->toDateTimeString(), + ); + } + + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'preset' => 'custom', + 'from' => '2026-03-01', + 'to' => '2026-03-31', + 'recent_page' => 2, + ])); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('recentChecks.pagination.current_page', 2) + ->has('recentChecks.data', 5) + ); + } + + public function test_recent_checks_filter_by_recent_type(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $this->seedLog($monitor, MonitorCheckLogService::CHECK_TYPE_UPTIME, MonitorCheckLogService::STATUS_SUCCESS, '2026-03-10 10:00:00'); + $this->seedLog($monitor, MonitorCheckLogService::CHECK_TYPE_DOMAIN, MonitorCheckLogService::STATUS_WARNING, '2026-03-11 10:00:00'); + + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'preset' => 'custom', + 'from' => '2026-03-01', + 'to' => '2026-03-31', + 'recent_type' => 'domain', + ])); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('recentChecks.type', 'domain') + ->has('recentChecks.data', 1) + ->where('recentChecks.data.0.check_type', 'domain') + ->where('recentChecks.data.0.status', MonitorCheckLogService::STATUS_WARNING) + ); + } + + public function test_recent_checks_default_type_is_uptime(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $this->seedLog($monitor, MonitorCheckLogService::CHECK_TYPE_UPTIME, MonitorCheckLogService::STATUS_SUCCESS, '2026-03-10 10:00:00'); + + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'preset' => 'custom', + 'from' => '2026-03-01', + 'to' => '2026-03-31', + ])); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('recentChecks.type', 'uptime') + ); + } + + public function test_recent_checks_respect_the_selected_range(): void + { + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $this->seedLog($monitor, MonitorCheckLogService::CHECK_TYPE_UPTIME, MonitorCheckLogService::STATUS_SUCCESS, '2026-03-10 10:00:00'); + $this->seedLog($monitor, MonitorCheckLogService::CHECK_TYPE_UPTIME, MonitorCheckLogService::STATUS_FAILED, '2025-01-01 10:00:00'); + + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'preset' => 'custom', + 'from' => '2026-03-01', + 'to' => '2026-03-31', + ])); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('recentChecks.pagination.total', 1) + ); + } +} +``` + +- [ ] **Step 2: Run the test and confirm it fails.** +``` +php artisan test --filter MonitorHistoryRecentChecksTest +``` +Expected: FAIL (top-level `recentChecks` prop does not exist yet). + +- [ ] **Step 3: Add the `buildRecentChecks()` helper below `buildTodayChecks()` in `MonitorsController`.** +```php + protected function buildRecentChecks(Monitor $monitor, string $type, Carbon $fromUtc, Carbon $toUtc, string $timezone): array + { + $paginator = $monitor->checkLogs() + ->where('check_type', $type) + ->whereBetween('checked_at', [$fromUtc, $toUtc]) + ->latest('checked_at') + ->paginate(25, ['*'], 'recent_page'); + + $data = collect($paginator->items()) + ->map(function (MonitorCheckLog $log) use ($timezone) { + return [ + 'id' => $log->id, + 'check_type' => $log->check_type, + 'status' => $log->status, + 'checked_at' => $log->checked_at->timezone($timezone)->toDateTimeString(), + 'message' => $log->message, + 'failure_reason' => $log->failure_reason, + 'response_time_ms' => $log->response_time_ms, + ]; + }) + ->all(); + + return [ + 'type' => $type, + 'data' => $data, + 'pagination' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + ]; + } +``` + +- [ ] **Step 4: In `show()`, add `$recentChecks = null;` to the top-of-method null block.** +```php + $history = null; + $graph = null; + $filters = null; + $summary = null; + $recentChecks = null; +``` + +- [ ] **Step 5: Inside the flag block, resolve `recent_type` and build the `$recentChecks` prop (reusing `$fromUtc`/`$toUtc`/`$timezone`).** After the `$summary = [...]` assignment from Task 2.4, add: +```php + $recentType = $request->string('recent_type')->toString() ?: MonitorCheckLogService::CHECK_TYPE_UPTIME; + if (! in_array($recentType, [MonitorCheckLogService::CHECK_TYPE_UPTIME, MonitorCheckLogService::CHECK_TYPE_DOMAIN], true)) { + $recentType = MonitorCheckLogService::CHECK_TYPE_UPTIME; + } + + $recentChecks = $this->buildRecentChecks($monitor, $recentType, $fromUtc, $toUtc, $timezone); +``` + +- [ ] **Step 6: Add `'recentChecks' => $recentChecks,` to the `Inertia::render('Monitors/Show', [...])` array.** +```php + return Inertia::render('Monitors/Show', [ + 'monitor' => $monitor, + 'graph' => $graph, + 'filters' => $filters, + 'summary' => $summary, + 'recentChecks' => $recentChecks, + 'history' => $history, + ]); +``` + +- [ ] **Step 7: Run the test and confirm it passes.** +``` +php artisan test --filter MonitorHistoryRecentChecksTest +``` +Expected: PASS (5 tests green; per_page 25, last_page 2, recent_type filter and range filter all honored). + +- [ ] **Step 8: Run Pint.** +``` +vendor/bin/pint --dirty +``` +Expected: no style errors reported. + +- [ ] **Step 9: Commit.** +``` +git add app/Http/Controllers/MonitorsController.php tests/Feature/MonitorHistory/MonitorHistoryRecentChecksTest.php && git commit -m "Implement buildRecentChecks paginated by type and range" +``` + +### Task 2.6: Remove the legacy `history` prop and migrate existing tests + flag-off coverage + +**Files:** +- modify: `app/Http/Controllers/MonitorsController.php` +- test: `tests/Feature/MonitorHistory/MonitorHistoryShowTest.php` (modify — existing OLD `history.*` assertions) + +**Interfaces:** +- Removes top-level `history` prop entirely; `show()` now exposes only `monitor`, `graph`, `filters`, `summary`, `recentChecks` +- When `config('monitor-history.enabled')` is false, `graph`/`filters`/`summary`/`recentChecks` are all `null` +- Consumes the new props produced in Tasks 2.1–2.5 + +- [ ] **Step 1: Migrate the legacy timezone test in `MonitorHistoryShowTest` to the new prop name.** Replace: +```php + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->has('history.daily_metrics.uptime', 1) + ); +``` +with (this monitor's single log is on `2026-03-01`, so the graph year must be 2026): +```php + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->has('graph.series.uptime.daily_metrics', 1) + ); +``` +And change the request to pin the graph year so the assertion is year-stable: +```php + $response = $this->actingAs($user)->get(route('monitors.show', [ + 'monitor' => $monitor->id, + 'preset' => 'all', + 'timezone' => 'Asia/Kolkata', + 'year' => 2026, + ])); +``` + +- [ ] **Step 2: Migrate the legacy check-types test to `graph.check_types` and drop the certificate assertion (cert omitted from graph types).** Replace: +```php + ->where('history.check_types', fn ($types) => collect($types)->firstWhere('type', 'uptime')['enabled'] === true + && collect($types)->firstWhere('type', 'domain')['enabled'] === false + && collect($types)->firstWhere('type', 'certificate')['enabled'] === false + ) +``` +with: +```php + ->where('graph.check_types', fn ($types) => collect($types)->pluck('type')->all() === ['uptime', 'domain'] + && collect($types)->firstWhere('type', 'uptime')['enabled'] === true + && collect($types)->firstWhere('type', 'domain')['enabled'] === false + ) +``` + +- [ ] **Step 3: Migrate the legacy recent-checks range test to `recentChecks.pagination.total`.** Replace: +```php + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->has('history.recent_checks', 1) + ); +``` +with: +```php + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('recentChecks.pagination.total', 1) + ); +``` + +- [ ] **Step 4: Add a flag-off test asserting all four props are null.** Append to `MonitorHistoryShowTest`: +```php + public function test_history_props_are_null_when_feature_disabled(): void + { + config(['monitor-history.enabled' => false]); + + $user = User::factory()->create(); + $monitor = $this->makeMonitor(); + + $response = $this->actingAs($user)->get(route('monitors.show', $monitor->id)); + + $response->assertInertia(fn ($page) => $page + ->component('Monitors/Show') + ->where('graph', null) + ->where('filters', null) + ->where('summary', null) + ->where('recentChecks', null) + ); + } +``` + +- [ ] **Step 5: Remove the legacy `history` payload from `MonitorsController@show`.** Delete the `$history = null;` declaration, the entire `$dailyMetrics = ...`, `$recentChecks = $monitor->checkLogs()...->map(...)` legacy block (the one building `metadata`/`recent_checks`), and the `$history = [ ... ]` assembly array; remove `'history' => $history,` from the `Inertia::render` array. Final render array: +```php + return Inertia::render('Monitors/Show', [ + 'monitor' => $monitor, + 'graph' => $graph, + 'filters' => $filters, + 'summary' => $summary, + 'recentChecks' => $recentChecks, + ]); +``` +Note: keep the `$selectedRangeQuery`, `$allTimeSummary`, `$selectedRangeSummary` computations — they feed the new `$summary` prop. Keep `resolveHistoryRange()`, `buildSummary()`, `parseDateInput()` intact. + +- [ ] **Step 6: Run the full MonitorHistory feature suite and confirm everything passes.** +``` +php artisan test --filter MonitorHistory +``` +Expected: PASS (MonitorHistoryShowTest including the new flag-off test, plus MonitorHistoryGraphTest, MonitorHistorySummaryTest, MonitorHistoryRecentChecksTest — all green; no reference to `history.*` remains). + +- [ ] **Step 7: Run Pint.** +``` +vendor/bin/pint --dirty +``` +Expected: no style errors reported. + +- [ ] **Step 8: Commit.** +``` +git add app/Http/Controllers/MonitorsController.php tests/Feature/MonitorHistory/MonitorHistoryShowTest.php && git commit -m "Remove legacy history prop and migrate tests to graph/filters/summary/recentChecks" +``` + +--- + +Authoring notes for the calling script: +- The `Show.jsx` frontend currently reads the now-removed `history` prop (lines 32–86, 273, 306–313). After Task 2.6 the page will render with `undefined` history data until Phase 3/4/5 rewire it to the new props. This is expected — backend feature tests (not the JS page) verify Phase 2; the page is rebuilt in later phases. No `npm run build` verification is added here because Phase 2 touches no `.jsx`/`.js`. +- `npm run test:js` is added in Phase 1; this phase uses only `php artisan test`. + + +--- + +## Phase 3 — Graphs section (build-verified) + +**Deliverable:** A decoupled, full-calendar-year Graphs section rendered as position #1 of the Monitor History card in `Show.jsx`: a `buildHistoryParams` param helper (Vitest-TDD), a rebuilt responsive full-year `MonitorHistoryHeatmap`, a new `MonitorTodayBar`, async year navigation (prev/next bounded by `graph.available_years`, `only:['graph']`), and a per-type headline driven by `graph.series[type].summary` — all build-verified and covered by the Phase 2 graph-payload feature tests. + +### Task 3.1: `buildHistoryParams(current, overrides)` helper (Vitest TDD) + +**Files:** +- Test: `resources/js/Utils/historyParams.test.js` (create) +- Create: `resources/js/Utils/historyParams.js` + +**Interfaces:** +- Produces: `export function buildHistoryParams(current, overrides)` — `current = { year, preset, from, to, recent_type, recent_page }` (derived from props); returns a merged plain object suitable for `router.get` params. Consumed by the year-nav control in Task 3.4 (override `{ year }`) and by Phases 4–5. +- Consumes: nothing (pure util). + +- [ ] **Step 1: Write the failing Vitest spec.** Create `resources/js/Utils/historyParams.test.js`: + ```js + import { describe, it, expect } from "vitest"; + import { buildHistoryParams } from "@/Utils/historyParams"; + + describe("buildHistoryParams", () => { + const current = { + year: 2026, + preset: "30d", + from: "2026-05-01", + to: "2026-05-31", + recent_type: "uptime", + recent_page: 3, + }; + + it("returns the full current param set when no overrides are given", () => { + expect(buildHistoryParams(current, {})).toEqual({ + year: 2026, + preset: "30d", + from: "2026-05-01", + to: "2026-05-31", + recent_type: "uptime", + recent_page: 3, + }); + }); + + it("merges overrides over the current set without mutating current", () => { + const result = buildHistoryParams(current, { year: 2025 }); + expect(result.year).toBe(2025); + expect(result.preset).toBe("30d"); + expect(current.year).toBe(2026); + }); + + it("applies multiple overrides at once", () => { + const result = buildHistoryParams(current, { + recent_type: "domain", + recent_page: 1, + }); + expect(result.recent_type).toBe("domain"); + expect(result.recent_page).toBe(1); + }); + + it("drops keys whose override value is null or undefined", () => { + const result = buildHistoryParams(current, { from: null, to: undefined }); + expect("from" in result).toBe(false); + expect("to" in result).toBe(false); + expect(result.preset).toBe("30d"); + }); + + it("tolerates a partial current object", () => { + expect(buildHistoryParams({ year: 2026 }, { recent_page: 2 })).toEqual({ + year: 2026, + recent_page: 2, + }); + }); + }); + ``` + +- [ ] **Step 2: Run the spec and confirm it fails.** Run: + ```bash + npm run test:js -- resources/js/Utils/historyParams.test.js + ``` + Expected: FAIL — `Failed to resolve import "@/Utils/historyParams"` (module does not exist yet). + +- [ ] **Step 3: Implement the helper.** Create `resources/js/Utils/historyParams.js`: + ```js + // Builds the full history param set for an Inertia partial visit by merging + // the current param set with a control's change. Keys whose override value is + // null/undefined are removed entirely so they fall back to the server default. + export function buildHistoryParams(current, overrides) { + const merged = { ...current, ...overrides }; + + Object.keys(merged).forEach((key) => { + if (merged[key] === null || merged[key] === undefined) { + delete merged[key]; + } + }); + + return merged; + } + ``` + +- [ ] **Step 4: Run the spec and confirm it passes.** Run: + ```bash + npm run test:js -- resources/js/Utils/historyParams.test.js + ``` + Expected: PASS — all 5 tests green. + +- [ ] **Step 5: Commit.** + ```bash + git add resources/js/Utils/historyParams.js resources/js/Utils/historyParams.test.js && git commit -m "Add buildHistoryParams history-param merge helper (Vitest TDD)" + ``` + +### Task 3.2: Rebuild `MonitorHistoryHeatmap` to the full-year spine props + +**Files:** +- Modify: `resources/js/Components/MonitorHistoryHeatmap.jsx` + +**Interfaces:** +- Produces: `MonitorHistoryHeatmap({ checkType, title, description, year, points, todayIso })` — `points = graph.series[type].daily_metrics`; full-year grid; month axis; today ring; per-metric legend; focusable cells + Tooltip. +- Consumes (all from Phase 1): `buildYearGrid(year)`, `monthLabelColumns(weeks)`, `computeCellSize(containerWidth, weekCount, opts)` from `@/Utils/heatmapCalendar`; `formatDateUTC(iso)` from `@/Utils/formatDate`; `statusesForCheckType(checkType)`, `CHECK_STATUS`, `normalizeCheckStatus`, `getCheckStatusMeta` from `@/Utils/checkStatusSeverity`; `Tooltip({ content, children, className })` from `@/Components/Tooltip`. Payload shape covered by Phase 2 graph-payload feature tests. + +- [ ] **Step 1: Replace the entire file** `resources/js/Components/MonitorHistoryHeatmap.jsx` with the rebuilt full-year implementation: + ```jsx + import React, { useEffect, useMemo, useRef, useState } from "react"; + import { + buildYearGrid, + monthLabelColumns, + computeCellSize, + } from "@/Utils/heatmapCalendar"; + import { formatDateUTC } from "@/Utils/formatDate"; + import { + CHECK_STATUS, + normalizeCheckStatus, + getCheckStatusMeta, + statusesForCheckType, + } from "@/Utils/checkStatusSeverity"; + import Tooltip from "@/Components/Tooltip"; + + const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const CELL_GAP = 3; + const CELL_MIN = 10; + const CELL_MAX = 16; + + // Graded cell color by worst status + success ratio. Mirrors the locked status + // palette. "No checks" days are an explicit gray fill (never transparent). + function getCellClasses(point) { + if (!point || point.total_checks === 0) { + return "bg-gray-100 border-gray-200"; + } + + const normalizedStatus = normalizeCheckStatus(point.worst_status); + const successRatio = Number(point.success_ratio || 0); + + if (normalizedStatus === CHECK_STATUS.FAILED) { + if (successRatio < 30) return "bg-red-700 border-red-700"; + if (successRatio < 70) return "bg-red-500 border-red-500"; + return "bg-red-300 border-red-300"; + } + + if (normalizedStatus === CHECK_STATUS.WARNING) { + if (successRatio < 80) return "bg-orange-400 border-orange-400"; + return "bg-yellow-300 border-yellow-300"; + } + + if (normalizedStatus === CHECK_STATUS.SUCCESS) { + if (successRatio >= 99) return "bg-green-700 border-green-700"; + if (successRatio >= 95) return "bg-green-500 border-green-500"; + return "bg-green-300 border-green-300"; + } + + return "bg-gray-300 border-gray-300"; + } + + // Per-status legend swatches, filtered to the statuses this check type can emit. + const LEGEND_DEFS = { + success: { + label: "Healthy", + swatches: [ + "bg-green-300 border-green-300", + "bg-green-500 border-green-500", + "bg-green-700 border-green-700", + ], + }, + warning: { + label: "Warning", + swatches: [ + "bg-yellow-300 border-yellow-300", + "bg-orange-400 border-orange-400", + ], + }, + failed: { + label: "Failed", + swatches: [ + "bg-red-300 border-red-300", + "bg-red-500 border-red-500", + "bg-red-700 border-red-700", + ], + }, + unknown: { + label: "Unknown", + swatches: ["bg-gray-300 border-gray-300"], + }, + }; + + // Explicit null -> "not measured" so a real 0ms value is never hidden by a + // falsy check (fixes the 0ms-falsy bug in the previous tooltip builder). + function formatMetric(value, suffix) { + if (value === null || value === undefined) { + return "not measured"; + } + return `${value}${suffix}`; + } + + function buildCellTooltip(point, iso) { + const dateLabel = formatDateUTC(iso); + + if (!point || point.total_checks === 0) { + return `${dateLabel}\nNo checks`; + } + + return [ + dateLabel, + `Status: ${getCheckStatusMeta(point.worst_status).label}`, + `Total checks: ${point.total_checks}`, + `Success: ${point.successful_checks}`, + `Warning: ${point.warning_checks}`, + `Failed: ${point.failed_checks}`, + `Success ratio: ${point.success_ratio}%`, + `Avg response: ${formatMetric(point.avg_response_time_ms, "ms")}`, + `P95 response: ${formatMetric(point.p95_response_time_ms, "ms")}`, + ].join("\n"); + } + + export default function MonitorHistoryHeatmap({ + checkType, + title, + description, + year, + points = [], + todayIso = null, + }) { + const containerRef = useRef(null); + const [cellSize, setCellSize] = useState(CELL_MIN); + + const pointMap = useMemo( + () => new Map(points.map((point) => [point.date, point])), + [points] + ); + + const grid = useMemo(() => buildYearGrid(year), [year]); + const weeks = grid.weeks; + const monthColumns = useMemo(() => monthLabelColumns(weeks), [weeks]); + + // Responsive fit: size cells to the container width via ResizeObserver, + // clamped to [CELL_MIN, CELL_MAX]. Below CELL_MIN the wrapper scrolls. + useEffect(() => { + const element = containerRef.current; + if (!element) { + return undefined; + } + + const measure = () => { + setCellSize( + computeCellSize(element.clientWidth, weeks.length, { + gap: CELL_GAP, + min: CELL_MIN, + max: CELL_MAX, + }) + ); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [weeks.length]); + + const isCurrentYear = + todayIso !== null && todayIso.startsWith(String(year) + "-"); + + const legendStatuses = statusesForCheckType(checkType); + + // Visually-hidden one-sentence summary for screen readers. + const daysWithData = points.length; + const failedDays = points.filter( + (point) => normalizeCheckStatus(point.worst_status) === CHECK_STATUS.FAILED + ).length; + const srSummary = `${title}: ${year} calendar. ${daysWithData} day${ + daysWithData === 1 ? "" : "s" + } with recorded checks, ${failedDays} with a failed worst-status.`; + + const cellStyle = { width: cellSize, height: cellSize }; + const labelTrackStyle = { height: cellSize }; + + return ( +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ +

{srSummary}

+ +
+
+ {/* Month label row, aligned to the first week-column of each month. */} + + +
+ {/* Weekday labels (Y axis). */} +
+ {WEEKDAY_LABELS.map((label) => ( + + {label} + + ))} +
+ +
+ {weeks.map((week, weekIndex) => ( +
+ {week.map((day) => { + // Pad days outside the year (leading/trailing + // week fill) are the ONLY transparent cells. + if (!day.inYear) { + return ( + +
+
+
+ +
+ + + No checks + + {legendStatuses + .filter((status) => LEGEND_DEFS[status]) + .map((status) => { + const def = LEGEND_DEFS[status]; + return ( + + + {def.swatches.map((swatch) => ( + + ))} + + {def.label} + + ); + })} + {isCurrentYear ? ( + + + Today + + ) : null} +
+
+ ); + } + ``` + +- [ ] **Step 2: Build-verify.** Run: + ```bash + npm run build + ``` + Expected: built successfully (no import/JSX errors; `@/Utils/heatmapCalendar`, `@/Utils/formatDate`, `@/Components/Tooltip`, and `statusesForCheckType` resolve from Phase 1). + +- [ ] **Step 3: Commit.** + ```bash + git add resources/js/Components/MonitorHistoryHeatmap.jsx && git commit -m "Rebuild MonitorHistoryHeatmap as responsive full-year grid with month axis, today ring, per-metric legend and a11y" + ``` + +### Task 3.3: Create `MonitorTodayBar` component + +**Files:** +- Create: `resources/js/Components/MonitorTodayBar.jsx` + +**Interfaces:** +- Produces: `MonitorTodayBar({ checkType, checks })` — `checks = graph.series[type].today_checks` (newest first, each `{ id, checked_at:'YYYY-MM-DD HH:mm:ss', status, message, failure_reason, response_time_ms }`); thin segments newest->oldest, most-recent-that-fit (measure width); each segment colored by the status `heatmapClass` and wrapped in a Tooltip; small `Today (N checks)` label; per-metric legend reused. +- Consumes (Phase 1): `formatDateTimeUTC(iso)` from `@/Utils/formatDate`; `getCheckStatusMeta(status)` (returns `{ heatmapClass, label, ... }`) and `statusesForCheckType(checkType)` from `@/Utils/checkStatusSeverity`; `Tooltip` from `@/Components/Tooltip`. Payload `today_checks` covered by Phase 2 graph-payload feature tests. + +> Note for implementer: the spine phrases this as `CHECK_STATUS_META[status].heatmapClass`. `CHECK_STATUS_META` is module-local (not exported) in `checkStatusSeverity.js`; use the exported accessor `getCheckStatusMeta(status).heatmapClass`, which returns that exact meta object. Do not add a new export. + +- [ ] **Step 1: Create the file** `resources/js/Components/MonitorTodayBar.jsx`: + ```jsx + import React, { useEffect, useMemo, useRef, useState } from "react"; + import { formatDateTimeUTC } from "@/Utils/formatDate"; + import { + getCheckStatusMeta, + statusesForCheckType, + } from "@/Utils/checkStatusSeverity"; + import Tooltip from "@/Components/Tooltip"; + + const SEGMENT_WIDTH = 8; + const SEGMENT_GAP = 2; + + const LEGEND_SWATCH = { + success: "bg-green-500", + warning: "bg-yellow-400", + failed: "bg-red-500", + unknown: "bg-gray-300", + }; + + function buildSegmentTooltip(check) { + return [ + formatDateTimeUTC(check.checked_at), + `Status: ${getCheckStatusMeta(check.status).label}`, + check.message ? `Message: ${check.message}` : null, + check.response_time_ms !== null && check.response_time_ms !== undefined + ? `Response: ${check.response_time_ms}ms` + : null, + check.failure_reason ? `Failure: ${check.failure_reason}` : null, + ] + .filter(Boolean) + .join("\n"); + } + + export default function MonitorTodayBar({ checkType, checks = [] }) { + const containerRef = useRef(null); + const [maxSegments, setMaxSegments] = useState(checks.length); + + // Measure how many newest-first segments fit; show the most recent that fit. + useEffect(() => { + const element = containerRef.current; + if (!element) { + return undefined; + } + + const measure = () => { + const width = element.clientWidth; + const perSegment = SEGMENT_WIDTH + SEGMENT_GAP; + const fit = Math.max(1, Math.floor((width + SEGMENT_GAP) / perSegment)); + setMaxSegments(fit); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [checks.length]); + + // checks are newest-first; keep the most recent that fit, then render + // oldest->newest left-to-right so the newest sits on the right edge. + const visible = useMemo(() => { + return checks.slice(0, maxSegments).reverse(); + }, [checks, maxSegments]); + + const legendStatuses = statusesForCheckType(checkType); + + return ( +
+

+ {`Today (${checks.length} checks)`} +

+ +
+ {visible.length === 0 ? ( + + No checks recorded today. + + ) : ( +
+ {visible.map((check) => ( + +
+ + ))} +
+ )} +
+ +
+ {legendStatuses.map((status) => ( + + + {getCheckStatusMeta(status).label} + + ))} +
+
+ ); + } + ``` + +- [ ] **Step 2: Build-verify.** Run: + ```bash + npm run build + ``` + Expected: built successfully (imports from `@/Utils/formatDate`, `@/Utils/checkStatusSeverity`, `@/Components/Tooltip` all resolve). + +- [ ] **Step 3: Commit.** + ```bash + git add resources/js/Components/MonitorTodayBar.jsx && git commit -m "Add MonitorTodayBar per-check today timeline with tooltips and legend" + ``` + +### Task 3.4: Wire the Graphs section into `Show.jsx` as position #1 (decoupled) + +**Files:** +- Modify: `resources/js/Pages/Monitors/Show.jsx` + +**Interfaces:** +- Consumes: Inertia prop `graph` (`{ year, available_years, timezone, check_types:[{type,enabled}], series:{:{summary:{total_checks,success_ratio,status_totals},daily_metrics,today_checks}} }`) from Phase 2; `buildHistoryParams(current, overrides)` (Task 3.1); `MonitorHistoryHeatmap` (Task 3.2); `MonitorTodayBar` (Task 3.3). Graph payload shape is covered by the Phase 2 feature tests (`MonitorHistoryShowTest`: graph year / `available_years` / `today_checks` / cert-omitted `check_types`). +- Produces: year-nav control (`only:['graph']`, override `{ year }`) + per-type headline + today-bar + heatmap, rendered first inside the history card. + +> Note for implementer: Phase 2 splits the payload into top-level `graph`/`filters`/`summary`/`recentChecks` props and removes the old `history` prop. This task wires ONLY the Graphs section and reads ONLY `graph`. The legacy `history`-driven filter/summary/recent-checks blocks already present in `Show.jsx` (the gray filter card, the 4-stat grid, the old heatmap loop, the Recent Checks table) are replaced in Phases 4–5; leave them untouched here EXCEPT for removing the single old `MonitorHistoryHeatmap` usage loop, which this task supersedes. To keep the build green in the interim, this task does not delete the legacy blocks — it inserts the new Graphs section above them and removes only the old heatmap `.map(...)` loop (lines that render the old `MonitorHistoryHeatmap` with `fromDate`/`toDate`). The `current` param set is derived defensively from whatever range/year props exist. + +- [ ] **Step 1: Replace the imports block** at the top of `Show.jsx`. Change: + ```jsx + import MonitorHistoryHeatmap from "@/Components/MonitorHistoryHeatmap"; + import { + getCheckStatusBadgeColor, + normalizeCheckStatus, + } from "@/Utils/checkStatusSeverity"; + ``` + to: + ```jsx + import MonitorHistoryHeatmap from "@/Components/MonitorHistoryHeatmap"; + import MonitorTodayBar from "@/Components/MonitorTodayBar"; + import { buildHistoryParams } from "@/Utils/historyParams"; + import { + getCheckStatusBadgeColor, + normalizeCheckStatus, + } from "@/Utils/checkStatusSeverity"; + import { + ChevronLeftIcon, + ChevronRightIcon, + } from "@heroicons/react/24/outline"; + ``` + +- [ ] **Step 2: Read the new `graph` prop and derive the param set.** Immediately after the existing line `const selectedRange = history?.range || null;` (inside `Show`), insert: + ```jsx + const { graph } = usePage().props; + const [graphPending, setGraphPending] = useState(false); + + // The graph is driven solely by ?year and is decoupled from the filters. + const currentParams = useMemo( + () => ({ + year: graph?.year, + preset: selectedRange?.preset, + from: selectedRange?.from, + to: selectedRange?.to, + recent_type: recentChecks?.type || "uptime", + recent_page: recentChecks?.pagination?.current_page || 1, + }), + [graph?.year, selectedRange, recentChecks] + ); + ``` + > Implementer note: `recentChecks` becomes a top-level prop in Phase 2. To avoid a ReferenceError before Phase 5 wires it, also destructure it defensively in Step 3. + +- [ ] **Step 3: Destructure `recentChecks` defensively.** Change the existing destructure line: + ```jsx + const { monitor, features, history } = usePage().props; + ``` + to: + ```jsx + const { monitor, features, history, recentChecks } = usePage().props; + ``` + (Remove the separate `const { graph } = usePage().props;` line added in Step 2 and instead fold `graph` into this same destructure to keep one source: `const { monitor, features, history, graph, recentChecks } = usePage().props;`. Keep the `graphPending` state and `currentParams` memo from Step 2.) + +- [ ] **Step 4: Add the year-nav handler.** After the `submitCustomRange` function definition, insert: + ```jsx + const goToYear = (targetYear) => { + router.get( + route("monitors.show", monitor.id), + buildHistoryParams(currentParams, { year: targetYear }), + { + only: ["graph"], + preserveState: true, + preserveScroll: true, + replace: true, + onStart: () => setGraphPending(true), + onFinish: () => setGraphPending(false), + } + ); + }; + ``` + +- [ ] **Step 5: Add a Graphs section renderer.** Inside the `(...)` branch where `history` is truthy (the `
` block), insert the following as the FIRST child, immediately after the opening `
`: + ```jsx + {graph ? ( +
+
+

+ Health by year +

+
+ + + {graph.year} + + +
+
+ + {graph.check_types + .filter(({ enabled }) => enabled) + .map(({ type }) => { + const series = graph.series?.[type]; + const typeSummary = series?.summary; + const todayIso = (graph.timezone, null) || null; + + return ( +
+

+ {`${formatCheckTypeLabel(type)} · ${ + typeSummary + ? Number( + typeSummary.success_ratio + ).toFixed(1) + : "0.0" + }% · ${( + typeSummary?.total_checks || 0 + ).toLocaleString()} checks`} +

+ + +
+ ); + })} +
+ ) : null} + ``` + > Implementer note on `todayIso`: the spine says today's cell ring shows only when `year === current year` and `todayIso` is in range. The heatmap derives "current year" from `todayIso.startsWith(year + '-')`, so `todayIso` must be the server-tz "today" ISO date. The `graph` payload exposes it; pass `graph.today_iso`. Remove the throwaway `const todayIso = (graph.timezone, null) || null;` line — it was a placeholder; use `graph.today_iso` directly in the `todayIso` prop as shown. (If Phase 2's payload names this field differently, align to that exact field name; the canonical contract is the server-tz today ISO string.) + +- [ ] **Step 6: Remove the legacy heatmap `.map(...)` loop.** Delete the old block in `Show.jsx` that renders `MonitorHistoryHeatmap` with `fromDate`/`toDate` (the `{checkTypes.map(({ type, enabled }) => enabled ? () : (...disabled card...))}` block). The new Graphs section in Step 5 supersedes it. Leave the gray filter card, the 4-stat grid, and the Recent Checks table in place (replaced in Phases 4–5). + +- [ ] **Step 7: Build-verify.** Run: + ```bash + npm run build + ``` + Expected: built successfully (no unresolved imports; `graph` reads, `buildHistoryParams`, `MonitorTodayBar`, `MonitorHistoryHeatmap`, and the Chevron icons all resolve). + +- [ ] **Step 8: Confirm the Phase 2 graph-payload tests still pass** (they assert the props this section renders). Run: + ```bash + php artisan config:clear && php artisan test --filter MonitorHistoryShowTest + ``` + Expected: PASS — the graph year, `available_years`, `today_checks`, and cert-omitted `check_types` assertions from Phase 2 are green (this task only consumes that payload, does not change it). + +- [ ] **Step 9: Commit.** + ```bash + git add resources/js/Pages/Monitors/Show.jsx && git commit -m "Wire decoupled Graphs section (year nav, per-type headline, today bar, full-year heatmap) as position 1 in Show" + ``` + + +--- + +## Phase 4 — Filters + Summary (build-verified) + +**Deliverable:** `MonitorHistoryFilters` inline filter row (preset pills + From/To + Apply, all `h-9`, filter-driven only) and `SummaryStats` reliability-led KPI block (unknown reconciliation E4, all-time compare, empty-state disambiguation E5), both wired into `Show.jsx` at positions #2 and #3 with a single timezone label (E7) on the history section header; `npm run build` succeeds. + +### Task 4.1: Create `MonitorHistoryFilters` component + +**Files:** +- Create: `resources/js/Components/MonitorHistoryFilters.jsx` + +**Interfaces:** +- Produces: `MonitorHistoryFilters({ filters, pending, onApply })` — `filters` = top-level Inertia `filters` prop `{ preset, from, to, timezone }`; `pending` = boolean from `Show.jsx`; `onApply({preset})` for a preset pill or `onApply({preset:'custom', from, to})` for the date range. +- Consumes: nothing from other Phase 4 files (self-contained, raw `` for matched height — `Input.jsx` wraps in a full-width div that would break the inline row). + +- [ ] **Step 1: Create the component file with the preset definitions and controlled custom-range state.** +```jsx +import React, { useEffect, useState } from "react"; + +const PRESETS = [ + { value: "7d", label: "7d" }, + { value: "30d", label: "30d" }, + { value: "all", label: "All" }, +]; + +function todayIso() { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export default function MonitorHistoryFilters({ filters, pending = false, onApply }) { + const activePreset = filters?.preset || "30d"; + + const [customRange, setCustomRange] = useState({ + from: filters?.from || "", + to: filters?.to || "", + }); + + // Keep inputs in sync with the range the server actually applied (it may clamp/swap). + useEffect(() => { + setCustomRange({ from: filters?.from || "", to: filters?.to || "" }); + }, [filters?.from, filters?.to]); + + const max = todayIso(); + + const handlePreset = (value) => { + onApply({ preset: value }); + }; + + const submitCustomRange = (event) => { + event.preventDefault(); + onApply({ preset: "custom", from: customRange.from, to: customRange.to }); + }; + + const segmentBase = + "h-9 px-3 inline-flex items-center justify-center text-xs font-semibold border focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-500 focus-visible:ring-offset-1 transition-colors duration-150 ease-out motion-reduce:transition-none disabled:opacity-50 disabled:cursor-not-allowed"; + + const dateInputClass = + "h-9 px-3 text-sm font-medium tabular-nums text-gray-900 bg-white border border-gray-300 rounded-lg shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-500 focus-visible:border-transparent disabled:opacity-50"; + + return ( +
+
+ {PRESETS.map((preset, index) => { + const isActive = activePreset === preset.value; + return ( + + ); + })} +
+ +
+
+ + + setCustomRange((previous) => ({ + ...previous, + from: event.target.value, + })) + } + className={dateInputClass} + /> +
+
+ + + setCustomRange((previous) => ({ + ...previous, + to: event.target.value, + })) + } + className={dateInputClass} + /> +
+ +
+
+ ); +} +``` + +- [ ] **Step 2: Verify the build compiles the new component.** + Run: `npm run build` + Expected: build completes successfully (no module-resolution or JSX errors); output ends with the Vite "built in