@d3lm/pr-stats 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +9 -1
  2. package/dist/tui-app.mjs +573 -166
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -25,7 +25,15 @@ You can also run it without installing through `npx @d3lm/pr-stats`. The bunx eq
25
25
  pr-stats
26
26
  ```
27
27
 
28
- Without flags, it looks at PRs from the last 90 days across all repositories you can access. It opens on a tab with two lists, the open PRs awaiting your review with how long each has been waiting, and below them the open PRs you already reviewed or commented on with how long ago that was, so a PR stays visible until it merges or closes. A fresh review request moves a PR from the reviewing list back into the awaiting one. The Your PRs tab next to it has two sub-tabs, which the `t` key switches. The first lists your own authored PRs that are still open with their age and size. The second charts how your authored PRs got created, merged, and closed, telling a merge apart from a close without one, with time-to-merge percentiles, a histogram and trend, a merge-time heatmap, weekly created and merged volumes, an outcome gauge, and the most recently merged and closed PRs. The remaining tabs hold the time-to-review report, the PR size report, and the comments report with a histogram of comments per PR, a scatter of comments against PR size, and the most commented PRs. When the data spans multiple repos, every tab opens on a repo picker that drills into one repo or the aggregate across all of them, and on the two queue lists the `g` key groups the aggregate list by repo.
28
+ Without flags, it looks at PRs from the last 90 days across all repositories you can access. The tabs hold these views.
29
+
30
+ - The queue tab, which it opens on, shows two lists. The open PRs awaiting your review come first with how long each has been waiting, and below them sit the open PRs you already reviewed or commented on with how long ago that was, so a PR stays visible until it merges or closes. A fresh review request moves a PR from the reviewing list back into the awaiting one.
31
+ - The Your PRs tab has two sub-tabs, which the `t` key switches. The first lists your own authored PRs that are still open with their age and size. The second reports how your authored PRs got created, merged, and closed, telling a merge apart from a close without one. It charts time-to-merge percentiles, a histogram and trend, a merge-time heatmap, and a scatter of merge time against PR size. It also plots a merge-rate trend over the concluded PRs, cumulative created and merged lines whose gap shows the backlog, weekly created and merged volumes, an outcome gauge, and the most recently merged and closed PRs. A reviewer leaderboard ranks who reviews your PRs by distinct PRs reviewed, and a review-coverage gauge counts the merged PRs that never received a review. Your own replies to review threads never count as a review for either of them.
32
+ - The time-to-review report pairs its histogram, trend, heatmap, and weekly volume with the completed review cycles per PR and a verdict gauge splitting approvals from change requests. It also shows the age of the requests still waiting on you, how old PRs already were when the request reached you, and an off-hours gauge that splits weekdays into work hours and after hours once `--work-hours` is set. On the aggregate view it additionally compares median review times by repo.
33
+ - The PR size report carries the same histogram, trend, heatmap, and weekly volume for PR sizes and adds a net-lines trend that sums additions minus deletions per week.
34
+ - The comments report holds a histogram of comments per PR, a scatter of comments against PR size, and the most commented PRs.
35
+
36
+ When the data spans multiple repos, every tab opens on a repo picker that drills into one repo or the aggregate across all of them, and on the two queue lists the `g` key groups the aggregate list by repo. Comparison cards like the reviewer leaderboard cap themselves at eight rows and fold the rest into an overflow line, and the `x` key lifts the cap on the open stats tab and restores it. The footer names the key whenever the tab has a capped card.
29
37
 
30
38
  Node gates the FFI that OpenTUI renders through behind the `--experimental-ffi` flag, and the launcher re-executes itself with that flag when it is missing, so a plain `pr-stats` works without extra flags on both runtimes.
31
39
 
package/dist/tui-app.mjs CHANGED
@@ -146,7 +146,7 @@ import { join as join2 } from "node:path";
146
146
  import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
147
147
  import { homedir } from "node:os";
148
148
  import { dirname, join } from "node:path";
149
- var VERSION = 2;
149
+ var VERSION = 3;
150
150
  var enabled = false;
151
151
  function configureCache(on) {
152
152
  enabled = on;
@@ -621,10 +621,12 @@ function hintsFor(modal, editing, tab, authoredTab, views, copyLinks2) {
621
621
  if (scope?.view === "list") {
622
622
  return `\u2191/\u2193 select \xB7 enter open \xB7 ${toggle}\u2190/\u2192 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
623
623
  }
624
+ const view = views === null ? null : tab === 1 ? views.merged : tab === 2 ? views.review : tab === 3 ? views.size : views.comments;
625
+ const expand = view?.expandable ? view.expanded ? "x collapse \xB7 " : "x expand \xB7 " : "";
624
626
  if (scope !== null && repos.length > 0) {
625
- return `${toggle}esc back \xB7 j/k scroll \xB7 1-5 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
627
+ return `${toggle}${expand}esc back \xB7 j/k scroll \xB7 1-5 tabs \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
626
628
  }
627
- return `${toggle}1-5 tabs \xB7 j/k scroll \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
629
+ return `${toggle}${expand}1-5 tabs \xB7 j/k scroll \xB7 o options \xB7 s settings \xB7 r reload \xB7 R refetch \xB7 q quit`;
628
630
  }
629
631
 
630
632
  // src/tui/components/Spinner.tsx
@@ -726,7 +728,19 @@ function wallParts(instantMs) {
726
728
  function zonedStamp(date) {
727
729
  const parts = wallParts(date.getTime());
728
730
  const dayUtcMs = Date.UTC(parts.year, parts.month - 1, parts.day);
729
- return { dayUtcMs, weekday: new Date(dayUtcMs).getUTCDay(), hour: parts.hour };
731
+ return { dayUtcMs, weekday: new Date(dayUtcMs).getUTCDay(), hour: parts.hour, minute: parts.minute };
732
+ }
733
+ function hasWorkWindows() {
734
+ const covered = timeMode.workWindows.reduce((sum, window) => sum + (window.endMin - window.startMin), 0);
735
+ return covered < 24 * 60;
736
+ }
737
+ function classifyInstant(date) {
738
+ const { weekday, hour, minute } = zonedStamp(date);
739
+ if (weekday === 0 || weekday === 6) {
740
+ return "weekend";
741
+ }
742
+ const minuteOfDay = hour * 60 + minute;
743
+ return timeMode.workWindows.some((window) => minuteOfDay >= window.startMin && minuteOfDay < window.endMin) ? "work" : "after";
730
744
  }
731
745
  function utcFromWall(wallTargetMs) {
732
746
  let guess = wallTargetMs;
@@ -780,7 +794,8 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
780
794
  pr: result.pr,
781
795
  requestedAt: result.requestedAt,
782
796
  reviewedAt: result.reviewedAt,
783
- hours: durationHours(result.requestedAt, result.reviewedAt)
797
+ hours: durationHours(result.requestedAt, result.reviewedAt),
798
+ verdict: result.verdict
784
799
  });
785
800
  } else if (result.kind === "pending" && result.pr.state === "open") {
786
801
  pending.push({ pr: result.pr, requestedAt: result.requestedAt, hours: durationHours(result.requestedAt, now) });
@@ -816,7 +831,22 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
816
831
  }
817
832
  const byRepo = [...hoursByRepo.entries()].toSorted((a, b) => b[1].length - a[1].length);
818
833
  const misses = targetHours === void 0 ? [] : reviewed.filter((result) => result.hours > targetHours).toSorted((a, b) => b.hours - a.hours);
819
- return { reviewed, pending, reviewing, expired, unrequested, allHours, byRepo, misses };
834
+ const cyclesByPr = /* @__PURE__ */ new Map();
835
+ for (const result of reviewed) {
836
+ const key = `${result.pr.repo}#${result.pr.number}`;
837
+ cyclesByPr.set(key, (cyclesByPr.get(key) ?? 0) + 1);
838
+ }
839
+ return {
840
+ reviewed,
841
+ pending,
842
+ reviewing,
843
+ expired,
844
+ unrequested,
845
+ allHours,
846
+ byRepo,
847
+ misses,
848
+ cycles: [...cyclesByPr.values()]
849
+ };
820
850
  }
