@d3lm/pr-stats 0.2.4 → 0.2.6

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 +2 -2
  2. package/dist/tui-app.mjs +219 -116
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pr-stats
2
2
 
3
- An interactive terminal UI, built with [OpenTUI](https://github.com/anomalyco/opentui) and React, that shows statistics for a GitHub user, e.g., time to review, size of authored PRs, or comments received on them. It works with any repository your GitHub login can see. It has a queue tab for the open PRs awaiting your review, a Your PRs tab that splits into your open PRs and a merged-and-closed report, chart tabs for the review-time, size, and comments reports, and a live options modal.
3
+ An interactive terminal UI, built with [OpenTUI](https://github.com/anomalyco/opentui) and React, that shows statistics for a GitHub user, e.g., time to review, size of authored PRs, or comments received on them. It works with any repository your GitHub login can see. It has a queue tab for the open PRs on your reviewing plate, split into those awaiting your review and those you already reviewed, a Your PRs tab that splits into your open PRs and a merged-and-closed report, chart tabs for the review-time, size, and comments reports, and a live options modal.
4
4
 
5
5
  ![pr-stats](./assets/screenshot.png)
6
6
 
@@ -25,7 +25,7 @@ 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 that lists the open PRs awaiting your review with how long each has been waiting. 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. 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.
29
29
 
30
30
  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
31
 
package/dist/tui-app.mjs CHANGED
@@ -787,6 +787,24 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
787
787
  }
788
788
  }
789
789
  pending.sort((a, b) => a.requestedAt.getTime() - b.requestedAt.getTime());
790
+ const pendingKeys = new Set(pending.map((entry) => `${entry.pr.repo}#${entry.pr.number}`));
791
+ const latestReviews = /* @__PURE__ */ new Map();
792
+ for (const result of results) {
793
+ if (result.kind !== "reviewed" && result.kind !== "unrequested" || result.pr.state !== "open") {
794
+ continue;
795
+ }
796
+ const key = `${result.pr.repo}#${result.pr.number}`;
797
+ if (pendingKeys.has(key)) {
798
+ continue;
799
+ }
800
+ const latest = latestReviews.get(key);
801
+ if (latest === void 0 || result.reviewedAt > latest.reviewedAt) {
802
+ latestReviews.set(key, { pr: result.pr, reviewedAt: result.reviewedAt });
803
+ }
804
+ }
805
+ const reviewing = [...latestReviews.values()].map(({ pr, reviewedAt }) => {
806
+ return { pr, reviewedAt, hours: durationHours(reviewedAt, now) };
807
+ }).toSorted((a, b) => a.reviewedAt.getTime() - b.reviewedAt.getTime());
790
808
  const expired = results.filter((result) => result.kind === "pending" && result.pr.state !== "open");
791
809
  const unrequested = results.filter((result) => result.kind === "unrequested");
792
810
  const allHours = reviewed.map((result) => result.hours);
@@ -798,7 +816,7 @@ function computeReviewStats(results, { targetHours, now = /* @__PURE__ */ new Da
798
816
  }
799
817
  const byRepo = [...hoursByRepo.entries()].toSorted((a, b) => b[1].length - a[1].length);
800
818
  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 };
819
+ return { reviewed, pending, reviewing, expired, unrequested, allHours, byRepo, misses };
802
820
  }
