"use client";import { useMemo, useState, type ReactNode } from "react";import { BarChart } from "@/components/ui/BarChart";import { LineChart } from "@/components/ui/LineChart";import { AreaChart } from "@/components/ui/AreaChart";import { PieChart } from "@/components/ui/PieChart";import { DataTable, type DataColumn, type SortState } from "@/components/ui/DataTable";import { PillTabs } from "@/components/ui/PillTabs";import { Select } from "@/components/ui/Select";import type { AxisSpec, ChartPoint, SeriesSpec } from "@/components/ui/ChartFrame";const cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(" ");/* ------------------------------------------------------------------ * * Data * ------------------------------------------------------------------ */export interface SprintContributor { id: string; name: string; authored: number; merged: number; /** Reviews this person left on other people's PRs. */ reviews: number; /** Median hours from opening a PR to its first review. */ reviewHours: number; /** Median hours from opening a PR to merging it. */ mergeHours: number;}/** One bucket of merged PRs: a change type or a size band. */export interface SprintBucket { id: string; label: string; merged: number; /** Size bands only: counts towards the large-PR share. */ large?: boolean;}export interface SprintDay { id: string; /** Short calendar label, e.g. "Mon 14". Rows are in calendar order. */ label: string; opened: number; merged: number;}export interface SprintPrReportProps { /** e.g. "Sprint 38". */ sprint: string; /** e.g. "Sep 14 to 25". */ range: string; contributors: SprintContributor[]; changeTypes: SprintBucket[]; days: SprintDay[]; sizes: SprintBucket[]; /** Plot height in px for the vertical charts. Pies and horizontal bars scale from it. */ chartHeight?: number; className?: string;}type DatasetKey = "people" | "types" | "days" | "sizes";type ChartKind = "column" | "bar" | "stacked" | "line" | "area" | "pie" | "donut" | "burnup";const DATASETS: { key: DatasetKey; label: string }[] = [ { key: "people", label: "Contributors" }, { key: "types", label: "Change types" }, { key: "days", label: "Merged per day" }, { key: "sizes", label: "PR size" },];const MEASURES = ["Authored vs merged", "Reviews vs authored", "Hours to review and merge"] as const;const CHART_LABEL: Record<ChartKind, string> = { column: "Columns", bar: "Horizontal bars", stacked: "Stacked: merged + open", line: "Line", area: "Area", pie: "Pie", donut: "Donut", burnup: "Burn-up (cumulative)",};/** * Every dataset can be drawn every common way. Two are withheld where they would misstate the * data: a stack only where the parts add up to something (merged + still open = authored), and * a pie or donut only where the values are parts of a whole, which rules out summed hours. */function chartKinds(dataset: DatasetKey, measure: number): ChartKind[] { const kinds: ChartKind[] = ["column", "bar"]; if (dataset === "people" && measure === 0) kinds.push("stacked"); kinds.push("line", "area"); if (!(dataset === "people" && measure === 2)) kinds.push("pie", "donut"); if (dataset === "days") kinds.push("burnup"); return kinds;}/** One dataset, reduced to what every chart type needs. */interface ChartModel { label: string; summary?: string; series: SeriesSpec[]; /** In the table's sort order. */ points: ChartPoint[]; /** Calendar order, for time series that must not follow the sort. */ timeline?: ChartPoint[]; stack?: { series: SeriesSpec[]; points: ChartPoint[] }; /** A single quantity split into parts, for pie and donut. */ share?: { slices: { id: string; label: string; value: number }[]; caption: string }; format?: (v: number) => string; unit?: string;}const maxOf = (points: ChartPoint[]) => Math.max(0, ...points.flatMap((p) => Object.values(p.values).map((v) => v ?? 0)));const VIEWS = ["Table", "Graph"];/* ------------------------------------------------------------------ * * Maths * ------------------------------------------------------------------ */function median(values: number[]): number { if (values.length === 0) return 0; const s = [...values].sort((a, b) => a - b); const mid = s.length >> 1; return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;}const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);const pct = (v: number) => `${Math.round(v * 100)}%`;const hours = (v: number) => `${v.toFixed(1)}h`;/** * A value axis for whole counts. The frame picks whichever step lands nearest its tick count, and * on a 0 to 15 axis that is 2.5, which prints as 3, 5, 8, 13. Pinning the domain to a multiple of * a 1-2-5 step and asking for exactly that many ticks makes the step an integer. */function countAxis(max: number, format?: (v: number) => string): AxisSpec { const want = Math.max(1, max) / 5; const mag = 10 ** Math.floor(Math.log10(want)); const step = Math.max(1, [1, 2, 5, 10].map((m) => m * mag).find((v) => v >= want) ?? 10 * mag); const top = Math.max(step, Math.ceil(max / step) * step); return { domain: [0, top], ticks: Math.round(top / step), zero: true, format };}/** Rows whose id is checked, in their original order. */function checked<T extends { id: string }>(rows: T[], ids: string[]): T[] { const on = new Set(ids); return rows.filter((r) => on.has(r.id));}const rateOf = (c: SprintContributor) => (c.authored > 0 ? c.merged / c.authored : 0);/** The same ordering DataTable applies, so the chart reads rows in the order the table shows them. */function applySort<T>(rows: T[], columns: DataColumn<T>[], sort: SortState): T[] { if (!sort) return rows; const col = columns.find((c) => c.key === sort.key); if (!col) return rows; const acc = col.sortAccessor ?? ((r: T) => (r as Record<string, unknown>)[sort.key] as string | number); return [...rows].sort((a, b) => { const av = acc(a); const bv = acc(b); const r = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv)); return sort.dir === "asc" ? r : -r; });}/* ------------------------------------------------------------------ * * Columns * ------------------------------------------------------------------ */const PEOPLE_COLUMNS: DataColumn<SprintContributor>[] = [ { key: "name", header: "Name", sortable: true }, { key: "authored", header: "Authored", align: "right", sortable: true, width: 104 }, { key: "merged", header: "Merged", align: "right", sortable: true, width: 104, bar: (r) => r.merged }, { key: "rate", header: "Merge rate", align: "right", sortable: true, width: 116, sortAccessor: rateOf, render: (r) => pct(rateOf(r)), }, { key: "open", header: "Open", align: "right", sortable: true, width: 88, sortAccessor: (r) => r.authored - r.merged, render: (r) => String(r.authored - r.merged), }, { key: "reviews", header: "Reviews", align: "right", sortable: true, width: 100 }, { key: "reviewHours", header: "1st review", align: "right", sortable: true, width: 112, render: (r) => hours(r.reviewHours), }, { key: "mergeHours", header: "To merge", align: "right", sortable: true, width: 104, render: (r) => hours(r.mergeHours), },];/** Share is of the rows still checked, so it rebalances when one is dropped. */function bucketColumns(head: string, total: number): DataColumn<SprintBucket>[] { return [ { key: "label", header: head, sortable: true }, { key: "merged", header: "Merged", align: "right", sortable: true, width: 120, bar: (r) => r.merged }, { key: "share", header: "Share", align: "right", sortable: true, width: 104, sortAccessor: (r) => r.merged, render: (r) => (total > 0 ? pct(r.merged / total) : "0%"), }, ];}function dayColumns(days: SprintDay[]): DataColumn<SprintDay>[] { const running = new Map<string, number>(); let acc = 0; for (const d of days) { acc += d.merged; running.set(d.id, acc); } return [ { key: "label", header: "Day", sortable: true, sortAccessor: (r) => days.indexOf(r) }, { key: "opened", header: "Opened", align: "right", sortable: true, width: 104 }, { key: "merged", header: "Merged", align: "right", sortable: true, width: 120, bar: (r) => r.merged }, { key: "running", header: "Running total", align: "right", width: 132, render: (r) => String(running.get(r.id) ?? 0), }, ];}/* ------------------------------------------------------------------ * * Component * ------------------------------------------------------------------ */export function SprintPrReport({ sprint, range, contributors, changeTypes, days, sizes, chartHeight = 280, className,}: SprintPrReportProps) { const [datasetIndex, setDatasetIndex] = useState(0); const [view, setView] = useState(0); const [measure, setMeasure] = useState(0); const [chartBy, setChartBy] = useState<Record<DatasetKey, ChartKind>>({ people: "column", types: "donut", days: "burnup", sizes: "column", }); const [included, setIncluded] = useState<Record<DatasetKey, string[]>>(() => ({ people: contributors.map((r) => r.id), types: changeTypes.map((r) => r.id), days: days.map((r) => r.id), sizes: sizes.map((r) => r.id), })); const [sort, setSort] = useState<SortState>(null); const dataset = DATASETS[datasetIndex].key; const kinds = chartKinds(dataset, measure); const chart = kinds.includes(chartBy[dataset]) ? chartBy[dataset] : kinds[0]; const people = useMemo(() => checked(contributors, included.people), [contributors, included.people]); const types = useMemo(() => checked(changeTypes, included.types), [changeTypes, included.types]); const dayRows = useMemo(() => checked(days, included.days), [days, included.days]); const sizeRows = useMemo(() => checked(sizes, included.sizes), [sizes, included.sizes]); const typeColumns = useMemo(() => bucketColumns("Type", sum(types.map((r) => r.merged))), [types]); const sizeColumns = useMemo(() => bucketColumns("Size", sum(sizeRows.map((r) => r.merged))), [sizeRows]); const dayCols = useMemo(() => dayColumns(days), [days]); const summary = useMemo(() => { const authored = sum(people.map((c) => c.authored)); const merged = sum(people.map((c) => c.merged)); const busiest = dayRows.reduce<SprintDay | null>((best, d) => (!best || d.merged > best.merged ? d : best), null); return { authored, merged, open: authored - merged, rate: authored > 0 ? merged / authored : 0, reviewHours: median(people.map((c) => c.reviewHours)), mergeHours: median(people.map((c) => c.mergeHours)), busiest, }; }, [people, dayRows]); const notes = useMemo( () => buildNotes(people, dayRows, sizeRows, summary.reviewHours), [people, dayRows, sizeRows, summary.reviewHours], ); const changeDataset = (i: number) => { setDatasetIndex(i); setSort(null); }; const setIds = (key: DatasetKey) => (ids: string[]) => setIncluded((prev) => ({ ...prev, [key]: ids })); /* ---- table ---- */ const tableProps = { selectable: true, density: "compact" as const, onSortChange: setSort, emptyLabel: "No rows", }; const table = dataset === "people" ? ( <DataTable key="people" {...tableProps} data={contributors} columns={PEOPLE_COLUMNS} getRowId={(r) => r.id} selectedIds={included.people} onSelectedIdsChange={setIds("people")} minWidth={840} /> ) : dataset === "types" ? ( <DataTable key="types" {...tableProps} data={changeTypes} columns={typeColumns} getRowId={(r) => r.id} selectedIds={included.types} onSelectedIdsChange={setIds("types")} minWidth={420} /> ) : dataset === "days" ? ( <DataTable key="days" {...tableProps} data={days} columns={dayCols} getRowId={(r) => r.id} selectedIds={included.days} onSelectedIdsChange={setIds("days")} minWidth={520} /> ) : ( <DataTable key="sizes" {...tableProps} data={sizes} columns={sizeColumns} getRowId={(r) => r.id} selectedIds={included.sizes} onSelectedIdsChange={setIds("sizes")} minWidth={420} /> ); /* ---- graph ---- */ const model = ((): ChartModel => { if (dataset === "people") { const rows = applySort(people, PEOPLE_COLUMNS, sort); const pair = ( a: [string, string, (c: SprintContributor) => number], b: [string, string, (c: SprintContributor) => number], ) => ({ series: [ { id: a[0], label: a[1] }, { id: b[0], label: b[1] }, ], points: rows.map((c) => ({ x: c.name, values: { [a[0]]: a[2](c), [b[0]]: b[2](c) } })), }); const base = { label: `${MEASURES[measure]} per contributor`, summary: `${people.length} contributors, ${summary.merged} of ${summary.authored} PRs merged.`, }; if (measure === 2) { return { ...base, ...pair(["review", "Hours to first review", (c) => c.reviewHours], ["merge", "Hours to merge", (c) => c.mergeHours]), format: hours, unit: "h", }; } const slices = (value: (c: SprintContributor) => number) => rows.map((c) => ({ id: c.id, label: c.name, value: value(c) })); if (measure === 1) { return { ...base, ...pair(["reviews", "Reviews given", (c) => c.reviews], ["authored", "PRs authored", (c) => c.authored]), share: { slices: slices((c) => c.reviews), caption: "reviews" }, }; } return { ...base, ...pair(["authored", "Authored", (c) => c.authored], ["merged", "Merged", (c) => c.merged]), stack: pair(["merged", "Merged", (c) => c.merged], ["open", "Still open", (c) => c.authored - c.merged]), share: { slices: slices((c) => c.merged), caption: "merged" }, }; } if (dataset === "days") { const toPoint = (d: SprintDay): ChartPoint => ({ x: d.label, values: { merged: d.merged, opened: d.opened } }); return { label: "PRs opened and merged per day", series: [ { id: "merged", label: "Merged" }, { id: "opened", label: "Opened" }, ], points: applySort(dayRows, dayCols, sort).map(toPoint), timeline: dayRows.map(toPoint), share: { slices: dayRows.map((d) => ({ id: d.id, label: d.label, value: d.merged })), caption: "merged" }, }; } const isTypes = dataset === "types"; const rows = applySort(isTypes ? types : sizeRows, isTypes ? typeColumns : sizeColumns, sort); return { label: `Merged PRs by ${isTypes ? "change type" : "PR size"}`, series: [{ id: "merged", label: "Merged" }], points: rows.map((r) => ({ x: r.label, values: { merged: r.merged } })), share: { slices: rows.map((r) => ({ id: r.id, label: r.label, value: r.merged })), caption: "merged" }, }; })(); // Line, area and burn-up read left to right as time, so on the day view they keep calendar order. const followsCalendar = dataset === "days" && (chart === "line" || chart === "area" || chart === "burnup"); const graph = (() => { const rowsIn = { people, types, days: dayRows, sizes: sizeRows }[dataset]; if (rowsIn.length === 0) { return ( <div className="flex h-72 items-center justify-center rounded-xl border border-dashed border-stone-300 px-4 text-center text-sm text-stone-500 dark:border-stone-700 dark:text-stone-400"> Every row is unchecked. Check a row in the table to chart it. </div> ); } const height = chartHeight; const roomy = chartHeight > 360; const key = `${dataset}-${measure}-${chart}`; const single = model.series.length === 1; const tick = model.unit ? (v: number) => `${v}${model.unit}` : undefined; if ((chart === "pie" || chart === "donut") && model.share) { const total = sum(model.share.slices.map((d) => d.value)); return ( <div className="flex justify-center"> <PieChart key={key} label={model.label} variant={chart} data={model.share.slices} centre={chart === "donut" ? { value: String(total), caption: model.share.caption } : undefined} size={Math.round(chartHeight * 0.93)} /> </div> ); } if (chart === "burnup" && model.timeline) { const data: ChartPoint[] = []; let o = 0; let m = 0; for (const p of model.timeline) { o += p.values.opened ?? 0; m += p.values.merged ?? 0; data.push({ x: p.x, values: { opened: o, merged: m } }); } return ( <AreaChart key={key} label="Cumulative PRs opened and merged" summary={`By the last day ${o} PRs were opened and ${m} merged, leaving ${o - m} open.`} data={data} series={[ { id: "opened", label: "Opened, running total" }, { id: "merged", label: "Merged, running total" }, ]} offset="none" y={countAxis(o)} height={height} /> ); } const points = followsCalendar && model.timeline ? model.timeline : model.points; if (chart === "line") { return ( <LineChart key={key} label={model.label} summary={model.summary} data={points} series={model.series} dots legend={!single} formatValue={model.format} y={countAxis(maxOf(points), tick)} height={height} /> ); } if (chart === "area") { return ( <AreaChart key={key} label={model.label} summary={model.summary} data={points} series={model.series} offset="none" legend={!single} formatValue={model.format} y={countAxis(maxOf(points), tick)} height={height} /> ); } const stacked = chart === "stacked" && model.stack; const bars = stacked ? model.stack! : { series: model.series, points }; const top = stacked ? Math.max(0, ...bars.points.map((p) => sum(Object.values(p.values).map((v) => v ?? 0)))) : maxOf(bars.points); const axis = countAxis(top, tick); const horizontal = chart === "bar"; return ( <BarChart key={key} label={model.label} summary={model.summary} data={bars.points} series={bars.series} layout={stacked ? "stacked" : "grouped"} orientation={horizontal ? "horizontal" : "vertical"} legend={!single} formatValue={model.format} {...(horizontal ? { x: axis } : { y: axis })} height={horizontal ? Math.max(chartHeight - 60, bars.points.length * (roomy ? 56 : 44) + 56) : height} /> ); })(); return ( <section aria-label={`${sprint} pull request report`} className={cx( "w-full rounded-2xl border border-stone-200 bg-white p-4 sm:p-6 dark:border-stone-800 dark:bg-stone-900", className, )} > <header className="flex flex-wrap items-end justify-between gap-4"> <div> <p className="text-[11px] font-medium uppercase tracking-[0.08em] text-stone-500 dark:text-stone-500"> {sprint} · {range} </p> <h3 className="mt-1 text-lg font-semibold text-stone-900 dark:text-stone-100">Pull request report</h3> </div> <PillTabs tabs={VIEWS} active={view} onChange={setView} showPanels={false} label="View as" /> </header> <dl className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3"> <Tile term="Merged" value={String(summary.merged)} detail={`of ${summary.authored} authored`} /> <Tile term="Merge rate" value={pct(summary.rate)} detail="of PRs authored" /> <Tile term="Still open" value={String(summary.open)} detail="carry into next sprint" /> <Tile term="First review" value={hours(summary.reviewHours)} detail="median wait" /> <Tile term="Time to merge" value={hours(summary.mergeHours)} detail="median, from open" /> <Tile term="Busiest day" value={summary.busiest ? summary.busiest.label : "None"} detail={summary.busiest ? `${summary.busiest.merged} merged` : "no days checked"} /> </dl> <div className="mt-6 flex flex-wrap items-center gap-2"> <Select size="sm" label="Dataset" options={DATASETS.map((d) => d.label)} value={datasetIndex} onChange={changeDataset} /> {dataset === "people" && ( <Select size="sm" label="Measure" options={[...MEASURES]} value={measure} onChange={setMeasure} /> )} {view === 1 && ( <Select size="sm" label="Chart type" options={kinds.map((k) => CHART_LABEL[k])} value={kinds.indexOf(chart)} onChange={(i) => setChartBy((prev) => ({ ...prev, [dataset]: kinds[i] }))} /> )} </div> <div role="tabpanel" aria-label={VIEWS[view]} className="mt-3"> {/* The table stays mounted under the graph so its sort survives a trip to the other tab. */} <div hidden={view !== 0}>{table}</div> {view === 1 && graph} <p className="mt-2 text-xs text-stone-500 dark:text-stone-400"> {view === 0 ? "Uncheck a row to drop it from the graph and the summary. Sort a column to reorder the bars." : followsCalendar ? "Charting the days checked in the table, in calendar order." : "Charting the rows checked in the table, in the order it is sorted."} </p> </div> {notes.length > 0 && ( <div className="mt-6 border-t border-stone-200 pt-4 dark:border-stone-800"> <p className="text-[11px] font-medium uppercase tracking-[0.08em] text-stone-500 dark:text-stone-500"> Worth a look </p> <ul className="mt-2 flex flex-col gap-1"> {notes.map((n) => ( <li key={n.id} className="flex gap-2 text-sm text-stone-600 dark:text-stone-400"> <span aria-hidden className="mt-2 h-1 w-1 shrink-0 rounded-full bg-stone-400 dark:bg-stone-600" /> <span>{n.text}</span> </li> ))} </ul> </div> )} </section> );}function Tile({ term, value, detail }: { term: string; value: ReactNode; detail: string }) { return ( <div className="rounded-xl border border-stone-200 p-3 dark:border-stone-800"> <dt className="text-xs text-stone-500 dark:text-stone-500">{term}</dt> <dd className="mt-1 text-lg font-semibold tabular-nums text-stone-900 dark:text-stone-100">{value}</dd> <dd className="text-xs text-stone-500 dark:text-stone-500">{detail}</dd> </div> );}/** Up to three observations, each recomputed from the rows still checked. */function buildNotes( people: SprintContributor[], days: SprintDay[], sizes: SprintBucket[], medianReview: number,): { id: string; text: string }[] { const out: { id: string; text: string }[] = []; const reviewer = people.reduce<SprintContributor | null>( (best, c) => (!best || c.reviews / Math.max(1, c.authored) > best.reviews / Math.max(1, best.authored) ? c : best), null, ); if (reviewer && people.length > 1 && reviewer.reviews > reviewer.authored * 1.5) { out.push({ id: "reviewer", text: `${reviewer.name} gave ${reviewer.reviews} reviews and authored ${reviewer.authored} PRs, the heaviest review load on the team.`, }); } const waiting = people.reduce<SprintContributor | null>( (worst, c) => (!worst || c.reviewHours > worst.reviewHours ? c : worst), null, ); if (waiting && people.length > 1 && medianReview > 0 && waiting.reviewHours >= medianReview * 1.5) { out.push({ id: "waiting", text: `PRs from ${waiting.name} waited ${hours(waiting.reviewHours)} for a first review, ${(waiting.reviewHours / medianReview).toFixed(1)}x the team median.`, }); } const merged = sum(days.map((d) => d.merged)); const lastTwo = sum(days.slice(-2).map((d) => d.merged)); if (days.length > 4 && merged > 0 && lastTwo / merged > 0.25) { out.push({ id: "pileup", text: `${pct(lastTwo / merged)} of merges landed in the last two days, which usually means review queued up mid-sprint.`, }); } const sized = sum(sizes.map((s) => s.merged)); const large = sum(sizes.filter((s) => s.large).map((s) => s.merged)); if (out.length < 3 && sized > 0 && large / sized > 0.2) { out.push({ id: "large", text: `Large PRs made up ${pct(large / sized)} of merges. Splitting them tends to cut the wait for review.`, }); } return out.slice(0, 3);}