@reddoorla/maintenance 0.66.0 → 0.68.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 (107) hide show
  1. package/README.md +16 -0
  2. package/dist/airtable-WGQQ3KAD.js +9 -0
  3. package/dist/{announce-YPXVHNNP.js → announce-72R3LRNT.js} +9 -11
  4. package/dist/{announce-YPXVHNNP.js.map → announce-72R3LRNT.js.map} +1 -1
  5. package/dist/{bump-deps-INDDTDSG.js → bump-deps-QNM3H4GI.js} +2 -2
  6. package/dist/{chunk-CH6K2VMM.js → chunk-2E2X5BCG.js} +43 -3
  7. package/dist/chunk-2E2X5BCG.js.map +1 -0
  8. package/dist/{chunk-VKAYZVMI.js → chunk-45AUGDFR.js} +2 -1
  9. package/dist/chunk-45AUGDFR.js.map +1 -0
  10. package/dist/{chunk-SYTPVYIK.js → chunk-6JFOAPW4.js} +241 -4
  11. package/dist/chunk-6JFOAPW4.js.map +1 -0
  12. package/dist/{chunk-NMGGP5WN.js → chunk-6MT5W6UF.js} +2 -2
  13. package/dist/{chunk-XKSKHHSX.js → chunk-A5KYRGQA.js} +4 -4
  14. package/dist/{chunk-4P6UQG5L.js → chunk-C3PDS623.js} +6 -1
  15. package/dist/chunk-C3PDS623.js.map +1 -0
  16. package/dist/{chunk-JD3X3QMP.js → chunk-GKSXZIMX.js} +3 -3
  17. package/dist/{chunk-CWSQZ27M.js → chunk-H6YZBXBQ.js} +19 -13
  18. package/dist/chunk-H6YZBXBQ.js.map +1 -0
  19. package/dist/{chunk-XKKTK6WA.js → chunk-HIN3P4ZN.js} +23 -2
  20. package/dist/chunk-HIN3P4ZN.js.map +1 -0
  21. package/dist/chunk-MAYHLXCS.js +322 -0
  22. package/dist/chunk-MAYHLXCS.js.map +1 -0
  23. package/dist/{chunk-G35B5U6F.js → chunk-O6G4HHMF.js} +7 -9
  24. package/dist/{chunk-G35B5U6F.js.map → chunk-O6G4HHMF.js.map} +1 -1
  25. package/dist/{chunk-G2YRXV26.js → chunk-OTBA7DON.js} +2 -2
  26. package/dist/{chunk-PTPTVVY7.js → chunk-PH3AGF4Z.js} +2 -2
  27. package/dist/{chunk-6LIWDIXU.js → chunk-RINXPU3H.js} +2 -2
  28. package/dist/{chunk-YBYWNUT3.js → chunk-XWPZ46IN.js} +2 -2
  29. package/dist/{chunk-LISJ3K3R.js → chunk-YQMYQAJX.js} +2 -2
  30. package/dist/cli/bin.js +33 -15
  31. package/dist/cli/bin.js.map +1 -1
  32. package/dist/cli/commands/audit.js +8 -8
  33. package/dist/client-NVVQK3HH.js +11 -0
  34. package/dist/{convert-to-pnpm-4GD4667L.js → convert-to-pnpm-XH7NYRVK.js} +2 -2
  35. package/dist/{db-YBLQR2YY.js → db-3QW2XEXA.js} +3 -3
  36. package/dist/{digest-SIZUXT67.js → digest-J7TDG62Q.js} +14 -5
  37. package/dist/digest-J7TDG62Q.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/forms/index.d.ts +13 -0
  41. package/dist/forms/index.js +28 -2
  42. package/dist/forms/index.js.map +1 -1
  43. package/dist/{github-signals-3O64PFWP.js → github-signals-PP7GWYIY.js} +7 -7
  44. package/dist/index.d.ts +19 -2
  45. package/dist/index.js +117 -39
  46. package/dist/index.js.map +1 -1
  47. package/dist/{init-KMWXJZEX.js → init-WLR35YGN.js} +2 -2
  48. package/dist/{launch-5KRCKFYM.js → launch-2HPQXWII.js} +10 -12
  49. package/dist/{launch-5KRCKFYM.js.map → launch-2HPQXWII.js.map} +1 -1
  50. package/dist/{lighthouse-airtable-5JICCZND.js → lighthouse-airtable-UBSXKHAQ.js} +3 -3
  51. package/dist/migrate-35DNNF3H.js +7 -0
  52. package/dist/{onboard-RCBXS5B3.js → onboard-X6KJ5Z2V.js} +2 -2
  53. package/dist/{orchestrate-O4KR6JME.js → orchestrate-PVCGBH4K.js} +7 -9
  54. package/dist/preflight-KWVYJ2DU.js +64 -0
  55. package/dist/preflight-KWVYJ2DU.js.map +1 -0
  56. package/dist/{renovate-dispatch-ABRSIT77.js → renovate-dispatch-LG4RJGDY.js} +2 -2
  57. package/dist/{report-LSGTMXQJ.js → report-ZBFMTT76.js} +8 -9
  58. package/dist/{report-LSGTMXQJ.js.map → report-ZBFMTT76.js.map} +1 -1
  59. package/dist/{self-updating-CPMY3HFY.js → self-updating-JTTFOJ7Q.js} +2 -2
  60. package/dist/{selftest-7Y5K4PYL.js → selftest-LFZPZRJP.js} +10 -12
  61. package/dist/{selftest-7Y5K4PYL.js.map → selftest-LFZPZRJP.js.map} +1 -1
  62. package/dist/{svelte-codemods-GJRTBLJE.js → svelte-codemods-KJLNH6FQ.js} +2 -2
  63. package/dist/{sync-configs-NYJNSIRN.js → sync-configs-6GBZLGRQ.js} +2 -2
  64. package/dist/{upgrade-LVYRDKDL.js → upgrade-TPTE26FG.js} +2 -2
  65. package/dist/{websites-5ETIXB7F.js → websites-6YTT5ZTO.js} +2 -2
  66. package/dist/{write-audits-to-airtable-GHR337N7.js → write-audits-to-airtable-IWOO4FFH.js} +4 -4
  67. package/package.json +1 -1
  68. package/dist/airtable-M4AQHRNV.js +0 -9
  69. package/dist/chunk-4P6UQG5L.js.map +0 -1
  70. package/dist/chunk-CH6K2VMM.js.map +0 -1
  71. package/dist/chunk-CWSQZ27M.js.map +0 -1
  72. package/dist/chunk-OCS7SZUZ.js +0 -243
  73. package/dist/chunk-OCS7SZUZ.js.map +0 -1
  74. package/dist/chunk-OGGRFBL4.js +0 -14
  75. package/dist/chunk-OGGRFBL4.js.map +0 -1
  76. package/dist/chunk-SYTPVYIK.js.map +0 -1
  77. package/dist/chunk-VKAYZVMI.js.map +0 -1
  78. package/dist/chunk-XKKTK6WA.js.map +0 -1
  79. package/dist/client-C2GOZD2D.js +0 -11
  80. package/dist/digest-SIZUXT67.js.map +0 -1
  81. package/dist/migrate-BFOG2OCP.js +0 -7
  82. /package/dist/{airtable-M4AQHRNV.js.map → airtable-WGQQ3KAD.js.map} +0 -0
  83. /package/dist/{bump-deps-INDDTDSG.js.map → bump-deps-QNM3H4GI.js.map} +0 -0
  84. /package/dist/{chunk-NMGGP5WN.js.map → chunk-6MT5W6UF.js.map} +0 -0
  85. /package/dist/{chunk-XKSKHHSX.js.map → chunk-A5KYRGQA.js.map} +0 -0
  86. /package/dist/{chunk-JD3X3QMP.js.map → chunk-GKSXZIMX.js.map} +0 -0
  87. /package/dist/{chunk-G2YRXV26.js.map → chunk-OTBA7DON.js.map} +0 -0
  88. /package/dist/{chunk-PTPTVVY7.js.map → chunk-PH3AGF4Z.js.map} +0 -0
  89. /package/dist/{chunk-6LIWDIXU.js.map → chunk-RINXPU3H.js.map} +0 -0
  90. /package/dist/{chunk-YBYWNUT3.js.map → chunk-XWPZ46IN.js.map} +0 -0
  91. /package/dist/{chunk-LISJ3K3R.js.map → chunk-YQMYQAJX.js.map} +0 -0
  92. /package/dist/{client-C2GOZD2D.js.map → client-NVVQK3HH.js.map} +0 -0
  93. /package/dist/{convert-to-pnpm-4GD4667L.js.map → convert-to-pnpm-XH7NYRVK.js.map} +0 -0
  94. /package/dist/{db-YBLQR2YY.js.map → db-3QW2XEXA.js.map} +0 -0
  95. /package/dist/{github-signals-3O64PFWP.js.map → github-signals-PP7GWYIY.js.map} +0 -0
  96. /package/dist/{init-KMWXJZEX.js.map → init-WLR35YGN.js.map} +0 -0
  97. /package/dist/{lighthouse-airtable-5JICCZND.js.map → lighthouse-airtable-UBSXKHAQ.js.map} +0 -0
  98. /package/dist/{migrate-BFOG2OCP.js.map → migrate-35DNNF3H.js.map} +0 -0
  99. /package/dist/{onboard-RCBXS5B3.js.map → onboard-X6KJ5Z2V.js.map} +0 -0
  100. /package/dist/{orchestrate-O4KR6JME.js.map → orchestrate-PVCGBH4K.js.map} +0 -0
  101. /package/dist/{renovate-dispatch-ABRSIT77.js.map → renovate-dispatch-LG4RJGDY.js.map} +0 -0
  102. /package/dist/{self-updating-CPMY3HFY.js.map → self-updating-JTTFOJ7Q.js.map} +0 -0
  103. /package/dist/{svelte-codemods-GJRTBLJE.js.map → svelte-codemods-KJLNH6FQ.js.map} +0 -0
  104. /package/dist/{sync-configs-NYJNSIRN.js.map → sync-configs-6GBZLGRQ.js.map} +0 -0
  105. /package/dist/{upgrade-LVYRDKDL.js.map → upgrade-TPTE26FG.js.map} +0 -0
  106. /package/dist/{websites-5ETIXB7F.js.map → websites-6YTT5ZTO.js.map} +0 -0
  107. /package/dist/{write-audits-to-airtable-GHR337N7.js.map → write-audits-to-airtable-IWOO4FFH.js.map} +0 -0
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
- import "./chunk-CH6K2VMM.js";
1
+ import "./chunk-2E2X5BCG.js";
2
+ import {
3
+ approveBlockers
4
+ } from "./chunk-MAYHLXCS.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";
@@ -24,26 +27,25 @@ import {
24
27
  } from "./chunk-ZYBOT5PM.js";