803
821
  function computeSizeStats(sizes, { sizeTarget } = {}) {
804
822
  const metrics = [
@@ -948,7 +966,7 @@ function toPrRows(entries, leads) {
948
966
 
949
967
  // src/tui/views/queue.ts
950
968
  function queueRows(view) {
951
- return view.lists.flatMap((list) => list.rows);
969
+ return view.sections.flatMap((section) => [...section.rows, ...section.lists.flatMap((list) => list.rows)]);
952
970
  }
953
971
  function groupedLists(entries, rowsOf2) {
954
972
  const groups = /* @__PURE__ */ new Map();
@@ -962,23 +980,26 @@ function groupedLists(entries, rowsOf2) {
962
980
  });
963
981
  }
964
982
  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
- }
983
+ const stats = computeReviewStats(raw.reviewResults, { now: raw.fetchedAt });
984
+ const awaiting = repo === null ? stats.pending : stats.pending.filter((entry) => entry.pr.repo === repo);
985
+ const reviewing = repo === null ? stats.reviewing : stats.reviewing.filter((entry) => entry.pr.repo === repo);
986
+ if (awaiting.length === 0 && reviewing.length === 0) {
987
+ return { empty: "No PRs are awaiting your review, and none you reviewed are still open.", sections: [] };
988
+ }
989
+ const split = repo === null && grouped;
990
+ const sectionOf = (title, entries) => split ? { title, rows: [], lists: groupedLists(entries, rowsOf) } : { title, rows: rowsOf(entries), lists: [] };
973
991
  return {
974
992
  empty: null,
975
- lists: [{ title: `Open and awaiting your review (n=${entries.length})`, rows: rowsOf(entries) }]
993
+ sections: [
994
+ ...awaiting.length === 0 ? [] : [sectionOf(`Awaiting your review (n=${awaiting.length})`, awaiting)],
995
+ ...reviewing.length === 0 ? [] : [sectionOf(`Reviewing (n=${reviewing.length})`, reviewing)]
996
+ ]
976
997
  };
977
998
  }
978
999
  function buildOpenAuthoredView(raw, repo = null, grouped = false) {
979
1000
  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
1001
  if (open.length === 0) {
981
- return { empty: "No open authored PRs found.", lists: [] };
1002
+ return { empty: "No open authored PRs found.", sections: [] };
982
1003
  }
983
1004
  const rowsOf2 = (group) => {
984
1005
  const ages = group.map((entry) => durationLead({ hours: durationHours(entry.pr.createdAt, raw.fetchedAt) }));
@@ -990,13 +1011,11 @@ function buildOpenAuthoredView(raw, repo = null, grouped = false) {
990
1011
  )
991
1012
  );
992
1013
  };
1014
+ const title = `Your open authored PRs (n=${open.length})`;
993
1015
  if (repo === null && grouped) {
994
- return { empty: null, lists: groupedLists(open, rowsOf2) };
1016
+ return { empty: null, sections: [{ title, rows: [], lists: groupedLists(open, rowsOf2) }] };
995
1017
  }
996
- return {
997
- empty: null,
998
- lists: [{ title: `Your open authored PRs (n=${open.length})`, rows: rowsOf2(open) }]
999
- };
1018
+ return { empty: null, sections: [{ title, rows: rowsOf2(open), lists: [] }] };
1000
1019
  }
