@@ -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.
|
||||||
|
|||||||
@@ -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'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
);
|
||||||
+1831
-1
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 {};
|
||||||
|
|||||||
@@ -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'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,26 +298,20 @@ 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,
|
||||||
row.events,
|
row.events,
|
||||||
row.games,
|
row.games,
|
||||||
row.firsts,
|
row.firsts,
|
||||||
row.seconds,
|
row.seconds,
|
||||||
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;
|
||||||
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
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('<script>');
|
||||||
|
expect(html).toContain('Fight & <b>Win</b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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{2}"
|
||||||
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) {
|
|
||||||
goto('/login', { replaceState: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!started) {
|
|
||||||
started = true;
|
|
||||||
void refreshEvents();
|
|
||||||
// Not reaching it just leaves the export saying "not configured"; the page must
|
|
||||||
// not otherwise care.
|
|
||||||
void getSheetsConfig()
|
|
||||||
.then((value) => (sheets = value))
|
|
||||||
.catch(() => (sheets = null));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function report(cause: unknown, fallback: string) {
|
const guard = requireSession(() => {
|
||||||
error = toErrorMessage(cause, fallback);
|
void refreshEvents();
|
||||||
}
|
// Not reaching it just leaves the export saying "not configured"; the page must
|
||||||
|
// not otherwise care. A 401 is the exception — that is an expired session, not
|
||||||
|
// an unconfigured server, and must go through `report` so the user is signed out
|
||||||
|
// instead of being told the export is unavailable.
|
||||||
|
void getSheetsConfig()
|
||||||
|
.then((value) => (sheets = value))
|
||||||
|
.catch((cause) => {
|
||||||
|
sheets = null;
|
||||||
|
if (cause instanceof ApiError && cause.status === 401) {
|
||||||
|
report(cause, 'Could not read the Google Sheets configuration.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function refreshEvents() {
|
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{2}"
|
||||||
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,11 +534,20 @@
|
|||||||
<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"
|
||||||
>
|
>
|
||||||
{@html html}
|
<!--
|
||||||
|
The one {@html} in the app, and the only place a suppression is
|
||||||
|
warranted: `html` is not user input passing through, it is built by
|
||||||
|
`buildHtml` in $lib/tournaments/results.ts, which escapes every
|
||||||
|
interpolated value. This is a preview of the markup the user is about
|
||||||
|
to paste into WordPress, so it has to render as markup.
|
||||||
|
-->
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
{@html html}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/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;
|
// The page is admin-only on the server too; this just avoids showing a shell that
|
||||||
$effect(() => {
|
// can only produce 403s.
|
||||||
// The page is admin-only on the server too; this just avoids showing a shell that
|
const guard = requireAdmin(() => void refresh());
|
||||||
// can only produce 403s.
|
|
||||||
if (!session.isLoggedIn) {
|
|
||||||
goto('/login', { replaceState: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!session.isAdmin) {
|
|
||||||
goto('/', { replaceState: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!started) {
|
|
||||||
started = true;
|
|
||||||
void refresh();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user