feat(dashboard): KPI row and always-visible methodology panel

This commit is contained in:
Dave 2026-09-16 13:51:34 +02:00
parent be49a69b2f
commit 2792f4ea93
4 changed files with 233 additions and 0 deletions

View file

@ -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 (
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
{title}
</h2>
{rows.length === 0 ? (
<p className="text-sm text-text-muted">{emptyLabel}</p>
) : (
<ul className="space-y-1.5">
{rows.map((row) => (
<li key={row.key} className="flex items-center gap-3">
<span className="w-24 shrink-0 truncate text-xs text-text-secondary">
{row.key}
</span>
<span className="h-5 flex-1 overflow-hidden rounded bg-surface-raised">
<span
className="block h-full rounded bg-brand"
style={{ width: `${(row.count / max) * 100}%` }}
/>
</span>
<span className="tabular w-10 shrink-0 text-right text-xs text-text-muted">
{row.count}
</span>
</li>
))}
</ul>
)}
</section>
);
}

View file

@ -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 (
<div className="rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
<div className="flex items-baseline gap-2">
<p className="text-xs text-text-muted">{label}</p>
{/* The spec requires "estimated" in the UI, not only in a footnote:
these figures are shown to clients and have to survive scrutiny. */}
{estimated && <Pill tone="neutral">estimated</Pill>}
</div>
<p className="tabular mt-2 text-3xl font-semibold tracking-tight text-text-primary">
{value}
</p>
{sub && <p className="mt-1 text-xs text-text-muted">{sub}</p>}
</div>
);
}
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 (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Tile
label="Commits"
value={String(metrics.totalCommits)}
sub={
metrics.mergeCommits > 0
? `${metrics.mergeCommits} merges excluded`
: undefined
}
/>
<Tile
label="Features shipped"
value={String(metrics.features)}
sub={`${metrics.fixes} ${metrics.fixes === 1 ? "fix" : "fixes"} along the way`}
/>
<Tile
label="Hours invested"
value={`${hours}h`}
sub={`across ${metrics.sessionCount} sessions · longest ${longestHours}h`}
estimated
/>
<Tile
label="Active days"
value={`${metrics.activeDays}/${metrics.workdays}`}
sub={`${activePercent}% of workdays · ${metrics.firstCommit} to ${metrics.lastCommit}`}
/>
</div>
);
}

View file

@ -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 (
<section className="space-y-3 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
How these numbers are worked out
</h2>
<div className="space-y-2.5 text-sm text-text-secondary">
<p>
Git records what changed and when, but not how long it took. Hours
here are <strong className="text-text-primary">estimated</strong> from
the timing of commits, not measured.
</p>
<p>
Commits less than{" "}
<span className="tabular text-text-primary">{sessionGapMinutes}</span>{" "}
minutes apart are treated as one working session. Each session counts
the time between its first and last commit, plus{" "}
<span className="tabular text-text-primary">{preSessionMinutes}</span>{" "}
minutes before it — reading and planning happen before code is
committed — and{" "}
<span className="tabular text-text-primary">{postSessionMinutes}</span>{" "}
minutes after, for testing and deploying.
</p>
<p>
<strong className="text-text-primary">What this cannot see:</strong>{" "}
design, meetings, reading code, and debugging that ended without a
commit. The estimate is more likely to be low than high.
</p>
<p>
Merge commits are excluded — a merge records when a branch was
integrated, not work done.
{metrics.mergeCommits > 0 && (
<>
{" "}
<span className="tabular">{metrics.mergeCommits}</span>{" "}
{metrics.mergeCommits === 1 ? "merge was" : "merges were"} left out.
</>
)}
</p>
<p>
Active days are counted against{" "}
<strong className="text-text-primary">workdays</strong> — weekdays
excluding South African public holidays — rather than calendar days,
so weekends are not counted as time not worked.
</p>
</div>
</section>
);
}

View file

@ -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 (
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
Estimated hours by month
</h2>
<div className="flex h-40 items-end gap-2">
{hours.map((month, index) => (
<div key={`${month.label}-${index}`} className="flex flex-1 flex-col items-center gap-1.5">
<span className="tabular text-[11px] text-text-secondary">
{month.hours}h
</span>
<div
className="w-full rounded-t bg-brand"
style={{ height: `${Math.max(2, (month.hours / max) * 100)}%` }}
role="img"
aria-label={`${month.label}: about ${month.hours} estimated hours`}
/>
<span className="text-[11px] text-text-muted">{month.label}</span>
</div>
))}
</div>
</section>
);
}