@reddoorla/maintenance 0.67.0 → 0.69.0

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 (99) hide show
  1. package/README.md +16 -0
  2. package/dist/airtable-WGQQ3KAD.js +9 -0
  3. package/dist/{announce-74ZIAV26.js → announce-72R3LRNT.js} +9 -11
  4. package/dist/{announce-74ZIAV26.js.map → announce-72R3LRNT.js.map} +1 -1
  5. package/dist/{bump-deps-HYBYNPMY.js → bump-deps-QNM3H4GI.js} +2 -2
  6. package/dist/{chunk-YKOOAWVL.js → chunk-2DCA7FHZ.js} +21 -15
  7. package/dist/chunk-2DCA7FHZ.js.map +1 -0
  8. package/dist/{chunk-LCKKIJ22.js → chunk-42KKVLYN.js} +3 -3
  9. package/dist/{chunk-VKAYZVMI.js → chunk-45AUGDFR.js} +2 -1
  10. package/dist/chunk-45AUGDFR.js.map +1 -0
  11. package/dist/{chunk-WCN54744.js → chunk-6G7BC5SN.js} +4 -4
  12. package/dist/{chunk-SYTPVYIK.js → chunk-6JFOAPW4.js} +241 -4
  13. package/dist/chunk-6JFOAPW4.js.map +1 -0
  14. package/dist/{chunk-Y4O44LZB.js → chunk-6MT5W6UF.js} +2 -2
  15. package/dist/{chunk-OAMJINM5.js → chunk-A5KYRGQA.js} +4 -4
  16. package/dist/{chunk-JDGIO54P.js → chunk-AESJBEM7.js} +43 -3
  17. package/dist/chunk-AESJBEM7.js.map +1 -0
  18. package/dist/{chunk-CL5DYUWQ.js → chunk-C3PDS623.js} +3 -1
  19. package/dist/chunk-C3PDS623.js.map +1 -0
  20. package/dist/chunk-E2GKKUEX.js +322 -0
  21. package/dist/chunk-E2GKKUEX.js.map +1 -0
  22. package/dist/{chunk-KN5OC5YH.js → chunk-GKSXZIMX.js} +3 -3
  23. package/dist/{chunk-XVR7UM6Z.js → chunk-JNXCDUYB.js} +4 -4
  24. package/dist/{chunk-ULL4AD3Y.js → chunk-O6G4HHMF.js} +7 -9
  25. package/dist/{chunk-ULL4AD3Y.js.map → chunk-O6G4HHMF.js.map} +1 -1
  26. package/dist/{chunk-6LIWDIXU.js → chunk-RINXPU3H.js} +2 -2
  27. package/dist/{chunk-T34EUM5B.js → chunk-XWPZ46IN.js} +2 -2
  28. package/dist/{chunk-7MJJEEII.js → chunk-YQMYQAJX.js} +2 -2
  29. package/dist/cli/bin.js +32 -14
  30. package/dist/cli/bin.js.map +1 -1
  31. package/dist/cli/commands/audit.js +8 -8
  32. package/dist/client/index.d.ts +84 -0
  33. package/dist/client/index.js +47 -0
  34. package/dist/client/index.js.map +1 -0
  35. package/dist/{convert-to-pnpm-5MPZWFHM.js → convert-to-pnpm-XH7NYRVK.js} +2 -2
  36. package/dist/{digest-MECMZPFE.js → digest-Q2TAQHXF.js} +17 -8
  37. package/dist/digest-Q2TAQHXF.js.map +1 -0
  38. package/dist/ensure-site-2PF522VL.js +87 -0
  39. package/dist/ensure-site-2PF522VL.js.map +1 -0
  40. package/dist/{github-signals-VWBMQNLH.js → github-signals-CLQC2IPY.js} +7 -7
  41. package/dist/index.d.ts +7 -1
  42. package/dist/index.js +103 -37
  43. package/dist/index.js.map +1 -1
  44. package/dist/{init-X3BCJAUG.js → init-UA6D7GKW.js} +7 -7
  45. package/dist/{launch-NQCL5K5H.js → launch-AENAS2O2.js} +15 -17
  46. package/dist/{launch-NQCL5K5H.js.map → launch-AENAS2O2.js.map} +1 -1
  47. package/dist/{lighthouse-airtable-QUNOSDT5.js → lighthouse-airtable-UBSXKHAQ.js} +3 -3
  48. package/dist/{onboard-7JOBZY35.js → onboard-X6KJ5Z2V.js} +2 -2
  49. package/dist/{orchestrate-JQX72RJE.js → orchestrate-GADNFGDH.js} +5 -7
  50. package/dist/preflight-D5RQGX4V.js +64 -0
  51. package/dist/preflight-D5RQGX4V.js.map +1 -0
  52. package/dist/recipes/sync-configs.js +2 -2
  53. package/dist/{renovate-dispatch-PWXFZNHN.js → renovate-dispatch-LG4RJGDY.js} +2 -2
  54. package/dist/{report-QUKNKUUE.js → report-3FJ224H5.js} +8 -9
  55. package/dist/{report-QUKNKUUE.js.map → report-3FJ224H5.js.map} +1 -1
  56. package/dist/{self-updating-OKJKU2FE.js → self-updating-JTTFOJ7Q.js} +2 -2
  57. package/dist/{selftest-6JVYR24F.js → selftest-55OJIWEB.js} +10 -12
  58. package/dist/{selftest-6JVYR24F.js.map → selftest-55OJIWEB.js.map} +1 -1
  59. package/dist/{svelte-codemods-GG6EEES5.js → svelte-codemods-KJLNH6FQ.js} +2 -2
  60. package/dist/{sync-configs-UFHZZPL7.js → sync-configs-TIBO6DUJ.js} +4 -4
  61. package/dist/{upgrade-S25N3473.js → upgrade-TPTE26FG.js} +2 -2
  62. package/dist/{websites-U3UZS5VG.js → websites-6YTT5ZTO.js} +2 -2
  63. package/dist/{write-audits-to-airtable-GAJUCSTY.js → write-audits-to-airtable-IWOO4FFH.js} +4 -4
  64. package/package.json +5 -1
  65. package/dist/airtable-33SY2UBZ.js +0 -9
  66. package/dist/chunk-CL5DYUWQ.js.map +0 -1
  67. package/dist/chunk-JDGIO54P.js.map +0 -1
  68. package/dist/chunk-OCS7SZUZ.js +0 -243
  69. package/dist/chunk-OCS7SZUZ.js.map +0 -1
  70. package/dist/chunk-OGGRFBL4.js +0 -14
  71. package/dist/chunk-OGGRFBL4.js.map +0 -1
  72. package/dist/chunk-SYTPVYIK.js.map +0 -1
  73. package/dist/chunk-VKAYZVMI.js.map +0 -1
  74. package/dist/chunk-YKOOAWVL.js.map +0 -1
  75. package/dist/digest-MECMZPFE.js.map +0 -1
  76. /package/dist/{airtable-33SY2UBZ.js.map → airtable-WGQQ3KAD.js.map} +0 -0
  77. /package/dist/{bump-deps-HYBYNPMY.js.map → bump-deps-QNM3H4GI.js.map} +0 -0
  78. /package/dist/{chunk-LCKKIJ22.js.map → chunk-42KKVLYN.js.map} +0 -0
  79. /package/dist/{chunk-WCN54744.js.map → chunk-6G7BC5SN.js.map} +0 -0
  80. /package/dist/{chunk-Y4O44LZB.js.map → chunk-6MT5W6UF.js.map} +0 -0
  81. /package/dist/{chunk-OAMJINM5.js.map → chunk-A5KYRGQA.js.map} +0 -0
  82. /package/dist/{chunk-KN5OC5YH.js.map → chunk-GKSXZIMX.js.map} +0 -0
  83. /package/dist/{chunk-XVR7UM6Z.js.map → chunk-JNXCDUYB.js.map} +0 -0
  84. /package/dist/{chunk-6LIWDIXU.js.map → chunk-RINXPU3H.js.map} +0 -0
  85. /package/dist/{chunk-T34EUM5B.js.map → chunk-XWPZ46IN.js.map} +0 -0
  86. /package/dist/{chunk-7MJJEEII.js.map → chunk-YQMYQAJX.js.map} +0 -0
  87. /package/dist/{convert-to-pnpm-5MPZWFHM.js.map → convert-to-pnpm-XH7NYRVK.js.map} +0 -0
  88. /package/dist/{github-signals-VWBMQNLH.js.map → github-signals-CLQC2IPY.js.map} +0 -0
  89. /package/dist/{init-X3BCJAUG.js.map → init-UA6D7GKW.js.map} +0 -0
  90. /package/dist/{lighthouse-airtable-QUNOSDT5.js.map → lighthouse-airtable-UBSXKHAQ.js.map} +0 -0
  91. /package/dist/{onboard-7JOBZY35.js.map → onboard-X6KJ5Z2V.js.map} +0 -0
  92. /package/dist/{orchestrate-JQX72RJE.js.map → orchestrate-GADNFGDH.js.map} +0 -0
  93. /package/dist/{renovate-dispatch-PWXFZNHN.js.map → renovate-dispatch-LG4RJGDY.js.map} +0 -0
  94. /package/dist/{self-updating-OKJKU2FE.js.map → self-updating-JTTFOJ7Q.js.map} +0 -0
  95. /package/dist/{svelte-codemods-GG6EEES5.js.map → svelte-codemods-KJLNH6FQ.js.map} +0 -0
  96. /package/dist/{sync-configs-UFHZZPL7.js.map → sync-configs-TIBO6DUJ.js.map} +0 -0
  97. /package/dist/{upgrade-S25N3473.js.map → upgrade-TPTE26FG.js.map} +0 -0
  98. /package/dist/{websites-U3UZS5VG.js.map → websites-6YTT5ZTO.js.map} +0 -0
  99. /package/dist/{write-audits-to-airtable-GAJUCSTY.js.map → write-audits-to-airtable-IWOO4FFH.js.map} +0 -0
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
- import "./chunk-JDGIO54P.js";
1
+ import "./chunk-AESJBEM7.js";
2
+ import {
3
+ approveBlockers
4
+ } from "./chunk-E2GKKUEX.js";
2
5
  import {
3
6
  findDueReports
4
- } from "./chunk-VKAYZVMI.js";
7
+ } from "./chunk-45AUGDFR.js";
5
8
  import {
6
9
  upgradeSvelte4to5
7
10
  } from "./chunk-ZO7WNY57.js";
