@d3lm/pr-stats 0.2.5 → 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 +10 -2
  2. package/dist/tui-app.mjs +656 -256
  3. package/package.json +1 -1
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,13 +792,32 @@ 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) });
787
800
  }
788
801
  }
789
802
  pending.sort((a, b) => a.requestedAt.getTime() - b.requestedAt.getTime());
803
+ const pendingKeys = new Set(pending.map((entry) => `${entry.pr.repo}#${entry.pr.number}`));
804
+ const latestReviews = /* @__PURE__ */ new Map();
805
+ for (const result of results) {
806
+ if (result.kind !== "reviewed" && result.kind !== "unrequested" || result.pr.state !== "open") {
807
+ continue;
808
+ }
809
+ const key = `${result.pr.repo}#${result.pr.number}`;
810
+ if (pendingKeys.has(key)) {
811
+ continue;
812
+ }
813
+ const latest = latestReviews.get(key);
814
+ if (latest === void 0 || result.reviewedAt > latest.reviewedAt) {
815
+ latestReviews.set(key, { pr: result.pr, reviewedAt: result.reviewedAt });
816
+ }
817
+ }
818
+ const reviewing = [...latestReviews.values()].map(({ pr, reviewedAt }) => {
819
+ return { pr, reviewedAt, hours: durationHours(reviewedAt, now) };
820
+ }).toSorted((a, b) => a.reviewedAt.getTime() - b.reviewedAt.getTime());
790
821
  const expired = results.filter((result) => result.kind === "pending" && result.pr.state !== "open");
791
822
  const unrequested = results.filter((result) => result.kind === "unrequested");
792
823
  const allHours = reviewed.map((result) => result.hours);
@@ -798,7 +829,22 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
798
829
  }
799
830
  const byRepo = [...hoursByRepo.entries()].toSorted((a, b) => b[1].length - a[1].length);
800
831
  const misses = targetHours === void 0 ? [] : reviewed.filter((result) => result.hours > targetHours).toSorted((a, b) => b.hours - a.hours);
801
- return { reviewed, pending, 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
+ };
802
848
  }
803
849
  function computeSizeStats(sizes, { sizeTarget } = {}) {
804
850
  const metrics = [
@@ -900,6 +946,13 @@ var FILE_BUCKETS = [
900
946
  { label: "21-50", max: 51 },
901
947
  { label: "> 50", max: Infinity }
902
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
+ ];
903
956
  var COMMENT_BUCKETS = [
904
957
  { label: "0", max: 1 },
905
958
  { label: "1-2", max: 3 },
@@ -948,7 +1001,7 @@ function toPrRows(entries, leads) {
948
1001
 
949
1002
  // src/tui/views/queue.ts
950
1003
  function queueRows(view) {
951
- return view.lists.flatMap((list) => list.rows);
1004
+ return view.sections.flatMap((section) => [...section.rows, ...section.lists.flatMap((list) => list.rows)]);
952
1005
  }
953
1006
  function groupedLists(entries, rowsOf2) {
954
1007
  const groups = /* @__PURE__ */ new Map();
@@ -962,23 +1015,26 @@ function groupedLists(entries, rowsOf2) {
962
1015
  });
963
1016
  }
964
1017
  function buildPendingReviewView(raw, repo = null, grouped = false) {
965
- const { pending } = computeReviewStats(raw.reviewResults, { now: raw.fetchedAt });
966
- const entries = repo === null ? pending : pending.filter((entry) => entry.pr.repo === repo);
967
- if (entries.length === 0) {
968
- return { empty: "No PRs are awaiting your review.", lists: [] };
969
- }
970
- if (repo === null && grouped) {
971
- return { empty: null, lists: groupedLists(entries, rowsOf) };
972
- }
1018
+ const stats = computeReviewStats(raw.reviewResults, { now: raw.fetchedAt });
1019
+ const awaiting = repo === null ? stats.pending : stats.pending.filter((entry) => entry.pr.repo === repo);
1020
+ const reviewing = repo === null ? stats.reviewing : stats.reviewing.filter((entry) => entry.pr.repo === repo);
1021
+ if (awaiting.length === 0 && reviewing.length === 0) {
1022
+ return { empty: "No PRs are awaiting your review, and none you reviewed are still open.", sections: [] };
1023
+ }
1024
+ const split = repo === null && grouped;
1025
+ const sectionOf = (title, entries) => split ? { title, rows: [], lists: groupedLists(entries, rowsOf) } : { title, rows: rowsOf(entries), lists: [] };
973
1026
  return {
974
1027
  empty: null,
975
- lists: [{ title: `Open and awaiting your review (n=${entries.length})`, rows: rowsOf(entries) }]
1028
+ sections: [
1029
+ ...awaiting.length === 0 ? [] : [sectionOf(`Awaiting your review (n=${awaiting.length})`, awaiting)],
1030
+ ...reviewing.length === 0 ? [] : [sectionOf(`Reviewing (n=${reviewing.length})`, reviewing)]
1031
+ ]
976
1032
  };
977
1033
  }
978
1034
  function buildOpenAuthoredView(raw, repo = null, grouped = false) {
979
1035
  const open = raw.sizes.filter((entry) => entry.pr.state === "open" && (repo === null || entry.pr.repo === repo)).toSorted((a, b) => a.pr.createdAt.getTime() - b.pr.createdAt.getTime());
980
1036
  if (open.length === 0) {
981
- return { empty: "No open authored PRs found.", lists: [] };
1037
+ return { empty: "No open authored PRs found.", sections: [] };
982
1038
  }
983
1039
  const rowsOf2 = (group) => {
984
1040
  const ages = group.map((entry) => durationLead({ hours: durationHours(entry.pr.createdAt, raw.fetchedAt) }));
@@ -990,13 +1046,11 @@ function buildOpenAuthoredView(raw, repo = null, grouped = false) {
990
1046
  )
991
1047
  );
992
1048
  };
1049
+ const title = `Your open authored PRs (n=${open.length})`;
993
1050
  if (repo === null && grouped) {
994
- return { empty: null, lists: groupedLists(open, rowsOf2) };
1051
+ return { empty: null, sections: [{ title, rows: [], lists: groupedLists(open, rowsOf2) }] };
995
1052
  }
996
- return {
997
- empty: null,
998
- lists: [{ title: `Your open authored PRs (n=${open.length})`, rows: rowsOf2(open) }]
999
- };
1053
+ return { empty: null, sections: [{ title, rows: rowsOf2(open), lists: [] }] };
1000
1054
  }
1001
1055
  function rowsOf(group) {
1002
1056
  return toPrRows(
@@ -1075,18 +1129,18 @@ function ChartsPanel({
1075
1129
  if (view.empty !== null) {
1076
1130
  return /* @__PURE__ */ jsx4("box", { flexGrow: 1, alignItems: "center", justifyContent: "center", children: /* @__PURE__ */ jsx4("text", { fg: theme.muted, children: view.empty }) });
1077
1131
  }
1078
- const leftWidth = Math.max(0, ...view.left.map(cardWidth));
1079
- const rightWidth = Math.max(0, ...view.right.map(cardWidth));
1080
- const twoColumns = view.left.length > 0 && view.right.length > 0 && width - 4 >= leftWidth + COLUMN_GAP + rightWidth;
1081
- const rows = Array.from({ length: Math.max(view.left.length, view.right.length) }, (_, i) => {
1082
- const left = view.left.at(i);
1083
- const right = view.right.at(i);
1084
- 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) };
1085
1139
  });
1086
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: [
1087
- /* @__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 }) }),
1088
1142
  /* @__PURE__ */ jsx4("box", { flexDirection: "column", width: rightWidth, flexShrink: 0, children: row.right !== void 0 && /* @__PURE__ */ jsx4(ChartCard, { card: row.right }) })
1089
- ] }, 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)) });
1090
1144
  return /* @__PURE__ */ jsxs3("box", { flexGrow: 1, flexDirection: "column", children: [
1091
1145
  /* @__PURE__ */ jsx4("box", { height: 1, children: /* @__PURE__ */ jsx4("text", { wrapMode: "none", fg: theme.border, children: "\u2500".repeat(width) }) }),
1092
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)) }),
@@ -1127,7 +1181,7 @@ function ChartsPanel({
1127
1181
  ] }),