821
851
  function computeSizeStats(sizes, { sizeTarget } = {}) {
822
852
  const metrics = [
@@ -855,6 +885,37 @@ function computeMergeStats(sizes) {
855
885
  closed.sort((a, b) => b.closedAt.getTime() - a.closedAt.getTime());
856
886
  return { merged, closed, open, allHours: merged.map((result) => result.hours) };
857
887
  }
888
+ function computeReviewerStats(sizes, author) {
889
+ const byLogin = /* @__PURE__ */ new Map();
890
+ let mergedReviewed = 0;
891
+ let mergedUnreviewed = 0;
892
+ for (const entry of sizes) {
893
+ const others = entry.reviewers.filter((login) => login !== author);
894
+ for (const login of others) {
895
+ const counts = byLogin.get(login) ?? { prs: 0, reviews: 0 };
896
+ counts.reviews += 1;
897
+ byLogin.set(login, counts);
898
+ }
899
+ const distinct = new Set(others);
900
+ for (const login of distinct) {
901
+ const counts = byLogin.get(login);
902
+ if (counts !== void 0) {
903
+ counts.prs += 1;
904
+ }
905
+ }
906
+ if (entry.mergedAt !== null) {
907
+ if (others.length > 0) {
908
+ mergedReviewed += 1;
909
+ } else {
910
+ mergedUnreviewed += 1;
911
+ }
912
+ }
913
+ }
914
+ const leaderboard = [...byLogin.entries()].map(([login, counts]) => {
915
+ return { login, ...counts };
916
+ }).toSorted((a, b) => b.prs - a.prs || b.reviews - a.reviews || a.login.localeCompare(b.login));
917
+ return { leaderboard, mergedReviewed, mergedUnreviewed };
918
+ }
858
919
  function computeCommentStats(sizes) {
859
920
  const metrics = [
860
921
  { label: "discussion comments", values: sizes.map((size) => size.comments.discussion) },
@@ -918,6 +979,13 @@ var FILE_BUCKETS = [
918
979
  { label: "21-50", max: 51 },
919
980
  { label: "> 50", max: Infinity }
920
981
  ];
982
+ var CYCLE_BUCKETS = [
983
+ { label: "1", max: 2 },
984
+ { label: "2", max: 3 },
985
+ { label: "3", max: 4 },
986
+ { label: "4-5", max: 6 },
987
+ { label: "> 5", max: Infinity }
988
+ ];
921
989
  var COMMENT_BUCKETS = [
922
990
  { label: "0", max: 1 },
923
991
  { label: "1-2", max: 3 },
@@ -1051,9 +1119,24 @@ function useScrollbarSettle(scrollRef, mounted = true) {
1051
1119
  renderer2.off(CliRenderEvents.FRAME, release);
1052
1120
  scrollRef.current?.verticalScrollBar.resetVisibilityControl();
1053
1121
  };
1122
+ const resyncThumb = () => {
1123
+ const bar = scrollRef.current?.verticalScrollBar;
1124
+ if (!bar) {
1125
+ return;
1126
+ }
1127
+ const height = bar.viewportSize;
1128
+ const wanted = Math.max(1, height);
1129
+ if (bar.scrollSize < wanted || bar.slider.viewPortSize === wanted) {
1130
+ return;
1131
+ }
1132
+ bar.viewportSize = 0;
1133
+ bar.viewportSize = height;
1134
+ };
1054
1135
  renderer2.on(CliRenderEvents.FRAME, release);
1136
+ renderer2.on(CliRenderEvents.FRAME, resyncThumb);
1055
1137
  return () => {
1056
1138
  renderer2.off(CliRenderEvents.FRAME, release);
1139
+ renderer2.off(CliRenderEvents.FRAME, resyncThumb);
1057
1140
  };
1058
1141
  }, [scrollRef, renderer2, mounted]);
1059
1142
  }
@@ -1094,18 +1177,18 @@ function ChartsPanel({
1094
1177
  if (view.empty !== null) {
1095
1178
  return /* @__PURE__ */ jsx4("box", { flexGrow: 1, alignItems: "center", justifyContent: "center", children: /* @__PURE__ */ jsx4("text", { fg: theme.muted, children: view.empty }) });
1096
1179
  }
1097
- const leftWidth = Math.max(0, ...view.left.map(cardWidth));
1098
- const rightWidth = Math.max(0, ...view.right.map(cardWidth));
1099
- const twoColumns = view.left.length > 0 && view.right.length > 0 && width - 4 >= leftWidth + COLUMN_GAP + rightWidth;
1100
- const rows = Array.from({ length: Math.max(view.left.length, view.right.length) }, (_, i) => {
1101
- const left = view.left.at(i);
1102
- const right = view.right.at(i);
1103
- return { key: left?.title ?? right?.title ?? "", left, right };
1180
+ const left = view.cards.filter((_, i) => i % 2 === 0);
1181
+ const right = view.cards.filter((_, i) => i % 2 === 1);
1182
+ const leftWidth = Math.max(0, ...left.map((card) => cardWidth(card)));
1183
+ const rightWidth = Math.max(0, ...right.map((card) => cardWidth(card)));
1184
+ const twoColumns = right.length > 0 && width - 4 >= leftWidth + COLUMN_GAP + rightWidth;
1185
+ const rows = left.map((card, i) => {
1186
+ return { key: card.title, left: card, right: right.at(i) };
1104
1187
  });
1105
1188
  const cards = twoColumns ? /* @__PURE__ */ jsx4("box", { flexDirection: "column", rowGap: 1, children: rows.map((row) => /* @__PURE__ */ jsxs3("box", { flexDirection: "row", alignItems: "flex-start", columnGap: COLUMN_GAP, children: [
1106
- /* @__PURE__ */ jsx4("box", { flexDirection: "column", width: leftWidth, flexShrink: 0, children: row.left !== void 0 && /* @__PURE__ */ jsx4(ChartCard, { card: row.left }) }),
1189
+ /* @__PURE__ */ jsx4("box", { flexDirection: "column", width: leftWidth, flexShrink: 0, children: /* @__PURE__ */ jsx4(ChartCard, { card: row.left }) }),
1107
1190
  /* @__PURE__ */ jsx4("box", { flexDirection: "column", width: rightWidth, flexShrink: 0, children: row.right !== void 0 && /* @__PURE__ */ jsx4(ChartCard, { card: row.right }) })
1108
- ] }, row.key)) }) : /* @__PURE__ */ jsx4("box", { flexDirection: "column", rowGap: 1, children: [...view.left, ...view.right].map((card) => /* @__PURE__ */ jsx4(ChartCard, { card }, card.title)) });
1191
+ ] }, row.key)) }) : /* @__PURE__ */ jsx4("box", { flexDirection: "column", rowGap: 1, children: view.cards.map((card) => /* @__PURE__ */ jsx4(ChartCard, { card }, card.title)) });
1109
1192
  return /* @__PURE__ */ jsxs3("box", { flexGrow: 1, flexDirection: "column", children: [
1110
1193
  /* @__PURE__ */ jsx4("box", { height: 1, children: /* @__PURE__ */ jsx4("text", { wrapMode: "none", fg: theme.border, children: "\u2500".repeat(width) }) }),
1111
1194
  /* @__PURE__ */ jsx4("box", { height: 1, paddingLeft: 1, paddingRight: 2, flexDirection: "row", justifyContent: "space-between", children: keyed(view.strip, lineKey).map(({ key, item }) => /* @__PURE__ */ jsx4(ChartLine, { line: item }, key)) }),
@@ -1146,7 +1229,7 @@ function ChartsPanel({
1146
1229
  ] }),
1147
1230
  /* @__PURE__ */ jsx4("box", { flexDirection: "column", marginTop: 1, children: keyed(view.distribution.lines, lineKey).map(({ key, item }) => /* @__PURE__ */ jsx4(ChartLine, { line: item }, key)) })
1148
1231
  ] }),
1149
- view.left.length === 0 ? /* @__PURE__ */ jsx4("text", { wrapMode: "none", fg: theme.muted, marginTop: 1, children: view.noCharts }) : cards
1232
+ view.cards.length === 0 ? /* @__PURE__ */ jsx4("text", { wrapMode: "none", fg: theme.muted, marginTop: 1, children: view.noCharts }) : cards
1150
1233
  ]
1151
1234
  }
1152
1235
  )
@@ -1393,7 +1476,8 @@ var initialBrowseState = {
1393
1476
  },
1394
1477
  repoCursors: { pending: 0, open: 0, review: 0, size: 0, comment: 0, merged: 0 },
1395
1478
  rowCursors: { pending: 0, open: 0 },
1396
- grouped: { pending: false, open: false }
1479
+ grouped: { pending: false, open: false },
1480
+ expanded: { review: false, size: false, comment: false, merged: false }
1397
1481
  };
