@d3lm/pr-stats 0.2.6 → 0.2.7

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 +448 -145
  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.
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.
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
@@ -726,7 +726,19 @@ function wallParts(instantMs) {
726
726
  function zonedStamp(date) {
727
727
  const parts = wallParts(date.getTime());
728
728
  const dayUtcMs = Date.UTC(parts.year, parts.month - 1, parts.day);
729
- return { dayUtcMs, weekday: new Date(dayUtcMs).getUTCDay(), hour: parts.hour };
729
+ return { dayUtcMs, weekday: new Date(dayUtcMs).getUTCDay(), hour: parts.hour, minute: parts.minute };
730
+ }
731
+ function hasWorkWindows() {
732
+ const covered = timeMode.workWindows.reduce((sum, window) => sum + (window.endMin - window.startMin), 0);
733
+ return covered < 24 * 60;
734
+ }
735
+ function classifyInstant(date) {
736
+ const { weekday, hour, minute } = zonedStamp(date);
737
+ if (weekday === 0 || weekday === 6) {
738
+ return "weekend";
739
+ }
740
+ const minuteOfDay = hour * 60 + minute;
741
+ return timeMode.workWindows.some((window) => minuteOfDay >= window.startMin && minuteOfDay < window.endMin) ? "work" : "after";
730
742
  }
731
743
  function utcFromWall(wallTargetMs) {
732
744
  let guess = wallTargetMs;
@@ -780,7 +792,8 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
780
792
  pr: result.pr,
781
793
  requestedAt: result.requestedAt,
782
794
  reviewedAt: result.reviewedAt,
783
- hours: durationHours(result.requestedAt, result.reviewedAt)
795
+ hours: durationHours(result.requestedAt, result.reviewedAt),
796
+ verdict: result.verdict
784
797
  });
785
798
  } else if (result.kind === "pending" && result.pr.state === "open") {
786
799
  pending.push({ pr: result.pr, requestedAt: result.requestedAt, hours: durationHours(result.requestedAt, now) });
@@ -816,7 +829,22 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
816
829
  }
817
830
  const byRepo = [...hoursByRepo.entries()].toSorted((a, b) => b[1].length - a[1].length);
818
831
  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 };
832
+ const cyclesByPr = /* @__PURE__ */ new Map();
833
+ for (const result of reviewed) {
834
+ const key = `${result.pr.repo}#${result.pr.number}`;
835
+ cyclesByPr.set(key, (cyclesByPr.get(key) ?? 0) + 1);
836
+ }
837
+ return {
838
+ reviewed,
839
+ pending,
840
+ reviewing,
841
+ expired,
842
+ unrequested,
843
+ allHours,
844
+ byRepo,
845
+ misses,
846
+ cycles: [...cyclesByPr.values()]
847
+ };
820
848
  }
821
849
  function computeSizeStats(sizes, { sizeTarget } = {}) {
822
850
  const metrics = [
@@ -918,6 +946,13 @@ var FILE_BUCKETS = [
918
946
  { label: "21-50", max: 51 },
919
947
  { label: "> 50", max: Infinity }
920
948
  ];
949
+ var CYCLE_BUCKETS = [
950
+ { label: "1", max: 2 },
951
+ { label: "2", max: 3 },
952
+ { label: "3", max: 4 },
953
+ { label: "4-5", max: 6 },
954
+ { label: "> 5", max: Infinity }
955
+ ];
921
956
  var COMMENT_BUCKETS = [
922
957
  { label: "0", max: 1 },
923
958
  { label: "1-2", max: 3 },
@@ -1094,18 +1129,18 @@ function ChartsPanel({
1094
1129
  if (view.empty !== null) {
1095
1130
  return /* @__PURE__ */ jsx4("box", { flexGrow: 1, alignItems: "center", justifyContent: "center", children: /* @__PURE__ */ jsx4("text", { fg: theme.muted, children: view.empty }) });
1096
1131
  }
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 };
1132
+ const left = view.cards.filter((_, i) => i % 2 === 0);
1133
+ const right = view.cards.filter((_, i) => i % 2 === 1);
1134
+ const leftWidth = Math.max(0, ...left.map((card) => cardWidth(card)));
1135
+ const rightWidth = Math.max(0, ...right.map((card) => cardWidth(card)));
1136
+ const twoColumns = right.length > 0 && width - 4 >= leftWidth + COLUMN_GAP + rightWidth;
1137
+ const rows = left.map((card, i) => {
1138
+ return { key: card.title, left: card, right: right.at(i) };
1104
1139
  });
1105
1140
  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 }) }),
1141
+ /* @__PURE__ */ jsx4("box", { flexDirection: "column", width: leftWidth, flexShrink: 0, children: /* @__PURE__ */ jsx4(ChartCard, { card: row.left }) }),
1107
1142
  /* @__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)) });
1143
+ ] }, row.key)) }) : /* @__PURE__ */ jsx4("box", { flexDirection: "column", rowGap: 1, children: view.cards.map((card) => /* @__PURE__ */ jsx4(ChartCard, { card }, card.title)) });
1109
1144
  return /* @__PURE__ */ jsxs3("box", { flexGrow: 1, flexDirection: "column", children: [
1110
1145
  /* @__PURE__ */ jsx4("box", { height: 1, children: /* @__PURE__ */ jsx4("text", { wrapMode: "none", fg: theme.border, children: "\u2500".repeat(width) }) }),
1111
1146
  /* @__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 +1181,7 @@ function ChartsPanel({
1146
1181
  ] }),
1147
1182
  /* @__PURE__ */ jsx4("box", { flexDirection: "column", marginTop: 1, children: keyed(view.distribution.lines, lineKey).map(({ key, item }) => /* @__PURE__ */ jsx4(ChartLine, { line: item }, key)) })
1148
1183
  ] }),
1149
- view.left.length === 0 ? /* @__PURE__ */ jsx4("text", { wrapMode: "none", fg: theme.muted, marginTop: 1, children: view.noCharts }) : cards
1184
+ view.cards.length === 0 ? /* @__PURE__ */ jsx4("text", { wrapMode: "none", fg: theme.muted, marginTop: 1, children: view.noCharts }) : cards
1150
1185
  ]
1151
1186
  }
1152
1187
  )
@@ -3468,20 +3503,22 @@ function classifyPr(pr, details, user) {
3468
3503
  (node) => node?.requestedReviewer?.login === user ? [new Date(node.createdAt)] : []
3469
3504
  );
3470
3505
  const reviews = details.reviews.nodes.flatMap(
3471
- (node) => node?.author?.login === user && node.submittedAt ? [new Date(node.submittedAt)] : []
3506
+ (node) => node?.author?.login === user && node.submittedAt ? [{ at: new Date(node.submittedAt), state: node.state }] : []
3472
3507
  );
3473
3508
  if (requests.length === 0) {
3474
3509
  if (reviews.length === 0) {
3475
3510
  return [{ kind: "inaccessible", pr }];
3476
3511
  }
3477
- return [{ kind: "unrequested", pr, reviewedAt: new Date(Math.max(...reviews.map((review) => review.getTime()))) }];
3512
+ return [
3513
+ { kind: "unrequested", pr, reviewedAt: new Date(Math.max(...reviews.map((review) => review.at.getTime()))) }
3514
+ ];
3478
3515
  }
3479
3516
  const events = [
3480
3517
  ...requests.map((at) => {
3481
- return { at, isRequest: true };
3518
+ return { at, isRequest: true, state: "" };
3482
3519
  }),
3483
- ...reviews.map((at) => {
3484
- return { at, isRequest: false };
3520
+ ...reviews.map(({ at, state }) => {
3521
+ return { at, isRequest: false, state };
3485
3522
  })
3486
3523
  ].toSorted((a, b) => a.at.getTime() - b.at.getTime() || Number(b.isRequest) - Number(a.isRequest));
3487
3524
  const results = [];
@@ -3490,7 +3527,7 @@ function classifyPr(pr, details, user) {
3490
3527
  if (event.isRequest) {
3491
3528
  openedAt ??= event.at;
3492
3529
  } else if (openedAt !== null) {
3493
- results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at });
3530
+ results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at, verdict: event.state });
3494
3531
  openedAt = null;
3495
3532
  }
3496
3533
  }
@@ -3619,6 +3656,12 @@ function loadSnapshot(options) {
3619
3656
  if (data.reviewResults.some((result) => result.kind === "unrequested" && Number.isNaN(result.reviewedAt.getTime()))) {
3620
3657
  return null;
3621
3658
  }
3659
+ const hasMissingVerdict = data.reviewResults.some((result) => {
3660
+ return result.kind === "reviewed" && result.verdict === void 0;
3661
+ });
3662
+ if (hasMissingVerdict) {
3663
+ return null;
3664
+ }
3622
3665
  if (sinceIso === data.sinceIso) {
3623
3666
  return data;
3624
3667
  }
@@ -4002,6 +4045,137 @@ function hbar(fraction, width, color) {
4002
4045
  return line;
4003
4046
  }
4004
4047
 
4048
+ // src/tui/views/charts/bars.ts
4049
+ var BAR_WIDTH2 = 24;
4050
+ var MAX_BARS = 8;
4051
+ function buildBarsCard({ title, subtitle, rows, format }) {
4052
+ const shown = rows.slice(0, MAX_BARS);
4053
+ const max = Math.max(...shown.map((row) => row.value), 0);
4054
+ const labelWidth = Math.max(...shown.map((row) => row.label.length));
4055
+ const valueWidth = Math.max(...shown.map((row) => format(row.value).length));
4056
+ const lines = shown.map((row) => {
4057
+ const line = [{ text: `${row.label.padEnd(labelWidth)} `, fg: theme.muted }];
4058
+ if (row.value <= 0 || max <= 0) {
4059
+ line.push({ text: " ".repeat(BAR_WIDTH2) });
4060
+ } else {
4061
+ line.push(...hbar(row.value / max, BAR_WIDTH2, row.value === max ? theme.accent : theme.chartBar));
4062
+ }
4063
+ line.push(
4064
+ { text: ` ${format(row.value).padStart(valueWidth)}`, fg: theme.text },
4065
+ { text: ` ${row.detail}`, fg: theme.dim }
4066
+ );
4067
+ return line;
4068
+ });
4069
+ if (rows.length > shown.length) {
4070
+ lines.push([{ text: `+ ${rows.length - shown.length} more`, fg: theme.dim }]);
4071
+ }
4072
+ return { title, subtitle, lines };
4073
+ }
4074
+
4075
+ // src/tui/views/charts/weeks.ts
4076
+ var DAY_MS = 864e5;
4077
+ var WEEK_MS = 7 * DAY_MS;
4078
+ function mondayOf(dayUtcMs) {
4079
+ return dayUtcMs - (new Date(dayUtcMs).getUTCDay() + 6) % 7 * DAY_MS;
4080
+ }
4081
+ function dateLabel(ms) {
4082
+ return new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
4083
+ }
4084
+ function weekAxisRow(width, prefix, points, columnOf, mondayOfPoint) {
4085
+ const cells = blankCells(width);
4086
+ const every = Math.ceil(points / 4);
4087
+ let lastEnd = -2;
4088
+ for (let point = 0; point < points; point += every) {
4089
+ const label = dateLabel(mondayOfPoint(point));
4090
+ const at = prefix + columnOf(point);
4091
+ if (at >= lastEnd + 2 && at + label.length <= width) {
4092
+ placeText(cells, at, label, theme.dim);
4093
+ lastEnd = at + label.length;
4094
+ }
4095
+ }
4096
+ return mergeCells(cells);
4097
+ }
4098
+
4099
+ // src/tui/views/charts/cumulative.ts
4100
+ var CUM_WIDTH = 36;
4101
+ var CUM_HEIGHT = 8;
4102
+ function weekOf(date) {
4103
+ return mondayOf(zonedStamp(date).dayUtcMs);
4104
+ }
4105
+ function buildCumulativeCard({ title, series, legend }) {
4106
+ const subtitle = series.flatMap((entry, i) => [
4107
+ { text: `${i === 0 ? "" : " "}\u2500\u2500`, fg: entry.color },
4108
+ { text: ` ${entry.label}`, fg: theme.muted }
4109
+ ]);
4110
+ subtitle.push({ text: `, ${legend}`, fg: theme.muted });
4111
+ const mondays = series.flatMap((entry) => entry.dates.map((date) => weekOf(date)));
4112
+ const first = Math.min(...mondays);
4113
+ const weekCount = (Math.max(...mondays) - first) / WEEK_MS + 1;
4114
+ if (weekCount < 2) {
4115
+ return { title, subtitle, lines: [[{ text: "not enough weeks to draw a trend", fg: theme.muted }]] };
4116
+ }
4117
+ const totals = series.map((entry) => {
4118
+ const weekly = Array.from({ length: weekCount }, () => 0);
4119
+ for (const date of entry.dates) {
4120
+ weekly[(weekOf(date) - first) / WEEK_MS] += 1;
4121
+ }
4122
+ let running = 0;
4123
+ return weekly.map((count2) => running += count2);
4124
+ });
4125
+ const maxY = Math.max(...totals.map((cumulative) => cumulative.at(-1) ?? 0), 1);
4126
+ const grid = Array.from(
4127
+ { length: CUM_HEIGHT },
4128
+ () => Array.from({ length: CUM_WIDTH }, () => null)
4129
+ );
4130
+ for (const [i, cumulative] of totals.entries()) {
4131
+ const rows = Array.from({ length: CUM_WIDTH }, (_, x) => {
4132
+ const weekPos = x / (CUM_WIDTH - 1) * (weekCount - 1);
4133
+ const week = Math.floor(weekPos);
4134
+ const nextWeek = Math.min(week + 1, weekCount - 1);
4135
+ const value2 = cumulative[week] + (cumulative[nextWeek] - cumulative[week]) * (weekPos - week);
4136
+ return CUM_HEIGHT - 1 - Math.round(value2 / maxY * (CUM_HEIGHT - 1));
4137
+ });
4138
+ const color = series[i].color;
4139
+ for (let x = 0; x < CUM_WIDTH; x++) {
4140
+ const here = rows[x];
4141
+ const next = rows[Math.min(x + 1, CUM_WIDTH - 1)];
4142
+ if (here === next) {
4143
+ grid[here][x] = { ch: "\u2500", fg: color };
4144
+ continue;
4145
+ }
4146
+ grid[next][x] = { ch: next < here ? "\u256D" : "\u2570", fg: color };
4147
+ grid[here][x] = { ch: next < here ? "\u256F" : "\u256E", fg: color };
4148
+ for (let row = Math.min(here, next) + 1; row < Math.max(here, next); row++) {
4149
+ grid[row][x] = { ch: "\u2502", fg: color };
4150
+ }
4151
+ }
4152
+ }
4153
+ const topLabel = String(maxY);
4154
+ const yWidth = topLabel.length;
4155
+ const lines = grid.map((row, i) => {
4156
+ const label = i === 0 ? topLabel : i === CUM_HEIGHT - 1 ? "0" : "";
4157
+ const line = [
4158
+ { text: `${label.padStart(yWidth)} `, fg: theme.muted },
4159
+ { text: label === "" ? "\u2502" : "\u2524", fg: theme.dim }
4160
+ ];
4161
+ for (const cell of row) {
4162
+ line.push(cell === null ? { text: " " } : { text: cell.ch, fg: cell.fg });
4163
+ }
4164
+ return line;
4165
+ });
4166
+ lines.push(
4167
+ [{ text: `${" ".repeat(yWidth)} \u2514${"\u2500".repeat(CUM_WIDTH)}`, fg: theme.dim }],
4168
+ weekAxisRow(
4169
+ yWidth + 2 + CUM_WIDTH,
4170
+ yWidth + 2,
4171
+ weekCount,
4172
+ (week) => Math.round(week / (weekCount - 1) * (CUM_WIDTH - 1)),
4173
+ (week) => first + week * WEEK_MS
4174
+ )
4175
+ );
4176
+ return { title, subtitle, lines };
4177
+ }
4178
+
4005
4179
  // src/tui/views/charts/distribution.ts
4006
4180
  var DURATION_TICKS = [5 / 60, 0.25, 0.5, 1, 2, 4, 8, 24, 48, 96, 168, 336, 720, 2160];
4007
4181
  var COUNT_TICKS = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1e3, 2500, 5e3, 1e4, 25e3, 5e4, 1e5];
