Add stats page
This commit is contained in:
5
bun.lock
5
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
13
src/app/api/stats/leaderboard/route.ts
Normal file
13
src/app/api/stats/leaderboard/route.ts
Normal 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);
|
||||
}
|
||||
8
src/app/api/stats/matches/route.ts
Normal file
8
src/app/api/stats/matches/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
15
src/app/stats/leaderboard/page.tsx
Normal file
15
src/app/stats/leaderboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/app/stats/matches/page.tsx
Normal file
15
src/app/stats/matches/page.tsx
Normal 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
53
src/app/stats/page.tsx
Normal 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
21
src/components/nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/stats/controversial-list.tsx
Normal file
52
src/components/stats/controversial-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/components/stats/elo-histogram.tsx
Normal file
59
src/components/stats/elo-histogram.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
269
src/components/stats/leaderboard-table.tsx
Normal file
269
src/components/stats/leaderboard-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/stats/lens-table.tsx
Normal file
66
src/components/stats/lens-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
src/components/stats/matches-table.tsx
Normal file
146
src/components/stats/matches-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/components/stats/upsets-list.tsx
Normal file
63
src/components/stats/upsets-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/time-ago.tsx
Normal file
29
src/components/time-ago.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
329
src/lib/stats.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user