@reddoorla/maintenance 0.42.0 → 0.44.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.d.ts CHANGED
@@ -284,6 +284,9 @@ type ResolvedCopy = {
284
284
  launchSetupItems: string[];
285
285
  announceHeading: string;
286
286
  announceBody: string;
287
+ announceCadenceHeading: string;
288
+ announceTestingLabel: string;
289
+ announceMaintenanceLabel: string;
287
290
  announceMonitorItems: string[];
288
291
  announcePreviewLabel: string;
289
292
  announceImprovementResend: string;
@@ -293,6 +296,13 @@ type ResolvedCopy = {
293
296
  };
294
297
 
295
298
  type ReportType = "Maintenance" | "Testing" | "Launch" | "Announcement";
299
+ /** Recurrence pace. Mirrors `Frequency` in airtable/websites.ts — inlined here so
300
+ * the base types stay free of an airtable-layer import (avoids a type cycle). */
301
+ type ReportFrequency = "None" | "Monthly" | "Quarterly" | "Yearly";
302
+ type ReportCadence = {
303
+ maintenance: ReportFrequency;
304
+ testing: ReportFrequency;
305
+ };
296
306
  type LighthouseScores = {
297
307
  performance: number;
298
308
  accessibility: number;
@@ -332,6 +342,9 @@ type ReportData = {
332
342
  resendForms?: boolean;
333
343
  svelte5?: boolean;
334
344
  };
345
+ /** Announcement-only: the client's go-forward pace, rendered as the "WHAT TO EXPECT"
346
+ * section. A "None" pace is omitted; undefined → the whole section is absent. */
347
+ cadence?: ReportCadence;
335
348
  /** Resolved per-site copy (M6a). Omitted → the template falls back to DEFAULT_COPY. */
336
349
  copy?: ResolvedCopy;
337
350
  /** Used in the header `mj-image src`; the email attaches the bytes with this CID. */
@@ -379,6 +392,10 @@ type ReportRow = {
379
392
  } | null;
380
393
  /** Read out of the Resend response and stored in a hidden field; needed for webhook reconciliation. */
381
394
  resendMessageId: string | null;
395
+ /** The 12 operator-checklist checkboxes, keyed by their Airtable column name (ALL_CHECKLIST_FIELDS);
396
+ * missing/false cells read false. Maintenance/Testing reports gate approve+send on the relevant
397
+ * subset (see src/reports/checklist.ts). */
398
+ checklist: Record<string, boolean>;
382
399
  };
383
400
 
384
401
  type DraftOptions = {
package/dist/index.js CHANGED
@@ -2754,21 +2754,22 @@ import mjml2html from "mjml";
2754
2754
  var DEFAULT_COPY = {
2755
2755
  maintenanceIntro: "Includes checking the hosting, DNS, Content Management System (CMS, if applicable), search indexing and security of the site for major flaws and updating as necessary.",
2756
2756
  maintenanceChecks: [
2757
- "Reviewed Logs",
2757
+ "Deploy & Function Health",
2758
2758
  "CMS Checked",
2759
- "DNS Checked",
2759
+ "Domain, DNS & SSL",
2760
2760
  "Google Indexed",
2761
- "Reviewed Certificate",
2762
- "Security Updates"
2761
+ "Security Updates",
2762
+ "Uptime Checked"
2763
2763
  ],
2764
2764
  testingIntro: "Testing includes checks similar to those at launch: testing on common browsers and operating systems, at different screen sizes, and checking every function, and updating all packages for performance rather than just those needed for security.",
2765
2765
  testingChecklist: [
2766
2766
  "Desktop Browsers",
2767
2767
  "Mobile Browsers",
2768
- "Package Updates",
2769
- "Bottlenecks",
2768
+ "Page Titles & Meta",
2769
+ "Links & Navigation",
2770
2770
  "Form Functionality",
2771
- "Animation Functionality"
2771
+ "Interactions & Animations",
2772
+ "Verified After Updates"
2772
2773
  ],
2773
2774
  notesHeader: "NOTES",
2774
2775
  seoCta: "Contact us if you are interested in more in-depth data or have questions about SEO.",
@@ -2782,13 +2783,16 @@ var DEFAULT_COPY = {
2782
2783
  "Continuous integration + automatic dependency updates",
2783
2784
  "Analytics and uptime monitoring"
2784
2785
  ],
2785
- announceHeading: "YOUR MONTHLY REPORT",
2786
- announceBody: "We've set up ongoing monitoring and maintenance for your site. Each month we quietly check that everything's healthy and up to date \u2014 and now you'll get a short report so you can see it at a glance.",
2786
+ announceHeading: "YOUR ONGOING SITE CARE",
2787
+ 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",
2787
2791
  announceMonitorItems: ["Performance", "Accessibility", "Security", "Uptime"],
2788
- announcePreviewLabel: "A snapshot of your latest scores:",
2792
+ announcePreviewLabel: "From your latest full site test:",
2789
2793
  announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
2790
2794
  announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
2791
- announceCadence: "You'll receive this every month. There's nothing you need to do.",
2795
+ announceCadence: "After each one we'll send you a short report like this \u2014 there's nothing you need to do.",
2792
2796
  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."
2793
2797
  };
2794
2798
  function override(v) {
@@ -3125,6 +3129,11 @@ function buildLaunchMjml(data) {
3125
3129
  }
3126
3130
 
3127
3131
  // src/reports/announcement-email/template.ts
3132
+ var FREQ_PHRASE = {
3133
+ Monthly: "every month",
3134
+ Quarterly: "every quarter",
3135
+ Yearly: "every year"
3136
+ };
3128
3137
  var RED2 = "#C00";
3129
3138
  var GREY2 = "#757575";
3130
3139
  var SCORE_PREVIEW = [
@@ -3149,6 +3158,23 @@ function buildAnnouncementMjml(data) {
3149
3158
  ).join("")}
3150
3159
  </mj-column>
3151
3160
  </mj-section>` : "";
3161
+ const cad = data.cadence;
3162
+ const cadenceLines = [];
3163
+ if (cad && cad.testing !== "None")
3164
+ cadenceLines.push(`${copy.announceTestingLabel} \u2014 ${FREQ_PHRASE[cad.testing]}`);
3165
+ if (cad && cad.maintenance !== "None")
3166
+ cadenceLines.push(`${copy.announceMaintenanceLabel} \u2014 ${FREQ_PHRASE[cad.maintenance]}`);
3167
+ const cadenceSection = cadenceLines.length > 0 ? `
3168
+ <mj-section background-color="white">
3169
+ <mj-column>
3170
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">${escapeXml(copy.announceCadenceHeading)}</mj-text>
3171
+ ${cadenceLines.map(
3172
+ (line) => `
3173
+ <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>`
3174
+ ).join("")}
3175
+ <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>
3176
+ </mj-column>
3177
+ </mj-section>` : "";
3152
3178
  const monitorRows = copy.announceMonitorItems.map(
3153
3179
  (item) => `
3154
3180
  <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>`
@@ -3187,6 +3213,7 @@ function buildAnnouncementMjml(data) {
3187
3213
  <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>
3188
3214
  </mj-column>
3189
3215
  </mj-section>
3216
+ ${cadenceSection}
3190
3217
  ${improvementsSection}
3191
3218
  <mj-section background-color="white">
3192
3219
  <mj-column>
@@ -3202,8 +3229,7 @@ function buildAnnouncementMjml(data) {
3202
3229
  </mj-section>
3203
3230
  <mj-section background-color="white">
3204
3231
  <mj-column>
3205
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="36px">${escapeXml(copy.announceCadence)}</mj-text>
3206
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="8px">${escapeXml(copy.announceOpenDoor)}</mj-text>
3232
+ <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>
3207
3233
  </mj-column>
3208
3234
  </mj-section>
3209
3235
  <mj-section background-color="white">
@@ -3228,6 +3254,38 @@ async function renderReportHtml(data) {
3228
3254
  return { html: out.html, warnings: out.errors ?? [] };
3229
3255
  }
3230
3256
 
3257
+ // src/reports/checklist.ts
3258
+ var MAINTENANCE_CHECKLIST = [
3259
+ { key: "deploy", label: "Deploy & Function Health", field: "Maint: Deploy & Function Health" },
3260
+ { key: "cms", label: "CMS Checked", field: "Maint: CMS Checked" },
3261
+ { key: "domain", label: "Domain, DNS & SSL", field: "Maint: Domain, DNS & SSL" },
3262
+ { key: "google", label: "Google Indexed", field: "Maint: Google Indexed" },
3263
+ { key: "security", label: "Security Updates", field: "Maint: Security Updates" },
3264
+ { key: "uptime", label: "Uptime Checked", field: "Maint: Uptime Checked" }
3265
+ ];
3266
+ var TESTING_CHECKLIST = [
3267
+ { key: "desktop", label: "Desktop Browsers", field: "Test: Desktop Browsers" },
3268
+ { key: "mobile", label: "Mobile Browsers", field: "Test: Mobile Browsers" },
3269
+ { key: "titles", label: "Page Titles & Meta", field: "Test: Page Titles & Meta" },
3270
+ { key: "links", label: "Links & Navigation", field: "Test: Links & Navigation" },
3271
+ { key: "forms", label: "Form Functionality", field: "Test: Form Functionality" },
3272
+ {
3273
+ key: "interactions",
3274
+ label: "Interactions & Animations",
3275
+ field: "Test: Interactions & Animations"
3276
+ },
3277
+ { key: "updates", label: "Verified After Updates", field: "Test: Verified After Updates" }
3278
+ ];
3279
+ var ALL_CHECKLIST_FIELDS = [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map(
3280
+ (i) => i.field
3281
+ );
3282
+ function checklistFor(type) {
3283
+ return type === "Maintenance" ? MAINTENANCE_CHECKLIST : type === "Testing" ? TESTING_CHECKLIST : [];
3284
+ }
3285
+ function isChecklistComplete(report) {
3286
+ return checklistFor(report.reportType).every((i) => report.checklist[i.field] === true);
3287
+ }
3288
+
3231
3289
  // src/reports/airtable/reports.ts
3232
3290
  var REPORTS_TABLE = "Reports";
3233
3291
  var REPORT_TYPES = ["Maintenance", "Testing", "Launch", "Announcement"];
@@ -3268,7 +3326,8 @@ function mapRow2(rec) {
3268
3326
  approvedBy: f["Approved By"] ?? null,
3269
3327
  deliveryStatus: f["Delivery status"] ?? "pending",
3270
3328
  renderedHtmlAttachment: html,
3271
- resendMessageId: f["Resend message ID"] ?? null
3329
+ resendMessageId: f["Resend message ID"] ?? null,
3330
+ checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])]))
3272
3331
  };
3273
3332
  }
3274
3333
  function lighthouseFromFields(f) {
@@ -3793,6 +3852,13 @@ async function sendApprovedReports(options = {}) {
3793
3852
  return { output: lines.join("\n"), code: anyFailed ? 1 : 0 };
3794
3853
  }
3795
3854
  async function sendOne(client, base, site, report) {
3855
+ if (!isChecklistComplete(report)) {
3856
+ const items = checklistFor(report.reportType);
3857
+ const done = items.filter((i) => report.checklist[i.field] === true).length;
3858
+ throw new Error(
3859
+ `Report ${report.reportId} checklist incomplete \u2014 ${done}/${items.length} items checked`
3860
+ );
3861
+ }
3796
3862
  if (!site.headerImage) {
3797
3863
  throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
3798
3864
  }
@@ -4009,7 +4075,33 @@ function relativeTimeFromNow(iso, now = /* @__PURE__ */ new Date()) {
4009
4075
  var REDDOOR_FAVICON_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAACXBIWXMAAAAnAAAAJwEqCZFPAAAAk1BMVEVHcEzkGTfjGDbjGTfjGTfjGTfjGTfjGDbjGDbjGTfjGTfjGTfkGTfjGTfjGDbjGDbkGTfjGDbjGDbjGTfjGTfjGDbjGTfjGDbjGTfjGDbjGTfjGTfjGDbjGDbjGDbjGDbjGTfjGTbjGDbjGDbjGDbjGDbjGTfjGTfjGTfjGDbjGDbjGTfjGDbjGDbjGTfjGTbkGTfxxbwzAAAALXRSTlMA4DABMOD8cOBwDltwi+bFLMlk54/BDVPk/I61/u2cFaBw9nqO1izBIQE+Becdo6eEAAABBElEQVQ4y91SCVYCMQwt4kxbcEHFfUFFcMGf9P6nM0kXEPEAmtdJs+cnU+f+He2fIIEAYuVgkmvDfTG9Rh+6LoRBUBqE7gi09l9eAeeTrZJoFd5uQWfj4VbPvRqwmj9zfzP6CYpyi48F6HW5A3WuMHsEPsfvO8cSkMPTe+pfRr/MTckdg+4enqL3PkYf/YFIqiiP8VAw6EZkH6QEO0xJLUhmdbY5+TjHkCVohprlcpzlnJw9GlNkrZC16uCmWIaAZItNrT4xV0T2yxrIWoNKy4rdVXOq0MycWp4r43FOMQWciibMYaOHCXKSPhapqTupGFAnwHoVuUUZq7jaSkqDb0/uD9MXqvJMDtU7lL0AAAAASUVORK5CYII=";
4010
4076
  var FAVICON_LINK = `<link rel="icon" type="image/png" href="data:image/png;base64,${REDDOOR_FAVICON_PNG_BASE64}" />`;
4011
4077
 
4078
+ // src/dashboard/onboarding.ts
4079
+ function isNonEmpty(s) {
4080
+ return typeof s === "string" && s.trim().length > 0;
4081
+ }
4082
+ function onboardingStatus(row) {
4083
+ const checks = {
4084
+ firstAudit: isNonEmpty(row.lastLighthouseAuditAt),
4085
+ recipients: isNonEmpty(row.reportRecipientsTo),
4086
+ schedule: row.maintenanceFreq !== "None",
4087
+ poc: isNonEmpty(row.pointOfContact)
4088
+ };
4089
+ const score = Object.values(checks).filter(Boolean).length;
4090
+ return { score, total: 4, checks };
4091
+ }
4092
+ var ONBOARDING_LABELS = {
4093
+ firstAudit: "First audit",
4094
+ recipients: "Report recipients",
4095
+ schedule: "Maintenance schedule",
4096
+ poc: "Point of contact"
4097
+ };
4098
+ function missingOnboarding(row) {
4099
+ const { checks } = onboardingStatus(row);
4100
+ return Object.keys(ONBOARDING_LABELS).filter((key) => !checks[key]).map((key) => ONBOARDING_LABELS[key]);
4101
+ }
4102
+
4012
4103
  // src/dashboard/render.ts
4104
+ var DASH = "\u2014";
4013
4105
  function scoreTile(label, value) {
4014
4106
  const display = value === null ? "\u2014" : String(value);
4015
4107
  return `<div class="tile"><div class="tile-value">${escapeHtml(display)}</div><div class="tile-label">${escapeHtml(label)}</div></div>`;
@@ -4042,10 +4134,25 @@ function securitySub(site) {
4042
4134
  const l = site.securityVulnsLow ?? 0;
4043
4135
  return `${c}C / ${h}H / ${m}M / ${l}L`;
4044
4136
  }
4137
+ function checklistBlock(r) {
4138
+ const items = checklistFor(r.reportType);
4139
+ if (items.length === 0) return "";
4140
+ const rid = escapeHtml(r.id);
4141
+ const url = `/api/reports/${encodeURIComponent(r.id)}/checklist`;
4142
+ const boxes = items.map((item) => {
4143
+ const checked = r.checklist[item.field] === true ? " checked" : "";
4144
+ 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>`;
4145
+ }).join("");
4146
+ return `<div class="checklist" data-checklist-for="${rid}">${boxes}</div>`;
4147
+ }
4148
+ function approveButton(r) {
4149
+ const disabled = isChecklistComplete(r) ? "" : " disabled";
4150
+ return `<button class="approve" data-report-id="${escapeHtml(r.id)}" data-approve-url="/api/reports/${encodeURIComponent(r.id)}/approve"${disabled}>Approve</button>`;
4151
+ }
4045
4152
  function pendingRow(r) {
4046
4153
  const type = escapeHtml(r.reportType);
4047
4154
  const period = r.period ? escapeHtml(r.period) : "\u2014";
4048
- 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>`;
4155
+ return `<li><div class="pending-head"><strong>${type}</strong> <span class="muted">${period}</span> ${approveButton(r)}</div>${checklistBlock(r)}</li>`;
4049
4156
  }
4050
4157
  function pendingSection(reports) {
4051
4158
  const pending = reports.filter(isPendingApproval);
@@ -4055,13 +4162,29 @@ function pendingSection(reports) {
4055
4162
  <ul class="pending-list">${pending.map(pendingRow).join("")}</ul>
4056
4163
  </div>`;
4057
4164
  }