25
28
  import {
26
29
  draftReportForSite
27
- } from "./chunk-G35B5U6F.js";
28
- import "./chunk-6LIWDIXU.js";
30
+ } from "./chunk-O6G4HHMF.js";
31
+ import "./chunk-RINXPU3H.js";
29
32
  import {
30
- sendApprovedReports
31
- } from "./chunk-CWSQZ27M.js";
32
- import "./chunk-OGGRFBL4.js";
33
+ parseAddresses,
34
+ sendApprovedReports,
35
+ withGlobalCc
36
+ } from "./chunk-H6YZBXBQ.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";
47
49
  import {
48
50
  bumpDeps
49
51
  } from "./chunk-D3DQFC32.js";
@@ -64,9 +66,9 @@ import {
64
66
  securityAudit
65
67
  } from "./chunk-WCN54744.js";
66
68
  import "./chunk-LKGVSM2O.js";
67
- import "./chunk-G2YRXV26.js";
68
- import "./chunk-PTPTVVY7.js";
69
- import "./chunk-XKKTK6WA.js";
69
+ import "./chunk-OTBA7DON.js";
70
+ import "./chunk-PH3AGF4Z.js";
71
+ import "./chunk-HIN3P4ZN.js";
70
72
  import "./chunk-GASBX52O.js";
