@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/cli/bin.js +81 -9
- package/dist/cli/bin.js.map +1 -1
- package/dist/index.d.ts +17 -0
- package/dist/index.js +217 -38
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
2786
|
-
announceBody: "We've
|
|
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: "
|
|
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: "
|
|
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.
|
|
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>
|
|
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) :
|
|
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) ?
|
|
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 {
|
|
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
|
|
4411
|
+
var DASH2 = "\u2014";
|
|
4237
4412
|
function scoreSpan(category, value) {
|
|
4238
|
-
const display = value === null ?
|
|
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 ?
|
|
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">${
|
|
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">${
|
|
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)}
|