@d3lm/pr-stats 0.2.10 → 0.2.12

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 (2) hide show
  1. package/dist/tui-app.mjs +90 -36
  2. package/package.json +2 -2
package/dist/tui-app.mjs CHANGED
@@ -558,11 +558,11 @@ function Footer({
558
558
  views,
559
559
  copyLinks: copyLinks2,
560
560
  openError,
561
- copyNotice,
561
+ successNotice,
562
562
  stale
563
563
  }) {
564
- const notice = openError ?? copyNotice ?? (stale ? "options changed \xB7 press r to reload" : "");
565
- const check = openError === null && copyNotice !== null;
564
+ const notice = openError ?? successNotice ?? (stale ? "options changed \xB7 press r to reload" : "");
565
+ const check = openError === null && successNotice !== null;
566
566
  const noticeWidth = notice === "" ? 0 : notice.length + (check ? 2 : 0) + 2;
567
567
  const hints = truncated(hintsFor(modal, editing, tab, authoredTab, views, copyLinks2), width - 2 - noticeWidth);
568
568
  return /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -580,7 +580,7 @@ function Footer({
580
580
  /* @__PURE__ */ jsx("text", { wrapMode: "none", fg: theme.dim, children: hints }),
581
581
  /* @__PURE__ */ jsxs("text", { wrapMode: "none", children: [
582
582
  check && /* @__PURE__ */ jsx("span", { fg: theme.success, children: "\u2714 " }),
583
- /* @__PURE__ */ jsx("span", { fg: openError !== null ? theme.error : copyNotice !== null ? theme.muted : theme.warn, children: notice })
583
+ /* @__PURE__ */ jsx("span", { fg: openError !== null ? theme.error : successNotice !== null ? theme.muted : theme.warn, children: notice })
584
584
  ] })
585
585
  ]
586
586
  }
@@ -2361,6 +2361,12 @@ var OPTIONS = [
2361
2361
  placeholder: "<value>",
2362
2362
  help: "Report how many reviews finished within this time. Accepts hours (`24h` or plain `24`), minutes (`90m`), or days (`2d`). A day means 24 counted hours, or one working day when --work-hours is set. Open PRs that have already waited longer than the target count as misses."
2363
2363
  },
2364
+ {
2365
+ name: "target-percentile",
2366
+ type: "string",
2367
+ placeholder: "<p>",
2368
+ help: "Check the --target against this percentile of your review times. Accepts a whole percentile from 1 to 100, with an optional p prefix (`90` or `p90`). The default is `90`, so the review headline reports whether your p90 review time meets the target."
2369
+ },
2364
2370
  {
2365
2371
  name: "size-target",
2366
2372
  type: "string",
@@ -2525,6 +2531,15 @@ function parseTarget(input) {
2525
2531
  }
2526
2532
  return amount;
2527
2533
  }
2534
+ var DEFAULT_TARGET_PERCENTILE = 90;
2535
+ function parseTargetPercentile(input) {
2536
+ const match = /^p?(\d{1,3})$/i.exec(input);
2537
+ const value2 = match === null ? Number.NaN : Number(match[1]);
2538
+ if (!Number.isInteger(value2) || value2 < 1 || value2 > 100) {
2539
+ throw new CliError(`invalid --target-percentile value "${input}", use a percentile from 1 to 100 like 90 or p90`);
2540
+ }
2541
+ return value2;
2542
+ }
2528
2543
  function parseSizeTarget(input) {
2529
2544
  const target = {};
2530
2545
  for (const part of input.split(",")) {
@@ -2651,6 +2666,13 @@ var FIELDS = [
2651
2666
  kind: "text",
2652
2667
  fetch: false
2653
2668
  },
2669
+ {
2670
+ key: "targetPercentile",
2671
+ label: "Target percentile",
2672
+ hint: "the percentile the review target checks, like 90 or p99, empty means p90",
2673
+ kind: "text",
2674
+ fetch: false
2675
+ },
2654
2676
  {
2655
2677
  key: "sizeTarget",
2656
2678
  label: "Size target",
@@ -2713,6 +2735,10 @@ function validateField(key, value2) {
2713
2735
  parseTarget(value2);
2714
2736
  break;
2715
2737
  }
2738
+ case "targetPercentile": {
2739
+ parseTargetPercentile(value2);
2740
+ break;
2741
+ }
2716
2742
  case "sizeTarget": {
2717
2743
  parseSizeTarget(value2);
2718
2744
  break;
@@ -2741,6 +2767,7 @@ function readSavedOptions() {
2741
2767
  }
2742
2768
  const record = value2;
2743
2769
  record.reviewTypes ??= "";
2770
+ record.targetPercentile ??= "";
2744
2771
  const options = {};
2745
2772
  for (const field of FIELDS) {
2746
2773
  const raw = record[field.key];
@@ -2784,6 +2811,9 @@ function applySavedOptions(values, explicit) {
2784
2811
  if (!explicit.has("target") && saved2.target !== "") {
2785
2812
  values.target = saved2.target;
2786
2813
  }
2814
+ if (!explicit.has("target-percentile") && saved2.targetPercentile !== "") {
2815
+ values["target-percentile"] = saved2.targetPercentile;
2816
+ }
2787
2817
  if (!explicit.has("size-target") && saved2.sizeTarget !== "") {
2788
2818
  values["size-target"] = saved2.sizeTarget;
2789
2819
  }
@@ -2880,6 +2910,7 @@ var EMPTY_PLACEHOLDERS = {
2880
2910
  repos: "(all accessible)",
2881
2911
  user: "(authenticated user)",
2882
2912
  target: "(none)",
2913
+ targetPercentile: "(p90)",
2883
2914
  sizeTarget: "(none)",
2884
2915
  tz: "(system)",
2885
2916
  reviewTypes: "(every type)"
@@ -5170,9 +5201,17 @@ function buildVerdictCard(reviewed) {
5170
5201
  })
5171
5202
  });
5172
5203
  }
5173
- function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100, expanded = false) {
5204
+ function targetStatus(sorted, target) {
5205
+ const margin = target.hours - percentile(sorted, target.percentile);
5206
+ if (margin < 0) {
5207
+ return { text: ` ${formatDuration(-margin)} over the ${target.label} target`, fg: theme.error };
5208
+ }
5209
+ const lead = margin === 0 ? "at" : `${formatDuration(margin)} under`;
5210
+ return { text: ` ${lead} the ${target.label} target`, fg: theme.success };
5211
+ }
5212
+ function buildReviewView(raw, target, repo = null, width = 100, expanded = false) {
5174
5213
  const results = repo === null ? raw.reviewResults : raw.reviewResults.filter((result) => result.pr.repo === repo);
5175
- const stats = computeReviewStats(results, { targetHours, now: raw.fetchedAt });
5214
+ const stats = computeReviewStats(results, { targetHours: target?.hours, now: raw.fetchedAt });
5176
5215
  const strip = [
5177
5216
  countCell(stats.reviewed.length, "reviewed on request"),
5178
5217
  countCell(stats.pending.length, "awaiting you", true),
@@ -5193,9 +5232,9 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
5193
5232
  return { empty: "No reviewed or review-requested PRs found.", ...base, lists: [] };
5194
5233
  }
5195
5234
  const lists = [];
5196
- if (targetHours !== void 0 && stats.misses.length > 0) {
5235
+ if (target !== void 0 && stats.misses.length > 0) {
5197
5236
  lists.push({
5198
- title: `Reviews that missed the <= ${targetLabel} target`,
5237
+ title: `Reviews that missed the <= ${target.label} target`,
5199
5238
  rows: toPrRows(stats.misses, stats.misses.map(durationLead))
5200
5239
  });
5201
5240
  }
@@ -5211,11 +5250,19 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
5211
5250
  }
5212
5251
  const sorted = [...stats.allHours].toSorted((a, b) => a - b);
5213
5252
  const total = raw.reviewResults.filter((result) => result.kind === "reviewed").length;
5253
+ const secondPercentile = target === void 0 || target.percentile === 50 ? 90 : target.percentile;
5254
+ const percentileColor = (percent) => {
5255
+ if (target === void 0 || percent !== target.percentile) {
5256
+ return theme.accent;
5257
+ }
5258
+ return percentile(sorted, target.percentile) <= target.hours ? theme.success : theme.error;
5259
+ };
5214
5260
  const headline = [
5215
5261
  { text: "p50 ", fg: theme.muted },
5216
- { text: formatDuration(percentile(sorted, 50)), fg: theme.accent },
5217
- { text: " p90 ", fg: theme.muted },
5218
- { text: formatDuration(percentile(sorted, 90)), fg: theme.accent },
5262
+ { text: formatDuration(percentile(sorted, 50)), fg: percentileColor(50) },
5263
+ { text: ` p${secondPercentile} `, fg: theme.muted },
5264
+ { text: formatDuration(percentile(sorted, secondPercentile)), fg: percentileColor(secondPercentile) },
5265
+ ...target === void 0 ? [] : [targetStatus(sorted, target)],
5219
5266
  { text: ` ${stats.reviewed.length} of ${total} reviews`, fg: theme.muted }
5220
5267
  ];
5221
5268
  const requestDates = [...stats.reviewed, ...stats.pending].map((entry) => entry.requestedAt);
@@ -5224,15 +5271,15 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
5224
5271
  (entry) => durationHours(entry.pr.createdAt, entry.requestedAt)
5225
5272
  );
5226
5273
  let serviceCard = null;
5227
- if (targetHours !== void 0 && targetLabel !== void 0) {
5228
- const inside = stats.allHours.filter((value2) => value2 <= targetHours).length;
5229
- const overdue = stats.pending.filter((entry) => entry.hours > targetHours).length;
5274
+ if (target !== void 0) {
5275
+ const inside = stats.allHours.filter((value2) => value2 <= target.hours).length;
5276
+ const overdue = stats.pending.filter((entry) => entry.hours > target.hours).length;
5230
5277
  serviceCard = buildGaugeCard({
5231
5278
  title: "Service level",
5232
- subtitle: `reviewed within ${targetLabel}`,
5279
+ subtitle: `reviewed within ${target.label}`,
5233
5280
  rows: [
5234
- { label: `inside ${targetLabel}`, count: inside, color: theme.accent },
5235
- { label: `over ${targetLabel}`, count: stats.allHours.length - inside, color: theme.chartDim },
5281
+ { label: `inside ${target.label}`, count: inside, color: theme.accent },
5282
+ { label: `over ${target.label}`, count: stats.allHours.length - inside, color: theme.chartDim },
5236
5283
  ...overdue > 0 ? [{ label: "awaiting and already over", count: overdue, color: theme.warn }] : []
5237
5284
  ]
5238
5285
  });
@@ -5254,6 +5301,7 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
5254
5301
  expanded
5255
5302
  }) : null;
5256
5303
  const cards = [
5304
+ ...serviceCard === null ? [] : [serviceCard],
5257
5305
  buildHistogramCard({
5258
5306
  title: "Time to review",
5259
5307
  subtitle: "elapsed time, request \u2192 review",
@@ -5306,7 +5354,6 @@ function buildReviewView(raw, targetHours, targetLabel, repo = null, width = 100
5306
5354
  buildVerdictCard(stats.reviewed),
5307
5355
  ...pendingCard === null ? [] : [pendingCard],
5308
5356
  buildOffHoursCard("reviews submitted, local time", reviewDates),
5309
- ...serviceCard === null ? [] : [serviceCard],
5310
5357
  ...byRepoCard === null ? [] : [byRepoCard]
5311
5358
  ];
5312
5359
  const distribution = buildDistribution({
@@ -5760,7 +5807,12 @@ function useViewModel(raw, options, width, scopes, grouping, expanded, themeEpoc
5760
5807
  if (!raw) {
5761
5808
  return null;
5762
5809
  }
5763
- const targetHours = options.target === "" ? void 0 : parseTarget(options.target);
5810
+ const targetLabel = targetLabelOf(options.target);
5811
+ const reviewTarget = targetLabel === void 0 ? void 0 : {
5812
+ hours: parseTarget(options.target),
5813
+ label: targetLabel,
5814
+ percentile: options.targetPercentile === "" ? DEFAULT_TARGET_PERCENTILE : parseTargetPercentile(options.targetPercentile)
5815
+ };
5764
5816
  const sizeTarget = options.sizeTarget === "" ? void 0 : parseSizeTarget(options.sizeTarget);
5765
5817
  const pendingRepos = buildPendingRepoOptions(raw);
5766
5818
  const openRepos = buildOpenRepoOptions(raw);
@@ -5774,7 +5826,7 @@ function useViewModel(raw, options, width, scopes, grouping, expanded, themeEpoc
5774
5826
  const reviewScope = resolveScope(scopes.review, reviewRepos);
5775
5827
  const sizeScope = resolveScope(scopes.size, sizeRepos);
5776
5828
  const commentScope = resolveScope(scopes.comment, commentRepos);
5777
- const review = reviewScope.view === "detail" ? buildReviewView(raw, targetHours, targetLabelOf(options.target), reviewScope.repo, width, expanded.review) : null;
5829
+ const review = reviewScope.view === "detail" ? buildReviewView(raw, reviewTarget, reviewScope.repo, width, expanded.review) : null;
5778
5830
  return {
5779
5831
  pendingRepos,
5780
5832
  openRepos,
@@ -5801,6 +5853,7 @@ function useViewModel(raw, options, width, scopes, grouping, expanded, themeEpoc
5801
5853
  options.tz,
5802
5854
  options.wallClock,
5803
5855
  options.target,
5856
+ options.targetPercentile,
5804
5857
  options.sizeTarget,
5805
5858
  width,
5806
5859
  scopes.pending,
@@ -5999,7 +6052,7 @@ function queueTabOf(context) {
5999
6052
  }
6000
6053
  function handleQueueKey(key, context) {
6001
6054
  const { key: tab, view, repos, scope } = queueTabOf(context);
6002
- if (scope !== null && scope.view === "list") {
6055
+ if (scope?.view === "list") {
6003
6056
  switch (key.name) {
6004
6057
  case "up":
6005
6058
  case "k": {
@@ -6091,7 +6144,7 @@ function statsTabOf(context) {
6091
6144
  }
6092
6145
  function handleStatsKey(key, context) {
6093
6146
  const { key: tab, repos, scope, view } = statsTabOf(context);
6094
- if (scope !== null && scope.view === "list") {
6147
+ if (scope?.view === "list") {
6095
6148
  switch (key.name) {
6096
6149
  case "up":
6097
6150
  case "k": {
@@ -6177,7 +6230,7 @@ var initialUiState = {
6177
6230
  themeColorError: null,
6178
6231
  cacheAction: null,
6179
6232
  openError: null,
6180
- copyNotice: null
6233
+ successNotice: null
6181
6234
  };
6182
6235
  function cycled2(previous, delta, count2) {
6183
6236
  return (previous + count2 + delta) % count2;
@@ -6185,19 +6238,19 @@ function cycled2(previous, delta, count2) {
6185
6238
  function uiReducer(state, action) {
6186
6239
  switch (action.type) {
6187
6240
  case "noticesDismissed": {
6188
- if (state.openError === null && state.copyNotice === null) {
6241
+ if (state.openError === null && state.successNotice === null) {
6189
6242
  return state;
6190
6243
  }
6191
- return { ...state, openError: null, copyNotice: null };
6244
+ return { ...state, openError: null, successNotice: null };
6192
6245
  }
6193
6246
  case "openErrorReported": {
6194
6247
  return { ...state, openError: action.message };
6195
6248
  }
6196
6249
  case "copyReported": {
6197
- return { ...state, openError: null, copyNotice: { text: action.message } };
6250
+ return { ...state, openError: null, successNotice: { text: action.message } };
6198
6251
  }
6199
- case "copyNoticeExpired": {
6200
- return { ...state, copyNotice: null };
6252
+ case "successNoticeExpired": {
6253
+ return { ...state, successNotice: null };
6201
6254
  }
6202
6255
  case "modalOpened": {
6203
6256
  return { ...state, modal: action.modal, fieldError: null, themeColorError: null, cacheAction: null };
@@ -6245,10 +6298,10 @@ function uiReducer(state, action) {
6245
6298
  return { ...state, fieldError: action.message };
6246
6299
  }
6247
6300
  case "optionsSaveReported": {
6248
- return {
6249
- ...state,
6250
- fieldError: action.saved ? null : "the cache is disabled for this session \xB7 options not saved"
6251
- };
6301
+ if (action.saved) {
6302
+ return { ...state, fieldError: null, successNotice: { text: "options saved" } };
6303
+ }
6304
+ return { ...state, fieldError: "the cache is disabled for this session \xB7 options not saved" };
6252
6305
  }
6253
6306
  case "themeColorCommitted": {
6254
6307
  return { ...state, editing: false, themeColorError: null, cacheAction: action.action };
@@ -6351,16 +6404,16 @@ function App({
6351
6404
  dispatchUi({ type: "copyReported", message: `copied ${row.ref} to the clipboard` });
6352
6405
  };
6353
6406
  useEffect6(() => {
6354
- if (ui.copyNotice === null) {
6407
+ if (ui.successNotice === null) {
6355
6408
  return void 0;
6356
6409
  }
6357
6410
  const timer = setTimeout(() => {
6358
- dispatchUi({ type: "copyNoticeExpired" });
6411
+ dispatchUi({ type: "successNoticeExpired" });
6359
6412
  }, 2500);
6360
6413
  return () => {
6361
6414
  clearTimeout(timer);
6362
6415
  };
6363
- }, [ui.copyNotice]);
6416
+ }, [ui.successNotice]);
6364
6417
  const reviewScrollRef = useRef5(null);
6365
6418
  const sizeScrollRef = useRef5(null);
6366
6419
  const commentScrollRef = useRef5(null);
@@ -6477,7 +6530,7 @@ function App({
6477
6530
  views,
6478
6531
  copyLinks: copyLinks2,
6479
6532
  openError: ui.openError,
6480
- copyNotice: ui.copyNotice === null ? null : ui.copyNotice.text,
6533
+ successNotice: ui.successNotice === null ? null : ui.successNotice.text,
6481
6534
  stale
6482
6535
  }
6483
6536
  ),
@@ -6523,6 +6576,7 @@ function bootstrap() {
6523
6576
  repos: values.repo.join(","),
6524
6577
  user: values.user ?? "",
6525
6578
  target: values.target ?? "",
6579
+ targetPercentile: values["target-percentile"] ?? "",
6526
6580
  sizeTarget: values["size-target"] ?? "",
6527
6581
  workHours: values["work-hours"],
6528
6582
  tz: values.tz ?? "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3lm/pr-stats",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
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",
@@ -43,7 +43,7 @@
43
43
  "prepublishOnly": "pnpm build"
44
44
  },
45
45
  "dependencies": {
46
- "@d3lm/lint-preset": "^1.1.7",
46
+ "@d3lm/lint-preset": "^1.2.0",
47
47
  "@opentui/core": "^0.5.8",
48
48
  "@opentui/react": "^0.5.8",
49
49
  "asciichart": "^1.5.25",