diff --git a/backend/src/main/java/com/workwell/program/ProgramService.java b/backend/src/main/java/com/workwell/program/ProgramService.java index 6bf7554..c5f02af 100644 --- a/backend/src/main/java/com/workwell/program/ProgramService.java +++ b/backend/src/main/java/com/workwell/program/ProgramService.java @@ -133,6 +133,8 @@ public List trend(UUID measureId, String site, Instant from, // Union of outcome-level data (real runs with employee rows) and run-level aggregate // data (MEASURE-scoped seeded/historical runs that have no outcome rows). Deduplicates // by excluding run IDs already covered by the outcome-based branch. + // run_based uses 0 for per-bucket counts because the runs table only stores compliant + + // non_compliant aggregate totals, not the per-status breakdown. String sql = """ WITH active_measure_version AS ( SELECT mv.id @@ -144,8 +146,12 @@ WITH active_measure_version AS ( outcome_based AS ( SELECT o.run_id, r.started_at, - COUNT(*) AS total_evaluated, - COUNT(*) FILTER (WHERE o.status = 'COMPLIANT') AS compliant + COUNT(*) AS total_evaluated, + COUNT(*) FILTER (WHERE o.status = 'COMPLIANT') AS compliant, + COUNT(*) FILTER (WHERE o.status = 'DUE_SOON') AS due_soon, + COUNT(*) FILTER (WHERE o.status = 'OVERDUE') AS overdue, + COUNT(*) FILTER (WHERE o.status = 'MISSING_DATA') AS missing_data, + COUNT(*) FILTER (WHERE o.status = 'EXCLUDED') AS excluded FROM outcomes o JOIN runs r ON r.id = o.run_id JOIN employees e ON e.id = o.employee_id @@ -162,7 +168,11 @@ run_based AS ( SELECT r.id AS run_id, r.started_at, COALESCE(r.total_evaluated, 0) AS total_evaluated, - COALESCE(r.compliant, 0) AS compliant + COALESCE(r.compliant, 0) AS compliant, + 0 AS due_soon, + 0 AS overdue, + 0 AS missing_data, + 0 AS excluded FROM runs r JOIN active_measure_version amv ON amv.id = r.scope_id WHERE CAST(? AS TEXT) IS NULL @@ -173,10 +183,10 @@ WHERE CAST(? AS TEXT) IS NULL AND (CAST(? AS TIMESTAMPTZ) IS NULL OR r.started_at <= CAST(? AS TIMESTAMPTZ)) AND r.id NOT IN (SELECT run_id FROM outcome_based) ) - SELECT run_id, started_at, total_evaluated, compliant + SELECT run_id, started_at, total_evaluated, compliant, due_soon, overdue, missing_data, excluded FROM outcome_based UNION ALL - SELECT run_id, started_at, total_evaluated, compliant + SELECT run_id, started_at, total_evaluated, compliant, due_soon, overdue, missing_data, excluded FROM run_based ORDER BY started_at DESC LIMIT 10 @@ -185,12 +195,21 @@ AND r.id NOT IN (SELECT run_id FROM outcome_based) return jdbcTemplate.query(sql, (rs, rowNum) -> { long totalEvaluated = rs.getLong("total_evaluated"); long compliant = rs.getLong("compliant"); + long dueSoon = rs.getLong("due_soon"); + long overdue = rs.getLong("overdue"); + long missingData = rs.getLong("missing_data"); + long excluded = rs.getLong("excluded"); double complianceRate = totalEvaluated == 0 ? 0d : Math.round((compliant * 1000.0 / totalEvaluated)) / 10.0; return new ProgramTrendPoint( (UUID) rs.getObject("run_id"), toInstant(rs.getObject("started_at")), complianceRate, - totalEvaluated + totalEvaluated, + compliant, + dueSoon, + overdue, + missingData, + excluded ); }, measureId, site, site, from == null ? null : Timestamp.from(from), from == null ? null : Timestamp.from(from), @@ -350,7 +369,12 @@ public record ProgramTrendPoint( UUID runId, Instant startedAt, double complianceRate, - long totalEvaluated + long totalEvaluated, + long compliant, + long dueSoon, + long overdue, + long missingData, + long excluded ) { } diff --git a/backend/src/test/java/com/workwell/web/ProgramControllerTest.java b/backend/src/test/java/com/workwell/web/ProgramControllerTest.java index 77f0567..82ec5ae 100644 --- a/backend/src/test/java/com/workwell/web/ProgramControllerTest.java +++ b/backend/src/test/java/com/workwell/web/ProgramControllerTest.java @@ -65,8 +65,8 @@ void returnsTrend() throws Exception { UUID runIdA = UUID.fromString("44444444-4444-4444-4444-444444444444"); UUID runIdB = UUID.fromString("55555555-5555-5555-5555-555555555555"); when(programService.trend(measureId, null, null, null)).thenReturn(List.of( - new ProgramService.ProgramTrendPoint(runIdA, Instant.parse("2026-05-07T00:00:00Z"), 55.0, 100), - new ProgramService.ProgramTrendPoint(runIdB, Instant.parse("2026-04-07T00:00:00Z"), 50.0, 100) + new ProgramService.ProgramTrendPoint(runIdA, Instant.parse("2026-05-07T00:00:00Z"), 55.0, 100, 55, 10, 20, 10, 5), + new ProgramService.ProgramTrendPoint(runIdB, Instant.parse("2026-04-07T00:00:00Z"), 50.0, 100, 50, 12, 22, 11, 5) )); mockMvc.perform(get("/api/programs/{measureId}/trend", measureId)) diff --git a/frontend/app/(dashboard)/programs/[measureId]/page.tsx b/frontend/app/(dashboard)/programs/[measureId]/page.tsx index 9874b62..9d5d0dc 100644 --- a/frontend/app/(dashboard)/programs/[measureId]/page.tsx +++ b/frontend/app/(dashboard)/programs/[measureId]/page.tsx @@ -3,7 +3,10 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; -import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from "recharts"; +import { + PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend, + AreaChart, Area, XAxis, YAxis, CartesianGrid +} from "recharts"; import { emitToast } from "@/lib/toast"; import { useApi } from "@/lib/api/hooks"; import { OUTCOME_LABELS, ROLE_LABELS, labelFor } from "@/lib/status"; @@ -30,6 +33,11 @@ type TrendPoint = { startedAt: string; complianceRate: number; totalEvaluated: number; + compliant: number; + dueSoon: number; + overdue: number; + missingData: number; + excluded: number; }; type TopDrivers = { @@ -161,8 +169,8 @@ export default function ProgramDetailPage() {
-

Compliance trend (last 10)

- t.complianceRate)} /> +

