From 3277722ae64b15fb64a85b4239d3203c8fcfe739 Mon Sep 17 00:00:00 2001 From: Taleef Date: Mon, 8 Jun 2026 11:14:01 -0400 Subject: [PATCH 1/2] feat(programs): compliance trend chart with per-bucket breakdown Extended ProgramTrendPoint to include all 5 outcome counts (compliant, dueSoon, overdue, missingData, excluded). The trend SQL now returns per-bucket counts from outcome_based (full detail) and zeros from run_based (aggregate rows that only carry compliant + non_compliant). Frontend replaces the SVG sparkline with a recharts AreaChart showing compliance rate over time with date labels on the X axis, a gradient fill, and a tooltip that formats the percentage correctly. Co-Authored-By: Claude Sonnet 4.6 --- .../com/workwell/program/ProgramService.java | 38 +++++++-- .../workwell/web/ProgramControllerTest.java | 4 +- .../(dashboard)/programs/[measureId]/page.tsx | 77 ++++++++++++++----- 3 files changed, 91 insertions(+), 28 deletions(-) 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..7e7463f 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,55 @@ 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) => ({ + label: new Date(p.startedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }), + rate: p.complianceRate, + compliant: p.compliant, + dueSoon: p.dueSoon, + overdue: p.overdue, + missingData: p.missingData, + excluded: p.excluded, + })); + return ( - - - + + + + + + + + + + + `${v}%`} tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} /> + { + if (name === "rate") return [`${Number(value).toFixed(1)}%`, "Compliance rate"]; + return [value, String(name)]; + }} + contentStyle={{ fontSize: 11, borderRadius: 6, border: "1px solid #e2e8f0" }} + labelStyle={{ fontSize: 11, color: "#475569" }} + /> + + + ); } From a600c601df69cdfea533f2b74ee00c7f443b9217 Mon Sep 17 00:00:00 2001 From: Taleef Date: Mon, 8 Jun 2026 13:02:41 -0400 Subject: [PATCH 2/2] fix(programs): render per-bucket series in compliance trend chart Add 5 dashed Area lines (compliant, dueSoon, overdue, missingData, excluded) normalised to % of totalEvaluated so they share the 0-100% Y-axis with the existing compliance-rate area. Add Legend so all series are labelled. Tooltip now shows each bucket's % alongside the overall rate. Co-Authored-By: Claude Sonnet 4.6 --- .../(dashboard)/programs/[measureId]/page.tsx | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/frontend/app/(dashboard)/programs/[measureId]/page.tsx b/frontend/app/(dashboard)/programs/[measureId]/page.tsx index 7e7463f..9d5d0dc 100644 --- a/frontend/app/(dashboard)/programs/[measureId]/page.tsx +++ b/frontend/app/(dashboard)/programs/[measureId]/page.tsx @@ -437,18 +437,21 @@ function ComplianceTrendChart({ points }: { points: TrendPoint[] }) { ); } - const data = points.map((p) => ({ - label: new Date(p.startedAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }), - rate: p.complianceRate, - compliant: p.compliant, - dueSoon: p.dueSoon, - overdue: p.overdue, - missingData: p.missingData, - excluded: p.excluded, - })); + 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 ( - + @@ -459,23 +462,27 @@ function ComplianceTrendChart({ points }: { points: TrendPoint[] }) { `${v}%`} tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} /> + { - if (name === "rate") return [`${Number(value).toFixed(1)}%`, "Compliance rate"]; - return [value, String(name)]; - }} + formatter={(value, name) => [`${Number(value).toFixed(1)}%`, String(name)]} contentStyle={{ fontSize: 11, borderRadius: 6, border: "1px solid #e2e8f0" }} labelStyle={{ fontSize: 11, color: "#475569" }} /> + + + + + );