1001
1020
  function rowsOf(group) {
1002
1021
  return toPrRows(
@@ -1178,7 +1197,10 @@ function Placeholder({
1178
1197
  }
1179
1198
  function LoadProgress({ load }) {
1180
1199
  if (!load.total) {
1181
- return /* @__PURE__ */ jsx5("text", { fg: theme.muted, children: loadLabel(load) });
1200
+ return /* @__PURE__ */ jsxs4("box", { flexDirection: "row", columnGap: 1, children: [
1201
+ /* @__PURE__ */ jsx5("text", { fg: theme.muted, children: loadLabel(load) }),
1202
+ /* @__PURE__ */ jsx5(Spinner, {})
1203
+ ] });
1182
1204
  }
1183
1205
  const done = load.done ?? 0;
1184
1206
  const cells = Math.min(done / load.total, 1) * BAR_WIDTH;
@@ -1196,7 +1218,7 @@ function LoadProgress({ load }) {
1196
1218
  ] });
1197
1219
  }
1198
1220
  function loadLabel(load) {
1199
- return load.phase === "search" ? "searching PRs..." : "fetching PR details";
1221
+ return load.phase === "search" ? "searching PRs" : "fetching PR details";
1200
1222
  }
1201
1223
 
1202
1224
  // src/tui/components/QueuePanel.tsx
@@ -1207,7 +1229,7 @@ function QueuePanel({
1207
1229
  heading,
1208
1230
  warning,
1209
1231
  empty,
1210
- lists,
1232
+ sections,
1211
1233
  cursor,
1212
1234
  onRefClick
1213
1235
  }) {
@@ -1234,10 +1256,57 @@ function QueuePanel({
1234
1256
  }
1235
1257
  const offsets = [];
1236
1258
  let total = 0;
1237
- for (const list of lists) {
1238
- offsets.push(total);
1239
- total += list.rows.length;
1240
- }
1259
+ for (const section of sections) {
1260
+ const entry = { rows: total, lists: [] };
1261
+ total += section.rows.length;
1262
+ for (const list of section.lists) {
1263
+ entry.lists.push(total);
1264
+ total += list.rows.length;
1265
+ }
1266
+ offsets.push(entry);
1267
+ }
1268
+ const renderRow = (row, index, indent) => {
1269
+ const isSelected = index === cursor;
1270
+ const bg = isSelected ? theme.selectedBg : void 0;
1271
+ const refStart = indent.length + 2 + row.lead.length + 2;
1272
+ return /* @__PURE__ */ jsxs5(
1273
+ "text",
1274
+ {
1275
+ id: `queue-row-${index}`,
1276
+ wrapMode: "none",
1277
+ onMouseDown: onRefClick === null ? void 0 : (event) => {
1278
+ lastDown.current = event.button === 0 ? { x: event.x, y: event.y } : null;
1279
+ },
1280
+ onMouseUp: onRefClick === null ? void 0 : (event) => {
1281
+ const down = lastDown.current;
1282
+ lastDown.current = null;
1283
+ if (event.button !== 0 || down?.x !== event.x || down.y !== event.y) {
1284
+ return;
1285
+ }
1286
+ const local = event.currentTarget === null ? -1 : event.x - event.currentTarget.x;
1287
+ if (local >= refStart && local < refStart + row.ref.length) {
1288
+ onRefClick(row);
1289
+ }
1290
+ },
1291
+ children: [
1292
+ /* @__PURE__ */ jsxs5("span", { fg: theme.accent, children: [
1293
+ indent,
1294
+ isSelected ? "\u25B8 " : " "
1295
+ ] }),
1296
+ /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1297
+ row.lead,
1298
+ " "
1299
+ ] }),
1300
+ 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 }),
1301
+ /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1302
+ " ",
1303
+ row.title
1304
+ ] })
1305
+ ]
1306
+ },
1307
+ row.url
1308
+ );
1309
+ };
1241
1310
  return /* @__PURE__ */ jsxs5("box", { flexGrow: 1, flexDirection: "column", children: [
1242
1311
  header,
1243
1312
  /* @__PURE__ */ jsxs5(
@@ -1250,49 +1319,19 @@ function QueuePanel({
1250
1319
  verticalScrollbarOptions: overlayScrollbar,
1251
1320
  children: [
1252
1321
  warning !== null && /* @__PURE__ */ jsx6("text", { wrapMode: "word", fg: theme.warn, marginTop: 1, children: warning }),
1253
- lists.map((list, listIndex) => /* @__PURE__ */ jsxs5("box", { flexDirection: "column", marginTop: 1, children: [
1254
- /* @__PURE__ */ jsx6("text", { wrapMode: "none", fg: theme.accent, children: list.title }),
1255
- list.rows.map((row, rowIndex) => {
1256
- const index = offsets[listIndex] + rowIndex;
1257
- const isSelected = index === cursor;
1258
- const bg = isSelected ? theme.selectedBg : void 0;
1259
- const refStart = 2 + row.lead.length + 2;
1260
- return /* @__PURE__ */ jsxs5(
1261
- "text",
1262
- {
1263
- id: `queue-row-${index}`,
1264
- wrapMode: "none",
1265
- onMouseDown: onRefClick === null ? void 0 : (event) => {
1266
- lastDown.current = event.button === 0 ? { x: event.x, y: event.y } : null;
1267
- },
1268
- onMouseUp: onRefClick === null ? void 0 : (event) => {
1269
- const down = lastDown.current;
1270
- lastDown.current = null;
1271
- if (event.button !== 0 || down?.x !== event.x || down.y !== event.y) {
1272
- return;
1273
- }
1274
- const local = event.currentTarget === null ? -1 : event.x - event.currentTarget.x;
1275
- if (local >= refStart && local < refStart + row.ref.length) {
1276
- onRefClick(row);
1277
- }
1278
- },
1279
- children: [
1280
- /* @__PURE__ */ jsx6("span", { fg: theme.accent, children: isSelected ? "\u25B8 " : " " }),
1281
- /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1282
- row.lead,
1283
- " "
1284
- ] }),
1285
- 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 }),
1286
- /* @__PURE__ */ jsxs5("span", { fg: theme.text, bg, children: [
1287
- " ",
1288
- row.title
1289
- ] })
1290
- ]
1291
- },
1292
- row.url
1293
- );
1294
- })
1295
- ] }, list.title))
1322
+ sections.map((section, sectionIndex) => /* @__PURE__ */ jsxs5("box", { flexDirection: "column", marginTop: 1, children: [
1323
+ /* @__PURE__ */ jsx6("text", { wrapMode: "none", fg: theme.accent, children: section.title }),
1324
+ section.rows.map((row, rowIndex) => renderRow(row, offsets[sectionIndex].rows + rowIndex, "")),
1325
+ section.lists.map((list, listIndex) => /* @__PURE__ */ jsxs5("box", { flexDirection: "column", marginTop: listIndex > 0 ? 1 : 0, children: [
1326
+ /* @__PURE__ */ jsxs5("text", { wrapMode: "none", fg: theme.accent, children: [
1327
+ " ",
1328
+ list.title
1329
+ ] }),
1330
+ list.rows.map(
1331
+ (row, rowIndex) => renderRow(row, offsets[sectionIndex].lists[listIndex] + rowIndex, " ")
1332
+ )
1333
+ ] }, list.title))
1334
+ ] }, section.title))
1296
1335
  ]
1297
1336
  }
