@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/cli/bin.js +94 -16
- package/dist/cli/bin.js.map +1 -1
- package/dist/index.d.ts +17 -0
- package/dist/index.js +230 -45
- 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
|
@@ -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
|
-
"
|
|
2757
|
+
"Deploy & Function Health",
|
|
2758
2758
|
"CMS Checked",
|
|
2759
|
-
"DNS
|
|
2759
|
+
"Domain, DNS & SSL",
|
|
2760
2760
|
"Google Indexed",
|
|
2761
|
-
"
|
|
2762
|
-
"
|
|
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
|
-
"
|
|
2769
|
-
"
|
|
2768
|
+
"Page Titles & Meta",
|
|
2769
|
+
"Links & Navigation",
|
|
2770
2770
|
"Form Functionality",
|
|
2771
|
-
"
|
|
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
|
|
2786
|
-
announceBody: "We've
|
|
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: "
|
|
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: "
|
|
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.
|
|
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>
|
|
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) :
|
|
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) ?
|
|
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 {
|
|
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
|
|
4417
|
+
var DASH2 = "\u2014";
|
|
4237
4418
|
function scoreSpan(category, value) {
|
|
4238
|
-
const display = value === null ?
|
|
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 ?
|
|
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">${
|
|
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">${
|
|
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)}
|