From 2792f4ea93b52950c72e709ffcdb8cc84f378e03 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 13:51:34 +0200 Subject: [PATCH] feat(dashboard): KPI row and always-visible methodology panel --- src/components/dashboard/bar-list.tsx | 56 +++++++++++++++++++ src/components/dashboard/kpi-row.tsx | 69 ++++++++++++++++++++++++ src/components/dashboard/methodology.tsx | 67 +++++++++++++++++++++++ src/components/dashboard/month-bars.tsx | 41 ++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 src/components/dashboard/bar-list.tsx create mode 100644 src/components/dashboard/kpi-row.tsx create mode 100644 src/components/dashboard/methodology.tsx create mode 100644 src/components/dashboard/month-bars.tsx diff --git a/src/components/dashboard/bar-list.tsx b/src/components/dashboard/bar-list.tsx new file mode 100644 index 0000000..f60a798 --- /dev/null +++ b/src/components/dashboard/bar-list.tsx @@ -0,0 +1,56 @@ +import type { Tally } from "@/lib/metrics/compute"; + +/** + * Ranked horizontal bars, folded past `limit`. + * + * Folding rather than truncating: the remainder becomes an "other" row so the + * total still adds up. A chart whose bars sum to less than the headline figure + * invites exactly the question this dashboard exists to pre-empt. + */ +export function BarList({ + title, + items, + limit = 6, + emptyLabel = "Nothing yet", +}: { + title: string; + items: Tally[]; + limit?: number; + emptyLabel?: string; +}) { + const shown = items.slice(0, limit); + const rest = items.slice(limit).reduce((sum, item) => sum + item.count, 0); + const rows = rest > 0 ? [...shown, { key: "other", count: rest }] : shown; + const max = Math.max(1, ...rows.map((r) => r.count)); + + return ( +
+

+ {title} +

+ + {rows.length === 0 ? ( +

{emptyLabel}

+ ) : ( + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/dashboard/kpi-row.tsx b/src/components/dashboard/kpi-row.tsx new file mode 100644 index 0000000..b222368 --- /dev/null +++ b/src/components/dashboard/kpi-row.tsx @@ -0,0 +1,69 @@ +import { Pill } from "@/components/ui/pill"; +import type { ProjectMetrics } from "@/lib/metrics/compute"; + +function Tile({ + label, + value, + sub, + estimated, +}: { + label: string; + value: string; + sub?: string; + estimated?: boolean; +}) { + return ( +
+
+

{label}

+ {/* The spec requires "estimated" in the UI, not only in a footnote: + these figures are shown to clients and have to survive scrutiny. */} + {estimated && estimated} +
+

+ {value} +

+ {sub &&

{sub}

} +
+ ); +} + +export function KpiRow({ metrics }: { metrics: ProjectMetrics }) { + const hours = Math.round(metrics.estimatedMinutes / 60); + const longestHours = (metrics.longestSessionMinutes / 60).toFixed(1); + + const activePercent = + metrics.workdays > 0 + ? Math.round((metrics.activeDays / metrics.workdays) * 100) + : 0; + + return ( +
+ 0 + ? `${metrics.mergeCommits} merges excluded` + : undefined + } + /> + + + +
+ ); +} \ No newline at end of file diff --git a/src/components/dashboard/methodology.tsx b/src/components/dashboard/methodology.tsx new file mode 100644 index 0000000..1dbb8d4 --- /dev/null +++ b/src/components/dashboard/methodology.tsx @@ -0,0 +1,67 @@ +import type { ProjectMetrics } from "@/lib/metrics/compute"; + +/** + * Always rendered, never collapsed by default. + * + * Effort figures are shown to clients, so when one is questioned this panel is + * the entire answer. A methodology hidden behind a disclosure triangle is a + * methodology nobody reads, and an unexplained number on a client's screen is + * worse than no number. + */ +export function Methodology({ metrics }: { metrics: ProjectMetrics }) { + const { sessionGapMinutes, preSessionMinutes, postSessionMinutes } = + metrics.constants; + + return ( +
+

+ How these numbers are worked out +

+ +
+

+ Git records what changed and when, but not how long it took. Hours + here are estimated from + the timing of commits, not measured. +

+ +

+ Commits less than{" "} + {sessionGapMinutes}{" "} + minutes apart are treated as one working session. Each session counts + the time between its first and last commit, plus{" "} + {preSessionMinutes}{" "} + minutes before it — reading and planning happen before code is + committed — and{" "} + {postSessionMinutes}{" "} + minutes after, for testing and deploying. +

+ +

+ What this cannot see:{" "} + design, meetings, reading code, and debugging that ended without a + commit. The estimate is more likely to be low than high. +

+ +

+ Merge commits are excluded — a merge records when a branch was + integrated, not work done. + {metrics.mergeCommits > 0 && ( + <> + {" "} + {metrics.mergeCommits}{" "} + {metrics.mergeCommits === 1 ? "merge was" : "merges were"} left out. + + )} +

+ +

+ Active days are counted against{" "} + workdays — weekdays + excluding South African public holidays — rather than calendar days, + so weekends are not counted as time not worked. +

+
+
+ ); +} \ No newline at end of file diff --git a/src/components/dashboard/month-bars.tsx b/src/components/dashboard/month-bars.tsx new file mode 100644 index 0000000..764e17d --- /dev/null +++ b/src/components/dashboard/month-bars.tsx @@ -0,0 +1,41 @@ +import type { MonthlyEffort } from "@/lib/metrics/compute"; + +const MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +export function MonthBars({ monthly }: { monthly: MonthlyEffort[] }) { + if (monthly.length === 0) return null; + + const hours = monthly.map((m) => ({ + label: MONTHS[Number(m.month.slice(5, 7)) - 1], + hours: Math.round(m.minutes / 60), + })); + const max = Math.max(1, ...hours.map((h) => h.hours)); + + return ( +
+

+ Estimated hours by month +

+ +
+ {hours.map((month, index) => ( +
+ + {month.hours}h + +
+ {month.label} +
+ ))} +
+
+ ); +} \ No newline at end of file