1298
1337
  )
@@ -1479,7 +1518,7 @@ function QueueTab({
1479
1518
  heading: repos.length > 0 ? scope.repo ?? (grouped ? "All repos \xB7 grouped by repo" : "All repos") : null,
1480
1519
  warning,
1481
1520
  empty: view.empty,
1482
- lists: view.lists,
1521
+ sections: view.sections,
1483
1522
  cursor: Math.min(rowCursor, queueRows(view).length - 1),
1484
1523
  onRefClick
1485
1524
  }
@@ -2990,6 +3029,62 @@ function ColorRow({
2990
3029
  ] }) });
2991
3030
  }
2992
3031
 
3032
+ // src/tui/components/Modals.tsx
3033
+ import { jsx as jsx14 } from "@opentui/react/jsx-runtime";
3034
+ function Modals({
3035
+ ui,
3036
+ options,
3037
+ saved: saved2,
3038
+ noCache: noCache2,
3039
+ copyLinks: copyLinks2,
3040
+ themeState,
3041
+ onDraft,
3042
+ onSubmitField,
3043
+ onSubmitThemeColor
3044
+ }) {
3045
+ if (ui.modal === "options") {
3046
+ return /* @__PURE__ */ jsx14(
3047
+ OptionsModal,
3048
+ {
3049
+ options,
3050
+ saved: saved2,
3051
+ selected: ui.selectedField,
3052
+ editing: ui.editing,
3053
+ fieldError: ui.fieldError,
3054
+ onDraft,
3055
+ onSubmit: onSubmitField
3056
+ }
3057
+ );
3058
+ }
3059
+ if (ui.modal === "settings") {
3060
+ return /* @__PURE__ */ jsx14(
3061
+ SettingsModal,
3062
+ {
3063
+ selected: ui.selectedSetting,
3064
+ cacheAction: ui.cacheAction,
3065
+ noCache: noCache2,
3066
+ copyLinks: copyLinks2,
3067
+ preset: themeState.preset
3068
+ }
3069
+ );
3070
+ }
3071
+ if (ui.modal === "theme") {
3072
+ return /* @__PURE__ */ jsx14(
3073
+ ThemeModal,
3074
+ {
3075
+ selected: ui.selectedThemeColor,
3076
+ editing: ui.editing,
3077
+ error: ui.themeColorError,
3078
+ cacheAction: ui.cacheAction,
3079
+ overrides: themeState.preset === "custom" ? themeState.overrides : {},
3080
+ onDraft,
3081
+ onSubmit: onSubmitThemeColor
3082
+ }
3083
+ );
3084
+ }
3085
+ return null;
3086
+ }
3087
+
2993
3088
  // src/tui/hooks/useDeferredLoading.ts