@@ -9,7 +12,7 @@ import {
9
12
  DEFAULT_INIT_STEPS,
10
13
  a11yFixturesPage,
11
14
  init
12
- } from "./chunk-LCKKIJ22.js";
15
+ } from "./chunk-42KKVLYN.js";
13
16
  import {
14
17
  convertToPnpm
15
18
  } from "./chunk-2X322ZES.js";
@@ -24,34 +27,35 @@ import {
24
27
  } from "./chunk-ZYBOT5PM.js";
25
28
  import {
26
29
  draftReportForSite
27
- } from "./chunk-ULL4AD3Y.js";
28
- import "./chunk-6LIWDIXU.js";
30
+ } from "./chunk-O6G4HHMF.js";
31
+ import "./chunk-RINXPU3H.js";
29
32
  import {
30
- sendApprovedReports
31
- } from "./chunk-YKOOAWVL.js";
32
- import "./chunk-OGGRFBL4.js";
33
+ parseAddresses,
34
+ sendApprovedReports,
35
+ withGlobalCc
36
+ } from "./chunk-2DCA7FHZ.js";
33
37
  import "./chunk-GECESMVS.js";
34
38
  import {
35
39
  BLURRED_CID,
36
40
  CHECK_CID,
37
- loadBundledImages,
38
- renderReportHtml
39
- } from "./chunk-SYTPVYIK.js";
40
- import {
41
41
  checklistFor,
42
42
  escapeHtml,
43
43
  isChecklistComplete,
44
44
  isPendingApproval,
45
+ loadBundledImages,
46
+ renderReportHtml,
45
47
  safeUrl
46
- } from "./chunk-OCS7SZUZ.js";
48
+ } from "./chunk-6JFOAPW4.js";
49
+ import "./chunk-WEKL2HYJ.js";
47
50
  import {
48
51
  bumpDeps
49
52
  } from "./chunk-D3DQFC32.js";
