@reddoorla/maintenance 0.42.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.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
@@ -2782,13 +2782,16 @@ var DEFAULT_COPY = {
2782
2782
  "Continuous integration + automatic dependency updates",
2783
2783
  "Analytics and uptime monitoring"
2784
2784
  ],
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.",
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",
2787
2790
  announceMonitorItems: ["Performance", "Accessibility", "Security", "Uptime"],
2788
- announcePreviewLabel: "A snapshot of your latest scores:",
2791
+ announcePreviewLabel: "From your latest full site test:",
2789
2792
  announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
2790
2793
  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.",
2794
+ announceCadence: "After each one we'll send you a short report like this \u2014 there's nothing you need to do.",
2792
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."
2793
2796
  };
2794
2797
  function override(v) {
@@ -3125,6 +3128,11 @@ function buildLaunchMjml(data) {
3125
3128
  }
3126
3129
 
3127
3130
  // src/reports/announcement-email/template.ts
3131
+ var FREQ_PHRASE = {
3132
+ Monthly: "every month",
3133
+ Quarterly: "every quarter",
3134
+ Yearly: "every year"
3135
+ };
3128
3136
  var RED2 = "#C00";
3129
3137
  var GREY2 = "#757575";
3130
3138
  var SCORE_PREVIEW = [
@@ -3149,6 +3157,23 @@ function buildAnnouncementMjml(data) {
3149
3157
  ).join("")}
3150
3158
  </mj-column>
3151
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>` : "";
3152
3177
  const monitorRows = copy.announceMonitorItems.map(
3153
3178
  (item) => `
3154
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>`
@@ -3187,6 +3212,7 @@ function buildAnnouncementMjml(data) {
3187
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>
3188
3213
  </mj-column>
3189
3214
  </mj-section>
3215
+ ${cadenceSection}
3190
3216
  ${improvementsSection}
3191
3217
  <mj-section background-color="white">
3192
3218
  <mj-column>
@@ -3202,8 +3228,7 @@ function buildAnnouncementMjml(data) {
3202
3228
  </mj-section>
3203
3229
  <mj-section background-color="white">
3204
3230
  <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>
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>
3207
3232
  </mj-column>
3208
3233
  </mj-section>
3209
3234
  <mj-section background-color="white">
@@ -3228,6 +3253,33 @@ async function renderReportHtml(data) {
3228
3253
  return { html: out.html, warnings: out.errors ?? [] };
3229
3254
  }
3230
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
+
3231
3283
  // src/reports/airtable/reports.ts
3232
3284
  var REPORTS_TABLE = "Reports";
3233
3285
  var REPORT_TYPES = ["Maintenance", "Testing", "Launch", "Announcement"];
@@ -3268,7 +3320,8 @@ function mapRow2(rec) {
3268
3320
  approvedBy: f["Approved By"] ?? null,
3269
3321
  deliveryStatus: f["Delivery status"] ?? "pending",
3270
3322
  renderedHtmlAttachment: html,
3271
- 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])]))
3272
3325
  };
3273
3326
  }
3274
3327
  function lighthouseFromFields(f) {
@@ -3793,6 +3846,13 @@ async function sendApprovedReports(options = {}) {
3793
3846
  return { output: lines.join("\n"), code: anyFailed ? 1 : 0 };
3794
3847
  }
3795
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
+ }
3796
3856
  if (!site.headerImage) {
3797
3857
  throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
3798
3858
  }
@@ -4009,7 +4069,33 @@ function relativeTimeFromNow(iso, now = /* @__PURE__ */ new Date()) {
4009
4069
  var REDDOOR_FAVICON_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAACXBIWXMAAAAnAAAAJwEqCZFPAAAAk1BMVEVHcEzkGTfjGDbjGTfjGTfjGTfjGTfjGDbjGDbjGTfjGTfjGTfkGTfjGTfjGDbjGDbkGTfjGDbjGDbjGTfjGTfjGDbjGTfjGDbjGTfjGDbjGTfjGTfjGDbjGDbjGDbjGDbjGTfjGTbjGDbjGDbjGDbjGDbjGTfjGTfjGTfjGDbjGDbjGTfjGDbjGDbjGTfjGTbkGTfxxbwzAAAALXRSTlMA4DABMOD8cOBwDltwi+bFLMlk54/BDVPk/I61/u2cFaBw9nqO1izBIQE+Becdo6eEAAABBElEQVQ4y91SCVYCMQwt4kxbcEHFfUFFcMGf9P6nM0kXEPEAmtdJs+cnU+f+He2fIIEAYuVgkmvDfTG9Rh+6LoRBUBqE7gi09l9eAeeTrZJoFd5uQWfj4VbPvRqwmj9zfzP6CYpyi48F6HW5A3WuMHsEPsfvO8cSkMPTe+pfRr/MTckdg+4enqL3PkYf/YFIqiiP8VAw6EZkH6QEO0xJLUhmdbY5+TjHkCVohprlcpzlnJw9GlNkrZC16uCmWIaAZItNrT4xV0T2yxrIWoNKy4rdVXOq0MycWp4r43FOMQWciibMYaOHCXKSPhapqTupGFAnwHoVuUUZq7jaSkqDb0/uD9MXqvJMDtU7lL0AAAAASUVORK5CYII=";
4010
4070
  var FAVICON_LINK = `<link rel="icon" type="image/png" href="data:image/png;base64,${REDDOOR_FAVICON_PNG_BASE64}" />`;
4011
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
+
4012
4097
  // src/dashboard/render.ts
4098
+ var DASH = "\u2014";
4013
4099
  function scoreTile(label, value) {
4014
4100
  const display = value === null ? "\u2014" : String(value);
4015
4101
  return `<div class="tile"><div class="tile-value">${escapeHtml(display)}</div><div class="tile-label">${escapeHtml(label)}</div></div>`;
@@ -4042,10 +4128,25 @@ function securitySub(site) {
4042
4128
  const l = site.securityVulnsLow ?? 0;
4043
4129
  return `${c}C / ${h}H / ${m}M / ${l}L`;
4044
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
+ }
4045
4146
  function pendingRow(r) {
4046
4147
  const type = escapeHtml(r.reportType);
4047
4148
  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>`;
4149
+ return `<li><div class="pending-head"><strong>${type}</strong> <span class="muted">${period}</span> ${approveButton(r)}</div>${checklistBlock(r)}</li>`;
4049
4150
  }
