# LaDOSE.WebApp Svelte 5 + SvelteKit front-end for `LaDOSE.Api`, styled with Tailwind CSS v4. It ships as a static SPA (`@sveltejs/adapter-static`) and talks to the API over JWT bearer auth, so it can be served from any static host. ## Requirements Node ≥ 22.12 (the toolchain uses Vite 8). An `.nvmrc` is checked in: ```bash nvm use # resolves lts/* npm install ``` ## Running The API must be up — it serves on `http://localhost:5000` in development: ```bash cd ../LaDOSE.Api && dotnet run # terminal 1 npm run dev # terminal 2 -> http://localhost:5173 ``` Point the app at a different API with `VITE_API_BASE_URL` (see `.env.example`). `LaDOSE.Api` already allows any origin with credentials, so no dev proxy is needed. ## Typed API access `src/lib/api/schema.d.ts` is **generated** from the API's OpenAPI document — never edit it by hand. Regenerate whenever a C# controller or DTO changes: ```bash npm run api:sync # fetch openapi.json from the running API, then re-emit types ``` That is `api:fetch` (curl `/openapi/v1.json`, override the host with `LADOSE_API_URL`) followed by `api:types` (`openapi-typescript`). `openapi.json` is committed so the types can be rebuilt without a running API. Because paths and DTOs come from the generated types, a renamed route or a changed DTO field surfaces as a TypeScript error rather than a runtime 404. | Module | Purpose | | --- | --- | | `src/lib/api/schema.d.ts` | Generated types — all 31 API paths and every DTO | | `src/lib/api/schema-helpers.ts` | Friendly aliases (`ApplicationUserDTO`, `LoginRequest`, …) | | `src/lib/api/client.ts` | `apiRequest` — bearer auth, JSON, `ApiError`; paths constrained to real routes | | `src/lib/api/users.ts` | `login` / `register` against `/Users/auth` and `/Users/register` | | `src/lib/api/errors.ts` | `toErrorMessage` — message to show, or redirect to `/login` on a 401 | | `src/lib/api/tournaments.ts` | `listEvents` / `importSmashTournament` / `getResults`, authenticated from the session | | `src/lib/api/games.ts` | `listGames` / `saveGame` / `deleteGame` / `searchSmashGames` | | `src/lib/api/admin-users.ts` | `listUsers` / `listRoles` / `addUser` / `deleteUser` — all Admin-only | | `src/lib/api/statistics.ts` | `getMatchStats` (set-level win/loss and head to head), `listVersusPlayers` / `getVersus` (one pairing, per game) | | `src/lib/api/sheets.ts` | `getSheetsConfig` / `exportToSheets` — writes a ranking table into the club's Google Spreadsheet | | `src/lib/tournaments/results.ts` | Pure reshaping of `TournamentsResultDTO`: ranking grid, per-game placements, WordPress HTML, CSV | | `src/lib/tournaments/sheet.ts` | Pure: ranking grid → one spreadsheet tab, plus the tab title a selection suggests | | `src/lib/statistics/aggregate.ts` | Pure aggregation for `/statistiques`: standings, per-game and per-event summaries, CSV | | `src/lib/statistics/load.ts` | `loadEventResults` — per-event `GetResults` fan-out with progress, partial failure and abort | | `src/lib/ui/PlayerPicker.svelte` | Filterable player list used twice on `/statistiques/players` | | `src/lib/games/draft.ts` | `GameDTO` ⇄ editor form, including the blank-to-NULL rules | | `src/lib/ui/classes.ts` | The Tailwind class strings shared by the pages | | `src/lib/stores/session.svelte.ts` | Signed-in user, persisted to `localStorage`, drops expired JWTs | Calling another endpoint takes one line, and the path is checked at compile time: ```ts import { apiRequest, buildPath, session } from '$lib'; import type { GameDTO } from '$lib'; const games = await apiRequest('/api/Game', { token: session.token }); const one = await apiRequest(buildPath('/api/Game/{id}', { id: 3 })); ``` ## Routes - `/login` — username + password, posts to `POST /Users/auth`, stores the returned JWT - `/` — guarded; greets the signed-in user with **Hello, \.** and offers sign-out - `/tournaments` — guarded; the start.gg half of the old Avalonia `TournamentResultView`, and the one-click push of the generated ranking table into the club's Google Spreadsheet - `/statistiques` — redirects to `/statistiques/rankings` (the path the navbar used before the section became two pages) - `/statistiques/rankings` — guarded; standings, attendance and match statistics over a chosen scope - `/statistiques/players` — guarded; two players, every game they met in, all events - `/games` — guarded; the game catalogue editor, from the Avalonia `GamesView` - `/users` — **Admin only**; add and remove accounts `session.displayName` prefers `firstName lastName` and falls back to `username`. ### `/tournaments` Ports the Smash.gg (start.gg) column of `LaDOSE.DesktopApp.Avalonia`: 1. **Import** — a slug (`start.gg/tournament/`) is sent to `GET /api/Tournament/ParseSmash/{slug}`, which pulls the brackets, placements and sets into the database. Every bracket must be `COMPLETED` or the API throws. 2. **Events** — `GET /api/Event`, newest first. Tick one event for a single tournament, or several to aggregate a ranking season. The regex box replaces the selection with every matching event name (e.g. `Ranking #13\d{2}`). 3. **Generate results** — `POST /api/Tournament/GetResults` with the selected ids; the API applies the point rules in `ExternalProviderService`. Three views: - *Ranking* — players × games with totals, highest first, plus CSV export and the Google Sheets push below - *By game* — placements and points for one game - *HTML* — the podium table for the WordPress recap, with copy-to-clipboard The Challonge half of the Avalonia view (date range, Challonge tournament list, `ParseChallonge`) is deliberately not ported. #### Google Sheets export Next to **Export CSV** on the *Ranking* view: **Push to Google Sheets** writes that same table straight into the club's spreadsheet, as one tab. It replaces the download-then-import-by-hand step — the tab is the CSV, because `rankingToSheetTable` reads the very same `RankingTable` that `buildCsv` does, so the two cannot drift. Because `GetResults` merges everything it is given, selecting `Ranking #1301`, `#1302` and `#1303` produces the cumulative table for that ranking day — which is why the tab title defaults to the **latest** event in the selection (`Ranking #1303`). Override it in the **Tab** box; leaving it blank uses the suggestion shown as the placeholder. - The tab is **cleared and rewritten in place**. Formatting, notes and conditional formatting survive (the write only sets `userEnteredValue`), but anything typed into it by hand is lost — keep hand analysis in its own tab. - Tabs the export does not name are **never touched or deleted**. - Re-running changes nothing but the provenance footer's timestamp. - Points and totals are written as **numbers**, so formulas over them keep working. - Titles are sanitised server-side (Google forbids `: \ / ? * [ ]`, caps at 100 chars); any rename is reported back in the success banner. - Tab identity is the **title**, so renaming an event makes the next push write a new tab beside the old one. - The provenance footer, below the grid, names every event scored into the tab — so a stale tab says so itself. Server configuration lives in the `GoogleSheets` section — writer, target spreadsheet and limits. The target is config-only because the sheet is replaced each year; see `.env.example` for the one-time Google setup and the yearly swap. Set `GoogleSheets:Writer` to `Logging` to see the exact payload in the API log without touching a spreadsheet. ### `/statistiques/rankings` **Rankings Statistiques.** Pick a scope — everything, the last 6/12 events, or a regex over event names — then **Compute statistics**. Two independent sources feed the page, and they are kept apart on purpose: - **Points, placements, attendance** come from `POST /api/Tournament/GetResults`, called **one event at a time**. The endpoint merges everything it is given and never says which event a row came from, so per-event calls are the only way to get a time series — and they contain the damage, because it throws a 500 on any bracket missing a rank-1 or rank-2 row. One broken import is reported as a skipped event instead of taking the whole scope down with it. - **Set-level win rates and head to head** come from `POST /api/Statistics/Matches`, in a single call for the whole scope. The **Matches** tab leads with its coverage line, and it matters: brackets imported before set rows were persisted contribute placements but no matches, so those figures can describe a fraction of the scope while the standings above cover all of it. Win rates count **decided sets only**, so a player with no resolvable set is left out rather than shown at 0%. Other things worth knowing: - Rank `999` is the service's "unplaced" sentinel (the participation bucket in `ExternalProviderService`), so it never counts as a placement or a podium, and **Best** shows `—`. - Player names are merged case-insensitively, as everywhere else in the app. - Brackets are counted per event, so a bracket name reused every month counts once per event rather than once overall. - Undated events sort last in the chart and the Events tab — `GET /api/Event` returns newest first, which is the least misleading place to put them. - `aggregate.ts` is pure, so it can be exercised under plain Node with fixtures, the same way `src/lib/tournaments/results.ts` is. ### `/statistiques/players` **Players Statistiques.** Pick two players and the page answers one question: how often did they meet, and in which games. There is no event scope — the point of a pairing is its whole history, and slicing it by season would only be the Rankings page again. - The pickers come from `GET /api/Statistics/Players`, which lists **tournament players** (the rows `set` points at), not the application accounts of `/users`. Only players with at least one set in a bracket whose game is known are offered, so the list can never suggest a player the breakdown must then report as empty. - The breakdown comes from `GET /api/Statistics/Versus/{a}/{b}`, one request per complete pairing, aborted and re-issued when either side changes. `winsA` is always the first id's side. - **A set carries no game.** The game belongs to the `Tournament` the set was played in, and `Tournament.GameId` is nullable, so meetings in a bracket with no game cannot be filed under one. The API excludes them from `games` and from the totals and counts them in `unknownGameSets`; the page reports that number instead of hiding it — it is the difference between "they never met" and "we cannot tell what they played". - **Meetings** counts every recorded set, **Decided** only those with unequal scores. A set with equal scores (including 0-0, and the `-1 / -1` a double DQ leaves behind) happened, but nobody won it, so it is in neither record nor game counts. A single DQ is stored as `-1` and clamps to zero games. - `StatisticsService.AggregateVersus` is a pure static method over already-loaded rows, like `Aggregate` beside it, so the win inference is testable without a database. ### `/games` Ports `GamesView`: the list on the left (ordered by `Order`), an editor on the right. - **Save** — `POST /api/Game`. `AddOrUpdate` inserts when `id` is 0 and otherwise **replaces every column**, so the form always sends a complete `GameDTO`; blank text fields are sent as `null`. - **New game** — starts an empty draft with `id` 0 and the next free `Order`. The desktop app instead posted a blank row immediately and let you fill it in after. - **Delete** — `DELETE /api/Game/{id}`, behind a confirm. - **Find on start.gg** — `GET /api/Game/smash/{name}` searches start.gg's videogame catalogue using the long name (falling back to the name); picking a match fills `smashId`. The ids listed are **start.gg** videogame ids, not LaDOSE game ids. `smashId` is what bracket imports match on: a game without one collects its results under a synthetic "GAME NOT FOUND" entry. Unlike the desktop form, `imgUrl` is editable here — it is part of `GameDTO` and was otherwise only reachable through the database. ### `/users` (Admin only) Lists every account with its roles, creates accounts, and deletes them. `POST /Users/register` used to be `[AllowAnonymous]` so that the first account could be created. It is now `POST /Users/AddUser` and requires the **Admin** role, so before this page is reachable at all, one account has to be promoted directly in the database: ```bash # edit the username in the file first, then: psql "$LADOSE_DB" -v ON_ERROR_STOP=1 -f ../../Sql/2026-08-05_roles.sql ``` How roles work: - They live in the pre-existing `applicationrole` / `applicationuserrole` tables. The script seeds `Admin` and `User`; no schema change was needed. - Only user management checks a role. **Every other endpoint is unchanged** — a plain or role-less account can still use tournaments, games and the rest. - The JWT carries only the user id. Roles are read from the database on every request (`OnTokenValidated` in `Startup.cs`), so granting or revoking Admin applies to the caller's next request instead of whenever their 16-minute token expires. - `session.isAdmin` hides the link and the page, but that is cosmetic — the API is what enforces access, and a non-admin calling these endpoints gets a 403. - The API refuses to delete the caller's own account. Since only an admin can reach the endpoint, that is what guarantees at least one admin always remains. ## Notes - The API issues 16-minute tokens. A lapsed token is treated as signed out on load; there is no refresh flow yet, so long sessions will need a re-login. The guarded pages turn a 401 into a redirect back to `/login` (see `toErrorMessage`). - The API has no exception middleware, so an unhandled server error arrives as an HTML developer page. `ApiError` then carries only the status, which is why each call site supplies its own fallback message. - `GetResults` only fills `slug` when **one** event id is requested, so the "Voir le Bracket" links appear only for a single-event export. - The Sheets export posts a 7-deep body, so `Startup.cs` sets Newtonsoft's `MaxDepth` to 32. `MaxDepth` governs *reading*; at the previous value of 4 the request was rejected before it reached the controller. - Player names are merged case-insensitively across brackets, matching the desktop app. - `src/routes/+layout.ts` sets `ssr = false`: the JWT lives in the browser, so there is nothing meaningful to render on the server. ## Checks ```bash npm run check # svelte-check (types + template diagnostics) npm run build # static build into ./build ```