53
+ import "./chunk-QTHKOIFJ.js";
50
54
  import {
51
55
  syncConfigs
52
- } from "./chunk-XVR7UM6Z.js";
53
- import "./chunk-XKOZUVK2.js";
56
+ } from "./chunk-JNXCDUYB.js";
54
57
  import "./chunk-CASCWQQX.js";
58
+ import "./chunk-XKOZUVK2.js";
55
59
  import "./chunk-OTGB6YQO.js";
56
60
  import {
57
61
  ALL_AUDIT_NAMES,
@@ -62,13 +66,13 @@ import {
62
66
  runAudits,
63
67
  runAuditsAcross,
64
68
  securityAudit
65
- } from "./chunk-WCN54744.js";
69
+ } from "./chunk-6G7BC5SN.js";
70
+ import "./chunk-GASBX52O.js";
71
+ import "./chunk-YEGHUODU.js";
66
72
  import "./chunk-LKGVSM2O.js";
67
73
  import "./chunk-OTBA7DON.js";
68
74
  import "./chunk-PH3AGF4Z.js";
69
75
  import "./chunk-HIN3P4ZN.js";
70
- import "./chunk-GASBX52O.js";
71
- import "./chunk-YEGHUODU.js";
72
76
  import {
73
77
  fromJsonFile,
74
78
  localPath
@@ -78,18 +82,16 @@ import "./chunk-XXTZBPUY.js";
78
82
  import "./chunk-XTK5VIQB.js";
79
83
  import {
80
84
  fromAirtableBase
81
- } from "./chunk-Y4O44LZB.js";
85
+ } from "./chunk-6MT5W6UF.js";
82
86
  import "./chunk-U5SZ4FOP.js";
