Fix code smells
Build App / Build (push) Failing after 2s

This commit is contained in:
2026-08-07 13:22:53 +02:00
parent 937b8554dd
commit f934e69c90
45 changed files with 3400 additions and 395 deletions
@@ -0,0 +1,29 @@
<script lang="ts">
import { page } from '$app/state';
import { primary } from '$lib/ui/classes';
/*
* Without this, a thrown `load` (the /statistiques redirect is the one that can)
* or an uncaught render error drops the user on SvelteKit's built-in error page,
* which is unstyled and outside the app's theme entirely.
*/
const status = $derived(page.status);
const message = $derived(page.error?.message ?? 'Something went wrong.');
</script>
<svelte:head>
<title>{status} · LaDOSE</title>
</svelte:head>
<main id="main" class="mx-auto flex min-h-[60vh] max-w-xl flex-col justify-center px-4 py-10">
<p class="text-sm font-semibold tracking-wide text-subtle uppercase">Error {status}</p>
<h1 class="mt-2 text-3xl font-semibold tracking-tight">
{status === 404 ? 'Page not found' : 'Something went wrong'}
</h1>
<p role="alert" class="mt-3 text-sm text-muted">{message}</p>
<div class="mt-8">
<a href="/" class="{primary} inline-block">Back to the dashboard</a>
</div>
</main>
@@ -1,9 +1,10 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import favicon from '$lib/assets/favicon.svg';
import Navbar from '$lib/ui/Navbar.svelte';
import '../app.css';
let { children } = $props();
let { children }: { children: Snippet } = $props();
</script>
<svelte:head>
@@ -1,12 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { requireSession } from '$lib/auth/guard.svelte';
import { session } from '$lib/stores/session.svelte';
import { card } from '$lib/ui/classes';
// Guard the page: no session (or an expired JWT) sends the user to /login.
$effect(() => {
if (!session.isLoggedIn) goto('/login', { replaceState: true });
});
// No session (or an expired JWT) sends the user to /login.
const guard = requireSession();
interface Shortcut {
href: string;
@@ -52,7 +50,7 @@
</svelte:head>
<main id="main" class="mx-auto max-w-4xl px-4 py-12">
{#if session.user}
{#if guard.ready && session.user}
<h1 class="text-4xl font-semibold tracking-tight">Hello, {session.displayName}.</h1>
<p class="mt-3 text-sm text-muted">You are signed in to LaDOSE.</p>
@@ -1,10 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { deleteGame, listGames, saveGame, searchSmashGames } from '$lib/api/games';
import { toErrorMessage } from '$lib/api/errors';
import { errorReporter } from '$lib/api/errors';
import type { GameDTO } from '$lib/api/schema-helpers';
import { blankDraft, nextOrder, toDraft, toDto, type Draft } from '$lib/games/draft';
import { session } from '$lib/stores/session.svelte';
import { requireSession } from '$lib/auth/guard.svelte';
import { blankDraft, isDirty, nextOrder, toDraft, toDto, type Draft } from '$lib/games/draft';
import {
alertError,
alertNotice,
@@ -21,7 +20,8 @@
let games = $state<GameDTO[]>([]);
let draft = $state<Draft>({ ...blankDraft });
let pristine = $state(JSON.stringify(blankDraft));
/** The draft as it was last loaded or saved; `dirty` compares against it. */
let pristine = $state<Draft>({ ...blankDraft });
let smashMatches = $state<GameDTO[] | null>(null);
let loading = $state(false);
@@ -33,30 +33,18 @@
const ordered = $derived([...games].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)));
const isNew = $derived(draft.id === 0);
const dirty = $derived(JSON.stringify(draft) !== pristine);
const dirty = $derived(isDirty(draft, pristine));
const canSave = $derived(draft.name.trim() !== '' && !saving);
/** The provider searches start.gg by name; the long name is the one that matches. */
const searchTerm = $derived(draft.longName.trim() || draft.name.trim());
let started = false;
$effect(() => {
if (!session.isLoggedIn) {
goto('/login', { replaceState: true });
return;
}
if (!started) {
started = true;
void refresh();
}
});
const guard = requireSession(() => void refresh());
function report(cause: unknown, fallback: string) {
error = toErrorMessage(cause, fallback);
}
const report = errorReporter((message) => (error = message));
function load(game: GameDTO) {
draft = toDraft(game);
pristine = JSON.stringify(draft);
pristine = { ...draft };
smashMatches = null;
}
@@ -78,7 +66,7 @@
function reset() {
draft = { ...blankDraft, order: nextOrder(games) };
pristine = JSON.stringify(draft);
pristine = { ...draft };
smashMatches = null;
}
@@ -160,6 +148,7 @@
</svelte:head>
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
{#if guard.ready}
<header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Games</h1>
<p class="mt-1 text-sm text-muted">
@@ -349,4 +338,5 @@
</form>
</section>
</div>
{/if}
</main>
@@ -35,7 +35,7 @@
}
$effect(() => {
if (session.isLoggedIn) goto('/');
if (session.isLoggedIn) void goto('/');
});
</script>
@@ -1,9 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { toErrorMessage } from '$lib/api/errors';
import { errorReporter } 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 { requireSession } from '$lib/auth/guard.svelte';
import { percent } from '$lib/format';
import {
alertError,
alertWarning,
@@ -46,17 +46,9 @@
/** 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();
}
});
const guard = requireSession(() => void refreshPlayers());
const report = errorReporter((message) => (error = message));
/*
* Reads only the two ids, so writing `versus` / `loading` below cannot re-trigger
@@ -84,7 +76,7 @@
try {
players = await listVersusPlayers();
} catch (cause) {
error = toErrorMessage(cause, 'Could not load the player list.');
report(cause, 'Could not load the player list.');
} finally {
loadingPlayers = false;
}
@@ -105,7 +97,7 @@
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') return;
versus = null;
error = toErrorMessage(cause, 'Could not load this pairing.');
report(cause, 'Could not load this pairing.');
} finally {
if (inFlight === controller) {
inFlight = null;
@@ -123,11 +115,6 @@
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;
@@ -140,6 +127,7 @@
</svelte:head>
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
{#if guard.ready}
<header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Players Statistiques</h1>
<p class="mt-1 text-sm text-muted">
@@ -346,4 +334,5 @@
{/if}
</p>
{/if}
{/if}
</main>
@@ -1,11 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ApiError } from '$lib/api/client';
import { toErrorMessage } from '$lib/api/errors';
import { errorReporter } from '$lib/api/errors';
import type { EventDTO, MatchStatsDTO } from '$lib/api/schema-helpers';
import { getMatchStats } from '$lib/api/statistics';
import { listEvents } from '$lib/api/tournaments';
import { session } from '$lib/stores/session.svelte';
import { requireSession } from '$lib/auth/guard.svelte';
import { downloadCsv } from '$lib/download';
import { eventIds, identified, matchingEventIds, toggleId } from '$lib/events';
import { percent } from '$lib/format';
import { aggregate, formatMonth, standingsCsv, type Aggregate } from '$lib/statistics/aggregate';
import { loadEventResults, type FailedEvent } from '$lib/statistics/load';
import AttendanceChart from '$lib/ui/AttendanceChart.svelte';
@@ -77,17 +79,9 @@
return (row.winsA ?? 0) + (row.winsB ?? 0);
}
let started = false;
$effect(() => {
if (!session.isLoggedIn) {
goto('/login', { replaceState: true });
return;
}
if (!started) {
started = true;
void refreshEvents();
}
});
const guard = requireSession(() => void refreshEvents());
const report = errorReporter((message) => (error = message));
async function refreshEvents() {
loadingEvents = true;
@@ -95,47 +89,35 @@
try {
events = await listEvents();
} catch (cause) {
error = toErrorMessage(cause, 'Could not load the event list.');
report(cause, 'Could not load the event list.');
} finally {
loadingEvents = false;
}
}
function toggle(id: number | undefined) {
if (id === undefined) return;
selectedIds = selectedIds.includes(id)
? selectedIds.filter((selected) => selected !== id)
: [...selectedIds, id];
selectedIds = toggleId(selectedIds, id);
}
function selectAll() {
selectedIds = events.filter((e) => e.id !== undefined).map((e) => e.id as number);
selectedIds = eventIds(events);
}
/** `GET /api/Event` is newest first, so the head of the list is the recent season. */
function selectRecent(count: number) {
selectedIds = events
.filter((e) => e.id !== undefined)
selectedIds = identified(events)
.slice(0, count)
.map((e) => e.id as number);
.map((event) => event.id);
}
function selectMatching() {
const value = pattern.trim();
if (value === '') return;
if (pattern.trim() === '') return;
let regex: RegExp;
try {
regex = new RegExp(value);
} catch {
error = `"${value}" is not a valid regular expression.`;
return;
}
const match = matchingEventIds(events, pattern);
error = match.error;
if (match.error) return;
error = null;
selectedIds = events
.filter((e) => e.id !== undefined && e.name && regex.test(e.name))
.map((e) => e.id as number);
selectedIds = match.ids;
}
async function load() {
@@ -156,7 +138,7 @@
// sit idle behind the per-event fan-out.
const [outcome, matchStats] = await Promise.all([
loadEventResults(
events.filter((e) => e.id !== undefined && scope.includes(e.id)),
identified(events).filter((event) => scope.includes(event.id)),
{
signal: controller.signal,
onProgress: (done, total) => (progress = { done, total })
@@ -179,7 +161,7 @@
if (tab === 'matches' && !matchStats) tab = 'standings';
} catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') return;
error = toErrorMessage(cause, 'Could not compute statistics for this selection.');
report(cause, 'Could not compute statistics for this selection.');
} finally {
if (inFlight === controller) {
inFlight = null;
@@ -189,13 +171,7 @@
}
function exportCsv() {
const blob = new Blob([standingsCsv(standings)], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `ladose-standings-${loadedIds.length}-events.csv`;
link.click();
URL.revokeObjectURL(url);
downloadCsv(`ladose-standings-${loadedIds.length}-events.csv`, standingsCsv(standings));
}
const tabs = [
@@ -204,11 +180,6 @@
['events', 'Events'],
['matches', 'Matches']
] as const;
/** 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)}%`;
}
</script>
<svelte:head>
@@ -216,6 +187,7 @@
</svelte:head>
<main id="main" class="mx-auto max-w-6xl px-4 py-10">
{#if guard.ready}
<header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Rankings Statistiques</h1>
<p class="mt-1 text-sm text-muted">
@@ -259,7 +231,7 @@
<input
bind:value={pattern}
class="{field} w-48"
placeholder="Ranking #13\d{'{'}2{'}'}"
placeholder="Ranking #13\d&#123;2&#125;"
aria-label="Regular expression matching event names"
/>
<button class={ghost} onclick={selectMatching} disabled={pattern.trim() === ''}>
@@ -590,4 +562,5 @@
{/if}
</section>
{/if}
{/if}
</main>
@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { toErrorMessage } from '$lib/api/errors';
import { ApiError } from '$lib/api/client';
import { errorReporter } from '$lib/api/errors';
import type {
EventDTO,
SheetExportResultDTO,
@@ -9,7 +9,9 @@
} 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 { requireSession } from '$lib/auth/guard.svelte';
import { downloadCsv } from '$lib/download';
import { matchingEventIds, toggleId } from '$lib/events';
import {
buildCsv,
buildHtml,
@@ -75,26 +77,23 @@
const suggestedName = $derived(suggestedTabName(generatedEvents));
const resolvedTabName = $derived(tabName.trim() || suggestedName);
let started = false;
$effect(() => {
if (!session.isLoggedIn) {
goto('/login', { replaceState: true });
return;
}
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));
}
});
const report = errorReporter((message) => (error = message));
function report(cause: unknown, fallback: string) {
error = toErrorMessage(cause, fallback);
}
const guard = requireSession(() => {
void refreshEvents();
// Not reaching it just leaves the export saying "not configured"; the page must
// not otherwise care. A 401 is the exception — that is an expired session, not
// an unconfigured server, and must go through `report` so the user is signed out
// instead of being told the export is unavailable.
void getSheetsConfig()
.then((value) => (sheets = value))
.catch((cause) => {
sheets = null;
if (cause instanceof ApiError && cause.status === 401) {
report(cause, 'Could not read the Google Sheets configuration.');
}
});
});
async function refreshEvents() {
loadingEvents = true;
@@ -136,10 +135,7 @@
}
function toggle(id: number | undefined) {
if (id === undefined) return;
selectedIds = selectedIds.includes(id)
? selectedIds.filter((selected) => selected !== id)
: [...selectedIds, id];
selectedIds = toggleId(selectedIds, id);
}
/** Replaces the selection with every event whose name matches the regex. */
@@ -147,18 +143,11 @@
const value = pattern.trim();
if (value === '') return;
let regex: RegExp;
try {
regex = new RegExp(value);
} catch {
error = `"${value}" is not a valid regular expression.`;
return;
}
const match = matchingEventIds(events, value);
error = match.error;
if (match.error) return;
error = null;
selectedIds = events
.filter((e) => e.id !== undefined && e.name && regex.test(e.name))
.map((e) => e.id as number);
selectedIds = match.ids;
if (selectedIds.length === 0) notice = `No event name matches ${value}.`;
}
@@ -194,13 +183,9 @@
}
function exportCsv() {
const blob = new Blob([buildCsv(ranking)], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `ladose-results-${selectedIds.join('-')}.csv`;
link.click();
URL.revokeObjectURL(url);
// `generatedIds`, not `selectedIds`: the file must be named for the data it
// holds, which is whatever `generate` last ran for, not the live checkboxes.
downloadCsv(`ladose-results-${generatedIds.join('-')}.csv`, buildCsv(ranking));
}
/**
@@ -239,6 +224,7 @@
</svelte:head>
<main id="main" class="mx-auto max-w-6xl px-4 py-10">
{#if guard.ready}
<header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Tournaments</h1>
<p class="mt-1 text-sm text-muted">
@@ -301,7 +287,7 @@
<input
bind:value={pattern}
class={field}
placeholder="Ranking #13\d{'{'}2{'}'}"
placeholder="Ranking #13\d&#123;2&#125;"
aria-label="Regular expression matching event names"
/>
<button class={ghost} onclick={selectMatching} disabled={pattern.trim() === ''}>
@@ -548,11 +534,20 @@
<div
class="text-sm text-ladose-50 [&_a]:text-ladose-400 [&_a]:underline [&_table]:w-full [&_td]:py-2 [&_td]:pr-4 [&_td]:align-top"
>
{@html html}
<!--
The one {@html} in the app, and the only place a suppression is
warranted: `html` is not user input passing through, it is built by
`buildHtml` in $lib/tournaments/results.ts, which escapes every
interpolated value. This is a preview of the markup the user is about
to paste into WordPress, so it has to render as markup.
-->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html html}
</div>
</div>
</div>
{/if}
</section>
{/if}
{/if}
</main>
@@ -1,8 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { addUser, deleteUser, listRoles, listUsers } from '$lib/api/admin-users';
import { toErrorMessage } from '$lib/api/errors';
import { errorReporter } from '$lib/api/errors';
import type { ApplicationUserDTO } from '$lib/api/schema-helpers';
import { requireAdmin } from '$lib/auth/guard.svelte';
import { fullName } from '$lib/format';
import { session } from '$lib/stores/session.svelte';
import { alertError, alertNotice, card, cardHeading, danger, field, ghost, label, primary } from '$lib/ui/classes';
@@ -23,27 +24,11 @@
const canCreate = $derived(username.trim() !== '' && password !== '' && !creating);
let started = false;
$effect(() => {
// The page is admin-only on the server too; this just avoids showing a shell that
// can only produce 403s.
if (!session.isLoggedIn) {
goto('/login', { replaceState: true });
return;
}
if (!session.isAdmin) {
goto('/', { replaceState: true });
return;
}
if (!started) {
started = true;
void refresh();
}
});
// The page is admin-only on the server too; this just avoids showing a shell that
// can only produce 403s.
const guard = requireAdmin(() => void refresh());
function report(cause: unknown, fallback: string) {
error = toErrorMessage(cause, fallback);
}
const report = errorReporter((message) => (error = message));
async function refresh() {
loading = true;
@@ -112,8 +97,8 @@
}
}
function fullName(user: ApplicationUserDTO): string {
return [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
function displayName(user: ApplicationUserDTO): string {
return fullName(user.firstName, user.lastName);
}
</script>
@@ -122,6 +107,7 @@
</svelte:head>
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
{#if guard.ready}
<header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Users</h1>
<p class="mt-1 text-sm text-muted">
@@ -168,7 +154,7 @@
<span class="ml-1 text-xs font-normal text-subtle">(you)</span>
{/if}
</td>
<td class="py-2 pr-4 text-muted">{fullName(user) || '—'}</td>
<td class="py-2 pr-4 text-muted">{displayName(user) || '—'}</td>
<td class="py-2 pr-4">
{#if user.roles?.length}
{#each user.roles as role (role)}
@@ -290,4 +276,5 @@
</div>
</form>
</section>
{/if}
</main>