1128
1182
  /* @__PURE__ */ jsx4("box", { flexDirection: "column", marginTop: 1, children: keyed(view.distribution.lines, lineKey).map(({ key, item }) => /* @__PURE__ */ jsx4(ChartLine, { line: item }, key)) })
1129
1183
  ] }),
1130
- 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
1131
1185
  ]
1132
1186
  }
1133
1187
  )
@@ -1210,7 +1264,7 @@ function QueuePanel({
1210
1264
  heading,
1211
1265
  warning,
1212
1266
  empty,
1213
- lists,
1267
+ sections,
1214
1268
  cursor,
1215
1269
  onRefClick
1216
1270
  }) {
@@ -1237,10 +1291,57 @@ function QueuePanel({
1237
1291
  }
1238
1292
  const offsets = [];
1239
1293
  let total = 0;
1240
- for (const list of lists) {
1241
- offsets.push(total);
1242
- total += list.rows.length;
1243
- }
1294
+ for (const section of sections) {
1295
+ const entry = { rows: total, lists: [] };
1296
+ total += section.rows.length;
1297
+ for (const list of section.lists) {
1298
+ entry.lists.push(total);
1299
+ total += list.rows.length;
1300
+ }
1301
+ offsets.push(entry);
1302
+ }
1303
+ const renderRow = (row, index, indent) => {
1304
+ const isSelected = index === cursor;
1305
+ const bg = isSelected ? theme.selectedBg : void 0;
1306
+ const refStart = indent.length + 2 + row.lead.length + 2;
1307
+ return /* @__PURE__ */ jsxs5(
1308
+ "text",
1309
+ {
1310
+ id: `queue-row-${index}`,
1311
+ wrapMode: "none",
1312
+ onMouseDown: onRefClick === null ? void 0 : (event) => {
1313
+ lastDown.current = event.button === 0 ? { x: event.x, y: event.y } : null;
1314
+ },
1315
+ onMouseUp: onRefClick === null ? void 0 : (event) => {
1316
+ const down = lastDown.current;
1317
+ lastDown.current = null;
1318
+ if (event.button !== 0 || down?.x !== event.x || down.y !== event.y) {
1319
+ return;
1320
+ }
1321
+ const local = event.currentTarget === null ? -1 : event.x - event.currentTarget.x;
1322
+ if (local >= refStart && local < refStart + row.ref.length) {
1323
+ onRefClick(row);
1324
+ }
1325
+ },
1326
+ children: [
1327
+ /* @__PURE__ */ jsxs5("span", { fg: theme.accent, children: [
1328
+ indent,
1329
+ isSelected ? "\u25B8 " : " "
1330
+ ] }),
1331
+ /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1332
+ row.lead,
1333
+ " "
1334
+ ] }),
1335
+ onRefClick === null ? /* @__PURE__ */ jsx6("a", { href: row.url, fg: theme.accent, bg, children: row.ref }) : /* @__PURE__ */ jsx6("span", { fg: theme.accent, bg, children: row.ref }),
1336
+ /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1337
+ " ",
1338
+ row.title
1339
+ ] })
1340
+ ]
1341
+ },
1342
+ row.url
1343
+ );
1344
+ };
1244
1345
  return /* @__PURE__ */ jsxs5("box", { flexGrow: 1, flexDirection: "column", children: [
1245
1346
  header,
1246
1347
  /* @__PURE__ */ jsxs5(
@@ -1253,49 +1354,19 @@ function QueuePanel({
1253
1354
  verticalScrollbarOptions: overlayScrollbar,
1254
1355
  children: [
1255
1356
  warning !== null && /* @__PURE__ */ jsx6("text", { wrapMode: "word", fg: theme.warn, marginTop: 1, children: warning }),
1256
- lists.map((list, listIndex) => /* @__PURE__ */ jsxs5("box", { flexDirection: "column", marginTop: 1, children: [
1257
- /* @__PURE__ */ jsx6("text", { wrapMode: "none", fg: theme.accent, children: list.title }),
1258
- list.rows.map((row, rowIndex) => {
1259
- const index = offsets[listIndex] + rowIndex;
1260
- const isSelected = index === cursor;
1261
- const bg = isSelected ? theme.selectedBg : void 0;
1262
- const refStart = 2 + row.lead.length + 2;
1263
- return /* @__PURE__ */ jsxs5(
1264
- "text",
1265
- {
1266
- id: `queue-row-${index}`,
1267
- wrapMode: "none",
1268
- onMouseDown: onRefClick === null ? void 0 : (event) => {
1269
- lastDown.current = event.button === 0 ? { x: event.x, y: event.y } : null;
1270
- },
1271
- onMouseUp: onRefClick === null ? void 0 : (event) => {
1272
- const down = lastDown.current;
1273
- lastDown.current = null;
1274
- if (event.button !== 0 || down?.x !== event.x || down.y !== event.y) {
1275
- return;
1276
- }
1277
- const local = event.currentTarget === null ? -1 : event.x - event.currentTarget.x;
1278
- if (local >= refStart && local < refStart + row.ref.length) {
1279
- onRefClick(row);
1280
- }
1281
- },
1282
- children: [
1283
- /* @__PURE__ */ jsx6("span", { fg: theme.accent, children: isSelected ? "\u25B8 " : " " }),
1284
- /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1285
- row.lead,
1286
- " "
1287
- ] }),
1288
- onRefClick === null ? /* @__PURE__ */ jsx6("a", { href: row.url, fg: theme.accent, bg, children: row.ref }) : /* @__PURE__ */ jsx6("span", { fg: theme.accent, bg, children: row.ref }),
1289
- /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1290
- " ",
1291
- row.title
1292
- ] })
1293
- ]
1294
- },
1295
- row.url
1296
- );
1297
- })
1298
- ] }, list.title))
1357
+ sections.map((section, sectionIndex) => /* @__PURE__ */ jsxs5("box", { flexDirection: "column", marginTop: 1, children: [
1358
+ /* @__PURE__ */ jsx6("text", { wrapMode: "none", fg: theme.accent, children: section.title }),
1359
+ section.rows.map((row, rowIndex) => renderRow(row, offsets[sectionIndex].rows + rowIndex, "")),
1360
+ section.lists.map((list, listIndex) => /* @__PURE__ */ jsxs5("box", { flexDirection: "column", marginTop: listIndex > 0 ? 1 : 0, children: [
1361
+ /* @__PURE__ */ jsxs5("text", { wrapMode: "none", fg: theme.accent, children: [
1362
+ " ",
1363
+ list.title
1364
+ ] }),
1365
+ list.rows.map(
1366
+ (row, rowIndex) => renderRow(row, offsets[sectionIndex].lists[listIndex] + rowIndex, " ")
1367
+ )
1368
+ ] }, list.title))
1369
+ ] }, section.title))
1299
1370
  ]
