Add stats page

This commit is contained in:
2026-04-05 23:49:29 +01:00
parent 74b265213e
commit 8483d5b384
24 changed files with 1209 additions and 69 deletions

View File

@@ -9,6 +9,7 @@
"@heroui/styles": "^3.0.2",
"better-sqlite3": "^12.8.0",
"drizzle-orm": "^0.45.2",
"javascript-time-ago": "^2.6.4",
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4",
@@ -562,6 +563,8 @@
"intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="],
"javascript-time-ago": ["javascript-time-ago@2.6.4", "", { "dependencies": { "relative-time-format": "^1.1.12" } }, "sha512-7K/Z37LuwVaxxjutUDd1pXpznufPcox0b1UYu00ksAMMlV6IsxIvduwL3kgfPxuBVF8jVj7nhrKMPDslMq94aQ=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -628,6 +631,8 @@
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"relative-time-format": ["relative-time-format@1.1.12", "", {}, "sha512-qaZBjmRIuXLfuLnzgqpFdBPa5W0euSX1tMnoMUHGPphLwJmrt8xbNiOIHrlvYOD6oNJ0M5owPCZyPibI8de5pQ=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],

View File

@@ -14,6 +14,7 @@
"@heroui/styles": "^3.0.2",
"better-sqlite3": "^12.8.0",
"drizzle-orm": "^0.45.2",
"javascript-time-ago": "^2.6.4",
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4",

View File

@@ -0,0 +1,13 @@
import type { NextRequest } from "next/server";
import { getLeaderboard } from "@/lib/stats";
export async function GET(request: NextRequest) {
const params = request.nextUrl.searchParams;
const page = Number(params.get("page") ?? "0");
const sortBy = params.get("sortBy") ?? "elo";
const direction = params.get("direction") ?? "descending";
const limit = Number(params.get("limit") ?? "20");
const data = await getLeaderboard(page, sortBy, direction, limit);
return Response.json(data);
}

View File

@@ -0,0 +1,8 @@
import type { NextRequest } from "next/server";
import { getRecentMatches } from "@/lib/stats";
export async function GET(request: NextRequest) {
const page = Number(request.nextUrl.searchParams.get("page") ?? "0");
const data = await getRecentMatches(page);
return Response.json(data);
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Nav } from "@/components/nav";
import "./globals.css";
const geistSans = Geist({
@@ -29,6 +30,7 @@ export default function RootLayout({
data-theme="dark"
>
<body className="bg-background text-foreground min-h-dvh flex flex-col">
<Nav />
{children}
</body>
</html>

View File

@@ -16,5 +16,9 @@ export default async function Home() {
);
}
return <VotingArena initialPairing={pairing} />;
return (
<div className="flex flex-col flex-1 overflow-hidden">
<VotingArena initialPairing={pairing} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { LeaderboardTable } from "@/components/stats/leaderboard-table";
import { getLeaderboard } from "@/lib/stats";
export const revalidate = 60;
export default async function LeaderboardPage() {
const leaderboard = await getLeaderboard();
return (
<div className="flex flex-col flex-1 max-w-7xl mx-auto w-full px-4 py-8 gap-8">
<h1 className="text-3xl font-bold">Leaderboard</h1>
<LeaderboardTable initialData={leaderboard} />
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { MatchesTable } from "@/components/stats/matches-table";
import { getRecentMatches } from "@/lib/stats";
export const revalidate = 60;
export default async function MatchesPage() {
const matches = await getRecentMatches();
return (
<div className="flex flex-col flex-1 max-w-7xl mx-auto w-full px-4 py-8 gap-8">
<h1 className="text-3xl font-bold">Matches</h1>
<MatchesTable initialData={matches} />
</div>
);
}

53
src/app/stats/page.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { ControversialList } from "@/components/stats/controversial-list";
import { EloHistogram } from "@/components/stats/elo-histogram";
import { LeaderboardPreview } from "@/components/stats/leaderboard-table";
import { LensTable } from "@/components/stats/lens-table";
import { MatchesPreview } from "@/components/stats/matches-table";
import { UpsetsList } from "@/components/stats/upsets-list";
import {
getBiggestUpsets,
getEloDistribution,
getLeaderboard,
getLensStats,
getMostControversial,
getRecentMatches,
} from "@/lib/stats";
export const revalidate = 60;
export default async function StatsPage() {
const lensPromise = getLensStats();
const matchesPromise = lensPromise.then((lens) =>
getRecentMatches(0, lens.length + 11),
);
const [leaderboard, upsets, controversial, distribution, lensStats, matches] =
await Promise.all([
getLeaderboard(0, "elo", "descending", 12),
getBiggestUpsets(6),
getMostControversial(),
getEloDistribution(),
lensPromise,
matchesPromise,
]);
return (
<div className="flex flex-col flex-1 max-w-7xl mx-auto w-full px-4 py-8 gap-12">
<h1 className="text-3xl font-bold">Stats</h1>
<LeaderboardPreview data={leaderboard} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
<MatchesPreview data={matches} />
<div className="flex flex-col gap-12">
<UpsetsList data={upsets} />
<LensTable data={lensStats} />
</div>
</div>
<EloHistogram data={distribution} />
<ControversialList data={controversial} />
</div>
);
}

21
src/components/nav.tsx Normal file
View File

@@ -0,0 +1,21 @@
import Link from "next/link";
const links = [
{ href: "/", label: "Vote" },
{ href: "/stats", label: "Stats" },
] as const;
export function Nav() {
return (
<nav className="flex items-center gap-6 px-6 py-3 border-b border-separator">
<span className="font-bold text-lg tracking-tight">Rate My Shots</span>
<div className="flex items-center gap-4">
{links.map((link) => (
<Link key={link.href} href={link.href} className="text-sm link">
{link.label}
</Link>
))}
</div>
</nav>
);
}

View File

@@ -0,0 +1,52 @@
import Link from "next/link";
import { formatElo } from "@/lib/format";
import type { ControversialEntry } from "@/lib/stats";
export function ControversialList({ data }: { data: ControversialEntry[] }) {
if (data.length === 0) {
return <p className="text-muted text-sm">No assets synced yet.</p>;
}
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
Most Controversial
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{data.map((entry) => (
<Link
key={entry.id}
href={`/asset/${entry.id}`}
className="flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-default transition-colors"
>
<div className="flex items-center justify-center w-full h-24">
<img
src={entry.thumbnailUrl}
alt=""
className="h-full rounded object-contain"
style={{ aspectRatio: entry.aspectRatio }}
/>
</div>
<div className="text-center">
<div className="text-sm font-bold tabular-nums">
{formatElo(entry.eloProvisional)}
</div>
{entry.matchesProvisional > 0 ? (
<>
<div className="text-xs text-muted tabular-nums">
{(entry.winRate * 100).toFixed(1)}% win rate
</div>
<div className="text-xs text-muted tabular-nums">
{entry.matchesProvisional} matches
</div>
</>
) : (
<div className="text-xs text-muted">No matches yet</div>
)}
</div>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import type { EloDistributionBucket } from "@/lib/stats";
export function EloHistogram({ data }: { data: EloDistributionBucket[] }) {
if (data.length === 0) {
return <p className="text-muted text-sm">No data yet.</p>;
}
const maxCount = Math.max(...data.map((d) => d.count));
const bucketSize = data.length > 1 ? data[1].bucket - data[0].bucket : 50;
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
ELO Distribution
</h3>
<div className="flex flex-col">
{/* Bars */}
<div className="flex items-end h-40">
{data.map((bucket) => {
const pct = maxCount > 0 ? (bucket.count / maxCount) * 100 : 0;
return (
<div
key={bucket.bucket}
className="flex flex-col items-center justify-end flex-1 h-full"
>
{bucket.count > 0 && (
<div className="text-[10px] text-muted tabular-nums mb-0.5">
{bucket.count}
</div>
)}
<div
className="w-full bg-accent"
style={{ height: `${pct}%` }}
/>
</div>
);
})}
</div>
{/* Edge labels */}
<div className="flex">
{data.map((bucket, i) => (
<div key={bucket.bucket} className="flex-1 relative">
{/* Left edge of each bar */}
<span className="absolute -left-2 text-[10px] text-muted tabular-nums">
{bucket.bucket}
</span>
{/* Right edge of last bar */}
{i === data.length - 1 && (
<span className="absolute -right-2 text-[10px] text-muted tabular-nums">
{bucket.bucket + bucketSize}
</span>
)}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,269 @@
"use client";
import { Button, type SortDescriptor, Table } from "@heroui/react";
import { buttonVariants } from "@heroui/styles";
import Link from "next/link";
import { useState } from "react";
import useSWRInfinite from "swr/infinite";
import {
formatElo,
formatExposureTime,
formatFNumber,
formatFocalLength,
} from "@/lib/format";
import type { LeaderboardEntry } from "@/lib/stats";
function SortHeader({
children,
sortDirection,
}: {
children: React.ReactNode;
sortDirection?: "ascending" | "descending";
}) {
return (
<span className="flex items-center gap-1">
{children}
{sortDirection && (
<span className={sortDirection === "descending" ? "rotate-180" : ""}>
</span>
)}
</span>
);
}
function LeaderboardRows({ entries }: { entries: LeaderboardEntry[] }) {
return (
<>
{entries.map((entry, i) => (
<Table.Row key={entry.id}>
<Table.Cell className="tabular-nums">{i + 1}</Table.Cell>
<Table.Cell className="flex justify-center">
<Link href={`/asset/${entry.id}`}>
<img
src={entry.thumbnailUrl}
alt=""
className="h-8 rounded object-contain"
style={{ aspectRatio: entry.aspectRatio }}
/>
</Link>
</Table.Cell>
<Table.Cell className="font-bold tabular-nums">
{formatElo(entry.eloProvisional)}
</Table.Cell>
<Table.Cell className="tabular-nums text-success">
{entry.winsProvisional}
</Table.Cell>
<Table.Cell className="tabular-nums text-danger">
{entry.lossesProvisional}
</Table.Cell>
<Table.Cell className="tabular-nums">
{entry.matchesProvisional}
</Table.Cell>
<Table.Cell className="tabular-nums">
{(entry.winRate * 100).toFixed(1)}%
</Table.Cell>
<Table.Cell className="text-xs text-muted">
{entry.lensModel ?? "—"}
</Table.Cell>
<Table.Cell className="tabular-nums text-xs">
{formatFocalLength(entry.focalLength)}
</Table.Cell>
<Table.Cell className="tabular-nums text-xs">
{formatFNumber(entry.fNumber)}
</Table.Cell>
<Table.Cell className="tabular-nums text-xs">
{formatExposureTime(entry.exposureTime)}
</Table.Cell>
<Table.Cell className="tabular-nums text-xs">
{entry.iso || "—"}
</Table.Cell>
</Table.Row>
))}
</>
);
}
function LeaderboardHeader() {
return (
<Table.Header>
<Table.Column isRowHeader id="rank" className="w-0">
#
</Table.Column>
<Table.Column id="asset" className="text-center">
Asset
</Table.Column>
<Table.Column allowsSorting id="elo">
{({ sortDirection }) => (
<SortHeader sortDirection={sortDirection}>ELO</SortHeader>
)}
</Table.Column>
<Table.Column allowsSorting id="wins">
{({ sortDirection }) => (
<SortHeader sortDirection={sortDirection}>W</SortHeader>
)}
</Table.Column>
<Table.Column allowsSorting id="losses">
{({ sortDirection }) => (
<SortHeader sortDirection={sortDirection}>L</SortHeader>
)}
</Table.Column>
<Table.Column allowsSorting id="played">
{({ sortDirection }) => (
<SortHeader sortDirection={sortDirection}>P</SortHeader>
)}
</Table.Column>
<Table.Column allowsSorting id="winRate">
{({ sortDirection }) => (
<SortHeader sortDirection={sortDirection}>Win %</SortHeader>
)}
</Table.Column>
<Table.Column id="lens">Lens</Table.Column>
<Table.Column allowsSorting id="focal">
{({ sortDirection }) => (
<SortHeader sortDirection={sortDirection}>FL</SortHeader>
)}
</Table.Column>
<Table.Column allowsSorting id="aperture">
{({ sortDirection }) => (
<SortHeader sortDirection={sortDirection}>f/</SortHeader>
)}
</Table.Column>
<Table.Column allowsSorting id="shutter">
{({ sortDirection }) => (
<SortHeader sortDirection={sortDirection}>Shutter</SortHeader>
)}
</Table.Column>
<Table.Column allowsSorting id="iso">
{({ sortDirection }) => (
<SortHeader sortDirection={sortDirection}>ISO</SortHeader>
)}
</Table.Column>
</Table.Header>
);
}
export function LeaderboardPreview({
data,
pageSize = 12,
}: {
data: LeaderboardEntry[];
pageSize?: number;
}) {
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
column: "elo",
direction: "descending",
});
const fetcher = (url: string) => fetch(url).then((r) => r.json());
const { data: fetched } = useSWRInfinite(
(index) =>
`/api/stats/leaderboard?page=${index}&sortBy=${sortDescriptor.column}&direction=${sortDescriptor.direction}&limit=${pageSize}`,
fetcher,
{
fallbackData: [data],
revalidateFirstPage: false,
keepPreviousData: true,
},
);
const entries: LeaderboardEntry[] = fetched ? fetched[0] : data;
function handleSortChange(descriptor: SortDescriptor) {
setSortDescriptor(descriptor);
}
return (
<div className="flex flex-col gap-4">
<Table>
<Table.ScrollContainer>
<Table.Content
aria-label="Leaderboard"
className="min-w-[900px]"
sortDescriptor={sortDescriptor}
onSortChange={handleSortChange}
>
<LeaderboardHeader />
<Table.Body>
<LeaderboardRows entries={entries} />
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
<div className="flex justify-center">
<Link
href="/stats/leaderboard"
className={buttonVariants({ variant: "secondary" })}
>
See more
</Link>
</div>
</div>
);
}
export function LeaderboardTable({
initialData,
}: {
initialData: LeaderboardEntry[];
}) {
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
column: "elo",
direction: "descending",
});
const fetcher = (url: string) => fetch(url).then((r) => r.json());
const { data, size, setSize, isValidating } = useSWRInfinite(
(index) =>
`/api/stats/leaderboard?page=${index}&sortBy=${sortDescriptor.column}&direction=${sortDescriptor.direction}`,
fetcher,
{
fallbackData: [initialData],
revalidateFirstPage: false,
keepPreviousData: true,
},
);
const allEntries: LeaderboardEntry[] = data ? data.flat() : [];
const hasMore = data ? data[data.length - 1]?.length === 20 : false;
const isLoadingMore =
isValidating && data && typeof data[size - 1] === "undefined";
function handleSortChange(descriptor: SortDescriptor) {
setSortDescriptor(descriptor);
setSize(1);
}
return (
<div className="flex flex-col gap-4">
<Table>
<Table.ScrollContainer>
<Table.Content
aria-label="Leaderboard"
className="min-w-[900px]"
sortDescriptor={sortDescriptor}
onSortChange={handleSortChange}
>
<LeaderboardHeader />
<Table.Body>
<LeaderboardRows entries={allEntries} />
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
{hasMore && (
<div className="flex justify-center">
<Button
variant="secondary"
onPress={() => setSize(size + 1)}
isDisabled={isLoadingMore}
>
{isLoadingMore ? "Loading..." : "Load more"}
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { Table } from "@heroui/react";
import Link from "next/link";
import { formatElo } from "@/lib/format";
import type { LensStats } from "@/lib/stats";
export function LensTable({ data }: { data: LensStats[] }) {
if (data.length === 0) {
return <p className="text-muted text-sm">No lens data yet.</p>;
}
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
Lens Performance
</h3>
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="Lens stats">
<Table.Header>
<Table.Column isRowHeader>Lens</Table.Column>
<Table.Column>Assets</Table.Column>
<Table.Column>Avg ELO</Table.Column>
<Table.Column>Matches</Table.Column>
<Table.Column>Win %</Table.Column>
<Table.Column className="text-center">Best Shot</Table.Column>
</Table.Header>
<Table.Body>
{data.map((lens) => (
<Table.Row key={lens.lensModel}>
<Table.Cell className="font-medium text-sm">
{lens.lensModel}
</Table.Cell>
<Table.Cell className="tabular-nums">
{lens.assetCount}
</Table.Cell>
<Table.Cell className="tabular-nums font-bold">
{formatElo(lens.avgElo)}
</Table.Cell>
<Table.Cell className="tabular-nums">
{lens.totalMatches}
</Table.Cell>
<Table.Cell className="tabular-nums">
{(lens.winRate * 100).toFixed(1)}%
</Table.Cell>
<Table.Cell className="flex justify-center">
<Link
href={`/asset/${lens.bestAssetId}`}
className="inline-flex justify-center"
>
<img
src={`/img/${lens.bestAssetId}`}
alt=""
className="h-8 rounded object-contain"
style={{ aspectRatio: lens.bestAssetAspectRatio }}
/>
</Link>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
</div>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import { Button, Table } from "@heroui/react";
import { buttonVariants } from "@heroui/styles";
import Link from "next/link";
import useSWRInfinite from "swr/infinite";
import { TimeAgo } from "@/components/time-ago";
import { formatElo } from "@/lib/format";
import type { MatchEntry } from "@/lib/stats";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
function MatchRows({ matches }: { matches: MatchEntry[] }) {
return (
<>
{matches.map((match) => (
<Table.Row key={match.id}>
<Table.Cell className="text-center">
<Link
href={`/asset/${match.leftId}`}
className="inline-flex justify-center"
>
<img
src={`/img/${match.leftId}`}
alt=""
className="h-8 rounded object-contain"
style={{ aspectRatio: match.leftAspectRatio }}
/>
</Link>
</Table.Cell>
<Table.Cell
className={`text-center text-xs tabular-nums font-semibold ${match.winningSide === "left" ? "text-success" : "text-danger"}`}
>
{formatElo(match.leftEloAfter)}
</Table.Cell>
<Table.Cell className="text-center tabular-nums text-sm text-muted">
±{match.eloDelta.toFixed(1)}
</Table.Cell>
<Table.Cell
className={`text-center text-xs tabular-nums font-semibold ${match.winningSide === "right" ? "text-success" : "text-danger"}`}
>
{formatElo(match.rightEloAfter)}
</Table.Cell>
<Table.Cell className="text-center">
<Link
href={`/asset/${match.rightId}`}
className="inline-flex justify-center"
>
<img
src={`/img/${match.rightId}`}
alt=""
className="h-8 rounded object-contain"
style={{ aspectRatio: match.rightAspectRatio }}
/>
</Link>
</Table.Cell>
<Table.Cell>
<TimeAgo date={match.createdAt} />
</Table.Cell>
</Table.Row>
))}
</>
);
}
function MatchesTableHeader() {
return (
<Table.Header>
<Table.Column isRowHeader className="text-center">
Left
</Table.Column>
<Table.Column className="text-center tabular-nums">ELO</Table.Column>
<Table.Column className="text-center">±</Table.Column>
<Table.Column className="text-center tabular-nums">ELO</Table.Column>
<Table.Column className="text-center">Right</Table.Column>
<Table.Column>When</Table.Column>
</Table.Header>
);
}
export function MatchesPreview({ data }: { data: MatchEntry[] }) {
return (
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
Recent Matches
</h3>
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="Recent matches">
<MatchesTableHeader />
<Table.Body>
<MatchRows matches={data} />
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
<div className="flex justify-center">
<Link
href="/stats/matches"
className={buttonVariants({ variant: "secondary" })}
>
See more
</Link>
</div>
</div>
);
}
export function MatchesTable({ initialData }: { initialData: MatchEntry[] }) {
const { data, size, setSize, isValidating } = useSWRInfinite(
(index) => `/api/stats/matches?page=${index}`,
fetcher,
{ fallbackData: [initialData], revalidateFirstPage: false },
);
const allMatches: MatchEntry[] = data ? data.flat() : [];
const hasMore = data ? data[data.length - 1]?.length === 20 : false;
const isLoadingMore =
isValidating && data && typeof data[size - 1] === "undefined";
return (
<div className="flex flex-col gap-4">
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="Recent matches">
<MatchesTableHeader />
<Table.Body>
<MatchRows matches={allMatches} />
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
{hasMore && (
<div className="flex justify-center">
<Button
variant="secondary"
onPress={() => setSize(size + 1)}
isDisabled={isLoadingMore}
>
{isLoadingMore ? "Loading..." : "Load more"}
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,63 @@
import Link from "next/link";
import { formatElo } from "@/lib/format";
import type { UpsetEntry } from "@/lib/stats";
export function UpsetsList({ data }: { data: UpsetEntry[] }) {
if (data.length === 0) {
return <p className="text-muted text-sm">No upsets recorded yet.</p>;
}
return (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
Biggest Upsets
</h3>
<div className="flex flex-col gap-2">
{data.map((upset) => (
<div
key={upset.id}
className="relative flex items-center justify-between p-3 rounded-lg bg-surface"
>
{/* Thumbnails at the edges */}
<Link href={`/asset/${upset.winnerId}`} className="inline-flex">
<img
src={`/img/${upset.winnerId}`}
alt=""
className="h-18 rounded object-contain"
style={{ aspectRatio: upset.winnerAspectRatio }}
/>
</Link>
<Link href={`/asset/${upset.loserId}`} className="inline-flex">
<img
src={`/img/${upset.loserId}`}
alt=""
className="h-18 rounded object-contain"
style={{ aspectRatio: upset.loserAspectRatio }}
/>
</Link>
{/* Centered text overlay — "beat" anchored to exact center */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<div className="flex text-sm w-full">
<span className="text-success font-semibold tabular-nums text-right flex-1">
{formatElo(upset.winnerEloBefore)}
</span>
<span className="px-1.5 shrink-0">beat</span>
<span className="text-danger font-semibold tabular-nums text-left flex-1">
{formatElo(upset.loserEloBefore)}
</span>
</div>
<div className="flex text-xs text-muted tabular-nums w-full">
<span className="flex-1 text-right">
+{upset.eloDiff.toFixed(0)}
</span>
<span className="px-1.5 shrink-0">ELO</span>
<span className="flex-1 text-left">gap</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import TimeAgoLib from "javascript-time-ago";
import en from "javascript-time-ago/locale/en";
import { useEffect, useState } from "react";
TimeAgoLib.addDefaultLocale(en);
const timeAgo = new TimeAgoLib("en");
export function TimeAgo({ date }: { date: string }) {
const [text, setText] = useState<string | null>(null);
useEffect(() => {
const d = new Date(date);
setText(timeAgo.format(d));
const interval = setInterval(() => setText(timeAgo.format(d)), 30_000);
return () => clearInterval(interval);
}, [date]);
return (
<time
dateTime={date}
title={new Date(date).toLocaleString()}
className="text-xs text-muted"
>
{text ?? new Date(date).toLocaleDateString()}
</time>
);
}

View File

@@ -15,7 +15,7 @@ const PADDING = 32; // vertical padding
async function postVote(
_key: string,
{ arg }: { arg: { pairingUuid: string; winnerId: number } },
{ arg }: { arg: { pairingUuid: string; winnerId: string } },
): Promise<VoteResult> {
const res = await fetch("/api/vote", {
method: "POST",
@@ -35,7 +35,7 @@ export function VotingArena({
initialPairing: PairingResult;
}) {
const [pairing, setPairing] = useState(initialPairing);
const loadedRef = useRef<Set<number>>(new Set());
const loadedRef = useRef<Set<string>>(new Set());
const [bothLoaded, setBothLoaded] = useState(false);
const [dimensions, setDimensions] = useState({
w1: 0,
@@ -50,6 +50,8 @@ export function VotingArena({
const { trigger, isMutating, error } = useSWRMutation("/api/vote", postVote, {
onSuccess(data) {
if (data.nextPairing) {
loadedRef.current = new Set();
setBothLoaded(false);
setPairing(data.nextPairing);
}
},
@@ -62,15 +64,8 @@ export function VotingArena({
? thumbhashToUrl(pairing.assetB.thumbhash)
: null;
// Reset load tracking when pairing changes
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional re-trigger on pairing change
useEffect(() => {
loadedRef.current = new Set();
setBothLoaded(false);
}, [pairing.assetA.id, pairing.assetB.id]);
const markLoaded = useCallback(
(id: number) => {
(id: string) => {
loadedRef.current.add(id);
if (
loadedRef.current.has(pairing.assetA.id) &&
@@ -114,7 +109,7 @@ export function VotingArena({
// Keyboard controls
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (isMutating) return;
if (isMutating || !bothLoaded) return;
if (e.key === "ArrowLeft" || e.key === "a" || e.key === "A") {
trigger({ pairingUuid: pairing.uuid, winnerId: pairing.assetA.id });
} else if (e.key === "ArrowRight" || e.key === "d" || e.key === "D") {
@@ -123,21 +118,28 @@ export function VotingArena({
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [trigger, isMutating, pairing.uuid, pairing.assetA.id, pairing.assetB.id]);
}, [
trigger,
isMutating,
bothLoaded,
pairing.uuid,
pairing.assetA.id,
pairing.assetB.id,
]);
function vote(winnerId: number) {
if (isMutating) return;
function vote(winnerId: string) {
if (isMutating || !bothLoaded) return;
trigger({ pairingUuid: pairing.uuid, winnerId });
}
return (
<div ref={containerRef} className="flex flex-col h-dvh overflow-hidden">
<div ref={containerRef} className="flex flex-col flex-1 overflow-hidden">
{/* Images row — items-end so images align at the bottom */}
<div className="flex flex-1 items-end justify-center gap-8 px-8 pb-4">
<button
type="button"
onClick={() => vote(pairing.assetA.id)}
disabled={isMutating}
disabled={isMutating || !bothLoaded}
className="relative cursor-pointer transition-opacity hover:opacity-90 disabled:cursor-wait"
>
{placeholderA && !bothLoaded && (
@@ -165,7 +167,7 @@ export function VotingArena({
<button
type="button"
onClick={() => vote(pairing.assetB.id)}
disabled={isMutating}
disabled={isMutating || !bothLoaded}
className="relative cursor-pointer transition-opacity hover:opacity-90 disabled:cursor-wait"
>
{placeholderB && !bothLoaded && (

View File

@@ -1,8 +1,7 @@
import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const assets = sqliteTable("assets", {
id: integer().primaryKey({ autoIncrement: true }),
immichId: text("immich_id").notNull().unique(),
id: text().primaryKey(),
eloProvisional: real("elo_provisional").notNull().default(1500),
eloVerified: real("elo_verified").notNull().default(1500),
matchesProvisional: integer("matches_provisional").notNull().default(0),
@@ -13,7 +12,7 @@ export const assets = sqliteTable("assets", {
lensModel: text("lens_model"),
focalLength: real("focal_length"),
fNumber: real("f_number"),
exposureTime: text("exposure_time"),
exposureTime: real("exposure_time"),
iso: integer(),
width: integer().notNull(),
height: integer().notNull(),
@@ -28,10 +27,10 @@ export const assets = sqliteTable("assets", {
export const pairings = sqliteTable("pairings", {
uuid: text().primaryKey(),
assetAId: integer("asset_a_id")
assetAId: text("asset_a_id")
.notNull()
.references(() => assets.id),
assetBId: integer("asset_b_id")
assetBId: text("asset_b_id")
.notNull()
.references(() => assets.id),
voterIp: text("voter_ip").notNull(),
@@ -47,16 +46,14 @@ export const matches = sqliteTable("matches", {
pairingUuid: text("pairing_uuid")
.notNull()
.references(() => pairings.uuid),
winnerId: integer("winner_id")
winnerId: text("winner_id")
.notNull()
.references(() => assets.id),
loserId: integer("loser_id")
loserId: text("loser_id")
.notNull()
.references(() => assets.id),
winnerEloBefore: real("winner_elo_before").notNull(),
loserEloBefore: real("loser_elo_before").notNull(),
winnerEloAfter: real("winner_elo_after").notNull(),
loserEloAfter: real("loser_elo_after").notNull(),
verified: integer({ mode: "boolean" }).notNull().default(false),
createdAt: text("created_at")
.notNull()
@@ -65,7 +62,7 @@ export const matches = sqliteTable("matches", {
export const eloHistory = sqliteTable("elo_history", {
id: integer().primaryKey({ autoIncrement: true }),
assetId: integer("asset_id")
assetId: text("asset_id")
.notNull()
.references(() => assets.id),
elo: real().notNull(),

View File

@@ -2,7 +2,7 @@
* Format f-number for display: 1.8 → "f/1.8"
*/
export function formatFNumber(fNumber: number | null): string {
if (fNumber == null) return "—";
if (!fNumber) return "—";
return `f/${fNumber}`;
}
@@ -10,23 +10,27 @@ export function formatFNumber(fNumber: number | null): string {
* Format focal length for display: 50 → "50mm"
*/
export function formatFocalLength(focalLength: number | null): string {
if (focalLength == null) return "—";
if (!focalLength) return "—";
return `${focalLength}mm`;
}
/**
* Format exposure time for display. Already a string like "1/80" from Immich.
* Format exposure time (in seconds) for display.
* e.g. 0.0125 → "1/80s", 1.5 → "1.5s"
*/
export function formatExposureTime(exposureTime: string | null): string {
export function formatExposureTime(exposureTime: number | null): string {
if (!exposureTime) return "—";
return `${exposureTime}s`;
if (exposureTime >= 1) return `${exposureTime}s`;
// Express as fraction
const denominator = Math.round(1 / exposureTime);
return `1/${denominator}s`;
}
/**
* Format ISO for display: 640 → "ISO 640"
*/
export function formatIso(iso: number | null): string {
if (iso == null) return "—";
if (!iso) return "—";
return `ISO ${iso}`;
}

View File

@@ -11,8 +11,7 @@ export interface PairingResult {
}
export interface PairingAsset {
id: number;
immichId: string;
id: string;
thumbnailUrl: string;
thumbhash: string | null;
eloProvisional: number;
@@ -21,7 +20,7 @@ export interface PairingAsset {
lensModel: string | null;
focalLength: number | null;
fNumber: number | null;
exposureTime: string | null;
exposureTime: number | null;
iso: number | null;
width: number;
height: number;
@@ -32,8 +31,7 @@ export interface PairingAsset {
function toPairingAsset(asset: typeof assets.$inferSelect): PairingAsset {
return {
id: asset.id,
immichId: asset.immichId,
thumbnailUrl: `/img/${asset.immichId}`,
thumbnailUrl: `/img/${asset.id}`,
thumbhash: asset.thumbhash,
eloProvisional: asset.eloProvisional,
matchesProvisional: asset.matchesProvisional,

329
src/lib/stats.ts Normal file
View File

@@ -0,0 +1,329 @@
import { asc, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { assets, matches, pairings } from "@/db/schema";
import { calculateElo } from "./elo";
const PAGE_SIZE = 20;
// ---------- Leaderboard ----------
export interface LeaderboardEntry {
id: string;
thumbnailUrl: string;
eloProvisional: number;
matchesProvisional: number;
winsProvisional: number;
lossesProvisional: number;
winRate: number;
lensModel: string | null;
focalLength: number | null;
fNumber: number | null;
exposureTime: number | null;
iso: number | null;
aspectRatio: number;
}
const SORT_COLUMNS: Record<string, ReturnType<typeof sql>> = {
elo: sql`${assets.eloProvisional}`,
wins: sql`${assets.winsProvisional}`,
losses: sql`${assets.matchesProvisional} - ${assets.winsProvisional}`,
played: sql`${assets.matchesProvisional}`,
winRate: sql`CASE WHEN ${assets.matchesProvisional} > 0 THEN CAST(${assets.winsProvisional} AS REAL) / ${assets.matchesProvisional} ELSE 0 END`,
focal: sql`${assets.focalLength}`,
aperture: sql`${assets.fNumber}`,
shutter: sql`${assets.exposureTime}`,
iso: sql`${assets.iso}`,
};
export async function getLeaderboard(
page = 0,
sortBy = "elo",
direction = "descending",
limit = PAGE_SIZE,
): Promise<LeaderboardEntry[]> {
const col = SORT_COLUMNS[sortBy] ?? SORT_COLUMNS.elo;
const orderFn = direction === "ascending" ? asc : desc;
const rows = await db
.select()
.from(assets)
.orderBy(orderFn(col))
.limit(limit)
.offset(page * limit);
return rows.map((a) => ({
id: a.id,
thumbnailUrl: `/img/${a.id}`,
eloProvisional: a.eloProvisional,
matchesProvisional: a.matchesProvisional,
winsProvisional: a.winsProvisional,
lossesProvisional: a.matchesProvisional - a.winsProvisional,
winRate:
a.matchesProvisional > 0 ? a.winsProvisional / a.matchesProvisional : 0,
lensModel: a.lensModel,
focalLength: a.focalLength,
fNumber: a.fNumber,
exposureTime: a.exposureTime,
iso: a.iso,
aspectRatio: a.aspectRatio,
}));
}
// ---------- Recent Matches ----------
export interface MatchEntry {
id: number;
leftId: string;
rightId: string;
leftAspectRatio: number;
rightAspectRatio: number;
leftEloAfter: number;
rightEloAfter: number;
winningSide: "left" | "right";
eloDelta: number;
createdAt: string;
}
export async function getRecentMatches(
page = 0,
limit = PAGE_SIZE,
): Promise<MatchEntry[]> {
const left = db
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
})
.from(assets)
.as("left");
const right = db
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
})
.from(assets)
.as("right");
const rows = await db
.select({
id: matches.id,
winnerId: matches.winnerId,
leftId: pairings.assetAId,
rightId: pairings.assetBId,
leftAspectRatio: left.aspectRatio,
rightAspectRatio: right.aspectRatio,
winnerEloBefore: matches.winnerEloBefore,
loserEloBefore: matches.loserEloBefore,
createdAt: matches.createdAt,
})
.from(matches)
.innerJoin(pairings, eq(matches.pairingUuid, pairings.uuid))
.innerJoin(left, eq(pairings.assetAId, left.id))
.innerJoin(right, eq(pairings.assetBId, right.id))
.orderBy(desc(matches.createdAt))
.limit(limit)
.offset(page * limit);
return rows.map((r) => {
const { newWinnerElo, newLoserElo } = calculateElo(
r.winnerEloBefore,
r.loserEloBefore,
);
const winningSide = r.winnerId === r.leftId ? "left" : "right";
return {
id: r.id,
leftId: r.leftId,
rightId: r.rightId,
leftAspectRatio: r.leftAspectRatio,
rightAspectRatio: r.rightAspectRatio,
leftEloAfter: winningSide === "left" ? newWinnerElo : newLoserElo,
rightEloAfter: winningSide === "right" ? newWinnerElo : newLoserElo,
winningSide,
eloDelta: newWinnerElo - r.winnerEloBefore,
createdAt: r.createdAt,
};
});
}
// ---------- Biggest Upsets ----------
export interface UpsetEntry {
id: number;
winnerId: string;
loserId: string;
winnerAspectRatio: number;
loserAspectRatio: number;
winnerEloBefore: number;
loserEloBefore: number;
eloDiff: number;
createdAt: string;
}
export async function getBiggestUpsets(limit = 10): Promise<UpsetEntry[]> {
const winner = db
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
})
.from(assets)
.as("winner");
const loser = db
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
})
.from(assets)
.as("loser");
// An upset is when the winner had lower ELO than the loser
const rows = await db
.select({
id: matches.id,
winnerId: matches.winnerId,
loserId: matches.loserId,
winnerAspectRatio: winner.aspectRatio,
loserAspectRatio: loser.aspectRatio,
winnerEloBefore: matches.winnerEloBefore,
loserEloBefore: matches.loserEloBefore,
eloDiff: sql<number>`${matches.loserEloBefore} - ${matches.winnerEloBefore}`,
createdAt: matches.createdAt,
})
.from(matches)
.innerJoin(winner, eq(matches.winnerId, winner.id))
.innerJoin(loser, eq(matches.loserId, loser.id))
.where(sql`${matches.loserEloBefore} > ${matches.winnerEloBefore}`)
.orderBy(desc(sql`${matches.loserEloBefore} - ${matches.winnerEloBefore}`))
.limit(limit);
return rows;
}
// ---------- Most Controversial ----------
export interface ControversialEntry {
id: string;
thumbnailUrl: string;
aspectRatio: number;
eloProvisional: number;
matchesProvisional: number;
winRate: number;
}
export async function getMostControversial(
limit = 10,
): Promise<ControversialEntry[]> {
// Controversial = win rate closest to 50%, weighted by match count
// Assets with 0 matches get win rate 0.5 (maximally uncertain)
const rows = await db
.select()
.from(assets)
.orderBy(
sql`ABS(CASE WHEN ${assets.matchesProvisional} > 0 THEN CAST(${assets.winsProvisional} AS REAL) / ${assets.matchesProvisional} ELSE 0.5 END - 0.5)`,
desc(assets.matchesProvisional),
)
.limit(limit);
return rows.map((a) => ({
id: a.id,
thumbnailUrl: `/img/${a.id}`,
aspectRatio: a.aspectRatio,
eloProvisional: a.eloProvisional,
matchesProvisional: a.matchesProvisional,
winRate:
a.matchesProvisional > 0 ? a.winsProvisional / a.matchesProvisional : 0.5,
}));
}
// ---------- ELO Distribution ----------
export interface EloDistributionBucket {
bucket: number; // lower bound of the bucket
count: number;
}
export async function getEloDistribution(
bucketSize = 25,
): Promise<EloDistributionBucket[]> {
const rows = await db
.select({
bucket: sql<number>`CAST(${assets.eloProvisional} / ${bucketSize} AS INTEGER) * ${bucketSize}`,
count: sql<number>`COUNT(*)`,
})
.from(assets)
.groupBy(
sql`CAST(${assets.eloProvisional} / ${bucketSize} AS INTEGER) * ${bucketSize}`,
)
.orderBy(
sql`CAST(${assets.eloProvisional} / ${bucketSize} AS INTEGER) * ${bucketSize}`,
);
if (rows.length === 0) return [];
// Fill in empty buckets between min and max
const min = rows[0].bucket;
const max = rows[rows.length - 1].bucket;
const countMap = new Map(rows.map((r) => [r.bucket, r.count]));
const filled: EloDistributionBucket[] = [];
for (let b = min; b <= max; b += bucketSize) {
filled.push({ bucket: b, count: countMap.get(b) ?? 0 });
}
return filled;
}
// ---------- Lens Stats ----------
export interface LensStats {
lensModel: string;
assetCount: number;
avgElo: number;
totalMatches: number;
totalWins: number;
winRate: number;
bestAssetId: string;
bestAssetAspectRatio: number;
bestElo: number;
}
export async function getLensStats(): Promise<LensStats[]> {
// Two-step: aggregate per lens, then find the best asset per lens
const agg = await db
.select({
lensModel: assets.lensModel,
assetCount: sql<number>`COUNT(*)`,
avgElo: sql<number>`AVG(${assets.eloProvisional})`,
totalMatches: sql<number>`SUM(${assets.matchesProvisional})`,
totalWins: sql<number>`SUM(${assets.winsProvisional})`,
})
.from(assets)
.where(sql`${assets.lensModel} IS NOT NULL`)
.groupBy(assets.lensModel)
.orderBy(desc(sql`AVG(${assets.eloProvisional})`));
const results: LensStats[] = [];
for (const row of agg) {
const [best] = await db
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
elo: assets.eloProvisional,
})
.from(assets)
.where(eq(assets.lensModel, row.lensModel!))
.orderBy(desc(assets.eloProvisional))
.limit(1);
results.push({
lensModel: row.lensModel!,
assetCount: row.assetCount,
avgElo: row.avgElo,
totalMatches: row.totalMatches,
totalWins: row.totalWins,
winRate: row.totalMatches > 0 ? row.totalWins / row.totalMatches : 0,
bestAssetId: best.id,
bestAssetAspectRatio: best.aspectRatio,
bestElo: best.elo,
});
}
return results;
}

View File

@@ -11,19 +11,28 @@ const STAR_TO_ELO: Record<number, number> = {
5: 1800,
};
function parseExposureTime(raw: string | null): number | null {
if (!raw) return null;
if (raw.includes("/")) {
const [num, den] = raw.split("/");
return Number(num) / Number(den);
}
return Number.parseFloat(raw) || null;
}
function assetToRow(asset: ImmichAsset) {
const exif = asset.exifInfo;
const initialElo = STAR_TO_ELO[exif.rating ?? 3] ?? 1500;
return {
immichId: asset.id,
id: asset.id,
eloProvisional: initialElo,
eloVerified: initialElo,
thumbhash: asset.thumbhash,
lensModel: exif.lensModel,
focalLength: exif.focalLength,
fNumber: exif.fNumber,
exposureTime: exif.exposureTime,
exposureTime: parseExposureTime(exif.exposureTime),
iso: exif.iso,
width: asset.width,
height: asset.height,
@@ -35,8 +44,8 @@ function assetToRow(asset: ImmichAsset) {
export async function syncAssets() {
const immichAssets = await fetchAlbumAssets();
const existing = await db.select({ immichId: assets.immichId }).from(assets);
const existingIds = new Set(existing.map((a) => a.immichId));
const existing = await db.select({ id: assets.id }).from(assets);
const existingIds = new Set(existing.map((a) => a.id));
let inserted = 0;
let updated = 0;
@@ -62,7 +71,7 @@ export async function syncAssets() {
takenAt: row.takenAt,
active: true,
})
.where(eq(assets.immichId, asset.id));
.where(eq(assets.id, asset.id));
updated++;
} else {
await db.insert(assets).values(row);
@@ -72,7 +81,7 @@ export async function syncAssets() {
// Soft-delete assets no longer in the album
const immichIds = new Set(immichAssets.map((a) => a.id));
const toDeactivate = existing.filter((a) => !immichIds.has(a.immichId));
const toDeactivate = existing.filter((a) => !immichIds.has(a.id));
let deactivated = 0;
if (toDeactivate.length > 0) {
await db
@@ -80,8 +89,8 @@ export async function syncAssets() {
.set({ active: false })
.where(
inArray(
assets.immichId,
toDeactivate.map((a) => a.immichId),
assets.id,
toDeactivate.map((a) => a.id),
),
);
deactivated = toDeactivate.length;

View File

@@ -5,20 +5,12 @@ import { calculateElo } from "./elo";
import { generatePairing, type PairingResult } from "./pairing";
export interface VoteResult {
match: {
winnerId: number;
loserId: number;
winnerEloBefore: number;
loserEloBefore: number;
winnerEloAfter: number;
loserEloAfter: number;
};
nextPairing: PairingResult | null;
}
export async function submitVote(
pairingUuid: string,
winnerId: number,
winnerId: string,
voterIp: string,
): Promise<VoteResult> {
// Fetch and validate the pairing
@@ -77,8 +69,6 @@ export async function submitVote(
loserId,
winnerEloBefore: winner.eloProvisional,
loserEloBefore: loser.eloProvisional,
winnerEloAfter: newWinnerElo,
loserEloAfter: newLoserElo,
})
.returning();
@@ -119,15 +109,5 @@ export async function submitVote(
// Generate next pairing
const nextPairing = await generatePairing(voterIp);
return {
match: {
winnerId,
loserId,
winnerEloBefore: winner.eloProvisional,
loserEloBefore: loser.eloProvisional,
winnerEloAfter: newWinnerElo,
loserEloAfter: newLoserElo,
},
nextPairing,
};
return { nextPairing };
}