@reddoorla/maintenance 0.47.0 → 0.49.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
@@ -161,6 +161,9 @@ function parseNotifyRouting(raw) {
161
161
  function isDashboardVisible(site) {
162
162
  return site.status !== null && ACTIVE_STATUSES.has(site.status);
163
163
  }
164
+ function toFrequency(raw) {
165
+ return typeof raw === "string" && FREQUENCIES.includes(raw) ? raw : "None";
166
+ }
164
167
  function mapRow(rec) {
165
168
  const f = rec.fields;
166
169
  const attachments = f["Header image"] ?? [];
@@ -171,8 +174,8 @@ function mapRow(rec) {
171
174
  url: String(f["url"] ?? ""),
172
175
  status: f["Status"] ?? null,
173
176
  pointOfContact: f["point of contact"] ?? null,
174
- maintenanceFreq: f["maintenence freq"] ?? "None",
175
- testingFreq: f["testing freq"] ?? "None",
177
+ maintenanceFreq: toFrequency(f["maintenence freq"]),
178
+ testingFreq: toFrequency(f["testing freq"]),
176
179
  maintenanceDay: f["maintenance day"] ?? null,
177
180
  testingDay: f["testing day"] ?? null,
178
181
  ga4PropertyId: f["GA4 property ID"] ?? null,
@@ -295,7 +298,7 @@ async function updateLaunched(base, recordId, at) {
295
298
  const fields = { Status: "maintenance", "Launched at": at };
296
299
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
297
300
  }
298
- var WEBSITES_TABLE, ACTIVE_STATUSES;
301
+ var WEBSITES_TABLE, ACTIVE_STATUSES, FREQUENCIES;
299
302
  var init_websites = __esm({
300
303
  "src/reports/airtable/websites.ts"() {
301
304
  "use strict";
@@ -304,6 +307,7 @@ var init_websites = __esm({
304
307
  "maintenance",
305
308
  "launch period"
306
309
  ]);
310
+ FREQUENCIES = ["None", "Monthly", "Quarterly", "Yearly"];
307
311
  }
308
312
  });
309
313
 
@@ -835,15 +839,10 @@ var init_copy = __esm({
835
839
  ],
836
840
  announceHeading: "YOUR ONGOING SITE CARE",
837
841
  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:",
838
- announceCadenceHeading: "WHAT TO EXPECT",
839
- announceTestingLabel: "Full site testing",
840
- announceMaintenanceLabel: "Routine maintenance",
841
- announcePreviewLabel: "From your latest full site test:",
842
- announceScoreNote: "These are independent Google Lighthouse scores, each out of 100 \u2014 higher is better.",
843
842
  announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
844
843
  announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
845
844
  announceCadence: "After each one we'll send you a short report like this \u2014 there's nothing you need to do.",
846
- 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."
845
+ announceOpenDoor: "And if you'd ever like to expand the scope, add features, or freshen anything up, just let us know."
847
846
  };
848
847
  }
849
848
  });
@@ -917,65 +916,124 @@ var init_html = __esm({
917
916
  }
918
917
  });
919
918
 
920
- // src/reports/maintenance-email/template.ts
921
- function fmtDate(d) {
922
- if (!d || Number.isNaN(d.getTime())) return "";
923
- const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
924
- const dd = String(d.getUTCDate()).padStart(2, "0");
925
- const yyyy = d.getUTCFullYear();
926
- return `${mm}.${dd}.${yyyy}`;
927
- }
919
+ // src/reports/email-sections.ts
928
920
  function fmtUsers(n) {
929
921
  return n.toLocaleString("en-US");
930
922
  }
