@reddoorla/maintenance 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2218,6 +2218,13 @@ function mapRow(rec) {
2218
2218
  bpScore: f["bpScore"] ?? null,
2219
2219
  seoScore: f["seoScore"] ?? null,
2220
2220
  lastLighthouseAuditAt: f["Last lighthouse audit at"] ?? null,
2221
+ a11yViolations: f["A11y Violations"] ?? null,
2222
+ depsDrifted: f["Deps Drifted"] ?? null,
2223
+ depsMajorBehind: f["Deps Major Behind"] ?? null,
2224
+ securityVulnsCritical: f["Security Vulns Critical"] ?? null,
2225
+ securityVulnsHigh: f["Security Vulns High"] ?? null,
2226
+ securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
2227
+ securityVulnsLow: f["Security Vulns Low"] ?? null,
2221
2228
  dashboardToken: (() => {
2222
2229
  const raw = f["Dashboard Token"];
2223
2230
  if (typeof raw !== "string") return null;
@@ -2627,11 +2634,11 @@ async function uploadAttachment(recordId, fieldName, body, filename, contentType
2627
2634
  }
2628
2635
 
2629
2636
  // src/reports/draft.ts
2630
- function scoresFromWebsite(siteRow2) {
2631
- const { pScore, rScore, bpScore, seoScore } = siteRow2;
2637
+ function scoresFromWebsite(siteRow) {
2638
+ const { pScore, rScore, bpScore, seoScore } = siteRow;
2632
2639
  if (pScore === null || rScore === null || bpScore === null || seoScore === null) {
2633
2640
  throw new Error(
2634
- `Site '${siteRow2.name}' is missing one or more Lighthouse scores on the Websites row (pScore, rScore, bpScore, seoScore). Run 'reddoor-maint audit lighthouse' from the site's checkout and paste the four numbers into Airtable, then retry.`
2641
+ `Site '${siteRow.name}' is missing one or more Lighthouse scores on the Websites row (pScore, rScore, bpScore, seoScore). Run 'reddoor-maint audit lighthouse' from the site's checkout and paste the four numbers into Airtable, then retry.`
2635
2642
  );
2636
2643
  }
2637
2644
  return { performance: pScore, accessibility: rScore, bestPractices: bpScore, seo: seoScore };
@@ -2641,18 +2648,18 @@ function daysAgo(today, n) {
2641
2648
  out.setDate(out.getDate() - n);
2642
2649
  return out;
2643
2650
  }
2644
- async function draftReportForSite(base, siteRow2, reportType, options = {}) {
2645
- const scores = scoresFromWebsite(siteRow2);
2651
+ async function draftReportForSite(base, siteRow, reportType, options = {}) {
2652
+ const scores = scoresFromWebsite(siteRow);
2646
2653
  const today = /* @__PURE__ */ new Date();
2647
- const slug = siteSlug(siteRow2.name);
2648
- const periodStart = base !== null ? await derivePeriodStart(base, siteRow2, reportType, today) : daysAgo(today, 30);
2654
+ const slug = siteSlug(siteRow.name);
2655
+ const periodStart = base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);
2649
2656
  const periodEnd = today;
2650
2657
  const completedOn = today;
2651
- const lastTestedDate = reportType === "Maintenance" && siteRow2.testingDay ? new Date(siteRow2.testingDay) : null;
2658
+ const lastTestedDate = reportType === "Maintenance" && siteRow.testingDay ? new Date(siteRow.testingDay) : null;
2652
2659
  const cidName = `${slug}-header`;
2653
2660
  const { html } = await renderReportHtml({
2654
- siteName: siteRow2.name,
2655
- siteUrl: siteRow2.url,
2661
+ siteName: siteRow.name,
2662
+ siteUrl: siteRow.url,
2656
2663
  reportType,
2657
2664
  completedOn,
2658
2665
  lighthouse: scores,
@@ -2669,10 +2676,10 @@ async function draftReportForSite(base, siteRow2, reportType, options = {}) {
2669
2676
  return { reportRow: null, htmlPath: path, html };
2670
2677
  }
2671
2678
  if (base === null) throw new Error("base required when previewOnly=false");
2672
- const reportId = `${siteRow2.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
2679
+ const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
2673
2680
  const created = await createDraft(base, {
2674
2681
  reportId,
2675
- siteId: siteRow2.id,
2682
+ siteId: siteRow.id,
2676
2683
  reportType,
2677
2684
  periodStart,
2678
2685
  periodEnd,
@@ -2685,8 +2692,8 @@ async function draftReportForSite(base, siteRow2, reportType, options = {}) {
2685
2692
  await setDraftReady(base, created.id, true);
2686
2693
  return { reportRow: created, htmlPath: null, html };
2687
2694
  }
2688
- async function derivePeriodStart(base, siteRow2, reportType, today) {
2689
- const prior = await listReportsForSite(base, siteRow2.id);
2695
+ async function derivePeriodStart(base, siteRow, reportType, today) {
2696
+ const prior = await listReportsForSite(base, siteRow.id);
2690
2697
  const sameType = prior.filter((r) => r.reportType === reportType && r.periodEnd).map((r) => r.periodEnd).sort();
2691
2698
  const latest = sameType[sameType.length - 1];
2692
2699
  return latest ? new Date(latest) : daysAgo(today, 30);
@@ -3019,6 +3026,40 @@ function verifyDashboardToken(provided, expected) {
3019
3026
  return timingSafeEqual(Buffer.from(provided, "utf-8"), Buffer.from(expected, "utf-8"));
3020
3027
  }
3021
3028
 
3029
+ // src/dashboard/onboarding.ts
3030
+ function isNonEmpty(s) {
3031
+ return typeof s === "string" && s.trim().length > 0;
3032
+ }
3033
+ function onboardingStatus(row) {
3034
+ const checks = {
3035
+ firstAudit: isNonEmpty(row.lastLighthouseAuditAt),
3036
+ recipients: isNonEmpty(row.reportRecipientsTo),
3037
+ schedule: row.maintenanceFreq !== "None",
3038
+ poc: isNonEmpty(row.pointOfContact)
3039
+ };
3040
+ const score = Object.values(checks).filter(Boolean).length;
3041
+ return { score, total: 4, checks };
3042
+ }
3043
+
3044
+ // src/dashboard/relative-time.ts
3045
+ function relativeTimeFromNow(iso, now = /* @__PURE__ */ new Date()) {
3046
+ if (!iso) return "\u2014";
3047
+ const t = Date.parse(iso);
3048
+ if (Number.isNaN(t)) return "\u2014";
3049
+ const seconds = Math.max(0, Math.floor((now.getTime() - t) / 1e3));
3050
+ if (seconds < 60) return "just now";
3051
+ const minutes = Math.floor(seconds / 60);
3052
+ if (minutes < 60) return `${minutes}m ago`;
3053
+ const hours = Math.floor(minutes / 60);
3054
+ if (hours < 24) return `${hours}h ago`;
3055
+ const days = Math.floor(hours / 24);
3056
+ if (days < 7) return `${days}d ago`;
3057
+ const weeks = Math.floor(days / 7);
3058
+ if (weeks < 4) return `${weeks}w ago`;
3059
+ const months = Math.floor(days / 30);
3060
+ return `${months}mo ago`;
3061
+ }
3062
+
3022
3063
  // src/dashboard/fleet-render.ts
3023
3064
  function escapeHtml2(s) {
3024
3065
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -3031,54 +3072,88 @@ function safeUrl2(raw) {
3031
3072
  }
3032
3073
  return "#";
3033
3074
  }
3034
- function scoreCell(value) {
3035
- const display = value === null ? "\u2014" : String(value);
3036
- return `<td class="score">${escapeHtml2(display)}</td>`;
3075
+ var DASH = "\u2014";
3076
+ function scoreSpan(category, value) {
3077
+ const display = value === null ? DASH : String(value);
3078
+ return `<span class="score ${category}">${escapeHtml2(display)}</span>`;
3079
+ }
3080
+ function a11ySpan(value) {
3081
+ const display = value === null ? DASH : String(value);
3082
+ return `<span class="metric a11y">${escapeHtml2(display)}</span>`;
3037
3083
  }
3038
- function siteHrefCell(site) {
3084
+ function depsSpan(drifted, majorBehind) {
3085
+ if (drifted === null || majorBehind === null) {
3086
+ return `<span class="metric deps">${DASH}</span>`;
3087
+ }
3088
+ const display = drifted === 0 ? "0" : `${drifted} drifted (${majorBehind} major)`;
3089
+ return `<span class="metric deps">${escapeHtml2(display)}</span>`;
3090
+ }
3091
+ function securitySpan(critical, high, moderate, low) {
3092
+ if (critical === null || high === null || moderate === null || low === null) {
3093
+ return `<span class="metric sec">${DASH}</span>`;
3094
+ }
3095
+ const total = critical + high + moderate + low;
3096
+ const display = total === 0 ? "0" : `${critical}C/${high}H/${moderate}M/${low}L`;
3097
+ return `<span class="metric sec">${escapeHtml2(display)}</span>`;
3098
+ }
3099
+ function card(site) {
3039
3100
  const name = escapeHtml2(site.name);
3040
3101
  const token = site.dashboardToken ?? "";
3041
3102
  const href = `/s/${escapeHtml2(siteSlug(site.name))}?t=${escapeHtml2(token)}`;
3042
- return `<td class="site"><a href="${href}">${name}</a></td>`;
3043
- }
3044
- function siteRow(site) {
3045
- return `<tr>
3046
- ${siteHrefCell(site)}
3047
- <td><a href="${escapeHtml2(safeUrl2(site.url))}" class="url" target="_blank" rel="noopener">${escapeHtml2(site.url)}</a></td>
3048
- ${scoreCell(site.pScore)}
3049
- ${scoreCell(site.rScore)}
3050
- ${scoreCell(site.bpScore)}
3051
- ${scoreCell(site.seoScore)}
3052
- </tr>`;
3103
+ const onboarding = onboardingStatus(site);
3104
+ const audited = relativeTimeFromNow(site.lastLighthouseAuditAt);
3105
+ const safeSiteUrl = escapeHtml2(safeUrl2(site.url));
3106
+ const visibleUrl = escapeHtml2(site.url);
3107
+ return `<article class="card">
3108
+ <header class="card-head">
3109
+ <a class="site" href="${href}">${name}</a>
3110
+ <a class="url" href="${safeSiteUrl}" target="_blank" rel="noopener">${visibleUrl}</a>
3111
+ <span class="setup">Setup: <strong>${onboarding.score}/${onboarding.total}</strong></span>
3112
+ <span class="audited">Audited: <strong>${escapeHtml2(audited)}</strong></span>
3113
+ </header>
3114
+ <div class="card-metrics">
3115
+ <span class="cluster lighthouse">
3116
+ ${scoreSpan("perf", site.pScore)}
3117
+ ${scoreSpan("a11y-lh", site.rScore)}
3118
+ ${scoreSpan("bp", site.bpScore)}
3119
+ ${scoreSpan("seo", site.seoScore)}
3120
+ </span>
3121
+ <span class="cluster health">
3122
+ <span class="metric-label">a11y</span> ${a11ySpan(site.a11yViolations)}
3123
+ <span class="metric-label">deps</span> ${depsSpan(site.depsDrifted, site.depsMajorBehind)}
3124
+ <span class="metric-label">sec</span> ${securitySpan(
3125
+ site.securityVulnsCritical,
3126
+ site.securityVulnsHigh,
3127
+ site.securityVulnsModerate,
3128
+ site.securityVulnsLow
3129
+ )}
3130
+ </span>
3131
+ </div>
3132
+ </article>`;
3053
3133
  }
3054
3134
  var STYLES2 = `
3055
3135
  :root { color-scheme: light dark; }
3056
3136
  body { font: 16px/1.5 system-ui, -apple-system, sans-serif; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
3057
3137
  @media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #111; } a { color: #6cb6ff; } }
3058
3138
  h1 { margin: 0 0 0.25rem; font-size: 1.75rem; }
3059
- .meta { color: #666; margin-bottom: 2rem; }
3060
- table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
3061
- th { text-align: left; padding: 0.5rem; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: #666; border-bottom: 2px solid #ddd; }
3062
- @media (prefers-color-scheme: dark) { th { border-color: #333; } }
3063
- td { padding: 0.65rem 0.5rem; border-bottom: 1px solid #eee; }
3064
- @media (prefers-color-scheme: dark) { td { border-color: #2a2a2a; } }
3065
- td.site a { font-weight: 500; }
3066
- td.url { color: #666; font-size: 0.85rem; }
3067
- td.score { text-align: right; font-variant-numeric: tabular-nums; min-width: 3.5rem; }
3139
+ .meta { color: #666; margin-bottom: 1.5rem; }
3068
3140
  .empty { color: #999; padding: 2rem; text-align: center; border: 1px dashed #ccc; border-radius: 6px; }
3141
+ .cards { display: flex; flex-direction: column; gap: 0.75rem; }
3142
+ .card { border: 1px solid #e5e5e5; border-radius: 8px; padding: 0.9rem 1.1rem; }
3143
+ @media (prefers-color-scheme: dark) { .card { border-color: #2a2a2a; background: #181818; } }
3144
+ .card-head { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; align-items: baseline; }
3145
+ .card-head .site { font-weight: 600; font-size: 1.05rem; }
3146
+ .card-head .url { color: #666; font-size: 0.85rem; }
3147
+ .card-head .setup, .card-head .audited { color: #666; font-size: 0.85rem; }
3148
+ .card-head .setup { margin-left: auto; }
3149
+ .card-metrics { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; margin-top: 0.5rem; font-variant-numeric: tabular-nums; }
3150
+ .cluster { display: inline-flex; gap: 0.5rem; align-items: baseline; }
3151
+ .cluster.lighthouse .score { display: inline-block; min-width: 2.25rem; text-align: right; }
3152
+ .metric-label { color: #999; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
3153
+ .metric { font-feature-settings: "tnum"; }
3069
3154
  `;
3070
3155
  function renderFleetHomeHtml(sites) {
3071
- const body = sites.length === 0 ? `<div class="empty">No sites to display.</div>` : `<table>
3072
- <thead><tr>
3073
- <th>Site</th>
3074
- <th>URL</th>
3075
- <th>Perf</th>
3076
- <th>A11y</th>
3077
- <th>BP</th>
3078
- <th>SEO</th>
3079
- </tr></thead>
3080
- <tbody>${sites.map(siteRow).join("")}</tbody>
3081
- </table>`;
3156
+ const body = sites.length === 0 ? `<div class="empty">No sites to display.</div>` : `<div class="cards">${sites.map(card).join("")}</div>`;
3082
3157
  return `<!doctype html>
3083
3158
  <html lang="en">
3084
3159
  <head>