From 3473cf8ab743d0a3e97f50e8a6ac587d72f629ff Mon Sep 17 00:00:00 2001 From: weeihan Date: Sat, 11 Jul 2026 16:38:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20dashboard=20=E2=80=94=20leading/lagging?= =?UTF-8?q?,=20zone=20heatmap,=20CAPA=20on-time=20rate,=20DOSH=20filing=20?= =?UTF-8?q?status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr --- app/(protected)/hse/dashboard/page.tsx | 137 ++++++++++++++++++++- tests/components/dashboard/metrics.test.ts | 68 ++++++++++ 2 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 tests/components/dashboard/metrics.test.ts diff --git a/app/(protected)/hse/dashboard/page.tsx b/app/(protected)/hse/dashboard/page.tsx index b253958..60145c1 100644 --- a/app/(protected)/hse/dashboard/page.tsx +++ b/app/(protected)/hse/dashboard/page.tsx @@ -17,10 +17,40 @@ const TYPE_LABELS: Record = { export default async function HseDashboardPage() { const supabase = await createClient() - const { data: incidents } = await supabase - .from('incidents') - .select('id, status, incident_type, sites (name)') + const now = new Date() + const thirtyDaysAgo = new Date(now) + thirtyDaysAgo.setDate(now.getDate() - 30) + const ninetyDaysAgo = new Date(now) + ninetyDaysAgo.setDate(now.getDate() - 90) + const [ + { data: incidents }, + { data: recentIncidents }, + { data: zoneIncidents }, + { data: completedCapas }, + { data: doshPendingRows }, + ] = await Promise.all([ + supabase.from('incidents').select('id, status, incident_type, sites (name)'), + supabase + .from('incidents') + .select('incident_type') + .gte('reported_at', thirtyDaysAgo.toISOString()), + supabase + .from('incidents') + .select('zones (name)') + .gte('reported_at', ninetyDaysAgo.toISOString()) + .not('zone_id', 'is', null), + supabase + .from('capa_actions') + .select('due_date, completed_at, verified_at') + .not('completed_at', 'is', null), + supabase + .from('dosh_reports') + .select('id') + .eq('status', 'pending'), + ]) + + // --- Existing metrics --- const rows = incidents ?? [] const total = rows.length const closed = rows.filter(r => r.status === 'closed').length @@ -36,9 +66,45 @@ export default async function HseDashboardPage() { const name = (r.sites as unknown as { name: string } | null)?.name ?? 'Unknown' siteMap[name] = (siteMap[name] ?? 0) + 1 } - const by_site = Object.entries(siteMap).map(([name, count]) => ({ name, count })) + const by_site = Object.entries(siteMap) + .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count) + // --- Leading / lagging (last 30 days) --- + const recent = recentIncidents ?? [] + const leadingCount = recent.filter(r => ['hazard', 'near_miss'].includes(r.incident_type)).length + const laggingCount = recent.filter(r => r.incident_type === 'injury').length + + // --- Zone heatmap (last 90 days) --- + const zoneMap: Record = {} + for (const r of zoneIncidents ?? []) { + const name = (r.zones as unknown as { name: string } | null)?.name ?? 'Unknown' + zoneMap[name] = (zoneMap[name] ?? 0) + 1 + } + const by_zone = Object.entries(zoneMap) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10) + const zoneMax = by_zone[0]?.count ?? 1 + + // --- CAPA on-time rate --- + const capas = completedCapas ?? [] + const onTime = capas.filter(c => { + const due = new Date(c.due_date) + const done = c.verified_at + ? new Date(c.verified_at as string) + : c.completed_at + ? new Date(c.completed_at as string) + : null + return done !== null && done <= due + }) + const capaOnTimeRate = capas.length > 0 + ? Math.round((onTime.length / capas.length) * 100) + : null + + // --- DOSH pending filings --- + const doshPendingCount = doshPendingRows?.length ?? 0 + return (
@@ -48,12 +114,72 @@ export default async function HseDashboardPage() {
-
+ {/* Summary stats */} +
+ 0 ? 'yellow' : 'green'} + sub="filings outstanding" + />
+ {/* Leading vs lagging — last 30 days */} +
+

+ Leading vs Lagging — Last 30 Days +

+

+ Leading: hazard + near-miss reports (predict risk) · Lagging: injuries (past harm) +

+
+
+

{leadingCount}

+

Leading (Hazards + Near Misses)

+
+
+

{laggingCount}

+

Lagging (Injuries)

+
+ {capaOnTimeRate !== null && ( +
+

{capaOnTimeRate}%

+

CAPA On-Time Rate

+
+ )} +
+
+ + {/* Zone heatmap — last 90 days */} + {by_zone.length > 0 && ( +
+

+ Zone Incident Heatmap — Last 90 Days +

+
+ {by_zone.map(({ name, count }) => ( +
+ {name} +
+
+
+ {count} +
+ ))} +
+
+ )} + + {/* Incident type breakdown */}

By Incident Type

@@ -75,6 +201,7 @@ export default async function HseDashboardPage() {
+ {/* By site */}

By Site

diff --git a/tests/components/dashboard/metrics.test.ts b/tests/components/dashboard/metrics.test.ts new file mode 100644 index 0000000..6a20054 --- /dev/null +++ b/tests/components/dashboard/metrics.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest' + +// Pure helpers extracted (conceptually) from dashboard logic — tested here as standalone functions + +function computeCapaOnTimeRate( + capas: Array<{ due_date: string; completed_at: string | null; verified_at: string | null }> +): number | null { + if (capas.length === 0) return null + const onTime = capas.filter(c => { + const due = new Date(c.due_date) + const done = c.verified_at + ? new Date(c.verified_at) + : c.completed_at + ? new Date(c.completed_at) + : null + return done !== null && done <= due + }) + return Math.round((onTime.length / capas.length) * 100) +} + +function classifyIndicators(incidents: Array<{ incident_type: string }>) { + const leading = incidents.filter(i => ['hazard', 'near_miss'].includes(i.incident_type)).length + const lagging = incidents.filter(i => i.incident_type === 'injury').length + return { leading, lagging } +} + +describe('CAPA on-time rate', () => { + it('returns null for empty list', () => { + expect(computeCapaOnTimeRate([])).toBeNull() + }) + + it('returns 100% when all CAPAs completed on time', () => { + const capas = [ + { due_date: '2026-07-10', completed_at: '2026-07-09', verified_at: null }, + { due_date: '2026-07-10', completed_at: null, verified_at: '2026-07-08' }, + ] + expect(computeCapaOnTimeRate(capas)).toBe(100) + }) + + it('returns 50% when half are late', () => { + const capas = [ + { due_date: '2026-07-10', completed_at: '2026-07-09', verified_at: null }, + { due_date: '2026-07-10', completed_at: '2026-07-12', verified_at: null }, + ] + expect(computeCapaOnTimeRate(capas)).toBe(50) + }) + + it('returns 0% when all CAPAs are overdue', () => { + const capas = [ + { due_date: '2026-07-10', completed_at: '2026-07-15', verified_at: null }, + ] + expect(computeCapaOnTimeRate(capas)).toBe(0) + }) +}) + +describe('leading vs lagging indicators', () => { + it('counts hazard and near_miss as leading', () => { + const incidents = [ + { incident_type: 'hazard' }, + { incident_type: 'near_miss' }, + { incident_type: 'injury' }, + { incident_type: 'fire' }, + ] + const { leading, lagging } = classifyIndicators(incidents) + expect(leading).toBe(2) + expect(lagging).toBe(1) + }) +})