931
- function trendText(color, text) {
932
- return `<mj-text color="${color}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text}</mj-text>`;
923
+ function checklistRowsSection(rows, opts) {
924
+ return rows.map((label, i) => {
925
+ const isLast = i === rows.length - 1;
926
+ const border = isLast ? "" : ` border-bottom="solid ${BORDER} 1px"`;
927
+ const lastPad = isLast ? ` padding-bottom="${opts.lastPaddingBottom}"` : "";
928
+ return `
929
+ <mj-section background-color="${opts.background}" padding="0px"${lastPad}>
930
+ <mj-group>
931
+ <mj-column padding-left="0px" width="90%"${border}>
932
+ <mj-text height="25px" padding-left="0px" color="${GREY}" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
933
+ </mj-column>
934
+ <mj-column width="10%"${border} padding-top="15px">
935
+ <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
936
+ </mj-column>
937
+ </mj-group>
938
+ </mj-section>`;
939
+ }).join("");
940
+ }
941
+ function lighthouseScoresSection(lighthouse2, opts = {}) {
942
+ const background = opts.background ?? "#F4F4F4";
943
+ const sectionPad = opts.pad ? ` padding-top="${opts.pad}" padding-bottom="${opts.pad}"` : "";
944
+ const labelTop = opts.pad ?? "55px";
945
+ const footnoteBottom = opts.pad ? "0px" : "36px";
946
+ const rows = LIGHTHOUSE_ROWS.map(
947
+ ({ label, key, range }, i) => `
948
+ <mj-text color="${RED}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
949
+ <mj-text color="${RED}" font-size="44px" font-weight="400" padding-top="0px">${lighthouse2[key]}</mj-text>
950
+ <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">${range}</mj-text>${i < LIGHTHOUSE_ROWS.length - 1 ? `
951
+ <mj-divider border-width="1px" border-style="solid" border-color="${BORDER}" padding="0" />` : ""}`
952
+ ).join("");
953
+ return `
954
+ <mj-section background-color="${background}"${sectionPad}>
955
+ <mj-column>
956
+ <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">LIGHTHOUSE SCORES*</mj-text>${rows}
957
+ <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="${footnoteBottom}" line-height="20px">*A Lighthouse score is a numerical measure provided by Google's Lighthouse tool, which evaluates various aspects of a web page's quality.</mj-text>
958
+ </mj-column>
959
+ </mj-section>`;
933
960
  }
934
961
  function analyticsTrendLine(cur, prev) {
935
962
  if (cur === void 0 || prev === void 0) {
936
- return trendText(TREND_NEUTRAL, `Last Period: ${prev !== void 0 ? fmtUsers(prev) : "\u2014"}`);
963
+ return trendLine(TREND_NEUTRAL, `Last Period: ${prev !== void 0 ? fmtUsers(prev) : "\u2014"}`);
937
964
  }
938
965
  if (prev === 0) {
939
- return cur > 0 ? trendText(TREND_UP, "\u25B2 New this period (0 last period)") : trendText(TREND_NEUTRAL, "Last Period: 0");
966
+ return cur > 0 ? trendLine(TREND_UP, "\u25B2 New this period (0 last period)") : trendLine(TREND_NEUTRAL, "Last Period: 0");
940
967
  }
941
968
  const pct = Math.round((cur - prev) / prev * 100);
942
969
  const range = `(${fmtUsers(prev)} \u2192 ${fmtUsers(cur)})`;
943
- if (pct > 0) return trendText(TREND_UP, `\u25B2 ${pct}% vs last period ${range}`);
944
- if (pct < 0) return trendText(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs last period ${range}`);
945
- return trendText(TREND_NEUTRAL, `No change vs last period (${fmtUsers(prev)})`);
970
+ if (pct > 0) return trendLine(TREND_UP, `\u25B2 ${pct}% vs last period ${range}`);
971
+ if (pct < 0) return trendLine(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs last period ${range}`);
972
+ return trendLine(TREND_NEUTRAL, `No change vs last period (${fmtUsers(prev)})`);
973
+ }
974
+ function trendLine(color, text) {
975
+ return `<mj-text color="${color}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text}</mj-text>`;
976
+ }
977
+ function footnoteLine(text) {
978
+ return `<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px">${text}</mj-text>`;
979
+ }
980
+ function analyticsSection(opts) {
981
+ const users = opts.current !== void 0 ? fmtUsers(opts.current) : "\u2014";
982
+ const body = (opts.bodyLines ?? []).map((l) => trendLine(TREND_NEUTRAL, l)).join("\n ");
983
+ const footnotes = (opts.footnoteLines ?? []).map(footnoteLine).join("\n ");
984
+ const sectionPad = opts.pad ? ` padding-top="${opts.pad}" padding-bottom="${opts.pad}"` : "";
985
+ const labelTop = opts.pad ?? "75px";
986
+ return `
987
+ <mj-section background-color="${opts.background}"${sectionPad}>
988
+ <mj-column>
989
+ <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">ANALYTICS</mj-text>
990
+ <mj-text color="${RED}" font-size="44px" font-weight="400">${users} Users</mj-text>
991
+ ${analyticsTrendLine(opts.current, opts.previous)}
992
+ ${body}
993
+ ${footnotes}
994
+ </mj-column>
995
+ </mj-section>`;
996
+ }
997
+ var escapeXml, RED, GREY, BORDER, TREND_UP, TREND_NEUTRAL, CHECK_PNG, LIGHTHOUSE_ROWS;
998
+ var init_email_sections = __esm({
999
+ "src/reports/email-sections.ts"() {
1000
+ "use strict";
1001
+ init_html();
1002
+ init_assets();
1003
+ escapeXml = escapeHtml;
1004
+ RED = "#C00";
1005
+ GREY = "#757575";
1006
+ BORDER = "#CCCCCC";
1007
+ TREND_UP = "#2E7D32";
1008
+ TREND_NEUTRAL = GREY;
1009
+ CHECK_PNG = `cid:${CHECK_CID}`;
1010
+ LIGHTHOUSE_ROWS = [
1011
+ { label: "Performance", key: "performance", range: "Acceptable 50\u201389 // Ideal 90\u2013100" },
1012
+ { label: "Readability (A11y)", key: "accessibility", range: "Acceptable 80\u201399 // Ideal 100" },
1013
+ { label: "Best Practices", key: "bestPractices", range: "Acceptable 60\u201379 // Ideal 80\u2013100" },
1014
+ { label: "Site Structure", key: "seo", range: "Acceptable 50\u201389 // Ideal 90\u2013100" }
1015
+ ];
1016
+ }
1017
+ });
1018
+
1019
+ // src/reports/maintenance-email/template.ts
1020
+ function fmtDate(d) {
1021
+ if (!d || Number.isNaN(d.getTime())) return "";
1022
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
1023
+ const dd = String(d.getUTCDate()).padStart(2, "0");
1024
+ const yyyy = d.getUTCFullYear();
1025
+ return `${mm}.${dd}.${yyyy}`;
946
1026
  }
947
1027
  function maintenanceChecksSection(copy, searchPosition) {
948
1028
  const googleLabel = searchPosition !== void 0 ? `Page 1 Google Result (#${searchPosition})` : copy.maintenanceChecks[3] ?? "";
949
1029
  const rows = copy.maintenanceChecks.map((label, i) => i === 3 ? googleLabel : label);
950
- return rows.map(
951
- (label, i) => `
952
- <mj-section background-color="white" padding="0px"${i === rows.length - 1 ? ' padding-bottom="36px"' : ""}>
953
- <mj-group>
954
- <mj-column padding-left="0px" width="90%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
955
- <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
956
- </mj-column>
957
- <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
958
- <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
959
- </mj-column>
960
- </mj-group>
961
- </mj-section>`
962
- ).join("");
1030
+ return checklistRowsSection(rows, { background: "white", lastPaddingBottom: "36px" });
963
1031
  }
964
1032
  function testingChecklistSection(copy) {
965
- const rows = copy.testingChecklist;
966
- return rows.map(
967
- (label, i) => `
968
- <mj-section background-color="#F4F4F4" padding="0px"${i === rows.length - 1 ? ' padding-bottom="60px"' : ""}>
969
- <mj-group>
970
- <mj-column width="90%" padding-left="0px"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
971
- <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
972
- </mj-column>
973
- <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
974
- <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
975
- </mj-column>
976
- </mj-group>
977
- </mj-section>`
978
- ).join("");
1033
+ return checklistRowsSection(copy.testingChecklist, {
1034
+ background: "#F4F4F4",
1035
+ lastPaddingBottom: "60px"
1036
+ });
979
1037
  }
980
1038
  function maintenanceTestingPlaceholder(lastTested) {
981
1039
  return `
@@ -995,7 +1053,7 @@ function testingIntroSection(copy) {
995
1053
  <mj-section background-color="#F4F4F4">
996
1054
  <mj-column>
997
1055
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">TESTING</mj-text>
998
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(copy.testingIntro)}</mj-text>
1056
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(copy.testingIntro)}</mj-text>
999
1057
  </mj-column>
1000
1058
  </mj-section>`;
1001
1059
  }
@@ -1003,8 +1061,8 @@ function commentarySection(text, copy) {
1003
1061
  return `
1004
1062
  <mj-section background-color="white">
1005
1063
  <mj-column>
1006
- <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.notesHeader)}</mj-text>
1007
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(text).replace(/\r\n?|\n/g, "<br/>")}</mj-text>
1064
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">${escapeXml2(copy.notesHeader)}</mj-text>
1065
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(text).replace(/\r\n?|\n/g, "<br/>")}</mj-text>
1008
1066
  </mj-column>
1009
1067
  </mj-section>`;
1010
1068
  }
@@ -1013,8 +1071,8 @@ function hasHeaderDims(data) {
1013
1071
  }
1014
1072
  function headerImageTag(data) {
1015
1073
  const src = `cid:${data.headerImageCid}`;
1016
- const alt = `${escapeXml(data.siteName)} maintenance report`;
1017
- const href = isHttpUrl(data.siteUrl) ? escapeXml(data.siteUrl) : "#";
1074
+ const alt = `${escapeXml2(data.siteName)} maintenance report`;
1075
+ const href = isHttpUrl(data.siteUrl) ? escapeXml2(data.siteUrl) : "#";
1018
1076
  if (hasHeaderDims(data)) {
1019
1077
  return `<mj-image href="${href}" src="${src}" alt="${alt}" width="${data.headerWidth}px" css-class="rd-header" container-background-color="${data.headerBgColor}" />`;
1020
1078
  }
@@ -1027,7 +1085,7 @@ function headerStyleBlock(data) {
1027
1085
  function buildMjml(data) {
1028
1086
  const copy = data.copy ?? DEFAULT_COPY;
1029
1087
  const isTesting = data.reportType === "Testing";
1030
- const previewText = `Checked up on ${escapeXml(data.siteName)}`;
1088
+ const previewText = `Checked up on ${escapeXml2(data.siteName)}`;
1031
1089
  return `<mjml>
1032
1090
  <mj-head>
1033
1091
  <mj-attributes>
@@ -1049,89 +1107,65 @@ function buildMjml(data) {
1049
1107
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">COMPLETED ON</mj-text>
1050
1108
  <mj-text color="#C00" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
1051
1109
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">MAINTENANCE CHECKS</mj-text>
1052
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(copy.maintenanceIntro)}</mj-text>
1110
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(copy.maintenanceIntro)}</mj-text>
1053
1111
  </mj-column>
1054
1112
  </mj-section>
1055
1113
  ${maintenanceChecksSection(copy, data.searchPosition)}
1056
- <mj-section background-color="#F4F4F4">
1057
- <mj-column>
1058
- <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">LIGHTHOUSE SCORES*</mj-text>
1059
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Performance</mj-text>
1060
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.performance}</mj-text>
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>
1062
- <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1063
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Readability (A11y)</mj-text>
1064
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.accessibility}</mj-text>
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>
1066
- <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1067
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Best Practices</mj-text>
1068
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.bestPractices}</mj-text>
1069
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 60\u201379 // Ideal 80\u201392</mj-text>
1070
- <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1071
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Site Structure</mj-text>
1072
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.seo}</mj-text>
1073
- <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>
1074
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px">*A Lighthouse score is a numerical measure provided by Google's Lighthouse tool, which evaluates various aspects of a web page's quality.</mj-text>
1075
- </mj-column>
1076
- </mj-section>
1077
- <mj-section background-color="white">
1078
- <mj-column>
1079
- <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">ANALYTICS</mj-text>
1080
- <mj-text color="#C00" font-size="44px" font-weight="400">${data.gaUsersCurrent !== void 0 ? fmtUsers(data.gaUsersCurrent) : "\u2014"} Users</mj-text>
1081
- ${analyticsTrendLine(data.gaUsersCurrent, data.gaUsersPrevious)}
1082
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px">${escapeXml(copy.seoCta)}</mj-text>
1083
- </mj-column>
1084
- </mj-section>
1114
+ ${lighthouseScoresSection(data.lighthouse)}
1115
+ ${analyticsSection({
1116
+ current: data.gaUsersCurrent,
1117
+ previous: data.gaUsersPrevious,
1118
+ background: "white",
1119
+ footnoteLines: [escapeXml2(copy.seoCta)]
1120
+ })}
1085
1121
  ${isTesting ? testingIntroSection(copy) + testingChecklistSection(copy) : maintenanceTestingPlaceholder(data.lastTestedDate)}
1086
1122
  ${data.commentary ? commentarySection(data.commentary, copy) : ""}
1087
1123
  <mj-section background-color="white">
1088
1124
  <mj-column padding-top="36px">
1089
1125
  <mj-text color="#C00" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
1090
1126
  ${copy.contact.map(
1091
- (line, i) => i === copy.contact.length - 1 ? `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" padding-top="0px" line-height="30px" padding-bottom="36px">${escapeXml(line)}</mj-text>` : `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
1127
+ (line, i) => i === copy.contact.length - 1 ? `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" padding-top="0px" line-height="30px" padding-bottom="36px">${escapeXml2(line)}</mj-text>` : `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
1092
1128
  ).join("\n ")}
1093
1129
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1094
- <mj-text color="#757575" 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>
1130
+ <mj-text color="#757575" 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()} ${escapeXml2(copy.footerOrg)}. All rights reserved.</mj-text>
1095
1131
  <mj-text color="#757575" 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>
1096
1132
  ${[copy.footerOrg, ...copy.footerAddress].map(
1097
- (line) => `<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(line)}</mj-text>`
1133
+ (line) => `<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml2(line)}</mj-text>`
1098
1134
  ).join("\n ")}
1099
1135
  </mj-column>
1100
1136
  </mj-section>
1101
1137
  </mj-body>
1102
1138
  </mjml>`;
1103
1139
  }
1104
- var escapeXml, CHECK_PNG, BLURRED_TESTS, TREND_UP, TREND_NEUTRAL;
1140
+ var escapeXml2, BLURRED_TESTS;
1105
1141
  var init_template = __esm({
1106
1142
  "src/reports/maintenance-email/template.ts"() {
1107
1143
  "use strict";
1108
1144
  init_copy();
1109
1145
  init_assets();
1146
+ init_email_sections();
1110
1147
  init_html();
1111
1148
  init_url();
1112
- escapeXml = escapeHtml;
1113
- CHECK_PNG = `cid:${CHECK_CID}`;
1149
+ escapeXml2 = escapeHtml;
1114
1150
  BLURRED_TESTS = `cid:${BLURRED_CID}`;
1115
- TREND_UP = "#2E7D32";
1116
- TREND_NEUTRAL = "#757575";
1117
1151
  }
1118
1152
  });
1119
1153
 
1120
1154
  // src/reports/launch-email/template.ts
1121
1155
  function buildLaunchMjml(data) {
1122
1156
  const copy = data.copy ?? DEFAULT_COPY;
1123
- const previewText = `${escapeXml(data.siteName)} is live`;
1157
+ const previewText = `${escapeXml2(data.siteName)} is live`;
1124
1158
  const setupRows = copy.launchSetupItems.map(
1125
1159
  (item) => `
1126
- <mj-text color="${GREY}" 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>`
1160
+ <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 ${escapeXml2(item)}</mj-text>`
1127
1161
  ).join("");
1128
1162
  const contactRows = copy.contact.map(
1129
1163
  (line) => `
1130
- <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
1164
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
1131
1165
  ).join("");
1132
1166
  const footerAddressRows = copy.footerAddress.map(
1133
1167
  (line) => `
1134
- <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(line)}</mj-text>`
1168
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml2(line)}</mj-text>`
1135
1169
  ).join("");
1136
1170
  return `<mjml>
1137
1171
  <mj-head>
@@ -1149,47 +1183,43 @@ function buildLaunchMjml(data) {
1149
1183
  </mj-section>
1150
1184
  <mj-section background-color="white">
1151
1185
  <mj-column>
1152
- <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.launchHeading)}</mj-text>
1153
- <mj-text color="${RED}" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
1154
- <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="20px">${escapeXml(copy.launchBody)}</mj-text>
1186
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml2(copy.launchHeading)}</mj-text>
1187
+ <mj-text color="${RED2}" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
1188
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="20px">${escapeXml2(copy.launchBody)}</mj-text>
1155
1189
  ${setupRows}
