@reddoorla/maintenance 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2781,7 +2781,18 @@ var DEFAULT_COPY = {
2781
2781
  "Hosting, DNS, and SSL configured",
2782
2782
  "Continuous integration + automatic dependency updates",
2783
2783
  "Analytics and uptime monitoring"
2784
- ]
2784
+ ],
2785
+ announceHeading: "YOUR ONGOING SITE CARE",
2786
+ 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:",
2787
+ announceCadenceHeading: "WHAT TO EXPECT",
2788
+ announceTestingLabel: "Full site testing",
2789
+ announceMaintenanceLabel: "Routine maintenance",
2790
+ announceMonitorItems: ["Performance", "Accessibility", "Security", "Uptime"],
2791
+ announcePreviewLabel: "From your latest full site test:",
2792
+ announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
2793
+ announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
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."
2785
2796
  };
2786
2797
  function override(v) {
2787
2798
  if (typeof v !== "string") return null;
@@ -3116,16 +3127,162 @@ function buildLaunchMjml(data) {
3116
3127
  </mjml>`;
3117
3128
  }
3118
3129
 
3130
+ // src/reports/announcement-email/template.ts
3131
+ var FREQ_PHRASE = {
3132
+ Monthly: "every month",
3133
+ Quarterly: "every quarter",
3134
+ Yearly: "every year"
3135
+ };
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
+ ];
3144
+ function buildAnnouncementMjml(data) {
3145
+ const copy = data.copy ?? DEFAULT_COPY;
3146
+ const previewText = "Your monthly report from Reddoor";
3147
+ const improvementItems = [];
3148
+ if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);
3149
+ if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);
3150
+ const improvementsSection = improvementItems.length > 0 ? `
3151
+ <mj-section background-color="white">
3152
+ <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("")}
3158
+ </mj-column>
3159
+ </mj-section>` : "";
3160
+ const cad = data.cadence;
3161
+ const cadenceLines = [];
3162
+ if (cad && cad.testing !== "None")
3163
+ cadenceLines.push(`${copy.announceTestingLabel} \u2014 ${FREQ_PHRASE[cad.testing]}`);
3164
+ if (cad && cad.maintenance !== "None")
3165
+ cadenceLines.push(`${copy.announceMaintenanceLabel} \u2014 ${FREQ_PHRASE[cad.maintenance]}`);
3166
+ const cadenceSection = cadenceLines.length > 0 ? `
3167
+ <mj-section background-color="white">
3168
+ <mj-column>
3169
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">${escapeXml(copy.announceCadenceHeading)}</mj-text>
3170
+ ${cadenceLines.map(
3171
+ (line) => `
3172
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="4px" padding-bottom="4px">\u2022 ${escapeXml(line)}</mj-text>`
3173
+ ).join("")}
3174
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="12px">${escapeXml(copy.announceCadence)}</mj-text>
3175
+ </mj-column>
3176
+ </mj-section>` : "";
3177
+ const monitorRows = copy.announceMonitorItems.map(
3178
+ (item) => `
3179
+ <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>`
3180
+ ).join("");
3181
+ const scoreRows = SCORE_PREVIEW.map(
3182
+ ({ label, key }) => `
3183
+ <mj-text color="${RED2}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
3184
+ <mj-text color="${RED2}" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse[key]}</mj-text>`
3185
+ ).join("");
3186
+ const contactRows = copy.contact.map(
3187
+ (line) => `
3188
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
3189
+ ).join("");
3190
+ const footerAddressRows = copy.footerAddress.map(
3191
+ (line) => `
3192
+ <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>`
3193
+ ).join("");
3194
+ return `<mjml>
3195
+ <mj-head>
3196
+ <mj-attributes>
3197
+ <mj-text font-family="helvetica, sans-serif" padding-left="5px" padding-right="5px" />
3198
+ <mj-section padding-left="11%" padding-right="11%"/>
3199
+ <mj-image padding="0px" />
3200
+ </mj-attributes>
3201
+ <mj-preview>${escapeXml(previewText)}</mj-preview>
3202
+ ${headerStyleBlock(data)}
3203
+ </mj-head>
3204
+ <mj-body background-color="white">
3205
+ <mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
3206
+ <mj-column>${headerImageTag(data)}</mj-column>
3207
+ </mj-section>
3208
+ <mj-section background-color="white">
3209
+ <mj-column>
3210
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.announceHeading)}</mj-text>
3211
+ <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>
3212
+ <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>
3213
+ </mj-column>
3214
+ </mj-section>
3215
+ ${cadenceSection}
3216
+ ${improvementsSection}
3217
+ <mj-section background-color="white">
3218
+ <mj-column>
3219
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">WHAT WE MONITOR</mj-text>
3220
+ ${monitorRows}
3221
+ </mj-column>
3222
+ </mj-section>
3223
+ <mj-section background-color="#F4F4F4">
3224
+ <mj-column>
3225
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.announcePreviewLabel)}</mj-text>
3226
+ ${scoreRows}
3227
+ </mj-column>
3228
+ </mj-section>
3229
+ <mj-section background-color="white">
3230
+ <mj-column>
3231
+ <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>
3232
+ </mj-column>
3233
+ </mj-section>
3234
+ <mj-section background-color="white">
3235
+ <mj-column padding-top="36px">
3236
+ <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>
3237
+ ${contactRows}
3238
+ <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
3239
+ <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>
3240
+ <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>
3241
+ <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>
3242
+ ${footerAddressRows}
3243
+ </mj-column>
3244
+ </mj-section>
3245
+ </mj-body>
3246
+ </mjml>`;
3247
+ }
3248
+
3119
3249
  // src/reports/render.ts
