Files
LaDOSE/LaDOSE.Src/LaDOSE.WebApp/README.md
T
darkstack f934e69c90
Build App / Build (push) Failing after 2s
Fix code smells
2026-08-07 13:22:53 +02:00

15 KiB
Raw Blame History

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:

nvm use          # resolves lts/*
npm install

Running

The API must be up — it serves on http://localhost:5000 in development:

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:

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 against /Users/auth — the one unauthenticated call
src/lib/api/errors.ts toErrorMessage — message to show, or redirect to /login on a 401
src/lib/api/tournaments.ts listEvents / importSmashTournament / getResults, authenticated from the session
src/lib/api/games.ts listGames / saveGame / deleteGame / searchSmashGames
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:

import { apiRequest, buildPath, session } from '$lib';
import type { GameDTO } from '$lib';

const games = await apiRequest<GameDTO[]>('/api/Game', { token: session.token });
const one = await apiRequest<GameDTO>(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, <name>. 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
  • /usersAdmin 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/<slug>) 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. EventsGET /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 resultsPOST /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.

  • SavePOST /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.
  • DeleteDELETE /api/Game/{id}, behind a confirm.
  • Find on start.ggGET /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. 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:

# 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

npm run check    # svelte-check (types + template diagnostics)
npm run lint     # eslint, including type-aware rules
npm run test     # vitest over the pure modules in $lib
npm run build    # static build into ./build

npm run test covers $lib/statistics, $lib/tournaments, $lib/games/draft and the small shared helpers beside them (csv, events, format). Those modules are pure by design, so they run under plain Node with no API and no database.