1156
1190
  </mj-column>
1157
1191
  </mj-section>
1158
1192
  <mj-section background-color="white">
1159
1193
  <mj-column padding-top="36px">
1160
- <mj-text color="${RED}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
1194
+ <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>
1161
1195
  ${contactRows}
1162
1196
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1163
- <mj-text color="${GREY}" 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>
1164
- <mj-text color="${GREY}" 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>
1165
- <mj-text color="${GREY}" 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>
1197
+ <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()} ${escapeXml2(copy.footerOrg)}. All rights reserved.</mj-text>
1198
+ <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>
1199
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml2(copy.footerOrg)}</mj-text>
1166
1200
  ${footerAddressRows}
1167
1201
  </mj-column>
1168
1202
  </mj-section>
1169
1203
  </mj-body>
1170
1204
  </mjml>`;
1171
1205
  }
1172
- var RED, GREY;
1206
+ var RED2, GREY2;
1173
1207
  var init_template2 = __esm({
1174
1208
  "src/reports/launch-email/template.ts"() {
1175
1209
  "use strict";
1176
1210
  init_copy();
1177
1211
  init_template();
1178
- RED = "#C00";
1179
- GREY = "#757575";
1212
+ RED2 = "#C00";
1213
+ GREY2 = "#757575";
1180
1214
  }
1181
1215
  });
1182
1216
 
1183
1217
  // src/reports/announcement-email/template.ts
1184
- function fmtVisitors(n) {
1185
- return n.toLocaleString("en-US");
1218
+ function sectionLabel(text) {
1219
+ return `<mj-text color="${RED3}" font-size="20px" font-weight="700" padding-top="0px">${escapeXml2(text)}</mj-text>`;
1186
1220
  }
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";
1221
+ function bodyLine(text, paddingTop = "8px") {
1222
+ return `<mj-text color="${GREY3}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="${paddingTop}">${escapeXml2(text)}</mj-text>`;
1193
1223
  }