Compliance trend (last 10 runs)

+ new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime())} />

Outcome breakdown (latest run)

@@ -420,24 +428,62 @@ export default function ProgramDetailPage() { ); } -function Sparkline({ points }: { points: number[] }) { - const width = 360; - const height = 80; +function ComplianceTrendChart({ points }: { points: TrendPoint[] }) { if (!points.length) { - return
; + return ( +
+ No run history for this measure yet +
+ ); } - const min = Math.min(...points); - const max = Math.max(...points); - const range = max - min || 1; - const step = points.length === 1 ? 0 : width / (points.length - 1); - const d = points.map((p, i) => { - const x = i * step; - const y = height - ((p - min) / range) * height; - return `${i === 0 ? "M" : "L"}${x},${y}`; - }).join(" "); + + const data = points.map((p) => { + const total = p.totalEvaluated || 1; + return { + label: new Date(p.startedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }), + rate: p.complianceRate, + compliant: Math.round((p.compliant / total) * 100), + dueSoon: Math.round((p.dueSoon / total) * 100), + overdue: Math.round((p.overdue / total) * 100), + missingData: Math.round((p.missingData / total) * 100), + excluded: Math.round((p.excluded / total) * 100), + }; + }); + return ( - - - + + + + + + + + + + + `${v}%`} tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} /> + + [`${Number(value).toFixed(1)}%`, String(name)]} + contentStyle={{ fontSize: 11, borderRadius: 6, border: "1px solid #e2e8f0" }} + labelStyle={{ fontSize: 11, color: "#475569" }} + /> + + + + + + + + ); }