feat(dashboard): KPI row and always-visible methodology panel
This commit is contained in:
parent
be49a69b2f
commit
2792f4ea93
4 changed files with 233 additions and 0 deletions
56
src/components/dashboard/bar-list.tsx
Normal file
56
src/components/dashboard/bar-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/components/dashboard/kpi-row.tsx
Normal file
69
src/components/dashboard/kpi-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/dashboard/methodology.tsx
Normal file
67
src/components/dashboard/methodology.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/dashboard/month-bars.tsx
Normal file
41
src/components/dashboard/month-bars.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue