← back to Open Seo
src/client/features/rank-tracking/RankTrackingHistoryMatrix.tsx
146 lines
import { useMemo } from "react";
import { Loader2 } from "lucide-react";
import type { RankPositionMatrixCell } from "@/serverFunctions/rank-tracking";
/**
* "By date" view: keyword rows × recent check columns, each cell the position
* on that date with its change vs the previous check. This is the pivoted
* history table users want for client reporting ("look — we won 5 positions").
*/
export function RankTrackingHistoryMatrix({
cells,
isLoading,
keywords,
}: {
cells: RankPositionMatrixCell[];
isLoading: boolean;
keywords: { trackingKeywordId: string; keyword: string }[];
}) {
const { runs, cellByKeyword } = useMemo(() => buildMatrix(cells), [cells]);
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="size-5 animate-spin text-base-content/50" />
</div>
);
}
if (runs.length === 0 || keywords.length === 0) {
return (
<div className="rounded-xl border border-dashed border-base-300 p-10 text-center text-sm text-base-content/55">
No history yet. Run a check to start building the timeline.
</div>
);
}
return (
<div className="overflow-x-auto rounded-lg border border-base-300">
<table className="table table-sm">
<thead>
<tr>
{/* Unconstrained keyword column absorbs the slack when only a few
check columns exist, so sparse history doesn't stretch oddly. */}
<th className="sticky left-0 z-10 bg-base-100 w-full">Keyword</th>
{runs.map((r) => (
<th
key={r.runId}
className="w-24 whitespace-nowrap text-right text-xs font-medium text-base-content/60"
>
{formatDate(r.checkedAt)}
</th>
))}
</tr>
</thead>
<tbody>
{keywords.map((kw) => {
const byRun = cellByKeyword.get(kw.trackingKeywordId);
return (
<tr key={kw.trackingKeywordId}>
<td className="sticky left-0 z-10 bg-base-100 whitespace-nowrap font-medium">
{kw.keyword}
</td>
{runs.map((r, i) => {
const position = byRun?.get(r.runId) ?? null;
const previous =
i > 0 ? (byRun?.get(runs[i - 1].runId) ?? null) : undefined;
return (
<td key={r.runId} className="text-right">
<MatrixCell position={position} previous={previous} />
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function MatrixCell({
position,
previous,
}: {
position: number | null;
previous: number | null | undefined;
}) {
if (position === null) {
return <span className="text-base-content/30">—</span>;
}
// Only show a change arrow when both checks ranked (no subtracting through a
// null, matching the rest of the rank-tracking UI).
const change =
previous != null && previous !== undefined ? previous - position : null;
return (
<span className="inline-flex items-center justify-end gap-1 font-mono text-xs">
<span>{position}</span>
{change != null && change > 0 && (
<span className="text-success">▲{change}</span>
)}
{change != null && change < 0 && (
<span className="text-warning">▼{-change}</span>
)}
</span>
);
}
interface MatrixRun {
runId: string;
checkedAt: string;
}
/** Distinct completed runs in a matrix payload (= history columns). */
export function countMatrixRuns(cells: RankPositionMatrixCell[]): number {
return new Set(cells.map((c) => c.runId)).size;
}
function buildMatrix(cells: RankPositionMatrixCell[]): {
runs: MatrixRun[];
cellByKeyword: Map<string, Map<string, number | null>>;
} {
const runMap = new Map<string, string>(); // runId -> checkedAt
const cellByKeyword = new Map<string, Map<string, number | null>>();
for (const c of cells) {
runMap.set(c.runId, c.checkedAt);
let byRun = cellByKeyword.get(c.trackingKeywordId);
if (!byRun) {
byRun = new Map();
cellByKeyword.set(c.trackingKeywordId, byRun);
}
byRun.set(c.runId, c.position);
}
const runs = [...runMap.entries()]
.map(([runId, checkedAt]) => ({ runId, checkedAt }))
.toSorted((a, b) => a.checkedAt.localeCompare(b.checkedAt));
return { runs, cellByKeyword };
}
function formatDate(value: string): string {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}