@@ -4322,39 +4496,20 @@ function buildSpreadCard(title, metrics, format) {
4322
4496
 
4323
4497
  // src/tui/views/charts/trend.ts
4324
4498
  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
4499
  var TREND_POINTS = 40;
4352
4500
  var TREND_HEIGHT = 6;
4353
4501
  function axisSplit(line) {
4354
4502
  const positions = [line.indexOf("\u2524"), line.indexOf("\u253C")].filter((at) => at >= 0);
4355
4503
  return positions.length === 0 ? 0 : Math.min(...positions) + 1;
4356
4504
  }
4357
- function buildTrendCard({ title, entries, format, floor }) {
4505
+ function buildTrendCard({
4506
+ title,
4507
+ entries,
4508
+ format,
4509
+ floor = 1,
4510
+ scale = "log",
4511
+ valueLabel = "median"
4512
+ }) {
4358
4513
  const byWeek = /* @__PURE__ */ new Map();
4359
4514
  for (const entry of entries) {
4360
4515
  const monday = mondayOf(zonedStamp(entry.date).dayUtcMs);
@@ -4365,10 +4520,11 @@ function buildTrendCard({ title, entries, format, floor }) {
4365
4520
  const mondays = [...byWeek.keys()];
4366
4521
  const first = Math.min(...mondays);
4367
4522
  const weekCount = (Math.max(...mondays) - first) / WEEK_MS + 1;
4523
+ const scaleSuffix = scale === "log" ? ", log scale" : "";
4368
4524
  if (weekCount < 2) {
4369
4525
  return {
4370
4526
  title,
4371
- subtitle: "weekly median, log scale",
4527
+ subtitle: `weekly ${valueLabel}${scaleSuffix}`,
4372
4528
  lines: [[{ text: "not enough weeks to draw a trend", fg: theme.muted }]]
4373
4529
  };
4374
4530
  }
@@ -4386,9 +4542,11 @@ function buildTrendCard({ title, entries, format, floor }) {
4386
4542
  medians.push(medians.at(-1) ?? 0);
4387
4543
  }
4388
4544
  }
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;
4545
+ if (scale === "log") {
4546
+ const firstKnown = medians.find((value2) => value2 > 0) ?? 0;
4547
+ for (let i = 0; i < medians.length && medians[i] === 0; i++) {
4548
+ medians[i] = firstKnown;
4549
+ }
4392
4550
  }
4393
4551
  const chunk = Math.ceil(weekCount / TREND_POINTS);
4394
4552
  const points = [];
@@ -4398,23 +4556,23 @@ function buildTrendCard({ title, entries, format, floor }) {
4398
4556
  }
4399
4557
  const stretch = Math.max(1, Math.floor(TREND_POINTS / points.length));
4400
4558
  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);
4559
+ const plotted = scale === "log" ? series.map((value2) => Math.log2(Math.max(value2, floor))) : series;
4560
+ let lo = Math.min(...plotted);
4561
+ let hi = Math.max(...plotted);
4404
4562
  if (hi - lo < 1e-9) {
4405
4563
  lo -= 1;
4406
4564
  hi += 1;
4407
4565
  }
4408
- const chart = asciichart.plot(logs, {
4566
+ const chart = asciichart.plot(plotted, {
4409
4567
  height: TREND_HEIGHT,
4410
4568
  min: lo,
4411
4569
  max: hi,
4412
- format: (x) => format(2 ** x).padStart(6)
4570
+ format: (x) => format(scale === "log" ? 2 ** x : x).padStart(6)
4413
4571
  });
4414
4572
  const ratio = TREND_HEIGHT / (hi - lo);
4415
4573
  const min2 = Math.round(lo * ratio);
4416
4574
  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)));
4575
+ const lastRow = Math.min(rows, Math.max(0, rows - (Math.round((plotted.at(-1) ?? 0) * ratio) - min2)));
4418
4576
  const raw = chart.split("\n");
4419
4577
  const lines = raw.map((line, i) => {
4420
4578
  const split = axisSplit(line);
@@ -4437,7 +4595,7 @@ function buildTrendCard({ title, entries, format, floor }) {
4437
4595
  (point) => first + point * chunk * WEEK_MS
4438
4596
  )
4439
4597
  );
4440
- const subtitle = chunk === 1 ? "weekly median, log scale" : `median per ${chunk} weeks, log scale`;
4598
+ const subtitle = chunk === 1 ? `weekly ${valueLabel}${scaleSuffix}` : `${valueLabel} per ${chunk} weeks${scaleSuffix}`;
4441
4599
  return { title, subtitle, lines };
4442
4600
  }
4443
4601
 
@@ -4505,6 +4663,79 @@ function countCell(count2, label, dimWhenZero = false) {
4505
4663
  { text: ` ${label}`, fg: dim2 ? theme.dim : theme.muted }
4506
4664
  ];
4507
4665
  }
4666
+ function mondayNoon(monday) {
4667
+ return new Date(monday + 12 * 36e5);
4668
+ }
4669
+ function weeklySums(entries) {
4670
+ const byWeek = /* @__PURE__ */ new Map();
4671
+ for (const entry of entries) {
4672
+ const monday = mondayOf(zonedStamp(entry.date).dayUtcMs);
4673
+ byWeek.set(monday, (byWeek.get(monday) ?? 0) + entry.value);
4674
+ }
4675
+ const mondays = [...byWeek.keys()];
4676
+ const first = Math.min(...mondays);
4677
+ const last = Math.max(...mondays);
4678
+ const result = [];
4679
+ for (let monday = first; monday <= last; monday += WEEK_MS) {
4680
+ result.push({ date: mondayNoon(monday), value: byWeek.get(monday) ?? 0 });
4681
+ }
4682
+ return result;
4683
+ }
4684
+ function mergeRateEntries(stats) {
4685
+ const byWeek = /* @__PURE__ */ new Map();
4686
+ const add = (createdAt, merged) => {
4687
+ const monday = mondayOf(zonedStamp(createdAt).dayUtcMs);
4688
+ const counts = byWeek.get(monday) ?? { merged: 0, concluded: 0 };
4689
+ counts.concluded += 1;
4690
+ counts.merged += merged ? 1 : 0;
4691
+ byWeek.set(monday, counts);
4692
+ };
4693
+ for (const result of stats.merged) {
4694
+ add(result.entry.pr.createdAt, true);
4695
+ }
4696
+ for (const result of stats.closed) {
4697
+ add(result.entry.pr.createdAt, false);
4698
+ }
4699
+ return [...byWeek.entries()].map(([monday, counts]) => {
4700
+ return { date: mondayNoon(monday), value: counts.merged / counts.concluded * 100 };
4701
+ });
4702
+ }
4703
+ function buildOffHoursCard(subtitle, dates) {
4704
+ const counts = { work: 0, after: 0, weekend: 0 };
4705
+ for (const date of dates) {
4706
+ counts[classifyInstant(date)] += 1;
4707
+ }
4708
+ const rows = hasWorkWindows() ? [
4709
+ { label: "work hours", count: counts.work, color: theme.accent },
4710
+ { label: "after hours", count: counts.after, color: theme.warn },
4711
+ { label: "weekend", count: counts.weekend, color: theme.chartDim }
4712
+ ] : [
4713
+ { label: "weekday", count: counts.work + counts.after, color: theme.accent },
4714
+ { label: "weekend", count: counts.weekend, color: theme.chartDim }
4715
+ ];
4716
+ return buildGaugeCard({ title: "Off-hours share", subtitle, rows });
4717
+ }
4718
+ function buildVerdictCard(reviewed) {
4719
+ const countOf = (state) => reviewed.filter((entry) => entry.verdict === state).length;
4720
+ const approved = countOf("APPROVED");
4721
+ const changes = countOf("CHANGES_REQUESTED");
4722
+ const commented = countOf("COMMENTED");
4723
+ const other = reviewed.length - approved - changes - commented;
4724
+ const counts = [
4725
+ { label: "approved", count: approved },
4726
+ { label: "changes requested", count: changes },
4727
+ { label: "commented", count: commented },
4728
+ ...other > 0 ? [{ label: "other", count: other }] : []
4729
+ ];
4730
+ const max = Math.max(...counts.map((row) => row.count));
4731
+ return buildGaugeCard({
4732
+ title: "Review verdicts",
4733
+ subtitle: "how your requested reviews concluded",
4734
+ rows: counts.map((row) => {
4735
+ return { ...row, color: row.count === max ? theme.accent : theme.chartBar };
4736
+ })
4737
+ });
4738
+ }
4508
4739
  function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100) {
4509
4740
  const results = repo === null ? raw.reviewResults : raw.reviewResults.filter((result) => result.pr.repo === repo);
4510
4741
  const stats = computeReviewStats(results, { targetHours, now: raw.fetchedAt });
@@ -4519,8 +4750,7 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4519
4750
  headline: null,
4520
4751
  distributionTitle: "Review time distribution",
4521
4752
  noCharts: "No completed reviews to chart.",
4522
- left: [],
4523
- right: [],
4753
+ cards: [],
4524
4754
  distribution: null
4525
4755
  };