4165
+ function gaUsersCell(r) {
4166
+ if (r.gaUsersCurrent === null) return DASH;
4167
+ const current = String(r.gaUsersCurrent);
4168
+ if (r.gaUsersPrevious === null) return escapeHtml(current);
4169
+ const delta = r.gaUsersCurrent - r.gaUsersPrevious;
4170
+ const sign = delta > 0 ? "+" : "";
4171
+ return `${escapeHtml(current)} <span class="muted">(${escapeHtml(`${sign}${delta}`)})</span>`;
4172
+ }
4173
+ function searchCell(r) {
4174
+ if (r.searchFoundPage1 && r.searchPosition !== null) {
4175
+ return escapeHtml(`#${r.searchPosition}`);
4176
+ }
4177
+ return DASH;
4178
+ }
4058
4179
  function reportRow(r) {
4059
- const date = r.completedOn ? escapeHtml(r.completedOn) : "\u2014";
4180
+ const date = r.completedOn ? escapeHtml(r.completedOn) : DASH;
4060
4181
  const type = escapeHtml(r.reportType);
4061
4182
  const id = escapeHtml(r.reportId);
4183
+ const ga = gaUsersCell(r);
4184
+ const search = searchCell(r);
4062
4185
  const link = r.renderedHtmlAttachment ? `<a href="${escapeHtml(safeUrl(r.renderedHtmlAttachment.url))}">view</a>` : `<span class="muted">no attachment</span>`;
4063
- const action = isPendingApproval(r) ? `<button class="approve" data-report-id="${escapeHtml(r.id)}" data-approve-url="/api/reports/${encodeURIComponent(r.id)}/approve">Approve</button>` : "";
4064
- return `<tr><td>${date}</td><td>${type}</td><td><code>${id}</code></td><td>${link}</td><td>${action}</td></tr>`;
4186
+ const action = isPendingApproval(r) ? approveButton(r) : "";
4187
+ 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>`;
4065
4188
  }
4066
4189
  function submissionRow(s) {
4067
4190
  const when = s.submittedAt ? escapeHtml(relativeTimeFromNow(s.submittedAt)) : "\u2014";
@@ -4087,6 +4210,34 @@ function submissionsSection(submissions) {
4087
4210
  <ul class="subm-list">${recent.map(submissionRow).join("")}</ul>
4088
4211
  </div>`;
4089
4212
  }
