Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions backend/src/main/java/com/workwell/program/ProgramService.java
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ public List<ProgramTrendPoint> 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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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
) {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
84 changes: 65 additions & 19 deletions frontend/app/(dashboard)/programs/[measureId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,6 +33,11 @@ type TrendPoint = {
startedAt: string;
complianceRate: number;
totalEvaluated: number;
compliant: number;
dueSoon: number;
overdue: number;
missingData: number;
excluded: number;
};

type TopDrivers = {
Expand Down Expand Up @@ -161,8 +169,8 @@ export default function ProgramDetailPage() {

<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-md border border-slate-200 bg-white p-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-slate-500">Compliance trend (last 10)</p>
<Sparkline points={trend.map((t) => t.complianceRate)} />
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-slate-500">Compliance trend (last 10 runs)</p>
<ComplianceTrendChart points={[...trend].sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime())} />
</div>
<div className="rounded-md border border-slate-200 bg-white p-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-slate-500">Outcome breakdown (latest run)</p>
Expand Down Expand Up @@ -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 <div className="h-[80px] rounded border border-dashed border-slate-300 bg-slate-50" />;
return (
<div className="flex h-[160px] items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50">
<span className="text-xs text-slate-400">No run history for this measure yet</span>
</div>
);
}
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 (
<svg viewBox={`0 0 ${width} ${height}`} className="h-[80px] w-full rounded border border-slate-200 bg-white">
<path d={d} fill="none" stroke="#0f172a" strokeWidth="2" />
</svg>
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
<defs>
<linearGradient id="complianceGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#059669" stopOpacity={0.25} />
<stop offset="95%" stopColor="#059669" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="label" tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<YAxis domain={[0, 100]} tickFormatter={(v: number) => `${v}%`} tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<Legend iconSize={8} wrapperStyle={{ fontSize: 10, paddingTop: 4 }} />
<Tooltip
formatter={(value, name) => [`${Number(value).toFixed(1)}%`, String(name)]}
contentStyle={{ fontSize: 11, borderRadius: 6, border: "1px solid #e2e8f0" }}
labelStyle={{ fontSize: 11, color: "#475569" }}
/>
<Area
type="monotone"
dataKey="rate"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render the per-bucket series in the trend chart

In ComplianceTrendChart, the bucket counts are copied into data, but the chart renders only a single <Area dataKey="rate">. Recharts tooltips/legends are driven by rendered series, so compliant, dueSoon, overdue, missingData, and excluded never appear for historical points; the new API fields therefore do not produce the promised per-bucket outcome breakdown in the trend view even when those counts are available.

Useful? React with 👍 / 👎.

name="Compliance rate"
stroke="#059669"
strokeWidth={2}
fill="url(#complianceGrad)"
dot={{ r: 3, fill: "#059669", strokeWidth: 0 }}
activeDot={{ r: 5 }}
/>
<Area type="monotone" dataKey="compliant" name="Compliant" stroke="#059669" fill="none" strokeWidth={1} strokeDasharray="4 2" dot={false} />
<Area type="monotone" dataKey="dueSoon" name="Due Soon" stroke="#f59e0b" fill="none" strokeWidth={1} strokeDasharray="4 2" dot={false} />
<Area type="monotone" dataKey="overdue" name="Overdue" stroke="#ef4444" fill="none" strokeWidth={1} strokeDasharray="4 2" dot={false} />
<Area type="monotone" dataKey="missingData" name="Missing Data" stroke="#94a3b8" fill="none" strokeWidth={1} strokeDasharray="4 2" dot={false} />
<Area type="monotone" dataKey="excluded" name="Excluded" stroke="#64748b" fill="none" strokeWidth={1} strokeDasharray="4 2" dot={false} />
</AreaChart>
</ResponsiveContainer>
);
}
Loading