71
73
  import "./chunk-YEGHUODU.js";
72
74
  import {
@@ -78,12 +80,12 @@ import "./chunk-XXTZBPUY.js";
78
80
  import "./chunk-XTK5VIQB.js";
79
81
  import {
80
82
  fromAirtableBase
81
- } from "./chunk-NMGGP5WN.js";
83
+ } from "./chunk-6MT5W6UF.js";
82
84
  import "./chunk-U5SZ4FOP.js";
83
85
  import {
84
86
  SEVERITY_RANK,
85
87
  siteSlug
86
- } from "./chunk-4P6UQG5L.js";
88
+ } from "./chunk-C3PDS623.js";
87
89
  import "./chunk-HN6I2XWI.js";
88
90
  import "./chunk-WEKL2HYJ.js";
89
91
  import "./chunk-6VAL7XJT.js";
@@ -172,6 +174,9 @@ function extraFieldsList(raw) {
172
174
  ).join("");
173
175
  return rows;
174
176
  }
177
+ function isVisibleInStrip(s) {
178
+ return s.status !== "spam_auto";
179
+ }
175
180
  function renderSubmissionRow(s) {
176
181
  const when = s.submittedAt ? escapeHtml(relativeTimeFromNow(s.submittedAt)) : "\u2014";
177
182
  const type = escapeHtml(s.formType);
@@ -181,6 +186,8 @@ function renderSubmissionRow(s) {
181
186
  const id = escapeHtml(s.id);
182
187
  const url = `/api/submissions/${encodeURIComponent(s.id)}/status`;
183
188
  const btn = (label, action) => `<button class="subm-status" data-id="${id}" data-status="${action}" data-url="${url}">${label}</button>`;
189
+ const provenance = s.status === "spam_auto" && s.spamScore !== null && s.spamScore !== void 0 ? ` <span class="subm-provenance" title="${escapeHtml(s.spamReason ?? "")}">auto-spam \xB7 ${escapeHtml(String(s.spamScore))}</span>` : "";
190
+ const recover = s.status === "spam_auto" ? btn("Not spam \u2192 new", "new") : "";
184
191
  const kv = (label, value) => value === null || value === "" ? "" : `<div class="subm-kv"><span class="k">${label}</span> ${escapeHtml(String(value))}</div>`;
185
192
  const sourceLink = s.sourceUrl ? `<div class="subm-kv"><span class="k">Source</span> <a href="${escapeHtml(safeUrl(s.sourceUrl))}" rel="noopener noreferrer">${escapeHtml(s.sourceUrl)}</a></div>` : "";
186
193
  const messageBlock = s.message ? `<div class="subm-kv"><span class="k">Message</span></div><div class="subm-msg">${escapeHtml(s.message)}</div>` : "";
@@ -196,10 +203,10 @@ function renderSubmissionRow(s) {
196
203
  ].join("");
197
204
  return `<li class="subm-item">
198
205
  <details>
199
- <summary class="subm-head"><strong>${type}</strong> \xB7 ${who} <span class="muted">${email}</span> <span class="pill subm-${status}">${status}</span> <span class="muted">${when}</span></summary>
206
+ <summary class="subm-head"><strong>${type}</strong> \xB7 ${who} <span class="muted">${email}</span> <span class="pill subm-${status}">${status}</span>${provenance} <span class="muted">${when}</span></summary>
200
207
  <div class="subm-detail">${details}</div>
201
208
  </details>
202
- <div class="subm-actions">${btn("Read", "read")}${btn("Archive", "archived")}${btn("Spam", "spam")}</div>
209
+ <div class="subm-actions">${recover}${btn("Read", "read")}${btn("Archive", "archived")}${btn("Spam", "spam")}</div>
203
210
  </li>`;
204
211
  }
205
212
  var SUBMISSION_STYLES = `.subm-list { list-style: none; padding: 0; margin: 0; }
@@ -220,6 +227,8 @@ button.subm-status:disabled { opacity: 0.6; cursor: default; }
220
227
  .pill.subm-read { background: #f0f0f0; color: #555; }
221
228
  .pill.subm-archived { background: #eee; color: #888; }
222
229
  .pill.subm-spam { background: #fdecea; color: #b00; }
230
+ .pill.subm-spam_auto { background: #fff4e5; color: #a65a00; }
231
+ .subm-provenance { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; background: #fff4e5; color: #a65a00; }
223
232
  .subm-viewall { font-size: 0.8rem; font-weight: normal; margin-left: 0.4rem; white-space: nowrap; }`;
224
233
  var SUBMISSION_STATUS_SCRIPT = `document.querySelectorAll("button.subm-status").forEach((b) => {
225
234
  b.addEventListener("click", async () => {
@@ -320,21 +329,66 @@ function checklistBlock(r) {
320
329
  }).join("");
321
330
  return `<div class="checklist" data-checklist-for="${rid}">${boxes}</div>`;
322
331
  }
323
- function approveButton(r) {
324
- const disabled = isChecklistComplete(r) ? "" : " disabled";
325
- 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>`;
326
336
  }