3120
3250
  async function renderReportHtml(data) {
3121
- const mjml = data.reportType === "Launch" ? buildLaunchMjml(data) : buildMjml(data);
3251
+ const mjml = data.reportType === "Launch" ? buildLaunchMjml(data) : data.reportType === "Announcement" ? buildAnnouncementMjml(data) : buildMjml(data);
3122
3252
  const out = await mjml2html(mjml, { validationLevel: "strict" });
3123
3253
  return { html: out.html, warnings: out.errors ?? [] };
3124
3254
  }
3125
3255
 
3256
+ // src/reports/checklist.ts
3257
+ var MAINTENANCE_CHECKLIST = [
3258
+ { key: "logs", label: "Reviewed Logs", field: "Maint: Reviewed Logs" },
3259
+ { key: "cms", label: "CMS Checked", field: "Maint: CMS Checked" },
3260
+ { key: "dns", label: "DNS Checked", field: "Maint: DNS Checked" },
3261
+ { key: "google", label: "Google Indexed", field: "Maint: Google Indexed" },
3262
+ { key: "cert", label: "Reviewed Certificate", field: "Maint: Reviewed Certificate" },
3263
+ { key: "security", label: "Security Updates", field: "Maint: Security Updates" }
3264
+ ];
3265
+ var TESTING_CHECKLIST = [
3266
+ { key: "desktop", label: "Desktop Browsers", field: "Test: Desktop Browsers" },
3267
+ { key: "mobile", label: "Mobile Browsers", field: "Test: Mobile Browsers" },
3268
+ { key: "packages", label: "Package Updates", field: "Test: Package Updates" },
3269
+ { key: "bottle", label: "Bottlenecks", field: "Test: Bottlenecks" },
3270
+ { key: "forms", label: "Form Functionality", field: "Test: Form Functionality" },
3271
+ { key: "animation", label: "Animation Functionality", field: "Test: Animation Functionality" }
3272
+ ];
3273
+ var ALL_CHECKLIST_FIELDS = [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map(
3274
+ (i) => i.field
3275
+ );
3276
+ function checklistFor(type) {
3277
+ return type === "Maintenance" ? MAINTENANCE_CHECKLIST : type === "Testing" ? TESTING_CHECKLIST : [];
3278
+ }
3279
+ function isChecklistComplete(report) {
3280
+ return checklistFor(report.reportType).every((i) => report.checklist[i.field] === true);
3281
+ }
3282
+
3126
3283
  // src/reports/airtable/reports.ts
3127
3284
  var REPORTS_TABLE = "Reports";
3128
- var REPORT_TYPES = ["Maintenance", "Testing", "Launch"];
3285
+ var REPORT_TYPES = ["Maintenance", "Testing", "Launch", "Announcement"];
3129
3286
  function toReportType(raw) {
3130
3287
  if (raw && REPORT_TYPES.includes(raw)) return raw;
3131
3288
  if (raw)
@@ -3163,7 +3320,8 @@ function mapRow2(rec) {
3163
3320
  approvedBy: f["Approved By"] ?? null,
3164
3321
  deliveryStatus: f["Delivery status"] ?? "pending",
3165
3322
  renderedHtmlAttachment: html,
3166
- resendMessageId: f["Resend message ID"] ?? null
3323
+ resendMessageId: f["Resend message ID"] ?? null,
3324
+ checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])]))
3167
3325
  };