4050
4151
  function pendingSection(reports) {
4051
4152
  const pending = reports.filter(isPendingApproval);
@@ -4055,13 +4156,29 @@ function pendingSection(reports) {
4055
4156
  <ul class="pending-list">${pending.map(pendingRow).join("")}</ul>
4056
4157
  </div>`;
4057
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
+ }
4058
4173
  function reportRow(r) {
4059
- const date = r.completedOn ? escapeHtml(r.completedOn) : "\u2014";
4174
+ const date = r.completedOn ? escapeHtml(r.completedOn) : DASH;
4060
4175
  const type = escapeHtml(r.reportType);
4061
4176
  const id = escapeHtml(r.reportId);
4177
+ const ga = gaUsersCell(r);
4178
+ const search = searchCell(r);
4062
4179
  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>`;
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>`;
4065
4182
  }
4066
4183
  function submissionRow(s) {
4067
4184
  const when = s.submittedAt ? escapeHtml(relativeTimeFromNow(s.submittedAt)) : "\u2014";
@@ -4087,6 +4204,34 @@ function submissionsSection(submissions) {
4087
4204
  <ul class="subm-list">${recent.map(submissionRow).join("")}</ul>
4088
4205
  </div>`;
4089
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
+ }
4090
4235
  var STYLES = `
4091
4236
  :root { color-scheme: light dark; }
4092
4237
  body { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
@@ -4111,7 +4256,12 @@ th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }
4111
4256
  button.approve { font: inherit; padding: 0.35rem 0.85rem; border: 1px solid #2c7; border-radius: 6px; background: #2c7; color: #fff; cursor: pointer; }
4112
4257
  button.approve:disabled { opacity: 0.6; cursor: default; }
4113
4258
  .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; }
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; }
4115
4265
  .pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }
4116
4266
  .subm-list { list-style: none; padding: 0; margin: 0; }
4117
4267
  .subm-item { padding: 0.6rem 0; border-bottom: 1px solid #eee; }
@@ -4125,6 +4275,14 @@ button.subm-status:disabled { opacity: 0.6; cursor: default; }
4125
4275
  .pill.subm-read { background: #f0f0f0; color: #555; }
4126
4276
  .pill.subm-archived { background: #eee; color: #888; }
4127
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; }
4128
4286
  `;
4129
4287
  function renderSiteDashboardHtml(site, reports, submissions = []) {
4130
4288
  const name = escapeHtml(site.name);
@@ -4146,7 +4304,7 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
4146
4304
  const auditedLine = site.lastLighthouseAuditAt ? `<div class="audited">Last audited ${escapeHtml(relativeTimeFromNow(site.lastLighthouseAuditAt))}</div>` : "";
4147
4305
  const recentReports = [...reports].sort((a, b) => (b.completedOn ?? "").localeCompare(a.completedOn ?? "")).slice(0, 6);
4148
4306
  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>
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>
4150
4308
  <tbody>${recentReports.map(reportRow).join("")}</tbody>
4151
4309
  </table>`;
4152
4310
  return `<!doctype html>
@@ -4159,9 +4317,11 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
4159
4317
  <style>${STYLES}</style>
4160
4318
  </head>
4161
4319
  <body>
4320
+ <a class="home" href="/">\u2190 Fleet home</a>
4162
4321
  <h1>${name}</h1>
4163
4322
  <div class="meta"><a href="${escapeHtml(urlSafe)}">${escapeHtml(site.url)}</a></div>
4164
4323
  ${auditedLine}
4324
+ ${setupSection(site)}
4165
4325
  ${pendingSection(reports)}
4166
4326
  ${submissionsSection(submissions)}
4167
4327
 
@@ -4179,6 +4339,8 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
4179
4339
  <h2>Reports</h2>
4180
4340
  ${reportsSection}
4181
4341
  </div>
4342
+
4343
+ ${siteDetailsSection(site)}
4182
4344
  <script>
4183
4345
  document.querySelectorAll("button.approve").forEach((b) => {
4184
4346
  b.addEventListener("click", async () => {
@@ -4212,39 +4374,52 @@ function renderSiteDashboardHtml(site, reports, submissions = []) {
4212
4374
  }
4213
4375
  });
4214
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
+ });
4215
4405
  </script>
4216
4406
  </body>
4217
4407
  </html>`;
4218
4408
  }
4219
4409
 
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
4410
  // src/dashboard/fleet-render.ts
4236
- var DASH = "\u2014";
4411
+ var DASH2 = "\u2014";
4237
4412
  function scoreSpan(category, value) {
4238
- const display = value === null ? DASH : String(value);
4413
+ const display = value === null ? DASH2 : String(value);
4239
4414
  return `<span class="score ${category}">${escapeHtml(display)}</span>`;
4240
4415
  }
4241
4416
  function a11ySpan(value) {
4242
- const display = value === null ? DASH : String(value);
4417
+ const display = value === null ? DASH2 : String(value);
4243
4418
  return `<span class="metric a11y">${escapeHtml(display)}</span>`;
4244
4419
  }
4245
4420
  function depsSpan(drifted, majorBehind, outdated) {
4246
4421
  if (drifted === null || majorBehind === null) {
4247
- return `<span class="metric deps">${DASH}</span>`;
4422
+ return `<span class="metric deps">${DASH2}</span>`;
4248
4423
  }
4249
4424
  const driftPart = drifted === 0 ? "0" : `${drifted} drifted (${majorBehind} major)`;
4250
4425
  const display = outdated === null ? driftPart : `${driftPart} \xB7 ${outdated} outdated`;
@@ -4252,7 +4427,7 @@ function depsSpan(drifted, majorBehind, outdated) {
4252
4427
  }
4253
4428
  function securitySpan(critical, high, moderate, low) {
4254
4429
  if (critical === null || high === null || moderate === null || low === null) {
4255
- return `<span class="metric sec">${DASH}</span>`;
4430
+ return `<span class="metric sec">${DASH2}</span>`;
4256
4431
  }
4257
4432
  const total = critical + high + moderate + low;
4258
4433
  const display = total === 0 ? "0" : `${critical}C/${high}H/${moderate}M/${low}L`;
@@ -4262,6 +4437,10 @@ function card(site) {
4262
4437
  const name = escapeHtml(site.name);
4263
4438
  const href = `/s/${escapeHtml(siteSlug(site.name))}`;
4264
4439
  const onboarding = onboardingStatus(site);
4440
+ const missing2 = missingOnboarding(site);
4441
+ const setupTitle = escapeHtml(
4442
+ missing2.length === 0 ? "Setup complete" : `Missing: ${missing2.join(", ")}`
4443
+ );
4265
4444
  const audited = relativeTimeFromNow(site.lastLighthouseAuditAt);
4266
4445
  const safeSiteUrl = escapeHtml(safeUrl(site.url));
4267
4446
  const visibleUrl = escapeHtml(site.url);
@@ -4269,15 +4448,15 @@ function card(site) {
4269
4448
  <header class="card-head">
4270
4449
  <a class="site" href="${href}">${name}</a>
4271
4450
  <a class="url" href="${safeSiteUrl}" target="_blank" rel="noopener">${visibleUrl}</a>
4272
- <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>
4273
4452
  <span class="audited">Audited: <strong>${escapeHtml(audited)}</strong></span>
4274
4453
  </header>
4275
4454
  <div class="card-metrics">
4276
4455
  <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)}
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)}
4281
4460
  </span>
4282
4461
  <span class="cluster health">
4283
4462
  <span class="metric-label">a11y</span> ${a11ySpan(site.a11yViolations)}