TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import Link from "next/link";4import { ArrowLeft, Coins, Info, Menu, Swords, Trophy } from "lucide-react";5import { useApp } from "@/components/app/store";6import { ProviderIcon } from "@/components/brand/provider-icon";7import { Button } from "@/components/ui/button";8import { ChipRow } from "@/components/ui/segmented";9import { Skeleton } from "@/components/ui/misc";10import { Tooltip } from "@/components/ui/tooltip";11import { useApi } from "@/lib/client/api";12import { PROVIDERS } from "@/lib/client/providers";13import { CATEGORIES, criterionById, type ScoreboardRow, type TaskCategory } from "@/lib/arena/scoring";14import { cn, formatMs, formatUsd } from "@/lib/utils";1516type Filter = "all" | TaskCategory | "value";1718const FILTERS: { value: Filter; label: string; icon?: React.ReactNode }[] = [{ value: "all", label: "All" }, ...CATEGORIES.map((c) => ({ value: c.value as Filter, label: c.label })), { value: "value", label: "Cost efficiency", icon: <Coins /> }];1920interface ScoreboardResponse {21 rows: ScoreboardRow[];22 sessions: number;23 votes: number;24}2526/** Personal Arena scoreboard: win rate per model from your own votes. Mobile-first ranking list. */27export function ScoreboardView() {28 const { modelsByKey, setSidebarOpen, preferences } = useApp();29 const [filter, setFilter] = React.useState<Filter>("all");30 const query = filter === "all" ? "" : filter === "value" ? "?criterion=value" : `?category=${filter}`;31 const { data, isLoading, error } = useApi<ScoreboardResponse>(`/api/arena/scoreboard${query}`);32 const rows = React.useMemo(() => (data?.rows ?? []).filter((r) => r.sessions > 0), [data]);33 const ranked = React.useMemo(() => rows.filter((r) => r.decided > 0), [rows]);34 const unranked = React.useMemo(() => rows.filter((r) => r.decided === 0), [rows]);35 const nameOf = (k: string) => modelsByKey.get(k)?.displayName ?? k.split("/").slice(1).join("/");3637 return (38 <div className="flex h-full min-h-0 flex-col">39 <header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-2 sm:px-4">40 <Button variant="ghost" size="icon-sm" className="md:hidden" onClick={() => setSidebarOpen(true)} aria-label="Open sidebar">41 <Menu />42 </Button>43 <Button asChild variant="ghost" size="icon-sm" aria-label="Back to Arena">44 <Link href="/app/arena">45 <ArrowLeft />46 </Link>47 </Button>48 <span className="flex size-7 items-center justify-center rounded-md bg-accent-soft text-accent">49 <Trophy className="size-4" />50 </span>51 <div className="min-w-0 leading-tight">52 <h1 className="text-[14px] font-semibold tracking-tight">Scoreboard</h1>53 <p className="hidden truncate text-[11.5px] text-fg-muted sm:block">Which models win your comparisons.</p>54 </div>55 <Button asChild variant="outline" size="sm" className="ml-auto">56 <Link href="/app/arena">57 <Swords /> <span className="hidden sm:inline">New comparison</span>58 <span className="sm:hidden">Arena</span>59 </Link>60 </Button>61 </header>6263 <main className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">64 <div className="mx-auto w-full max-w-3xl px-3 py-4 sm:px-5">65 <ChipRow value={filter} onChange={setFilter} options={FILTERS} className="-mx-3 px-3 pb-1 sm:mx-0 sm:px-0" />66 <p className="mt-2 flex items-start gap-1.5 text-[12px] text-fg-muted">67 <Info className="mt-0.5 size-3.5 shrink-0" />68 <span>69 {filter === "value" ? "Ranked by “Best value” votes only." : filter === "all" ? "Ranked by the share of decided comparisons each model won (most criteria; ties → fastest)." : `Only comparisons classified as ${FILTERS.find((f) => f.value === filter)?.label.toLowerCase()} prompts.`}70 {data ? ` ${data.sessions} session${data.sessions === 1 ? "" : "s"} · ${data.votes} vote${data.votes === 1 ? "" : "s"}.` : ""}71 </span>72 </p>7374 {error ? (75 <p className="mt-6 rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[13px] text-danger">Could not load the scoreboard: {(error as Error).message}</p>76 ) : isLoading && !data ? (77 <ul className="mt-4 space-y-2">78 {[0, 1, 2, 3].map((i) => (79 <Skeleton key={i} className="h-[76px]" />80 ))}81 </ul>82 ) : !ranked.length ? (83 <EmptyScoreboard hasSessions={rows.length > 0} filtered={filter !== "all"} />84 ) : (85 <ol className="mt-4 space-y-2" aria-label="Ranking">86 {ranked.map((r, i) => (87 <RankRow key={r.modelKey} rank={i + 1} row={r} name={nameOf(r.modelKey)} showCosts={preferences.showCosts} filter={filter} />88 ))}89 </ol>90 )}9192 {unranked.length && ranked.length ? (93 <details className="mt-5 text-[12.5px] text-fg-muted">94 <summary className="cursor-pointer select-none">95 {unranked.length} model{unranked.length === 1 ? "" : "s"} without a decided comparison96 </summary>97 <ul className="mt-2 flex flex-wrap gap-1.5">98 {unranked.map((r) => (99 <li key={r.modelKey} className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1">100 <ProviderIcon provider={r.provider} size={12} /> {nameOf(r.modelKey)} <span className="text-fg-subtle">· {r.sessions}</span>101 </li>102 ))}103 </ul>104 </details>105 ) : null}106 </div>107 </main>108 </div>109 );110}111112function RankRow({ rank, row: r, name, showCosts, filter }: { rank: number; row: ScoreboardRow; name: string; showCosts: boolean; filter: Filter }) {113 const pct = Math.round(r.winRate * 100);114 const top = rank === 1;115 const crit = Object.entries(r.criteria)116 .sort((a, b) => b[1] - a[1])117 .slice(0, 3);118 return (119 <li className={cn("rounded-xl border bg-bg-elevated p-3 sm:p-3.5", top ? "border-accent/50 shadow-glow" : "border-border")}>120 <div className="flex items-center gap-3">121 <span className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg text-[13px] font-semibold tabular-nums", top ? "bg-accent text-accent-fg" : "bg-bg-muted text-fg-muted")} aria-label={`Rank ${rank}`}>122 {rank}123 </span>124 <span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-bg-subtle">125 <ProviderIcon provider={r.provider} size={16} />126 </span>127 <div className="min-w-0 flex-1">128 <div className="flex items-baseline gap-2">129 <h3 className="truncate text-[14px] font-semibold tracking-tight">{name}</h3>130 <span className="truncate text-[11.5px] text-fg-subtle">{PROVIDERS[r.provider as keyof typeof PROVIDERS]?.shortName ?? r.provider}</span>131 </div>132 <div className="mt-1.5 flex items-center gap-2">133 <div className="h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-bg-muted" role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label="Win rate">134 <div className={cn("h-full rounded-full transition-[width] duration-500", top ? "bg-accent" : "bg-fg/70")} style={{ width: `${Math.max(2, pct)}%` }} />135 </div>136 <span className="w-10 shrink-0 text-right text-[13px] font-semibold tabular-nums">{pct}%</span>137 </div>138 </div>139 </div>140 <dl className="mt-2.5 grid grid-cols-3 gap-2 text-[11.5px] tabular-nums sm:grid-cols-4">141 <Stat label={filter === "value" ? "Value wins" : "Wins"} value={`${r.wins} / ${r.decided}`} hint={`${r.sessions} session${r.sessions === 1 ? "" : "s"}`} />142 <Stat label="Votes" value={String(r.votes)} hint={crit.length ? crit.map(([id, n]) => `${criterionById(id).short} ${n}`).join(" · ") : undefined} />143 <Stat label="Avg TTFT" value={formatMs(r.avgTtftMs)} />144 {showCosts ? <Stat label="Avg cost" value={r.avgCostUsd === null ? "—" : formatUsd(r.avgCostUsd, { precise: r.avgCostUsd < 0.01 })} hint="per response" className="hidden sm:block" /> : null}145 </dl>146 </li>147 );148}149150function Stat({ label, value, hint, className }: { label: string; value: string; hint?: string; className?: string }) {151 return (152 <div className={cn("min-w-0", className)}>153 <dt className="text-[10.5px] uppercase tracking-wide text-fg-subtle">{label}</dt>154 <dd className="truncate font-medium text-fg">{value}</dd>155 {hint ? (156 <Tooltip content={hint}>157 <dd className="truncate text-[10.5px] text-fg-subtle">{hint}</dd>158 </Tooltip>159 ) : null}160 </div>161 );162}163164function EmptyScoreboard({ hasSessions, filtered }: { hasSessions: boolean; filtered: boolean }) {165 return (166 <section className="mt-6 rounded-xl border border-dashed border-border px-5 py-8 text-center sm:px-8">167 <span className="mx-auto flex size-10 items-center justify-center rounded-lg bg-bg-muted text-fg-muted">168 <Trophy className="size-5" />169 </span>170 <h2 className="mt-3 text-balance text-[16px] font-semibold tracking-tight">{filtered ? "No decided comparison in this filter yet" : hasSessions ? "Vote to build your ranking" : "Your ranking starts with a comparison"}</h2>171 <p className="mx-auto mt-1.5 max-w-md text-[13px] leading-5 text-fg-muted">172 Run the same prompt through several models in the Arena, then vote on criteria such as Best answer, Most accurate or Best value. Each decided comparison counts as a win for the model that took the most criteria (ties go to the fastest). The win rate is wins over173 the comparisons a model took part in that received a vote.174 </p>175 <Button asChild variant="accent" size="sm" className="mt-4">176 <Link href="/app/arena">177 <Swords /> Open the Arena178 </Link>179 </Button>180 </section>181 );182}183