Sprint PR Report

Free

A sprint's PR stats as a table or a chart, redrawn from the rows you check.

Sprint 38 · Sep 14 to 25

Pull request report

Merged
51
of 63 authored
Merge rate
81%
of PRs authored
Still open
12
carry into next sprint
First review
3.8h
median wait
Time to merge
20.0h
median, from open
Busiest day
Fri 25
12 merged
Aditi Rao
14
12
86%
2
9
3.5h
18.0h
Marcus Bell
11
8
73%
3
17
2.1h
26.0h
Yuki Tanaka
9
9
100%
0
6
5.8h
14.0h
Omar Haddad
12
9
75%
3
4
7.2h
31.0h
Lena Fischer
7
6
86%
1
21
1.4h
12.0h
Tomás Ruiz
10
7
70%
3
11
4.0h
22.0h

Uncheck a row to drop it from the graph and the summary. Sort a column to reorder the bars.

Worth a look

  • Lena Fischer gave 21 reviews and authored 7 PRs, the heaviest review load on the team.
  • PRs from Omar Haddad waited 7.2h for a first review, 1.9x the team median.
  • 31% of merges landed in the last two days, which usually means review queued up mid-sprint.

npx shadcn@latest add @ui-shrushank/sprint-pr-report

The install writes a single file to components/ui/SprintPrReport.tsx, which exports SprintPrReport.

tsx
import { SprintPrReport } from "@/components/ui/SprintPrReport";
tsx
<SprintPrReport
  sprint="Sprint"
  range="Range"
  contributors={contributors}
  changeTypes={changeTypes}
  days={days}
  sizes={sizes}
/>

The retro question is rarely "how many PRs", it is "who carried the review load, where did PRs wait, and did everything land on the last day". One block answers all of that from four small datasets (per person, per change type, per day, per size band) without a BI tool.

Built entirely from library parts: Data Table for the table, Bar, Line, Area and Pie Chart for the graph, Pill Tabs for the Table and Graph switch, and Select for the dataset, measure and chart-type pickers. The table is the control surface for the graph. A row's checkbox decides whether it is charted and counted in the summary, and the column the table is sorted by sets the bar order, through a new onSortChange on Data Table that the component replays with the same comparator. Values are read-only on purpose, so a number in the graph always traces back to a row someone can check.

One caption under the panel says what the table does to the graph ("Uncheck a row to drop it from the graph and the summary"), so the link is discoverable without a tour. Unchecking every row shows a dashed empty state that says how to get the chart back rather than an empty axis. Pill Tabs gained a label prop and no longer points aria-controls at panels it does not render when showPanels is off.

Every line below was read out of the file you are about to copy, so you can check each one against the source at the bottom of this page.

  • Uses the native role="tabpanel" semantics rather than a styled div, so assistive technology announces what the control is and how it behaves.
  • Carries aria-label, so its state is exposed and not left to the visual treatment alone.

6 required, 2 optional.

sprintrequired
string

e.g. "Sprint 38".

rangerequired
string

e.g. "Sep 14 to 25".

contributorsrequired
SprintContributor[]
changeTypesrequired
SprintBucket[]
daysrequired
SprintDay[]
sizesrequired
SprintBucket[]
chartHeight
number · default 280

Plot height in px for the vertical charts. Pies and horizontal bars scale from it.

className
string

Every dataset can be drawn every common way, with two exceptions.
Columns, horizontal bars, line, area, pie and donut are offered everywhere, because the first build limited each dataset to the two or three charts that suited it best and that read as a broken picker. What stays withheld is what would misstate the data. A stack is offered only on contributors' authored and merged, drawn as merged plus still open because those two add up to authored; authored plus merged would count every merged PR twice. Pie and donut need values that are parts of a whole, so hours are left out (adding six people's median waits gives a number that means nothing), and on contributors the slices are merged PRs or reviews given, one quantity, never both series at once. Days also get a burn-up of opened against merged, where the gap between the two areas is the open work.
Time series ignore the sort.
On the day view, a line, area or burn-up drawn in "most merged first" order is a different chart that happens to look like a line, so those two keep calendar order and only the column view follows the table.
Full screen is the library's, not the component's.
The block opts into the same expand button and overlay the templates use (expandable: true), so it opens with the same title bar, Esc hint and Close as every other full-screen preview. The component itself only takes a chartHeight prop (280 by default; pies and horizontal bars scale from it), which the demo raises to 460 inside the overlay. An earlier build called the browser's Fullscreen API from a button in the block's own header; it worked, but it was a second, different full screen next to the one the library already had, so it was removed.
The summary is derived, never typed.
Merged, merge rate, still open, median first review, median time to merge and busiest day are recomputed from the checked rows. Share columns rebalance the same way, so unchecking Docs moves every other type's percentage.
Notes are rules, not copy.
Up to three observations under the chart (heaviest review load by reviews per authored PR, a first-review wait at 1.5x the median or more, more than a quarter of merges in the last two days, large PRs above a fifth of merges) are computed from the same checked rows and only appear when their threshold is met.
The table stays mounted behind the graph
(hidden, not unmounted), because Data Table owns its sort and unmounting it would reset the table while the graph kept the old order.

One file, no runtime package. Copy it, or install it with the command at the top of this page.

SprintPrReport.tsx
"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);}