4213
+ function setupSection(site) {
4214
+ const { score, total } = onboardingStatus(site);
4215
+ const missing2 = missingOnboarding(site);
4216
+ const detail = missing2.length === 0 ? `<span class="setup-ok">complete</span>` : `<span class="setup-missing">Missing: ${escapeHtml(missing2.join(", "))}</span>`;
4217
+ return `<div class="setup-line">Setup ${score}/${total} \u2014 ${detail}</div>`;
4218
+ }
4219
+ function detailRow(label, value) {
4220
+ const display = typeof value === "string" && value.trim().length > 0 ? escapeHtml(value.trim()) : DASH;
4221
+ return `<div class="detail"><dt>${escapeHtml(label)}</dt><dd>${display}</dd></div>`;
4222
+ }
4223
+ function siteDetailsSection(site) {
4224
+ const lastCommit = site.lastCommitAt ? `${relativeTimeFromNow(site.lastCommitAt)}` : null;
4225
+ const rows = [
4226
+ detailRow("Maintenance cadence", site.maintenanceFreq),
4227
+ detailRow("Testing cadence", site.testingFreq),
4228
+ detailRow("Report recipients (To)", site.reportRecipientsTo),
4229
+ detailRow("Report recipients (CC)", site.reportRecipientsCc),
4230
+ detailRow("Point of contact", site.pointOfContact),
4231
+ detailRow("GA4 property", site.ga4PropertyId),
4232
+ detailRow("Search query", site.searchQuery),
4233
+ detailRow("Git repo", site.gitRepo),
4234
+ detailRow("Last commit", lastCommit)
4235
+ ].join("");
4236
+ return `<div class="section site-details">
4237
+ <h2>Site details</h2>
4238
+ <dl class="details">${rows}</dl>
4239
+ </div>`;
4240
+ }
4090
4241
  var STYLES = `
4091
4242
  :root { color-scheme: light dark; }
4092
4243
  body { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
@@ -4111,7 +4262,12 @@ th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }
4111
4262
  button.approve { font: inherit; padding: 0.35rem 0.85rem; border: 1px solid #2c7; border-radius: 6px; background: #2c7; color: #fff; cursor: pointer; }
4112
4263
  button.approve:disabled { opacity: 0.6; cursor: default; }
4113
4264
  .pending-list { list-style: none; padding: 0; margin: 0; }
4114
- .pending-list li { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-bottom: 1px solid #eee; }
4265
+ .pending-list li { padding: 0.5rem; border-bottom: 1px solid #eee; }
4266
+ @media (prefers-color-scheme: dark) { .pending-list li { border-color: #2a2a2a; } }
4267
+ .pending-head { display: flex; align-items: center; gap: 0.5rem; }
4268
+ .checklist { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0.5rem 0 0.25rem 0.25rem; }
4269
+ .check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; }
4270
+ .check-item input { margin: 0; }
4115
4271
  .pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }
4116
4272
  .subm-list { list-style: none; padding: 0; margin: 0; }
4117
4273
  .subm-item { padding: 0.6rem 0; border-bottom: 1px solid #eee; }
@@ -4125,6 +4281,14 @@ button.subm-status:disabled { opacity: 0.6; cursor: default; }
4125
4281
  .pill.subm-read { background: #f0f0f0; color: #555; }
4126
4282
  .pill.subm-archived { background: #eee; color: #888; }
4127
4283
  .pill.subm-spam { background: #fdecea; color: #b00; }
4284
+ .home { display: inline-block; font-size: 0.9rem; margin-bottom: 0.75rem; text-decoration: none; }
4285
+ .setup-line { font-size: 0.9rem; color: #666; margin-bottom: 1rem; }
4286
+ .setup-ok { color: #1b7a2f; font-weight: 600; }
4287
+ .setup-missing { color: #a65a00; }
4288
+ .details { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.5rem 1.5rem; margin: 0; }
4289
+ .detail { display: flex; flex-direction: column; }
4290
+ .detail dt { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: #999; }
4291
+ .detail dd { margin: 0; }
4128
4292
  `;