1300
1371
  }
1301
1372
  )
@@ -1482,7 +1553,7 @@ function QueueTab({
1482
1553
  heading: repos.length > 0 ? scope.repo ?? (grouped ? "All repos \xB7 grouped by repo" : "All repos") : null,
1483
1554
  warning,
1484
1555
  empty: view.empty,
1485
- lists: view.lists,
1556
+ sections: view.sections,
1486
1557
  cursor: Math.min(rowCursor, queueRows(view).length - 1),
1487
1558
  onRefClick
1488
1559
  }
@@ -2993,6 +3064,62 @@ function ColorRow({
2993
3064
  ] }) });
2994
3065
  }
2995
3066
 
3067
+ // src/tui/components/Modals.tsx
3068
+ import { jsx as jsx14 } from "@opentui/react/jsx-runtime";
3069
+ function Modals({
3070
+ ui,
3071
+ options,
3072
+ saved: saved2,
3073
+ noCache: noCache2,
3074
+ copyLinks: copyLinks2,
3075
+ themeState,
3076
+ onDraft,
3077
+ onSubmitField,
3078
+ onSubmitThemeColor
3079
+ }) {
3080
+ if (ui.modal === "options") {
3081
+ return /* @__PURE__ */ jsx14(
3082
+ OptionsModal,
3083
+ {
3084
+ options,
3085
+ saved: saved2,
3086
+ selected: ui.selectedField,
3087
+ editing: ui.editing,
3088
+ fieldError: ui.fieldError,
3089
+ onDraft,
3090
+ onSubmit: onSubmitField
3091
+ }
3092
+ );
3093
+ }
3094
+ if (ui.modal === "settings") {
3095
+ return /* @__PURE__ */ jsx14(
3096
+ SettingsModal,
3097
+ {
3098
+ selected: ui.selectedSetting,
3099
+ cacheAction: ui.cacheAction,
3100
+ noCache: noCache2,
3101
+ copyLinks: copyLinks2,
3102
+ preset: themeState.preset
3103
+ }
3104
+ );
3105
+ }
3106
+ if (ui.modal === "theme") {
3107
+ return /* @__PURE__ */ jsx14(
3108
+ ThemeModal,
3109
+ {
3110
+ selected: ui.selectedThemeColor,
3111
+ editing: ui.editing,
3112
+ error: ui.themeColorError,
3113
+ cacheAction: ui.cacheAction,
3114
+ overrides: themeState.preset === "custom" ? themeState.overrides : {},
3115
+ onDraft,
3116
+ onSubmit: onSubmitThemeColor
3117
+ }
3118
+ );
3119
+ }
3120
+ return null;
3121
+ }
3122
+
2996
3123
  // src/tui/hooks/useDeferredLoading.ts
