This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { toErrorMessage } from '$lib/api/errors';
|
||||
import type { PlayerOptionDTO, PlayerVersusDTO } from '$lib/api/schema-helpers';
|
||||
import { getVersus, listVersusPlayers } from '$lib/api/statistics';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
alertError,
|
||||
alertWarning,
|
||||
card,
|
||||
cardHeading,
|
||||
ghost
|
||||
} from '$lib/ui/classes';
|
||||
import PlayerPicker from '$lib/ui/PlayerPicker.svelte';
|
||||
|
||||
/*
|
||||
* One question, all events: how often did these two actually meet, and in which
|
||||
* games. That is a different shape from the Rankings page — no event scope, no
|
||||
* leaderboard — which is why it is its own page rather than a fifth tab there.
|
||||
*
|
||||
* The scope is deliberately "everything ever imported": a pairing's history is
|
||||
* the point, and slicing it by season would just be the Rankings page again.
|
||||
*
|
||||
* A set records its bracket, and a bracket's game is nullable, so meetings the
|
||||
* database cannot attribute to a game are excluded by the API and reported
|
||||
* separately as `unknownGameSets`. They are shown, never folded into a total.
|
||||
*/
|
||||
|
||||
let players = $state<PlayerOptionDTO[]>([]);
|
||||
let playerAId = $state<number | null>(null);
|
||||
let playerBId = $state<number | null>(null);
|
||||
|
||||
let versus = $state<PlayerVersusDTO | null>(null);
|
||||
let loadingPlayers = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let inFlight: AbortController | null = null;
|
||||
|
||||
const games = $derived(versus?.games ?? []);
|
||||
const sets = $derived(versus?.sets ?? 0);
|
||||
const decided = $derived(versus?.decidedSets ?? 0);
|
||||
const winsA = $derived(versus?.winsA ?? 0);
|
||||
const winsB = $derived(versus?.winsB ?? 0);
|
||||
const unknown = $derived(versus?.unknownGameSets ?? 0);
|
||||
/** Undecided meetings: recorded, but with equal scores, so nobody won them. */
|
||||
const undecided = $derived(Math.max(0, sets - decided));
|
||||
|
||||
let started = false;
|
||||
$effect(() => {
|
||||
if (!session.isLoggedIn) {
|
||||
goto('/login', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
if (!started) {
|
||||
started = true;
|
||||
void refreshPlayers();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Reads only the two ids, so writing `versus` / `loading` below cannot re-trigger
|
||||
* it. One request per complete pairing, and the previous one is aborted — picking
|
||||
* a third player mid-flight must not race an older answer into view.
|
||||
*/
|
||||
$effect(() => {
|
||||
const a = playerAId;
|
||||
const b = playerBId;
|
||||
|
||||
if (a === null || b === null || a === b) {
|
||||
inFlight?.abort();
|
||||
inFlight = null;
|
||||
versus = null;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
void compare(a, b);
|
||||
});
|
||||
|
||||
async function refreshPlayers() {
|
||||
loadingPlayers = true;
|
||||
error = null;
|
||||
try {
|
||||
players = await listVersusPlayers();
|
||||
} catch (cause) {
|
||||
error = toErrorMessage(cause, 'Could not load the player list.');
|
||||
} finally {
|
||||
loadingPlayers = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function compare(a: number, b: number) {
|
||||
inFlight?.abort();
|
||||
const controller = new AbortController();
|
||||
inFlight = controller;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await getVersus(a, b, { signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
versus = result;
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === 'AbortError') return;
|
||||
versus = null;
|
||||
error = toErrorMessage(cause, 'Could not load this pairing.');
|
||||
} finally {
|
||||
if (inFlight === controller) {
|
||||
inFlight = null;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function swap() {
|
||||
[playerAId, playerBId] = [playerBId, playerAId];
|
||||
}
|
||||
|
||||
function clear() {
|
||||
playerAId = null;
|
||||
playerBId = null;
|
||||
}
|
||||
|
||||
/** Percentages get one decimal only under 100, so the column stays narrow. */
|
||||
function percent(value: number): string {
|
||||
return value >= 99.95 ? '100%' : `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** First player's share of the decided meetings; 50 when nothing is decided. */
|
||||
function share(a: number, b: number): number {
|
||||
const total = a + b;
|
||||
return total === 0 ? 50 : (a / total) * 100;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Players Statistiques · LaDOSE</title>
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Players Statistiques</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Pick two players and see how often they met, game by game, across every event ever
|
||||
imported. For leaderboards and attendance, see
|
||||
<a href="/statistiques/rankings" class="underline decoration-line-strong hover:text-ink">
|
||||
Rankings Statistiques
|
||||
</a>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p role="alert" class="{alertError} mb-4">{error}</p>
|
||||
{/if}
|
||||
|
||||
<section class={card}>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<h2 class={cardHeading}>
|
||||
Pairing
|
||||
<span class="ml-1 font-normal text-muted normal-case">
|
||||
({players.length} players with recorded sets)
|
||||
</span>
|
||||
</h2>
|
||||
<button class={ghost} onclick={refreshPlayers} disabled={loadingPlayers}>
|
||||
{loadingPlayers ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-5 sm:grid-cols-2">
|
||||
<PlayerPicker
|
||||
heading="Player A"
|
||||
{players}
|
||||
bind:selectedId={playerAId}
|
||||
excludeId={playerBId}
|
||||
disabled={loadingPlayers}
|
||||
/>
|
||||
<PlayerPicker
|
||||
heading="Player B"
|
||||
{players}
|
||||
bind:selectedId={playerBId}
|
||||
excludeId={playerAId}
|
||||
disabled={loadingPlayers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-3 border-t border-line pt-4">
|
||||
<span class="text-xs text-muted">
|
||||
{#if loading}
|
||||
Reading their sets…
|
||||
{:else if loadingPlayers}
|
||||
Only players with at least one set in a bracket with a known game are listed.
|
||||
{:else}
|
||||
The count beside a name is their total sets, whoever the opponent was.
|
||||
{/if}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button class={ghost} onclick={swap} disabled={playerAId === null && playerBId === null}>
|
||||
Swap
|
||||
</button>
|
||||
<button class={ghost} onclick={clear} disabled={playerAId === null && playerBId === null}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if versus && (playerAId !== null) && (playerBId !== null)}
|
||||
<!--
|
||||
Scoreline first: the whole page answers one question, so the total sits above
|
||||
the per-game split rather than under it.
|
||||
-->
|
||||
<section class="{card} mt-6">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold tracking-tight">
|
||||
{versus.playerA}
|
||||
<span class="text-subtle">vs</span>
|
||||
{versus.playerB}
|
||||
</h2>
|
||||
<p class="text-3xl font-semibold tracking-tight tabular-nums">
|
||||
<span class={winsA >= winsB ? '' : 'text-muted'}>{winsA}</span>
|
||||
<span class="text-subtle">–</span>
|
||||
<span class={winsB > winsA ? '' : 'text-muted'}>{winsB}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if sets === 0}
|
||||
<p class="mt-3 text-sm text-subtle">
|
||||
No recorded meeting in a bracket with a known game.
|
||||
{#if unknown > 0}
|
||||
They did meet {unknown} time{unknown === 1 ? '' : 's'}, but in
|
||||
{unknown === 1 ? 'a bracket' : 'brackets'} with no game attached.
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<!--
|
||||
A share meter, not a two-series bar: one measure (player A's share of the
|
||||
decided meetings) painted in the accent over an inset track. The score
|
||||
above carries the same numbers for anyone who cannot see it.
|
||||
-->
|
||||
<div
|
||||
class="mt-4 h-2 overflow-hidden rounded-full bg-inset"
|
||||
role="img"
|
||||
aria-label="{versus.playerA} won {winsA} of {decided} decided meetings against {versus.playerB}"
|
||||
>
|
||||
<div class="h-full rounded-full bg-accent" style="width: {share(winsA, winsB)}%"></div>
|
||||
</div>
|
||||
|
||||
<dl class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{#each [['Meetings', String(sets)], ['Games played', String(games.length)], ['Decided', String(decided)], [`${versus.playerA ?? 'A'} win rate`, percent(share(winsA, winsB))]] as const as [caption, value] (caption)}
|
||||
<div class="rounded-xl border border-line bg-inset p-3">
|
||||
<dt class="truncate text-xs tracking-wide text-muted uppercase">{caption}</dt>
|
||||
<dd class="mt-1 text-2xl font-semibold tracking-tight tabular-nums">{value}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if unknown > 0 && sets > 0}
|
||||
<p class="{alertWarning} mt-4">
|
||||
{unknown} further meeting{unknown === 1 ? '' : 's'} excluded: the bracket has no game
|
||||
attached, so {unknown === 1 ? 'it' : 'they'} cannot be filed under one. Set the game on
|
||||
those tournaments to see {unknown === 1 ? 'it' : 'them'} here.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if games.length}
|
||||
<section class="{card} mt-6">
|
||||
<h2 class={cardHeading}>Meetings per game</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
Most-played first. Games counts the individual games inside the decided sets — a
|
||||
DQ is stored as a negative score and counts as zero.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="w-full min-w-max text-sm">
|
||||
<thead class="text-left text-xs tracking-wide text-muted uppercase">
|
||||
<tr class="border-b border-line">
|
||||
<th class="py-2 pr-4 font-medium">Game</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Meetings</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Decided</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Record</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Games</th>
|
||||
<th class="w-40 py-2 pl-2 font-medium">{versus.playerA} share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="tabular-nums">
|
||||
{#each games as row (row.gameId)}
|
||||
{@const rowWinsA = row.winsA ?? 0}
|
||||
{@const rowWinsB = row.winsB ?? 0}
|
||||
<tr class="border-b border-line/60 last:border-0">
|
||||
<td class="py-2 pr-4">
|
||||
<span class="font-medium">{row.game}</span>
|
||||
{#if row.gameLongName && row.gameLongName !== row.game}
|
||||
<span class="ml-1 text-xs text-subtle">{row.gameLongName}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-2 py-2 text-right font-semibold">{row.sets}</td>
|
||||
<td class="px-2 py-2 text-right {row.decidedSets === row.sets ? 'text-subtle' : ''}">
|
||||
{row.decidedSets}
|
||||
</td>
|
||||
<td class="px-2 py-2 text-right font-semibold">{rowWinsA}–{rowWinsB}</td>
|
||||
<td class="px-2 py-2 text-right text-subtle">
|
||||
{row.gamesWonA}–{row.gamesWonB}
|
||||
</td>
|
||||
<td class="py-2 pl-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-1.5 grow overflow-hidden rounded-full bg-inset"
|
||||
role="img"
|
||||
aria-label="{versus.playerA} won {rowWinsA} of {rowWinsA + rowWinsB} decided meetings in {row.game}"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-accent"
|
||||
style="width: {share(rowWinsA, rowWinsB)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="w-12 shrink-0 text-right text-xs text-muted">
|
||||
{rowWinsA + rowWinsB === 0 ? '—' : percent(share(rowWinsA, rowWinsB))}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if undecided > 0}
|
||||
<p class="mt-4 text-xs text-muted">
|
||||
{undecided} of these {sets} meetings {undecided === 1 ? 'has' : 'have'} equal scores
|
||||
— counted as a meeting, but won by nobody, so
|
||||
{undecided === 1 ? 'it is' : 'they are'} absent from the records above.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{:else if !loading}
|
||||
<p class="mt-6 text-sm text-subtle">
|
||||
{#if playerAId === null && playerBId === null}
|
||||
Pick a player on each side to see their history.
|
||||
{:else}
|
||||
Pick a second player to compare.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</main>
|
||||
Reference in New Issue
Block a user