327
- function pendingRow(r) {
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;
355
+ }
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) {
328
378
  const type = escapeHtml(r.reportType);
329
379
  const period = r.period ? escapeHtml(r.period) : "\u2014";
330
- 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>`;
331
385
  }
332
- function pendingSection(reports) {
386
+ function pendingSection(reports, site, now) {
333
387
  const pending = reports.filter(isPendingApproval);
334
388
  if (pending.length === 0) return "";
335
389
  return `<div class="section pending">
336
390
  <h2>Pending your yes (${pending.length})</h2>
337
- <ul class="pending-list">${pending.map(pendingRow).join("")}</ul>
391
+ <ul class="pending-list">${pending.map((r) => pendingRow(r, site, now)).join("")}</ul>
338
392
  </div>`;
339
393
  }
340
394
  function gaUsersCell(r) {
@@ -351,24 +405,28 @@ function searchCell(r) {
351
405
  }
352
406
  return DASH;
353
407
  }
354
- function reportRow(r) {
408
+ function reportRow(r, site) {
355
409
  const date = r.completedOn ? escapeHtml(r.completedOn) : DASH;
356
410
  const type = escapeHtml(r.reportType);
357
411
  const id = escapeHtml(r.reportId);
358
412
  const ga = gaUsersCell(r);
359
413
  const search = searchCell(r);
360
414
  const link = r.renderedHtmlAttachment ? `<a href="${escapeHtml(safeUrl(r.renderedHtmlAttachment.url))}">view</a>` : `<span class="muted">no attachment</span>`;
361
- const action = isPendingApproval(r) ? approveButton(r) : "";
415
+ const action = isPendingApproval(r) ? approveButton(
416
+ r,
417
+ approveBlockers(site, r).some((f) => f.level === "fail")
418
+ ) : "";
362
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>`;
363
420
  }
