diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index a5ea3deca..ddc5a91b4 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import Link from "next/link"; +import { ChevronDown, ChevronUp } from "lucide-react"; import type { Benchmark, LedgerColumn, @@ -20,6 +21,27 @@ import { buildProviderColors } from "@/lib/series-colors"; import { isRegion } from "@/lib/brand"; import { isAll } from "@/lib/dimensions"; +/** Sort keys exposed by the header click handlers. `null` (the default) + * means "use the bench's natural sort" — pickValue + higherIsBetter — + * which is what callers, OG renders, and share cards depend on. Any + * non-null key takes over and the comparator routes through + * pickSortValue instead. Custom-column slots are keyed by their index + * in benchmark.ledgerColumns to keep the type narrow without smuggling + * the full column object through state. */ +type SortKey = + | "name" + | "p50" + | "p90" + | "p99" + | "mean" + | "value" + | "delta" + | "success" + | "slot_p50" + | `col_${number}`; + +type SortDir = "asc" | "desc"; + type Props = { benchmark: Benchmark; /** When the bench page has a companion-panel tab selected on the @@ -85,6 +107,14 @@ export function LedgerTable({ col.unit ?? (col.panel ? (panelById.get(col.panel)?.unit ?? unit) : unit); + // Clickable column sort. Defaults to null so the natural per-bench + // ordering (pickValue + higherIsBetter) keeps driving the table — this + // is the order OG images, share cards, and snapshot tests all expect. + // A non-null sortKey takes over and routes the comparator through + // pickSortValue with the chosen direction. + const [sortKey, setSortKey] = useState(null); + const [sortDir, setSortDir] = useState("desc"); + // Timeframe toggle. Columns that declare `windows` (7d/30d panel-id // sources) flip to the selected window's values; columns without keep // their 24h figure and the header says so. Rendered only when at least @@ -140,6 +170,59 @@ export function LedgerTable({ // The chart's panel tabs still surface those providers via // seriesByProvider when the reader switches metric, so coverage isn't // lost — only the noisy ledger rows are pruned. + // Field-mean Δ — duplicated here so the sort comparator can rank by it + // without waiting on per-Row computation. Kept aligned with the same + // formula the Row uses for the displayed Δ% (see deltaPct below). + const fieldMeanRaw = + results.reduce((s, r) => s + pickValue(r), 0) / + Math.max(1, results.length); + const deltaForSort = (r: ProviderResult): number => { + if (fieldMeanRaw === 0) return 0; + return (pickValue(r) - fieldMeanRaw) / fieldMeanRaw; + }; + + // Map a sort key to a comparable scalar (or string for "name"). Numbers + // missing for a row sort to -Infinity / +Infinity depending on direction + // so empty cells consistently land at the bottom. + const pickSortValue = ( + r: ProviderResult, + k: SortKey, + ): number | string => { + if (k === "name") return r.name.toLowerCase(); + if (k === "value") { + const v = activePanel?.values[r.slug]; + return v != null && Number.isFinite(v) ? v : -Infinity; + } + if (k === "p50") return r.ms.p50; + if (k === "p90") return r.ms.p90; + if (k === "p99") return r.ms.p99; + if (k === "mean") return r.ms.mean; + if (k === "delta") return deltaForSort(r); + if (k === "success") return r.successRate ?? 0; + if (k === "slot_p50") return r.slots?.p50 ?? Infinity; + if (k.startsWith("col_")) { + const idx = Number(k.slice(4)); + const col = customCols?.[idx]; + if (!col) return -Infinity; + const v = colValueW(r, col); + return v != null ? v : -Infinity; + } + return 0; + }; + + // Toggle direction on the active header, otherwise switch to the new + // header and default to descending. Descending is the more natural + // first-click read for nearly every column (largest revenue, slowest + // latency, highest success) — for ascending the reader clicks twice. + const handleHeaderClick = (k: SortKey) => { + if (sortKey === k) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(k); + setSortDir("desc"); + } + }; + const sortedAll = [...results] .filter((r) => { // Sample-health gate. Rows tagged "insufficient" by the load path @@ -159,9 +242,23 @@ export function LedgerTable({ return r.ms.p50 !== 0 || r.ms.p90 !== 0 || r.ms.p99 !== 0; }) .sort((a, b) => { - const av = pickValue(a); - const bv = pickValue(b); - return higherIsBetter ? bv - av : av - bv; + // Default branch: preserve the EXACT comparator that shipped before + // sortable columns. OG renders, share cards, and snapshot tests all + // pin against this ordering — diverging here breaks them silently. + if (sortKey == null) { + const av = pickValue(a); + const bv = pickValue(b); + return higherIsBetter ? bv - av : av - bv; + } + const av = pickSortValue(a, sortKey); + const bv = pickSortValue(b, sortKey); + const dir = sortDir === "asc" ? 1 : -1; + if (typeof av === "string" && typeof bv === "string") { + return av.localeCompare(bv) * dir; + } + const na = typeof av === "number" ? av : 0; + const nb = typeof bv === "number" ? bv : 0; + return (na - nb) * dir; }); const sorted = topN == null ? sortedAll : sortedAll.slice(0, topN); const colors = useMemo(() => buildProviderColors(results), [results]); @@ -248,33 +345,121 @@ export function LedgerTable({ № - Name + + Name + {customCols ? ( customCols.map((c, idx) => ( - {colLabel(c)} - + )) ) : panelActive ? ( // Panel sort owns the table: a single honest "Value" column // instead of p50/p90/p99/Mean headers over dashed-out cells // (a USD volume sort labeled "p50" reads as a bug). - Value + + Value + ) : ( <> - p50 - p90 - p99 - Mean + + p50 + + + p90 + + + p99 + + + Mean + )} - Δ field - Success + + Δ field + + + Success + 24h - {hasSlots && p50 / p99} + {hasSlots && ( + + p50 / p99 + + )} {secondary && Value} @@ -576,3 +761,73 @@ function Row({ ); } + +/** + * Sortable column header. Renders a button-styled `` that toggles + * the table's sort key on click and shows an up/down chevron next to + * the label when this header is the active sort. Hover lifts the label + * from ink-muted to ink to telegraph that it's clickable; the chevron + * itself stays ink-muted so it reads as a marker, not as emphasis. + * + * The button is the inner element (not the `` itself) so the + * existing `` className keeps controlling layout / responsive + * visibility, and the click target is a real focusable button. + */ +function SortableHeader({ + sortKey, + activeKey, + dir, + onClick, + align, + className, + children, +}: { + sortKey: SortKey; + activeKey: SortKey | null; + dir: SortDir; + onClick: (k: SortKey) => void; + align: "left" | "right"; + className?: string; + children: React.ReactNode; +}) { + const isActive = activeKey === sortKey; + const alignClass = align === "right" ? "text-right" : "text-left"; + const justifyClass = align === "right" ? "justify-end" : "justify-start"; + return ( + + + + ); +}