2997
3124
  import { useEffect as useEffect4, useRef as useRef3, useState as useState2 } from "react";
2998
3125
  function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } = {}) {
@@ -3376,17 +3503,22 @@ function classifyPr(pr, details, user) {
3376
3503
  (node) => node?.requestedReviewer?.login === user ? [new Date(node.createdAt)] : []
3377
3504
  );
3378
3505
  const reviews = details.reviews.nodes.flatMap(
3379
- (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 }] : []
3380
3507
  );
3381
3508
  if (requests.length === 0) {
3382
- return reviews.length > 0 ? [{ kind: "unrequested", pr }] : [{ kind: "inaccessible", pr }];
3509
+ if (reviews.length === 0) {
3510
+ return [{ kind: "inaccessible", pr }];
3511
+ }
3512
+ return [
3513
+ { kind: "unrequested", pr, reviewedAt: new Date(Math.max(...reviews.map((review) => review.at.getTime()))) }
3514
+ ];
3383
3515
  }
3384
3516
  const events = [
3385
3517
  ...requests.map((at) => {
3386
- return { at, isRequest: true };
3518
+ return { at, isRequest: true, state: "" };
3387
3519
  }),
3388
- ...reviews.map((at) => {
3389
- return { at, isRequest: false };
3520
+ ...reviews.map(({ at, state }) => {
3521
+ return { at, isRequest: false, state };
3390
3522
  })
3391
3523
  ].toSorted((a, b) => a.at.getTime() - b.at.getTime() || Number(b.isRequest) - Number(a.isRequest));
3392
3524
  const results = [];
@@ -3395,7 +3527,7 @@ function classifyPr(pr, details, user) {
3395
3527
  if (event.isRequest) {
3396
3528
  openedAt ??= event.at;
3397
3529
  } else if (openedAt !== null) {
3398
- results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at });
3530
+ results.push({ kind: "reviewed", pr, requestedAt: openedAt, reviewedAt: event.at, verdict: event.state });
3399
3531
  openedAt = null;
3400
3532
  }
3401
3533
  }
@@ -3492,6 +3624,9 @@ function reviveRawData(data) {
3492
3624
  if (result.kind === "reviewed") {
3493
3625
  return { ...result, pr, requestedAt: new Date(result.requestedAt), reviewedAt: new Date(result.reviewedAt) };
3494
3626
  }
3627
+ if (result.kind === "unrequested") {
3628
+ return { ...result, pr, reviewedAt: new Date(result.reviewedAt) };
3629
+ }
3495
3630
  return { ...result, pr };
3496
3631
  }),
3497
3632
  sizes: data.sizes.map((entry) => {
@@ -3518,6 +3653,15 @@ function loadSnapshot(options) {
3518
3653
  return null;
3519
3654
  }
3520
3655
  const data = reviveRawData(stored.data);
3656
+ if (data.reviewResults.some((result) => result.kind === "unrequested" && Number.isNaN(result.reviewedAt.getTime()))) {
3657
+ return null;
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
+ }
3521
3665
  if (sinceIso === data.sinceIso) {
3522
3666
  return data;
3523
3667
  }
@@ -3726,24 +3870,42 @@ function buildSizeRepoOptions(raw) {
3726
3870
  })
3727
3871
  ];
3728
3872
  }
