Docker Compose bot + Google Api
Build App / Build (push) Failing after 3s

This commit is contained in:
2026-08-06 16:23:50 +02:00
parent c9a3c252e1
commit 937b8554dd
44 changed files with 3360 additions and 74 deletions
@@ -22,9 +22,14 @@
description: 'Import a start.gg tournament and score one event or a whole ranking season.'
},
{
href: '/statistiques',
title: 'Statistiques',
description: 'Leaderboards, attendance over time and head-to-head records.'
href: '/statistiques/rankings',
title: 'Rankings Statistiques',
description: 'Leaderboards, attendance over time and head-to-head records for a scope.'
},
{
href: '/statistiques/players',
title: 'Players Statistiques',
description: 'Two players, every game they met in, and how the meetings went.'
},
{
href: '/games',
@@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
/**
* The statistics section is two pages now — Rankings and Players — so this path is
* only a landing spot. Redirect rather than delete it: it is what the navbar linked
* to before the split, and what any bookmark still points at.
*/
export const load = () => {
redirect(307, '/statistiques/rankings');
};
@@ -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>
@@ -212,15 +212,18 @@
</script>
<svelte:head>
<title>Statistiques · LaDOSE</title>
<title>Rankings Statistiques · LaDOSE</title>
</svelte:head>
<main id="main" class="mx-auto max-w-6xl px-4 py-10">
<header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Statistiques</h1>
<h1 class="text-3xl font-semibold tracking-tight">Rankings Statistiques</h1>
<p class="mt-1 text-sm text-muted">
Pick a scope — a season, a year, everything — and see who turned up, who won, and how
the games compare.
the games compare. For one pairing's whole history, see
<a href="/statistiques/players" class="underline decoration-line-strong hover:text-ink">
Players Statistiques
</a>.
</p>
</header>
@@ -1,7 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { toErrorMessage } from '$lib/api/errors';
import type { EventDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
import type {
EventDTO,
SheetExportResultDTO,
SheetsConfigDTO,
TournamentsResultDTO
} from '$lib/api/schema-helpers';
import { exportToSheets, getSheetsConfig } from '$lib/api/sheets';
import { getResults, importSmashTournament, listEvents } from '$lib/api/tournaments';
import { session } from '$lib/stores/session.svelte';
import {
@@ -11,6 +17,7 @@
playedGames,
resultsForGame
} from '$lib/tournaments/results';
import { rankingToSheetTable, suggestedTabName } from '$lib/tournaments/sheet';
import {
alertError,
alertNotice,
@@ -34,6 +41,12 @@
let tab = $state<'ranking' | 'game' | 'html'>('ranking');
let selectedGameId = $state<number | null>(null);
/**
* The ids `results` was actually computed for. Changing the selection afterwards must not
* change what the export claims to be, since the tab title decides where the data lands.
*/
let generatedIds = $state<number[]>([]);
let loadingEvents = $state(false);
let importing = $state(false);
let generating = $state(false);
@@ -41,12 +54,27 @@
let error = $state<string | null>(null);
let copied = $state(false);
// --- Google Sheets export -------------------------------------------------------
let sheets = $state<SheetsConfigDTO | null>(null);
/** Blank means "use the suggestion", which is shown as the input's placeholder. */
let tabName = $state('');
let pushing = $state(false);
let pushResult = $state<SheetExportResultDTO | null>(null);
const ranking = $derived(buildRanking(results));
const games = $derived(playedGames(results));
const gameResults = $derived(resultsForGame(results, selectedGameId));
const html = $derived(buildHtml(results));
const selectedGame = $derived(games.find((g) => g.id === selectedGameId) ?? null);
/** The events behind the table on screen, not the current checkboxes. */
const generatedEvents = $derived(
events.filter((event) => event.id !== undefined && generatedIds.includes(event.id))
);
/** A ranking day's table aggregates up to that day, so the latest event names the tab. */
const suggestedName = $derived(suggestedTabName(generatedEvents));
const resolvedTabName = $derived(tabName.trim() || suggestedName);
let started = false;
$effect(() => {
if (!session.isLoggedIn) {
@@ -56,6 +84,11 @@
if (!started) {
started = true;
void refreshEvents();
// Not reaching it just leaves the export saying "not configured"; the page must
// not otherwise care.
void getSheetsConfig()
.then((value) => (sheets = value))
.catch(() => (sheets = null));
}
});
@@ -137,8 +170,12 @@
notice = null;
try {
results = await getResults(selectedIds);
generatedIds = [...selectedIds];
selectedGameId = playedGames(results)[0]?.id ?? null;
tab = 'ranking';
// A new table belongs in its own tab, so drop any title typed for the previous one.
tabName = '';
pushResult = null;
} catch (cause) {
report(cause, 'Could not compute the results for this selection.');
} finally {
@@ -166,6 +203,35 @@
URL.revokeObjectURL(url);
}
/**
* Writes the table above into the club's spreadsheet, as one tab — the same grid the CSV
* carries, so this replaces "download, then import by hand".
*
* The target spreadsheet is server configuration (it is replaced each year); only the tab
* title is chosen here.
*/
async function pushToSheets() {
if (pushing || !ranking.rows.length || !sheets?.configured) return;
pushing = true;
error = null;
notice = null;
pushResult = null;
try {
const table = rankingToSheetTable(ranking, {
name: resolvedTabName,
events: generatedEvents,
generatedAt: new Date().toISOString()
});
pushResult = await exportToSheets({ tabs: [table] });
} catch (cause) {
report(cause, 'Could not write to the spreadsheet.');
} finally {
pushing = false;
}
}
</script>
<svelte:head>
@@ -334,9 +400,79 @@
</tbody>
</table>
</div>
<button class="{ghost} mt-4" onclick={exportCsv} disabled={!ranking.rows.length}>
Export CSV
</button>
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-line pt-4">
<button class={ghost} onclick={exportCsv} disabled={!ranking.rows.length}>
Export CSV
</button>
{#if sheets?.configured}
<!--
Same table, straight into the spreadsheet — the point being to skip the
download-then-import step. Only the tab title is chosen here; the
spreadsheet itself is server configuration, since it changes every year.
-->
<span class="ml-auto flex flex-wrap items-center gap-2">
<label class="flex items-center gap-2 text-xs text-muted">
Tab
<input
bind:value={tabName}
class="{field} w-44"
placeholder={suggestedName}
aria-label="Spreadsheet tab to write"
/>
</label>
<button
class={primary}
onclick={pushToSheets}
disabled={pushing || !ranking.rows.length}
>
{pushing ? 'Writing…' : 'Push to Google Sheets'}
</button>
</span>
{:else if sheets}
<span class="ml-auto text-xs text-subtle">
Google Sheets export not configured on the server{sheets.writer
? ` (writer: ${sheets.writer})`
: ''} — see <code>.env.example</code>.
</span>
{/if}
</div>
{#if sheets?.configured}
<p class="mt-2 text-xs text-muted">
Writes this table to
<span class="text-ink">{resolvedTabName}</span>, replacing that tab's contents and
keeping its formatting. Other tabs are left alone, and re-running changes nothing
but the timestamp.
</p>
{/if}
{#if pushResult}
{@const written = pushResult.tabs?.[0]}
<div class="{alertNotice} mt-3">
<p>
Wrote {written?.rows ?? 0} rows to
<span class="font-semibold">{written?.name}</span>
{#if pushResult.spreadsheetUrl}
in
<a
href={pushResult.spreadsheetUrl}
target="_blank"
rel="noreferrer"
class="underline">the spreadsheet</a
>
{/if}
{#if written && !written.created}· replaced an existing tab{/if}
</p>
{#if pushResult.warnings?.length}
<ul class="mt-1 space-y-0.5 text-xs">
{#each pushResult.warnings as warning (warning)}
<li>{warning}</li>
{/each}
</ul>
{/if}
</div>
{/if}
{:else if tab === 'game'}
<div class="mt-5 grid gap-5 sm:grid-cols-[14rem_1fr]">
<ul class="max-h-80 space-y-1 overflow-y-auto pr-1 text-sm">