3168
3326
  }
3169
3327
  function lighthouseFromFields(f) {
@@ -3198,6 +3356,7 @@ async function createDraft(base, input) {
3198
3356
  if (input.searchFoundPage1 !== void 0) fields["Search found page 1"] = input.searchFoundPage1;
3199
3357
  if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
3200
3358
  if (input.period !== void 0) fields["Period"] = input.period;
3359
+ if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
3201
3360
  const created = await base(REPORTS_TABLE).create([{ fields }]);
3202
3361
  const rec = created[0];
3203
3362
  if (!rec) throw new Error("Airtable create returned no records");
@@ -3687,6 +3846,13 @@ async function sendApprovedReports(options = {}) {
3687
3846
  return { output: lines.join("\n"), code: anyFailed ? 1 : 0 };
3688
3847
  }
3689
3848
  async function sendOne(client, base, site, report) {
3849
+ if (!isChecklistComplete(report)) {
3850
+ const items = checklistFor(report.reportType);
3851
+ const done = items.filter((i) => report.checklist[i.field] === true).length;
3852
+ throw new Error(
3853
+ `Report ${report.reportId} checklist incomplete \u2014 ${done}/${items.length} items checked`
3854
+ );
3855
+ }
3690
3856
  if (!site.headerImage) {
3691
3857
  throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
3692
3858
  }
@@ -3903,7 +4069,33 @@ function relativeTimeFromNow(iso, now = /* @__PURE__ */ new Date()) {
3903
4069
  var REDDOOR_FAVICON_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAACXBIWXMAAAAnAAAAJwEqCZFPAAAAk1BMVEVHcEzkGTfjGDbjGTfjGTfjGTfjGTfjGDbjGDbjGTfjGTfjGTfkGTfjGTfjGDbjGDbkGTfjGDbjGDbjGTfjGTfjGDbjGTfjGDbjGTfjGDbjGTfjGTfjGDbjGDbjGDbjGDbjGTfjGTbjGDbjGDbjGDbjGDbjGTfjGTfjGTfjGDbjGDbjGTfjGDbjGDbjGTfjGTbkGTfxxbwzAAAALXRSTlMA4DABMOD8cOBwDltwi+bFLMlk54/BDVPk/I61/u2cFaBw9nqO1izBIQE+Becdo6eEAAABBElEQVQ4y91SCVYCMQwt4kxbcEHFfUFFcMGf9P6nM0kXEPEAmtdJs+cnU+f+He2fIIEAYuVgkmvDfTG9Rh+6LoRBUBqE7gi09l9eAeeTrZJoFd5uQWfj4VbPvRqwmj9zfzP6CYpyi48F6HW5A3WuMHsEPsfvO8cSkMPTe+pfRr/MTckdg+4enqL3PkYf/YFIqiiP8VAw6EZkH6QEO0xJLUhmdbY5+TjHkCVohprlcpzlnJw9GlNkrZC16uCmWIaAZItNrT4xV0T2yxrIWoNKy4rdVXOq0MycWp4r43FOMQWciibMYaOHCXKSPhapqTupGFAnwHoVuUUZq7jaSkqDb0/uD9MXqvJMDtU7lL0AAAAASUVORK5CYII=";
3904
4070
  var FAVICON_LINK = `<link rel="icon" type="image/png" href="data:image/png;base64,${REDDOOR_FAVICON_PNG_BASE64}" />`;
3905
4071
 
4072
+ // src/dashboard/onboarding.ts
4073
+ function isNonEmpty(s) {
4074
+ return typeof s === "string" && s.trim().length > 0;
4075
+ }
4076
+ function onboardingStatus(row) {
4077
+ const checks = {
4078
+ firstAudit: isNonEmpty(row.lastLighthouseAuditAt),
4079
+ recipients: isNonEmpty(row.reportRecipientsTo),
4080
+ schedule: row.maintenanceFreq !== "None",
4081
+ poc: isNonEmpty(row.pointOfContact)
4082
+ };
4083
+ const score = Object.values(checks).filter(Boolean).length;
4084
+ return { score, total: 4, checks };
4085
+ }
4086
+ var ONBOARDING_LABELS = {
4087
+ firstAudit: "First audit",
4088
+ recipients: "Report recipients",
4089
+ schedule: "Maintenance schedule",
4090
+ poc: "Point of contact"
4091
+ };
4092
+ function missingOnboarding(row) {
4093
+ const { checks } = onboardingStatus(row);
4094
+ return Object.keys(ONBOARDING_LABELS).filter((key) => !checks[key]).map((key) => ONBOARDING_LABELS[key]);
4095
+ }
4096
+
3906
4097
  // src/dashboard/render.ts
4098
+ var DASH = "\u2014";
3907
4099
  function scoreTile(label, value) {
3908
4100
  const display = value === null ? "\u2014" : String(value);
3909
4101
  return `<div class="tile"><div class="tile-value">${escapeHtml(display)}</div><div class="tile-label">${escapeHtml(label)}</div></div>`;
@@ -3936,10 +4128,25 @@ function securitySub(site) {
3936
4128
  const l = site.securityVulnsLow ?? 0;
3937
4129
  return `${c}C / ${h}H / ${m}M / ${l}L`;
3938
4130
  }
4131
+ function checklistBlock(r) {
4132
+ const items = checklistFor(r.reportType);
4133
+ if (items.length === 0) return "";
4134
+ const rid = escapeHtml(r.id);
4135
+ const url = `/api/reports/${encodeURIComponent(r.id)}/checklist`;
4136
+ const boxes = items.map((item) => {
4137
+ const checked = r.checklist[item.field] === true ? " checked" : "";
4138
+ return `<label class="check-item"><input type="checkbox" class="checklist-checkbox" data-checklist-report-id="${rid}" data-field="${escapeHtml(item.field)}" data-checklist-url="${escapeHtml(url)}"${checked} /> ${escapeHtml(item.label)}</label>`;
4139
+ }).join("");
4140
+ return `<div class="checklist" data-checklist-for="${rid}">${boxes}</div>`;
4141
+ }
4142
+ function approveButton(r) {
4143
+ const disabled = isChecklistComplete(r) ? "" : " disabled";
4144
+ return `<button class="approve" data-report-id="${escapeHtml(r.id)}" data-approve-url="/api/reports/${encodeURIComponent(r.id)}/approve"${disabled}>Approve</button>`;
4145
+ }
3939
4146
  function pendingRow(r) {
3940
4147
  const type = escapeHtml(r.reportType);
3941
4148
  const period = r.period ? escapeHtml(r.period) : "\u2014";
3942
- return `<li><strong>${type}</strong> <span class="muted">${period}</span> <button class="approve" data-report-id="${escapeHtml(r.id)}" data-approve-url="/api/reports/${encodeURIComponent(r.id)}/approve">Approve</button></li>`;
4149
+ return `<li><div class="pending-head"><strong>${type}</strong> <span class="muted">${period}</span> ${approveButton(r)}</div>${checklistBlock(r)}</li>`;
3943
4150
  }
3944
4151
  function pendingSection(reports) {
3945
4152
  const pending = reports.filter(isPendingApproval);
@@ -3949,13 +4156,29 @@ function pendingSection(reports) {
3949
4156
  <ul class="pending-list">${pending.map(pendingRow).join("")}</ul>
3950
4157
  </div>`;
3951
4158
  }
4159
+ function gaUsersCell(r) {
4160
+ if (r.gaUsersCurrent === null) return DASH;
4161
+ const current = String(r.gaUsersCurrent);
4162
+ if (r.gaUsersPrevious === null) return escapeHtml(current);
4163
+ const delta = r.gaUsersCurrent - r.gaUsersPrevious;
4164
+ const sign = delta > 0 ? "+" : "";
4165
+ return `${escapeHtml(current)} <span class="muted">(${escapeHtml(`${sign}${delta}`)})</span>`;
4166
+ }
4167
+ function searchCell(r) {
4168
+ if (r.searchFoundPage1 && r.searchPosition !== null) {
4169
+ return escapeHtml(`#${r.searchPosition}`);
4170
+ }
4171
+ return DASH;
4172
+ }
3952
4173
  function reportRow(r) {
3953
- const date = r.completedOn ? escapeHtml(r.completedOn) : "\u2014";
4174
+ const date = r.completedOn ? escapeHtml(r.completedOn) : DASH;
3954
4175
  const type = escapeHtml(r.reportType);
3955
4176
  const id = escapeHtml(r.reportId);
4177
+ const ga = gaUsersCell(r);
4178
+ const search = searchCell(r);
3956
4179
  const link = r.renderedHtmlAttachment ? `<a href="${escapeHtml(safeUrl(r.renderedHtmlAttachment.url))}">view</a>` : `<span class="muted">no attachment</span>`;
3957
- const action = isPendingApproval(r) ? `<button class="approve" data-report-id="${escapeHtml(r.id)}" data-approve-url="/api/reports/${encodeURIComponent(r.id)}/approve">Approve</button>` : "";
3958
- return `<tr><td>${date}</td><td>${type}</td><td><code>${id}</code></td><td>${link}</td><td>${action}</td></tr>`;
4180
+ const action = isPendingApproval(r) ? approveButton(r) : "";
4181
+ return `<tr><td>${date}</td><td>${type}</td><td><code>${id}</code></td><td>${ga}</td><td>${search}</td><td>${link}</td><td>${action}</td></tr>`;
3959
4182
  }
3960
4183
  function submissionRow(s) {
3961
4184
  const when = s.submittedAt ? escapeHtml(relativeTimeFromNow(s.submittedAt)) : "\u2014";
@@ -3981,6 +4204,34 @@ function submissionsSection(submissions) {
3981
4204
  <ul class="subm-list">${recent.map(submissionRow).join("")}</ul>
3982
4205
  </div>`;
3983
4206
  }
4207
+ function setupSection(site) {
4208
+ const { score, total } = onboardingStatus(site);
4209
+ const missing2 = missingOnboarding(site);
4210
+ const detail = missing2.length === 0 ? `<span class="setup-ok">complete</span>` : `<span class="setup-missing">Missing: ${escapeHtml(missing2.join(", "))}</span>`;
4211
+ return `<div class="setup-line">Setup ${score}/${total} \u2014 ${detail}</div>`;
4212
+ }
4213
+ function detailRow(label, value) {
4214
+ const display = typeof value === "string" && value.trim().length > 0 ? escapeHtml(value.trim()) : DASH;
4215
+ return `<div class="detail"><dt>${escapeHtml(label)}</dt><dd>${display}</dd></div>`;
4216
+ }
4217
+ function siteDetailsSection(site) {
4218
+ const lastCommit = site.lastCommitAt ? `${relativeTimeFromNow(site.lastCommitAt)}` : null;
4219
+ const rows = [
4220
+ detailRow("Maintenance cadence", site.maintenanceFreq),
4221
+ detailRow("Testing cadence", site.testingFreq),
4222
+ detailRow("Report recipients (To)", site.reportRecipientsTo),
4223
+ detailRow("Report recipients (CC)", site.reportRecipientsCc),
4224
+ detailRow("Point of contact", site.pointOfContact),
4225
+ detailRow("GA4 property", site.ga4PropertyId),
4226
+ detailRow("Search query", site.searchQuery),
4227
+ detailRow("Git repo", site.gitRepo),
4228
+ detailRow("Last commit", lastCommit)
4229
+ ].join("");
4230
+ return `<div class="section site-details">
4231
+ <h2>Site details</h2>
4232
+ <dl class="details">${rows}</dl>
4233
+ </div>`;
4234
+ }
3984
4235
  var STYLES = `
3985
4236
  :root { color-scheme: light dark; }
3986
4237
  body { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
@@ -4005,7 +4256,12 @@ th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }
4005
4256
  button.approve { font: inherit; padding: 0.35rem 0.85rem; border: 1px solid #2c7; border-radius: 6px; background: #2c7; color: #fff; cursor: pointer; }
4006
4257
  button.approve:disabled { opacity: 0.6; cursor: default; }
4007
4258
  .pending-list { list-style: none; padding: 0; margin: 0; }
4008
- .pending-list li { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-bottom: 1px solid #eee; }
4259
+ .pending-list li { padding: 0.5rem; border-bottom: 1px solid #eee; }
4260
+ @media (prefers-color-scheme: dark) { .pending-list li { border-color: #2a2a2a; } }
4261
+ .pending-head { display: flex; align-items: center; gap: 0.5rem; }
4262
+ .checklist { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0.5rem 0 0.25rem 0.25rem; }
4263
+ .check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; }
4264
+ .check-item input { margin: 0; }
4009
4265
  .pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }
4010
4266
  .subm-list { list-style: none; padding: 0; margin: 0; }
4011
4267
  .subm-item { padding: 0.6rem 0; border-bottom: 1px solid #eee; }
@@ -4019,6 +4275,14 @@ button.subm-status:disabled { opacity: 0.6; cursor: default; }
4019
4275
  .pill.subm-read { background: #f0f0f0; color: #555; }
4020
4276
  .pill.subm-archived { background: #eee; color: #888; }
4021
4277
  .pill.subm-spam { background: #fdecea; color: #b00; }
4278
+ .home { display: inline-block; font-size: 0.9rem; margin-bottom: 0.75rem; text-decoration: none; }
4279
+ .setup-line { font-size: 0.9rem; color: #666; margin-bottom: 1rem; }
4280
+ .setup-ok { color: #1b7a2f; font-weight: 600; }
4281
+ .setup-missing { color: #a65a00; }
4282
+ .details { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.5rem 1.5rem; margin: 0; }
4283
+ .detail { display: flex; flex-direction: column; }
4284
+ .detail dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: #999; }
4285
+ .detail dd { margin: 0; }
4022
4286
  `;
4023
4287
  function renderSiteDashboardHtml(site, reports, submissions = []) {
4024
4288
  const name = escapeHtml(site.name);
@@ -4040,7 +4304,7 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
4040
4304
  const auditedLine = site.lastLighthouseAuditAt ? `<div class="audited">Last audited ${escapeHtml(relativeTimeFromNow(site.lastLighthouseAuditAt))}</div>` : "";
4041
4305
  const recentReports = [...reports].sort((a, b) => (b.completedOn ?? "").localeCompare(a.completedOn ?? "")).slice(0, 6);
4042
4306
  const reportsSection = recentReports.length === 0 ? `<div class="empty">No reports yet.</div>` : `<table>
4043
- <thead><tr><th>Completed</th><th>Type</th><th>ID</th><th>Report</th><th></th></tr></thead>
4307
+ <thead><tr><th>Completed</th><th>Type</th><th>ID</th><th>GA users</th><th>Search</th><th>Report</th><th></th></tr></thead>
4044
4308
  <tbody>${recentReports.map(reportRow).join("")}</tbody>
4045
4309
  </table>`;
4046
4310
  return `<!doctype html>
@@ -4053,9 +4317,11 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
4053
4317
  <style>${STYLES}</style>
4054
4318
  </head>
4055
4319
  <body>
4320
+ <a class="home" href="/">\u2190 Fleet home</a>
4056
4321
  <h1>${name}</h1>
4057
4322
  <div class="meta"><a href="${escapeHtml(urlSafe)}">${escapeHtml(site.url)}</a></div>
4058
4323
  ${auditedLine}
4324
+ ${setupSection(site)}
4059
4325
  ${pendingSection(reports)}
4060
4326
  ${submissionsSection(submissions)}
4061
4327
 
@@ -4073,6 +4339,8 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
4073
4339
  <h2>Reports</h2>
4074
4340
  ${reportsSection}
4075
4341
  </div>
4342
+
4343
+ ${siteDetailsSection(site)}
4076
4344
  <script>
4077
4345
  document.querySelectorAll("button.approve").forEach((b) => {
4078
4346
  b.addEventListener("click", async () => {
@@ -4106,39 +4374,52 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
4106
4374
  }
4107
4375
  });
4108
4376
  });
4377
+ // Checklist gate: ticking a box POSTs the one field; the response { complete }
4378
+ // decides whether THIS report's Approve button is enabled. Scoped per report by
4379
+ // matching the checkbox's report id to the Approve button's id, so multiple
4380
+ // pending reports on one page never cross-toggle. On failure the checkbox reverts.
4381
+ document.querySelectorAll("input.checklist-checkbox").forEach((cb) => {
4382
+ cb.addEventListener("change", async () => {
4383
+ const reportId = cb.dataset.checklistReportId;
4384
+ const approveBtn = document.querySelector(
4385
+ 'button.approve[data-report-id="' + (window.CSS && CSS.escape ? CSS.escape(reportId) : reportId) + '"]',
4386
+ );
4387
+ cb.disabled = true;
4388
+ try {
4389
+ const res = await fetch(cb.dataset.checklistUrl, {
4390
+ method: "POST",
4391
+ headers: { "content-type": "application/json" },
4392
+ body: JSON.stringify({ reportId, field: cb.dataset.field, value: cb.checked }),
4393
+ });
4394
+ if (!res.ok) throw new Error("bad status");
4395
+ const data = await res.json();
4396
+ if (approveBtn) approveBtn.disabled = !data.complete;
4397
+ } catch {
4398
+ // Revert the optimistic flip so the box reflects the (unchanged) server state.
4399
+ cb.checked = !cb.checked;
4400
+ } finally {
4401
+ cb.disabled = false;
4402
+ }
4403
+ });
4404
+ });
4109
4405
  </script>
4110
4406
  </body>
4111
4407
  </html>`;
4112
4408
  }
4113
4409
 
4114
- // src/dashboard/onboarding.ts
4115
- function isNonEmpty(s) {
4116
- return typeof s === "string" && s.trim().length > 0;
4117
- }
4118
- function onboardingStatus(row) {
4119
- const checks = {
4120
- firstAudit: isNonEmpty(row.lastLighthouseAuditAt),
4121
- recipients: isNonEmpty(row.reportRecipientsTo),
4122
- schedule: row.maintenanceFreq !== "None",
4123
- poc: isNonEmpty(row.pointOfContact)
4124
- };
4125
- const score = Object.values(checks).filter(Boolean).length;
4126
- return { score, total: 4, checks };
4127
- }
4128
-
4129
4410
  // src/dashboard/fleet-render.ts
4130
- var DASH = "\u2014";
4411
+ var DASH2 = "\u2014";
4131
4412
  function scoreSpan(category, value) {
4132
- const display = value === null ? DASH : String(value);
4413
+ const display = value === null ? DASH2 : String(value);
4133
4414
  return `<span class="score ${category}">${escapeHtml(display)}</span>`;
4134
4415
  }
4135
4416
  function a11ySpan(value) {
4136
- const display = value === null ? DASH : String(value);
4417
+ const display = value === null ? DASH2 : String(value);
4137
4418
  return `<span class="metric a11y">${escapeHtml(display)}</span>`;
4138
4419
  }
4139
4420
  function depsSpan(drifted, majorBehind, outdated) {
4140
4421
  if (drifted === null || majorBehind === null) {
4141
- return `<span class="metric deps">${DASH}</span>`;
4422
+ return `<span class="metric deps">${DASH2}</span>`;
4142
4423
  }
4143
4424
  const driftPart = drifted === 0 ? "0" : `${drifted} drifted (${majorBehind} major)`;
4144
4425
  const display = outdated === null ? driftPart : `${driftPart} \xB7 ${outdated} outdated`;
@@ -4146,7 +4427,7 @@ function depsSpan(drifted, majorBehind, outdated) {
4146
4427
  }
4147
4428
  function securitySpan(critical, high, moderate, low) {
4148
4429
  if (critical === null || high === null || moderate === null || low === null) {
4149
- return `<span class="metric sec">${DASH}</span>`;
4430
+ return `<span class="metric sec">${DASH2}</span>`;
4150
4431
  }
4151
4432
  const total = critical + high + moderate + low;
4152
4433
  const display = total === 0 ? "0" : `${critical}C/${high}H/${moderate}M/${low}L`;
@@ -4156,6 +4437,10 @@ function card(site) {
4156
4437
  const name = escapeHtml(site.name);
4157
4438
  const href = `/s/${escapeHtml(siteSlug(site.name))}`;
4158
4439
  const onboarding = onboardingStatus(site);
4440
+ const missing2 = missingOnboarding(site);
4441
+ const setupTitle = escapeHtml(
4442
+ missing2.length === 0 ? "Setup complete" : `Missing: ${missing2.join(", ")}`
4443
+ );
4159
4444
  const audited = relativeTimeFromNow(site.lastLighthouseAuditAt);
4160
4445
  const safeSiteUrl = escapeHtml(safeUrl(site.url));
4161
4446
  const visibleUrl = escapeHtml(site.url);
@@ -4163,15 +4448,15 @@ function card(site) {
4163
4448
  <header class="card-head">
4164
4449
  <a class="site" href="${href}">${name}</a>
4165
4450
  <a class="url" href="${safeSiteUrl}" target="_blank" rel="noopener">${visibleUrl}</a>
4166
- <span class="setup">Setup: <strong>${onboarding.score}/${onboarding.total}</strong></span>
4451
+ <span class="setup" title="${setupTitle}">Setup: <strong>${onboarding.score}/${onboarding.total}</strong></span>
4167
4452
  <span class="audited">Audited: <strong>${escapeHtml(audited)}</strong></span>
4168
4453
  </header>
4169
4454
  <div class="card-metrics">
4170
4455
  <span class="cluster lighthouse">
4171
- ${scoreSpan("perf", site.pScore)}
4172
- ${scoreSpan("a11y-lh", site.rScore)}
4173
- ${scoreSpan("bp", site.bpScore)}
4174
- ${scoreSpan("seo", site.seoScore)}
4456
+ <span class="metric-label">Perf</span> ${scoreSpan("perf", site.pScore)}
4457
+ <span class="metric-label">Access</span> ${scoreSpan("a11y-lh", site.rScore)}
4458
+ <span class="metric-label">BP</span> ${scoreSpan("bp", site.bpScore)}
4459
+ <span class="metric-label">SEO</span> ${scoreSpan("seo", site.seoScore)}
4175
4460
  </span>
4176
4461
  <span class="cluster health">
4177
4462
  <span class="metric-label">a11y</span> ${a11ySpan(site.a11yViolations)}
@@ -4244,6 +4529,7 @@ var FILTERS = [
4244
4529
  "prs",
4245
4530
  "ci",
4246
4531
  "stale",
4532
+ "no-domain",
4247
4533
  "pending",
4248
4534
  "submissions"
4249
4535
  ];