1398
1482
  function dropVanishedRepo(scope, repos) {
1399
1483
  if (scope.view === "detail" && scope.repo !== null && !repos.some((option) => option.repo === scope.repo)) {
@@ -1442,6 +1526,9 @@ function browseReducer(state, action) {
1442
1526
  case "groupingToggled": {
1443
1527
  return { ...state, grouped: { ...state.grouped, [action.tab]: !state.grouped[action.tab] } };
1444
1528
  }
1529
+ case "expandToggled": {
1530
+ return { ...state, expanded: { ...state.expanded, [action.tab]: !state.expanded[action.tab] } };
1531
+ }
1445
1532
  case "dataLoaded": {
1446
1533
  return {
1447
1534
  ...state,
@@ -3367,6 +3454,7 @@ async function fetchPrSizes(prs) {
3367
3454
  }
3368
3455
  reviews(first: 100) {
3369
3456
  nodes {
3457
+ author { login }
3370
3458
  comments {
3371
3459
  totalCount
3372
3460
  }
@@ -3468,20 +3556,22 @@ function classifyPr(pr, details, user) {
3468
3556
  (node) => node?.requestedReviewer?.login === user ? [new Date(node.createdAt)] : []
3469
3557
  );
3470
3558
  const reviews = details.reviews.nodes.flatMap(
3471
- (node) => node?.author?.login === user && node.submittedAt ? [new Date(node.submittedAt)] : []
3559
+ (node) => node?.author?.login === user && node.submittedAt ? [{ at: new Date(node.submittedAt), state: node.state }] : []
3472
3560
  );
3473
3561
  if (requests.length === 0) {
3474
3562
  if (reviews.length === 0) {
3475
3563
  return [{ kind: "inaccessible", pr }];
3476
3564
  }
3477
- return [{ kind: "unrequested", pr, reviewedAt: new Date(Math.max(...reviews.map((review) => review.getTime()))) }];
3565
+ return [
3566
+ { kind: "unrequested", pr, reviewedAt: new Date(Math.max(...reviews.map((review) => review.at.getTime()))) }
3567
+ ];
3478
3568
  }
3479
3569
  const events = [
3480
3570
  ...requests.map((at) => {
3481
- return { at, isRequest: true };
3571
+ return { at, isRequest: true, state: "" };
3482
3572
  }),
3483
- ...reviews.map((at) => {
3484
- return { at, isRequest: false };
3573
+ ...reviews.map(({ at, state }) => {
3574
+ return { at, isRequest: false, state };
3485
3575
  })
3486
3576
  ].toSorted((a, b) => a.at.getTime() - b.at.getTime() || Number(b.isRequest) - Number(a.isRequest));
3487
3577
  const results = [];
@@ -3490,7 +3580,7 @@ function classifyPr(pr, details, user) {
3490
3580
  if (event.isRequest) {
3491
3581
  openedAt ??= event.at;
3492
3582
  } else if (openedAt !== null) {
3493
- results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at });
3583
+ results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at, verdict: event.state });
3494
3584
  openedAt = null;
3495
3585
  }
3496
3586
  }
@@ -3559,6 +3649,7 @@ async function fetchSizeRaw(prs, onProgress, options = {}) {
3559
3649
  if (details) {
3560
3650
  const discussion = details.comments.totalCount;
3561
3651
  const review = details.reviews.nodes.reduce((sum, node) => sum + (node?.comments.totalCount ?? 0), 0);
3652
+ const reviewers = details.reviews.nodes.flatMap((node) => node?.author == null ? [] : [node.author.login]);
3562
3653
  sizes.push({
3563
3654
  pr,
3564
3655
  files: details.changedFiles,
@@ -3567,7 +3658,8 @@ async function fetchSizeRaw(prs, onProgress, options = {}) {
3567
3658
  total: details.additions + details.deletions,
3568
3659
  mergedAt: details.mergedAt === null ? null : new Date(details.mergedAt),
3569
3660
  closedAt: details.closedAt === null ? null : new Date(details.closedAt),
3570
- comments: { discussion, review, total: discussion + review }
3661
+ comments: { discussion, review, total: discussion + review },
3662
+ reviewers
3571
3663
  });
3572
3664
  }
3573
3665
  }
@@ -3619,6 +3711,12 @@ function loadSnapshot(options) {
3619
3711
  if (data.reviewResults.some((result) => result.kind === "unrequested" && Number.isNaN(result.reviewedAt.getTime()))) {
3620
3712
  return null;
3621
3713
  }
3714
+ const hasMissingVerdict = data.reviewResults.some((result) => {
3715
+ return result.kind === "reviewed" && result.verdict === void 0;
3716
+ });
3717
+ if (hasMissingVerdict) {
3718
+ return null;
3719
+ }
3622
3720
  if (sinceIso === data.sinceIso) {
3623
3721
  return data;
3624
3722
  }
@@ -4002,6 +4100,137 @@ function hbar(fraction, width, color) {
4002
4100
  return line;
4003
4101
  }
4004
4102
 
4103
+ // src/tui/views/charts/bars.ts
4104
+ var BAR_WIDTH2 = 24;
4105
+ var MAX_BARS = 8;
4106
+ function buildBarsCard({ title, subtitle, rows, format, expanded = false }) {
4107
+ const shown = expanded ? rows : rows.slice(0, MAX_BARS);
4108
+ const max = Math.max(...shown.map((row) => row.value), 0);
4109
+ const labelWidth = Math.max(...shown.map((row) => row.label.length));
4110
+ const valueWidth = Math.max(...shown.map((row) => format(row.value).length));
4111
+ const lines = shown.map((row) => {
4112
+ const line = [{ text: `${row.label.padEnd(labelWidth)} `, fg: theme.muted }];
4113
+ if (row.value <= 0 || max <= 0) {
4114
+ line.push({ text: " ".repeat(BAR_WIDTH2) });
4115
+ } else {
4116
+ line.push(...hbar(row.value / max, BAR_WIDTH2, row.value === max ? theme.accent : theme.chartBar));
4117
+ }
4118
+ line.push(
4119
+ { text: ` ${format(row.value).padStart(valueWidth)}`, fg: theme.text },
4120
+ { text: ` ${row.detail}`, fg: theme.dim }
4121
+ );
4122
+ return line;
4123
+ });
4124
+ if (rows.length > shown.length) {
4125
+ lines.push([{ text: `+ ${rows.length - shown.length} more \xB7 x expands`, fg: theme.dim }]);
4126
+ }
4127
+ return { title, subtitle, lines };
4128
+ }
4129
+
4130
+ // src/tui/views/charts/weeks.ts
4131
+ var DAY_MS = 864e5;
4132
+ var WEEK_MS = 7 * DAY_MS;
4133
+ function mondayOf(dayUtcMs) {
4134
+ return dayUtcMs - (new Date(dayUtcMs).getUTCDay() + 6) % 7 * DAY_MS;
4135
+ }
4136
+ function dateLabel(ms) {
4137
+ return new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
4138
+ }
4139
+ function weekAxisRow(width, prefix, points, columnOf, mondayOfPoint) {
4140
+ const cells = blankCells(width);
4141
+ const every = Math.ceil(points / 4);
4142
+ let lastEnd = -2;
4143
+ for (let point = 0; point < points; point += every) {
4144
+ const label = dateLabel(mondayOfPoint(point));
4145
+ const at = prefix + columnOf(point);
4146
+ if (at >= lastEnd + 2 && at + label.length <= width) {
4147
+ placeText(cells, at, label, theme.dim);
4148
+ lastEnd = at + label.length;
4149
+ }
4150
+ }
4151
+ return mergeCells(cells);
4152
+ }
4153
+
4154
+ // src/tui/views/charts/cumulative.ts
4155
+ var CUM_WIDTH = 36;
4156
+ var CUM_HEIGHT = 8;
4157
+ function weekOf(date) {
4158
+ return mondayOf(zonedStamp(date).dayUtcMs);
4159
+ }
4160
+ function buildCumulativeCard({ title, series, legend }) {
4161
+ const subtitle = series.flatMap((entry, i) => [
4162
+ { text: `${i === 0 ? "" : " "}\u2500\u2500`, fg: entry.color },
4163
+ { text: ` ${entry.label}`, fg: theme.muted }
4164
+ ]);
4165
+ subtitle.push({ text: `, ${legend}`, fg: theme.muted });
4166
+ const mondays = series.flatMap((entry) => entry.dates.map((date) => weekOf(date)));
4167
+ const first = Math.min(...mondays);
4168
+ const weekCount = (Math.max(...mondays) - first) / WEEK_MS + 1;
4169
+ if (weekCount < 2) {
4170
+ return { title, subtitle, lines: [[{ text: "not enough weeks to draw a trend", fg: theme.muted }]] };
4171
+ }
4172
+ const totals = series.map((entry) => {
4173
+ const weekly = Array.from({ length: weekCount }, () => 0);
4174
+ for (const date of entry.dates) {
4175
+ weekly[(weekOf(date) - first) / WEEK_MS] += 1;
4176
+ }
4177
+ let running = 0;
4178
+ return weekly.map((count2) => running += count2);
4179
+ });
4180
+ const maxY = Math.max(...totals.map((cumulative) => cumulative.at(-1) ?? 0), 1);
4181
+ const grid = Array.from(
4182
+ { length: CUM_HEIGHT },
4183
+ () => Array.from({ length: CUM_WIDTH }, () => null)
4184
+ );
4185
+ for (const [i, cumulative] of totals.entries()) {
4186
+ const rows = Array.from({ length: CUM_WIDTH }, (_, x) => {
4187
+ const weekPos = x / (CUM_WIDTH - 1) * (weekCount - 1);
4188
+ const week = Math.floor(weekPos);
4189
+ const nextWeek = Math.min(week + 1, weekCount - 1);
4190
+ const value2 = cumulative[week] + (cumulative[nextWeek] - cumulative[week]) * (weekPos - week);
4191
+ return CUM_HEIGHT - 1 - Math.round(value2 / maxY * (CUM_HEIGHT - 1));
4192
+ });
4193
+ const color = series[i].color;
4194
+ for (let x = 0; x < CUM_WIDTH; x++) {
4195
+ const here = rows[x];
4196
+ const next = rows[Math.min(x + 1, CUM_WIDTH - 1)];
4197
+ if (here === next) {
4198
+ grid[here][x] = { ch: "\u2500", fg: color };
4199
+ continue;
4200
+ }
4201
+ grid[next][x] = { ch: next < here ? "\u256D" : "\u2570", fg: color };
4202
+ grid[here][x] = { ch: next < here ? "\u256F" : "\u256E", fg: color };
4203
+ for (let row = Math.min(here, next) + 1; row < Math.max(here, next); row++) {
4204
+ grid[row][x] = { ch: "\u2502", fg: color };
4205
+ }
4206
+ }
4207
+ }
4208
+ const topLabel = String(maxY);
4209
+ const yWidth = topLabel.length;
4210
+ const lines = grid.map((row, i) => {
4211
+ const label = i === 0 ? topLabel : i === CUM_HEIGHT - 1 ? "0" : "";
4212
+ const line = [
4213
+ { text: `${label.padStart(yWidth)} `, fg: theme.muted },
4214
+ { text: label === "" ? "\u2502" : "\u2524", fg: theme.dim }
4215
+ ];
4216
+ for (const cell of row) {
4217
+ line.push(cell === null ? { text: " " } : { text: cell.ch, fg: cell.fg });
4218
+ }
4219
+ return line;
4220
+ });
4221
+ lines.push(
4222
+ [{ text: `${" ".repeat(yWidth)} \u2514${"\u2500".repeat(CUM_WIDTH)}`, fg: theme.dim }],
4223
+ weekAxisRow(
4224
+ yWidth + 2 + CUM_WIDTH,
4225
+ yWidth + 2,
4226
+ weekCount,
4227
+ (week) => Math.round(week / (weekCount - 1) * (CUM_WIDTH - 1)),
4228
+ (week) => first + week * WEEK_MS
4229
+ )
4230
+ );
4231
+ return { title, subtitle, lines };
4232
+ }
4233
+
4005
4234
  // src/tui/views/charts/distribution.ts
4006
4235
  var DURATION_TICKS = [5 / 60, 0.25, 0.5, 1, 2, 4, 8, 24, 48, 96, 168, 336, 720, 2160];
4007
4236
  var COUNT_TICKS = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1e3, 2500, 5e3, 1e4, 25e3, 5e4, 1e5];
@@ -4322,39 +4551,20 @@ function buildSpreadCard(title, metrics, format) {
4322
4551
 
4323
4552
  // src/tui/views/charts/trend.ts
4324
4553
  var asciichart = __toESM(require_asciichart(), 1);
4325
-
4326
- // src/tui/views/charts/weeks.ts
4327
- var DAY_MS = 864e5;
4328
- var WEEK_MS = 7 * DAY_MS;
4329
- function mondayOf(dayUtcMs) {
4330
- return dayUtcMs - (new Date(dayUtcMs).getUTCDay() + 6) % 7 * DAY_MS;
4331
- }
4332
- function dateLabel(ms) {
4333
- return new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
4334
- }
4335
- function weekAxisRow(width, prefix, points, columnOf, mondayOfPoint) {
4336
- const cells = blankCells(width);
4337
- const every = Math.ceil(points / 4);
4338
- let lastEnd = -2;
4339
- for (let point = 0; point < points; point += every) {
4340
- const label = dateLabel(mondayOfPoint(point));
4341
- const at = prefix + columnOf(point);
4342
- if (at >= lastEnd + 2 && at + label.length <= width) {
4343
- placeText(cells, at, label, theme.dim);
4344
- lastEnd = at + label.length;
4345
- }
4346
- }
4347
- return mergeCells(cells);
4348
- }
4349
-
4350
- // src/tui/views/charts/trend.ts
4351
4554
  var TREND_POINTS = 40;
4352
4555
  var TREND_HEIGHT = 6;
4353
4556
  function axisSplit(line) {
4354
4557
  const positions = [line.indexOf("\u2524"), line.indexOf("\u253C")].filter((at) => at >= 0);
4355
4558
  return positions.length === 0 ? 0 : Math.min(...positions) + 1;
4356
4559
  }
4357
- function buildTrendCard({ title, entries, format, floor }) {
4560
+ function buildTrendCard({
4561
+ title,
4562
+ entries,
4563
+ format,
4564
+ floor = 1,
4565
+ scale = "log",
4566
+ valueLabel = "median"
4567
+ }) {
4358
4568
  const byWeek = /* @__PURE__ */ new Map();
4359
4569
  for (const entry of entries) {
4360
4570
  const monday = mondayOf(zonedStamp(entry.date).dayUtcMs);
@@ -4365,10 +4575,11 @@ function buildTrendCard({ title, entries, format, floor }) {
4365
4575
  const mondays = [...byWeek.keys()];
4366
4576
  const first = Math.min(...mondays);
4367
4577
  const weekCount = (Math.max(...mondays) - first) / WEEK_MS + 1;
4578
+ const scaleSuffix = scale === "log" ? ", log scale" : "";
4368
4579
  if (weekCount < 2) {
4369
4580
  return {
4370
4581
  title,
4371
- subtitle: "weekly median, log scale",
4582
+ subtitle: `weekly ${valueLabel}${scaleSuffix}`,
4372
4583
  lines: [[{ text: "not enough weeks to draw a trend", fg: theme.muted }]]
4373
4584
  };
4374
4585
  }
@@ -4386,9 +4597,11 @@ function buildTrendCard({ title, entries, format, floor }) {
4386
4597
  medians.push(medians.at(-1) ?? 0);
4387
4598
  }
4388
4599
  }
4389
- const firstKnown = medians.find((value2) => value2 > 0) ?? 0;
4390
- for (let i = 0; i < medians.length && medians[i] === 0; i++) {
4391
- medians[i] = firstKnown;
4600
+ if (scale === "log") {
4601
+ const firstKnown = medians.find((value2) => value2 > 0) ?? 0;
4602
+ for (let i = 0; i < medians.length && medians[i] === 0; i++) {
4603
+ medians[i] = firstKnown;
4604
+ }
4392
4605
  }
4393
4606
  const chunk = Math.ceil(weekCount / TREND_POINTS);
4394
4607
  const points = [];
@@ -4398,23 +4611,23 @@ function buildTrendCard({ title, entries, format, floor }) {
4398
4611
  }
4399
4612
  const stretch = Math.max(1, Math.floor(TREND_POINTS / points.length));
4400
4613
  const series = points.flatMap((value2) => Array.from({ length: stretch }, () => value2));
4401
- const logs = series.map((value2) => Math.log2(Math.max(value2, floor)));
4402
- let lo = Math.min(...logs);
4403
- let hi = Math.max(...logs);
4614
+ const plotted = scale === "log" ? series.map((value2) => Math.log2(Math.max(value2, floor))) : series;
4615
+ let lo = Math.min(...plotted);
4616
+ let hi = Math.max(...plotted);
4404
4617
  if (hi - lo < 1e-9) {
4405
4618
  lo -= 1;
4406
4619
  hi += 1;
4407
4620
  }
4408
- const chart = asciichart.plot(logs, {
4621
+ const chart = asciichart.plot(plotted, {
4409
4622
  height: TREND_HEIGHT,
4410
4623
  min: lo,
4411
4624
  max: hi,
4412
- format: (x) => format(2 ** x).padStart(6)
4625
+ format: (x) => format(scale === "log" ? 2 ** x : x).padStart(6)
4413
4626
  });
4414
4627
  const ratio = TREND_HEIGHT / (hi - lo);
4415
4628
  const min2 = Math.round(lo * ratio);
4416
4629
  const rows = Math.abs(Math.round(hi * ratio) - min2);
4417
- const lastRow = Math.min(rows, Math.max(0, rows - (Math.round((logs.at(-1) ?? 0) * ratio) - min2)));
4630
+ const lastRow = Math.min(rows, Math.max(0, rows - (Math.round((plotted.at(-1) ?? 0) * ratio) - min2)));
4418
4631
  const raw = chart.split("\n");
4419
4632
  const lines = raw.map((line, i) => {
4420
4633
  const split = axisSplit(line);
@@ -4437,7 +4650,7 @@ function buildTrendCard({ title, entries, format, floor }) {
4437
4650
  (point) => first + point * chunk * WEEK_MS
4438
4651
  )
4439
4652
  );
4440
- const subtitle = chunk === 1 ? "weekly median, log scale" : `median per ${chunk} weeks, log scale`;
4653
+ const subtitle = chunk === 1 ? `weekly ${valueLabel}${scaleSuffix}` : `${valueLabel} per ${chunk} weeks${scaleSuffix}`;
4441
4654
  return { title, subtitle, lines };
4442
4655
  }
4443
4656
 
@@ -4505,7 +4718,80 @@ function countCell(count2, label, dimWhenZero = false) {
4505
4718
  { text: ` ${label}`, fg: dim2 ? theme.dim : theme.muted }
4506
4719
  ];
4507
4720
  }
4508
- function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100) {
4721
+ function mondayNoon(monday) {
4722
+ return new Date(monday + 12 * 36e5);
4723
+ }
4724
+ function weeklySums(entries) {
4725
+ const byWeek = /* @__PURE__ */ new Map();
4726
+ for (const entry of entries) {
4727
+ const monday = mondayOf(zonedStamp(entry.date).dayUtcMs);
4728
+ byWeek.set(monday, (byWeek.get(monday) ?? 0) + entry.value);
4729
+ }
4730
+ const mondays = [...byWeek.keys()];
4731
+ const first = Math.min(...mondays);
4732
+ const last = Math.max(...mondays);
4733
+ const result = [];
4734
+ for (let monday = first; monday <= last; monday += WEEK_MS) {
4735
+ result.push({ date: mondayNoon(monday), value: byWeek.get(monday) ?? 0 });
4736
+ }
4737
+ return result;
4738
+ }
4739
+ function mergeRateEntries(stats) {
4740
+ const byWeek = /* @__PURE__ */ new Map();
4741
+ const add = (createdAt, merged) => {
4742
+ const monday = mondayOf(zonedStamp(createdAt).dayUtcMs);
4743
+ const counts = byWeek.get(monday) ?? { merged: 0, concluded: 0 };
4744
+ counts.concluded += 1;
4745
+ counts.merged += merged ? 1 : 0;
4746
+ byWeek.set(monday, counts);
4747
+ };
4748
+ for (const result of stats.merged) {
4749
+ add(result.entry.pr.createdAt, true);
4750
+ }
4751
+ for (const result of stats.closed) {
4752
+ add(result.entry.pr.createdAt, false);
4753
+ }
4754
+ return [...byWeek.entries()].map(([monday, counts]) => {
4755
+ return { date: mondayNoon(monday), value: counts.merged / counts.concluded * 100 };
4756
+ });
4757
+ }
4758
+ function buildOffHoursCard(subtitle, dates) {
4759
+ const counts = { work: 0, after: 0, weekend: 0 };
4760
+ for (const date of dates) {
4761
+ counts[classifyInstant(date)] += 1;
4762
+ }
4763
+ const rows = hasWorkWindows() ? [
4764
+ { label: "work hours", count: counts.work, color: theme.accent },
4765
+ { label: "after hours", count: counts.after, color: theme.warn },
4766
+ { label: "weekend", count: counts.weekend, color: theme.chartDim }
4767
+ ] : [
4768
+ { label: "weekday", count: counts.work + counts.after, color: theme.accent },
4769
+ { label: "weekend", count: counts.weekend, color: theme.chartDim }
4770
+ ];
4771
+ return buildGaugeCard({ title: "Off-hours share", subtitle, rows });
4772
+ }
4773
+ function buildVerdictCard(reviewed) {
4774
+ const countOf = (state) => reviewed.filter((entry) => entry.verdict === state).length;
4775
+ const approved = countOf("APPROVED");
4776
+ const changes = countOf("CHANGES_REQUESTED");
4777
+ const commented = countOf("COMMENTED");
4778
+ const other = reviewed.length - approved - changes - commented;
4779
+ const counts = [
4780
+ { label: "approved", count: approved },
4781
+ { label: "changes requested", count: changes },
4782
+ { label: "commented", count: commented },
4783
+ ...other > 0 ? [{ label: "other", count: other }] : []
4784
+ ];
4785
+ const max = Math.max(...counts.map((row) => row.count));
4786
+ return buildGaugeCard({
4787
+ title: "Review verdicts",
4788
+ subtitle: "how your requested reviews concluded",
4789
+ rows: counts.map((row) => {
4790
+ return { ...row, color: row.count === max ? theme.accent : theme.chartBar };
4791
+ })
4792
+ });
4793
+ }
4794
+ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100, expanded = false) {
4509
4795
  const results = repo === null ? raw.reviewResults : raw.reviewResults.filter((result) => result.pr.repo === repo);
4510
4796
  const stats = computeReviewStats(results, { targetHours, now: raw.fetchedAt });
4511
4797
  const strip = [
@@ -4519,9 +4805,10 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4519
4805
  headline: null,
4520
4806
  distributionTitle: "Review time distribution",
4521
4807
  noCharts: "No completed reviews to chart.",
4522
- left: [],
4523
- right: [],
4524
- distribution: null
4808
+ cards: [],
4809
+ distribution: null,
4810
+ expandable: false,
4811
+ expanded
4525
4812
  };
4526
4813
  if (results.length === 0) {
4527
4814
  return { empty: "No reviewed or review-requested PRs found.", ...base, lists: [] };
@@ -4533,8 +4820,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4533
4820
  rows: toPrRows(stats.misses, stats.misses.map(durationLead))
4534
4821
  });
4535
4822
  }
4823
+ const pendingCard = stats.pending.length === 0 ? null : buildHistogramCard({
4824
+ title: "Pending request age",
4825
+ subtitle: "how long open requests have waited",
4826
+ values: stats.pending.map((entry) => entry.hours),
4827
+ buckets: currentBuckets(),
4828
+ format: formatDuration
4829
+ });
4536
4830
  if (stats.reviewed.length === 0) {
4537
- return { empty: null, ...base, lists };
4831
+ return { empty: null, ...base, cards: pendingCard === null ? [] : [pendingCard], lists };
4538
4832
  }
4539
4833
  const sorted = [...stats.allHours].toSorted((a, b) => a - b);
4540
4834
  const total = raw.reviewResults.filter((result) => result.kind === "reviewed").length;
@@ -4547,7 +4841,40 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4547
4841
  ];
4548
4842
  const requestDates = [...stats.reviewed, ...stats.pending].map((entry) => entry.requestedAt);
4549
4843
  const reviewDates = stats.reviewed.map((entry) => entry.reviewedAt);
4550
- const left = [
4844
+ const requestAges = [...stats.reviewed, ...stats.pending].map(
4845
+ (entry) => durationHours(entry.pr.createdAt, entry.requestedAt)
4846
+ );
4847
+ let serviceCard = null;
4848
+ if (targetHours !== void 0 && targetLabel !== void 0) {
4849
+ const inside = stats.allHours.filter((value2) => value2 <= targetHours).length;
4850
+ const overdue = stats.pending.filter((entry) => entry.hours > targetHours).length;
4851
+ serviceCard = buildGaugeCard({
4852
+ title: "Service level",
4853
+ subtitle: `reviewed within ${targetLabel}`,
4854
+ rows: [
4855
+ { label: `inside ${targetLabel}`, count: inside, color: theme.accent },
4856
+ { label: `over ${targetLabel}`, count: stats.allHours.length - inside, color: theme.chartDim },
4857
+ ...overdue > 0 ? [{ label: "awaiting and already over", count: overdue, color: theme.warn }] : []
4858
+ ]
4859
+ });
4860
+ }
4861
+ const byRepoCard = repo === null && stats.byRepo.length > 1 ? buildBarsCard({
4862
+ title: "Review time by repo",
4863
+ subtitle: "median review time, slowest first",
4864
+ rows: stats.byRepo.map(([name, hours]) => {
4865
+ return {
4866
+ label: name,
4867
+ value: percentile(
4868
+ hours.toSorted((a, b) => a - b),
4869
+ 50
4870
+ ),
4871
+ detail: `n=${hours.length}`
4872
+ };
4873
+ }).toSorted((a, b) => b.value - a.value),
4874
+ format: formatDuration,
4875
+ expanded
4876
+ }) : null;
4877
+ const cards = [
4551
4878
  buildHistogramCard({
4552
4879
  title: "Time to review",
4553
4880
  subtitle: "elapsed time, request \u2192 review",
@@ -4555,6 +4882,14 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4555
4882
  buckets: currentBuckets(),
4556
4883
  format: formatDuration
4557
4884
  }),
4885
+ buildTrendCard({
4886
+ title: "Review time trend",
4887
+ entries: stats.reviewed.map((entry) => {
4888
+ return { date: entry.reviewedAt, value: entry.hours };
4889
+ }),
4890
+ format: formatDuration,
4891
+ floor: 1 / 60
4892
+ }),
4558
4893
  buildHeatmapCard({
4559
4894
  title: "When you review",
4560
4895
  subtitle: "reviews submitted, weekday \xD7 hour, local time",
@@ -4564,34 +4899,28 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4564
4899
  { label: "req", dates: requestDates, muted: true }
4565
4900
  ],
4566
4901
  legend: "reviews in that hour"
4567
- })
4568
- ];
4569
- const right = [
4570
- buildTrendCard({
4571
- title: "Review time trend",
4572
- entries: stats.reviewed.map((entry) => {
4573
- return { date: entry.reviewedAt, value: entry.hours };
4574
- }),
4575
- format: formatDuration,
4576
- floor: 1 / 60
4577
4902
  }),
4578
- buildVolumeCard("Reviews completed per week", reviewDates)
4903
+ buildVolumeCard("Reviews completed per week", reviewDates),
4904
+ buildHistogramCard({
4905
+ title: "Review cycles per PR",
4906
+ subtitle: "completed request \u2192 review rounds per PR",
4907
+ values: stats.cycles,
4908
+ buckets: CYCLE_BUCKETS,
4909
+ format: count
4910
+ }),
4911
+ buildHistogramCard({
4912
+ title: "PR age at request",
4913
+ subtitle: "elapsed time, PR created \u2192 review requested",
4914
+ values: requestAges,
4915
+ buckets: currentBuckets(),
4916
+ format: formatDuration
4917
+ }),
4918
+ buildVerdictCard(stats.reviewed),
4919
+ ...pendingCard === null ? [] : [pendingCard],
4920
+ buildOffHoursCard("reviews submitted, local time", reviewDates),
4921
+ ...serviceCard === null ? [] : [serviceCard],
4922
+ ...byRepoCard === null ? [] : [byRepoCard]
4579
4923
  ];
4580
- if (targetHours !== void 0 && targetLabel !== void 0) {
4581
- const inside = stats.allHours.filter((value2) => value2 <= targetHours).length;
4582
- const overdue = stats.pending.filter((entry) => entry.hours > targetHours).length;
4583
- left.push(
4584
- buildGaugeCard({
4585
- title: "Service level",
4586
- subtitle: `reviewed within ${targetLabel}`,
4587
- rows: [
4588
- { label: `inside ${targetLabel}`, count: inside, color: theme.accent },
4589
- { label: `over ${targetLabel}`, count: stats.allHours.length - inside, color: theme.chartDim },
4590
- ...overdue > 0 ? [{ label: "awaiting and already over", count: overdue, color: theme.warn }] : []
4591
- ]
4592
- })
4593
- );
4594
- }
4595
4924
  const distribution = buildDistribution({
4596
4925
  values: stats.allHours,
4597
4926
  width: Math.max(width - 3, 40),
@@ -4599,7 +4928,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4599
4928
  ticks: DURATION_TICKS,
4600
4929
  flat: (count2, value2) => `all ${count2} ${count2 === 1 ? "review" : "reviews"} took ${value2}`
4601
4930
  });
4602
- return { empty: null, ...base, headline, left, right, distribution, lists };
4931
+ return {
4932
+ empty: null,
4933
+ ...base,
4934
+ headline,
4935
+ cards,
4936
+ distribution,
4937
+ lists,
4938
+ expandable: byRepoCard !== null && stats.byRepo.length > MAX_BARS
4939
+ };
4603
4940
  }
4604
4941
  function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4605
4942
  const base = {
@@ -4607,10 +4944,11 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4607
4944
  headline: null,
4608
4945
  distributionTitle: "PR size distribution",
4609
4946
  noCharts: "No authored PRs to chart.",
4610
- left: [],
4611
- right: [],
4947
+ cards: [],
4612
4948
  distribution: null,
4613
- lists: []
4949
+ lists: [],
4950
+ expandable: false,
4951
+ expanded: false
4614
4952
  };
4615
4953
  if (raw.authoredTotal === 0) {
4616
4954
  return { empty: "No authored PRs found.", ...base };
@@ -4639,7 +4977,15 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4639
4977
  { text: `${count(percentile(sorted, 90))} lines`, fg: theme.accent },
4640
4978
  { text: ` ${sizes.length} of ${raw.sizes.length} PRs`, fg: theme.muted }
4641
4979
  ];
4642
- const left = [
4980
+ const targetCard = stats.met !== void 0 && stats.targetLabel !== void 0 ? buildGaugeCard({
4981
+ title: "Size target",
4982
+ subtitle: `authored within ${stats.targetLabel}`,
4983
+ rows: [
4984
+ { label: "inside target", count: stats.met, color: theme.accent },
4985
+ { label: "over target", count: sizes.length - stats.met, color: theme.chartDim }
4986
+ ]
4987
+ }) : null;
4988
+ const cards = [
4643
4989
  buildHistogramCard({
4644
4990
  title: "PR size",
4645
4991
  subtitle: "total lines changed per authored PR",
@@ -4647,6 +4993,14 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4647
4993
  buckets: LINE_BUCKETS,
4648
4994
  format: count
4649
4995
  }),
4996
+ buildTrendCard({
4997
+ title: "PR size trend",
4998
+ entries: sizes.map((size) => {
4999
+ return { date: size.pr.createdAt, value: size.total };
5000
+ }),
5001
+ format: count,
5002
+ floor: 1
5003
+ }),
4650
5004
  buildHistogramCard({
4651
5005
  title: "Files touched",
4652
5006
  subtitle: "files changed per authored PR",
@@ -4654,38 +5008,28 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4654
5008
  buckets: FILE_BUCKETS,
4655
5009
  format: count
4656
5010
  }),
5011
+ buildTrendCard({
5012
+ title: "Net lines trend",
5013
+ entries: weeklySums(
5014
+ sizes.map((size) => {
5015
+ return { date: size.pr.createdAt, value: size.additions - size.deletions };
5016
+ })
5017
+ ),
5018
+ format: netCount,
5019
+ scale: "linear",
5020
+ valueLabel: "net lines"
5021
+ }),
4657
5022
  buildHeatmapCard({
4658
5023
  title: "When you open PRs",
4659
5024
  subtitle: "PRs opened, weekday \xD7 hour, local time",
4660
5025
  grid: created,
4661
5026
  columns: [{ label: "opened", dates: created }],
4662
5027
  legend: "PRs opened in that hour"
4663
- })
4664
- ];
4665
- const right = [
4666
- buildTrendCard({
4667
- title: "PR size trend",
4668
- entries: sizes.map((size) => {
4669
- return { date: size.pr.createdAt, value: size.total };
4670
- }),
4671
- format: count,
4672
- floor: 1
4673
5028
  }),
4674
5029
  buildVolumeCard("PRs opened per week", created),
5030
+ ...targetCard === null ? [] : [targetCard],
4675
5031
  buildSpreadCard("Size spread", stats.metrics, count)
4676
5032
  ];
4677
- if (stats.met !== void 0 && stats.targetLabel !== void 0) {
4678
- left.push(
4679
- buildGaugeCard({
4680
- title: "Size target",
4681
- subtitle: `authored within ${stats.targetLabel}`,
4682
- rows: [
4683
- { label: "inside target", count: stats.met, color: theme.accent },
4684
- { label: "over target", count: sizes.length - stats.met, color: theme.chartDim }
4685
- ]
4686
- })
4687
- );
4688
- }
4689
5033
  const distribution = buildDistribution({
4690
5034
  values: totals,
4691
5035
  width: Math.max(width - 3, 40),
@@ -4703,7 +5047,7 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4703
5047
  )
4704
5048
  });
4705
5049
  }
4706
- return { empty: null, ...base, strip, headline, left, right, distribution, lists };
5050
+ return { empty: null, ...base, strip, headline, cards, distribution, lists };
4707
5051
  }
4708
5052
  function buildCommentView(raw, repo = null, width = 100) {
4709
5053
  const base = {
@@ -4711,10 +5055,11 @@ function buildCommentView(raw, repo = null, width = 100) {
4711
5055
  headline: null,
4712
5056
  distributionTitle: "Comments per PR distribution",
4713
5057
  noCharts: "No authored PRs to chart.",
4714
- left: [],
4715
- right: [],
5058
+ cards: [],
4716
5059
  distribution: null,
4717
- lists: []
5060
+ lists: [],
5061
+ expandable: false,
5062
+ expanded: false
4718
5063
  };
4719
5064
  if (raw.authoredTotal === 0) {
4720
5065
  return { empty: "No authored PRs found.", ...base };
@@ -4742,7 +5087,7 @@ function buildCommentView(raw, repo = null, width = 100) {
4742
5087
  { text: `${count(percentile(sorted, 90))} comments`, fg: theme.accent },
4743
5088
  { text: ` ${sizes.length} of ${raw.sizes.length} PRs`, fg: theme.muted }
4744
5089
  ];
4745
- const left = [
5090
+ const cards = [
4746
5091
  buildHistogramCard({
4747
5092
  title: "Comments per PR",
4748
5093
  subtitle: "discussion plus review comments per authored PR",
@@ -4750,6 +5095,14 @@ function buildCommentView(raw, repo = null, width = 100) {
4750
5095
  buckets: COMMENT_BUCKETS,
4751
5096
  format: count
4752
5097
  }),
5098
+ buildTrendCard({
5099
+ title: "Comment trend",
5100
+ entries: sizes.map((size) => {
5101
+ return { date: size.pr.createdAt, value: size.comments.total };
5102
+ }),
5103
+ format: count,
5104
+ floor: 1
5105
+ }),
4753
5106
  buildScatterCard({
4754
5107
  title: "Comments vs size",
4755
5108
  subtitle: "comments against lines changed, log scale",
@@ -4759,18 +5112,8 @@ function buildCommentView(raw, repo = null, width = 100) {
4759
5112
  formatX: count,
4760
5113
  formatY: count
4761
5114
  }),
4762
- buildSpreadCard("Comment spread", stats.metrics, count)
4763
- ];
4764
- const right = [
4765
- buildTrendCard({
4766
- title: "Comment trend",
4767
- entries: sizes.map((size) => {
4768
- return { date: size.pr.createdAt, value: size.comments.total };
4769
- }),
4770
- format: count,
4771
- floor: 1
4772
- }),
4773
5115
  buildVolumeCard("Comments received per week", created, stats.totals),
5116
+ buildSpreadCard("Comment spread", stats.metrics, count),
4774
5117
  buildGaugeCard({
4775
5118
  title: "Feedback rate",
4776
5119
  subtitle: "authored PRs that received comments",
@@ -4800,18 +5143,19 @@ function buildCommentView(raw, repo = null, width = 100) {
4800
5143
  )
4801
5144
  });
4802
5145
  }
4803
- return { empty: null, ...base, strip, headline, left, right, distribution, lists };
5146
+ return { empty: null, ...base, strip, headline, cards, distribution, lists };
4804
5147
  }
4805
- function buildMergedView(raw, repo = null, width = 100) {
5148
+ function buildMergedView(raw, repo = null, width = 100, expanded = false) {
4806
5149
  const base = {
4807
5150
  strip: [],
4808
5151
  headline: null,
4809
5152
  distributionTitle: "Time to merge distribution",
4810
5153
  noCharts: "No merged PRs to chart.",
4811
- left: [],
4812
- right: [],
5154
+ cards: [],
4813
5155
  distribution: null,
4814
- lists: []
5156
+ lists: [],
5157
+ expandable: false,
5158
+ expanded
4815
5159
  };
4816
5160
  if (raw.authoredTotal === 0) {
4817
5161
  return { empty: "No authored PRs found.", ...base };
@@ -4821,6 +5165,7 @@ function buildMergedView(raw, repo = null, width = 100) {
4821
5165
  return { empty: "No accessible authored PRs to analyze.", ...base };
4822
5166
  }
4823
5167
  const stats = computeMergeStats(sizes);
5168
+ const reviewers = computeReviewerStats(sizes, raw.user);
4824
5169
  const strip = [
4825
5170
  countCell(sizes.length, "PRs created"),
4826
5171
  countCell(stats.merged.length, "merged"),
@@ -4851,8 +5196,22 @@ function buildMergedView(raw, repo = null, width = 100) {
4851
5196
  )
4852
5197
  });
4853
5198
  }
5199
+ const reviewerCard = reviewers.leaderboard.length === 0 ? null : buildBarsCard({
5200
+ title: "Who reviews your PRs",
5201
+ subtitle: "distinct PRs reviewed per person",
5202
+ rows: reviewers.leaderboard.map((row) => {
5203
+ return {
5204
+ label: row.login,
5205
+ value: row.prs,
5206
+ detail: row.reviews === 1 ? "1 review" : `${row.reviews} reviews`
5207
+ };
5208
+ }),
5209
+ format: count,
5210
+ expanded
5211
+ });
5212
+ const expandable = reviewers.leaderboard.length > MAX_BARS;
4854
5213
  if (stats.merged.length === 0) {
4855
- return { empty: null, ...base, strip, lists };
5214
+ return { empty: null, ...base, strip, cards: reviewerCard === null ? [] : [reviewerCard], lists, expandable };
4856
5215
  }
4857
5216
  const sorted = [...stats.allHours].toSorted((a, b) => a - b);
4858
5217
  const headline = [
@@ -4864,7 +5223,7 @@ function buildMergedView(raw, repo = null, width = 100) {
4864
5223
  ];
4865
5224
  const mergeDates = stats.merged.map((result) => result.mergedAt);
4866
5225
  const created = sizes.map((size) => size.pr.createdAt);
4867
- const left = [
5226
+ const cards = [
4868
5227
  buildHistogramCard({
4869
5228
  title: "Time to merge",
4870
5229
  subtitle: "elapsed time, created \u2192 merged",
@@ -4872,6 +5231,14 @@ function buildMergedView(raw, repo = null, width = 100) {
4872
5231
  buckets: currentBuckets(),
4873
5232
  format: formatDuration
4874
5233
  }),
5234
+ buildTrendCard({
5235
+ title: "Time to merge trend",
5236
+ entries: stats.merged.map((result) => {
5237
+ return { date: result.mergedAt, value: result.hours };
5238
+ }),
5239
+ format: formatDuration,
5240
+ floor: 1 / 60
5241
+ }),
4875
5242
  buildHeatmapCard({
4876
5243
  title: "When your PRs merge",
4877
5244
  subtitle: "PRs merged, weekday \xD7 hour, local time",
@@ -4882,6 +5249,38 @@ function buildMergedView(raw, repo = null, width = 100) {
4882
5249
  ],
4883
5250
  legend: "PRs merged in that hour"
4884
5251
  }),
5252
+ buildTrendCard({
5253
+ title: "Merge rate trend",
5254
+ entries: mergeRateEntries(stats),
5255
+ format: (value2) => `${Math.round(value2)}%`,
5256
+ scale: "linear",
5257
+ valueLabel: "merge rate"
5258
+ }),
5259
+ buildScatterCard({
5260
+ title: "Merge time vs size",
5261
+ subtitle: "time to merge against lines changed, log scale",
5262
+ points: stats.merged.map((result) => {
5263
+ return { x: result.entry.total, y: result.hours };
5264
+ }),
5265
+ formatX: count,
5266
+ formatY: formatDuration
5267
+ }),
5268
+ buildCumulativeCard({
5269
+ title: "Created vs merged",
5270
+ series: [
5271
+ /**
5272
+ * The created line stays a neutral gray because every theme preset
5273
+ * keeps chartLine and accent in one hue family, which made the two
5274
+ * lines indistinguishable. The gray also survives theme changes,
5275
+ * since the presets only rotate the hue-carrying colors.
5276
+ */
5277
+ { label: "created", dates: created, color: theme.muted },
5278
+ { label: "merged", dates: mergeDates, color: theme.accent }
5279
+ ],
5280
+ legend: "cumulative PRs by week"
5281
+ }),
5282
+ buildVolumeCard("PRs created per week", created),
5283
+ buildVolumeCard("PRs merged per week", mergeDates),
4885
5284
  buildGaugeCard({
4886
5285
  title: "Outcomes",
4887
5286
  subtitle: "where your authored PRs ended up",
@@ -4890,19 +5289,16 @@ function buildMergedView(raw, repo = null, width = 100) {
4890
5289
  { label: "closed unmerged", count: stats.closed.length, color: theme.warn },
4891
5290
  ...stats.open.length > 0 ? [{ label: "still open", count: stats.open.length, color: theme.chartDim }] : []
4892
5291
  ]
4893
- })
4894
- ];
4895
- const right = [
4896
- buildTrendCard({
4897
- title: "Time to merge trend",
4898
- entries: stats.merged.map((result) => {
4899
- return { date: result.mergedAt, value: result.hours };
4900
- }),
4901
- format: formatDuration,
4902
- floor: 1 / 60
4903
5292
  }),
4904
- buildVolumeCard("PRs merged per week", mergeDates),
4905
- buildVolumeCard("PRs created per week", created)
5293
+ buildGaugeCard({
5294
+ title: "Review coverage",
5295
+ subtitle: "merged PRs that received a review",
5296
+ rows: [
5297
+ { label: "reviewed", count: reviewers.mergedReviewed, color: theme.accent },
5298
+ { label: "merged unreviewed", count: reviewers.mergedUnreviewed, color: theme.warn }
5299
+ ]
5300
+ }),
5301
+ ...reviewerCard === null ? [] : [reviewerCard]
4906
5302
  ];
4907
5303
  const distribution = buildDistribution({
4908
5304
  values: stats.allHours,
@@ -4911,11 +5307,14 @@ function buildMergedView(raw, repo = null, width = 100) {
4911
5307
  ticks: DURATION_TICKS,
4912
5308
  flat: (n, value2) => `all ${n} merged ${n === 1 ? "PR" : "PRs"} took ${value2}`
4913
5309
  });
4914
- return { empty: null, ...base, strip, headline, left, right, distribution, lists };
5310
+ return { empty: null, ...base, strip, headline, cards, distribution, lists, expandable };
4915
5311
  }
4916
5312
  function count(value2) {
4917
5313
  return formatCount(Math.round(value2));
4918
5314
  }
5315
+ function netCount(value2) {
5316
+ return value2 < 0 ? `-${count(-value2)}` : `+${count(value2)}`;
5317
+ }
4919
5318
 
4920
5319
  // src/tui/hooks/useViewModel.ts
4921
5320
  function resolveScope(scope, repos) {
@@ -4924,7 +5323,7 @@ function resolveScope(scope, repos) {
4924
5323
  }
4925
5324
  return dropVanishedRepo(scope, repos);
4926
5325
  }
4927
- function useViewModel(raw, options, width, scopes, grouping, themeEpoch) {
5326
+ function useViewModel(raw, options, width, scopes, grouping, expanded, themeEpoch) {
4928
5327
  return useMemo(() => {
4929
5328
  void themeEpoch;
4930
5329
  configureTimeMode({
@@ -4950,7 +5349,7 @@ function useViewModel(raw, options, width, scopes, grouping, themeEpoch) {
4950
5349
  const reviewScope = resolveScope(scopes.review, reviewRepos);
4951
5350
  const sizeScope = resolveScope(scopes.size, sizeRepos);
4952
5351
  const commentScope = resolveScope(scopes.comment, commentRepos);
4953
- const review = reviewScope.view === "detail" ? buildReviewView(raw, targetHours, targetLabelOf(options.target), reviewScope.repo, width) : null;
5352
+ const review = reviewScope.view === "detail" ? buildReviewView(raw, targetHours, targetLabelOf(options.target), reviewScope.repo, width, expanded.review) : null;
4954
5353
  return {
4955
5354
  pendingRepos,
4956
5355
  openRepos,
@@ -4966,7 +5365,7 @@ function useViewModel(raw, options, width, scopes, grouping, themeEpoch) {
4966
5365
  commentScope,
4967
5366
  pending: pendingScope.view === "detail" ? buildPendingReviewView(raw, pendingScope.repo, grouping.pending) : null,
4968
5367
  open: openScope.view === "detail" ? buildOpenAuthoredView(raw, openScope.repo, grouping.open) : null,
4969
- merged: mergedScope.view === "detail" ? buildMergedView(raw, mergedScope.repo, width) : null,
5368
+ merged: mergedScope.view === "detail" ? buildMergedView(raw, mergedScope.repo, width, expanded.merged) : null,
4970
5369
  review,
4971
5370
  size: sizeScope.view === "detail" ? buildSizeView(raw, sizeTarget, sizeScope.repo, width) : null,
4972
5371
  comments: commentScope.view === "detail" ? buildCommentView(raw, commentScope.repo, width) : null
@@ -4987,6 +5386,8 @@ function useViewModel(raw, options, width, scopes, grouping, themeEpoch) {
4987
5386
  scopes.comment,
4988
5387
  grouping.pending,
4989
5388
  grouping.open,
5389
+ expanded.review,
5390
+ expanded.merged,
4990
5391
  themeEpoch
4991
5392
  ]);
4992
5393
  }
@@ -5216,31 +5617,35 @@ function statsTabOf(context) {
5216
5617
  return {
5217
5618
  key: "merged",
5218
5619
  repos: views?.mergedRepos ?? [],
5219
- scope: views?.mergedScope ?? null
5620
+ scope: views?.mergedScope ?? null,
5621
+ view: views?.merged ?? null
5220
5622
  };
5221
5623
  }
5222
5624
  if (context.browse.tab === 2) {
5223
5625
  return {
5224
5626
  key: "review",
5225
5627
  repos: views?.reviewRepos ?? [],
5226
- scope: views?.reviewScope ?? null
5628
+ scope: views?.reviewScope ?? null,
5629
+ view: views?.review ?? null
5227
5630
  };
5228
5631
  }
5229
5632
  if (context.browse.tab === 3) {
5230
5633
  return {
5231
5634
  key: "size",
5232
5635
  repos: views?.sizeRepos ?? [],
5233
- scope: views?.sizeScope ?? null
5636
+ scope: views?.sizeScope ?? null,
5637
+ view: views?.size ?? null
5234
5638
  };
5235
5639
  }
5236
5640
  return {
5237
5641
  key: "comment",
5238
5642
  repos: views?.commentRepos ?? [],
5239
- scope: views?.commentScope ?? null
5643
+ scope: views?.commentScope ?? null,
5644
+ view: views?.comments ?? null
5240
5645
  };
5241
5646
  }
5242
5647
  function handleStatsKey(key, context) {
5243
- const { key: tab, repos, scope } = statsTabOf(context);
5648
+ const { key: tab, repos, scope, view } = statsTabOf(context);
5244
5649
  if (scope !== null && scope.view === "list") {
5245
5650
  switch (key.name) {
5246
5651
  case "up":
@@ -5261,6 +5666,8 @@ function handleStatsKey(key, context) {
5261
5666
  }
5262
5667
  } else if ((key.name === "escape" || key.name === "backspace") && repos.length > 0) {
5263
5668
  context.dispatchBrowse({ type: "pickerReturned", tab });
5669
+ } else if (key.name === "x" && view?.expandable === true) {
5670
+ context.dispatchBrowse({ type: "expandToggled", tab });
5264
5671
  } else if (key.name === "j") {
5265
5672
  context.scrollBy(context.browse.tab, 2);
5266
5673
  } else if (key.name === "k") {
@@ -5526,7 +5933,7 @@ function App({
5526
5933
  }
5527
5934
  });
5528
5935
  });
5529
- const views = useViewModel(raw, options, width, browse.scopes, browse.grouped, themeState);
5936
+ const views = useViewModel(raw, options, width, browse.scopes, browse.grouped, browse.expanded, themeState);
5530
5937
  const showLoad = useDeferredLoading(loading, isSnapshot ? { showDelay: 0 } : void 0);
5531
5938
  const draftRef = useRef5("");
5532
5939
  const commitField = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3lm/pr-stats",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "GitHub PR stats in an interactive terminal UI, via the gh CLI or an access token",
5
5
  "type": "module",
6
6
  "author": "Dominic Elm",