Files
LaDOSE/LaDOSE.Src/LaDOSE.WebApp/src/routes/statistiques/players/+page.svelte
T
darkstack 937b8554dd
Build App / Build (push) Failing after 3s
Docker Compose bot + Google Api
2026-08-06 16:23:50 +02:00

350 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>