2994
3089
  import { useEffect as useEffect4, useRef as useRef3, useState as useState2 } from "react";
2995
3090
  function useDeferredLoading(isLoading, { showDelay = 300, minDuration = 500 } = {}) {
@@ -3376,7 +3471,10 @@ function classifyPr(pr, details, user) {
3376
3471
  (node) => node?.author?.login === user && node.submittedAt ? [new Date(node.submittedAt)] : []
3377
3472
  );
3378
3473
  if (requests.length === 0) {
3379
- return reviews.length > 0 ? [{ kind: "unrequested", pr }] : [{ kind: "inaccessible", pr }];
3474
+ if (reviews.length === 0) {
3475
+ return [{ kind: "inaccessible", pr }];
3476
+ }
3477
+ return [{ kind: "unrequested", pr, reviewedAt: new Date(Math.max(...reviews.map((review) => review.getTime()))) }];
3380
3478
  }
3381
3479
  const events = [
3382
3480
  ...requests.map((at) => {
@@ -3489,6 +3587,9 @@ function reviveRawData(data) {
3489
3587
  if (result.kind === "reviewed") {
3490
3588
  return { ...result, pr, requestedAt: new Date(result.requestedAt), reviewedAt: new Date(result.reviewedAt) };
3491
3589
  }
3590
+ if (result.kind === "unrequested") {
3591
+ return { ...result, pr, reviewedAt: new Date(result.reviewedAt) };
3592
+ }
3492
3593
  return { ...result, pr };
3493
3594
  }),
3494
3595
  sizes: data.sizes.map((entry) => {
@@ -3515,6 +3616,9 @@ function loadSnapshot(options) {
3515
3616
  return null;
3516
3617
  }
3517
3618
  const data = reviveRawData(stored.data);
3619
+ if (data.reviewResults.some((result) => result.kind === "unrequested" && Number.isNaN(result.reviewedAt.getTime()))) {
3620
+ return null;
3621
+ }
3518
3622
  if (sinceIso === data.sinceIso) {
3519
3623
  return data;
3520
3624
  }
@@ -3723,24 +3827,42 @@ function buildSizeRepoOptions(raw) {
3723
3827
  })
3724
3828
  ];
3725
3829
  }
3726
- function pendingDetail(count2) {
3727
- return `${count2} ${count2 === 1 ? "PR" : "PRs"} awaiting your review`;
3830
+ function pendingDetail(counts) {
3831
+ const awaiting = `${counts.awaiting} ${counts.awaiting === 1 ? "PR" : "PRs"} awaiting your review`;
3832
+ return awaiting + (counts.reviewing > 0 ? `, ${counts.reviewing} reviewing` : "");
3728
3833
  }
3729
3834
  function buildPendingRepoOptions(raw) {
3730
- const countByRepo = /* @__PURE__ */ new Map();
3835
+ const countsByRepo = /* @__PURE__ */ new Map();
3836
+ const countsOf = (repo) => {
3837
+ const counts = countsByRepo.get(repo) ?? { awaiting: 0, reviewing: 0 };
3838
+ countsByRepo.set(repo, counts);
3839
+ return counts;
3840
+ };
3731
3841
  for (const result of raw.reviewResults) {
3732
- const pending = result.kind === "pending" && result.pr.state === "open" ? 1 : 0;
3733
- countByRepo.set(result.pr.repo, (countByRepo.get(result.pr.repo) ?? 0) + pending);
3842
+ countsOf(result.pr.repo);
3734
3843
  }
3735
- if (countByRepo.size < 2) {
3844
+ if (countsByRepo.size < 2) {
3736
3845
  return [];
3737
3846
  }
3738
- const entries = [...countByRepo.entries()].toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
3739
- const total = entries.reduce((sum, [, count2]) => sum + count2, 0);
3847
+ const stats = computeReviewStats(raw.reviewResults, { now: raw.fetchedAt });
3848
+ for (const entry of stats.pending) {
3849
+ countsOf(entry.pr.repo).awaiting += 1;
3850
+ }
3851
+ for (const entry of stats.reviewing) {
3852
+ countsOf(entry.pr.repo).reviewing += 1;
3853
+ }
3854
+ const entries = [...countsByRepo.entries()].toSorted(
3855
+ (a, b) => b[1].awaiting - a[1].awaiting || b[1].reviewing - a[1].reviewing || a[0].localeCompare(b[0])
3856
+ );
3857
+ const totals = { awaiting: 0, reviewing: 0 };
3858
+ for (const [, counts] of entries) {
3859
+ totals.awaiting += counts.awaiting;
3860
+ totals.reviewing += counts.reviewing;
3861
+ }
3740
3862
  return [
3741
- { repo: null, label: "All repos", detail: pendingDetail(total) },
3742
- ...entries.map(([repo, count2]) => {
3743
- return { repo, label: repo, detail: pendingDetail(count2) };
3863
+ { repo: null, label: "All repos", detail: pendingDetail(totals) },
3864
+ ...entries.map(([repo, counts]) => {
3865
+ return { repo, label: repo, detail: pendingDetail(counts) };
3744
3866
  })
3745
3867
  ];
3746
3868
  }
@@ -4008,7 +4130,10 @@ function buildHeatmapCard({ title, subtitle, grid, columns, legend }) {
4008
4130
  });
4009
4131
  const columnWidths = columns.map((column) => column.label.length + 2);
4010
4132
  const header = [
4011
- { text: ` ${["00", "06", "12", "18"].map((label) => label.padEnd(12)).join(" ")}`, fg: theme.dim },
4133
+ {
4134
+ text: ` ${["00 03", "06 09", "12 15", "18 21"].map((label) => label.padEnd(12)).join(" ")}`,
4135
+ fg: theme.dim
4136
+ },
4012
4137
  { text: columns.map((column, i) => column.label.padStart(columnWidths[i])).join(""), fg: theme.dim }
4013
4138
  ];
4014
4139
  const lines = [header];
@@ -5345,7 +5470,7 @@ function createClipboardCopier(renderer2) {
5345
5470
  }
5346
5471
 
5347
5472
  // src/tui/App.tsx
5348
- import { jsx as jsx14, jsxs as jsxs13 } from "@opentui/react/jsx-runtime";
5473
+ import { jsx as jsx15, jsxs as jsxs13 } from "@opentui/react/jsx-runtime";
5349
5474
  function App({
5350
5475
  initial: initial2,
5351
5476
  initialSaved = null,
@@ -5467,9 +5592,9 @@ function App({
5467
5592
  });
5468
5593
  const capWarning = raw?.searchCapped ? "Warning, a search hit the 1000 result cap, so data may be incomplete. Narrow since or repos." : null;
5469
5594
  return /* @__PURE__ */ jsxs13("box", { flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg, children: [
5470
- /* @__PURE__ */ jsx14(Header, { options, raw, error, spinning: showLoad }),
5471
- /* @__PURE__ */ jsx14(TabBar, { tab: browse.tab }),
5472
- /* @__PURE__ */ jsx14(
5595
+ /* @__PURE__ */ jsx15(Header, { options, raw, error, spinning: showLoad }),
5596
+ /* @__PURE__ */ jsx15(TabBar, { tab: browse.tab }),
5597
+ /* @__PURE__ */ jsx15(
5473
5598
  MainPanel,
5474
5599
  {
5475
5600
  views,
@@ -5488,7 +5613,7 @@ function App({
5488
5613
  onRefClick: copyLinks2 ? copyRow : null
5489
5614
  }
5490
5615
  ),
5491
- /* @__PURE__ */ jsx14(
5616
+ /* @__PURE__ */ jsx15(
5492
5617
  Footer,
5493
5618
  {
5494
5619
  width,
@@ -5503,42 +5628,20 @@ function App({
5503
5628
  stale
5504
5629
  }
5505
5630
  ),
5506
- ui.modal === "options" && /* @__PURE__ */ jsx14(
5507
- OptionsModal,
5631
+ /* @__PURE__ */ jsx15(
5632
+ Modals,
5508
5633
  {
5634
+ ui,
5509
5635
  options,
5510
5636
  saved: saved2,
5511
- selected: ui.selectedField,
5512
- editing: ui.editing,
5513
- fieldError: ui.fieldError,
5514
- onDraft: (value2) => {
5515
- draftRef.current = value2;
5516
- },
5517
- onSubmit: commitField
5518
- }
5519
- ),
5520
- ui.modal === "settings" && /* @__PURE__ */ jsx14(
5521
- SettingsModal,
5522
- {
5523
- selected: ui.selectedSetting,
5524
- cacheAction: ui.cacheAction,
5525
5637
  noCache: noCache2,
5526
5638
  copyLinks: copyLinks2,
5527
- preset: themeState.preset
5528
- }
5529
- ),
5530
- ui.modal === "theme" && /* @__PURE__ */ jsx14(
5531
- ThemeModal,
5532
- {
5533
- selected: ui.selectedThemeColor,
5534
- editing: ui.editing,
5535
- error: ui.themeColorError,
5536
- cacheAction: ui.cacheAction,
5537
- overrides: themeState.preset === "custom" ? themeState.overrides : {},
5639
+ themeState,
5538
5640
  onDraft: (value2) => {
5539
5641
  draftRef.current = value2;
5540
5642
  },
5541
- onSubmit: commitThemeColor
5643
+ onSubmitField: commitField,
5644
+ onSubmitThemeColor: commitThemeColor
5542
5645
  }
5543
5646
  )
5544
5647
  ] });
@@ -5584,7 +5687,7 @@ function bootstrap() {
5584
5687
  }
5585
5688
 
5586
5689
  // src/tui/main.tsx
5587
- import { jsx as jsx15 } from "@opentui/react/jsx-runtime";
5690
+ import { jsx as jsx16 } from "@opentui/react/jsx-runtime";
5588
5691
  var { initial, saved, noCache, copyLinks, theme: theme2 } = bootstrap();
5589
5692
  var exitSignals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGBREAK", "SIGBUS"];
5590
5693
  var renderer = await createCliRenderer({ exitOnCtrlC: true, exitSignals });
@@ -5595,7 +5698,7 @@ for (const signal of ["SIGTERM", "SIGHUP"]) {
5595
5698
  });
5596
5699
  }
5597
5700
  createRoot(renderer).render(
5598
- /* @__PURE__ */ jsx15(
5701
+ /* @__PURE__ */ jsx16(
5599
5702
  App,
5600
5703
  {
5601
5704
  initial,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3lm/pr-stats",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
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",