3729
- function pendingDetail(count2) {
3730
- return `${count2} ${count2 === 1 ? "PR" : "PRs"} awaiting your review`;
3873
+ function pendingDetail(counts) {
3874
+ const awaiting = `${counts.awaiting} ${counts.awaiting === 1 ? "PR" : "PRs"} awaiting your review`;
3875
+ return awaiting + (counts.reviewing > 0 ? `, ${counts.reviewing} reviewing` : "");
3731
3876
  }
3732
3877
  function buildPendingRepoOptions(raw) {
3733
- const countByRepo = /* @__PURE__ */ new Map();
3878
+ const countsByRepo = /* @__PURE__ */ new Map();
3879
+ const countsOf = (repo) => {
3880
+ const counts = countsByRepo.get(repo) ?? { awaiting: 0, reviewing: 0 };
3881
+ countsByRepo.set(repo, counts);
3882
+ return counts;
3883
+ };
3734
3884
  for (const result of raw.reviewResults) {
3735
- const pending = result.kind === "pending" && result.pr.state === "open" ? 1 : 0;
3736
- countByRepo.set(result.pr.repo, (countByRepo.get(result.pr.repo) ?? 0) + pending);
3885
+ countsOf(result.pr.repo);
3737
3886
  }
3738
- if (countByRepo.size < 2) {
3887
+ if (countsByRepo.size < 2) {
3739
3888
  return [];
3740
3889
  }
3741
- const entries = [...countByRepo.entries()].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
3742
- const total = entries.reduce((sum, [, count2]) => sum + count2, 0);
3890
+ const stats = computeReviewStats(raw.reviewResults, { now: raw.fetchedAt });
3891
+ for (const entry of stats.pending) {
3892
+ countsOf(entry.pr.repo).awaiting += 1;
3893
+ }
3894
+ for (const entry of stats.reviewing) {
3895
+ countsOf(entry.pr.repo).reviewing += 1;
3896
+ }
3897
+ const entries = [...countsByRepo.entries()].toSorted(
3898
+ (a, b) => b[1].awaiting - a[1].awaiting || b[1].reviewing - a[1].reviewing || a[0].localeCompare(b[0])
3899
+ );
3900
+ const totals = { awaiting: 0, reviewing: 0 };
3901
+ for (const [, counts] of entries) {
3902
+ totals.awaiting += counts.awaiting;
3903
+ totals.reviewing += counts.reviewing;
3904
+ }
3743
3905
  return [
3744
- { repo: null, label: "All repos", detail: pendingDetail(total) },
3745
- ...entries.map(([repo, count2]) => {
3746
- return { repo, label: repo, detail: pendingDetail(count2) };
3906
+ { repo: null, label: "All repos", detail: pendingDetail(totals) },
3907
+ ...entries.map(([repo, counts]) => {
3908
+ return { repo, label: repo, detail: pendingDetail(counts) };
3747
3909
  })
3748
3910
  ];
3749
3911
  }
@@ -3883,6 +4045,137 @@ function hbar(fraction, width, color) {
3883
4045
  return line;
3884
4046
  }
3885
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
+
3886
4179
  // src/tui/views/charts/distribution.ts
3887
4180
  var DURATION_TICKS = [5 / 60, 0.25, 0.5, 1, 2, 4, 8, 24, 48, 96, 168, 336, 720, 2160];
3888
4181
  var COUNT_TICKS = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1e3, 2500, 5e3, 1e4, 25e3, 5e4, 1e5];
@@ -4203,39 +4496,20 @@ function buildSpreadCard(title, metrics, format) {
4203
4496
 
4204
4497
  // src/tui/views/charts/trend.ts
4205
4498
  var asciichart = __toESM(require_asciichart(), 1);
4206
-
4207
- // src/tui/views/charts/weeks.ts
4208
- var DAY_MS = 864e5;
4209
- var WEEK_MS = 7 * DAY_MS;
4210
- function mondayOf(dayUtcMs) {
4211
- return dayUtcMs - (new Date(dayUtcMs).getUTCDay() + 6) % 7 * DAY_MS;
4212
- }
4213
- function dateLabel(ms) {
4214
- return new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
4215
- }
4216
- function weekAxisRow(width, prefix, points, columnOf, mondayOfPoint) {
4217
- const cells = blankCells(width);
4218
- const every = Math.ceil(points / 4);
4219
- let lastEnd = -2;
4220
- for (let point = 0; point < points; point += every) {
4221
- const label = dateLabel(mondayOfPoint(point));
4222
- const at = prefix + columnOf(point);
4223
- if (at >= lastEnd + 2 && at + label.length <= width) {
4224
- placeText(cells, at, label, theme.dim);
4225
- lastEnd = at + label.length;
4226
- }
4227
- }
4228
- return mergeCells(cells);
4229
- }
4230
-
4231
- // src/tui/views/charts/trend.ts
4232
4499
  var TREND_POINTS = 40;
4233
4500
  var TREND_HEIGHT = 6;
4234
4501
  function axisSplit(line) {
4235
4502
  const positions = [line.indexOf("\u2524"), line.indexOf("\u253C")].filter((at) => at >= 0);
4236
4503
  return positions.length === 0 ? 0 : Math.min(...positions) + 1;
4237
4504
  }
4238
- 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
+ }) {
4239
4513
  const byWeek = /* @__PURE__ */ new Map();
4240
4514
  for (const entry of entries) {
4241
4515
  const monday = mondayOf(zonedStamp(entry.date).dayUtcMs);
@@ -4246,10 +4520,11 @@ function buildTrendCard({ title, entries, format, floor }) {
4246
4520
  const mondays = [...byWeek.keys()];
4247
4521
  const first = Math.min(...mondays);
4248
4522
  const weekCount = (Math.max(...mondays) - first) / WEEK_MS + 1;
4523
+ const scaleSuffix = scale === "log" ? ", log scale" : "";
4249
4524
  if (weekCount < 2) {
4250
4525
  return {
4251
4526
  title,
4252
- subtitle: "weekly median, log scale",
4527
+ subtitle: `weekly ${valueLabel}${scaleSuffix}`,
4253
4528
  lines: [[{ text: "not enough weeks to draw a trend", fg: theme.muted }]]
4254
4529
  };
4255
4530
  }
@@ -4267,9 +4542,11 @@ function buildTrendCard({ title, entries, format, floor }) {
4267
4542
  medians.push(medians.at(-1) ?? 0);
4268
4543
  }
4269
4544
  }
4270
- const firstKnown = medians.find((value2) => value2 > 0) ?? 0;
4271
- for (let i = 0; i < medians.length && medians[i] === 0; i++) {
4272
- 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
+ }
4273
4550
  }
4274
4551
  const chunk = Math.ceil(weekCount / TREND_POINTS);
4275
4552
  const points = [];
@@ -4279,23 +4556,23 @@ function buildTrendCard({ title, entries, format, floor }) {
4279
4556
  }
4280
4557
  const stretch = Math.max(1, Math.floor(TREND_POINTS / points.length));
4281
4558
  const series = points.flatMap((value2) => Array.from({ length: stretch }, () => value2));
4282
- const logs = series.map((value2) => Math.log2(Math.max(value2, floor)));
4283
- let lo = Math.min(...logs);
4284
- 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);
4285
4562
  if (hi - lo < 1e-9) {
4286
4563
  lo -= 1;
4287
4564
  hi += 1;
4288
4565
  }
4289
- const chart = asciichart.plot(logs, {
4566
+ const chart = asciichart.plot(plotted, {
4290
4567
  height: TREND_HEIGHT,
4291
4568
  min: lo,
4292
4569
  max: hi,
4293
- format: (x) => format(2 ** x).padStart(6)
4570
+ format: (x) => format(scale === "log" ? 2 ** x : x).padStart(6)
4294
4571
  });
4295
4572
  const ratio = TREND_HEIGHT / (hi - lo);
4296
4573
  const min2 = Math.round(lo * ratio);
4297
4574
  const rows = Math.abs(Math.round(hi * ratio) - min2);
4298
- 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)));
4299
4576
  const raw = chart.split("\n");
4300
4577
  const lines = raw.map((line, i) => {
4301
4578
  const split = axisSplit(line);
@@ -4318,7 +4595,7 @@ function buildTrendCard({ title, entries, format, floor }) {
4318
4595
  (point) => first + point * chunk * WEEK_MS
4319
4596
  )
4320
4597
  );
4321
- 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}`;
4322
4599
  return { title, subtitle, lines };
4323
4600
  }
4324
4601
 
@@ -4386,6 +4663,79 @@ function countCell(count2, label, dimWhenZero = false) {
4386
4663
  { text: ` ${label}`, fg: dim2 ? theme.dim : theme.muted }
4387
4664
  ];
4388
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
+ }
4389
4739
  function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100) {
4390
4740
  const results = repo === null ? raw.reviewResults : raw.reviewResults.filter((result) => result.pr.repo === repo);
4391
4741
  const stats = computeReviewStats(results, { targetHours, now: raw.fetchedAt });
@@ -4400,8 +4750,7 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4400
4750
  headline: null,
4401
4751
  distributionTitle: "Review time distribution",
4402
4752
  noCharts: "No completed reviews to chart.",
4403
- left: [],
4404
- right: [],
4753
+ cards: [],
4405
4754
  distribution: null
4406
4755
  };
4407
4756
  if (results.length === 0) {
@@ -4414,8 +4763,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4414
4763
  rows: toPrRows(stats.misses, stats.misses.map(durationLead))
4415
4764
  });
4416
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
+ });
4417
4773
  if (stats.reviewed.length === 0) {
4418
- return { empty: null, ...base, lists };
4774
+ return { empty: null, ...base, cards: pendingCard === null ? [] : [pendingCard], lists };
4419
4775
  }
4420
4776
  const sorted = [...stats.allHours].toSorted((a, b) => a - b);
4421
4777
  const total = raw.reviewResults.filter((result) => result.kind === "reviewed").length;
@@ -4428,7 +4784,39 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4428
4784
  ];
4429
4785
  const requestDates = [...stats.reviewed, ...stats.pending].map((entry) => entry.requestedAt);
4430
4786
  const reviewDates = stats.reviewed.map((entry) => entry.reviewedAt);
4431
- 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 = [
4432
4820
  buildHistogramCard({
4433
4821
  title: "Time to review",
4434
4822
  subtitle: "elapsed time, request \u2192 review",
@@ -4436,6 +4824,14 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4436
4824
  buckets: currentBuckets(),
4437
4825
  format: formatDuration
4438
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
+ }),
4439
4835
  buildHeatmapCard({
4440
4836
  title: "When you review",
4441
4837
  subtitle: "reviews submitted, weekday \xD7 hour, local time",
@@ -4445,34 +4841,28 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4445
4841
  { label: "req", dates: requestDates, muted: true }
4446
4842
  ],
4447
4843
  legend: "reviews in that hour"
4448
- })
4449
- ];
4450
- const right = [
4451
- buildTrendCard({
4452
- title: "Review time trend",
4453
- entries: stats.reviewed.map((entry) => {
4454
- return { date: entry.reviewedAt, value: entry.hours };
4455
- }),
4456
- format: formatDuration,
4457
- floor: 1 / 60
4458
4844
  }),
4459
- 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]
4460
4865
  ];
4461
- if (targetHours !== void 0 && targetLabel !== void 0) {
4462
- const inside = stats.allHours.filter((value2) => value2 <= targetHours).length;
4463
- const overdue = stats.pending.filter((entry) => entry.hours > targetHours).length;
4464
- left.push(
4465
- buildGaugeCard({
4466
- title: "Service level",
4467
- subtitle: `reviewed within ${targetLabel}`,
4468
- rows: [
4469
- { label: `inside ${targetLabel}`, count: inside, color: theme.accent },
4470
- { label: `over ${targetLabel}`, count: stats.allHours.length - inside, color: theme.chartDim },
4471
- ...overdue > 0 ? [{ label: "awaiting and already over", count: overdue, color: theme.warn }] : []
4472
- ]
4473
- })
4474
- );
4475
- }
4476
4866
  const distribution = buildDistribution({
4477
4867
  values: stats.allHours,
4478
4868
  width: Math.max(width - 3, 40),
@@ -4480,7 +4870,7 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
4480
4870
  ticks: DURATION_TICKS,
4481
4871
  flat: (count2, value2) => `all ${count2} ${count2 === 1 ? "review" : "reviews"} took ${value2}`
4482
4872
  });
4483
- return { empty: null, ...base, headline, left, right, distribution, lists };
4873
+ return { empty: null, ...base, headline, cards, distribution, lists };
4484
4874
  }
4485
4875
  function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4486
4876
  const base = {
@@ -4488,8 +4878,7 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4488
4878
  headline: null,
4489
4879
  distributionTitle: "PR size distribution",
4490
4880
  noCharts: "No authored PRs to chart.",
4491
- left: [],
4492
- right: [],
4881
+ cards: [],
4493
4882
  distribution: null,
4494
4883
  lists: []
4495
4884
  };
@@ -4520,7 +4909,15 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4520
4909
  { text: `${count(percentile(sorted, 90))} lines`, fg: theme.accent },
4521
4910
  { text: ` ${sizes.length} of ${raw.sizes.length} PRs`, fg: theme.muted }
4522
4911
  ];
4523
- 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 = [
4524
4921
  buildHistogramCard({
4525
4922
  title: "PR size",
4526
4923
  subtitle: "total lines changed per authored PR",
@@ -4528,6 +4925,14 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4528
4925
  buckets: LINE_BUCKETS,
4529
4926
  format: count
4530
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
+ }),
4531
4936
  buildHistogramCard({
4532
4937
  title: "Files touched",
4533
4938
  subtitle: "files changed per authored PR",
@@ -4535,38 +4940,28 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4535
4940
  buckets: FILE_BUCKETS,
4536
4941
  format: count
4537
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
+ }),
4538
4954
  buildHeatmapCard({
4539
4955
  title: "When you open PRs",
4540
4956
  subtitle: "PRs opened, weekday \xD7 hour, local time",
4541
4957
  grid: created,
4542
4958
  columns: [{ label: "opened", dates: created }],
4543
4959
  legend: "PRs opened in that hour"
4544
- })
4545
- ];
4546
- const right = [
4547
- buildTrendCard({
4548
- title: "PR size trend",
4549
- entries: sizes.map((size) => {
4550
- return { date: size.pr.createdAt, value: size.total };
4551
- }),
4552
- format: count,
4553
- floor: 1
4554
4960
  }),
4555
4961
  buildVolumeCard("PRs opened per week", created),
4962
+ ...targetCard === null ? [] : [targetCard],
4556
4963
  buildSpreadCard("Size spread", stats.metrics, count)
4557
4964
  ];
4558
- if (stats.met !== void 0 && stats.targetLabel !== void 0) {
4559
- left.push(
4560
- buildGaugeCard({
4561
- title: "Size target",
4562
- subtitle: `authored within ${stats.targetLabel}`,
4563
- rows: [
4564
- { label: "inside target", count: stats.met, color: theme.accent },
4565
- { label: "over target", count: sizes.length - stats.met, color: theme.chartDim }
4566
- ]
4567
- })
4568
- );
4569
- }
4570
4965
  const distribution = buildDistribution({
4571
4966
  values: totals,
4572
4967
  width: Math.max(width - 3, 40),
@@ -4584,7 +4979,7 @@ function buildSizeView(raw, sizeTarget, repo = null, width = 100) {
4584
4979
  )
4585
4980
  });
4586
4981
  }
4587
- return { empty: null, ...base, strip, headline, left, right, distribution, lists };
4982
+ return { empty: null, ...base, strip, headline, cards, distribution, lists };
4588
4983
  }
4589
4984
  function buildCommentView(raw, repo = null, width = 100) {
4590
4985
  const base = {
@@ -4592,8 +4987,7 @@ function buildCommentView(raw, repo = null, width = 100) {
4592
4987
  headline: null,
4593
4988
  distributionTitle: "Comments per PR distribution",
4594
4989
  noCharts: "No authored PRs to chart.",
4595
- left: [],
4596
- right: [],
4990
+ cards: [],
4597
4991
  distribution: null,
4598
4992
  lists: []
4599
4993
  };
@@ -4623,7 +5017,7 @@ function buildCommentView(raw, repo = null, width = 100) {
4623
5017
  { text: `${count(percentile(sorted, 90))} comments`, fg: theme.accent },
4624
5018
  { text: ` ${sizes.length} of ${raw.sizes.length} PRs`, fg: theme.muted }
4625
5019
  ];
4626
- const left = [
5020
+ const cards = [
4627
5021
  buildHistogramCard({
4628
5022
  title: "Comments per PR",
4629
5023
  subtitle: "discussion plus review comments per authored PR",
@@ -4631,6 +5025,14 @@ function buildCommentView(raw, repo = null, width = 100) {
4631
5025
  buckets: COMMENT_BUCKETS,
4632
5026
  format: count
4633
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
+ }),
4634
5036
  buildScatterCard({
4635
5037
  title: "Comments vs size",
4636
5038
  subtitle: "comments against lines changed, log scale",
@@ -4640,18 +5042,8 @@ function buildCommentView(raw, repo = null, width = 100) {
4640
5042
  formatX: count,
4641
5043
  formatY: count
4642
5044
  }),
4643
- buildSpreadCard("Comment spread", stats.metrics, count)
4644
- ];
4645
- const right = [
4646
- buildTrendCard({
4647
- title: "Comment trend",
4648
- entries: sizes.map((size) => {
4649
- return { date: size.pr.createdAt, value: size.comments.total };
4650
- }),
4651
- format: count,
4652
- floor: 1
4653
- }),
4654
5045
  buildVolumeCard("Comments received per week", created, stats.totals),
5046
+ buildSpreadCard("Comment spread", stats.metrics, count),
4655
5047
  buildGaugeCard({
4656
5048
  title: "Feedback rate",
4657
5049
  subtitle: "authored PRs that received comments",
@@ -4681,7 +5073,7 @@ function buildCommentView(raw, repo = null, width = 100) {
4681
5073
  )
4682
5074
  });
4683
5075
  }
4684
- return { empty: null, ...base, strip, headline, left, right, distribution, lists };
5076
+ return { empty: null, ...base, strip, headline, cards, distribution, lists };
4685
5077
  }
4686
5078
  function buildMergedView(raw, repo = null, width = 100) {
4687
5079
  const base = {
@@ -4689,8 +5081,7 @@ function buildMergedView(raw, repo = null, width = 100) {
4689
5081
  headline: null,
4690
5082
  distributionTitle: "Time to merge distribution",
4691
5083
  noCharts: "No merged PRs to chart.",
4692
- left: [],
4693
- right: [],
5084
+ cards: [],
4694
5085
  distribution: null,
4695
5086
  lists: []
4696
5087
  };
@@ -4745,7 +5136,7 @@ function buildMergedView(raw, repo = null, width = 100) {
4745
5136
  ];
4746
5137
  const mergeDates = stats.merged.map((result) => result.mergedAt);
4747
5138
  const created = sizes.map((size) => size.pr.createdAt);
4748
- const left = [
5139
+ const cards = [
4749
5140
  buildHistogramCard({
4750
5141
  title: "Time to merge",
4751
5142
  subtitle: "elapsed time, created \u2192 merged",
@@ -4753,6 +5144,14 @@ function buildMergedView(raw, repo = null, width = 100) {
4753
5144
  buckets: currentBuckets(),
4754
5145
  format: formatDuration
4755
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
+ }),
4756
5155
  buildHeatmapCard({
4757
5156
  title: "When your PRs merge",
4758
5157
  subtitle: "PRs merged, weekday \xD7 hour, local time",
@@ -4763,6 +5162,38 @@ function buildMergedView(raw, repo = null, width = 100) {
4763
5162
  ],
4764
5163
  legend: "PRs merged in that hour"
4765
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),
4766
5197
  buildGaugeCard({
4767
5198
  title: "Outcomes",
4768
5199
  subtitle: "where your authored PRs ended up",
@@ -4773,18 +5204,6 @@ function buildMergedView(raw, repo = null, width = 100) {
4773
5204
  ]
4774
5205
  })
4775
5206
  ];
4776
- const right = [
4777
- buildTrendCard({
4778
- title: "Time to merge trend",
4779
- entries: stats.merged.map((result) => {
4780
- return { date: result.mergedAt, value: result.hours };
4781
- }),
4782
- format: formatDuration,
4783
- floor: 1 / 60
4784
- }),
4785
- buildVolumeCard("PRs merged per week", mergeDates),
4786
- buildVolumeCard("PRs created per week", created)
4787
- ];
4788
5207
  const distribution = buildDistribution({
4789
5208
  values: stats.allHours,
4790
5209
  width: Math.max(width - 3, 40),
@@ -4792,11 +5211,14 @@ function buildMergedView(raw, repo = null, width = 100) {
4792
5211
  ticks: DURATION_TICKS,
4793
5212
  flat: (n, value2) => `all ${n} merged ${n === 1 ? "PR" : "PRs"} took ${value2}`
4794
5213
  });
4795
- return { empty: null, ...base, strip, headline, left, right, distribution, lists };
5214
+ return { empty: null, ...base, strip, headline, cards, distribution, lists };
4796
5215
  }
4797
5216
  function count(value2) {
4798
5217
  return formatCount(Math.round(value2));
4799
5218
  }
5219
+ function netCount(value2) {
5220
+ return value2 < 0 ? `-${count(-value2)}` : `+${count(value2)}`;
5221
+ }
4800
5222
 
4801
5223
  // src/tui/hooks/useViewModel.ts
4802
5224
  function resolveScope(scope, repos) {
@@ -5351,7 +5773,7 @@ function createClipboardCopier(renderer2) {
5351
5773
  }
5352
5774
 
5353
5775
  // src/tui/App.tsx
5354
- import { jsx as jsx14, jsxs as jsxs13 } from "@opentui/react/jsx-runtime";
5776
+ import { jsx as jsx15, jsxs as jsxs13 } from "@opentui/react/jsx-runtime";
5355
5777
  function App({
5356
5778
  initial: initial2,
5357
5779
  initialSaved = null,
@@ -5473,9 +5895,9 @@ function App({
5473
5895
  });
5474
5896
  const capWarning = raw?.searchCapped ? "Warning, a search hit the 1000 result cap, so data may be incomplete. Narrow since or repos." : null;
5475
5897
  return /* @__PURE__ */ jsxs13("box", { flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg, children: [
5476
- /* @__PURE__ */ jsx14(Header, { options, raw, error, spinning: showLoad }),
5477
- /* @__PURE__ */ jsx14(TabBar, { tab: browse.tab }),
5478
- /* @__PURE__ */ jsx14(
5898
+ /* @__PURE__ */ jsx15(Header, { options, raw, error, spinning: showLoad }),
5899
+ /* @__PURE__ */ jsx15(TabBar, { tab: browse.tab }),
5900
+ /* @__PURE__ */ jsx15(
5479
5901
  MainPanel,
5480
5902
  {
5481
5903
  views,
@@ -5494,7 +5916,7 @@ function App({
5494
5916
  onRefClick: copyLinks2 ? copyRow : null
5495
5917
  }
5496
5918
  ),
5497
- /* @__PURE__ */ jsx14(
5919
+ /* @__PURE__ */ jsx15(
5498
5920
  Footer,
5499
5921
  {
5500
5922
  width,
@@ -5509,42 +5931,20 @@ function App({
5509
5931
  stale
5510
5932
  }
5511
5933
  ),
5512
- ui.modal === "options" && /* @__PURE__ */ jsx14(
5513
- OptionsModal,
5934
+ /* @__PURE__ */ jsx15(
5935
+ Modals,
5514
5936
  {
5937
+ ui,
5515
5938
  options,
5516
5939
  saved: saved2,
5517
- selected: ui.selectedField,
5518
- editing: ui.editing,
5519
- fieldError: ui.fieldError,
5520
- onDraft: (value2) => {
5521
- draftRef.current = value2;
5522
- },
5523
- onSubmit: commitField
5524
- }
5525
- ),
5526
- ui.modal === "settings" && /* @__PURE__ */ jsx14(
5527
- SettingsModal,
5528
- {
5529
- selected: ui.selectedSetting,
5530
- cacheAction: ui.cacheAction,
5531
5940
  noCache: noCache2,
5532
5941
  copyLinks: copyLinks2,
5533
- preset: themeState.preset
5534
- }
5535
- ),
5536
- ui.modal === "theme" && /* @__PURE__ */ jsx14(
5537
- ThemeModal,
5538
- {
5539
- selected: ui.selectedThemeColor,
5540
- editing: ui.editing,
5541
- error: ui.themeColorError,
5542
- cacheAction: ui.cacheAction,
5543
- overrides: themeState.preset === "custom" ? themeState.overrides : {},
5942
+ themeState,
5544
5943
  onDraft: (value2) => {
5545
5944
  draftRef.current = value2;
5546
5945
  },
5547
- onSubmit: commitThemeColor
5946
+ onSubmitField: commitField,
5947
+ onSubmitThemeColor: commitThemeColor
5548
5948
  }
5549
5949
  )
5550
5950
  ] });
@@ -5590,7 +5990,7 @@ function bootstrap() {
5590
5990
  }
5591
5991
 
5592
5992
  // src/tui/main.tsx
5593
- import { jsx as jsx15 } from "@opentui/react/jsx-runtime";
5993
+ import { jsx as jsx16 } from "@opentui/react/jsx-runtime";
5594
5994
  var { initial, saved, noCache, copyLinks, theme: theme2 } = bootstrap();
5595
5995
  var exitSignals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGBREAK", "SIGBUS"];
5596
5996
  var renderer = await createCliRenderer({ exitOnCtrlC: true, exitSignals });
@@ -5601,7 +6001,7 @@ for (const signal of ["SIGTERM", "SIGHUP"]) {
5601
6001
  });
5602
6002
  }
5603
6003
  createRoot(renderer).render(
5604
- /* @__PURE__ */ jsx15(
6004
+ /* @__PURE__ */ jsx16(
5605
6005
  App,
5606
6006
  {
5607
6007
  initial,