@reddoorla/maintenance 0.46.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/index.js CHANGED
@@ -2646,6 +2646,10 @@ var ACTIVE_STATUSES = /* @__PURE__ */ new Set([
2646
2646
  "maintenance",
2647
2647
  "launch period"
2648
2648
  ]);
2649
+ var FREQUENCIES = ["None", "Monthly", "Quarterly", "Yearly"];
2650
+ function toFrequency(raw) {
2651
+ return typeof raw === "string" && FREQUENCIES.includes(raw) ? raw : "None";
2652
+ }
2649
2653
  function mapRow(rec) {
2650
2654
  const f = rec.fields;
2651
2655
  const attachments = f["Header image"] ?? [];
@@ -2656,8 +2660,8 @@ function mapRow(rec) {
2656
2660
  url: String(f["url"] ?? ""),
2657
2661
  status: f["Status"] ?? null,
2658
2662
  pointOfContact: f["point of contact"] ?? null,
2659
- maintenanceFreq: f["maintenence freq"] ?? "None",
2660
- testingFreq: f["testing freq"] ?? "None",
2663
+ maintenanceFreq: toFrequency(f["maintenence freq"]),
2664
+ testingFreq: toFrequency(f["testing freq"]),
2661
2665
  maintenanceDay: f["maintenance day"] ?? null,
2662
2666
  testingDay: f["testing day"] ?? null,
2663
2667
  ga4PropertyId: f["GA4 property ID"] ?? null,
@@ -2769,7 +2773,7 @@ var DEFAULT_COPY = {
2769
2773
  "Links & Navigation",
2770
2774
  "Form Functionality",
2771
2775
  "Interactions & Animations",
2772
- "Verified After Updates"
2776
+ "Tested After Updates"
2773
2777
  ],
2774
2778
  notesHeader: "NOTES",
2775
2779
  seoCta: "Contact us if you are interested in more in-depth data or have questions about SEO.",
@@ -2785,14 +2789,10 @@ var DEFAULT_COPY = {
2785
2789
  ],
2786
2790
  announceHeading: "YOUR ONGOING SITE CARE",
2787
2791
  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:",
2788
- announceCadenceHeading: "WHAT TO EXPECT",
2789
- announceTestingLabel: "Full site testing",
2790
- announceMaintenanceLabel: "Routine maintenance",
2791
- announcePreviewLabel: "From your latest full site test:",
2792
2792
  announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
2793
2793
  announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
2794
2794
  announceCadence: "After each one we'll send you a short report like this \u2014 there's nothing you need to do.",
2795
- 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."
2795
+ announceOpenDoor: "And if you'd ever like to expand the scope, add features, or freshen anything up, just let us know."
2796
2796
  };
2797
2797
  function override(v) {
2798
2798
  if (typeof v !== "string") return null;
@@ -2882,70 +2882,118 @@ function safeUrl(raw) {
2882
2882
  return "#";
2883
2883
  }
2884
2884
 
2885
- // src/reports/maintenance-email/template.ts
2885
+ // src/reports/email-sections.ts
2886
2886
  var escapeXml = escapeHtml;
2887
+ var RED = "#C00";
2888
+ var GREY = "#757575";
2889
+ var BORDER = "#CCCCCC";
2890
+ var TREND_UP = "#2E7D32";
2891
+ var TREND_NEUTRAL = GREY;
2887
2892
  var CHECK_PNG = `cid:${CHECK_CID}`;
2888
- var BLURRED_TESTS = `cid:${BLURRED_CID}`;
2889
- function fmtDate(d) {
2890
- if (!d || Number.isNaN(d.getTime())) return "";
2891
- const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
2892
- const dd = String(d.getUTCDate()).padStart(2, "0");
2893
- const yyyy = d.getUTCFullYear();
2894
- return `${mm}.${dd}.${yyyy}`;
2895
- }
2896
2893
  function fmtUsers(n) {
2897
2894
  return n.toLocaleString("en-US");
2898
2895
  }
2899
- var TREND_UP = "#2E7D32";
2900
- var TREND_NEUTRAL = "#757575";
2901
- function trendText(color, text) {
2902
- return `<mj-text color="${color}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text}</mj-text>`;
2896
+ function checklistRowsSection(rows, opts) {
2897
+ return rows.map((label, i) => {
2898
+ const isLast = i === rows.length - 1;
2899
+ const border = isLast ? "" : ` border-bottom="solid ${BORDER} 1px"`;
2900
+ const lastPad = isLast ? ` padding-bottom="${opts.lastPaddingBottom}"` : "";
2901
+ return `
2902
+ <mj-section background-color="${opts.background}" padding="0px"${lastPad}>
2903
+ <mj-group>
2904
+ <mj-column padding-left="0px" width="90%"${border}>
2905
+ <mj-text height="25px" padding-left="0px" color="${GREY}" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
2906
+ </mj-column>
2907
+ <mj-column width="10%"${border} padding-top="15px">
2908
+ <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
2909
+ </mj-column>
2910
+ </mj-group>
2911
+ </mj-section>`;
2912
+ }).join("");
2913
+ }
2914
+ var LIGHTHOUSE_ROWS = [
2915
+ { label: "Performance", key: "performance", range: "Acceptable 50\u201389 // Ideal 90\u2013100" },
2916
+ { label: "Readability (A11y)", key: "accessibility", range: "Acceptable 80\u201399 // Ideal 100" },
2917
+ { label: "Best Practices", key: "bestPractices", range: "Acceptable 60\u201379 // Ideal 80\u2013100" },
2918
+ { label: "Site Structure", key: "seo", range: "Acceptable 50\u201389 // Ideal 90\u2013100" }
2919
+ ];
2920
+ function lighthouseScoresSection(lighthouse2, opts = {}) {
2921
+ const background = opts.background ?? "#F4F4F4";
2922
+ const sectionPad = opts.pad ? ` padding-top="${opts.pad}" padding-bottom="${opts.pad}"` : "";
2923
+ const labelTop = opts.pad ?? "55px";
2924
+ const footnoteBottom = opts.pad ? "0px" : "36px";
2925
+ const rows = LIGHTHOUSE_ROWS.map(
2926
+ ({ label, key, range }, i) => `
2927
+ <mj-text color="${RED}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
2928
+ <mj-text color="${RED}" font-size="44px" font-weight="400" padding-top="0px">${lighthouse2[key]}</mj-text>
2929
+ <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 ? `
2930
+ <mj-divider border-width="1px" border-style="solid" border-color="${BORDER}" padding="0" />` : ""}`
2931
+ ).join("");
2932
+ return `
2933
+ <mj-section background-color="${background}"${sectionPad}>
2934
+ <mj-column>
2935
+ <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">LIGHTHOUSE SCORES*</mj-text>${rows}
2936
+ <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>
2937
+ </mj-column>
2938
+ </mj-section>`;
2903
2939
  }
2904
2940
  function analyticsTrendLine(cur, prev) {
2905
2941
  if (cur === void 0 || prev === void 0) {
2906
- return trendText(TREND_NEUTRAL, `Last Period: ${prev !== void 0 ? fmtUsers(prev) : "\u2014"}`);
2942
+ return trendLine(TREND_NEUTRAL, `Last Period: ${prev !== void 0 ? fmtUsers(prev) : "\u2014"}`);
2907
2943
  }
2908
2944
  if (prev === 0) {
2909
- return cur > 0 ? trendText(TREND_UP, "\u25B2 New this period (0 last period)") : trendText(TREND_NEUTRAL, "Last Period: 0");
2945
+ return cur > 0 ? trendLine(TREND_UP, "\u25B2 New this period (0 last period)") : trendLine(TREND_NEUTRAL, "Last Period: 0");
2910
2946
  }
2911
2947
  const pct = Math.round((cur - prev) / prev * 100);
2912
2948
  const range = `(${fmtUsers(prev)} \u2192 ${fmtUsers(cur)})`;
2913
- if (pct > 0) return trendText(TREND_UP, `\u25B2 ${pct}% vs last period ${range}`);
2914
- if (pct < 0) return trendText(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs last period ${range}`);
2915
- return trendText(TREND_NEUTRAL, `No change vs last period (${fmtUsers(prev)})`);
2949
+ if (pct > 0) return trendLine(TREND_UP, `\u25B2 ${pct}% vs last period ${range}`);
2950
+ if (pct < 0) return trendLine(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs last period ${range}`);
2951
+ return trendLine(TREND_NEUTRAL, `No change vs last period (${fmtUsers(prev)})`);
2952
+ }
2953
+ function trendLine(color, text) {
2954
+ return `<mj-text color="${color}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text}</mj-text>`;
2955
+ }
2956
+ function footnoteLine(text) {
2957
+ 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>`;
2958
+ }
2959
+ function analyticsSection(opts) {
2960
+ const users = opts.current !== void 0 ? fmtUsers(opts.current) : "\u2014";
2961
+ const body = (opts.bodyLines ?? []).map((l) => trendLine(TREND_NEUTRAL, l)).join("\n ");
2962
+ const footnotes = (opts.footnoteLines ?? []).map(footnoteLine).join("\n ");
2963
+ const sectionPad = opts.pad ? ` padding-top="${opts.pad}" padding-bottom="${opts.pad}"` : "";
2964
+ const labelTop = opts.pad ?? "75px";
2965
+ return `
2966
+ <mj-section background-color="${opts.background}"${sectionPad}>
2967
+ <mj-column>
2968
+ <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">ANALYTICS</mj-text>
2969
+ <mj-text color="${RED}" font-size="44px" font-weight="400">${users} Users</mj-text>
2970
+ ${analyticsTrendLine(opts.current, opts.previous)}
2971
+ ${body}
2972
+ ${footnotes}
2973
+ </mj-column>
2974
+ </mj-section>`;
2975
+ }
2976
+
2977
+ // src/reports/maintenance-email/template.ts
2978
+ var escapeXml2 = escapeHtml;
2979
+ var BLURRED_TESTS = `cid:${BLURRED_CID}`;
2980
+ function fmtDate(d) {
2981
+ if (!d || Number.isNaN(d.getTime())) return "";
2982
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
2983
+ const dd = String(d.getUTCDate()).padStart(2, "0");
2984
+ const yyyy = d.getUTCFullYear();
2985
+ return `${mm}.${dd}.${yyyy}`;
2916
2986
  }
2917
2987
  function maintenanceChecksSection(copy, searchPosition) {
2918
2988
  const googleLabel = searchPosition !== void 0 ? `Page 1 Google Result (#${searchPosition})` : copy.maintenanceChecks[3] ?? "";
2919
2989
  const rows = copy.maintenanceChecks.map((label, i) => i === 3 ? googleLabel : label);
2920
- return rows.map(
2921
- (label, i) => `
2922
- <mj-section background-color="white" padding="0px"${i === rows.length - 1 ? ' padding-bottom="36px"' : ""}>
2923
- <mj-group>
2924
- <mj-column padding-left="0px" width="90%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
2925
- <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
2926
- </mj-column>
2927
- <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
2928
- <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
2929
- </mj-column>
2930
- </mj-group>
2931
- </mj-section>`
2932
- ).join("");
2990
+ return checklistRowsSection(rows, { background: "white", lastPaddingBottom: "36px" });
2933
2991
  }
2934
2992
  function testingChecklistSection(copy) {
2935
- const rows = copy.testingChecklist;
2936
- return rows.map(
2937
- (label, i) => `
2938
- <mj-section background-color="#F4F4F4" padding="0px"${i === rows.length - 1 ? ' padding-bottom="60px"' : ""}>
2939
- <mj-group>
2940
- <mj-column width="90%" padding-left="0px"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
2941
- <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
2942
- </mj-column>
2943
- <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
2944
- <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
2945
- </mj-column>
2946
- </mj-group>
2947
- </mj-section>`
2948
- ).join("");
2993
+ return checklistRowsSection(copy.testingChecklist, {
2994
+ background: "#F4F4F4",
2995
+ lastPaddingBottom: "60px"
2996
+ });
2949
2997
  }
2950
2998
  function maintenanceTestingPlaceholder(lastTested) {
2951
2999
  return `
@@ -2965,7 +3013,7 @@ function testingIntroSection(copy) {
2965
3013
  <mj-section background-color="#F4F4F4">
2966
3014
  <mj-column>
2967
3015
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">TESTING</mj-text>
2968
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(copy.testingIntro)}</mj-text>
3016
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(copy.testingIntro)}</mj-text>
2969
3017
  </mj-column>
2970
3018
  </mj-section>`;
2971
3019
  }
@@ -2973,8 +3021,8 @@ function commentarySection(text, copy) {
2973
3021
  return `
2974
3022
  <mj-section background-color="white">
2975
3023
  <mj-column>
2976
- <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.notesHeader)}</mj-text>
2977
- <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>
3024
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">${escapeXml2(copy.notesHeader)}</mj-text>
3025
+ <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>
2978
3026
  </mj-column>
2979
3027
  </mj-section>`;
2980
3028
  }
@@ -2983,8 +3031,8 @@ function hasHeaderDims(data) {
2983
3031
  }
2984
3032
  function headerImageTag(data) {
2985
3033
  const src = `cid:${data.headerImageCid}`;
2986
- const alt = `${escapeXml(data.siteName)} maintenance report`;
2987
- const href = isHttpUrl(data.siteUrl) ? escapeXml(data.siteUrl) : "#";
3034
+ const alt = `${escapeXml2(data.siteName)} maintenance report`;
3035
+ const href = isHttpUrl(data.siteUrl) ? escapeXml2(data.siteUrl) : "#";
2988
3036
  if (hasHeaderDims(data)) {
2989
3037
  return `<mj-image href="${href}" src="${src}" alt="${alt}" width="${data.headerWidth}px" css-class="rd-header" container-background-color="${data.headerBgColor}" />`;
2990
3038
  }
@@ -2997,7 +3045,7 @@ function headerStyleBlock(data) {
2997
3045
  function buildMjml(data) {
2998
3046
  const copy = data.copy ?? DEFAULT_COPY;
2999
3047
  const isTesting = data.reportType === "Testing";
3000
- const previewText = `Checked up on ${escapeXml(data.siteName)}`;
3048
+ const previewText = `Checked up on ${escapeXml2(data.siteName)}`;
3001
3049
  return `<mjml>
3002
3050
  <mj-head>
3003
3051
  <mj-attributes>
@@ -3019,52 +3067,30 @@ function buildMjml(data) {
3019
3067
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">COMPLETED ON</mj-text>
3020
3068
  <mj-text color="#C00" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
3021
3069
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">MAINTENANCE CHECKS</mj-text>
3022
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(copy.maintenanceIntro)}</mj-text>
3070
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(copy.maintenanceIntro)}</mj-text>
3023
3071
  </mj-column>
3024
3072
  </mj-section>
3025
3073
  ${maintenanceChecksSection(copy, data.searchPosition)}
3026
- <mj-section background-color="#F4F4F4">
3027
- <mj-column>
3028
- <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">LIGHTHOUSE SCORES*</mj-text>
3029
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Performance</mj-text>
3030
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.performance}</mj-text>
3031
- <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>
3032
- <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
3033
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Readability</mj-text>
3034
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.accessibility}</mj-text>
3035
- <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>
3036
- <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
3037
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Best Practices</mj-text>
3038
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.bestPractices}</mj-text>
3039
- <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>
3040
- <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
3041
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Site Structure</mj-text>
3042
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.seo}</mj-text>
3043
- <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>
3044
- <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>
3045
- </mj-column>
3046
- </mj-section>
3047
- <mj-section background-color="white">
3048
- <mj-column>
3049
- <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">ANALYTICS</mj-text>
3050
- <mj-text color="#C00" font-size="44px" font-weight="400">${data.gaUsersCurrent !== void 0 ? fmtUsers(data.gaUsersCurrent) : "\u2014"} Users</mj-text>
3051
- ${analyticsTrendLine(data.gaUsersCurrent, data.gaUsersPrevious)}
3052
- <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>
3053
- </mj-column>
3054
- </mj-section>
3074
+ ${lighthouseScoresSection(data.lighthouse)}
3075
+ ${analyticsSection({
3076
+ current: data.gaUsersCurrent,
3077
+ previous: data.gaUsersPrevious,
3078
+ background: "white",
3079
+ footnoteLines: [escapeXml2(copy.seoCta)]
3080
+ })}
3055
3081
  ${isTesting ? testingIntroSection(copy) + testingChecklistSection(copy) : maintenanceTestingPlaceholder(data.lastTestedDate)}
3056
3082
  ${data.commentary ? commentarySection(data.commentary, copy) : ""}
3057
3083
  <mj-section background-color="white">
3058
3084
  <mj-column padding-top="36px">
3059
3085
  <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>
3060
3086
  ${copy.contact.map(
3061
- (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>`
3087
+ (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>`
3062
3088
  ).join("\n ")}
3063
3089
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
3064
- <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>
3090
+ <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>
3065
3091
  <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>
3066
3092
  ${[copy.footerOrg, ...copy.footerAddress].map(
3067
- (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>`
3093
+ (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>`
3068
3094
  ).join("\n ")}
3069
3095
  </mj-column>
3070
3096
  </mj-section>
@@ -3073,22 +3099,22 @@ function buildMjml(data) {
3073
3099
  }
3074
3100
 
3075
3101
  // src/reports/launch-email/template.ts
3076
- var RED = "#C00";
3077
- var GREY = "#757575";
3102
+ var RED2 = "#C00";
3103
+ var GREY2 = "#757575";
3078
3104
  function buildLaunchMjml(data) {
3079
3105
  const copy = data.copy ?? DEFAULT_COPY;
3080
- const previewText = `${escapeXml(data.siteName)} is live`;
3106
+ const previewText = `${escapeXml2(data.siteName)} is live`;
3081
3107
  const setupRows = copy.launchSetupItems.map(
3082
3108
  (item) => `
3083
- <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>`
3109
+ <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>`
3084
3110
  ).join("");
3085
3111
  const contactRows = copy.contact.map(
3086
3112
  (line) => `
3087
- <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
3113
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
3088
3114
  ).join("");
3089
3115
  const footerAddressRows = copy.footerAddress.map(
3090
3116
  (line) => `
3091
- <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>`
3117
+ <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>`
3092
3118
  ).join("");
3093
3119
  return `<mjml>
3094
3120
  <mj-head>
@@ -3106,20 +3132,20 @@ function buildLaunchMjml(data) {
3106
3132
  </mj-section>
3107
3133
  <mj-section background-color="white">
3108
3134
  <mj-column>
3109
- <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.launchHeading)}</mj-text>
3110
- <mj-text color="${RED}" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
3111
- <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>
3135
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml2(copy.launchHeading)}</mj-text>
3136
+ <mj-text color="${RED2}" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
3137
+ <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>
3112
3138
  ${setupRows}
3113
3139
  </mj-column>
3114
3140
  </mj-section>
3115
3141
  <mj-section background-color="white">
3116
3142
  <mj-column padding-top="36px">
3117
- <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>
3143
+ <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>
3118
3144
  ${contactRows}
3119
3145
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
3120
- <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>
3121
- <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>
3122
- <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>
3146
+ <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>
3147
+ <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>
3148
+ <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>
3123
3149
  ${footerAddressRows}
3124
3150
  </mj-column>
3125
3151
  </mj-section>
@@ -3133,66 +3159,83 @@ var FREQ_PHRASE = {
3133
3159
  Quarterly: "every quarter",
3134
3160
  Yearly: "every year"
3135
3161
  };
3136
- var RED2 = "#C00";
3137
- var GREY2 = "#757575";
3138
- var SCORE_PREVIEW = [
3139
- { label: "Performance", key: "performance" },
3140
- { label: "Readability", key: "accessibility" },
3141
- { label: "Best Practices", key: "bestPractices" },
3142
- { label: "Site Structure", key: "seo" }
3143
- ];
3162
+ var RED3 = "#C00";
3163
+ var GREY3 = "#757575";
3164
+ var SECTION_PAD = "40px";
3165
+ function sectionLabel(text) {
3166
+ return `<mj-text color="${RED3}" font-size="20px" font-weight="700" padding-top="0px">${escapeXml2(text)}</mj-text>`;
3167
+ }
3168
+ function bodyLine(text, paddingTop = "8px") {
3169
+ 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>`;
3170
+ }
3171
+ function announcementSiteExtras(site) {
3172
+ return {
3173
+ cadence: { maintenance: site.maintenanceFreq, testing: site.testingFreq },
3174
+ improvements: { resendForms: true, svelte5: true }
3175
+ };
3176
+ }
3144
3177
  function buildAnnouncementMjml(data) {
3145
3178
  const copy = data.copy ?? DEFAULT_COPY;
3146
3179
  const previewText = "Your monthly report from Reddoor";
3180
+ const cad = data.cadence;
3181
+ const hasMaint = Boolean(cad && cad.maintenance !== "None");
3182
+ const hasTesting = Boolean(cad && cad.testing !== "None");
3147
3183
  const improvementItems = [];
3148
3184
  if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);
3149
3185
  if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);
3150
- const improvementsSection = improvementItems.length > 0 ? `
3151
- <mj-section background-color="white">
3186
+ const hasImpr = improvementItems.length > 0;
3187
+ const BANDS = ["white", "#F4F4F4"];
3188
+ let bandN = 0;
3189
+ const nextBg = () => BANDS[bandN++ % 2];
3190
+ const introBg = nextBg();
3191
+ const maintBg = hasMaint ? nextBg() : "";
3192
+ const testBg = hasTesting ? nextBg() : "";
3193
+ const lighthouseBg = nextBg();
3194
+ const analyticsBg = nextBg();
3195
+ const improvementsBg = hasImpr ? nextBg() : "";
3196
+ const contactBg = nextBg();
3197
+ const maintenanceSection = cad && cad.maintenance !== "None" ? `
3198
+ <mj-section background-color="${maintBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
3152
3199
  <mj-column>
3153
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">RECENT IMPROVEMENTS</mj-text>
3154
- ${improvementItems.map(
3155
- (item) => `
3156
- <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>`
3157
- ).join("")}
3200
+ ${sectionLabel("MAINTENANCE CHECKS")}
3201
+ ${bodyLine(`${copy.maintenanceIntro} We do this ${FREQ_PHRASE[cad.maintenance]}.${cad.testing === "None" ? ` ${copy.announceCadence}` : ""}`)}
3158
3202
  </mj-column>
3159
- </mj-section>` : "";
3160
- const cad = data.cadence;
3161
- const cadenceBlocks = [];
3162
- if (cad && cad.testing !== "None")
3163
- cadenceBlocks.push({
3164
- line: `${copy.announceTestingLabel} \u2014 ${FREQ_PHRASE[cad.testing]}`,
3165
- checks: copy.testingChecklist
3166
- });
3167
- if (cad && cad.maintenance !== "None")
3168
- cadenceBlocks.push({
3169
- line: `${copy.announceMaintenanceLabel} \u2014 ${FREQ_PHRASE[cad.maintenance]}`,
3170
- checks: copy.maintenanceChecks
3171
- });
3172
- const cadenceSection = cadenceBlocks.length > 0 ? `
3173
- <mj-section background-color="white">
3203
+ </mj-section>${checklistRowsSection(copy.maintenanceChecks, {
3204
+ background: maintBg,
3205
+ lastPaddingBottom: SECTION_PAD
3206
+ })}` : "";
3207
+ const testingSection = cad && cad.testing !== "None" ? `
3208
+ <mj-section background-color="${testBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
3209
+ <mj-column>
3210
+ ${sectionLabel("TESTING")}
3211
+ ${bodyLine(`${copy.testingIntro} We run a full test ${FREQ_PHRASE[cad.testing]}. ${copy.announceCadence}`)}
3212
+ </mj-column>
3213
+ </mj-section>${checklistRowsSection(copy.testingChecklist, {
3214
+ background: testBg,
3215
+ lastPaddingBottom: SECTION_PAD
3216
+ })}` : "";
3217
+ const analytics = analyticsSection({
3218
+ current: data.gaUsersCurrent,
3219
+ previous: data.gaUsersPrevious,
3220
+ background: analyticsBg,
3221
+ pad: SECTION_PAD,
3222
+ bodyLines: data.searchPosition !== void 0 ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`] : []
3223
+ });
3224
+ const improvementsSection = hasImpr ? `
3225
+ <mj-section background-color="${improvementsBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
3174
3226
  <mj-column>
3175
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">${escapeXml(copy.announceCadenceHeading)}</mj-text>
3176
- ${cadenceBlocks.map(
3177
- (b) => `
3178
- <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>
3179
- <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>`
3180
- ).join("")}
3181
- <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>
3227
+ ${sectionLabel("RECENT IMPROVEMENTS")}
3228
+ ${improvementItems.map((item) => bodyLine(item)).join("\n ")}
3229
+ ${bodyLine(copy.announceOpenDoor, "16px")}
3182
3230
  </mj-column>
3183
3231
  </mj-section>` : "";
3184
- const scoreRows = SCORE_PREVIEW.map(
3185
- ({ label, key }) => `
3186
- <mj-text color="${RED2}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
3187
- <mj-text color="${RED2}" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse[key]}</mj-text>`
3188
- ).join("");
3189
3232
  const contactRows = copy.contact.map(
3190
3233
  (line) => `
3191
- <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
3234
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
3192
3235
  ).join("");
3193
3236
  const footerAddressRows = copy.footerAddress.map(
3194
3237
  (line) => `
3195
- <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>`
3238
+ <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>`
3196
3239
  ).join("");
3197
3240
  return `<mjml>
3198
3241
  <mj-head>
@@ -3201,41 +3244,33 @@ function buildAnnouncementMjml(data) {
3201
3244
  <mj-section padding-left="11%" padding-right="11%"/>
3202
3245
  <mj-image padding="0px" />
3203
3246
  </mj-attributes>
3204
- <mj-preview>${escapeXml(previewText)}</mj-preview>
3247
+ <mj-preview>${escapeXml2(previewText)}</mj-preview>
3205
3248
  ${headerStyleBlock(data)}
3206
3249
  </mj-head>
3207
3250
  <mj-body background-color="white">
3208
3251
  <mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
3209
3252
  <mj-column>${headerImageTag(data)}</mj-column>
3210
3253
  </mj-section>
3211
- <mj-section background-color="white">
3254
+ <mj-section background-color="${introBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
3212
3255
  <mj-column>
3213
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.announceHeading)}</mj-text>
3214
- <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>
3215
- <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>
3256
+ ${sectionLabel(copy.announceHeading)}
3257
+ <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>
3258
+ ${bodyLine(copy.announceBody)}
3216
3259
  </mj-column>
3217
3260
  </mj-section>
3218
- ${cadenceSection}
3261
+ ${maintenanceSection}
3262
+ ${testingSection}
3263
+ ${lighthouseScoresSection(data.lighthouse, { background: lighthouseBg, pad: SECTION_PAD })}
3264
+ ${analytics}
3219
3265
  ${improvementsSection}
3220
- <mj-section background-color="#F4F4F4">
3221
- <mj-column>
3222
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.announcePreviewLabel)}</mj-text>
3223
- ${scoreRows}
3224
- </mj-column>
3225
- </mj-section>
3226
- <mj-section background-color="white">
3266
+ <mj-section background-color="${contactBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
3227
3267
  <mj-column>
3228
- <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>
3229
- </mj-column>
3230
- </mj-section>
3231
- <mj-section background-color="white">
3232
- <mj-column padding-top="36px">
3233
- <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>
3268
+ <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>
3234
3269
  ${contactRows}
3235
3270
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
3236
- <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>
3237
- <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>
3238
- <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>
3271
+ <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>
3272
+ <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>
3273
+ <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>
3239
3274
  ${footerAddressRows}
3240
3275
  </mj-column>
3241
3276
  </mj-section>
@@ -3270,7 +3305,10 @@ var TESTING_CHECKLIST = [
3270
3305
  label: "Interactions & Animations",
3271
3306
  field: "Test: Interactions & Animations"
3272
3307
  },
3273
- { key: "updates", label: "Verified After Updates", field: "Test: Verified After Updates" }
3308
+ // `field` keeps its original Airtable column name ("Verified After Updates") even though
3309
+ // the client-facing label is now "Tested After Updates" — the column holds operator data,
3310
+ // so renaming the label is display-only and avoids a live-base column migration.
3311
+ { key: "updates", label: "Tested After Updates", field: "Test: Verified After Updates" }
3274
3312
  ];
3275
3313
  var ALL_CHECKLIST_FIELDS = [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map(
3276
3314
  (i) => i.field
@@ -3402,6 +3440,33 @@ async function stampSent(base, recordId, sentAt, messageId) {
3402
3440
  ]);
3403
3441
  }
3404
3442
 
3443
+ // src/reports/queue.ts
3444
+ var REPORT_TIER = {
3445
+ Maintenance: 1,
3446
+ Testing: 2,
3447
+ Announcement: 3,
3448
+ Launch: 3
3449
+ };
3450
+ function reportTier(type) {
3451
+ return REPORT_TIER[type];
3452
+ }
3453
+ async function queueDraft(base, report) {
3454
+ const newTier = reportTier(report.reportType);
3455
+ const others = (await listReportsForSite(base, report.siteId)).filter(isPendingApproval).filter((r) => r.id !== report.id);
3456
+ const blocker = others.find((r) => reportTier(r.reportType) >= newTier);
3457
+ if (blocker) {
3458
+ await setDraftReady(base, report.id, false);
3459
+ return { queued: false, blockedBy: blocker.reportType, supersededIds: [] };
3460
+ }
3461
+ const supersededIds = [];
3462
+ for (const r of others) {
3463
+ await setDraftReady(base, r.id, false);
3464
+ supersededIds.push(r.id);
3465
+ }
3466
+ await setDraftReady(base, report.id, true);
3467
+ return { queued: true, supersededIds };
3468
+ }
3469
+
3405
3470
  // src/reports/airtable/attachments.ts
3406
3471
  function looksLikeHtml(bytes) {
3407
3472
  const start = bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191 ? 3 : 0;
@@ -3511,6 +3576,7 @@ import { JWT as JWT2 } from "google-auth-library";
3511
3576
  var WEBMASTERS_READONLY = "https://www.googleapis.com/auth/webmasters.readonly";
3512
3577
  var SC_BASE = "https://searchconsole.googleapis.com/webmasters/v3";
3513
3578
  var PAGE_1_MAX_POSITION = 10;
3579
+ var BRAND_QUERY_ROW_LIMIT = 100;
3514
3580
  function bareHost(s) {
3515
3581
  return s.trim().replace(/^sc-domain:/i, "").replace(/^https?:\/\//i, "").split("/")[0].replace(/^www\./i, "").toLowerCase();
3516
3582
  }
@@ -3524,6 +3590,25 @@ function resolvePropertyCandidates(entries, host) {
3524
3590
  function ymd3(d) {
3525
3591
  return d.toISOString().slice(0, 10);
3526
3592
  }
3593
+ function pickBrandQuery(rows) {
3594
+ let best;
3595
+ for (const r of rows) {
3596
+ if (typeof r.position !== "number") continue;
3597
+ const impressions = r.impressions ?? 0;
3598
+ if (best === void 0 || impressions > best.impressions || impressions === best.impressions && r.position < best.position) {
3599
+ best = { position: r.position, impressions };
3600
+ }
3601
+ }
3602
+ return best?.position;
3603
+ }
3604
+ function selectBrandPosition(rows, exactQuery) {
3605
+ const want = exactQuery.toLowerCase();
3606
+ const exact = rows.find(
3607
+ (r) => typeof r.position === "number" && (r.keys?.[0] ?? "").toLowerCase() === want
3608
+ );
3609
+ if (exact) return exact.position;
3610
+ return pickBrandQuery(rows);
3611
+ }
3527
3612
  async function fetchSearchPresence(q, periodStart, periodEnd) {
3528
3613
  const key = JSON.parse(readFileSync4(q.keyPath, "utf8"));
3529
3614
  const jwt = new JWT2({
@@ -3555,14 +3640,14 @@ async function fetchSearchPresence(q, periodStart, periodEnd) {
3555
3640
  dimensionFilterGroups: [
3556
3641
  {
3557
3642
  filters: [
3558
- { dimension: "query", operator: "equals", expression: q.query.toLowerCase() }
3643
+ { dimension: "query", operator: "contains", expression: q.query.toLowerCase() }
3559
3644
  ]
3560
3645
  }
3561
3646
  ],
3562
- rowLimit: 1
3647
+ rowLimit: BRAND_QUERY_ROW_LIMIT
3563
3648
  }
3564
3649
  });
3565
- const pos = res.data.rows?.[0]?.position;
3650
+ const pos = selectBrandPosition(res.data.rows ?? [], q.query.toLowerCase());
3566
3651
  if (typeof pos === "number") {
3567
3652
  return { foundOnPage1: pos <= PAGE_1_MAX_POSITION, position: Math.max(1, Math.round(pos)) };
3568
3653
  }
@@ -3620,12 +3705,24 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
3620
3705
  const path = options.previewPath ?? `reports/${slug}/draft.html`;
3621
3706
  await mkdir3(dirname6(path), { recursive: true });
3622
3707
  await writeFile10(path, html, "utf-8");
3623
- return { reportRow: null, htmlPath: path, html, softFailures };
3708
+ return { reportRow: null, htmlPath: path, html, softFailures, queued: null, supersededIds: [] };
3624
3709
  }
3625
3710
  if (base === null) throw new Error("base required when previewOnly=false");
3626
3711
  if (options.completeRowId) {
3627
- await finishDraftRow(base, options.completeRowId, slug, periodEnd, html);
3628
- return { reportRow: options.existingRow ?? null, htmlPath: null, html, softFailures };
3712
+ await uploadDraftHtml(options.completeRowId, slug, periodEnd, html);
3713
+ const outcome2 = await queueDraft(base, {
3714
+ id: options.completeRowId,
3715
+ siteId: siteRow.id,
3716
+ reportType
3717
+ });
3718
+ return {
3719
+ reportRow: options.existingRow ?? null,
3720
+ htmlPath: null,
3721
+ html,
3722
+ softFailures,
3723
+ queued: outcome2.queued,
3724
+ supersededIds: outcome2.supersededIds
3725
+ };
3629
3726
  }
3630
3727
  const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
3631
3728
  const created = await createDraft(base, {
@@ -3642,13 +3739,24 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
3642
3739
  ...search ? { searchFoundPage1: search.foundOnPage1 } : {},
3643
3740
  ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
3644
3741
  });
3645
- await finishDraftRow(base, created.id, slug, periodEnd, html);
3646
- return { reportRow: created, htmlPath: null, html, softFailures };
3742
+ await uploadDraftHtml(created.id, slug, periodEnd, html);
3743
+ const outcome = await queueDraft(base, {
3744
+ id: created.id,
3745
+ siteId: siteRow.id,
3746
+ reportType
3747
+ });
3748
+ return {
3749
+ reportRow: created,
3750
+ htmlPath: null,
3751
+ html,
3752
+ softFailures,
3753
+ queued: outcome.queued,
3754
+ supersededIds: outcome.supersededIds
3755
+ };
3647
3756
  }
3648
- async function finishDraftRow(base, rowId, slug, periodEnd, html) {
3757
+ async function uploadDraftHtml(rowId, slug, periodEnd, html) {
3649
3758
  const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
3650
3759
  await uploadAttachment(rowId, "Rendered HTML", html, htmlFilename, "text/html");
3651
- await setDraftReady(base, rowId, true);
3652
3760
  }
3653
3761
  var NO_ENRICHMENT = { value: null, softFailed: false };
3654
3762
  async function fetchGaUsers(siteRow, periodStart, periodEnd) {
@@ -3910,7 +4018,11 @@ async function sendOne(client, base, site, report) {
3910
4018
  headerImageCid: cidName,
3911
4019
  headerWidth: header.displayWidth,
3912
4020
  headerHeight: header.displayHeight,
3913
- headerBgColor: header.placeholderColor
4021
+ headerBgColor: header.placeholderColor,
4022
+ // Announcement-only: re-derive cadence + improvements from the site row so the SENT email
4023
+ // keeps its cadence copy + improvement callouts. Without this the send-time re-render drops
4024
+ // them entirely (they're not stored on the Reports row). Ignored by the other templates.
4025
+ ...report.reportType === "Announcement" ? announcementSiteExtras(site) : {}
3914
4026
  });
3915
4027
  const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
3916
4028
  const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;