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.d.ts` | Generated types — all 31 API paths and every DTO |
| `src/lib/api/schema-helpers.ts` | Friendly aliases (`ApplicationUserDTO`, `LoginRequest`, …) | | `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/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/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/tournaments.ts` | `listEvents` / `importSmashTournament` / `getResults`, authenticated from the session |
| `src/lib/api/games.ts` | `listGames` / `saveGame` / `deleteGame` / `searchSmashGames` | | `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. 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 `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: page is reachable at all, one account has to be promoted directly in the database:
```bash ```bash
@@ -266,5 +267,11 @@ How roles work:
```bash ```bash
npm run check # svelte-check (types + template diagnostics) 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 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", "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: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: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": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1", "@eslint/js": "^10.0.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"eslint": "^10.8.0",
"eslint-plugin-svelte": "^3.22.0",
"globals": "^17.9.0",
"openapi-typescript": "^7.13.0", "openapi-typescript": "^7.13.0",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "^6.0.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 PageState {}
// interface Platform {} // 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 {}; export {};
+5 -4
View File
@@ -12,8 +12,11 @@
--> -->
<script src="%sveltekit.assets%/config.js"></script> <script src="%sveltekit.assets%/config.js"></script>
<!-- <!--
Theme, before %sveltekit.head% emits the render-blocking stylesheet, so Theme, ahead of the head placeholder below that emits the render-blocking
data-theme is already on <html> when its selectors are first matched. 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 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 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 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 */ /* localStorage throws in Safari private mode and with cookies blocked */
} }
</script> </script>
<!--
%sveltekit.head% %sveltekit.head%
-->
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <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 { apiRequest, buildPath, type RequestOptions } from './client';
import type { ApplicationUserDTO, NewUserRequest } from './schema-helpers'; 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. * `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. */ /** GET /Users — every account, ordered by username. Never includes password material. */
export function listUsers(options: RequestOptions = {}): Promise<ApplicationUserDTO[]> { 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. * that is not in this list is rejected by the API rather than creating a new role.
*/ */
export function listRoles(options: RequestOptions = {}): Promise<string[]> { 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 = {} options: RequestOptions = {}
): Promise<ApplicationUserDTO> { ): Promise<ApplicationUserDTO> {
return apiRequest<ApplicationUserDTO>('/Users/AddUser', { return apiRequest<ApplicationUserDTO>('/Users/AddUser', {
...authed(options), ...options,
method: 'POST', method: 'POST',
body: user body: user
}); });
@@ -47,7 +42,7 @@ export function addUser(
*/ */
export async function deleteUser(id: number, options: RequestOptions = {}): Promise<void> { export async function deleteUser(id: number, options: RequestOptions = {}): Promise<void> {
await apiRequest<void>(buildPath('/Users/{id}', { id }), { await apiRequest<void>(buildPath('/Users/{id}', { id }), {
...authed(options), ...options,
method: 'DELETE' method: 'DELETE'
}); });
} }
+29 -15
View File
@@ -1,12 +1,6 @@
import { session } from '$lib/stores/session.svelte';
import type { ApiPath } from './schema-helpers'; 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: * Base URL of LaDOSE.Api, resolved in this order:
* *
@@ -24,7 +18,11 @@ function resolveBaseUrl(): string {
const runtime = const runtime =
typeof window !== 'undefined' ? window.__LADOSE_CONFIG__?.apiBaseUrl : undefined; 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(/\/$/, ''); return configured.replace(/\/$/, '');
} }
@@ -53,7 +51,14 @@ function extractMessage(body: unknown, status: number): string {
export interface RequestOptions { export interface RequestOptions {
method?: 'GET' | 'POST' | 'DELETE'; method?: 'GET' | 'POST' | 'DELETE';
body?: unknown; 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; token?: string | null;
fetch?: typeof globalThis.fetch; fetch?: typeof globalThis.fetch;
signal?: AbortSignal; signal?: AbortSignal;
@@ -62,13 +67,16 @@ export interface RequestOptions {
/** /**
* Calls LaDOSE.Api. `path` is constrained to the paths in the generated OpenAPI * 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. * 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>( export async function apiRequest<TResponse>(
path: ApiPath | (string & {}), path: ApiPath | BuiltPath,
options: RequestOptions = {} options: RequestOptions = {}
): Promise<TResponse> { ): 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' }; const headers: Record<string, string> = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = '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 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) { if (!response.ok) {
throw new ApiError(response.status, extractMessage(payload, response.status)); throw new ApiError(response.status, extractMessage(payload, response.status));
@@ -100,14 +108,20 @@ export async function apiRequest<TResponse>(
return payload as 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 }). */ /** Fills a templated OpenAPI path, e.g. buildPath('/api/Game/{id}', { id: 3 }). */
export function buildPath( export function buildPath(
template: ApiPath, template: ApiPath,
params: Record<string, string | number> params: Record<string, string | number>
): string { ): BuiltPath {
return template.replace(/\{(\w+)\}/g, (_, key: string) => { return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = params[key]; const value = params[key];
if (value === undefined) throw new Error(`Missing route parameter "${key}" for ${template}`); if (value === undefined) throw new Error(`Missing route parameter "${key}" for ${template}`);
return encodeURIComponent(String(value)); 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. // ApiError already carries the API's own `message`, or "unreachable" for status 0.
return cause instanceof ApiError ? cause.message : fallback; 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 { apiRequest, buildPath, type RequestOptions } from './client';
import type { GameDTO } from './schema-helpers'; 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). */ /** GET /api/Game — every game, in database order (sort by `order` for display). */
export function listGames(options: RequestOptions = {}): Promise<GameDTO[]> { 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. * Returns the saved game with its assigned id.
*/ */
export function saveGame(game: GameDTO, options: RequestOptions = {}): Promise<GameDTO> { 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> { export async function deleteGame(id: number, options: RequestOptions = {}): Promise<void> {
await apiRequest<void>(buildPath('/api/Game/{id}', { id }), { await apiRequest<void>(buildPath('/api/Game/{id}', { id }), {
...authed(options), ...options,
method: 'DELETE' method: 'DELETE'
}); });
} }
@@ -41,5 +36,5 @@ export function searchSmashGames(
name: string, name: string,
options: RequestOptions = {} options: RequestOptions = {}
): Promise<GameDTO[]> { ): 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 { apiRequest, type RequestOptions } from './client';
import type { import type {
SheetExportRequestDTO, SheetExportRequestDTO,
@@ -6,18 +6,13 @@ import type {
SheetsConfigDTO SheetsConfigDTO
} from './schema-helpers'; } 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 * 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 * 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. * rather than offering it as an input. Carries no credentials.
*/ */
export function getSheetsConfig(options: RequestOptions = {}): Promise<SheetsConfigDTO> { 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 = {} options: RequestOptions = {}
): Promise<SheetExportResultDTO> { ): Promise<SheetExportResultDTO> {
return apiRequest<SheetExportResultDTO>('/api/Sheets/Export', { return apiRequest<SheetExportResultDTO>('/api/Sheets/Export', {
...authed(options), ...options,
method: 'POST', method: 'POST',
body: request 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 { apiRequest, buildPath, type RequestOptions } from './client';
import type { MatchStatsDTO, PlayerOptionDTO, PlayerVersusDTO } from './schema-helpers'; 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 * 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. * head) for the given events, read from the `set` rows an import persisted.
@@ -22,7 +17,7 @@ export function getMatchStats(
options: RequestOptions = {} options: RequestOptions = {}
): Promise<MatchStatsDTO> { ): Promise<MatchStatsDTO> {
return apiRequest<MatchStatsDTO>('/api/Statistics/Matches', { return apiRequest<MatchStatsDTO>('/api/Statistics/Matches', {
...authed(options), ...options,
method: 'POST', method: 'POST',
body: eventIds body: eventIds
}); });
@@ -35,7 +30,7 @@ export function getMatchStats(
* whose game is known — the same filter `getVersus` applies. * whose game is known — the same filter `getVersus` applies.
*/ */
export function listVersusPlayers(options: RequestOptions = {}): Promise<PlayerOptionDTO[]> { 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, playerAId,
playerBId 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 { apiRequest, buildPath, type RequestOptions } from './client';
import type { EventDTO, TournamentsResultDTO } from './schema-helpers'; 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 * GET /api/Event — every imported event, newest first (the controller orders by
* `Date` descending). A start.gg tournament becomes one Event holding one * `Date` descending). A start.gg tournament becomes one Event holding one
* Tournament per bracket. * Tournament per bracket.
*/ */
export function listEvents(options: RequestOptions = {}): Promise<EventDTO[]> { 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}', { const path = buildPath('/api/Tournament/ParseSmash/{tournamentSlug}', {
tournamentSlug: slug tournamentSlug: slug
}); });
return apiRequest<boolean>(path, authed(options)); return apiRequest<boolean>(path, options);
} }
/** /**
@@ -46,7 +41,7 @@ export function getResults(
options: RequestOptions = {} options: RequestOptions = {}
): Promise<TournamentsResultDTO> { ): Promise<TournamentsResultDTO> {
return apiRequest<TournamentsResultDTO>('/api/Tournament/GetResults', { return apiRequest<TournamentsResultDTO>('/api/Tournament/GetResults', {
...authed(options), ...options,
method: 'POST', method: 'POST',
body: eventIds body: eventIds
}); });
@@ -8,7 +8,11 @@ import type { ApplicationUserDTO, AuthenticatedUser, LoginRequest } from './sche
export async function login(credentials: LoginRequest): Promise<AuthenticatedUser> { export async function login(credentials: LoginRequest): Promise<AuthenticatedUser> {
const user = await apiRequest<ApplicationUserDTO>('/Users/auth', { const user = await apiRequest<ApplicationUserDTO>('/Users/auth', {
method: 'POST', 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 // 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; 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 { export function nextOrder(games: GameDTO[]): number {
return games.reduce((max, game) => Math.max(max, game.order ?? 0), 0) + 1; 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 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 * 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[] { function sortByDate(attendance: EventAttendance[]): EventAttendance[] {
return attendance return attendance
.map((entry, index) => ({ entry, index })) .map((entry, index) => ({ entry, index }))
.sort((a, b) => { .sort(
const aTime = a.entry.date ? Date.parse(a.entry.date) : NaN; (a, b) =>
const bTime = b.entry.date ? Date.parse(b.entry.date) : NaN; compareByDate(a.entry.date, b.entry.date, 'asc') || a.index - b.index
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;
})
.map(({ entry }) => entry); .map(({ entry }) => entry);
} }
@@ -288,9 +284,8 @@ export function formatMonth(date: string | null): string {
return new Date(parsed).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }); 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 { export function standingsCsv(standings: PlayerStanding[]): string {
const quote = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`;
const header = [ const header = [
'Player', 'Player',
'Points', 'Points',
@@ -303,11 +298,10 @@ export function standingsCsv(standings: PlayerStanding[]): string {
'Podiums', 'Podiums',
'Best rank' 'Best rank'
]; ];
const lines = [header.map(quote).join(';')];
for (const row of standings) { return toCsv(
lines.push( header,
[ standings.map((row) => [
row.player, row.player,
row.points, row.points,
row.entries, row.entries,
@@ -318,11 +312,6 @@ export function standingsCsv(standings: PlayerStanding[]): string {
row.thirds, row.thirds,
row.podiums, row.podiums,
row.bestRank ?? '' row.bestRank ?? ''
] ])
.map(quote)
.join(';')
); );
} }
return lines.join('\r\n') + '\r\n';
}
@@ -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'; 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 { function restore(): AuthenticatedUser | null {
if (!browser) return null; if (!browser) return null;
try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null; if (!raw) return null;
try {
const user = JSON.parse(raw) as AuthenticatedUser; const user = JSON.parse(raw) as AuthenticatedUser;
if (!user?.token || !user.username) return null; 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)) { if (isExpired(user)) {
localStorage.removeItem(STORAGE_KEY); forget();
return null; return null;
} }
return user; return user;
} catch { } catch {
localStorage.removeItem(STORAGE_KEY); forget();
return null; 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. */ /** The API issues short-lived tokens (16 min), so treat a lapsed one as logged out. */
function isExpired(user: AuthenticatedUser): boolean { function isExpired(user: AuthenticatedUser): boolean {
if (!user.expire) return false; if (!user.expire) return false;
@@ -72,14 +91,24 @@ class Session {
return full.length > 0 ? full : this.#user.username; 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 { start(user: AuthenticatedUser): void {
this.#user = user; 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 { clear(): void {
this.#user = null; 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 type { GameDTO, ResultDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
import { toCsv } from '$lib/csv';
/** /**
* Scoring lives on the server (ExternalProviderService applies the point rules); * Scoring lives on the server (ExternalProviderService applies the point rules);
@@ -145,15 +146,18 @@ export function buildHtml(result: TournamentsResultDTO | null): string {
return parts.join(''); 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 { export function buildCsv(table: RankingTable): string {
const quote = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`; return toCsv(
const header = ['Players', ...table.games.map((g) => g.name ?? ''), 'Total']; rankingHeader(table),
const lines = [header.map(quote).join(';')]; table.rows.map((row) => [row.player, ...row.points, row.total])
);
for (const row of table.rows) {
lines.push([row.player, ...row.points, row.total].map(quote).join(';'));
}
return lines.join('\r\n') + '\r\n';
} }
@@ -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 { 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. * 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 * The latest event of a selection the one that names the tab, since a ranking day's
* aggregates every event up to that day. Falls back to the first event, then to a * table aggregates every event up to that day. Undated events cannot claim to be the
* generic label, so this always returns something usable as a title. * 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 { export function suggestedTabName(events: EventDTO[]): string {
if (events.length === 0) return 'Ranking'; const latest = latestEvent(events);
return latest ? tabNameOf(latest) : '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}`;
} }
export function rankingToSheetTable( export function rankingToSheetTable(
@@ -53,8 +54,8 @@ export function rankingToSheetTable(
name: options.name, name: options.name,
// Provenance only — the tab is addressed by title. The latest event, matching // Provenance only — the tab is addressed by title. The latest event, matching
// suggestedTabName, so the id and the default title agree. // suggestedTabName, so the id and the default title agree.
eventId: options.events.length ? (latestId(options.events) ?? 0) : 0, eventId: latestEvent(options.events)?.id ?? 0,
header: ['Players', ...table.games.map((game) => game.name ?? ''), 'Total'], header: rankingHeader(table),
rows: table.rows.map((row) => ({ rows: table.rows.map((row) => ({
player: row.player, player: row.player,
points: row.points, 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 LaDOSE
</a> </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 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. share one snippet: two hand-copied panels drift the moment one is touched.
@@ -133,14 +148,7 @@
{#if openMenu === id} {#if openMenu === id}
<div class="{menuPanel} w-56" role="menu"> <div class="{menuPanel} w-56" role="menu">
{#each links as link (link.href)} {#each links as link (link.href)}
<a {@render menuLink(link)}
href={link.href}
role="menuitem"
class={isActive(link.href) ? menuItemActive : menuItem}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -200,42 +208,21 @@
{#if openMenu === 'mobile'} {#if openMenu === 'mobile'}
<div class="{menuPanel} w-56" role="menu"> <div class="{menuPanel} w-56" role="menu">
{#each mainLinks as link (link.href)} {#each mainLinks as link (link.href)}
<a {@render menuLink(link)}
href={link.href}
role="menuitem"
class={isActive(link.href) ? menuItemActive : menuItem}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
{/each} {/each}
<p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase"> <p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase">
Statistiques Statistiques
</p> </p>
{#each statsLinks as link (link.href)} {#each statsLinks as link (link.href)}
<a {@render menuLink(link)}
href={link.href}
role="menuitem"
class={isActive(link.href) ? menuItemActive : menuItem}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
{/each} {/each}
<p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase"> <p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase">
Settings Settings
</p> </p>
{#each settingsLinks as link (link.href)} {#each settingsLinks as link (link.href)}
<a {@render menuLink(link)}
href={link.href}
role="menuitem"
class={isActive(link.href) ? menuItemActive : menuItem}
aria-current={isActive(link.href) ? 'page' : undefined}
>
{link.label}
</a>
{/each} {/each}
<div class="mt-1 border-t border-line pt-1"> <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"> <script lang="ts">
import type { Snippet } from 'svelte';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import Navbar from '$lib/ui/Navbar.svelte'; import Navbar from '$lib/ui/Navbar.svelte';
import '../app.css'; import '../app.css';
let { children } = $props(); let { children }: { children: Snippet } = $props();
</script> </script>
<svelte:head> <svelte:head>
@@ -1,12 +1,10 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { requireSession } from '$lib/auth/guard.svelte';
import { session } from '$lib/stores/session.svelte'; import { session } from '$lib/stores/session.svelte';
import { card } from '$lib/ui/classes'; import { card } from '$lib/ui/classes';
// Guard the page: no session (or an expired JWT) sends the user to /login. // No session (or an expired JWT) sends the user to /login.
$effect(() => { const guard = requireSession();
if (!session.isLoggedIn) goto('/login', { replaceState: true });
});
interface Shortcut { interface Shortcut {
href: string; href: string;
@@ -52,7 +50,7 @@
</svelte:head> </svelte:head>
<main id="main" class="mx-auto max-w-4xl px-4 py-12"> <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> <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> <p class="mt-3 text-sm text-muted">You are signed in to LaDOSE.</p>
@@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { deleteGame, listGames, saveGame, searchSmashGames } from '$lib/api/games'; 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 type { GameDTO } from '$lib/api/schema-helpers';
import { blankDraft, nextOrder, toDraft, toDto, type Draft } from '$lib/games/draft'; import { requireSession } from '$lib/auth/guard.svelte';
import { session } from '$lib/stores/session.svelte'; import { blankDraft, isDirty, nextOrder, toDraft, toDto, type Draft } from '$lib/games/draft';
import { import {
alertError, alertError,
alertNotice, alertNotice,
@@ -21,7 +20,8 @@
let games = $state<GameDTO[]>([]); let games = $state<GameDTO[]>([]);
let draft = $state<Draft>({ ...blankDraft }); 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 smashMatches = $state<GameDTO[] | null>(null);
let loading = $state(false); let loading = $state(false);
@@ -33,30 +33,18 @@
const ordered = $derived([...games].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))); const ordered = $derived([...games].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)));
const isNew = $derived(draft.id === 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); const canSave = $derived(draft.name.trim() !== '' && !saving);
/** The provider searches start.gg by name; the long name is the one that matches. */ /** The provider searches start.gg by name; the long name is the one that matches. */
const searchTerm = $derived(draft.longName.trim() || draft.name.trim()); const searchTerm = $derived(draft.longName.trim() || draft.name.trim());
let started = false; const guard = requireSession(() => void refresh());
$effect(() => {
if (!session.isLoggedIn) {
goto('/login', { replaceState: true });
return;
}
if (!started) {
started = true;
void refresh();
}
});
function report(cause: unknown, fallback: string) { const report = errorReporter((message) => (error = message));
error = toErrorMessage(cause, fallback);
}
function load(game: GameDTO) { function load(game: GameDTO) {
draft = toDraft(game); draft = toDraft(game);
pristine = JSON.stringify(draft); pristine = { ...draft };
smashMatches = null; smashMatches = null;
} }
@@ -78,7 +66,7 @@
function reset() { function reset() {
draft = { ...blankDraft, order: nextOrder(games) }; draft = { ...blankDraft, order: nextOrder(games) };
pristine = JSON.stringify(draft); pristine = { ...draft };
smashMatches = null; smashMatches = null;
} }
@@ -160,6 +148,7 @@
</svelte:head> </svelte:head>
<main id="main" class="mx-auto max-w-5xl px-4 py-10"> <main id="main" class="mx-auto max-w-5xl px-4 py-10">
{#if guard.ready}
<header class="mb-8"> <header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Games</h1> <h1 class="text-3xl font-semibold tracking-tight">Games</h1>
<p class="mt-1 text-sm text-muted"> <p class="mt-1 text-sm text-muted">
@@ -349,4 +338,5 @@
</form> </form>
</section> </section>
</div> </div>
{/if}
</main> </main>
@@ -35,7 +35,7 @@
} }
$effect(() => { $effect(() => {
if (session.isLoggedIn) goto('/'); if (session.isLoggedIn) void goto('/');
}); });
</script> </script>
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { errorReporter } from '$lib/api/errors';
import { toErrorMessage } from '$lib/api/errors';
import type { PlayerOptionDTO, PlayerVersusDTO } from '$lib/api/schema-helpers'; import type { PlayerOptionDTO, PlayerVersusDTO } from '$lib/api/schema-helpers';
import { getVersus, listVersusPlayers } from '$lib/api/statistics'; 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 { import {
alertError, alertError,
alertWarning, alertWarning,
@@ -46,17 +46,9 @@
/** Undecided meetings: recorded, but with equal scores, so nobody won them. */ /** Undecided meetings: recorded, but with equal scores, so nobody won them. */
const undecided = $derived(Math.max(0, sets - decided)); const undecided = $derived(Math.max(0, sets - decided));
let started = false; const guard = requireSession(() => void refreshPlayers());
$effect(() => {
if (!session.isLoggedIn) { const report = errorReporter((message) => (error = message));
goto('/login', { replaceState: true });
return;
}
if (!started) {
started = true;
void refreshPlayers();
}
});
/* /*
* Reads only the two ids, so writing `versus` / `loading` below cannot re-trigger * Reads only the two ids, so writing `versus` / `loading` below cannot re-trigger
@@ -84,7 +76,7 @@
try { try {
players = await listVersusPlayers(); players = await listVersusPlayers();
} catch (cause) { } catch (cause) {
error = toErrorMessage(cause, 'Could not load the player list.'); report(cause, 'Could not load the player list.');
} finally { } finally {
loadingPlayers = false; loadingPlayers = false;
} }
@@ -105,7 +97,7 @@
} catch (cause) { } catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') return; if (cause instanceof DOMException && cause.name === 'AbortError') return;
versus = null; versus = null;
error = toErrorMessage(cause, 'Could not load this pairing.'); report(cause, 'Could not load this pairing.');
} finally { } finally {
if (inFlight === controller) { if (inFlight === controller) {
inFlight = null; inFlight = null;
@@ -123,11 +115,6 @@
playerBId = null; playerBId = null;
} }
/** Percentages get one decimal only under 100, so the column stays narrow. */
function percent(value: number): string {
return value >= 99.95 ? '100%' : `${value.toFixed(1)}%`;
}
/** First player's share of the decided meetings; 50 when nothing is decided. */ /** First player's share of the decided meetings; 50 when nothing is decided. */
function share(a: number, b: number): number { function share(a: number, b: number): number {
const total = a + b; const total = a + b;
@@ -140,6 +127,7 @@
</svelte:head> </svelte:head>
<main id="main" class="mx-auto max-w-5xl px-4 py-10"> <main id="main" class="mx-auto max-w-5xl px-4 py-10">
{#if guard.ready}
<header class="mb-8"> <header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Players Statistiques</h1> <h1 class="text-3xl font-semibold tracking-tight">Players Statistiques</h1>
<p class="mt-1 text-sm text-muted"> <p class="mt-1 text-sm text-muted">
@@ -346,4 +334,5 @@
{/if} {/if}
</p> </p>
{/if} {/if}
{/if}
</main> </main>
@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { ApiError } from '$lib/api/client'; 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 type { EventDTO, MatchStatsDTO } from '$lib/api/schema-helpers';
import { getMatchStats } from '$lib/api/statistics'; import { getMatchStats } from '$lib/api/statistics';
import { listEvents } from '$lib/api/tournaments'; 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 { aggregate, formatMonth, standingsCsv, type Aggregate } from '$lib/statistics/aggregate';
import { loadEventResults, type FailedEvent } from '$lib/statistics/load'; import { loadEventResults, type FailedEvent } from '$lib/statistics/load';
import AttendanceChart from '$lib/ui/AttendanceChart.svelte'; import AttendanceChart from '$lib/ui/AttendanceChart.svelte';
@@ -77,17 +79,9 @@
return (row.winsA ?? 0) + (row.winsB ?? 0); return (row.winsA ?? 0) + (row.winsB ?? 0);
} }
let started = false; const guard = requireSession(() => void refreshEvents());
$effect(() => {
if (!session.isLoggedIn) { const report = errorReporter((message) => (error = message));
goto('/login', { replaceState: true });
return;
}
if (!started) {
started = true;
void refreshEvents();
}
});
async function refreshEvents() { async function refreshEvents() {
loadingEvents = true; loadingEvents = true;
@@ -95,47 +89,35 @@
try { try {
events = await listEvents(); events = await listEvents();
} catch (cause) { } catch (cause) {
error = toErrorMessage(cause, 'Could not load the event list.'); report(cause, 'Could not load the event list.');
} finally { } finally {
loadingEvents = false; loadingEvents = false;
} }
} }
function toggle(id: number | undefined) { function toggle(id: number | undefined) {
if (id === undefined) return; selectedIds = toggleId(selectedIds, id);
selectedIds = selectedIds.includes(id)
? selectedIds.filter((selected) => selected !== id)
: [...selectedIds, id];
} }
function selectAll() { 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. */ /** `GET /api/Event` is newest first, so the head of the list is the recent season. */
function selectRecent(count: number) { function selectRecent(count: number) {
selectedIds = events selectedIds = identified(events)
.filter((e) => e.id !== undefined)
.slice(0, count) .slice(0, count)
.map((e) => e.id as number); .map((event) => event.id);
} }
function selectMatching() { function selectMatching() {
const value = pattern.trim(); if (pattern.trim() === '') return;
if (value === '') return;
let regex: RegExp; const match = matchingEventIds(events, pattern);
try { error = match.error;
regex = new RegExp(value); if (match.error) return;
} catch {
error = `"${value}" is not a valid regular expression.`;
return;
}
error = null; selectedIds = match.ids;
selectedIds = events
.filter((e) => e.id !== undefined && e.name && regex.test(e.name))
.map((e) => e.id as number);
} }
async function load() { async function load() {
@@ -156,7 +138,7 @@
// sit idle behind the per-event fan-out. // sit idle behind the per-event fan-out.
const [outcome, matchStats] = await Promise.all([ const [outcome, matchStats] = await Promise.all([
loadEventResults( loadEventResults(
events.filter((e) => e.id !== undefined && scope.includes(e.id)), identified(events).filter((event) => scope.includes(event.id)),
{ {
signal: controller.signal, signal: controller.signal,
onProgress: (done, total) => (progress = { done, total }) onProgress: (done, total) => (progress = { done, total })
@@ -179,7 +161,7 @@
if (tab === 'matches' && !matchStats) tab = 'standings'; if (tab === 'matches' && !matchStats) tab = 'standings';
} catch (cause) { } catch (cause) {
if (cause instanceof DOMException && cause.name === 'AbortError') return; 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 { } finally {
if (inFlight === controller) { if (inFlight === controller) {
inFlight = null; inFlight = null;
@@ -189,13 +171,7 @@
} }
function exportCsv() { function exportCsv() {
const blob = new Blob([standingsCsv(standings)], { type: 'text/csv;charset=utf-8' }); downloadCsv(`ladose-standings-${loadedIds.length}-events.csv`, standingsCsv(standings));
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);
} }
const tabs = [ const tabs = [
@@ -204,11 +180,6 @@
['events', 'Events'], ['events', 'Events'],
['matches', 'Matches'] ['matches', 'Matches']
] as const; ] 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> </script>
<svelte:head> <svelte:head>
@@ -216,6 +187,7 @@
</svelte:head> </svelte:head>
<main id="main" class="mx-auto max-w-6xl px-4 py-10"> <main id="main" class="mx-auto max-w-6xl px-4 py-10">
{#if guard.ready}
<header class="mb-8"> <header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Rankings Statistiques</h1> <h1 class="text-3xl font-semibold tracking-tight">Rankings Statistiques</h1>
<p class="mt-1 text-sm text-muted"> <p class="mt-1 text-sm text-muted">
@@ -259,7 +231,7 @@
<input <input
bind:value={pattern} bind:value={pattern}
class="{field} w-48" class="{field} w-48"
placeholder="Ranking #13\d{'{'}2{'}'}" placeholder="Ranking #13\d&#123;2&#125;"
aria-label="Regular expression matching event names" aria-label="Regular expression matching event names"
/> />
<button class={ghost} onclick={selectMatching} disabled={pattern.trim() === ''}> <button class={ghost} onclick={selectMatching} disabled={pattern.trim() === ''}>
@@ -590,4 +562,5 @@
{/if} {/if}
</section> </section>
{/if} {/if}
{/if}
</main> </main>
@@ -1,6 +1,6 @@
<script lang="ts"> <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 { import type {
EventDTO, EventDTO,
SheetExportResultDTO, SheetExportResultDTO,
@@ -9,7 +9,9 @@
} from '$lib/api/schema-helpers'; } from '$lib/api/schema-helpers';
import { exportToSheets, getSheetsConfig } from '$lib/api/sheets'; import { exportToSheets, getSheetsConfig } from '$lib/api/sheets';
import { getResults, importSmashTournament, listEvents } from '$lib/api/tournaments'; 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 { import {
buildCsv, buildCsv,
buildHtml, buildHtml,
@@ -75,26 +77,23 @@
const suggestedName = $derived(suggestedTabName(generatedEvents)); const suggestedName = $derived(suggestedTabName(generatedEvents));
const resolvedTabName = $derived(tabName.trim() || suggestedName); const resolvedTabName = $derived(tabName.trim() || suggestedName);
let started = false; const report = errorReporter((message) => (error = message));
$effect(() => {
if (!session.isLoggedIn) { const guard = requireSession(() => {
goto('/login', { replaceState: true });
return;
}
if (!started) {
started = true;
void refreshEvents(); void refreshEvents();
// Not reaching it just leaves the export saying "not configured"; the page must // Not reaching it just leaves the export saying "not configured"; the page must
// not otherwise care. // 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() void getSheetsConfig()
.then((value) => (sheets = value)) .then((value) => (sheets = value))
.catch(() => (sheets = null)); .catch((cause) => {
sheets = null;
if (cause instanceof ApiError && cause.status === 401) {
report(cause, 'Could not read the Google Sheets configuration.');
} }
}); });
});
function report(cause: unknown, fallback: string) {
error = toErrorMessage(cause, fallback);
}
async function refreshEvents() { async function refreshEvents() {
loadingEvents = true; loadingEvents = true;
@@ -136,10 +135,7 @@
} }
function toggle(id: number | undefined) { function toggle(id: number | undefined) {
if (id === undefined) return; selectedIds = toggleId(selectedIds, id);
selectedIds = selectedIds.includes(id)
? selectedIds.filter((selected) => selected !== id)
: [...selectedIds, id];
} }
/** Replaces the selection with every event whose name matches the regex. */ /** Replaces the selection with every event whose name matches the regex. */
@@ -147,18 +143,11 @@
const value = pattern.trim(); const value = pattern.trim();
if (value === '') return; if (value === '') return;
let regex: RegExp; const match = matchingEventIds(events, value);
try { error = match.error;
regex = new RegExp(value); if (match.error) return;
} catch {
error = `"${value}" is not a valid regular expression.`;
return;
}
error = null; selectedIds = match.ids;
selectedIds = events
.filter((e) => e.id !== undefined && e.name && regex.test(e.name))
.map((e) => e.id as number);
if (selectedIds.length === 0) notice = `No event name matches ${value}.`; if (selectedIds.length === 0) notice = `No event name matches ${value}.`;
} }
@@ -194,13 +183,9 @@
} }
function exportCsv() { function exportCsv() {
const blob = new Blob([buildCsv(ranking)], { type: 'text/csv;charset=utf-8' }); // `generatedIds`, not `selectedIds`: the file must be named for the data it
const url = URL.createObjectURL(blob); // holds, which is whatever `generate` last ran for, not the live checkboxes.
const link = document.createElement('a'); downloadCsv(`ladose-results-${generatedIds.join('-')}.csv`, buildCsv(ranking));
link.href = url;
link.download = `ladose-results-${selectedIds.join('-')}.csv`;
link.click();
URL.revokeObjectURL(url);
} }
/** /**
@@ -239,6 +224,7 @@
</svelte:head> </svelte:head>
<main id="main" class="mx-auto max-w-6xl px-4 py-10"> <main id="main" class="mx-auto max-w-6xl px-4 py-10">
{#if guard.ready}
<header class="mb-8"> <header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Tournaments</h1> <h1 class="text-3xl font-semibold tracking-tight">Tournaments</h1>
<p class="mt-1 text-sm text-muted"> <p class="mt-1 text-sm text-muted">
@@ -301,7 +287,7 @@
<input <input
bind:value={pattern} bind:value={pattern}
class={field} class={field}
placeholder="Ranking #13\d{'{'}2{'}'}" placeholder="Ranking #13\d&#123;2&#125;"
aria-label="Regular expression matching event names" aria-label="Regular expression matching event names"
/> />
<button class={ghost} onclick={selectMatching} disabled={pattern.trim() === ''}> <button class={ghost} onclick={selectMatching} disabled={pattern.trim() === ''}>
@@ -548,6 +534,14 @@
<div <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" class="text-sm text-ladose-50 [&_a]:text-ladose-400 [&_a]:underline [&_table]:w-full [&_td]:py-2 [&_td]:pr-4 [&_td]:align-top"
> >
<!--
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} {@html html}
</div> </div>
</div> </div>
@@ -555,4 +549,5 @@
{/if} {/if}
</section> </section>
{/if} {/if}
{/if}
</main> </main>
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import { addUser, deleteUser, listRoles, listUsers } from '$lib/api/admin-users'; 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 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 { session } from '$lib/stores/session.svelte';
import { alertError, alertNotice, card, cardHeading, danger, field, ghost, label, primary } from '$lib/ui/classes'; 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); 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 // The page is admin-only on the server too; this just avoids showing a shell that
// can only produce 403s. // can only produce 403s.
if (!session.isLoggedIn) { const guard = requireAdmin(() => void refresh());
goto('/login', { replaceState: true });
return;
}
if (!session.isAdmin) {
goto('/', { replaceState: true });
return;
}
if (!started) {
started = true;
void refresh();
}
});
function report(cause: unknown, fallback: string) { const report = errorReporter((message) => (error = message));
error = toErrorMessage(cause, fallback);
}
async function refresh() { async function refresh() {
loading = true; loading = true;
@@ -112,8 +97,8 @@
} }
} }
function fullName(user: ApplicationUserDTO): string { function displayName(user: ApplicationUserDTO): string {
return [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); return fullName(user.firstName, user.lastName);
} }
</script> </script>
@@ -122,6 +107,7 @@
</svelte:head> </svelte:head>
<main id="main" class="mx-auto max-w-5xl px-4 py-10"> <main id="main" class="mx-auto max-w-5xl px-4 py-10">
{#if guard.ready}
<header class="mb-8"> <header class="mb-8">
<h1 class="text-3xl font-semibold tracking-tight">Users</h1> <h1 class="text-3xl font-semibold tracking-tight">Users</h1>
<p class="mt-1 text-sm text-muted"> <p class="mt-1 text-sm text-muted">
@@ -168,7 +154,7 @@
<span class="ml-1 text-xs font-normal text-subtle">(you)</span> <span class="ml-1 text-xs font-normal text-subtle">(you)</span>
{/if} {/if}
</td> </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"> <td class="py-2 pr-4">
{#if user.roles?.length} {#if user.roles?.length}
{#each user.roles as role (role)} {#each user.roles as role (role)}
@@ -290,4 +276,5 @@
</div> </div>
</form> </form>
</section> </section>
{/if}
</main> </main>
+10 -2
View File
@@ -1,7 +1,7 @@
import adapter from '@sveltejs/adapter-static'; import adapter from '@sveltejs/adapter-static';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@@ -17,5 +17,13 @@ export default defineConfig({
// `fallback` hands every unknown path to the client-side router. // `fallback` hands every unknown path to the client-side router.
adapter: adapter({ fallback: 'index.html' }) 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'
}
}); });