4526
4756
  if (results.length === 0) {
@@ -4533,8 +4763,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4533
4763
  rows: toPrRows(stats.misses, stats.misses.map(durationLead))
4534
4764
  });
4535
4765
  }
4766
+ const pendingCard = stats.pending.length === 0 ? null : buildHistogramCard({
4767
+ title: "Pending request age",
4768
+ subtitle: "how long open requests have waited",
4769
+ values: stats.pending.map((entry) => entry.hours),
4770
+ buckets: currentBuckets(),
4771
+ format: formatDuration
4772
+ });
4536
4773
  if (stats.reviewed.length === 0) {
4537
- return { empty: null, ...base, lists };
4774
+ return { empty: null, ...base, cards: pendingCard === null ? [] : [pendingCard], lists };
4538
4775
  }
4539
4776
  const sorted = [...stats.allHours].toSorted((a, b) => a - b);
4540
4777
  const total = raw.reviewResults.filter((result) => result.kind === "reviewed").length;
@@ -4547,7 +4784,39 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4547
4784
  ];
4548
4785
  const requestDates = [...stats.reviewed, ...stats.pending].map((entry) => entry.requestedAt);
4549
4786
  const reviewDates = stats.reviewed.map((entry) => entry.reviewedAt);
4550
- const left = [
4787
+ const requestAges = [...stats.reviewed, ...stats.pending].map(
4788
+ (entry) => durationHours(entry.pr.createdAt, entry.requestedAt)
4789
+ );
4790
+ let serviceCard = null;
4791
+ if (targetHours !== void 0 && targetLabel !== void 0) {
4792
+ const inside = stats.allHours.filter((value2) => value2 <= targetHours).length;
4793
+ const overdue = stats.pending.filter((entry) => entry.hours > targetHours).length;
4794
+ serviceCard = buildGaugeCard({
4795
+ title: "Service level",
4796
+ subtitle: `reviewed within ${targetLabel}`,
4797
+ rows: [
4798
+ { label: `inside ${targetLabel}`, count: inside, color: theme.accent },
4799
+ { label: `over ${targetLabel}`, count: stats.allHours.length - inside, color: theme.chartDim },
4800
+ ...overdue > 0 ? [{ label: "awaiting and already over", count: overdue, color: theme.warn }] : []
4801
+ ]
4802
+ });
4803
+ }
4804
+ const byRepoCard = repo === null && stats.byRepo.length > 1 ? buildBarsCard({
4805
+ title: "Review time by repo",
4806
+ subtitle: "median review time, slowest first",
4807
+ rows: stats.byRepo.map(([name, hours]) => {
4808
+ return {
4809
+ label: name,
4810
+ value: percentile(
4811
+ hours.toSorted((a, b) => a - b),
4812
+ 50
4813
+ ),
4814
+ detail: `n=${hours.length}`
4815
+ };
4816
+ }).toSorted((a, b) => b.value - a.value),
4817
+ format: formatDuration
4818
+ }) : null;
4819
+ const cards = [
4551
4820
  buildHistogramCard({
4552
4821
  title: "Time to review",
4553
4822
  subtitle: "elapsed time, request \u2192 review",
@@ -4555,6 +4824,14 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4555
4824
  buckets: currentBuckets(),
4556
4825
  format: formatDuration
4557
4826
  }),
4827
+ buildTrendCard({
4828
+ title: "Review time trend",
4829
+ entries: stats.reviewed.map((entry) => {
4830
+ return { date: entry.reviewedAt, value: entry.hours };
4831
+ }),
4832
+ format: formatDuration,
4833
+ floor: 1 / 60
4834
+ }),
4558
4835
  buildHeatmapCard({
4559
4836
  title: "When you review",
4560
4837
  subtitle: "reviews submitted, weekday \xD7 hour, local time",
@@ -4564,34 +4841,28 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4564
4841
  { label: "req", dates: requestDates, muted: true }
4565
4842
  ],
4566
4843
  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
4844
  }),
4578
- buildVolumeCard("Reviews completed per week", reviewDates)
4845
+ buildVolumeCard("Reviews completed per week", reviewDates),
4846
+ buildHistogramCard({
4847
+ title: "Review cycles per PR",
4848
+ subtitle: "completed request \u2192 review rounds per PR",
4849
+ values: stats.cycles,
4850
+ buckets: CYCLE_BUCKETS,
4851
+ format: count
4852
+ }),
4853
+ buildHistogramCard({
4854
+ title: "PR age at request",
4855
+ subtitle: "elapsed time, PR created \u2192 review requested",
4856
+ values: requestAges,
4857
+ buckets: currentBuckets(),
4858
+ format: formatDuration
4859
+ }),
4860
+ buildVerdictCard(stats.reviewed),
4861
+ ...pendingCard === null ? [] : [pendingCard],
4862
+ buildOffHoursCard("reviews submitted, local time", reviewDates),
4863
+ ...serviceCard === null ? [] : [serviceCard],
4864
+ ...byRepoCard === null ? [] : [byRepoCard]
4579
4865
  ];
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
4866
  const distribution = buildDistribution({
4596
4867
  values: stats.allHours,
4597
4868
  width: Math.max(width - 3, 40),
@@ -4599,7 +4870,7 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4599
4870
  ticks: DURATION_TICKS,
4600
4871
  flat: (count2, value2) => `all ${count2} ${count2 === 1 ? "review" : "reviews"} took ${value2}`
4601
4872
  });