364
421
  var SUBMISSIONS_PER_SITE_CAP = 25;
365
422
  function submissionsSection(submissions, site) {
366
- if (submissions.length === 0) return "";
367
- const recent = [...submissions].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, SUBMISSIONS_PER_SITE_CAP);
368
- const note = submissions.length > recent.length ? `<span class="muted"> \u2014 showing ${recent.length} of ${submissions.length}</span>` : "";
423
+ const visible = submissions.filter(isVisibleInStrip);
424
+ if (visible.length === 0) return "";
425
+ const recent = [...visible].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, SUBMISSIONS_PER_SITE_CAP);
426
+ const note = visible.length > recent.length ? `<span class="muted"> \u2014 showing ${recent.length} of ${visible.length}</span>` : "";
369
427
  const viewAll = `<a class="subm-viewall" href="/submissions?site=${escapeHtml(siteSlug(site.name))}">View all for this site \u2192</a>`;
370
428
  return `<div class="section submissions">
371
- <h2>Form submissions (${submissions.length})${note} ${viewAll}</h2>
429
+ <h2>Form submissions (${visible.length})${note} ${viewAll}</h2>
372
430
  <ul class="subm-list">${recent.map(renderSubmissionRow).join("")}</ul>
373
431
  </div>`;
374
432
  }
