Ben Senescu 638f5a6602 refactor: move lighthouse audits to dataforseo (#43)
* refactor: move lighthouse audits to dataforseo

* chore: remove obsolete audit settings modal

* refactor: rename psi flows to lighthouse

* save

* refactor: simplify audit lighthouse storage flow

* fix: separate lighthouse metrics from actionable audits

* refactor: remove redundant audit project inputs

* feat: redesign lighthouse issues screen with score gauges and table layout

Replace flat score cards with circular SVG gauges, condense metrics into
a compact grid, and switch issue list from cards to an expandable table
with fixed column widths.

* test: harden lighthouse regression coverage

* fix: restore project-scoped audit inputs

* refactor: simplify lighthouse payload handling

* refactor: inline lighthouse server handlers

* refactor: share audit workflow types

* refactor: simplify lighthouse payload flows

* save

* refactor: drop project pagespeed api key

* fix: restore lighthouse issues loading with resilient project context

* fix: restore audit issues back navigation

* refactor: simplify project context and lighthouse error handling

* fix: tolerate DataForSEO lighthouse payload drift

* refactor: route audit lighthouse through dataforseo client
2026-03-26 20:25:27 -04:00

236 lines
6.5 KiB
TypeScript

import { ChevronDown, Download, ExternalLink } from "lucide-react";
import {
extractPathname,
HttpStatusBadge,
LighthouseScoreBadge,
} from "@/client/features/audit/shared";
import type { AuditResultsData } from "@/client/features/audit/results/types";
type LighthouseFailureFields = {
errorMessage: string | null;
performanceScore: number | null;
accessibilityScore: number | null;
bestPracticesScore: number | null;
seoScore: number | null;
};
function hasMissingLighthouseScores(row: LighthouseFailureFields) {
return (
row.performanceScore == null &&
row.accessibilityScore == null &&
row.bestPracticesScore == null &&
row.seoScore == null
);
}
export function isLighthouseFailure(row: LighthouseFailureFields) {
return !!row.errorMessage || hasMissingLighthouseScores(row);
}
function getLighthouseFailureMessage(row: LighthouseFailureFields) {
return row.errorMessage ?? "Lighthouse returned no category scores";
}
export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>URL</th>
<th>Status</th>
<th>Title</th>
<th>H1</th>
<th>Words</th>
<th>Images</th>
<th>Speed</th>
</tr>
</thead>
<tbody>
{pages.map((page: AuditResultsData["pages"][number]) => (
<tr key={page.id}>
<td className="max-w-[200px] truncate">
<a
href={page.url}
target="_blank"
rel="noopener noreferrer"
className="link link-primary text-xs inline-flex items-center gap-1"
>
{extractPathname(page.url)}
<ExternalLink className="size-3" />
</a>
</td>
<td>
<HttpStatusBadge code={page.statusCode} />
</td>
<td className="max-w-[180px] truncate" title={page.title ?? ""}>
{page.title || (
<span className="text-error text-xs">missing</span>
)}
</td>
<td>{page.h1Count}</td>
<td>{page.wordCount}</td>
<td>
{page.imagesMissingAlt > 0 ? (
<span className="text-warning">
{page.imagesMissingAlt}/{page.imagesTotal}
</span>
) : (
page.imagesTotal
)}
</td>
<td className="text-xs">
{page.responseTimeMs ? `${page.responseTimeMs}ms` : "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function PerformanceTable({
auditId,
projectId,
lighthouse,
pages,
}: {
auditId: string;
projectId: string;
lighthouse: AuditResultsData["lighthouse"];
pages: AuditResultsData["pages"];
}) {
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>URL</th>
<th>Device</th>
<th>Status</th>
<th>Perf</th>
<th>A11y</th>
<th>SEO</th>
<th>LCP</th>
<th>CLS</th>
<th>INP</th>
<th>TTFB</th>
<th>Issues</th>
</tr>
</thead>
<tbody>
{lighthouse.map((result: AuditResultsData["lighthouse"][number]) => (
<PerformanceRow
key={result.id}
auditId={auditId}
projectId={projectId}
result={result}
page={pages.find(
(candidate: AuditResultsData["pages"][number]) =>
candidate.id === result.pageId,
)}
/>
))}
</tbody>
</table>
</div>
);
}
function PerformanceRow({
auditId,
projectId,
result,
page,
}: {
auditId: string;
projectId: string;
result: AuditResultsData["lighthouse"][number];
page: AuditResultsData["pages"][number] | undefined;
}) {
const isFailed = isLighthouseFailure(result);
const failureMessage = getLighthouseFailureMessage(result);
return (
<tr>
<td className="max-w-[160px] truncate text-xs">
{page ? extractPathname(page.url) : "-"}
</td>
<td className="capitalize text-xs">{result.strategy}</td>
<td>
{isFailed ? (
<span
className="badge badge-error badge-outline text-xs"
title={failureMessage}
>
failed
</span>
) : (
<span className="badge badge-success badge-outline text-xs">ok</span>
)}
</td>
<td>
<LighthouseScoreBadge score={result.performanceScore} />
</td>
<td>
<LighthouseScoreBadge score={result.accessibilityScore} />
</td>
<td>
<LighthouseScoreBadge score={result.seoScore} />
</td>
<td className="text-xs">
{result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"}
</td>
<td className="text-xs">
{result.cls != null ? result.cls.toFixed(3) : "-"}
</td>
<td className="text-xs">
{result.inpMs ? `${Math.round(result.inpMs)}ms` : "-"}
</td>
<td className="text-xs">
{result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"}
</td>
<td>
{result.r2Key && !isFailed ? (
<a
className="btn btn-primary btn-xs"
href={`/p/${projectId}/audit/issues/${result.id}?auditId=${auditId}&category=performance`}
>
View issues
</a>
) : (
<span className="text-xs text-base-content/40">-</span>
)}
</td>
</tr>
);
}
export function ExportDropdown({
onExport,
}: {
onExport: (format: "csv" | "json") => void;
}) {
return (
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-sm btn-ghost gap-1">
<Download className="size-4" />
Export
<ChevronDown className="size-3 opacity-60" />
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-40"
>
<li>
<button onClick={() => onExport("csv")}>CSV</button>
</li>
<li>
<button onClick={() => onExport("json")}>JSON</button>
</li>
</ul>
</div>
);
}