1194
1224
  function announcementSiteExtras(site) {
1195
1225
  return {
@@ -1200,74 +1230,65 @@ function announcementSiteExtras(site) {
1200
1230
  function buildAnnouncementMjml(data) {
1201
1231
  const copy = data.copy ?? DEFAULT_COPY;
1202
1232
  const previewText = "Your monthly report from Reddoor";
1233
+ const cad = data.cadence;
1234
+ const hasMaint = Boolean(cad && cad.maintenance !== "None");
1235
+ const hasTesting = Boolean(cad && cad.testing !== "None");
1203
1236
  const improvementItems = [];
1204
1237
  if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);
1205
1238
  if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);
1206
- const improvementsSection = improvementItems.length > 0 ? `
1207
- <mj-section background-color="white">
1239
+ const hasImpr = improvementItems.length > 0;
1240
+ const BANDS = ["white", "#F4F4F4"];
1241
+ let bandN = 0;
1242
+ const nextBg = () => BANDS[bandN++ % 2];
1243
+ const introBg = nextBg();
1244
+ const maintBg = hasMaint ? nextBg() : "";
1245
+ const testBg = hasTesting ? nextBg() : "";
1246
+ const lighthouseBg = nextBg();
1247
+ const analyticsBg = nextBg();
1248
+ const improvementsBg = hasImpr ? nextBg() : "";
1249
+ const contactBg = nextBg();
1250
+ const maintenanceSection = cad && cad.maintenance !== "None" ? `
1251
+ <mj-section background-color="${maintBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
1208
1252
  <mj-column>
1209
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">RECENT IMPROVEMENTS</mj-text>
1210
- ${improvementItems.map(
1211
- (item) => `
1212
- <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>`
1213
- ).join("")}
1253
+ ${sectionLabel("MAINTENANCE CHECKS")}
1254
+ ${bodyLine(`${copy.maintenanceIntro} We do this ${FREQ_PHRASE[cad.maintenance]}.${cad.testing === "None" ? ` ${copy.announceCadence}` : ""}`)}
1214
1255
  </mj-column>
1215
- </mj-section>` : "";
1216
- const cad = data.cadence;
1217
- const cadenceBlocks = [];
1218
- if (cad && cad.testing !== "None")
1219
- cadenceBlocks.push({
1220
- line: `${copy.announceTestingLabel} \u2014 ${FREQ_PHRASE[cad.testing]}`,
1221
- checks: copy.testingChecklist
1222
- });
1223
- if (cad && cad.maintenance !== "None")
1224
- cadenceBlocks.push({
1225
- line: `${copy.announceMaintenanceLabel} \u2014 ${FREQ_PHRASE[cad.maintenance]}`,
1226
- checks: copy.maintenanceChecks
1227
- });
1228
- const cadenceSection = cadenceBlocks.length > 0 ? `
1229
- <mj-section background-color="white">
1256
+ </mj-section>${checklistRowsSection(copy.maintenanceChecks, {
1257
+ background: maintBg,
1258
+ lastPaddingBottom: SECTION_PAD
1259
+ })}` : "";
1260
+ const testingSection = cad && cad.testing !== "None" ? `
1261
+ <mj-section background-color="${testBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
1230
1262
  <mj-column>
1231
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">${escapeXml(copy.announceCadenceHeading)}</mj-text>
1232
- ${cadenceBlocks.map(
1233
- (b) => `
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("")}`
1238
- ).join("")}
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>
1263
+ ${sectionLabel("TESTING")}
1264
+ ${bodyLine(`${copy.testingIntro} We run a full test ${FREQ_PHRASE[cad.testing]}. ${copy.announceCadence}`)}
1240
1265
  </mj-column>
1241
- </mj-section>` : "";
1242
- const scoreRows = SCORE_PREVIEW.map(
1243
- ({ label, key }) => `
1244
- <mj-text color="${RED2}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
1245
- <mj-text color="${RED2}" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse[key]}</mj-text>`
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">
1266
+ </mj-section>${checklistRowsSection(copy.testingChecklist, {
1267
+ background: testBg,
1268
+ lastPaddingBottom: SECTION_PAD
1269
+ })}` : "";
1270
+ const analytics = analyticsSection({
1271
+ current: data.gaUsersCurrent,
1272
+ previous: data.gaUsersPrevious,
1273
+ background: analyticsBg,
1274
+ pad: SECTION_PAD,
1275
+ bodyLines: data.searchPosition !== void 0 ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`] : []
1276
+ });
1277
+ const improvementsSection = hasImpr ? `
1278
+ <mj-section background-color="${improvementsBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
1259
1279
  <mj-column>
1260
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">TRAFFIC &amp; SEARCH</mj-text>
1261
- ${trafficRows.join("")}
1280
+ ${sectionLabel("RECENT IMPROVEMENTS")}
1281
+ ${improvementItems.map((item) => bodyLine(item)).join("\n ")}
1282
+ ${bodyLine(copy.announceOpenDoor, "16px")}
1262
1283
  </mj-column>
1263
1284
  </mj-section>` : "";
1264
1285
  const contactRows = copy.contact.map(
1265
1286
  (line) => `
1266
- <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
1287
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
1267
1288
  ).join("");
1268
1289
  const footerAddressRows = copy.footerAddress.map(
1269
1290
  (line) => `
1270
- <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>`
1291
+ <mj-text color="${GREY3}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml2(line)}</mj-text>`
1271
1292
  ).join("");
1272
1293
  return `<mjml>
1273
1294
  <mj-head>
@@ -1276,69 +1297,54 @@ function buildAnnouncementMjml(data) {
1276
1297
  <mj-section padding-left="11%" padding-right="11%"/>
1277
1298
  <mj-image padding="0px" />
1278
1299
  </mj-attributes>
1279
- <mj-preview>${escapeXml(previewText)}</mj-preview>
1300
+ <mj-preview>${escapeXml2(previewText)}</mj-preview>
1280
1301
  ${headerStyleBlock(data)}
1281
1302
  </mj-head>
1282
1303
  <mj-body background-color="white">
1283
1304
  <mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
1284
1305
  <mj-column>${headerImageTag(data)}</mj-column>
1285
1306
  </mj-section>
1286
- <mj-section background-color="white">
1307
+ <mj-section background-color="${introBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
1287
1308
  <mj-column>
1288
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.announceHeading)}</mj-text>
1289
- <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>
1290
- <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>
1309
+ ${sectionLabel(copy.announceHeading)}
1310
+ <mj-text color="${GREY3}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="20px">Prepared for ${escapeXml2(data.siteName)}</mj-text>
1311
+ ${bodyLine(copy.announceBody)}
1291
1312
  </mj-column>
1292
1313
  </mj-section>
1293
- ${cadenceSection}
1314
+ ${maintenanceSection}
1315
+ ${testingSection}
1316
+ ${lighthouseScoresSection(data.lighthouse, { background: lighthouseBg, pad: SECTION_PAD })}
1317
+ ${analytics}
1294
1318
  ${improvementsSection}
1295
- <mj-section background-color="#F4F4F4">
1319
+ <mj-section background-color="${contactBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
1296
1320
  <mj-column>
1297
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.announcePreviewLabel)}</mj-text>
1298
- ${scoreRows}${scoreNote}
1299
- </mj-column>
1300
- </mj-section>
1301
- ${trafficSection}
1302
- <mj-section background-color="white">
1303
- <mj-column>
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>
1305
- </mj-column>
1306
- </mj-section>
1307
- <mj-section background-color="white">
1308
- <mj-column padding-top="36px">
1309
- <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>
1321
+ <mj-text color="${RED3}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="0px" line-height="36px">Any questions, concerns or requests?</mj-text>
1310
1322
  ${contactRows}
1311
1323
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1312
- <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>
1313
- <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>
1314
- <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>
1324
+ <mj-text color="${GREY3}" 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()} ${escapeXml2(copy.footerOrg)}. All rights reserved.</mj-text>
1325
+ <mj-text color="${GREY3}" 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>
1326
+ <mj-text color="${GREY3}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml2(copy.footerOrg)}</mj-text>
1315
1327
  ${footerAddressRows}
1316
1328
  </mj-column>
1317
1329
  </mj-section>
1318
1330
  </mj-body>
1319
1331
  </mjml>`;
1320
1332
  }
1321
- var FREQ_PHRASE, RED2, GREY2, CHECK_PNG2, SCORE_PREVIEW;
1333
+ var FREQ_PHRASE, RED3, GREY3, SECTION_PAD;
1322
1334
  var init_template3 = __esm({
1323
1335
  "src/reports/announcement-email/template.ts"() {
1324
1336
  "use strict";
1325
1337
  init_copy();
1326
1338
  init_template();
1327
- init_assets();
1339
+ init_email_sections();
1328
1340
  FREQ_PHRASE = {
1329
1341
  Monthly: "every month",
1330
1342
  Quarterly: "every quarter",
1331
1343
  Yearly: "every year"
1332
1344
  };
1333
- RED2 = "#C00";
1334
- GREY2 = "#757575";
1335
- CHECK_PNG2 = `cid:${CHECK_CID}`;
1336
- SCORE_PREVIEW = [
1337
- { label: "Performance", key: "performance" },
1338
- { label: "Readability (A11y)", key: "accessibility" },
1339
- { label: "Best Practices", key: "bestPractices" },
1340
- { label: "Site Structure", key: "seo" }
1341
- ];
1345
+ RED3 = "#C00";
1346
+ GREY3 = "#757575";
1347
+ SECTION_PAD = "40px";
1342
1348
  }
1343
1349
  });
1344
1350
 
@@ -1648,16 +1654,16 @@ __export(digest_exports, {
1648
1654
  runDigest: () => runDigest
1649
1655
  });
1650
1656
  function readySection(items) {
1651
- 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>`;
1657
+ const heading = `<h2 style="color:${RED4};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Ready for your yes</h2>`;
1652
1658
  if (items.length === 0) {
1653
- return `${heading}<p style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
1659
+ return `${heading}<p style="color:${GREY4};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
1654
1660
  }
1655
1661
  const rows = items.map((it) => {
1656
1662
  const safeUrl = it.dashboardUrl.startsWith("https://") ? it.dashboardUrl : void 0;
1657
1663
  const link = safeUrl ? `<a href="${escapeHtml(safeUrl)}" style="${ANCHOR_STYLE}">review &amp; approve</a>` : `review &amp; approve`;
1658
1664
  return `
1659
1665
  <tr>
1660
- <td style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
1666
+ <td style="color:${GREY4};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
1661
1667
  <strong style="color:#222">${escapeHtml(it.siteName)}</strong> \u2014 ${escapeHtml(it.reportType)} (${escapeHtml(it.period)})
1662
1668
  \u2014 ${link}
1663
1669
  </td>
@@ -1667,15 +1673,15 @@ function readySection(items) {
1667
1673
  }
1668
1674
  function attentionBadge(status) {
1669
1675
  if (status === "new")
1670
- return `<strong style="color:${RED3};font-family:helvetica,sans-serif">NEW</strong> `;
1676
+ return `<strong style="color:${RED4};font-family:helvetica,sans-serif">NEW</strong> `;
1671
1677
  if (status === "worse")
1672
- return `<strong style="color:${RED3};font-family:helvetica,sans-serif">WORSE</strong> `;
1678
+ return `<strong style="color:${RED4};font-family:helvetica,sans-serif">WORSE</strong> `;
1673
1679
  return "";
1674
1680
  }
1675
1681
  function attentionSection(items) {
1676
- const heading = `<h2 style="color:${RED3};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Needs attention</h2>`;
1682
+ const heading = `<h2 style="color:${RED4};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Needs attention</h2>`;
1677
1683
  if (items.length === 0) {
1678
- return `${heading}<p style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
1684
+ return `${heading}<p style="color:${GREY4};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
1679
1685
  }
1680
1686
  const bySite = /* @__PURE__ */ new Map();
1681
1687
  for (const it of items) {
@@ -1692,7 +1698,7 @@ function attentionSection(items) {
1692
1698
  const titleHtml = safeUrl ? `<a href="${escapeHtml(safeUrl)}" style="${ANCHOR_STYLE}">${escapeHtml(it.title)}</a>` : escapeHtml(it.title);
1693
1699
  return `
1694
1700
  <tr>
1695
- <td style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${attentionBadge(it.status)}${titleHtml}</td>
1701
+ <td style="color:${GREY4};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${attentionBadge(it.status)}${titleHtml}</td>
1696
1702
  </tr>`;
1697
1703
  }).join("");
1698
1704
  return `
@@ -1817,7 +1823,7 @@ function renderDigestHtml(sections) {
1817
1823
  <table width="600" style="border-collapse:collapse">
1818
1824
  <tr>
1819
1825
  <td>
1820
- <h1 style="color:${RED3};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
1826
+ <h1 style="color:${RED4};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
1821
1827
  ${readySection(sections.readyForYourYes)}
1822
1828
  ${attentionSection(sections.needsAttention)}
1823
1829
  </td>
@@ -1829,7 +1835,7 @@ function renderDigestHtml(sections) {
1829
1835
  </body>
1830
1836
  </html>`;
1831
1837
  }
1832
- var GREY3, RED3, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
1838
+ var GREY4, RED4, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
1833
1839
  var init_digest = __esm({
1834
1840
  "src/reports/digest.ts"() {
1835
1841
  "use strict";
@@ -1841,9 +1847,9 @@ var init_digest = __esm({
1841
1847
  init_digest_collectors();
1842
1848
  init_digest_state();
1843
1849
  init_html();
1844
- GREY3 = "#757575";
1845
- RED3 = "#C00";
1846
- ANCHOR_STYLE = `color:${RED3};font-family:helvetica,sans-serif`;
1850
+ GREY4 = "#757575";
1851
+ RED4 = "#C00";
1852
+ ANCHOR_STYLE = `color:${RED4};font-family:helvetica,sans-serif`;
1847
1853
  SEVERITY_ORDER = { critical: 0, warning: 1 };
1848
1854
  FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
1849
1855
  DIGEST_OPERATOR_FALLBACK = "info@reddoorla.com";
@@ -2003,9 +2009,8 @@ async function sendOne(client, base, site, report) {
2003
2009
  headerHeight: header.displayHeight,
2004
2010
  headerBgColor: header.placeholderColor,
2005
2011
  // 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.
2012
+ // keeps its cadence copy + improvement callouts. Without this the send-time re-render drops
2013
+ // them entirely (they're not stored on the Reports row). Ignored by the other templates.
2009
2014
  ...report.reportType === "Announcement" ? announcementSiteExtras(site) : {}
2010
2015
  });
2011
2016
  const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
@@ -5775,10 +5780,40 @@ init_render();
5775
5780
  init_websites();
5776
5781
  init_copy();
5777
5782
  init_reports();
5778
- init_attachments();
5779
5783
  import { mkdir as mkdir4, writeFile as writeFile10 } from "fs/promises";
5780
5784
  import { dirname as dirname6 } from "path";
5781
5785
 
5786
+ // src/reports/queue.ts
5787
+ init_reports();
5788
+ var REPORT_TIER = {
5789
+ Maintenance: 1,
5790
+ Testing: 2,
5791
+ Announcement: 3,
5792
+ Launch: 3
5793
+ };
5794
+ function reportTier(type) {
5795
+ return REPORT_TIER[type];
5796
+ }
5797
+ async function queueDraft(base, report) {
5798
+ const newTier = reportTier(report.reportType);
5799
+ const others = (await listReportsForSite(base, report.siteId)).filter(isPendingApproval).filter((r) => r.id !== report.id);
5800
+ const blocker = others.find((r) => reportTier(r.reportType) >= newTier);
5801
+ if (blocker) {
5802
+ await setDraftReady(base, report.id, false);
5803
+ return { queued: false, blockedBy: blocker.reportType, supersededIds: [] };
5804
+ }
5805
+ const supersededIds = [];
5806
+ for (const r of others) {
5807
+ await setDraftReady(base, r.id, false);
5808
+ supersededIds.push(r.id);
5809
+ }
5810
+ await setDraftReady(base, report.id, true);
5811
+ return { queued: true, supersededIds };
5812
+ }
5813
+
5814
+ // src/reports/draft.ts
5815
+ init_attachments();
5816
+
5782
5817
  // src/reports/ga/config.ts
5783
5818
  init_credentials();
5784
5819
  import { dirname as dirname5, join as join26 } from "path";
@@ -5961,12 +5996,24 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
5961
5996
  const path = options.previewPath ?? `reports/${slug}/draft.html`;
5962
5997
  await mkdir4(dirname6(path), { recursive: true });
5963
5998
  await writeFile10(path, html, "utf-8");
5964
- return { reportRow: null, htmlPath: path, html, softFailures };
5999
+ return { reportRow: null, htmlPath: path, html, softFailures, queued: null, supersededIds: [] };
5965
6000
  }
5966
6001
  if (base === null) throw new Error("base required when previewOnly=false");
5967
6002
  if (options.completeRowId) {
5968
- await finishDraftRow(base, options.completeRowId, slug, periodEnd, html);
5969
- return { reportRow: options.existingRow ?? null, htmlPath: null, html, softFailures };
6003
+ await uploadDraftHtml(options.completeRowId, slug, periodEnd, html);
6004
+ const outcome2 = await queueDraft(base, {
6005
+ id: options.completeRowId,
6006
+ siteId: siteRow.id,
6007
+ reportType
6008
+ });
6009
+ return {
6010
+ reportRow: options.existingRow ?? null,
6011
+ htmlPath: null,
6012
+ html,
6013
+ softFailures,
6014
+ queued: outcome2.queued,
6015
+ supersededIds: outcome2.supersededIds
6016
+ };
5970
6017
  }
5971
6018
  const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
5972
6019
  const created = await createDraft(base, {
@@ -5983,13 +6030,24 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
5983
6030
  ...search ? { searchFoundPage1: search.foundOnPage1 } : {},
5984
6031
  ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
5985
6032
  });
5986
- await finishDraftRow(base, created.id, slug, periodEnd, html);
5987
- return { reportRow: created, htmlPath: null, html, softFailures };
6033
+ await uploadDraftHtml(created.id, slug, periodEnd, html);
6034
+ const outcome = await queueDraft(base, {
6035
+ id: created.id,
6036
+ siteId: siteRow.id,
6037
+ reportType
6038
+ });
6039
+ return {
6040
+ reportRow: created,
6041
+ htmlPath: null,
6042
+ html,
6043
+ softFailures,
6044
+ queued: outcome.queued,
6045
+ supersededIds: outcome.supersededIds
6046
+ };
5988
6047
  }
5989
- async function finishDraftRow(base, rowId, slug, periodEnd, html) {
6048
+ async function uploadDraftHtml(rowId, slug, periodEnd, html) {
5990
6049
  const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
5991
6050
  await uploadAttachment(rowId, "Rendered HTML", html, htmlFilename, "text/html");
5992
- await setDraftReady(base, rowId, true);
5993
6051
  }
5994
6052
  var NO_ENRICHMENT = { value: null, softFailed: false };
5995
6053
  async function fetchGaUsers(siteRow, periodStart, periodEnd) {
@@ -6039,6 +6097,14 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
6039
6097
  }
6040
6098
 
6041
6099
  // src/cli/commands/report.ts
6100
+ function draftLine(reportId, queued, supersededIds, verb = "drafted") {
6101
+ const id = reportId ?? "(unknown)";
6102
+ if (queued === false) {
6103
+ return `\u2022 ${verb} but NOT queued: ${id} \u2014 a higher-or-equal-tier report is already pending approval`;
6104
+ }
6105
+ const sup = supersededIds.length > 0 ? ` (superseded ${supersededIds.length} lower-tier draft${supersededIds.length > 1 ? "s" : ""})` : "";
6106
+ return `\u2713 ${verb}: ${id}${sup}`;
6107
+ }
6042
6108
  function parseSingleSiteReportType(raw) {
6043
6109
  if (raw === void 0 || raw.trim() === "") return "Maintenance";
6044
6110
  const norm = raw.trim().toLowerCase();
@@ -6101,15 +6167,30 @@ async function draftDueReports(base, today) {
6101
6167
  lines.push(`\u2022 skipped (already drafted ${period}): ${item.site.name} ${item.reportType}`);
6102
6168
  continue;
6103
6169
  }
6170
+ const blockedByPending = reports.some(
6171
+ (r) => r.siteId === item.site.id && r.id !== existing.id && r.sentAt === null && r.draftReady && reportTier(r.reportType) >= reportTier(item.reportType)
6172
+ );
6173
+ if (blockedByPending) {
6174
+ skipped++;
6175
+ lines.push(
6176
+ `\u2022 skipped (superseded \u2014 a higher-or-equal-tier report is pending): ${item.site.name} ${item.reportType}`
6177
+ );
6178
+ continue;
6179
+ }
6104
6180
  try {
6105
6181
  const result = await draftReportForSite(base, item.site, item.reportType, {
6106
6182
  period,
6107
6183
  completeRowId: existing.id,
6108
6184
  existingRow: existing
6109
6185
  });
6110
- existing.draftReady = true;
6186
+ existing.draftReady = result.queued === true;
6111
6187
  lines.push(
6112
- `\u2713 completed half-made draft: ${result.reportRow?.reportId ?? existing.reportId}`
6188
+ draftLine(
6189
+ result.reportRow?.reportId ?? existing.reportId,
6190
+ result.queued,
6191
+ result.supersededIds,
6192
+ "completed half-made draft"
6193
+ )
6113
6194
  );
6114
6195
  if (result.softFailures.length > 0) softFailedSites++;
6115
6196
  } catch (e) {
@@ -6129,7 +6210,7 @@ async function draftDueReports(base, today) {
6129
6210
  }
6130
6211
  try {
6131
6212
  const result = await draftReportForSite(base, item.site, item.reportType, { period });
6132
- lines.push(`\u2713 drafted: ${result.reportRow?.reportId}`);
6213
+ lines.push(draftLine(result.reportRow?.reportId, result.queued, result.supersededIds));
6133
6214
  if (result.reportRow) reports.push(result.reportRow);
6134
6215
  if (result.softFailures.length > 0) softFailedSites++;
6135
6216
  } catch (e) {
@@ -6159,7 +6240,15 @@ async function runSingleSiteDraft(slug, opts) {
6159
6240
  if (opts.previewOnly) {
6160
6241
  return { output: `Preview written to ${result.htmlPath}`, code: 0 };
6161
6242
  }
6162
- return { output: `Draft created: ${result.reportRow?.reportId}`, code: 0 };
6243
+ return {
6244
+ output: draftLine(
6245
+ result.reportRow?.reportId,
6246
+ result.queued,
6247
+ result.supersededIds,
6248
+ "Draft created"
6249
+ ),
6250
+ code: 0
6251
+ };
6163
6252
  }
6164
6253
 
6165
6254
  // src/cli/commands/init.ts
@@ -6428,7 +6517,7 @@ async function launch(site, deps = {}) {
6428
6517
  `\u26A0 Launch preview upload skipped for ${target.name}: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
6429
6518
  );
6430
6519
  }
6431
- await setDraftReady(base, report.id, true);
6520
+ await queueDraft(base, { id: report.id, siteId: target.id, reportType: "Launch" });
6432
6521
  } catch (err) {
6433
6522
  steps.push({ name: "draft", result: errorOf(err) });
6434
6523
  return stop();
@@ -6566,9 +6655,19 @@ async function announce(deps) {
6566
6655
  `\u26A0 Announcement preview upload skipped for ${w.name}: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
6567
6656
  );
6568
6657
  }
6569
- await setDraftReady(base, report.id, true);
6658
+ const queue = await queueDraft(base, {
6659
+ id: report.id,
6660
+ siteId: w.id,
6661
+ reportType: "Announcement"
6662
+ });
6570
6663
  const recipientMissing = !(w.reportRecipientsTo && w.reportRecipientsTo.trim());
6571
- results.push({ site: w.name, status: statusKind, reportId: report.id, recipientMissing });
6664
+ results.push({
6665
+ site: w.name,
6666
+ status: statusKind,
6667
+ reportId: report.id,
6668
+ recipientMissing,
6669
+ queued: queue.queued
6670
+ });
6572
6671
  } catch (err) {
6573
6672
  results.push({
6574
6673
  site: w.name,