241 lines
6.6 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" | "sheets") => 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-52"
>
<li>
<button onClick={() => onExport("sheets")}>
Export to Google Sheets
</button>
</li>
<li>
<button onClick={() => onExport("csv")}>CSV</button>
</li>
<li>
<button onClick={() => onExport("json")}>JSON</button>
</li>
</ul>
</div>
);
}