4129
4293
  function renderSiteDashboardHtml(site, reports, submissions = []) {
4130
4294
  const name = escapeHtml(site.name);
@@ -4146,7 +4310,7 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
4146
4310
  const auditedLine = site.lastLighthouseAuditAt ? `<div class="audited">Last audited ${escapeHtml(relativeTimeFromNow(site.lastLighthouseAuditAt))}</div>` : "";
4147
4311
  const recentReports = [...reports].sort((a, b) => (b.completedOn ?? "").localeCompare(a.completedOn ?? "")).slice(0, 6);
4148
4312
  const reportsSection = recentReports.length === 0 ? `<div class="empty">No reports yet.</div>` : `<table>
4149
- <thead><tr><th>Completed</th><th>Type</th><th>ID</th><th>Report</th><th></th></tr></thead>
4313
+ <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>
4150
4314
  <tbody>${recentReports.map(reportRow).join("")}</tbody>
4151
4315
  </table>`;
4152
4316
  return `<!doctype html>
@@ -4159,9 +4323,11 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
4159
4323
  <style>${STYLES}</style>
4160
4324
  </head>
4161
4325
  <body>
4326
+ <a class="home" href="/">\u2190 Fleet home</a>
4162
4327
  <h1>${name}</h1>
4163
4328
  <div class="meta"><a href="${escapeHtml(urlSafe)}">${escapeHtml(site.url)}</a></div>
4164
4329
  ${auditedLine}
4330
+ ${setupSection(site)}
4165
4331
  ${pendingSection(reports)}
4166
4332
  ${submissionsSection(submissions)}
4167
4333
 
@@ -4179,6 +4345,8 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
4179
4345
  <h2>Reports</h2>
4180
4346
  ${reportsSection}
4181
4347
  </div>
4348
+
4349
+ ${siteDetailsSection(site)}
4182
4350
  <script>
4183
4351
  document.querySelectorAll("button.approve").forEach((b) => {
4184
4352
  b.addEventListener("click", async () => {
@@ -4212,39 +4380,52 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
4212
4380
  }
4213
4381
  });
4214
4382
  });
4383
+ // Checklist gate: ticking a box POSTs the one field; the response { complete }
4384
+ // decides whether THIS report's Approve button is enabled. Scoped per report by
4385
+ // matching the checkbox's report id to the Approve button's id, so multiple
4386
+ // pending reports on one page never cross-toggle. On failure the checkbox reverts.
4387
+ document.querySelectorAll("input.checklist-checkbox").forEach((cb) => {
4388
+ cb.addEventListener("change", async () => {
4389
+ const reportId = cb.dataset.checklistReportId;
4390
+ const approveBtn = document.querySelector(
4391
+ 'button.approve[data-report-id="' + (window.CSS && CSS.escape ? CSS.escape(reportId) : reportId) + '"]',
4392
+ );
4393
+ cb.disabled = true;
4394
+ try {
4395
+ const res = await fetch(cb.dataset.checklistUrl, {
4396
+ method: "POST",
4397
+ headers: { "content-type": "application/json" },
4398
+ body: JSON.stringify({ reportId, field: cb.dataset.field, value: cb.checked }),
4399
+ });
4400
+ if (!res.ok) throw new Error("bad status");
4401
+ const data = await res.json();
4402
+ if (approveBtn) approveBtn.disabled = !data.complete;
4403
+ } catch {
4404
+ // Revert the optimistic flip so the box reflects the (unchanged) server state.
4405
+ cb.checked = !cb.checked;
4406
+ } finally {
4407
+ cb.disabled = false;
4408
+ }
4409
+ });
4410
+ });
4215
4411
  </script>
4216
4412
  </body>
4217
4413
  </html>`;
4218
4414
  }