4602
- return { empty: null, ...base, headline, left, right, distribution, lists };
4873
+ return { empty: null, ...base, headline, cards, distribution, lists };
4603
4874
  }
4604
4875
  function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4605
4876
  const base = {
@@ -4607,8 +4878,7 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4607
4878
  headline: null,
4608
4879
  distributionTitle: "PR size distribution",
4609
4880
  noCharts: "No authored PRs to chart.",
4610
- left: [],
4611
- right: [],
4881
+ cards: [],
4612
4882
  distribution: null,
4613
4883
  lists: []
4614
4884
  };
@@ -4639,7 +4909,15 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4639
4909
  { text: `${count(percentile(sorted, 90))} lines`, fg: theme.accent },
4640
4910
  { text: ` ${sizes.length} of ${raw.sizes.length} PRs`, fg: theme.muted }
4641
4911
  ];
4642
- const left = [
4912
+ const targetCard = stats.met !== void 0 && stats.targetLabel !== void 0 ? buildGaugeCard({
4913
+ title: "Size target",
4914
+ subtitle: `authored within ${stats.targetLabel}`,
4915
+ rows: [
4916
+ { label: "inside target", count: stats.met, color: theme.accent },
4917
+ { label: "over target", count: sizes.length - stats.met, color: theme.chartDim }
4918
+ ]
4919
+ }) : null;
4920
+ const cards = [
4643
4921
  buildHistogramCard({
4644
4922
  title: "PR size",
4645
4923
  subtitle: "total lines changed per authored PR",
@@ -4647,6 +4925,14 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4647
4925
  buckets: LINE_BUCKETS,
4648
4926
  format: count
4649
4927
  }),
4928
+ buildTrendCard({
4929
+ title: "PR size trend",
4930
+ entries: sizes.map((size) => {
4931
+ return { date: size.pr.createdAt, value: size.total };
4932
+ }),
4933
+ format: count,
4934
+ floor: 1
4935
+ }),
4650
4936
  buildHistogramCard({
4651
4937
  title: "Files touched",
4652
4938
  subtitle: "files changed per authored PR",
@@ -4654,38 +4940,28 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4654
4940
  buckets: FILE_BUCKETS,
4655
4941
  format: count
4656
4942
  }),
4943
+ buildTrendCard({
4944
+ title: "Net lines trend",
4945
+ entries: weeklySums(
4946
+ sizes.map((size) => {
4947
+ return { date: size.pr.createdAt, value: size.additions - size.deletions };
4948
+ })
4949
+ ),
4950
+ format: netCount,
4951
+ scale: "linear",
4952
+ valueLabel: "net lines"
4953
+ }),
4657
4954
  buildHeatmapCard({
4658
4955
  title: "When you open PRs",
4659
4956
  subtitle: "PRs opened, weekday \xD7 hour, local time",
4660
4957
  grid: created,
4661
4958
  columns: [{ label: "opened", dates: created }],
4662
4959
  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
4960
  }),
4674
4961
  buildVolumeCard("PRs opened per week", created),
4962
+ ...targetCard === null ? [] : [targetCard],
4675
4963
  buildSpreadCard("Size spread", stats.metrics, count)
4676
4964
  ];
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
4965
  const distribution = buildDistribution({
4690
4966
  values: totals,
4691
4967
  width: Math.max(width - 3, 40),
@@ -4703,7 +4979,7 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4703
4979
  )
4704
4980
  });