83
87
  import {
84
88
  SEVERITY_RANK,
85
89
  siteSlug
86
- } from "./chunk-CL5DYUWQ.js";
90
+ } from "./chunk-C3PDS623.js";
87
91
  import "./chunk-HN6I2XWI.js";
88
- import "./chunk-WEKL2HYJ.js";
89
92
  import "./chunk-6VAL7XJT.js";
90
93
  import "./chunk-EYODPUHV.js";
91
94
  import "./chunk-LYCKN7ZR.js";
92
- import "./chunk-QTHKOIFJ.js";
93
95
 
94
96
  // src/recipes/index.ts
95
97
  var ALL_RECIPE_NAMES = [
@@ -327,21 +329,66 @@ function checklistBlock(r) {
327
329
  }).join("");
328
330
  return `<div class="checklist" data-checklist-for="${rid}">${boxes}</div>`;
329
331
  }
330
- function approveButton(r) {
331
- const disabled = isChecklistComplete(r) ? "" : " disabled";
332
- return `<button class="approve" data-report-id="${escapeHtml(r.id)}" data-approve-url="${escapeHtml(`/api/reports/${encodeURIComponent(r.id)}/approve`)}"${disabled}>Approve</button>`;
332
+ function approveButton(r, blocked) {
333
+ const disabled = isChecklistComplete(r) && !blocked ? "" : " disabled";
334
+ const blockedAttr = blocked ? ` data-send-blocked="1"` : "";
335
+ return `<button class="approve" data-report-id="${escapeHtml(r.id)}" data-approve-url="${escapeHtml(`/api/reports/${encodeURIComponent(r.id)}/approve`)}"${blockedAttr}${disabled}>Approve</button>`;
336
+ }
337
+ function preflightChip(findings) {
338
+ const fails = findings.filter((f) => f.level === "fail");
339
+ const warns = findings.filter((f) => f.level === "warn");
340
+ if (fails.length > 0) {
341
+ const title = escapeHtml(fails.map((f) => `${f.check}: ${f.message}`).join("\n"));
342
+ return `<span class="preflight preflight-fail" title="${title}">preflight \u2717 ${fails.length}</span>`;
343
+ }
344
+ if (warns.length > 0) {
345
+ const title = escapeHtml(warns.map((f) => `${f.check}: ${f.message}`).join("\n"));
346
+ return `<span class="preflight preflight-warn" title="${title}">preflight \u26A0 ${warns.length}</span>`;
347
+ }
348
+ return `<span class="preflight preflight-ok" title="recipients, header image and report scores all present">preflight \u2713</span>`;
349
+ }
350
+ function nextDailyRun(now) {
351
+ const run = new Date(now);
352
+ run.setUTCHours(9, 23, 0, 0);
353
+ if (run.getTime() <= now.getTime()) run.setUTCDate(run.getUTCDate() + 1);
354
+ return run;
333
355
  }
334
- function pendingRow(r) {
356
+ function sendTimingLine(now) {
357
+ const minuteOfDay = now.getUTCHours() * 60 + now.getUTCMinutes();
358
+ const RUN_START = 9 * 60 + 23;
359
+ const GRACE_END = 10 * 60 + 30;
360
+ if (minuteOfDay >= RUN_START && minuteOfDay < GRACE_END) {
361
+ return `<span class="muted">today's 09:23 UTC run may still be in flight \u2014 approving now can send within minutes</span>`;
362
+ }
363
+ const run = nextDailyRun(now);
364
+ const leadMin = Math.floor((run.getTime() - now.getTime()) / 6e4);
365
+ const lead = leadMin >= 60 ? `~${Math.floor(leadMin / 60)}h` : leadMin >= 1 ? `~${leadMin} min` : "under 1 min";
366
+ return `<span class="muted">approve \u2260 send: goes out at the next daily run, 09:23 UTC (${lead})</span>`;
367
+ }
368
+ function recipientsLine(site) {
369
+ const to = parseAddresses(site.reportRecipientsTo) ?? parseAddresses(site.pointOfContact) ?? [];
370
+ if (to.length === 0) {
371
+ return `<span class="recipients recipients-missing">recipients: none resolve \u2014 set point of contact in Airtable</span>`;
372
+ }
373
+ const cc = withGlobalCc(parseAddresses(site.reportRecipientsCc), to);
374
+ const ccPart = cc.length > 0 ? ` \xB7 CC ${cc.map(escapeHtml).join(", ")}` : "";
375
+ return `<span class="recipients">To ${to.map(escapeHtml).join(", ")}${ccPart}</span>`;
376
+ }
377
+ function pendingRow(r, site, now) {
335
378
  const type = escapeHtml(r.reportType);
336
379
  const period = r.period ? escapeHtml(r.period) : "\u2014";
337
- return `<li><div class="pending-head"><strong>${type}</strong> <span class="muted">${period}</span> ${approveButton(r)}</div>${checklistBlock(r)}</li>`;
380
+ const findings = approveBlockers(site, r);
381
+ const blocked = findings.some((f) => f.level === "fail");
382
+ const preview = r.renderedHtmlAttachment ? `<a href="${escapeHtml(safeUrl(r.renderedHtmlAttachment.url))}" rel="noopener noreferrer" title="rendered at draft time \u2014 Commentary/subject edits after drafting are not reflected">draft preview \u25B8</a>` : `<span class="muted">no preview yet</span>`;
383
+ const sendLine = sendTimingLine(now);
384
+ return `<li><div class="pending-head"><strong>${type}</strong> <span class="muted">${period}</span> ${preflightChip(findings)} ${preview} ${approveButton(r, blocked)}</div><div class="pending-info">${recipientsLine(site)} ${sendLine}</div>${checklistBlock(r)}</li>`;
338
385
  }
339
- function pendingSection(reports) {
386
+ function pendingSection(reports, site, now) {
340
387
  const pending = reports.filter(isPendingApproval);
341
388
  if (pending.length === 0) return "";
342
389
  return `<div class="section pending">
343
390
  <h2>Pending your yes (${pending.length})</h2>
344
- <ul class="pending-list">${pending.map(pendingRow).join("")}</ul>
391
+ <ul class="pending-list">${pending.map((r) => pendingRow(r, site, now)).join("")}</ul>
345
392
  </div>`;
346
393
  }
347
394
  function gaUsersCell(r) {
@@ -358,14 +405,17 @@ function searchCell(r) {
358
405
  }
359
406
  return DASH;
360
407
  }
361
- function reportRow(r) {
408
+ function reportRow(r, site) {
362
409
  const date = r.completedOn ? escapeHtml(r.completedOn) : DASH;
363
410
  const type = escapeHtml(r.reportType);
364
411
  const id = escapeHtml(r.reportId);
365
412
  const ga = gaUsersCell(r);
366
413
  const search = searchCell(r);
367
414
  const link = r.renderedHtmlAttachment ? `<a href="${escapeHtml(safeUrl(r.renderedHtmlAttachment.url))}">view</a>` : `<span class="muted">no attachment</span>`;
368
- const action = isPendingApproval(r) ? approveButton(r) : "";
415
+ const action = isPendingApproval(r) ? approveButton(
416
+ r,
417
+ approveBlockers(site, r).some((f) => f.level === "fail")
418
+ ) : "";
369
419
  return `<tr><td>${date}</td><td>${type}</td><td><code>${id}</code></td><td>${ga}</td><td>${search}</td><td>${link}</td><td>${action}</td></tr>`;
370
420
  }
371
421
  var SUBMISSIONS_PER_SITE_CAP = 25;
@@ -471,10 +521,16 @@ th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }
471
521
  .empty { color: #999; padding: 1rem; border: 1px dashed #ccc; border-radius: 6px; text-align: center; }
472
522
  button.approve { font: inherit; padding: 0.35rem 0.85rem; border: 1px solid #2c7; border-radius: 6px; background: #2c7; color: #fff; cursor: pointer; }
473
523
  button.approve:disabled { opacity: 0.6; cursor: default; }
524
+ .pending-info { display: flex; flex-wrap: wrap; gap: 0.35rem 1rem; font-size: 0.82rem; margin: 0.3rem 0 0.15rem; }
525
+ .recipients-missing { color: #e57373; }
526
+ .preflight { font-size: 0.78rem; padding: 0.1rem 0.45rem; border-radius: 999px; white-space: nowrap; }
527
+ .preflight-ok { background: #1b5e2033; color: #7bc67e; }
528
+ .preflight-warn { background: #f9a82533; color: #d79921; }
529
+ .preflight-fail { background: #b71c1c33; color: #e57373; }
474
530
  .pending-list { list-style: none; padding: 0; margin: 0; }
475
531
  .pending-list li { padding: 0.5rem; border-bottom: 1px solid #eee; }
476
532
  @media (prefers-color-scheme: dark) { .pending-list li { border-color: #2a2a2a; } }
477
- .pending-head { display: flex; align-items: center; gap: 0.5rem; }
533
+ .pending-head { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; row-gap: 0.25rem; }
478
534
  .checklist { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0.5rem 0 0.25rem 0.25rem; }
479
535
  .check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; }
480
536
  .check-item input { margin: 0; }
@@ -524,7 +580,7 @@ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = n
524
580
  const recentReports = [...reports].sort((a, b) => (b.completedOn ?? "").localeCompare(a.completedOn ?? "")).slice(0, 6);
525
581
  const reportsSection = recentReports.length === 0 ? `<div class="empty">No reports yet.</div>` : `<table>
526
582
  <thead><tr><th>Completed</th><th>Type</th><th>ID</th><th>GA users</th><th>Search</th><th>Report</th><th></th></tr></thead>
527
- <tbody>${recentReports.map(reportRow).join("")}</tbody>
583
+ <tbody>${recentReports.map((r) => reportRow(r, site)).join("")}</tbody>
528
584
  </table>`;
529
585
  return `<!doctype html>
530
586
  <html lang="en">
@@ -541,7 +597,7 @@ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = n
541
597
  <div class="meta"><a href="${escapeHtml(urlSafe)}">${escapeHtml(site.url)}</a></div>
542
598
  ${auditedLine}
543
599
  ${setupSection(site)}
544
- ${pendingSection(reports)}
600
+ ${pendingSection(reports, site, now)}
545
601
 
546
602
  <div class="section">
547
603
  <h2>Lighthouse</h2>
@@ -569,8 +625,18 @@ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = n
569
625
  b.disabled = true;
570
626
  try {
571
627
  const res = await fetch(b.dataset.approveUrl, { method: "POST" });
572
- b.textContent = res.ok ? "Approved" : "Failed";
573
- if (!res.ok) b.disabled = false;
628
+ if (res.ok) {
629
+ b.textContent = "Approved";
630
+ } else {
631
+ // A 409 carries { reason, blockers } \u2014 surface WHY instead of a bare
632
+ // "Failed" next to a possibly-stale green chip. textContent/title
633
+ // assignment only (no innerHTML), so server strings stay inert.
634
+ const data = await res.json().catch(() => null);
635
+ b.textContent = data && data.reason === "send-blocked" ? "Blocked" : "Failed";
636
+ if (data && Array.isArray(data.blockers)) b.title = data.blockers.join("
637
+ ");
638
+ b.disabled = false;
639
+ }
574
640
  } catch {
575
641
  // Network rejection (offline, DNS, abort): mirror the !res.ok path so
576
642
  // the button doesn't sit permanently disabled reading "Approve".
@@ -638,7 +704,7 @@ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = n
638
704
  });
639
705
  if (!res.ok) throw new Error("bad status");
640
706
  const data = await res.json();
641
- if (approveBtn) approveBtn.disabled = !data.complete;
707
+ if (approveBtn) approveBtn.disabled = !data.complete || approveBtn.dataset.sendBlocked === "1";
642
708
  } catch {
643
709
  // Revert the optimistic flip so the box reflects the (unchanged) server state.
644
710
  cb.checked = !cb.checked;