@@ -463,10 +521,16 @@ th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }
463
521
  .empty { color: #999; padding: 1rem; border: 1px dashed #ccc; border-radius: 6px; text-align: center; }
464
522
  button.approve { font: inherit; padding: 0.35rem 0.85rem; border: 1px solid #2c7; border-radius: 6px; background: #2c7; color: #fff; cursor: pointer; }
465
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; }
466
530
  .pending-list { list-style: none; padding: 0; margin: 0; }
467
531
  .pending-list li { padding: 0.5rem; border-bottom: 1px solid #eee; }
468
532
  @media (prefers-color-scheme: dark) { .pending-list li { border-color: #2a2a2a; } }
469
- .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; }
470
534
  .checklist { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0.5rem 0 0.25rem 0.25rem; }
471
535
  .check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; }
472
536
  .check-item input { margin: 0; }
@@ -516,7 +580,7 @@ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = n
516
580
  const recentReports = [...reports].sort((a, b) => (b.completedOn ?? "").localeCompare(a.completedOn ?? "")).slice(0, 6);
517
581
  const reportsSection = recentReports.length === 0 ? `<div class="empty">No reports yet.</div>` : `<table>
518
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>
519
- <tbody>${recentReports.map(reportRow).join("")}</tbody>
583
+ <tbody>${recentReports.map((r) => reportRow(r, site)).join("")}</tbody>
520
584
  </table>`;
521
585
  return `<!doctype html>
522
586
  <html lang="en">
@@ -533,7 +597,7 @@ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = n
533
597
  <div class="meta"><a href="${escapeHtml(urlSafe)}">${escapeHtml(site.url)}</a></div>
534
598
  ${auditedLine}
535
599
  ${setupSection(site)}
536
- ${pendingSection(reports)}
600
+ ${pendingSection(reports, site, now)}
537
601
 
538
602
  <div class="section">
539
603
  <h2>Lighthouse</h2>
@@ -561,8 +625,18 @@ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = n
561
625
  b.disabled = true;
562
626
  try {
563
627
  const res = await fetch(b.dataset.approveUrl, { method: "POST" });
564
- b.textContent = res.ok ? "Approved" : "Failed";
565
- 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
+ }
566
640
  } catch {
567
641
  // Network rejection (offline, DNS, abort): mirror the !res.ok path so
568
642
  // the button doesn't sit permanently disabled reading "Approve".
@@ -630,7 +704,7 @@ function renderSiteDashboardHtml(site, reports, submissions = [], spamTotals = n
630
704
  });
631
705
  if (!res.ok) throw new Error("bad status");
632
706
  const data = await res.json();
633
- if (approveBtn) approveBtn.disabled = !data.complete;
707
+ if (approveBtn) approveBtn.disabled = !data.complete || approveBtn.dataset.sendBlocked === "1";
634
708
  } catch {
635
709
  // Revert the optimistic flip so the box reflects the (unchanged) server state.
636
710
  cb.checked = !cb.checked;
@@ -1103,7 +1177,9 @@ function renderInboxLane(model) {
1103
1177
  const subs = model.submissions ?? [];
1104
1178
  const spam = model.spam;
1105
1179
  const hasSpam = !!spam && (spam.caught > 0 || spam.through > 0);
1106
- if (subs.length === 0 && !hasSpam) return "";
1180
+ const autoFiltered = model.autoFiltered ?? 0;
1181
+ const hasAutoFiltered = autoFiltered > 0;
1182
+ if (subs.length === 0 && !hasSpam && !hasAutoFiltered) return "";
1107
1183
  const shown = [...subs].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, SUBMISSIONS_STRIP_CAP);
1108
1184
  const rows = shown.map((sub) => {
1109
1185
  const href = `/s/${escapeHtml(sub.slug)}`;
@@ -1120,10 +1196,12 @@ function renderInboxLane(model) {
1120
1196
  const more = overflow > 0 ? `<div class="approve-row subm-more muted"><a href="/submissions">+${overflow} more \u2014 view all submissions</a></div>` : `<div class="approve-row subm-more muted"><a href="/submissions">View all submissions \u2192</a></div>`;
1121
1197
  const spamLine = hasSpam ? `<div class="spam-rollup muted">\u{1F6E1} Spam (30d) \u2014 caught ${spam.caught} \xB7 through ${spam.through}</div>` : "";
1122
1198
  const spamInSummary = hasSpam ? " \xB7 \u{1F6E1} Spam (30d)" : "";
1199
+ const autoFilteredLine = hasAutoFiltered ? `<div class="spam-rollup muted">\u{1F6AB} ${autoFiltered} auto-filtered this week \u2014 <a href="/submissions?status=spam_auto">review</a></div>` : "";
1123
1200
  return `<details class="inbox">
1124
1201
  <summary>\u{1F4E5} Submissions (${subs.length} new)${spamInSummary}</summary>
1125
1202
  ${rows}${subs.length > 0 ? more : ""}
1126
1203
  ${spamLine}
1204
+ ${autoFilteredLine}
1127
1205
  </details>`;
1128
1206
  }
1129
1207
  var AUDIT_SCRIPT = `<script>