From 62295ac93a400cc45176d8de7ce0cba5dd53b144 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 28 Jun 2026 12:22:44 +0200 Subject: [PATCH] feat(ledger): clickable column headers to sort leaderboard Numeric column headers in LedgerTable now toggle the table's sort on click. First click on a header descends on its value, second click toggles to ascending. An up/down chevron next to the active header's label marks the direction; hover lifts ink-muted to ink so the headers read as interactive. Sortable columns: Name (A-Z), every customCols entry (per-index key), Value (when a panel tab is active), p50/p90/p99/Mean, Delta field, Success, and Slot delta p50 (when hasSlots). Trend and Number columns stay static. The default sort (sortKey null) preserves the exact prior comparator so OG renders, share cards, and snapshot tests keep matching. A non-null sortKey routes through a new pickSortValue helper instead. Missing values land at +/-Infinity so empty cells consistently sort to the bottom regardless of direction. --- src/components/ledger-table.tsx | 285 ++++++++++++++++++++++++++++++-- 1 file changed, 270 insertions(+), 15 deletions(-) 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 ( + + + + ); +}