4705
4981
  }
4706
- return { empty: null, ...base, strip, headline, left, right, distribution, lists };
4982
+ return { empty: null, ...base, strip, headline, cards, distribution, lists };
4707
4983
  }
4708
4984
  function buildCommentView(raw, repo = null, width = 100) {
4709
4985
  const base = {
@@ -4711,8 +4987,7 @@ function buildCommentView(raw, repo = null, width = 100) {
4711
4987
  headline: null,
4712
4988
  distributionTitle: "Comments per PR distribution",
4713
4989
  noCharts: "No authored PRs to chart.",
4714
- left: [],
4715
- right: [],
4990
+ cards: [],
4716
4991
  distribution: null,
4717
4992
  lists: []
4718
4993
  };
@@ -4742,7 +5017,7 @@ function buildCommentView(raw, repo = null, width = 100) {
4742
5017
  { text: `${count(percentile(sorted, 90))} comments`, fg: theme.accent },
4743
5018
  { text: ` ${sizes.length} of ${raw.sizes.length} PRs`, fg: theme.muted }
4744
5019
  ];
4745
- const left = [
5020
+ const cards = [
4746
5021
  buildHistogramCard({
4747
5022
  title: "Comments per PR",
4748
5023
  subtitle: "discussion plus review comments per authored PR",
@@ -4750,6 +5025,14 @@ function buildCommentView(raw, repo = null, width = 100) {
4750
5025
  buckets: COMMENT_BUCKETS,
4751
5026
  format: count
4752
5027
  }),
5028
+ buildTrendCard({
5029
+ title: "Comment trend",
5030
+ entries: sizes.map((size) => {
5031
+ return { date: size.pr.createdAt, value: size.comments.total };
5032
+ }),
5033
+ format: count,
5034
+ floor: 1
5035
+ }),
4753
5036
  buildScatterCard({
4754
5037
  title: "Comments vs size",
4755
5038
  subtitle: "comments against lines changed, log scale",
@@ -4759,18 +5042,8 @@ function buildCommentView(raw, repo = null, width = 100) {
4759
5042
  formatX: count,
4760
5043
  formatY: count
4761
5044
  }),
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
5045
  buildVolumeCard("Comments received per week", created, stats.totals),
5046
+ buildSpreadCard("Comment spread", stats.metrics, count),
4774
5047
  buildGaugeCard({
4775
5048
  title: "Feedback rate",
4776
5049
  subtitle: "authored PRs that received comments",
@@ -4800,7 +5073,7 @@ function buildCommentView(raw, repo = null, width = 100) {
4800
5073
  )
4801
5074
  });
4802
5075
  }
4803
- return { empty: null, ...base, strip, headline, left, right, distribution, lists };
5076
+ return { empty: null, ...base, strip, headline, cards, distribution, lists };
4804
5077
  }
4805
5078
  function buildMergedView(raw, repo = null, width = 100) {
4806
5079
  const base = {
@@ -4808,8 +5081,7 @@ function buildMergedView(raw, repo = null, width = 100) {
4808
5081
  headline: null,
4809
5082
  distributionTitle: "Time to merge distribution",
4810
5083
  noCharts: "No merged PRs to chart.",
4811
- left: [],
4812
- right: [],
5084
+ cards: [],
4813
5085
  distribution: null,
4814
5086
  lists: []
4815
5087
  };
@@ -4864,7 +5136,7 @@ function buildMergedView(raw, repo = null, width = 100) {
4864
5136
  ];
4865
5137
  const mergeDates = stats.merged.map((result) => result.mergedAt);
4866
5138
  const created = sizes.map((size) => size.pr.createdAt);
4867
- const left = [
5139
+ const cards = [
4868
5140
  buildHistogramCard({
4869
5141
  title: "Time to merge",
4870
5142
  subtitle: "elapsed time, created \u2192 merged",
@@ -4872,6 +5144,14 @@ function buildMergedView(raw, repo = null, width = 100) {
4872
5144
  buckets: currentBuckets(),
4873
5145
  format: formatDuration
4874
5146
  }),
5147
+ buildTrendCard({
5148
+ title: "Time to merge trend",
5149
+ entries: stats.merged.map((result) => {
5150
+ return { date: result.mergedAt, value: result.hours };
5151
+ }),
5152
+ format: formatDuration,
5153
+ floor: 1 / 60
5154
+ }),
4875
5155
  buildHeatmapCard({
4876
5156
  title: "When your PRs merge",
4877
5157
  subtitle: "PRs merged, weekday \xD7 hour, local time",
@@ -4882,6 +5162,38 @@ function buildMergedView(raw, repo = null, width = 100) {
4882
5162
  ],
4883
5163
  legend: "PRs merged in that hour"
4884
5164
  }),
5165
+ buildTrendCard({
5166
+ title: "Merge rate trend",
5167
+ entries: mergeRateEntries(stats),
5168
+ format: (value2) => `${Math.round(value2)}%`,
5169
+ scale: "linear",
5170
+ valueLabel: "merge rate"
5171
+ }),
5172
+ buildScatterCard({
5173
+ title: "Merge time vs size",
5174
+ subtitle: "time to merge against lines changed, log scale",
5175
+ points: stats.merged.map((result) => {
5176
+ return { x: result.entry.total, y: result.hours };
5177
+ }),
5178
+ formatX: count,
5179
+ formatY: formatDuration
5180
+ }),
5181
+ buildCumulativeCard({
5182
+ title: "Created vs merged",
5183
+ series: [
5184
+ /**
5185
+ * The created line stays a neutral gray because every theme preset
5186
+ * keeps chartLine and accent in one hue family, which made the two
5187
+ * lines indistinguishable. The gray also survives theme changes,
5188
+ * since the presets only rotate the hue-carrying colors.
5189
+ */
5190
+ { label: "created", dates: created, color: theme.muted },
5191
+ { label: "merged", dates: mergeDates, color: theme.accent }
5192
+ ],
5193
+ legend: "cumulative PRs by week"
5194
+ }),
5195
+ buildVolumeCard("PRs created per week", created),
5196
+ buildVolumeCard("PRs merged per week", mergeDates),
4885
5197
  buildGaugeCard({
4886
5198
  title: "Outcomes",
4887
5199
  subtitle: "where your authored PRs ended up",
@@ -4892,18 +5204,6 @@ function buildMergedView(raw, repo = null, width = 100) {
4892
5204
  ]
4893
5205
  })
4894
5206
  ];
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
- }),
4904
- buildVolumeCard("PRs merged per week", mergeDates),
4905
- buildVolumeCard("PRs created per week", created)
4906
- ];
4907
5207
  const distribution = buildDistribution({
4908
5208
  values: stats.allHours,
4909
5209
  width: Math.max(width - 3, 40),
@@ -4911,11 +5211,14 @@ function buildMergedView(raw, repo = null, width = 100) {
4911
5211
  ticks: DURATION_TICKS,
4912
5212
  flat: (n, value2) => `all ${n} merged ${n === 1 ? "PR" : "PRs"} took ${value2}`
4913
5213
  });
4914
- return { empty: null, ...base, strip, headline, left, right, distribution, lists };
5214
+ return { empty: null, ...base, strip, headline, cards, distribution, lists };
4915
5215
  }
4916
5216
  function count(value2) {
4917
5217
  return formatCount(Math.round(value2));
4918
5218
  }
5219
+ function netCount(value2) {
5220
+ return value2 < 0 ? `-${count(-value2)}` : `+${count(value2)}`;
5221
+ }
4919
5222
 
4920
5223
  // src/tui/hooks/useViewModel.ts
4921
5224
  function resolveScope(scope, repos) {
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.7",
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",