@reddoorla/maintenance 0.41.0 → 0.43.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.
package/dist/cli/bin.js CHANGED
@@ -575,6 +575,39 @@ var init_write_audits_to_airtable = __esm({
575
575
  }
576
576
  });
577
577
 
578
+ // src/reports/checklist.ts
579
+ function checklistFor(type) {
580
+ return type === "Maintenance" ? MAINTENANCE_CHECKLIST : type === "Testing" ? TESTING_CHECKLIST : [];
581
+ }
582
+ function isChecklistComplete(report) {
583
+ return checklistFor(report.reportType).every((i) => report.checklist[i.field] === true);
584
+ }
585
+ var MAINTENANCE_CHECKLIST, TESTING_CHECKLIST, ALL_CHECKLIST_FIELDS;
586
+ var init_checklist = __esm({
587
+ "src/reports/checklist.ts"() {
588
+ "use strict";
589
+ MAINTENANCE_CHECKLIST = [
590
+ { key: "logs", label: "Reviewed Logs", field: "Maint: Reviewed Logs" },
591
+ { key: "cms", label: "CMS Checked", field: "Maint: CMS Checked" },
592
+ { key: "dns", label: "DNS Checked", field: "Maint: DNS Checked" },
593
+ { key: "google", label: "Google Indexed", field: "Maint: Google Indexed" },
594
+ { key: "cert", label: "Reviewed Certificate", field: "Maint: Reviewed Certificate" },
595
+ { key: "security", label: "Security Updates", field: "Maint: Security Updates" }
596
+ ];
597
+ TESTING_CHECKLIST = [
598
+ { key: "desktop", label: "Desktop Browsers", field: "Test: Desktop Browsers" },
599
+ { key: "mobile", label: "Mobile Browsers", field: "Test: Mobile Browsers" },
600
+ { key: "packages", label: "Package Updates", field: "Test: Package Updates" },
601
+ { key: "bottle", label: "Bottlenecks", field: "Test: Bottlenecks" },
602
+ { key: "forms", label: "Form Functionality", field: "Test: Form Functionality" },
603
+ { key: "animation", label: "Animation Functionality", field: "Test: Animation Functionality" }
604
+ ];
605
+ ALL_CHECKLIST_FIELDS = [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map(
606
+ (i) => i.field
607
+ );
608
+ }
609
+ });
610
+
578
611
  // src/reports/airtable/reports.ts
579
612
  function toReportType(raw) {
580
613
  if (raw && REPORT_TYPES.includes(raw)) return raw;
@@ -613,7 +646,8 @@ function mapRow2(rec) {
613
646
  approvedBy: f["Approved By"] ?? null,
614
647
  deliveryStatus: f["Delivery status"] ?? "pending",
615
648
  renderedHtmlAttachment: html,
616
- resendMessageId: f["Resend message ID"] ?? null
649
+ resendMessageId: f["Resend message ID"] ?? null,
650
+ checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])]))
617
651
  };
618
652
  }
619
653
  function lighthouseFromFields(f) {
@@ -651,6 +685,7 @@ async function createDraft(base, input) {
651
685
  if (input.searchFoundPage1 !== void 0) fields["Search found page 1"] = input.searchFoundPage1;
652
686
  if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
653
687
  if (input.period !== void 0) fields["Period"] = input.period;
688
+ if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
654
689
  const created = await base(REPORTS_TABLE).create([{ fields }]);
655
690
  const rec = created[0];
656
691
  if (!rec) throw new Error("Airtable create returned no records");
@@ -716,8 +751,9 @@ var REPORTS_TABLE, REPORT_TYPES;
716
751
  var init_reports = __esm({
717
752
  "src/reports/airtable/reports.ts"() {
718
753
  "use strict";
754
+ init_checklist();
719
755
  REPORTS_TABLE = "Reports";
720
- REPORT_TYPES = ["Maintenance", "Testing", "Launch"];
756
+ REPORT_TYPES = ["Maintenance", "Testing", "Launch", "Announcement"];
721
757
  }
722
758
  });
723
759
 
@@ -777,7 +813,18 @@ var init_copy = __esm({
777
813
  "Hosting, DNS, and SSL configured",
778
814
  "Continuous integration + automatic dependency updates",
779
815
  "Analytics and uptime monitoring"
780
- ]
816
+ ],
817
+ announceHeading: "YOUR ONGOING SITE CARE",
818
+ announceBody: "We've completed a full test of your site and set it up for ongoing care to keep it fast, secure, and healthy. Here's what you can expect from us going forward:",
819
+ announceCadenceHeading: "WHAT TO EXPECT",
820
+ announceTestingLabel: "Full site testing",
821
+ announceMaintenanceLabel: "Routine maintenance",
822
+ announceMonitorItems: ["Performance", "Accessibility", "Security", "Uptime"],
823
+ announcePreviewLabel: "From your latest full site test:",
824
+ announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
825
+ announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
826
+ announceCadence: "After each one we'll send you a short report like this \u2014 there's nothing you need to do.",
827
+ announceOpenDoor: "And if you'd ever like to expand the scope, add features, or freshen anything up, just reply \u2014 we'd love to help."
781
828
  };
782
829
  }