4219
4415
 
4220
- // src/dashboard/onboarding.ts
4221
- function isNonEmpty(s) {
4222
- return typeof s === "string" && s.trim().length > 0;
4223
- }
4224
- function onboardingStatus(row) {
4225
- const checks = {
4226
- firstAudit: isNonEmpty(row.lastLighthouseAuditAt),
4227
- recipients: isNonEmpty(row.reportRecipientsTo),
4228
- schedule: row.maintenanceFreq !== "None",
4229
- poc: isNonEmpty(row.pointOfContact)
4230
- };
4231
- const score = Object.values(checks).filter(Boolean).length;
4232
- return { score, total: 4, checks };
4233
- }
4234
-
4235
4416
  // src/dashboard/fleet-render.ts
4236
- var DASH = "\u2014";
4417
+ var DASH2 = "\u2014";
4237
4418
  function scoreSpan(category, value) {
4238
- const display = value === null ? DASH : String(value);
4419
+ const display = value === null ? DASH2 : String(value);
4239
4420
  return `<span class="score ${category}">${escapeHtml(display)}</span>`;
4240
4421
  }
4241
4422
  function a11ySpan(value) {
4242
- const display = value === null ? DASH : String(value);
4423
+ const display = value === null ? DASH2 : String(value);
4243
4424
  return `<span class="metric a11y">${escapeHtml(display)}</span>`;
4244
4425
  }
