Add asset page
This commit is contained in:
12
src/app/api/asset/[id]/matches/route.ts
Normal file
12
src/app/api/asset/[id]/matches/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { getAssetMatchHistory } from "@/lib/asset-queries";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const page = Number(request.nextUrl.searchParams.get("page") ?? "0");
|
||||
const data = await getAssetMatchHistory(id, page);
|
||||
return Response.json(data);
|
||||
}
|
||||
99
src/app/asset/[id]/page.tsx
Normal file
99
src/app/asset/[id]/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { EloChart } from "@/components/asset/elo-chart";
|
||||
import { HeadToHead } from "@/components/asset/head-to-head";
|
||||
import { AssetMatchHistory } from "@/components/asset/match-history";
|
||||
import { EloNeighbors } from "@/components/asset/neighbors";
|
||||
import { StatsBlock } from "@/components/asset/stats-block";
|
||||
import {
|
||||
getAssetById,
|
||||
getAssetEloHistory,
|
||||
getAssetMatchHistory,
|
||||
getEloNeighbors,
|
||||
getHeadToHead,
|
||||
getWinTrend,
|
||||
} from "@/lib/asset-queries";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export default async function AssetPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const asset = await getAssetById(id);
|
||||
if (!asset) notFound();
|
||||
|
||||
const [eloHistory, matchHistory, h2h, neighbors, winTrend] =
|
||||
await Promise.all([
|
||||
getAssetEloHistory(id),
|
||||
getAssetMatchHistory(id, 0),
|
||||
getHeadToHead(id),
|
||||
getEloNeighbors(id, asset.eloProvisional),
|
||||
getWinTrend(id),
|
||||
]);
|
||||
|
||||
const isHorizontal = asset.aspectRatio >= 1;
|
||||
|
||||
const statsProps = {
|
||||
eloProvisional: asset.eloProvisional,
|
||||
winsProvisional: asset.winsProvisional,
|
||||
matchesProvisional: asset.matchesProvisional,
|
||||
lensModel: asset.lensModel,
|
||||
focalLength: asset.focalLength,
|
||||
fNumber: asset.fNumber,
|
||||
exposureTime: asset.exposureTime,
|
||||
iso: asset.iso,
|
||||
winTrend,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col flex-1">
|
||||
{/* Blurred background */}
|
||||
<div className="absolute inset-0 overflow-hidden -z-10">
|
||||
<img
|
||||
src={`/img/${asset.id}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover blur-3xl opacity-20 scale-110"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 max-w-7xl mx-auto w-full px-4 py-8 gap-10">
|
||||
{/* Hero: image + stats */}
|
||||
{isHorizontal ? (
|
||||
<div className="flex flex-col gap-8">
|
||||
<img
|
||||
src={`/img/${asset.id}`}
|
||||
alt=""
|
||||
className="w-full rounded-lg object-contain"
|
||||
style={{ aspectRatio: asset.aspectRatio }}
|
||||
/>
|
||||
<StatsBlock {...statsProps} layout="horizontal" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col md:flex-row gap-8 items-center md:items-start">
|
||||
<img
|
||||
src={`/img/${asset.id}`}
|
||||
alt=""
|
||||
className="max-h-[32rem] rounded-lg object-contain"
|
||||
style={{ aspectRatio: asset.aspectRatio }}
|
||||
/>
|
||||
<StatsBlock {...statsProps} layout="vertical" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ELO Chart */}
|
||||
<EloChart data={eloHistory} initialElo={asset.eloProvisional} />
|
||||
|
||||
{/* Head to Head */}
|
||||
<HeadToHead data={h2h} />
|
||||
|
||||
{/* Match History */}
|
||||
<AssetMatchHistory assetId={id} initialData={matchHistory} />
|
||||
|
||||
{/* Neighbors */}
|
||||
<EloNeighbors data={neighbors} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/components/asset/elo-chart.tsx
Normal file
114
src/components/asset/elo-chart.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { EloHistoryEntry } from "@/lib/asset-queries";
|
||||
|
||||
const HEIGHT = 200;
|
||||
const PAD_Y = 20;
|
||||
const PAD_LEFT = 40;
|
||||
const PAD_RIGHT = 10;
|
||||
|
||||
export function EloChart({
|
||||
data,
|
||||
initialElo,
|
||||
}: {
|
||||
data: EloHistoryEntry[];
|
||||
initialElo: number;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
setWidth(entries[0].contentRect.width);
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
if (data.length === 0) {
|
||||
return <p className="text-muted text-sm">No match history yet.</p>;
|
||||
}
|
||||
|
||||
const points = [{ elo: initialElo, createdAt: "" }, ...data];
|
||||
const elos = points.map((d) => d.elo);
|
||||
const min = Math.min(...elos);
|
||||
const max = Math.max(...elos);
|
||||
const range = max - min || 1;
|
||||
|
||||
const chartW = width - PAD_LEFT - PAD_RIGHT;
|
||||
const chartH = HEIGHT - PAD_Y * 2;
|
||||
|
||||
const xStep = points.length > 1 ? chartW / (points.length - 1) : 0;
|
||||
|
||||
const pathPoints = points.map((p, i) => {
|
||||
const x = PAD_LEFT + i * xStep;
|
||||
const y = PAD_Y + chartH - ((p.elo - min) / range) * chartH;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const polyline = pathPoints.join(" ");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
|
||||
ELO Over Time
|
||||
</h3>
|
||||
<div ref={containerRef} className="w-full">
|
||||
{width > 0 && (
|
||||
<svg width={width} height={HEIGHT}>
|
||||
{/* Grid lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((pct) => {
|
||||
const y = PAD_Y + chartH - pct * chartH;
|
||||
const label = Math.round(min + pct * range);
|
||||
return (
|
||||
<g key={pct}>
|
||||
<line
|
||||
x1={PAD_LEFT}
|
||||
y1={y}
|
||||
x2={width - PAD_RIGHT}
|
||||
y2={y}
|
||||
stroke="var(--separator)"
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
<text
|
||||
x={PAD_LEFT - 4}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
className="fill-muted"
|
||||
fontSize={11}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* Line */}
|
||||
<polyline
|
||||
points={polyline}
|
||||
fill="none"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Last point marker */}
|
||||
{points.length > 0 && (
|
||||
<circle
|
||||
cx={PAD_LEFT + (points.length - 1) * xStep}
|
||||
cy={
|
||||
PAD_Y +
|
||||
chartH -
|
||||
((points[points.length - 1].elo - min) / range) * chartH
|
||||
}
|
||||
r={3}
|
||||
className="fill-accent"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/components/asset/head-to-head.tsx
Normal file
36
src/components/asset/head-to-head.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import Link from "next/link";
|
||||
import type { HeadToHeadEntry } from "@/lib/asset-queries";
|
||||
|
||||
export function HeadToHead({ data }: { data: HeadToHeadEntry[] }) {
|
||||
if (data.length === 0) {
|
||||
return <p className="text-muted text-sm">No opponents yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
|
||||
Head to Head
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{data.map((h2h) => (
|
||||
<Link
|
||||
key={h2h.opponentId}
|
||||
href={`/asset/${h2h.opponentId}`}
|
||||
className="flex flex-col items-center gap-1.5 p-2 rounded-lg hover:bg-default transition-colors"
|
||||
>
|
||||
<img
|
||||
src={`/img/${h2h.opponentId}`}
|
||||
alt=""
|
||||
className="h-16 rounded object-contain"
|
||||
style={{ aspectRatio: h2h.opponentAspectRatio }}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 text-sm font-semibold tabular-nums">
|
||||
<span className="text-success">{h2h.wins}W</span>
|
||||
<span className="text-danger">{h2h.losses}L</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/asset/match-history.tsx
Normal file
102
src/components/asset/match-history.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Table } from "@heroui/react";
|
||||
import Link from "next/link";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { TimeAgo } from "@/components/time-ago";
|
||||
import type { AssetMatchEntry } from "@/lib/asset-queries";
|
||||
import { formatElo } from "@/lib/format";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||
|
||||
export function AssetMatchHistory({
|
||||
assetId,
|
||||
initialData,
|
||||
}: {
|
||||
assetId: string;
|
||||
initialData: AssetMatchEntry[];
|
||||
}) {
|
||||
const { data, size, setSize, isValidating } = useSWRInfinite(
|
||||
(index) => `/api/asset/${assetId}/matches?page=${index}`,
|
||||
fetcher,
|
||||
{ fallbackData: [initialData], revalidateFirstPage: false },
|
||||
);
|
||||
|
||||
const allMatches: AssetMatchEntry[] = 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">
|
||||
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
|
||||
Match History
|
||||
</h3>
|
||||
<Table>
|
||||
<Table.ScrollContainer>
|
||||
<Table.Content aria-label="Match history">
|
||||
<Table.Header>
|
||||
<Table.Column isRowHeader>Result</Table.Column>
|
||||
<Table.Column className="text-center">Opponent</Table.Column>
|
||||
<Table.Column>ELO</Table.Column>
|
||||
<Table.Column>Delta</Table.Column>
|
||||
<Table.Column>When</Table.Column>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{allMatches.map((match) => (
|
||||
<Table.Row key={match.id}>
|
||||
<Table.Cell>
|
||||
<span
|
||||
className={`text-sm font-bold ${match.won ? "text-success" : "text-danger"}`}
|
||||
>
|
||||
{match.won ? "W" : "L"}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="flex justify-center">
|
||||
<Link
|
||||
href={`/asset/${match.opponentId}`}
|
||||
className="inline-flex justify-center"
|
||||
>
|
||||
<img
|
||||
src={`/img/${match.opponentId}`}
|
||||
alt=""
|
||||
className="h-8 rounded object-contain"
|
||||
style={{ aspectRatio: match.opponentAspectRatio }}
|
||||
/>
|
||||
</Link>
|
||||
</Table.Cell>
|
||||
<Table.Cell className="tabular-nums text-xs">
|
||||
{formatElo(match.assetEloBefore)} →{" "}
|
||||
{formatElo(match.assetEloAfter)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span
|
||||
className={`tabular-nums text-xs font-semibold ${match.won ? "text-success" : "text-danger"}`}
|
||||
>
|
||||
{match.won ? "+" : ""}
|
||||
{match.eloDelta.toFixed(1)}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<TimeAgo date={match.createdAt} />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
34
src/components/asset/neighbors.tsx
Normal file
34
src/components/asset/neighbors.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import Link from "next/link";
|
||||
import type { EloNeighbor } from "@/lib/asset-queries";
|
||||
import { formatElo } from "@/lib/format";
|
||||
|
||||
export function EloNeighbors({ data }: { data: EloNeighbor[] }) {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
|
||||
Similar ELO
|
||||
</h3>
|
||||
<div className="flex gap-3">
|
||||
{data.map((n) => (
|
||||
<Link
|
||||
key={n.id}
|
||||
href={`/asset/${n.id}`}
|
||||
className="flex flex-col items-center gap-1 p-2 rounded-lg hover:bg-default transition-colors"
|
||||
>
|
||||
<img
|
||||
src={`/img/${n.id}`}
|
||||
alt=""
|
||||
className="h-16 rounded object-contain"
|
||||
style={{ aspectRatio: n.aspectRatio }}
|
||||
/>
|
||||
<span className="text-xs tabular-nums font-semibold">
|
||||
{formatElo(n.eloProvisional)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
src/components/asset/stats-block.tsx
Normal file
125
src/components/asset/stats-block.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { WinTrendDisplay } from "@/components/asset/win-trend";
|
||||
import {
|
||||
formatElo,
|
||||
formatExposureTime,
|
||||
formatFNumber,
|
||||
formatFocalLength,
|
||||
formatIso,
|
||||
} from "@/lib/format";
|
||||
import type { WinTrend } from "@/lib/asset-queries";
|
||||
|
||||
interface StatsBlockProps {
|
||||
eloProvisional: number;
|
||||
winsProvisional: number;
|
||||
matchesProvisional: number;
|
||||
lensModel: string | null;
|
||||
focalLength: number | null;
|
||||
fNumber: number | null;
|
||||
exposureTime: number | null;
|
||||
iso: number | null;
|
||||
winTrend: WinTrend;
|
||||
layout: "vertical" | "horizontal";
|
||||
}
|
||||
|
||||
function ExifLine({
|
||||
lensModel,
|
||||
focalLength,
|
||||
fNumber,
|
||||
exposureTime,
|
||||
iso,
|
||||
}: Pick<
|
||||
StatsBlockProps,
|
||||
"lensModel" | "focalLength" | "fNumber" | "exposureTime" | "iso"
|
||||
>) {
|
||||
const exifParts = [
|
||||
focalLength != null && formatFocalLength(focalLength),
|
||||
fNumber != null && formatFNumber(fNumber),
|
||||
exposureTime != null && formatExposureTime(exposureTime),
|
||||
iso != null && formatIso(iso),
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-sm font-mono text-muted">
|
||||
{lensModel && <span>{lensModel}</span>}
|
||||
{exifParts.length > 0 && <span>{exifParts.join(" \u2022 ")}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsBlock(props: StatsBlockProps) {
|
||||
const losses = props.matchesProvisional - props.winsProvisional;
|
||||
|
||||
if (props.layout === "horizontal") {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-5xl font-bold tabular-nums tracking-tight">
|
||||
{formatElo(props.eloProvisional)}
|
||||
</span>
|
||||
<span className="text-sm text-muted uppercase tracking-widest">
|
||||
ELO
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-success text-lg font-semibold tabular-nums">
|
||||
{props.winsProvisional}W
|
||||
</span>
|
||||
<span className="text-danger text-lg font-semibold tabular-nums">
|
||||
{losses}L
|
||||
</span>
|
||||
<span className="text-muted text-sm ml-2">
|
||||
{props.matchesProvisional} played
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ExifLine
|
||||
lensModel={props.lensModel}
|
||||
focalLength={props.focalLength}
|
||||
fNumber={props.fNumber}
|
||||
exposureTime={props.exposureTime}
|
||||
iso={props.iso}
|
||||
/>
|
||||
|
||||
<WinTrendDisplay data={props.winTrend} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-5xl font-bold tabular-nums tracking-tight">
|
||||
{formatElo(props.eloProvisional)}
|
||||
</span>
|
||||
<span className="text-sm text-muted uppercase tracking-widest">
|
||||
ELO
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-success text-lg font-semibold tabular-nums">
|
||||
{props.winsProvisional}W
|
||||
</span>
|
||||
<span className="text-danger text-lg font-semibold tabular-nums">
|
||||
{losses}L
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted text-sm">
|
||||
{props.matchesProvisional} played
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ExifLine
|
||||
lensModel={props.lensModel}
|
||||
focalLength={props.focalLength}
|
||||
fNumber={props.fNumber}
|
||||
exposureTime={props.exposureTime}
|
||||
iso={props.iso}
|
||||
/>
|
||||
|
||||
<WinTrendDisplay data={props.winTrend} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/components/asset/win-trend.tsx
Normal file
27
src/components/asset/win-trend.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { WinTrend } from "@/lib/asset-queries";
|
||||
|
||||
export function WinTrendDisplay({ data }: { data: WinTrend }) {
|
||||
const items = [
|
||||
{ label: "Last 10", value: data.last10 },
|
||||
{ label: "Last 20", value: data.last20 },
|
||||
{ label: "All time", value: data.allTime },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-muted uppercase tracking-widest">
|
||||
Win Rate Trend
|
||||
</h3>
|
||||
<div className="flex gap-6">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex flex-col items-center">
|
||||
<span className="text-2xl font-bold tabular-nums">
|
||||
{(item.value * 100).toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-xs text-muted">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { thumbhashToUrl } from "@/lib/thumbhash-url";
|
||||
import type { VoteResult } from "@/lib/vote";
|
||||
import { AssetStats } from "./asset-stats";
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const GAP = 32; // px between images
|
||||
const STATS_HEIGHT = 120; // height reserved for kbd + stats below images
|
||||
const PADDING = 32; // vertical padding
|
||||
@@ -109,7 +110,7 @@ export function VotingArena({
|
||||
// Keyboard controls
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (isMutating || !bothLoaded) return;
|
||||
if (isMutating || (!isDev && !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") {
|
||||
@@ -128,7 +129,7 @@ export function VotingArena({
|
||||
]);
|
||||
|
||||
function vote(winnerId: string) {
|
||||
if (isMutating || !bothLoaded) return;
|
||||
if (isMutating || (!isDev && !bothLoaded)) return;
|
||||
trigger({ pairingUuid: pairing.uuid, winnerId });
|
||||
}
|
||||
|
||||
@@ -139,7 +140,7 @@ export function VotingArena({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => vote(pairing.assetA.id)}
|
||||
disabled={isMutating || !bothLoaded}
|
||||
disabled={isMutating || (!isDev && !bothLoaded)}
|
||||
className="relative cursor-pointer transition-opacity hover:opacity-90 disabled:cursor-wait"
|
||||
>
|
||||
{placeholderA && !bothLoaded && (
|
||||
@@ -167,7 +168,7 @@ export function VotingArena({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => vote(pairing.assetB.id)}
|
||||
disabled={isMutating || !bothLoaded}
|
||||
disabled={isMutating || (!isDev && !bothLoaded)}
|
||||
className="relative cursor-pointer transition-opacity hover:opacity-90 disabled:cursor-wait"
|
||||
>
|
||||
{placeholderB && !bothLoaded && (
|
||||
|
||||
226
src/lib/asset-queries.ts
Normal file
226
src/lib/asset-queries.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { assets, eloHistory, matches } from "@/db/schema";
|
||||
import { calculateElo } from "./elo";
|
||||
|
||||
// ---------- Asset by ID ----------
|
||||
|
||||
export async function getAssetById(id: string) {
|
||||
const [asset] = await db.select().from(assets).where(eq(assets.id, id));
|
||||
return asset ?? null;
|
||||
}
|
||||
|
||||
// ---------- Match History ----------
|
||||
|
||||
export interface AssetMatchEntry {
|
||||
id: number;
|
||||
opponentId: string;
|
||||
opponentAspectRatio: number;
|
||||
won: boolean;
|
||||
assetEloBefore: number;
|
||||
assetEloAfter: number;
|
||||
opponentEloBefore: number;
|
||||
opponentEloAfter: number;
|
||||
eloDelta: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function getAssetMatchHistory(
|
||||
assetId: string,
|
||||
page = 0,
|
||||
limit = 20,
|
||||
): Promise<AssetMatchEntry[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(matches)
|
||||
.where(or(eq(matches.winnerId, assetId), eq(matches.loserId, assetId)))
|
||||
.orderBy(desc(matches.createdAt))
|
||||
.limit(limit)
|
||||
.offset(page * limit);
|
||||
|
||||
// Fetch opponent aspect ratios
|
||||
const opponentIds = rows.map((r) =>
|
||||
r.winnerId === assetId ? r.loserId : r.winnerId,
|
||||
);
|
||||
const opponents = await db
|
||||
.select({ id: assets.id, aspectRatio: assets.aspectRatio })
|
||||
.from(assets)
|
||||
.where(
|
||||
sql`${assets.id} IN (${sql.join(
|
||||
opponentIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
const arMap = new Map(opponents.map((o) => [o.id, o.aspectRatio]));
|
||||
|
||||
return rows.map((r) => {
|
||||
const won = r.winnerId === assetId;
|
||||
const { newWinnerElo, newLoserElo } = calculateElo(
|
||||
r.winnerEloBefore,
|
||||
r.loserEloBefore,
|
||||
);
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
opponentId: won ? r.loserId : r.winnerId,
|
||||
opponentAspectRatio: arMap.get(won ? r.loserId : r.winnerId) ?? 1,
|
||||
won,
|
||||
assetEloBefore: won ? r.winnerEloBefore : r.loserEloBefore,
|
||||
assetEloAfter: won ? newWinnerElo : newLoserElo,
|
||||
opponentEloBefore: won ? r.loserEloBefore : r.winnerEloBefore,
|
||||
opponentEloAfter: won ? newLoserElo : newWinnerElo,
|
||||
eloDelta: won
|
||||
? newWinnerElo - r.winnerEloBefore
|
||||
: newLoserElo - r.loserEloBefore,
|
||||
createdAt: r.createdAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- ELO History ----------
|
||||
|
||||
export interface EloHistoryEntry {
|
||||
elo: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function getAssetEloHistory(
|
||||
assetId: string,
|
||||
): Promise<EloHistoryEntry[]> {
|
||||
return db
|
||||
.select({ elo: eloHistory.elo, createdAt: eloHistory.createdAt })
|
||||
.from(eloHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(eloHistory.assetId, assetId),
|
||||
eq(eloHistory.eloType, "provisional"),
|
||||
),
|
||||
)
|
||||
.orderBy(eloHistory.createdAt);
|
||||
}
|
||||
|
||||
// ---------- Head to Head ----------
|
||||
|
||||
export interface HeadToHeadEntry {
|
||||
opponentId: string;
|
||||
opponentAspectRatio: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function getHeadToHead(
|
||||
assetId: string,
|
||||
): Promise<HeadToHeadEntry[]> {
|
||||
// Get all matches involving this asset
|
||||
const allMatches = await db
|
||||
.select({
|
||||
winnerId: matches.winnerId,
|
||||
loserId: matches.loserId,
|
||||
})
|
||||
.from(matches)
|
||||
.where(or(eq(matches.winnerId, assetId), eq(matches.loserId, assetId)));
|
||||
|
||||
// Tally per opponent
|
||||
const tally = new Map<string, { wins: number; losses: number }>();
|
||||
for (const m of allMatches) {
|
||||
const won = m.winnerId === assetId;
|
||||
const opponentId = won ? m.loserId : m.winnerId;
|
||||
const entry = tally.get(opponentId) ?? { wins: 0, losses: 0 };
|
||||
if (won) entry.wins++;
|
||||
else entry.losses++;
|
||||
tally.set(opponentId, entry);
|
||||
}
|
||||
|
||||
// Fetch opponent aspect ratios
|
||||
const opponentIds = [...tally.keys()];
|
||||
if (opponentIds.length === 0) return [];
|
||||
|
||||
const opponents = await db
|
||||
.select({ id: assets.id, aspectRatio: assets.aspectRatio })
|
||||
.from(assets)
|
||||
.where(
|
||||
sql`${assets.id} IN (${sql.join(
|
||||
opponentIds.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
const arMap = new Map(opponents.map((o) => [o.id, o.aspectRatio]));
|
||||
|
||||
return [...tally.entries()]
|
||||
.map(([opponentId, { wins, losses }]) => ({
|
||||
opponentId,
|
||||
opponentAspectRatio: arMap.get(opponentId) ?? 1,
|
||||
wins,
|
||||
losses,
|
||||
total: wins + losses,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
}
|
||||
|
||||
// ---------- ELO Neighbors ----------
|
||||
|
||||
export interface EloNeighbor {
|
||||
id: string;
|
||||
aspectRatio: number;
|
||||
eloProvisional: number;
|
||||
}
|
||||
|
||||
export async function getEloNeighbors(
|
||||
assetId: string,
|
||||
elo: number,
|
||||
limit = 4,
|
||||
): Promise<EloNeighbor[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: assets.id,
|
||||
aspectRatio: assets.aspectRatio,
|
||||
eloProvisional: assets.eloProvisional,
|
||||
})
|
||||
.from(assets)
|
||||
.where(sql`${assets.id} != ${assetId}`)
|
||||
.orderBy(sql`ABS(${assets.eloProvisional} - ${elo})`)
|
||||
.limit(limit);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ---------- Win Trend ----------
|
||||
|
||||
export interface WinTrend {
|
||||
last10: number;
|
||||
last20: number;
|
||||
allTime: number;
|
||||
}
|
||||
|
||||
export async function getWinTrend(assetId: string): Promise<WinTrend> {
|
||||
const recentMatches = await db
|
||||
.select({ winnerId: matches.winnerId })
|
||||
.from(matches)
|
||||
.where(or(eq(matches.winnerId, assetId), eq(matches.loserId, assetId)))
|
||||
.orderBy(desc(matches.createdAt))
|
||||
.limit(20);
|
||||
|
||||
const last10 = recentMatches.slice(0, 10);
|
||||
const last20 = recentMatches;
|
||||
|
||||
const winRate = (list: typeof recentMatches) => {
|
||||
if (list.length === 0) return 0;
|
||||
return list.filter((m) => m.winnerId === assetId).length / list.length;
|
||||
};
|
||||
|
||||
// All time from asset stats (faster than counting all matches)
|
||||
const [asset] = await db
|
||||
.select({
|
||||
matches: assets.matchesProvisional,
|
||||
wins: assets.winsProvisional,
|
||||
})
|
||||
.from(assets)
|
||||
.where(eq(assets.id, assetId));
|
||||
|
||||
return {
|
||||
last10: winRate(last10),
|
||||
last20: winRate(last20),
|
||||
allTime: asset.matches > 0 ? asset.wins / asset.matches : 0,
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export function checkRateLimit(ip: string): {
|
||||
allowed: boolean;
|
||||
retryAfterMs: number;
|
||||
} {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return { allowed: true, retryAfterMs: 0 };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const entry = store.get(ip);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user