783
830
  });
@@ -1114,10 +1161,137 @@ var init_template2 = __esm({
1114
1161
  }
1115
1162
  });
1116
1163
 
1164
+ // src/reports/announcement-email/template.ts
1165
+ function buildAnnouncementMjml(data) {
1166
+ const copy = data.copy ?? DEFAULT_COPY;
1167
+ const previewText = "Your monthly report from Reddoor";
1168
+ const improvementItems = [];
1169
+ if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);
1170
+ if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);
1171
+ const improvementsSection = improvementItems.length > 0 ? `
1172
+ <mj-section background-color="white">
1173
+ <mj-column>
1174
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">RECENT IMPROVEMENTS</mj-text>
1175
+ ${improvementItems.map(
1176
+ (item) => `
1177
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="4px" padding-bottom="4px">\u2022 ${escapeXml(item)}</mj-text>`
1178
+ ).join("")}
1179
+ </mj-column>
1180
+ </mj-section>` : "";
1181
+ const cad = data.cadence;
1182
+ const cadenceLines = [];
1183
+ if (cad && cad.testing !== "None")
1184
+ cadenceLines.push(`${copy.announceTestingLabel} \u2014 ${FREQ_PHRASE[cad.testing]}`);
1185
+ if (cad && cad.maintenance !== "None")
1186
+ cadenceLines.push(`${copy.announceMaintenanceLabel} \u2014 ${FREQ_PHRASE[cad.maintenance]}`);
1187
+ const cadenceSection = cadenceLines.length > 0 ? `
1188
+ <mj-section background-color="white">
1189
+ <mj-column>
1190
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">${escapeXml(copy.announceCadenceHeading)}</mj-text>
1191
+ ${cadenceLines.map(
1192
+ (line) => `
1193
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="4px" padding-bottom="4px">\u2022 ${escapeXml(line)}</mj-text>`
1194
+ ).join("")}
1195
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="12px">${escapeXml(copy.announceCadence)}</mj-text>
1196
+ </mj-column>
1197
+ </mj-section>` : "";
1198
+ const monitorRows = copy.announceMonitorItems.map(
1199
+ (item) => `
1200
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="4px" padding-bottom="4px">\u2022 ${escapeXml(item)}</mj-text>`
1201
+ ).join("");
1202
+ const scoreRows = SCORE_PREVIEW.map(
1203
+ ({ label, key }) => `
1204
+ <mj-text color="${RED2}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
1205
+ <mj-text color="${RED2}" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse[key]}</mj-text>`
1206
+ ).join("");
1207
+ const contactRows = copy.contact.map(
1208
+ (line) => `
1209
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
1210
+ ).join("");
1211
+ const footerAddressRows = copy.footerAddress.map(
1212
+ (line) => `
1213
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(line)}</mj-text>`
1214
+ ).join("");
1215
+ return `<mjml>
1216
+ <mj-head>
1217
+ <mj-attributes>
1218
+ <mj-text font-family="helvetica, sans-serif" padding-left="5px" padding-right="5px" />
1219
+ <mj-section padding-left="11%" padding-right="11%"/>
1220
+ <mj-image padding="0px" />
1221
+ </mj-attributes>
1222
+ <mj-preview>${escapeXml(previewText)}</mj-preview>
1223
+ ${headerStyleBlock(data)}
1224
+ </mj-head>
1225
+ <mj-body background-color="white">
1226
+ <mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
1227
+ <mj-column>${headerImageTag(data)}</mj-column>
1228
+ </mj-section>
1229
+ <mj-section background-color="white">
1230
+ <mj-column>
1231
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.announceHeading)}</mj-text>
1232
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="20px">Prepared for ${escapeXml(data.siteName)}</mj-text>
1233
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="8px">${escapeXml(copy.announceBody)}</mj-text>
1234
+ </mj-column>
1235
+ </mj-section>
1236
+ ${cadenceSection}
1237
+ ${improvementsSection}
1238
+ <mj-section background-color="white">
1239
+ <mj-column>
1240
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">WHAT WE MONITOR</mj-text>
1241
+ ${monitorRows}
1242
+ </mj-column>
1243
+ </mj-section>
1244
+ <mj-section background-color="#F4F4F4">
1245
+ <mj-column>
1246
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.announcePreviewLabel)}</mj-text>
1247
+ ${scoreRows}
1248
+ </mj-column>
1249
+ </mj-section>
1250
+ <mj-section background-color="white">
1251
+ <mj-column>
1252
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="36px">${escapeXml(copy.announceOpenDoor)}</mj-text>
1253
+ </mj-column>
1254
+ </mj-section>
1255
+ <mj-section background-color="white">
1256
+ <mj-column padding-top="36px">
1257
+ <mj-text color="${RED2}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
1258
+ ${contactRows}
1259
+ <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1260
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>
1261
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="700" line-height="16px" padding-top="0" padding-bottom="0px">Our mailing address is:</mj-text>
1262
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(copy.footerOrg)}</mj-text>
1263
+ ${footerAddressRows}
1264
+ </mj-column>
1265
+ </mj-section>
1266
+ </mj-body>
1267
+ </mjml>`;
1268
+ }
1269
+ var FREQ_PHRASE, RED2, GREY2, SCORE_PREVIEW;
1270
+ var init_template3 = __esm({
1271
+ "src/reports/announcement-email/template.ts"() {
1272
+ "use strict";
1273
+ init_copy();
1274
+ init_template();
1275
+ FREQ_PHRASE = {
1276
+ Monthly: "every month",
1277
+ Quarterly: "every quarter",
1278
+ Yearly: "every year"
1279
+ };
1280
+ RED2 = "#C00";
1281
+ GREY2 = "#757575";
1282
+ SCORE_PREVIEW = [
1283
+ { label: "Performance", key: "performance" },
1284
+ { label: "Readability", key: "accessibility" },
1285
+ { label: "Best Practices", key: "bestPractices" },
1286
+ { label: "Site Structure", key: "seo" }
1287
+ ];
1288
+ }
1289
+ });
1290
+
1117
1291
  // src/reports/render.ts
1118
1292
  import mjml2html from "mjml";
1119
1293
  async function renderReportHtml(data) {
1120
- const mjml = data.reportType === "Launch" ? buildLaunchMjml(data) : buildMjml(data);
1294
+ const mjml = data.reportType === "Launch" ? buildLaunchMjml(data) : data.reportType === "Announcement" ? buildAnnouncementMjml(data) : buildMjml(data);
1121
1295
  const out = await mjml2html(mjml, { validationLevel: "strict" });
1122
1296
  return { html: out.html, warnings: out.errors ?? [] };
1123
1297
  }
@@ -1126,6 +1300,7 @@ var init_render = __esm({
1126
1300
  "use strict";
1127
1301
  init_template();
1128
1302
  init_template2();
1303
+ init_template3();
1129
1304
  }
1130
1305
  });
1131
1306
 
@@ -1419,16 +1594,16 @@ __export(digest_exports, {
1419
1594
  runDigest: () => runDigest
1420
1595
  });
1421
1596
  function readySection(items) {
1422
- const heading = `<h2 style="color:${RED2};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Ready for your yes</h2>`;
1597
+ const heading = `<h2 style="color:${RED3};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Ready for your yes</h2>`;
1423
1598
  if (items.length === 0) {
1424
- return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
1599
+ return `${heading}<p style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
1425
1600
  }
1426
1601
  const rows = items.map((it) => {
1427
1602
  const safeUrl = it.dashboardUrl.startsWith("https://") ? it.dashboardUrl : void 0;
1428
1603
  const link = safeUrl ? `<a href="${escapeHtml(safeUrl)}" style="${ANCHOR_STYLE}">review &amp; approve</a>` : `review &amp; approve`;
1429
1604
  return `
1430
1605
  <tr>
1431
- <td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
1606
+ <td style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
1432
1607
  <strong style="color:#222">${escapeHtml(it.siteName)}</strong> \u2014 ${escapeHtml(it.reportType)} (${escapeHtml(it.period)})
1433
1608
  \u2014 ${link}
1434
1609
  </td>
@@ -1438,15 +1613,15 @@ function readySection(items) {
1438
1613
  }
1439
1614
  function attentionBadge(status) {
1440
1615
  if (status === "new")
1441
- return `<strong style="color:${RED2};font-family:helvetica,sans-serif">NEW</strong> `;
1616
+ return `<strong style="color:${RED3};font-family:helvetica,sans-serif">NEW</strong> `;
1442
1617
  if (status === "worse")
1443
- return `<strong style="color:${RED2};font-family:helvetica,sans-serif">WORSE</strong> `;
1618
+ return `<strong style="color:${RED3};font-family:helvetica,sans-serif">WORSE</strong> `;
1444
1619
  return "";
1445
1620
  }
1446
1621
  function attentionSection(items) {
1447
- const heading = `<h2 style="color:${RED2};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Needs attention</h2>`;
1622
+ const heading = `<h2 style="color:${RED3};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Needs attention</h2>`;
1448
1623
  if (items.length === 0) {
1449
- return `${heading}<p style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
1624
+ return `${heading}<p style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
1450
1625
  }
1451
1626
  const bySite = /* @__PURE__ */ new Map();
1452
1627
  for (const it of items) {
@@ -1463,7 +1638,7 @@ function attentionSection(items) {
1463
1638
  const titleHtml = safeUrl ? `<a href="${escapeHtml(safeUrl)}" style="${ANCHOR_STYLE}">${escapeHtml(it.title)}</a>` : escapeHtml(it.title);
1464
1639
  return `
1465
1640
  <tr>
1466
- <td style="color:${GREY2};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${attentionBadge(it.status)}${titleHtml}</td>
1641
+ <td style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${attentionBadge(it.status)}${titleHtml}</td>
1467
1642
  </tr>`;
1468
1643
  }).join("");
1469
1644
  return `
@@ -1588,7 +1763,7 @@ function renderDigestHtml(sections) {
1588
1763
  <table width="600" style="border-collapse:collapse">
1589
1764
  <tr>
1590
1765
  <td>
1591
- <h1 style="color:${RED2};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
1766
+ <h1 style="color:${RED3};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
1592
1767
  ${readySection(sections.readyForYourYes)}
1593
1768
  ${attentionSection(sections.needsAttention)}
1594
1769
  </td>
@@ -1600,7 +1775,7 @@ function renderDigestHtml(sections) {
1600
1775
  </body>
1601
1776
  </html>`;
1602
1777
  }
1603
- var GREY2, RED2, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
1778
+ var GREY3, RED3, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
1604
1779
  var init_digest = __esm({
1605
1780
  "src/reports/digest.ts"() {
1606
1781
  "use strict";
@@ -1612,9 +1787,9 @@ var init_digest = __esm({
1612
1787
  init_digest_collectors();
1613
1788
  init_digest_state();
1614
1789
  init_html();
1615
- GREY2 = "#757575";
1616
- RED2 = "#C00";
1617
- ANCHOR_STYLE = `color:${RED2};font-family:helvetica,sans-serif`;
1790
+ GREY3 = "#757575";
1791
+ RED3 = "#C00";
1792
+ ANCHOR_STYLE = `color:${RED3};font-family:helvetica,sans-serif`;
1618
1793
  SEVERITY_ORDER = { critical: 0, warning: 1 };
1619
1794
  FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
1620
1795
  DIGEST_OPERATOR_FALLBACK = "info@reddoorla.com";
@@ -1712,6 +1887,13 @@ async function sendApprovedReports(options = {}) {
1712
1887
  return { output: lines.join("\n"), code: anyFailed ? 1 : 0 };
1713
1888
  }
1714
1889
  async function sendOne(client, base, site, report) {
1890
+ if (!isChecklistComplete(report)) {
1891
+ const items = checklistFor(report.reportType);
1892
+ const done = items.filter((i) => report.checklist[i.field] === true).length;
1893
+ throw new Error(
1894
+ `Report ${report.reportId} checklist incomplete \u2014 ${done}/${items.length} items checked`
1895
+ );
1896
+ }
1715
1897
  if (!site.headerImage) {
1716
1898
  throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
1717
1899
  }
@@ -1855,6 +2037,7 @@ var init_orchestrate = __esm({
1855
2037
  init_header_image();
1856
2038
  init_resend();
1857
2039
  init_idempotency();
2040
+ init_checklist();
1858
2041
  FROM_ADDRESS2 = "Reddoor Reports <reports@reddoorla.com>";
1859
2042
  REPLY_TO = "info@reddoorla.com";
1860
2043
  MONTHS2 = [
@@ -6211,6 +6394,131 @@ async function runLaunchCommand(site, opts) {
6211
6394
  return { output: formatResult9(result), code: result.complete ? 0 : 1 };
6212
6395
  }
6213
6396
 
6397
+ // src/recipes/announce.ts
6398
+ init_client();
6399
+ init_websites();
6400
+ init_reports();
6401
+ init_attachments();
6402
+ init_render();
6403
+ init_copy();
6404
+ async function announce(deps) {
6405
+ const base = deps?.base ?? openBase(readAirtableConfig());
6406
+ const now = deps?.now ?? /* @__PURE__ */ new Date();
6407
+ const websites = await listWebsites(base);
6408
+ let targets = websites.filter((w) => w.status === "maintenance");
6409
+ if (deps?.site) {
6410
+ const wanted = siteSlug(deps.site);
6411
+ targets = targets.filter((w) => siteSlug(w.name) === wanted);
6412
+ }
6413
+ const period = now.toISOString().slice(0, 7);
6414
+ const results = [];
6415
+ for (const w of targets) {
6416
+ try {
6417
+ const scores = scoresFromRow(w);
6418
+ if (scores === null) {
6419
+ results.push({ site: w.name, status: "skipped-no-scores" });
6420
+ continue;
6421
+ }
6422
+ let report;
6423
+ let statusKind;
6424
+ const existing = await findReportByPeriod(base, w.id, "Announcement", period);
6425
+ if (existing) {
6426
+ await updateReportScores(base, existing.id, scores, now);
6427
+ report = existing;
6428
+ statusKind = "reused";
6429
+ } else {
6430
+ report = await createDraft(base, draftInputFor2(w, scores, now, period));
6431
+ statusKind = "drafted";
6432
+ }
6433
+ const slug = siteSlug(w.name);
6434
+ const { html } = await renderReportHtml({
6435
+ siteName: w.name,
6436
+ siteUrl: w.url,
6437
+ reportType: "Announcement",
6438
+ completedOn: now,
6439
+ lighthouse: scores,
6440
+ lastTestedDate: null,
6441
+ commentary: null,
6442
+ copy: resolveCopy(w),
6443
+ headerImageCid: `${slug}-header`,
6444
+ // The client's go-forward pace, read straight off the Websites row — the email
6445
+ // states each cadence ("Full site testing — every quarter", etc.); a "None" pace
6446
+ // is omitted so we never claim a cadence the site isn't on.
6447
+ cadence: { maintenance: w.maintenanceFreq, testing: w.testingFreq },
6448
+ // Default-on fleet-wide for v1: both recent-improvement callouts render for every
6449
+ // site. Operator review (the draft never auto-sends) is the relevance backstop;
6450
+ // per-site conditioning of these flags is a future lever, not a v1 requirement.
6451
+ improvements: { resendForms: true, svelte5: true }
6452
+ });
6453
+ try {
6454
+ await uploadAttachment(
6455
+ report.id,
6456
+ "Rendered HTML",
6457
+ html,
6458
+ `${slug}-${now.toISOString().slice(0, 10)}.html`,
6459
+ "text/html"
6460
+ );
6461
+ } catch (uploadErr) {
6462
+ console.warn(
6463
+ `\u26A0 Announcement preview upload skipped for ${w.name}: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
6464
+ );
6465
+ }
6466
+ await setDraftReady(base, report.id, true);
6467
+ const recipientMissing = !(w.reportRecipientsTo && w.reportRecipientsTo.trim());
6468
+ results.push({ site: w.name, status: statusKind, reportId: report.id, recipientMissing });
6469
+ } catch (err) {
6470
+ results.push({
6471
+ site: w.name,
6472
+ status: "error",
6473
+ message: err instanceof Error ? err.message : String(err)
6474
+ });
6475
+ }
6476
+ }
6477
+ return { results };
6478
+ }
6479
+ function scoresFromRow(w) {
6480
+ if (w.pScore === null || w.rScore === null || w.bpScore === null || w.seoScore === null) {
6481
+ return null;
6482
+ }
6483
+ return {
6484
+ performance: w.pScore,
6485
+ accessibility: w.rScore,
6486
+ bestPractices: w.bpScore,
6487
+ seo: w.seoScore
6488
+ };
6489
+ }
6490
+ function draftInputFor2(w, scores, now, period) {
6491
+ return {
6492
+ reportId: `${w.name} \u2014 Announcement \u2014 ${now.toISOString().slice(0, 10)}`,
6493
+ siteId: w.id,
6494
+ reportType: "Announcement",
6495
+ period,
6496
+ periodStart: now,
6497
+ periodEnd: now,
6498
+ completedOn: now,
6499
+ lighthouse: scores,
6500
+ lastTestedDate: null,
6501
+ subjectOverride: `Your testing & maintenance schedule for ${w.name}`
6502
+ };
6503
+ }
6504
+
6505
+ // src/cli/commands/announce.ts
6506
+ function formatSiteResult(r) {
6507
+ if (r.status === "skipped-no-scores") return `[${r.site}] skipped-no-scores`;
6508
+ if (r.status === "error") return `[${r.site}] error: ${r.message}`;
6509
+ const note = r.recipientMissing ? " \u26A0 recipient missing" : "";
6510
+ return `[${r.site}] ${r.status}${note}`;
6511
+ }
6512
+ function formatAnnounceResult(result) {
6513
+ if (result.results.length === 0) return "No maintenance sites to announce.";
6514
+ return result.results.map(formatSiteResult).join("\n");
6515
+ }
6516
+ async function runAnnounceCommand(site, _opts) {
6517
+ const result = await announce(site ? { site } : {});
6518
+ const hadError = result.results.some((r) => r.status === "error");
6519
+ return { output: formatAnnounceResult(result), code: hadError ? 1 : 0 };
6520
+ }
6521
+
6214
6522
  // src/cli/commands/github-signals.ts
6215
6523
  init_client();
6216
6524
  init_websites();
@@ -6464,6 +6772,12 @@ cli.command(
6464
6772
  ).action(
6465
6773
  async (site, opts) => runOrExit(() => runLaunchCommand(site, opts), opts)
6466
6774
  );
6775
+ cli.command(
6776
+ "announce [site]",
6777
+ "Draft the monthly-report announcement email for maintenance sites (all, or one) for approval."
6778
+ ).action(
6779
+ async (site, opts) => runOrExit(() => runAnnounceCommand(site, opts), opts)
6780
+ );
6467
6781
  cli.command("report [site]", "Draft or send maintenance/testing reports.").option("--due", "Scan all Websites and draft overdue reports.").option(
6468
6782
  "--preview",
6469
6783
  "Single-site dry run; writes reports/<slug>/draft.html, never touches Airtable."