4245
4426
  function depsSpan(drifted, majorBehind, outdated) {
4246
4427
  if (drifted === null || majorBehind === null) {
4247
- return `<span class="metric deps">${DASH}</span>`;
4428
+ return `<span class="metric deps">${DASH2}</span>`;
4248
4429
  }
4249
4430
  const driftPart = drifted === 0 ? "0" : `${drifted} drifted (${majorBehind} major)`;
4250
4431
  const display = outdated === null ? driftPart : `${driftPart} \xB7 ${outdated} outdated`;
@@ -4252,7 +4433,7 @@ function depsSpan(drifted, majorBehind, outdated) {
4252
4433
  }
4253
4434
  function securitySpan(critical, high, moderate, low) {
4254
4435
  if (critical === null || high === null || moderate === null || low === null) {
4255
- return `<span class="metric sec">${DASH}</span>`;
4436
+ return `<span class="metric sec">${DASH2}</span>`;
4256
4437
  }
4257
4438
  const total = critical + high + moderate + low;
4258
4439
  const display = total === 0 ? "0" : `${critical}C/${high}H/${moderate}M/${low}L`;
@@ -4262,6 +4443,10 @@ function card(site) {
4262
4443
  const name = escapeHtml(site.name);
4263
4444
  const href = `/s/${escapeHtml(siteSlug(site.name))}`;
4264
4445
  const onboarding = onboardingStatus(site);
4446
+ const missing2 = missingOnboarding(site);
4447
+ const setupTitle = escapeHtml(
4448
+ missing2.length === 0 ? "Setup complete" : `Missing: ${missing2.join(", ")}`
4449
+ );
4265
4450
  const audited = relativeTimeFromNow(site.lastLighthouseAuditAt);
4266
4451
  const safeSiteUrl = escapeHtml(safeUrl(site.url));
4267
4452
  const visibleUrl = escapeHtml(site.url);
@@ -4269,15 +4454,15 @@ function card(site) {
4269
4454
  <header class="card-head">
4270
4455
  <a class="site" href="${href}">${name}</a>
4271
4456
  <a class="url" href="${safeSiteUrl}" target="_blank" rel="noopener">${visibleUrl}</a>
4272
- <span class="setup">Setup: <strong>${onboarding.score}/${onboarding.total}</strong></span>
4457
+ <span class="setup" title="${setupTitle}">Setup: <strong>${onboarding.score}/${onboarding.total}</strong></span>
4273
4458
  <span class="audited">Audited: <strong>${escapeHtml(audited)}</strong></span>
4274
4459
  </header>
4275
4460
  <div class="card-metrics">
4276
4461
  <span class="cluster lighthouse">
4277
- ${scoreSpan("perf", site.pScore)}
4278
- ${scoreSpan("a11y-lh", site.rScore)}
4279
- ${scoreSpan("bp", site.bpScore)}
4280
- ${scoreSpan("seo", site.seoScore)}
4462
+ <span class="metric-label">Perf</span> ${scoreSpan("perf", site.pScore)}
4463
+ <span class="metric-label">Access</span> ${scoreSpan("a11y-lh", site.rScore)}
4464
+ <span class="metric-label">BP</span> ${scoreSpan("bp", site.bpScore)}
4465
+ <span class="metric-label">SEO</span> ${scoreSpan("seo", site.seoScore)}
4281
4466
  </span>
4282
4467
  <span class="cluster health">
4283
4468
  <span class="metric-label">a11y</span> ${a11ySpan(site.a11yViolations)}