@reddoorla/maintenance 0.46.0 → 0.47.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
@@ -607,7 +607,10 @@ var init_checklist = __esm({
607
607
  label: "Interactions & Animations",
608
608
  field: "Test: Interactions & Animations"
609
609
  },
610
- { key: "updates", label: "Verified After Updates", field: "Test: Verified After Updates" }
610
+ // `field` keeps its original Airtable column name ("Verified After Updates") even though
611
+ // the client-facing label is now "Tested After Updates" — the column holds operator data,
612
+ // so renaming the label is display-only and avoids a live-base column migration.
613
+ { key: "updates", label: "Tested After Updates", field: "Test: Verified After Updates" }
611
614
  ];
612
615
  ALL_CHECKLIST_FIELDS = [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map(
613
616
  (i) => i.field
@@ -701,7 +704,7 @@ async function createDraft(base, input) {
701
704
  async function setDraftReady(base, recordId, ready) {
702
705
  await base(REPORTS_TABLE).update([{ id: recordId, fields: { "Draft ready": ready } }]);
703
706
  }
704
- async function updateReportScores(base, recordId, scores, completedOn) {
707
+ async function updateReportScores(base, recordId, scores, completedOn, enrichment) {
705
708
  const fields = {
706
709
  "Lighthouse \u2014 Performance": scores.performance,
707
710
  "Lighthouse \u2014 Accessibility": scores.accessibility,
@@ -709,6 +712,14 @@ async function updateReportScores(base, recordId, scores, completedOn) {
709
712
  "Lighthouse \u2014 SEO": scores.seo
710
713
  };
711
714
  if (completedOn) fields["Completed on"] = ymd(completedOn);
715
+ if (enrichment?.gaUsersCurrent !== void 0)
716
+ fields["GA users (period)"] = enrichment.gaUsersCurrent;
717
+ if (enrichment?.gaUsersPrevious !== void 0)
718
+ fields["GA users (prev period)"] = enrichment.gaUsersPrevious;
719
+ if (enrichment?.searchFoundPage1 !== void 0)
720
+ fields["Search found page 1"] = enrichment.searchFoundPage1;
721
+ if (enrichment?.searchPosition !== void 0)
722
+ fields["Search position"] = enrichment.searchPosition;
712
723
  await base(REPORTS_TABLE).update([{ id: recordId, fields }]);
713
724
  }
714
725
  async function listSendableReports(base) {
@@ -808,7 +819,7 @@ var init_copy = __esm({
808
819
  "Links & Navigation",
809
820
  "Form Functionality",
810
821
  "Interactions & Animations",
811
- "Verified After Updates"
822
+ "Tested After Updates"
812
823
  ],
813
824
  notesHeader: "NOTES",
814
825
  seoCta: "Contact us if you are interested in more in-depth data or have questions about SEO.",
@@ -828,6 +839,7 @@ var init_copy = __esm({
828
839
  announceTestingLabel: "Full site testing",
829
840
  announceMaintenanceLabel: "Routine maintenance",
830
841
  announcePreviewLabel: "From your latest full site test:",
842
+ announceScoreNote: "These are independent Google Lighthouse scores, each out of 100 \u2014 higher is better.",
831
843
  announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
832
844
  announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
833
845
  announceCadence: "After each one we'll send you a short report like this \u2014 there's nothing you need to do.",
@@ -1048,7 +1060,7 @@ function buildMjml(data) {
1048
1060
  <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.performance}</mj-text>
1049
1061
  <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 50\u201389 // Ideal 90\u2013100</mj-text>
1050
1062
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1051
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Readability</mj-text>
1063
+ <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Readability (A11y)</mj-text>
1052
1064
  <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.accessibility}</mj-text>
1053
1065
  <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 80\u201399 // Ideal 100</mj-text>
1054
1066
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
@@ -1169,6 +1181,22 @@ var init_template2 = __esm({
1169
1181
  });
1170
1182
 
1171
1183
  // src/reports/announcement-email/template.ts
1184
+ function fmtVisitors(n) {
1185
+ return n.toLocaleString("en-US");
1186
+ }
1187
+ function visitorTrend(cur, prev) {
1188
+ if (cur === void 0 || prev === void 0 || prev === 0) return null;
1189
+ const pct = Math.round((cur - prev) / prev * 100);
1190
+ if (pct > 0) return `\u25B2 ${pct}% vs the previous month`;
1191
+ if (pct < 0) return `\u25BC ${Math.abs(pct)}% vs the previous month`;
1192
+ return "No change vs the previous month";
1193
+ }
1194
+ function announcementSiteExtras(site) {
1195
+ return {
1196
+ cadence: { maintenance: site.maintenanceFreq, testing: site.testingFreq },
1197
+ improvements: { resendForms: true, svelte5: true }
1198
+ };
1199
+ }
1172
1200
  function buildAnnouncementMjml(data) {
1173
1201
  const copy = data.copy ?? DEFAULT_COPY;
1174
1202
  const previewText = "Your monthly report from Reddoor";
@@ -1203,8 +1231,10 @@ function buildAnnouncementMjml(data) {
1203
1231
  <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">${escapeXml(copy.announceCadenceHeading)}</mj-text>
1204
1232
  ${cadenceBlocks.map(
1205
1233
  (b) => `
1206
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="400" line-height="24px" padding-top="12px" padding-bottom="0px">\u2022 ${escapeXml(b.line)}</mj-text>
1207
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="13px" font-weight="300" line-height="20px" padding-top="2px" padding-bottom="0px" padding-left="16px">${escapeXml(b.checks.join(" \xB7 "))}</mj-text>`
1234
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="400" line-height="24px" padding-top="12px" padding-bottom="2px">\u2022 ${escapeXml(b.line)}</mj-text>${b.checks.map(
1235
+ (c) => `
1236
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="14px" font-weight="300" line-height="22px" padding-top="1px" padding-bottom="0px" padding-left="16px">${escapeXml(c)} <img src="${CHECK_PNG2}" alt="\u2713" width="14" height="14" style="vertical-align:middle;display:inline-block;margin-left:2px;" /></mj-text>`
1237
+ ).join("")}`
1208
1238
  ).join("")}
1209
1239
  <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="14px">${escapeXml(copy.announceCadence)}</mj-text>
1210
1240
  </mj-column>
@@ -1214,6 +1244,23 @@ function buildAnnouncementMjml(data) {
1214
1244
  <mj-text color="${RED2}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
1215
1245
  <mj-text color="${RED2}" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse[key]}</mj-text>`
1216
1246
  ).join("");
1247
+ const scoreNote = copy.announceScoreNote ? `
1248
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" font-style="italic" line-height="18px" padding-top="16px">${escapeXml(copy.announceScoreNote)}</mj-text>` : "";
1249
+ const trend = visitorTrend(data.gaUsersCurrent, data.gaUsersPrevious);
1250
+ const trafficRows = [];
1251
+ if (data.gaUsersCurrent !== void 0)
1252
+ trafficRows.push(`
1253
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="8px"><span style="color:${RED2};font-size:22px;font-weight:400;">${escapeXml(fmtVisitors(data.gaUsersCurrent))}</span> visitors in the last month${trend ? ` \u2014 ${escapeXml(trend)}` : ""}</mj-text>`);
1254
+ if (data.searchPosition !== void 0)
1255
+ trafficRows.push(`
1256
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="4px">Page 1 Google result (#${data.searchPosition}) for your brand search</mj-text>`);
1257
+ const trafficSection = trafficRows.length > 0 ? `
1258
+ <mj-section background-color="white">
1259
+ <mj-column>
1260
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">TRAFFIC &amp; SEARCH</mj-text>
1261
+ ${trafficRows.join("")}
1262
+ </mj-column>
1263
+ </mj-section>` : "";
1217
1264
  const contactRows = copy.contact.map(
1218
1265
  (line) => `
1219
1266
  <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
@@ -1248,9 +1295,10 @@ function buildAnnouncementMjml(data) {
1248
1295
  <mj-section background-color="#F4F4F4">
1249
1296
  <mj-column>
1250
1297
  <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.announcePreviewLabel)}</mj-text>
1251
- ${scoreRows}
1298
+ ${scoreRows}${scoreNote}
1252
1299
  </mj-column>
1253
1300
  </mj-section>
1301
+ ${trafficSection}
1254
1302
  <mj-section background-color="white">
1255
1303
  <mj-column>
1256
1304
  <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>
@@ -1270,12 +1318,13 @@ function buildAnnouncementMjml(data) {
1270
1318
  </mj-body>
1271
1319
  </mjml>`;
1272
1320
  }
1273
- var FREQ_PHRASE, RED2, GREY2, SCORE_PREVIEW;
1321
+ var FREQ_PHRASE, RED2, GREY2, CHECK_PNG2, SCORE_PREVIEW;
1274
1322
  var init_template3 = __esm({
1275
1323
  "src/reports/announcement-email/template.ts"() {
1276
1324
  "use strict";
1277
1325
  init_copy();
1278
1326
  init_template();
1327
+ init_assets();
1279
1328
  FREQ_PHRASE = {
1280
1329
  Monthly: "every month",
1281
1330
  Quarterly: "every quarter",
@@ -1283,9 +1332,10 @@ var init_template3 = __esm({
1283
1332
  };
1284
1333
  RED2 = "#C00";
1285
1334
  GREY2 = "#757575";
1335
+ CHECK_PNG2 = `cid:${CHECK_CID}`;
1286
1336
  SCORE_PREVIEW = [
1287
1337
  { label: "Performance", key: "performance" },
1288
- { label: "Readability", key: "accessibility" },
1338
+ { label: "Readability (A11y)", key: "accessibility" },
1289
1339
  { label: "Best Practices", key: "bestPractices" },
1290
1340
  { label: "Site Structure", key: "seo" }
1291
1341
  ];
@@ -1951,7 +2001,12 @@ async function sendOne(client, base, site, report) {
1951
2001
  headerImageCid: cidName,
1952
2002
  headerWidth: header.displayWidth,
1953
2003
  headerHeight: header.displayHeight,
1954
- headerBgColor: header.placeholderColor
2004
+ headerBgColor: header.placeholderColor,
2005
+ // Announcement-only: re-derive cadence + improvements from the site row so the SENT email
2006
+ // keeps its WHAT TO EXPECT section + improvement callouts. Without this the send-time
2007
+ // re-render drops them entirely (they're not stored on the Reports row). Ignored by the
2008
+ // other report templates.
2009
+ ...report.reportType === "Announcement" ? announcementSiteExtras(site) : {}
1955
2010
  });
1956
2011
  const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
1957
2012
  const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;
@@ -2037,6 +2092,7 @@ var init_orchestrate = __esm({
2037
2092
  init_attachments();
2038
2093
  init_render();
2039
2094
  init_copy();
2095
+ init_template3();
2040
2096
  init_assets();
2041
2097
  init_header_image();
2042
2098
  init_resend();
@@ -5776,6 +5832,7 @@ import { JWT as JWT2 } from "google-auth-library";
5776
5832
  var WEBMASTERS_READONLY = "https://www.googleapis.com/auth/webmasters.readonly";
5777
5833
  var SC_BASE = "https://searchconsole.googleapis.com/webmasters/v3";
5778
5834
  var PAGE_1_MAX_POSITION = 10;
5835
+ var BRAND_QUERY_ROW_LIMIT = 100;
5779
5836
  function bareHost(s) {
5780
5837
  return s.trim().replace(/^sc-domain:/i, "").replace(/^https?:\/\//i, "").split("/")[0].replace(/^www\./i, "").toLowerCase();
5781
5838
  }
@@ -5789,6 +5846,25 @@ function resolvePropertyCandidates(entries, host) {
5789
5846
  function ymd3(d) {
5790
5847
  return d.toISOString().slice(0, 10);
5791
5848
  }
5849
+ function pickBrandQuery(rows) {
5850
+ let best;
5851
+ for (const r of rows) {
5852
+ if (typeof r.position !== "number") continue;
5853
+ const impressions = r.impressions ?? 0;
5854
+ if (best === void 0 || impressions > best.impressions || impressions === best.impressions && r.position < best.position) {
5855
+ best = { position: r.position, impressions };
5856
+ }
5857
+ }
5858
+ return best?.position;
5859
+ }
5860
+ function selectBrandPosition(rows, exactQuery) {
5861
+ const want = exactQuery.toLowerCase();
5862
+ const exact = rows.find(
5863
+ (r) => typeof r.position === "number" && (r.keys?.[0] ?? "").toLowerCase() === want
5864
+ );
5865
+ if (exact) return exact.position;
5866
+ return pickBrandQuery(rows);
5867
+ }
5792
5868
  async function fetchSearchPresence(q, periodStart, periodEnd) {
5793
5869
  const key = JSON.parse(readFileSync4(q.keyPath, "utf8"));
5794
5870
  const jwt = new JWT2({
@@ -5820,14 +5896,14 @@ async function fetchSearchPresence(q, periodStart, periodEnd) {
5820
5896
  dimensionFilterGroups: [
5821
5897
  {
5822
5898
  filters: [
5823
- { dimension: "query", operator: "equals", expression: q.query.toLowerCase() }
5899
+ { dimension: "query", operator: "contains", expression: q.query.toLowerCase() }
5824
5900
  ]
5825
5901
  }
5826
5902
  ],
5827
- rowLimit: 1
5903
+ rowLimit: BRAND_QUERY_ROW_LIMIT
5828
5904
  }
5829
5905
  });
5830
- const pos = res.data.rows?.[0]?.position;
5906
+ const pos = selectBrandPosition(res.data.rows ?? [], q.query.toLowerCase());
5831
5907
  if (typeof pos === "number") {
5832
5908
  return { foundOnPage1: pos <= PAGE_1_MAX_POSITION, position: Math.max(1, Math.round(pos)) };
5833
5909
  }
@@ -6419,6 +6495,7 @@ init_reports();
6419
6495
  init_attachments();
6420
6496
  init_render();
6421
6497
  init_copy();
6498
+ init_template3();
6422
6499
  async function announce(deps) {
6423
6500
  const base = deps?.base ?? openBase(readAirtableConfig());
6424
6501
  const now = deps?.now ?? /* @__PURE__ */ new Date();
@@ -6437,15 +6514,24 @@ async function announce(deps) {
6437
6514
  results.push({ site: w.name, status: "skipped-no-scores" });
6438
6515
  continue;
6439
6516
  }
6517
+ const periodEnd = now;
6518
+ const periodStart = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1e3);
6519
+ const gaUsers = (await fetchGaUsers(w, periodStart, periodEnd)).value;
6520
+ const search = (await fetchSearch(w, periodStart, periodEnd)).value;
6521
+ const enrichment = {
6522
+ ...gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {},
6523
+ ...search ? { searchFoundPage1: search.foundOnPage1 } : {},
6524
+ ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
6525
+ };
6440
6526
  let report;
6441
6527
  let statusKind;
6442
6528
  const existing = await findReportByPeriod(base, w.id, "Announcement", period);
6443
6529
  if (existing) {
6444
- await updateReportScores(base, existing.id, scores, now);
6530
+ await updateReportScores(base, existing.id, scores, now, enrichment);
6445
6531
  report = existing;
6446
6532
  statusKind = "reused";
6447
6533
  } else {
6448
- report = await createDraft(base, draftInputFor2(w, scores, now, period));
6534
+ report = await createDraft(base, draftInputFor2(w, scores, now, period, enrichment));
6449
6535
  statusKind = "drafted";
6450
6536
  }
6451
6537
  const slug = siteSlug(w.name);
@@ -6455,18 +6541,17 @@ async function announce(deps) {
6455
6541
  reportType: "Announcement",
6456
6542
  completedOn: now,
6457
6543
  lighthouse: scores,
6544
+ gaUsersCurrent: gaUsers?.current,
6545
+ gaUsersPrevious: gaUsers?.previous,
6546
+ searchPosition: search?.foundOnPage1 ? search.position ?? void 0 : void 0,
6458
6547
  lastTestedDate: null,
6459
6548
  commentary: null,
6460
6549
  copy: resolveCopy(w),
6461
6550
  headerImageCid: `${slug}-header`,
6462
- // The client's go-forward pace, read straight off the Websites row — the email
6463
- // states each cadence ("Full site testing every quarter", etc.); a "None" pace
6464
- // is omitted so we never claim a cadence the site isn't on.
6465
- cadence: { maintenance: w.maintenanceFreq, testing: w.testingFreq },
6466
- // Default-on fleet-wide for v1: both recent-improvement callouts render for every
6467
- // site. Operator review (the draft never auto-sends) is the relevance backstop;
6468
- // per-site conditioning of these flags is a future lever, not a v1 requirement.
6469
- improvements: { resendForms: true, svelte5: true }
6551
+ // cadence (the client's go-forward pace, "None" omitted) + default-on improvement
6552
+ // callouts. Shared with the send re-render via announcementSiteExtras so the sent
6553
+ // email matches this reviewed preview.
6554
+ ...announcementSiteExtras(w)
6470
6555
  });
6471
6556
  try {
6472
6557
  await uploadAttachment(
@@ -6505,7 +6590,7 @@ function scoresFromRow(w) {
6505
6590
  seo: w.seoScore
6506
6591
  };
6507
6592
  }
6508
- function draftInputFor2(w, scores, now, period) {
6593
+ function draftInputFor2(w, scores, now, period, enrichment) {
6509
6594
  return {
6510
6595
  reportId: `${w.name} \u2014 Announcement \u2014 ${now.toISOString().slice(0, 10)}`,
6511
6596
  siteId: w.id,
@@ -6516,7 +6601,8 @@ function draftInputFor2(w, scores, now, period) {
6516
6601
  completedOn: now,
6517
6602
  lighthouse: scores,
6518
6603
  lastTestedDate: null,
6519
- subjectOverride: `Your testing & maintenance schedule for ${w.name}`
6604
+ subjectOverride: `Your testing & maintenance schedule for ${w.name}`,
6605
+ ...enrichment
6520
6606
  };
6521
6607
  }
6522
6608