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
+9 -2
View File
@@ -46,7 +46,7 @@ DTO field surfaces as a TypeScript error rather than a runtime 404.
| `src/lib/api/schema.d.ts` | Generated types — all 31 API paths and every DTO |
| `src/lib/api/schema-helpers.ts` | Friendly aliases (`ApplicationUserDTO`, `LoginRequest`, …) |
| `src/lib/api/client.ts` | `apiRequest` — bearer auth, JSON, `ApiError`; paths constrained to real routes |
| `src/lib/api/users.ts` | `login` / `register` against `/Users/auth` and `/Users/register` |
| `src/lib/api/users.ts` | `login` against `/Users/auth` — the one unauthenticated call |
| `src/lib/api/errors.ts` | `toErrorMessage` — message to show, or redirect to `/login` on a 401 |
| `src/lib/api/tournaments.ts` | `listEvents` / `importSmashTournament` / `getResults`, authenticated from the session |
| `src/lib/api/games.ts` | `listGames` / `saveGame` / `deleteGame` / `searchSmashGames` |
@@ -223,7 +223,8 @@ otherwise only reachable through the database.
Lists every account with its roles, creates accounts, and deletes them.
`POST /Users/register` used to be `[AllowAnonymous]` so that the first account could be
created. It is now `POST /Users/AddUser` and requires the **Admin** role, so before this
created. That route is gone; account creation is now `POST /Users/AddUser` and requires
the **Admin** role, so before this
page is reachable at all, one account has to be promoted directly in the database:
```bash
@@ -266,5 +267,11 @@ How roles work:
```bash
npm run check # svelte-check (types + template diagnostics)
npm run lint # eslint, including type-aware rules
npm run test # vitest over the pure modules in $lib
npm run build # static build into ./build
```
`npm run test` covers `$lib/statistics`, `$lib/tournaments`, `$lib/games/draft` and the
small shared helpers beside them (`csv`, `events`, `format`). Those modules are pure by
design, so they run under plain Node with no API and no database.
+81
View File
@@ -0,0 +1,81 @@
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import ts from 'typescript-eslint';
/**
* Lint only. Formatting is deliberately not enforced here — there is no Prettier in
* this project, so any stylistic rule would fight the existing hand-kept style.
*
* Type-aware linting is on (`projectService`), which is what makes
* `no-floating-promises` able to see that an `async` handler's promise is dropped.
*/
export default ts.config(
js.configs.recommended,
...ts.configs.recommendedTypeChecked,
...svelte.configs.recommended,
{
languageOptions: {
globals: { ...globals.browser },
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
tsconfigRootDir: import.meta.dirname
}
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
rules: {
// The app ships no logging of its own; `hooks.client.ts` is the one exception
// and opts in explicitly below.
'no-console': 'error',
/*
* Off: this rule wants every href and goto() wrapped in `resolve()`, which
* matters only for an app served under a base path. This one is served from
* the root (see nginx.conf) and sets no `base`, so it would be 14 wrappers
* buying nothing. Revisit if the app ever moves under a sub-path.
*/
'svelte/no-navigation-without-resolve': 'off',
/*
* The regex placeholders on the event pickers need a literal `{` in an
* attribute, which in Svelte can only be written as a mustache.
*/
'svelte/no-useless-mustaches': ['error', { ignoreStringEscape: true }],
// This is the rule that catches an `async` function used directly as an
// event handler, where a rejection becomes an unhandled rejection.
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
]
}
},
{
files: ['src/hooks.client.ts'],
rules: { 'no-console': 'off' }
},
{
// This file and the Vite config are build tooling, outside the app's tsconfig
// project, so type-aware rules cannot resolve them.
files: ['eslint.config.js', 'vite.config.ts'],
...ts.configs.disableTypeChecked
},
{
// Generated from openapi.json by `npm run api:types`; not ours to lint.
ignores: [
'src/lib/api/schema.d.ts',
'build/',
'.svelte-kit/',
'node_modules/',
'static/config.js'
]
}
);
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -12,19 +12,30 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"api:fetch": "curl -fsS ${LADOSE_API_URL:-http://localhost:5000}/openapi/v1.json -o openapi.json",
"api:types": "openapi-typescript openapi.json -o src/lib/api/schema.d.ts",
"api:sync": "npm run api:fetch && npm run api:types"
"api:sync": "npm run api:fetch && npm run api:types",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@eslint/js": "^10.0.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.3",
"eslint": "^10.8.0",
"eslint-plugin-svelte": "^3.22.0",
"globals": "^17.9.0",
"openapi-typescript": "^7.13.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.0.16"
"typescript-eslint": "^8.66.0",
"vite": "^8.0.16",
"vitest": "^4.1.10"
},
"engines": {
"node": "^20.19 || ^22.12 || >=24"
}
}
+18
View File
@@ -8,6 +8,24 @@ declare global {
// interface PageState {}
// interface Platform {}
}
/**
* Build-time configuration Vite inlines into the bundle. Declared so the reads in
* `$lib/api/client` are typed rather than `any` — see `resolveBaseUrl` for how
* this relates to the runtime `window.__LADOSE_CONFIG__` tier.
*/
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
/** Shape of the object `static/config.js` defines, rewritten at container start. */
interface Window {
__LADOSE_CONFIG__?: { apiBaseUrl?: string };
}
}
export {};
+6 -5
View File
@@ -12,8 +12,11 @@
-->
<script src="%sveltekit.assets%/config.js"></script>
<!--
Theme, before %sveltekit.head% emits the render-blocking stylesheet, so
data-theme is already on <html> when its selectors are first matched.
Theme, ahead of the head placeholder below that emits the render-blocking
stylesheet, so data-theme is already on <html> when its selectors are first
matched. Do not name that placeholder literally anywhere above it: SvelteKit
substitutes the first textual occurrence, so a mention in prose swallows the
real one and the built page ships with no stylesheet.
Must stay a plain synchronous inline script: type=module, defer, async or
an external file all run after first paint, which is the flash we are
avoiding. Only ever writes 'light' or 'dark' — the CSS keys off those two
@@ -27,9 +30,7 @@
/* localStorage throws in Safari private mode and with cookies blocked */
}
</script>
<!--
%sveltekit.head%
-->
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
@@ -0,0 +1,19 @@
import type { HandleClientError } from '@sveltejs/kit';
/**
* Last resort for errors no page caught — an uncaught error in an `$effect`, or a
* failed `load`. Without this they surface nowhere at all: the app logs nothing
* anywhere else, so a blank panel is the only symptom a user could report.
*
* There is no telemetry backend to ship these to, so the console is the whole
* story. It is the one place in the app where `console.error` is intentional.
*/
export const handleError: HandleClientError = ({ error, status, message }) => {
if (status !== 404) console.error('[LaDOSE]', error);
// What `+error.svelte` renders. Deliberately generic: `error` can carry API
// internals, and the pages already surface anything the user can act on.
return {
message: status === 404 ? message : 'An unexpected error occurred.'
};
};
@@ -1,4 +1,3 @@
import { session } from '$lib/stores/session.svelte';
import { apiRequest, buildPath, type RequestOptions } from './client';
import type { ApplicationUserDTO, NewUserRequest } from './schema-helpers';
@@ -8,13 +7,9 @@ import type { ApplicationUserDTO, NewUserRequest } from './schema-helpers';
*
* `login` and the session live in `users.ts`; this module is only the admin screen.
*/
function authed(options: RequestOptions): RequestOptions {
return { ...options, token: options.token ?? session.token };
}
/** GET /Users — every account, ordered by username. Never includes password material. */
export function listUsers(options: RequestOptions = {}): Promise<ApplicationUserDTO[]> {
return apiRequest<ApplicationUserDTO[]>('/Users', authed(options));
return apiRequest<ApplicationUserDTO[]>('/Users', options);
}
/**
@@ -22,7 +17,7 @@ export function listUsers(options: RequestOptions = {}): Promise<ApplicationUser
* that is not in this list is rejected by the API rather than creating a new role.
*/
export function listRoles(options: RequestOptions = {}): Promise<string[]> {
return apiRequest<string[]>('/Users/Roles', authed(options));
return apiRequest<string[]>('/Users/Roles', options);
}
/**
@@ -35,7 +30,7 @@ export function addUser(
options: RequestOptions = {}
): Promise<ApplicationUserDTO> {
return apiRequest<ApplicationUserDTO>('/Users/AddUser', {
...authed(options),
...options,
method: 'POST',
body: user
});
@@ -47,7 +42,7 @@ export function addUser(
*/
export async function deleteUser(id: number, options: RequestOptions = {}): Promise<void> {
await apiRequest<void>(buildPath('/Users/{id}', { id }), {
...authed(options),
...options,
method: 'DELETE'
});
}
+29 -15
View File
@@ -1,12 +1,6 @@
import { session } from '$lib/stores/session.svelte';
import type { ApiPath } from './schema-helpers';
/** Shape of the object `static/config.js` defines, rewritten at container start. */
declare global {
interface Window {
__LADOSE_CONFIG__?: { apiBaseUrl?: string };
}
}
/**
* Base URL of LaDOSE.Api, resolved in this order:
*
@@ -24,7 +18,11 @@ function resolveBaseUrl(): string {
const runtime =
typeof window !== 'undefined' ? window.__LADOSE_CONFIG__?.apiBaseUrl : undefined;
const configured = runtime?.trim() || import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000';
// Vite replaces this with a string literal at build time, or leaves it undefined
// when the variable was not set (see ImportMetaEnv in app.d.ts).
const baked = import.meta.env.VITE_API_BASE_URL;
const configured = runtime?.trim() || baked || 'http://localhost:5000';
return configured.replace(/\/$/, '');
}
@@ -53,7 +51,14 @@ function extractMessage(body: unknown, status: number): string {
export interface RequestOptions {
method?: 'GET' | 'POST' | 'DELETE';
body?: unknown;
/** JWT from `POST /Users/auth`; sent as `Authorization: Bearer ...`. */
/**
* JWT sent as `Authorization: Bearer ...`.
*
* Left out, the session store's token is used — almost every endpoint on
* LaDOSE.Api is `[Authorize]`, so authenticated is the useful default. Pass
* `null` to send the request unauthenticated (`POST /Users/auth` is the only
* caller that needs to), or a string to override the stored token.
*/
token?: string | null;
fetch?: typeof globalThis.fetch;
signal?: AbortSignal;
@@ -62,13 +67,16 @@ export interface RequestOptions {
/**
* Calls LaDOSE.Api. `path` is constrained to the paths in the generated OpenAPI
* types, so a typo or a route removed on the server is a compile error.
* Templated paths (e.g. `/api/Game/{id}`) are built with `buildPath`.
* Templated paths (e.g. `/api/Game/{id}`) are built with `buildPath`, whose
* branded return type is the only other thing accepted here — widening this to
* `string` would silently re-admit routes the server no longer serves.
*/
export async function apiRequest<TResponse>(
path: ApiPath | (string & {}),
path: ApiPath | BuiltPath,
options: RequestOptions = {}
): Promise<TResponse> {
const { method = 'GET', body, token, fetch: fetchImpl = globalThis.fetch, signal } = options;
const { method = 'GET', body, fetch: fetchImpl = globalThis.fetch, signal } = options;
const token = options.token === undefined ? session.token : options.token;
const headers: Record<string, string> = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
@@ -91,7 +99,7 @@ export async function apiRequest<TResponse>(
}
const isJson = response.headers.get('content-type')?.includes('json') ?? false;
const payload = isJson ? await response.json().catch(() => null) : null;
const payload: unknown = isJson ? await response.json().catch(() => null) : null;
if (!response.ok) {
throw new ApiError(response.status, extractMessage(payload, response.status));
@@ -100,14 +108,20 @@ export async function apiRequest<TResponse>(
return payload as TResponse;
}
/**
* A path already filled in by `buildPath`. Branded so `apiRequest` can accept it
* without accepting `string`, which would defeat the `ApiPath` constraint.
*/
export type BuiltPath = string & { readonly __apiPath: unique symbol };
/** Fills a templated OpenAPI path, e.g. buildPath('/api/Game/{id}', { id: 3 }). */
export function buildPath(
template: ApiPath,
params: Record<string, string | number>
): string {
): BuiltPath {
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = params[key];
if (value === undefined) throw new Error(`Missing route parameter "${key}" for ${template}`);
return encodeURIComponent(String(value));
});
}) as BuiltPath;
}
@@ -19,3 +19,20 @@ export function toErrorMessage(cause: unknown, fallback: string): string | null
// ApiError already carries the API's own `message`, or "unreachable" for status 0.
return cause instanceof ApiError ? cause.message : fallback;
}
/**
* Binds `toErrorMessage` to a page's error state, so every page reports failures
* the same way:
*
* const report = errorReporter((message) => (error = message));
* ...
* catch (cause) { report(cause, 'Could not load the accounts.'); }
*
* Pages used to split between a hand-copied `report` wrapper and calling
* `toErrorMessage` inline — two conventions for one behaviour.
*/
export function errorReporter(
set: (message: string | null) => void
): (cause: unknown, fallback: string) => void {
return (cause, fallback) => set(toErrorMessage(cause, fallback));
}
+5 -10
View File
@@ -1,15 +1,10 @@
import { session } from '$lib/stores/session.svelte';
/** GameController is `[Authorize]`; `apiRequest` supplies the session JWT. */
import { apiRequest, buildPath, type RequestOptions } from './client';
import type { GameDTO } from './schema-helpers';
/** GameController is `[Authorize]`; reuse the session JWT unless one is passed in. */
function authed(options: RequestOptions): RequestOptions {
return { ...options, token: options.token ?? session.token };
}
/** GET /api/Game — every game, in database order (sort by `order` for display). */
export function listGames(options: RequestOptions = {}): Promise<GameDTO[]> {
return apiRequest<GameDTO[]>('/api/Game', authed(options));
return apiRequest<GameDTO[]>('/api/Game', options);
}
/**
@@ -18,7 +13,7 @@ export function listGames(options: RequestOptions = {}): Promise<GameDTO[]> {
* Returns the saved game with its assigned id.
*/
export function saveGame(game: GameDTO, options: RequestOptions = {}): Promise<GameDTO> {
return apiRequest<GameDTO>('/api/Game', { ...authed(options), method: 'POST', body: game });
return apiRequest<GameDTO>('/api/Game', { ...options, method: 'POST', body: game });
}
/**
@@ -27,7 +22,7 @@ export function saveGame(game: GameDTO, options: RequestOptions = {}): Promise<G
*/
export async function deleteGame(id: number, options: RequestOptions = {}): Promise<void> {
await apiRequest<void>(buildPath('/api/Game/{id}', { id }), {
...authed(options),
...options,
method: 'DELETE'
});
}
@@ -41,5 +36,5 @@ export function searchSmashGames(
name: string,
options: RequestOptions = {}
): Promise<GameDTO[]> {
return apiRequest<GameDTO[]>(buildPath('/api/Game/smash/{name}', { name }), authed(options));
return apiRequest<GameDTO[]>(buildPath('/api/Game/smash/{name}', { name }), options);
}
@@ -1,4 +1,4 @@
import { session } from '$lib/stores/session.svelte';
/** SheetsController is `[Authorize]`, like the rest of the API. */
import { apiRequest, type RequestOptions } from './client';
import type {
SheetExportRequestDTO,
@@ -6,18 +6,13 @@ import type {
SheetsConfigDTO
} from './schema-helpers';
/** SheetsController is `[Authorize]`, like the rest of the API. */
function authed(options: RequestOptions): RequestOptions {
return { ...options, token: options.token ?? session.token };
}
/**
* GET /api/Sheets/Config — whether the export is usable and which spreadsheet it points
* at. The target is server configuration (it is reset each year), so the UI reads it
* rather than offering it as an input. Carries no credentials.
*/
export function getSheetsConfig(options: RequestOptions = {}): Promise<SheetsConfigDTO> {
return apiRequest<SheetsConfigDTO>('/api/Sheets/Config', authed(options));
return apiRequest<SheetsConfigDTO>('/api/Sheets/Config', options);
}
/**
@@ -32,7 +27,7 @@ export function exportToSheets(
options: RequestOptions = {}
): Promise<SheetExportResultDTO> {
return apiRequest<SheetExportResultDTO>('/api/Sheets/Export', {
...authed(options),
...options,
method: 'POST',
body: request
});
@@ -1,12 +1,7 @@
import { session } from '$lib/stores/session.svelte';
/** StatisticsController is `[Authorize]`, like the tournament endpoints. */
import { apiRequest, buildPath, type RequestOptions } from './client';
import type { MatchStatsDTO, PlayerOptionDTO, PlayerVersusDTO } from './schema-helpers';
/** StatisticsController is `[Authorize]`, like the tournament endpoints. */
function authed(options: RequestOptions): RequestOptions {
return { ...options, token: options.token ?? session.token };
}
/**
* POST /api/Statistics/Matches — set-level statistics (win/loss, games, head to
* head) for the given events, read from the `set` rows an import persisted.
@@ -22,7 +17,7 @@ export function getMatchStats(
options: RequestOptions = {}
): Promise<MatchStatsDTO> {
return apiRequest<MatchStatsDTO>('/api/Statistics/Matches', {
...authed(options),
...options,
method: 'POST',
body: eventIds
});
@@ -35,7 +30,7 @@ export function getMatchStats(
* whose game is known — the same filter `getVersus` applies.
*/
export function listVersusPlayers(options: RequestOptions = {}): Promise<PlayerOptionDTO[]> {
return apiRequest<PlayerOptionDTO[]>('/api/Statistics/Players', authed(options));
return apiRequest<PlayerOptionDTO[]>('/api/Statistics/Players', options);
}
/**
@@ -56,5 +51,5 @@ export function getVersus(
playerAId,
playerBId
});
return apiRequest<PlayerVersusDTO>(path, authed(options));
return apiRequest<PlayerVersusDTO>(path, options);
}
@@ -1,22 +1,17 @@
import { session } from '$lib/stores/session.svelte';
/**
* TournamentController and EventController are both `[Authorize]`. `apiRequest`
* defaults the JWT to the session store's, so nothing here passes one explicitly.
*/
import { apiRequest, buildPath, type RequestOptions } from './client';
import type { EventDTO, TournamentsResultDTO } from './schema-helpers';
/**
* TournamentController and EventController are both `[Authorize]`, so every call
* here carries the JWT held by the session store unless one is passed explicitly.
*/
function authed(options: RequestOptions): RequestOptions {
return { ...options, token: options.token ?? session.token };
}
/**
* GET /api/Event — every imported event, newest first (the controller orders by
* `Date` descending). A start.gg tournament becomes one Event holding one
* Tournament per bracket.
*/
export function listEvents(options: RequestOptions = {}): Promise<EventDTO[]> {
return apiRequest<EventDTO[]>('/api/Event', authed(options));
return apiRequest<EventDTO[]>('/api/Event', options);
}
/**
@@ -32,7 +27,7 @@ export function importSmashTournament(
const path = buildPath('/api/Tournament/ParseSmash/{tournamentSlug}', {
tournamentSlug: slug
});
return apiRequest<boolean>(path, authed(options));
return apiRequest<boolean>(path, options);
}
/**
@@ -46,7 +41,7 @@ export function getResults(
options: RequestOptions = {}
): Promise<TournamentsResultDTO> {
return apiRequest<TournamentsResultDTO>('/api/Tournament/GetResults', {
...authed(options),
...options,
method: 'POST',
body: eventIds
});
@@ -8,7 +8,11 @@ import type { ApplicationUserDTO, AuthenticatedUser, LoginRequest } from './sche
export async function login(credentials: LoginRequest): Promise<AuthenticatedUser> {
const user = await apiRequest<ApplicationUserDTO>('/Users/auth', {
method: 'POST',
body: credentials
body: credentials,
// The only unauthenticated endpoint: opt out of the session token that
// `apiRequest` would otherwise attach, so signing in as a second user while
// a stale session is still in memory sends only the credentials.
token: null
});
// The generated DTO marks every field optional because the C# properties are
@@ -19,8 +23,3 @@ export async function login(credentials: LoginRequest): Promise<AuthenticatedUse
return user as AuthenticatedUser;
}
/** POST /Users/register */
export async function register(credentials: LoginRequest): Promise<void> {
await apiRequest<void>('/Users/register', { method: 'POST', body: credentials });
}
@@ -0,0 +1,74 @@
import { goto } from '$app/navigation';
import { session } from '$lib/stores/session.svelte';
/**
* The client-side route guard, in one place.
*
* Every protected page used to carry its own copy of this effect plus a `started`
* latch — five verbatim copies, which meant a new protected route shipped unguarded
* whenever someone forgot to paste it.
*
* This is presentation only. The API authorises every request independently and
* re-reads the caller's roles from the database, so a guard here never decides
* access; it only avoids rendering a shell that can produce nothing but 401s
* and 403s.
*/
export interface Guard {
/**
* Whether the page may render. Gate the page's `<main>` on this: `goto` is
* asynchronous, so without it an unauthenticated deep link paints the whole
* page for a frame before the redirect lands.
*/
readonly ready: boolean;
}
interface GuardOptions {
/** Also require the Admin role, sending non-admins to the dashboard. */
admin?: boolean;
/** Run once, the first time the guard admits the user. For the initial fetch. */
onReady?: () => void;
}
function guard(options: GuardOptions = {}): Guard {
const { admin = false, onReady } = options;
let ready = $state(false);
let started = false;
$effect(() => {
if (!session.isLoggedIn) {
ready = false;
void goto('/login', { replaceState: true });
return;
}
if (admin && !session.isAdmin) {
ready = false;
void goto('/', { replaceState: true });
return;
}
ready = true;
if (!started) {
started = true;
onReady?.();
}
});
return {
get ready() {
return ready;
}
};
}
/** Requires a signed-in user. Call once, at the top level of a page component. */
export function requireSession(onReady?: () => void): Guard {
return guard({ onReady });
}
/** Requires a signed-in user holding the Admin role. */
export function requireAdmin(onReady?: () => void): Guard {
return guard({ admin: true, onReady });
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { quote, toCsv } from './csv';
describe('quote', () => {
it('wraps every field, including numbers', () => {
expect(quote('Bob')).toBe('"Bob"');
expect(quote(12)).toBe('"12"');
expect(quote('')).toBe('""');
});
it('doubles inner quotes rather than escaping them', () => {
expect(quote('He said "hi"')).toBe('"He said ""hi"""');
});
it('leaves separators alone — quoting is what makes them safe', () => {
expect(quote('Smash; Melee')).toBe('"Smash; Melee"');
expect(quote('line\nbreak')).toBe('"line\nbreak"');
});
});
describe('toCsv', () => {
it('emits CRLF between rows and a trailing CRLF', () => {
expect(toCsv(['A', 'B'], [['1', '2']])).toBe('"A";"B"\r\n"1";"2"\r\n');
});
it('writes a header-only document when there are no rows', () => {
expect(toCsv(['A'], [])).toBe('"A"\r\n');
});
it('is semicolon separated, which is what Excel expects here', () => {
expect(toCsv(['A', 'B'], []).includes(';')).toBe(true);
expect(toCsv(['A', 'B'], []).includes(',')).toBe(false);
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* The one CSV dialect this app writes.
*
* Excel is picky: semicolon separated, every field quoted, inner quotes doubled,
* CRLF line endings and a trailing newline. `buildCsv` (tournaments) and
* `standingsCsv` (statistics) both used to carry their own copy of this — they
* were character-identical, and a comment in one pointed at the other rather than
* sharing it.
*
* Pure: exercisable under plain Node.
*/
const SEPARATOR = ';';
const NEWLINE = '\r\n';
/** One field, quoted, with inner quotes doubled. */
export function quote(value: string | number): string {
return `"${String(value).replaceAll('"', '""')}"`;
}
/** A full document: header row, then one row per record, CRLF-terminated throughout. */
export function toCsv(header: readonly (string | number)[], rows: readonly (string | number)[][]): string {
const lines = [header.map(quote).join(SEPARATOR)];
for (const row of rows) lines.push(row.map(quote).join(SEPARATOR));
return lines.join(NEWLINE) + NEWLINE;
}
@@ -0,0 +1,33 @@
/**
* Client-side file download.
*
* The rankings and tournaments pages each carried their own copy of this. Both
* clicked an anchor that was never in the document and revoked the object URL on
* the very next line — browsers that hand the blob to a download manager
* asynchronously can produce a silently empty file. This appends the anchor,
* clicks it, removes it, and defers the revoke to the next task.
*/
/** Triggers a download of `content` as `filename`. Browser only. */
export function downloadText(filename: string, content: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.append(link);
link.click();
link.remove();
// Revoking synchronously can cut the download off before the browser has read
// the blob; one task later is enough and still frees the memory.
setTimeout(() => URL.revokeObjectURL(url), 0);
}
/** `downloadText` with the CSV content type the exports use. */
export function downloadCsv(filename: string, content: string): void {
downloadText(filename, content, 'text/csv;charset=utf-8');
}
@@ -0,0 +1,94 @@
import type { EventDTO } from '$lib/api/schema-helpers';
import { describe, expect, it } from 'vitest';
import { compareByDate, eventIds, identified, matchingEventIds, toggleId } from './events';
function event(partial: Partial<EventDTO>): EventDTO {
return { id: 1, name: 'Ranking #1301', date: '2026-01-10T00:00:00', ...partial };
}
describe('identified / eventIds', () => {
it('drops events the API gave no id', () => {
const events = [event({ id: 1 }), event({ id: undefined }), event({ id: 3 })];
expect(eventIds(events)).toEqual([1, 3]);
expect(identified(events)).toHaveLength(2);
});
it('keeps id 0, which is a real id and not "missing"', () => {
expect(eventIds([event({ id: 0 })])).toEqual([0]);
});
});
describe('toggleId', () => {
it('adds then removes', () => {
expect(toggleId([], 3)).toEqual([3]);
expect(toggleId([1, 3], 3)).toEqual([1]);
});
it('ignores undefined instead of pushing a hole into the selection', () => {
expect(toggleId([1], undefined)).toEqual([1]);
});
it('does not mutate the input', () => {
const selected = [1];
toggleId(selected, 2);
expect(selected).toEqual([1]);
});
});
describe('matchingEventIds', () => {
const events = [
event({ id: 1, name: 'Ranking #1301' }),
event({ id: 2, name: 'Ranking #1302' }),
event({ id: 3, name: 'Tournoi de Noël' }),
event({ id: 4, name: undefined })
];
it('selects by regular expression', () => {
expect(matchingEventIds(events, String.raw`Ranking #13\d{2}`).ids).toEqual([1, 2]);
});
it('is case sensitive', () => {
expect(matchingEventIds(events, 'ranking').ids).toEqual([]);
});
it('never matches an event with no name', () => {
expect(matchingEventIds(events, '').ids).toEqual([]);
expect(matchingEventIds(events, '.*').ids).toEqual([1, 2, 3]);
});
it('reports an invalid pattern instead of throwing', () => {
const match = matchingEventIds(events, 'Ranking #13(');
expect(match.ids).toEqual([]);
expect(match.error).toContain('not a valid regular expression');
});
it('treats a blank pattern as no request at all', () => {
expect(matchingEventIds(events, ' ')).toEqual({ ids: [], error: null });
});
});
describe('compareByDate', () => {
const older = '2026-01-01T00:00:00';
const newer = '2026-06-01T00:00:00';
it('orders ascending by default and descending on request', () => {
expect(compareByDate(older, newer, 'asc')).toBeLessThan(0);
expect(compareByDate(older, newer, 'desc')).toBeGreaterThan(0);
});
it('ties on equal dates, so callers can add their own tiebreak', () => {
expect(compareByDate(older, older, 'asc')).toBe(0);
});
it('sorts undated last in both directions — they cannot claim to be latest', () => {
for (const direction of ['asc', 'desc'] as const) {
expect(compareByDate(older, null, direction)).toBeLessThan(0);
expect(compareByDate(null, older, direction)).toBeGreaterThan(0);
}
});
it('treats an unparseable date as undated', () => {
expect(compareByDate(older, 'not a date', 'asc')).toBeLessThan(0);
expect(compareByDate(null, undefined, 'asc')).toBe(0);
});
});
@@ -0,0 +1,92 @@
/**
* Event-selection helpers shared by the tournaments and rankings pages, which both
* present the same "tick events, or select all / recent / by pattern" scope picker.
*
* `EventDTO.id` is optional because the generated DTO mirrors nullable C# reference
* types, so every consumer used to repeat `.filter(e => e.id !== undefined)` followed
* by an `as number` cast. `eventIds` narrows properly and removes the cast.
*
* Pure: exercisable under plain Node.
*/
import type { EventDTO } from '$lib/api/schema-helpers';
/** An event the API gave an id, so it can be requested. */
export type IdentifiedEvent = EventDTO & { id: number };
export function hasId(event: EventDTO): event is IdentifiedEvent {
return event.id !== undefined;
}
/** The requestable events of a list, in order. */
export function identified(events: EventDTO[]): IdentifiedEvent[] {
return events.filter(hasId);
}
/** Just the ids — what the scoring endpoints take. */
export function eventIds(events: EventDTO[]): number[] {
return identified(events).map((event) => event.id);
}
/** Adds or removes an id, ignoring the undefined case. Returns a new array. */
export function toggleId(selected: number[], id: number | undefined): number[] {
if (id === undefined) return selected;
return selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id];
}
export interface PatternMatch {
/** Ids of the events whose name matched. Empty when the pattern was invalid. */
ids: number[];
/** Set when the pattern would not compile — show it instead of the match count. */
error: string | null;
}
/**
* Selects events by regular expression on their name, used for things like
* `Ranking #13\d{2}`. Case-sensitive, and events with no name never match.
*
* An invalid pattern is a user typo, not a bug, so it comes back as a message
* rather than throwing. A blank pattern selects nothing and reports nothing —
* callers treat it as "no request made" and leave the selection alone.
*/
export function matchingEventIds(events: EventDTO[], pattern: string): PatternMatch {
const value = pattern.trim();
if (!value) return { ids: [], error: null };
let regex: RegExp;
try {
regex = new RegExp(value);
} catch {
return { ids: [], error: `"${value}" is not a valid regular expression.` };
}
return {
ids: identified(events)
.filter((event) => event.name && regex.test(event.name))
.map((event) => event.id),
error: null
};
}
/**
* Compares events by date. Events the API gave no date for cannot claim to be the
* latest, so they always sort last regardless of direction.
*
* `'desc'` is newest first (which event names a spreadsheet tab); `'asc'` is oldest
* first (which way a chart reads).
*/
export function compareByDate(
a: string | null | undefined,
b: string | null | undefined,
direction: 'asc' | 'desc' = 'asc'
): number {
const aTime = a ? Date.parse(a) : NaN;
const bTime = b ? Date.parse(b) : NaN;
const aOk = Number.isFinite(aTime);
const bOk = Number.isFinite(bTime);
if (aOk && bOk) return direction === 'asc' ? aTime - bTime : bTime - aTime;
if (aOk) return -1;
if (bOk) return 1;
return 0;
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { fullName, percent } from './format';
describe('percent', () => {
it('keeps one decimal below 100', () => {
expect(percent(0)).toBe('0.0%');
expect(percent(66.666)).toBe('66.7%');
expect(percent(99.9)).toBe('99.9%');
});
it('collapses to a bare 100% from 99.95 up, so the column never widens', () => {
expect(percent(99.95)).toBe('100%');
expect(percent(100)).toBe('100%');
});
});
describe('fullName', () => {
it('joins both names', () => {
expect(fullName('Ada', 'Lovelace')).toBe('Ada Lovelace');
});
it('uses whichever half is present', () => {
expect(fullName('Ada', null)).toBe('Ada');
expect(fullName(undefined, 'Lovelace')).toBe('Lovelace');
});
it('falls back when there is no name at all', () => {
expect(fullName(null, null)).toBe('');
expect(fullName(null, undefined, 'ada')).toBe('ada');
expect(fullName('', '', 'ada')).toBe('ada');
});
});
@@ -0,0 +1,24 @@
/**
* Small display helpers that were duplicated across routes.
*
* Pure: exercisable under plain Node.
*/
/**
* A percentage already on a 0..100 scale, rendered for a narrow table column:
* one decimal below 100, and a bare "100%" from 99.95 up so the column never
* widens to "100.0%".
*/
export function percent(value: number): string {
return value >= 99.95 ? '100%' : `${value.toFixed(1)}%`;
}
/** First and last name if either is set, otherwise the fallback (usually the username). */
export function fullName(
firstName: string | null | undefined,
lastName: string | null | undefined,
fallback = ''
): string {
const full = [firstName, lastName].filter(Boolean).join(' ').trim();
return full.length > 0 ? full : fallback;
}
@@ -0,0 +1,93 @@
import type { GameDTO } from '$lib/api/schema-helpers';
import { describe, expect, it } from 'vitest';
import { blankDraft, isDirty, nextOrder, toDraft, toDto } from './draft';
describe('toDraft / toDto', () => {
it('replaces every null with a value the inputs can bind to', () => {
const draft = toDraft({ id: 3, name: null, longName: null, order: undefined, smashId: undefined });
expect(draft).toEqual({
id: 3,
name: '',
longName: '',
order: 0,
imgUrl: '',
wordPressTag: '',
wordPressTagOs: '',
smashId: null
});
});
it('stores blank and whitespace-only text as NULL, not as an empty string', () => {
const dto = toDto({ ...blankDraft, name: 'SF6', longName: ' ', imgUrl: '' });
expect(dto.longName).toBeNull();
expect(dto.imgUrl).toBeNull();
expect(dto.name).toBe('SF6');
});
it('trims text on the way out', () => {
expect(toDto({ ...blankDraft, name: ' SF6 ' }).name).toBe('SF6');
});
it('round-trips a full game', () => {
const game: GameDTO = {
id: 4,
name: 'SF6',
longName: 'Street Fighter 6',
order: 2,
imgUrl: 'https://example.test/sf6.png',
wordPressTag: 'sf6',
wordPressTagOs: 'sf6-os',
smashId: 43868
};
expect(toDto(toDraft(game))).toEqual(game);
});
});
describe('isDirty', () => {
/*
* The editor used to compare `JSON.stringify` output, which is key-order
* dependent: an identical draft built with its keys in another order read as
* unsaved changes.
*/
it('is false for equal drafts regardless of key order', () => {
const a = { ...blankDraft, name: 'SF6', order: 2 };
const reordered = {
order: 2,
smashId: null,
wordPressTagOs: '',
wordPressTag: '',
imgUrl: '',
longName: '',
name: 'SF6',
id: 0
};
expect(JSON.stringify(a)).not.toBe(JSON.stringify(reordered));
expect(isDirty(a, reordered)).toBe(false);
});
it('detects a change in any field', () => {
const base = { ...blankDraft, name: 'SF6' };
expect(isDirty(base, { ...base, name: 'SFV' })).toBe(true);
expect(isDirty(base, { ...base, order: 9 })).toBe(true);
expect(isDirty(base, { ...base, smashId: 1 })).toBe(true);
expect(isDirty(base, { ...base })).toBe(false);
});
it('distinguishes null from 0 for the nullable numbers', () => {
expect(isDirty({ ...blankDraft, smashId: null }, { ...blankDraft, smashId: 0 })).toBe(true);
});
});
describe('nextOrder', () => {
it('sorts a new game after the current last one', () => {
expect(nextOrder([{ order: 1 }, { order: 5 }, { order: 3 }])).toBe(6);
});
it('starts at 1 for an empty catalogue', () => {
expect(nextOrder([])).toBe(1);
});
it('treats a missing order as 0', () => {
expect(nextOrder([{ order: undefined }])).toBe(1);
});
});
@@ -63,3 +63,23 @@ export function toDto(draft: Draft): GameDTO {
export function nextOrder(games: GameDTO[]): number {
return games.reduce((max, game) => Math.max(max, game.order ?? 0), 0) + 1;
}
/**
* Whether the editor holds unsaved changes.
*
* Field by field rather than by comparing `JSON.stringify` output, which is
* key-order dependent: it would report a clean draft as dirty the moment
* `blankDraft` and `toDraft` listed their keys in a different order.
*/
export function isDirty(a: Draft, b: Draft): boolean {
return (
a.id !== b.id ||
a.name !== b.name ||
a.longName !== b.longName ||
a.order !== b.order ||
a.imgUrl !== b.imgUrl ||
a.wordPressTag !== b.wordPressTag ||
a.wordPressTagOs !== b.wordPressTagOs ||
a.smashId !== b.smashId
);
}
-31
View File
@@ -1,31 +0,0 @@
// Re-export the API surface so pages can `import { login, session } from '$lib'`.
export { API_BASE_URL, ApiError, apiRequest, buildPath } from './api/client';
export { login, register } from './api/users';
export { toErrorMessage } from './api/errors';
export { addUser, deleteUser, listRoles, listUsers } from './api/admin-users';
export { deleteGame, listGames, saveGame, searchSmashGames } from './api/games';
export { getResults, importSmashTournament, listEvents } from './api/tournaments';
export { getMatchStats } from './api/statistics';
export { aggregate, formatMonth, standingsCsv } from './statistics/aggregate';
export type {
Aggregate,
EventAttendance,
EventResult,
GameSummary,
PlayerStanding,
Totals
} from './statistics/aggregate';
export { loadEventResults } from './statistics/load';
export type { FailedEvent, LoadOptions, LoadOutcome } from './statistics/load';
export { blankDraft, nextOrder, toDraft, toDto } from './games/draft';
export type { Draft } from './games/draft';
export { session } from './stores/session.svelte';
export {
buildCsv,
buildHtml,
buildRanking,
playedGames,
resultsForGame
} from './tournaments/results';
export type { RankingRow, RankingTable } from './tournaments/results';
export type * from './api/schema-helpers';
@@ -0,0 +1,206 @@
import type { EventDTO, ResultDTO } from '$lib/api/schema-helpers';
import { describe, expect, it } from 'vitest';
import { aggregate, formatMonth, standingsCsv, type EventResult } from './aggregate';
/** The sentinel ExternalProviderService emits for a player it could not place. */
const UNRANKED = 999;
const sf6 = { id: 1, name: 'SF6', longName: 'Street Fighter 6' };
const ssbu = { id: 2, name: 'SSBU', longName: 'Smash Ultimate' };
function row(partial: Partial<ResultDTO>): ResultDTO {
return { gameId: 1, player: 'Bob', rank: 1, point: 10, tournamentUrl: 'SF6', ...partial };
}
function scored(event: Partial<EventDTO>, results: ResultDTO[]): EventResult {
return {
event: { id: 1, name: 'Ranking #1301', date: '2026-01-10T00:00:00', ...event },
result: { games: [sf6, ssbu], results }
};
}
describe('aggregate — standings', () => {
it('sums points across events and counts distinct events and games', () => {
const stats = aggregate([
scored({ id: 1 }, [row({ player: 'Bob', point: 10 })]),
scored({ id: 2 }, [row({ player: 'Bob', point: 7, gameId: 2, tournamentUrl: 'SSBU' })])
]);
expect(stats.standings).toHaveLength(1);
expect(stats.standings[0]).toMatchObject({
player: 'Bob',
points: 17,
entries: 2,
events: 2,
games: 2
});
});
it('merges names that differ only in case or padding, keeping the first spelling', () => {
const stats = aggregate([
scored({}, [row({ player: 'Bob' }), row({ player: ' BOB ', rank: 2, point: 7 })])
]);
expect(stats.standings).toHaveLength(1);
expect(stats.standings[0]).toMatchObject({ player: 'Bob', points: 17, entries: 2 });
});
it('counts podium places and their total', () => {
const stats = aggregate([
scored({}, [
row({ player: 'Bob', rank: 1 }),
row({ player: 'Bob', rank: 2 }),
row({ player: 'Bob', rank: 3 }),
row({ player: 'Bob', rank: 4 })
])
]);
expect(stats.standings[0]).toMatchObject({
firsts: 1,
seconds: 1,
thirds: 1,
podiums: 3,
entries: 4
});
});
/*
* 999 is a "we could not place this player" bucket, not a placement. Reading it
* as one would report a best rank of 999 for anyone who only ever participated.
*/
it('never reads the 999 sentinel as a placement', () => {
const stats = aggregate([scored({}, [row({ player: 'Bob', rank: UNRANKED })])]);
expect(stats.standings[0].bestRank).toBeNull();
expect(stats.standings[0].podiums).toBe(0);
});
it('keeps the best real rank when a player also has unranked entries', () => {
const stats = aggregate([
scored({}, [row({ player: 'Bob', rank: UNRANKED }), row({ player: 'Bob', rank: 4 })])
]);
expect(stats.standings[0].bestRank).toBe(4);
});
it('sorts by points, then firsts, then podiums, then name', () => {
const stats = aggregate([
scored({}, [
row({ player: 'Amy', point: 10, rank: 4 }),
row({ player: 'Zoe', point: 10, rank: 1 }),
row({ player: 'Cid', point: 20, rank: 4 })
])
]);
// Cid leads on points; Zoe beats Amy on firsts at equal points.
expect(stats.standings.map((s) => s.player)).toEqual(['Cid', 'Zoe', 'Amy']);
});
it('ignores rows with no player name', () => {
const stats = aggregate([scored({}, [row({ player: ' ' }), row({ player: 'Bob' })])]);
expect(stats.standings.map((s) => s.player)).toEqual(['Bob']);
});
});
describe('aggregate — games', () => {
it('counts entries, distinct players and brackets per game', () => {
const stats = aggregate([
scored({}, [
row({ gameId: 1, player: 'Bob', tournamentUrl: 'SF6 A' }),
row({ gameId: 1, player: 'Amy', tournamentUrl: 'SF6 A' }),
row({ gameId: 1, player: 'Amy', tournamentUrl: 'SF6 B' })
])
]);
const game = stats.games.find((g) => g.gameId === 1);
expect(game).toMatchObject({ name: 'SF6', entries: 3, players: 2, brackets: 2 });
expect(game?.averageField).toBeCloseTo(1.5);
});
it('scopes brackets to their event, since bracket names repeat monthly', () => {
const stats = aggregate([
scored({ id: 1 }, [row({ tournamentUrl: 'SF6' })]),
scored({ id: 2 }, [row({ tournamentUrl: 'SF6' })])
]);
expect(stats.games[0].brackets).toBe(2);
});
it('reports the highest scoring player of each game', () => {
const stats = aggregate([
scored({}, [
row({ gameId: 1, player: 'Bob', point: 5 }),
row({ gameId: 1, player: 'Amy', point: 12 })
])
]);
expect(stats.games[0]).toMatchObject({ topPlayer: 'Amy', topPoints: 12 });
});
it('falls back to a placeholder name for a game the payload never described', () => {
const stats = aggregate([
{
event: { id: 1, name: 'Ranking' },
result: { games: [], results: [row({ gameId: 42 })] }
}
]);
expect(stats.games[0]).toMatchObject({ gameId: 42, name: '#42' });
});
});
describe('aggregate — attendance and totals', () => {
it('orders attendance oldest first, so a chart reads left to right', () => {
const stats = aggregate([
scored({ id: 2, name: 'June', date: '2026-06-01T00:00:00' }, [row({})]),
scored({ id: 1, name: 'January', date: '2026-01-01T00:00:00' }, [row({})])
]);
expect(stats.attendance.map((a) => a.name)).toEqual(['January', 'June']);
});
it('places undated events last, keeping their incoming order', () => {
const stats = aggregate([
scored({ id: 3, name: 'Undated A', date: undefined }, [row({})]),
scored({ id: 1, name: 'January', date: '2026-01-01T00:00:00' }, [row({})]),
scored({ id: 4, name: 'Undated B', date: undefined }, [row({})])
]);
expect(stats.attendance.map((a) => a.name)).toEqual(['January', 'Undated A', 'Undated B']);
});
it('totals events, entries, distinct players and points across the scope', () => {
const stats = aggregate([
scored({ id: 1 }, [row({ player: 'Bob', point: 10 }), row({ player: 'Amy', point: 7 })]),
scored({ id: 2 }, [row({ player: 'Bob', point: 3 })])
]);
expect(stats.totals).toMatchObject({ events: 2, entries: 3, players: 2, points: 20 });
});
it('returns an empty aggregate for an empty scope', () => {
const stats = aggregate([]);
expect(stats.standings).toEqual([]);
expect(stats.games).toEqual([]);
expect(stats.attendance).toEqual([]);
expect(stats.totals).toMatchObject({ events: 0, entries: 0, players: 0, points: 0 });
});
});
describe('standingsCsv', () => {
it('writes the header and one quoted row per player', () => {
const stats = aggregate([scored({}, [row({ player: 'Bob', point: 10, rank: 1 })])]);
const lines = standingsCsv(stats.standings).split('\r\n');
expect(lines[0]).toBe(
'"Player";"Points";"Entries";"Events";"Games";"1st";"2nd";"3rd";"Podiums";"Best rank"'
);
expect(lines[1]).toBe('"Bob";"10";"1";"1";"1";"1";"0";"0";"1";"1"');
expect(lines[2]).toBe('');
});
it('renders an absent best rank as an empty field rather than "null"', () => {
const stats = aggregate([scored({}, [row({ player: 'Bob', rank: UNRANKED })])]);
expect(standingsCsv(stats.standings)).toContain(';""\r\n');
});
});
describe('formatMonth', () => {
it('is empty for a missing or unparseable date', () => {
expect(formatMonth(null)).toBe('');
expect(formatMonth('not a date')).toBe('');
});
it('renders a month and year', () => {
expect(formatMonth('2026-08-05T00:00:00')).toMatch(/2026/);
});
});
@@ -1,4 +1,6 @@
import type { EventDTO, GameDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
import { toCsv } from '$lib/csv';
import { compareByDate } from '$lib/events';
/**
* Aggregation for the Statistiques page. Pure: it takes results already fetched
@@ -267,16 +269,10 @@ function buildGameSummaries(games: Map<number, GameAccumulator>): GameSummary[]
function sortByDate(attendance: EventAttendance[]): EventAttendance[] {
return attendance
.map((entry, index) => ({ entry, index }))
.sort((a, b) => {
const aTime = a.entry.date ? Date.parse(a.entry.date) : NaN;
const bTime = b.entry.date ? Date.parse(b.entry.date) : NaN;
const aOk = Number.isFinite(aTime);
const bOk = Number.isFinite(bTime);
if (aOk && bOk) return aTime - bTime || a.index - b.index;
if (aOk) return -1;
if (bOk) return 1;
return a.index - b.index;
})
.sort(
(a, b) =>
compareByDate(a.entry.date, b.entry.date, 'asc') || a.index - b.index
)
.map(({ entry }) => entry);
}
@@ -288,9 +284,8 @@ export function formatMonth(date: string | null): string {
return new Date(parsed).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
}
/** Semicolon-separated, quoted — same dialect as the tournaments CSV export. */
/** Semicolon-separated, quoted — the dialect `$lib/csv` defines. */
export function standingsCsv(standings: PlayerStanding[]): string {
const quote = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`;
const header = [
'Player',
'Points',
@@ -303,26 +298,20 @@ export function standingsCsv(standings: PlayerStanding[]): string {
'Podiums',
'Best rank'
];
const lines = [header.map(quote).join(';')];
for (const row of standings) {
lines.push(
[
row.player,
row.points,
row.entries,
row.events,
row.games,
row.firsts,
row.seconds,
row.thirds,
row.podiums,
row.bestRank ?? ''
]
.map(quote)
.join(';')
);
}
return lines.join('\r\n') + '\r\n';
return toCsv(
header,
standings.map((row) => [
row.player,
row.points,
row.entries,
row.events,
row.games,
row.firsts,
row.seconds,
row.thirds,
row.podiums,
row.bestRank ?? ''
])
);
}
@@ -0,0 +1,122 @@
import { ApiError } from '$lib/api/client';
import type { EventDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
import { beforeEach, describe, expect, it, vi } from 'vitest';
/*
* `loadEventResults` exists to contain the blast radius of one bad event, so what
* matters here is the failure behaviour: a broken event must not take the others
* with it, while an abort or a 401 must stop everything.
*/
// Typed so the assertions below read the call arguments without falling back to `any`.
const getResults = vi.hoisted(() =>
vi.fn<(ids: number[], options?: { signal?: AbortSignal }) => Promise<TournamentsResultDTO>>()
);
vi.mock('$lib/api/tournaments', () => ({ getResults }));
const { loadEventResults } = await import('./load');
function events(count: number): EventDTO[] {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Event ${i + 1}`,
date: undefined
}));
}
beforeEach(() => {
getResults.mockReset();
});
describe('loadEventResults', () => {
it('requests one event at a time and returns them all', async () => {
getResults.mockImplementation((ids: number[]) =>
Promise.resolve({ results: [{ player: `P${ids[0]}` }] })
);
const outcome = await loadEventResults(events(3));
expect(outcome.loaded).toHaveLength(3);
expect(outcome.failed).toEqual([]);
// One id per call — batching is what this module exists to avoid.
expect(getResults.mock.calls.map((c) => c[0])).toEqual([[1], [2], [3]]);
});
it('skips events with no id rather than requesting undefined', async () => {
getResults.mockResolvedValue({ results: [] });
await loadEventResults([{ id: 1, name: 'A' }, { name: 'B' }]);
expect(getResults).toHaveBeenCalledTimes(1);
});
it('isolates a failing event and reports it, keeping the rest', async () => {
getResults.mockImplementation((ids: number[]) =>
ids[0] === 2
? Promise.reject(new ApiError(500, 'Bracket has no rank-1 row'))
: Promise.resolve({ results: [] })
);
const outcome = await loadEventResults(events(3));
expect(outcome.loaded).toHaveLength(2);
expect(outcome.failed).toHaveLength(1);
expect(outcome.failed[0].event.id).toBe(2);
expect(outcome.failed[0].message).toBe('Bracket has no rank-1 row');
});
it('stops everything on a 401 — retrying N times would just burn requests', async () => {
getResults.mockRejectedValue(new ApiError(401, 'Unauthorized'));
await expect(loadEventResults(events(3))).rejects.toThrow(ApiError);
});
it('propagates an abort rather than recording it as a failed event', async () => {
getResults.mockRejectedValue(new DOMException('Aborted', 'AbortError'));
await expect(loadEventResults(events(2))).rejects.toThrow(DOMException);
});
it('makes no request at all when the signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
const outcome = await loadEventResults(events(3), { signal: controller.signal });
expect(getResults).not.toHaveBeenCalled();
expect(outcome.loaded).toEqual([]);
});
it('reports progress once per settled event, failures included', async () => {
getResults.mockImplementation((ids: number[]) =>
ids[0] === 2 ? Promise.reject(new ApiError(500, 'boom')) : Promise.resolve({ results: [] })
);
const seen: number[] = [];
await loadEventResults(events(3), { onProgress: (done, total) => {
expect(total).toBe(3);
seen.push(done);
} });
expect(seen).toEqual([1, 2, 3]);
});
it('never runs more requests at once than the concurrency allows', async () => {
let inFlight = 0;
let peak = 0;
getResults.mockImplementation(async () => {
inFlight++;
peak = Math.max(peak, inFlight);
await Promise.resolve();
inFlight--;
return { results: [] };
});
await loadEventResults(events(10), { concurrency: 3 });
expect(peak).toBeLessThanOrEqual(3);
expect(getResults).toHaveBeenCalledTimes(10);
});
it('handles an empty scope without spawning workers', async () => {
const outcome = await loadEventResults([]);
expect(outcome).toEqual({ loaded: [], failed: [] });
expect(getResults).not.toHaveBeenCalled();
});
});
@@ -3,27 +3,46 @@ import type { AuthenticatedUser } from '$lib/api/schema-helpers';
const STORAGE_KEY = 'ladose.session';
/** Restores the session written by a previous visit, discarding it if the JWT expired. */
/**
* Restores the session written by a previous visit, discarding it if the JWT expired.
*
* Every localStorage access sits inside the try: reading the property at all throws
* in Safari private mode and with cookies blocked, and this runs in the `#user`
* field initialiser, so an escaping error would fail module init and blank the app.
*/
function restore(): AuthenticatedUser | null {
if (!browser) return null;
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const user = JSON.parse(raw) as AuthenticatedUser;
if (!user?.token || !user.username) return null;
// `roles` is optional in the DTO but `isAdmin` calls `.some` on it. A
// hand-edited entry could hold a string, so drop anything that is neither
// absent nor an array instead of throwing at render time.
if (user.roles != null && !Array.isArray(user.roles)) return null;
if (isExpired(user)) {
localStorage.removeItem(STORAGE_KEY);
forget();
return null;
}
return user;
} catch {
localStorage.removeItem(STORAGE_KEY);
forget();
return null;
}
}
/** Best-effort removal: storage being unavailable is not worth failing a sign-out over. */
function forget(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* localStorage throws in Safari private mode and with cookies blocked */
}
}
/** The API issues short-lived tokens (16 min), so treat a lapsed one as logged out. */
function isExpired(user: AuthenticatedUser): boolean {
if (!user.expire) return false;
@@ -72,14 +91,24 @@ class Session {
return full.length > 0 ? full : this.#user.username;
}
/**
* The in-memory session is the source of truth; localStorage only survives a
* refresh. Persisting is therefore best-effort a storage failure must not
* surface as a failed sign-in when authentication actually succeeded.
*/
start(user: AuthenticatedUser): void {
this.#user = user;
if (browser) localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
if (!browser) return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
} catch {
/* localStorage throws in Safari private mode and with cookies blocked */
}
}
clear(): void {
this.#user = null;
if (browser) localStorage.removeItem(STORAGE_KEY);
if (browser) forget();
}
}
@@ -0,0 +1,149 @@
import type { GameDTO, ResultDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
import { describe, expect, it } from 'vitest';
import { buildCsv, buildHtml, buildRanking, playedGames, rankingHeader, resultsForGame } from './results';
const sf6: GameDTO = { id: 1, name: 'SF6', longName: 'Street Fighter 6', order: 1 };
const ssbu: GameDTO = { id: 2, name: 'SSBU', longName: 'Smash Ultimate', order: 2 };
function result(partial: Partial<ResultDTO>): ResultDTO {
return { gameId: 1, player: 'Bob', rank: 1, point: 10, ...partial };
}
function payload(partial: Partial<TournamentsResultDTO> = {}): TournamentsResultDTO {
return {
games: [sf6, ssbu],
participents: [{ name: 'Bob' }, { name: 'Alice' }],
results: [
result({ gameId: 1, player: 'Bob', rank: 1, point: 10 }),
result({ gameId: 1, player: 'Alice', rank: 2, point: 7 }),
result({ gameId: 2, player: 'Alice', rank: 1, point: 10 })
],
...partial
};
}
describe('playedGames', () => {
it('returns only games that have results, in Game.Order', () => {
const games = playedGames(payload({ games: [ssbu, sf6] }));
expect(games.map((g) => g.name)).toEqual(['SF6', 'SSBU']);
});
it('drops a game nobody entered', () => {
const only = playedGames(payload({ results: [result({ gameId: 1 })] }));
expect(only.map((g) => g.id)).toEqual([1]);
});
it('handles a null payload', () => {
expect(playedGames(null)).toEqual([]);
});
});
describe('buildRanking', () => {
it('sums points per game and sorts by total, highest first', () => {
const table = buildRanking(payload());
expect(table.rows.map((r) => [r.player, r.total])).toEqual([
['Alice', 17],
['Bob', 10]
]);
});
it('keeps points index-aligned with games — the whole grid depends on it', () => {
const table = buildRanking(payload());
expect(table.games.map((g) => g.name)).toEqual(['SF6', 'SSBU']);
const alice = table.rows.find((r) => r.player === 'Alice');
expect(alice?.points).toEqual([7, 10]);
expect(alice?.points).toHaveLength(table.games.length);
});
it('merges spellings that differ only in case', () => {
const table = buildRanking(
payload({
participents: [{ name: 'Bob' }, { name: 'BOB' }],
results: [
result({ player: 'Bob', point: 10 }),
result({ player: 'BOB', point: 5, rank: 2 })
]
})
);
expect(table.rows).toHaveLength(1);
expect(table.rows[0]).toMatchObject({ player: 'Bob', total: 15 });
});
it('breaks a tie on total by player name', () => {
const table = buildRanking(
payload({
participents: [{ name: 'Zoe' }, { name: 'Amy' }],
results: [result({ player: 'Zoe' }), result({ player: 'Amy' })]
})
);
expect(table.rows.map((r) => r.player)).toEqual(['Amy', 'Zoe']);
});
it('ignores unnamed participants', () => {
const table = buildRanking(payload({ participents: [{ name: ' ' }, { name: 'Bob' }] }));
expect(table.rows.map((r) => r.player)).toEqual(['Bob']);
});
});
describe('resultsForGame', () => {
it('returns one game, best rank first', () => {
const rows = resultsForGame(payload(), 1);
expect(rows.map((r) => r.player)).toEqual(['Bob', 'Alice']);
});
it('is empty for no selection', () => {
expect(resultsForGame(payload(), null)).toEqual([]);
});
});
describe('buildCsv', () => {
it('uses the same header as the spreadsheet tab', () => {
const table = buildRanking(payload());
expect(rankingHeader(table)).toEqual(['Players', 'SF6', 'SSBU', 'Total']);
expect(buildCsv(table).split('\r\n')[0]).toBe('"Players";"SF6";"SSBU";"Total"');
});
it('writes one row per player with a trailing newline', () => {
expect(buildCsv(buildRanking(payload()))).toBe(
'"Players";"SF6";"SSBU";"Total"\r\n"Alice";"7";"10";"17"\r\n"Bob";"10";"0";"10"\r\n'
);
});
});
describe('buildHtml', () => {
it('escapes interpolated values — this feeds an {@html} block', () => {
const html = buildHtml(
payload({
games: [{ ...sf6, longName: 'Fight & <b>Win</b>' }],
results: [result({ gameId: 1, player: '<script>alert(1)</script>' })]
})
);
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
expect(html).toContain('Fight &amp; &lt;b&gt;Win&lt;/b&gt;');
});
it('omits the bracket link when the API gave no slug', () => {
expect(buildHtml(payload())).not.toContain('start.gg');
});
it('links to the bracket when a slug is present', () => {
const html = buildHtml(
payload({
slug: 'ladose-13',
results: [result({ gameId: 1, tournamentUrl: 'Street Fighter 6' })]
})
);
expect(html).toContain('https://start.gg/tournament/ladose-13/event/Street-Fighter-6');
});
it('spans a lone trailing game across both columns', () => {
const html = buildHtml(payload({ games: [sf6], results: [result({ gameId: 1 })] }));
expect(html).toContain('colspan="2"');
});
it('returns an empty string for a null payload', () => {
expect(buildHtml(null)).toBe('');
});
});
@@ -1,4 +1,5 @@
import type { GameDTO, ResultDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
import { toCsv } from '$lib/csv';
/**
* Scoring lives on the server (ExternalProviderService applies the point rules);
@@ -145,15 +146,18 @@ export function buildHtml(result: TournamentsResultDTO | null): string {
return parts.join('');
}
/** Excel is picky: semicolon separated, every field quoted, inner quotes doubled. */
/**
* The column titles of the ranking grid. Shared so the CSV and the spreadsheet tab
* (`$lib/tournaments/sheet`) cannot drift apart they used to be two literals.
*/
export function rankingHeader(table: RankingTable): string[] {
return ['Players', ...table.games.map((g) => g.name ?? ''), 'Total'];
}
/** The ranking grid as CSV, in the dialect `$lib/csv` defines. */
export function buildCsv(table: RankingTable): string {
const quote = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`;
const header = ['Players', ...table.games.map((g) => g.name ?? ''), 'Total'];
const lines = [header.map(quote).join(';')];
for (const row of table.rows) {
lines.push([row.player, ...row.points, row.total].map(quote).join(';'));
}
return lines.join('\r\n') + '\r\n';
return toCsv(
rankingHeader(table),
table.rows.map((row) => [row.player, ...row.points, row.total])
);
}
@@ -0,0 +1,90 @@
import type { EventDTO } from '$lib/api/schema-helpers';
import { describe, expect, it } from 'vitest';
import type { RankingTable } from './results';
import { rankingToSheetTable, suggestedTabName } from './sheet';
const jan: EventDTO = { id: 1, name: 'Ranking #1301', date: '2026-01-10T00:00:00' };
const jun: EventDTO = { id: 2, name: 'Ranking #1306', date: '2026-06-10T00:00:00' };
const undated: EventDTO = { id: 3, name: 'Tournoi surprise' };
const table: RankingTable = {
games: [{ id: 1, name: 'SF6' }],
rows: [{ player: 'Bob', points: [10], total: 10 }]
};
describe('suggestedTabName', () => {
it('names the tab after the latest event, whatever the input order', () => {
expect(suggestedTabName([jan, jun])).toBe('Ranking #1306');
expect(suggestedTabName([jun, jan])).toBe('Ranking #1306');
});
it('never lets an undated event claim to be the latest', () => {
expect(suggestedTabName([jan, undated])).toBe('Ranking #1301');
});
it('falls back to a generic label for an empty selection', () => {
expect(suggestedTabName([])).toBe('Ranking');
});
it('falls back to the id when the event has no usable name', () => {
expect(suggestedTabName([{ id: 7, name: ' ' }])).toBe('Event 7');
});
});
describe('rankingToSheetTable', () => {
const options = { name: 'Ranking #1306', generatedAt: '2026-08-07T10:00:00.000Z' };
it('reuses the ranking header, so the tab and the CSV cannot drift', () => {
const sheet = rankingToSheetTable(table, { ...options, events: [jun] });
expect(sheet.header).toEqual(['Players', 'SF6', 'Total']);
});
it('stamps the latest event id for provenance', () => {
expect(rankingToSheetTable(table, { ...options, events: [jan, jun] }).eventId).toBe(2);
});
/*
* `latestId` used to find its event by comparing computed *display names*, so two
* events sharing a name returned whichever came first in the array rather than the
* latest one.
*/
it('picks the latest event by date even when two share a name', () => {
const sheet = rankingToSheetTable(table, {
...options,
events: [
{ id: 10, name: 'Ranking', date: '2026-01-01T00:00:00' },
{ id: 20, name: 'Ranking', date: '2026-09-01T00:00:00' }
]
});
expect(sheet.eventId).toBe(20);
});
it('picks the latest even when every event falls back to the same generic label', () => {
const sheet = rankingToSheetTable(table, {
...options,
events: [
{ id: 0, name: null, date: '2026-01-01T00:00:00' },
{ id: 0, name: null, date: '2026-09-01T00:00:00' }
]
});
expect(sheet.eventId).toBe(0);
});
it('uses eventId 0 for an empty selection', () => {
expect(rankingToSheetTable(table, { ...options, events: [] }).eventId).toBe(0);
});
it('names its sources in the footer so a stale tab says so', () => {
const one = rankingToSheetTable(table, { ...options, events: [jun] });
expect(one.footer?.[0]).toBe('Scored from Ranking #1306');
const many = rankingToSheetTable(table, { ...options, events: [jan, jun] });
expect(many.footer?.[0]).toBe('Scored from 2 events: Ranking #1301, Ranking #1306');
expect(many.footer?.[1]).toContain('2026-08-07T10:00:00.000Z');
});
it('carries the rows through unchanged', () => {
const sheet = rankingToSheetTable(table, { ...options, events: [jun] });
expect(sheet.rows).toEqual([{ player: 'Bob', points: [10], total: 10 }]);
});
});
@@ -1,5 +1,6 @@
import type { EventDTO, SheetTableDTO } from '$lib/api/schema-helpers';
import type { RankingTable } from './results';
import { compareByDate } from '$lib/events';
import { rankingHeader, type RankingTable } from './results';
/**
* Turns the generated ranking grid into one spreadsheet tab.
@@ -21,26 +22,26 @@ export interface SheetTableOptions {
}
/**
* The tab a selection belongs in: the latest event in it, since a ranking day's table
* aggregates every event up to that day. Falls back to the first event, then to a
* generic label, so this always returns something usable as a title.
* The latest event of a selection the one that names the tab, since a ranking day's
* table aggregates every event up to that day. Undated events cannot claim to be the
* latest, so they sort last. Returns undefined only for an empty list.
*/
function latestEvent(events: EventDTO[]): EventDTO | undefined {
return [...events].sort((a, b) => compareByDate(a.date, b.date, 'desc'))[0];
}
/** The title `latest` would be given, falling back to a generic but usable label. */
function tabNameOf(event: EventDTO): string {
return event.name?.trim() || `Event ${event.id ?? 0}`;
}
/**
* The tab a selection belongs in. Falls back to a generic label, so this always
* returns something usable as a title.
*/
export function suggestedTabName(events: EventDTO[]): string {
if (events.length === 0) return 'Ranking';
const latest = [...events].sort((a, b) => {
const aTime = a.date ? Date.parse(a.date) : NaN;
const bTime = b.date ? Date.parse(b.date) : NaN;
const aOk = Number.isFinite(aTime);
const bOk = Number.isFinite(bTime);
// Undated events cannot claim to be the latest.
if (aOk && bOk) return bTime - aTime;
if (aOk) return -1;
if (bOk) return 1;
return 0;
})[0];
return latest.name?.trim() || `Event ${latest.id ?? 0}`;
const latest = latestEvent(events);
return latest ? tabNameOf(latest) : 'Ranking';
}
export function rankingToSheetTable(
@@ -53,8 +54,8 @@ export function rankingToSheetTable(
name: options.name,
// Provenance only — the tab is addressed by title. The latest event, matching
// suggestedTabName, so the id and the default title agree.
eventId: options.events.length ? (latestId(options.events) ?? 0) : 0,
header: ['Players', ...table.games.map((game) => game.name ?? ''), 'Total'],
eventId: latestEvent(options.events)?.id ?? 0,
header: rankingHeader(table),
rows: table.rows.map((row) => ({
player: row.player,
points: row.points,
@@ -69,8 +70,3 @@ export function rankingToSheetTable(
]
};
}
function latestId(events: EventDTO[]): number | undefined {
const name = suggestedTabName(events);
return events.find((event) => (event.name?.trim() || `Event ${event.id ?? 0}`) === name)?.id;
}
@@ -102,6 +102,21 @@
LaDOSE
</a>
<!--
One menu entry. Rendered by both dropdowns and by every section of the
mobile panel, which used to hold three hand-copied versions of this.
-->
{#snippet menuLink(link: NavItem)}
<a
href={link.href}
role="menuitem"
class={isActive(link.href) ? menuItemActive : menuItem}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
{/snippet}
<!--
Both desktop dropdowns are the same control with a different list, so they
share one snippet: two hand-copied panels drift the moment one is touched.
@@ -133,14 +148,7 @@
{#if openMenu === id}
<div class="{menuPanel} w-56" role="menu">
{#each links as link (link.href)}
<a
href={link.href}
role="menuitem"
class={isActive(link.href) ? menuItemActive : menuItem}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
{@render menuLink(link)}
{/each}
</div>
{/if}
@@ -200,42 +208,21 @@
{#if openMenu === 'mobile'}
<div class="{menuPanel} w-56" role="menu">
{#each mainLinks as link (link.href)}
<a
href={link.href}
role="menuitem"
class={isActive(link.href) ? menuItemActive : menuItem}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
{@render menuLink(link)}
{/each}
<p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase">
Statistiques
</p>
{#each statsLinks as link (link.href)}
<a
href={link.href}
role="menuitem"
class={isActive(link.href) ? menuItemActive : menuItem}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
{@render menuLink(link)}
{/each}
<p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase">
Settings
</p>
{#each settingsLinks as link (link.href)}
<a
href={link.href}
role="menuitem"
class={isActive(link.href) ? menuItemActive : menuItem}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
{@render menuLink(link)}
{/each}
<div class="mt-1 border-t border-line pt-1">
@@ -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>
+10 -2
View File
@@ -1,7 +1,7 @@
import adapter from '@sveltejs/adapter-static';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
@@ -17,5 +17,13 @@ export default defineConfig({
// `fallback` hands every unknown path to the client-side router.
adapter: adapter({ fallback: 'index.html' })
})
]
],
test: {
// The pure modules only — `$lib/statistics`, `$lib/tournaments` and the small
// helpers beside them. Everything under `ui/` or `routes/` needs a component
// harness, which this project deliberately does not have yet.
include: ['src/**/*.test.ts'],
environment: 'node'
}
});