This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
# Base URL of LaDOSE.Api. Defaults to http://localhost:5000 when unset,
|
||||
# which matches the Kestrel binding the API uses in development.
|
||||
#
|
||||
# Read by `vite dev` and inlined at build time (`npm run build`, or the Dockerfile's
|
||||
# `--build-arg VITE_API_BASE_URL=...`). Baking it is optional.
|
||||
VITE_API_BASE_URL=http://localhost:5000
|
||||
|
||||
# Container runtime only, and the reason one image serves every environment:
|
||||
# docker-entrypoint.sh turns this into /config.js on start, which app.html loads
|
||||
# before the bundle. Not a Vite variable, so it does NOT belong in a .env file.
|
||||
#
|
||||
# podman run -e LADOSE_API_BASE_URL=https://api.ladose.net ladose-webapp
|
||||
#
|
||||
# Precedence: /config.js > baked VITE_API_BASE_URL > http://localhost:5000.
|
||||
# Leave it unset and /config.js is `{}`, so the baked value stays in charge.
|
||||
# LADOSE_API_BASE_URL=https://api.ladose.net
|
||||
@@ -43,7 +43,7 @@ DTO field surfaces as a TypeScript error rather than a runtime 404.
|
||||
|
||||
| Module | Purpose |
|
||||
| --- | --- |
|
||||
| `src/lib/api/schema.d.ts` | Generated types — all 23 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/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` |
|
||||
@@ -51,10 +51,13 @@ DTO field surfaces as a TypeScript error rather than a runtime 404.
|
||||
| `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` against `POST /api/Statistics/Matches` — set-level win/loss and head to head |
|
||||
| `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 |
|
||||
@@ -73,8 +76,13 @@ const one = await apiRequest<GameDTO>(buildPath('/api/Game/{id}', { id: 3 }));
|
||||
|
||||
- `/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`
|
||||
- `/statistiques` — guarded; standings, attendance and match statistics over a chosen scope
|
||||
- `/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
|
||||
|
||||
@@ -92,16 +100,48 @@ Ports the Smash.gg (start.gg) column of `LaDOSE.DesktopApp.Avalonia`:
|
||||
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
|
||||
- *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.
|
||||
|
||||
### `/statistiques`
|
||||
#### Google Sheets export
|
||||
|
||||
Pick a scope — everything, the last 6/12 events, or a regex over event names — then
|
||||
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:
|
||||
|
||||
@@ -133,6 +173,31 @@ Other things worth knowing:
|
||||
- `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.
|
||||
@@ -190,6 +255,9 @@ How roles work:
|
||||
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.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:5000/"
|
||||
"url": "http://localhost:5055/"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
@@ -440,6 +440,87 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Sheets/Config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Sheets"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SheetsConfigDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Sheets/Export": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Sheets"
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json-patch+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SheetExportRequestDTO"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SheetExportRequestDTO"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SheetExportRequestDTO"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SheetExportRequestDTO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SheetExportResultDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/json": { }
|
||||
}
|
||||
},
|
||||
"502": {
|
||||
"description": "Bad Gateway",
|
||||
"content": {
|
||||
"application/json": { }
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"content": {
|
||||
"application/json": { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Statistics/Matches": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -500,6 +581,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Statistics/Players": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Statistics"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PlayerOptionDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Statistics/Versus/{playerAId}/{playerBId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Statistics"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "playerAId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "playerBId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PlayerVersusDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Todo": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -1578,6 +1720,71 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PlayerOptionDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"sets": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PlayerVersusDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"playerAId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"playerA": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"playerBId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"playerB": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"sets": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"decidedSets": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"winsA": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"winsB": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"unknownGameSets": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"games": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/VersusGameStatsDTO"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"ResultDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1607,6 +1814,152 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SheetExportRequestDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tabs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SheetTableDTO"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"SheetExportResultDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"spreadsheetId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"spreadsheetUrl": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"writer": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"tabs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SheetTabResultDTO"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"SheetRowDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"player": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"points": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SheetsConfigDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"writer": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"configured": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"spreadsheetId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"maxTabs": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"maxRowsPerTab": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SheetTableDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"eventId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"header": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SheetRowDTO"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"footer": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"SheetTabResultDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"requestedName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"rows": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"columns": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"created": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TimeRangeDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1709,6 +2062,47 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VersusGameStatsDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"gameId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"game": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"gameLongName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"sets": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"decidedSets": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"winsA": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"winsB": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"gamesWonA": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"gamesWonB": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WPBooking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1905,6 +2299,9 @@
|
||||
{
|
||||
"name": "Game"
|
||||
},
|
||||
{
|
||||
"name": "Sheets"
|
||||
},
|
||||
{
|
||||
"name": "Statistics"
|
||||
},
|
||||
|
||||
@@ -20,6 +20,16 @@ export type MatchStatsDTO = Schemas['MatchStatsDTO'];
|
||||
export type MatchCoverageDTO = Schemas['MatchCoverageDTO'];
|
||||
export type PlayerMatchStatsDTO = Schemas['PlayerMatchStatsDTO'];
|
||||
export type HeadToHeadDTO = Schemas['HeadToHeadDTO'];
|
||||
export type PlayerVersusDTO = Schemas['PlayerVersusDTO'];
|
||||
export type VersusGameStatsDTO = Schemas['VersusGameStatsDTO'];
|
||||
export type PlayerOptionDTO = Schemas['PlayerOptionDTO'];
|
||||
|
||||
export type SheetsConfigDTO = Schemas['SheetsConfigDTO'];
|
||||
export type SheetExportRequestDTO = Schemas['SheetExportRequestDTO'];
|
||||
export type SheetTableDTO = Schemas['SheetTableDTO'];
|
||||
export type SheetRowDTO = Schemas['SheetRowDTO'];
|
||||
export type SheetExportResultDTO = Schemas['SheetExportResultDTO'];
|
||||
export type SheetTabResultDTO = Schemas['SheetTabResultDTO'];
|
||||
|
||||
/** The body `UsersController.Authenticate` binds — only these two fields are read. */
|
||||
export type LoginRequest = Pick<ApplicationUserDTO, 'username' | 'password'>;
|
||||
|
||||
+263
@@ -440,6 +440,110 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/Sheets/Config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SheetsConfigDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/Sheets/Export": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json-patch+json": components["schemas"]["SheetExportRequestDTO"];
|
||||
"application/json": components["schemas"]["SheetExportRequestDTO"];
|
||||
"text/json": components["schemas"]["SheetExportRequestDTO"];
|
||||
"application/*+json": components["schemas"]["SheetExportRequestDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["SheetExportResultDTO"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Bad Gateway */
|
||||
502: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Service Unavailable */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/Statistics/Matches": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -482,6 +586,79 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/Statistics/Players": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PlayerOptionDTO"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/Statistics/Versus/{playerAId}/{playerBId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
playerAId: number;
|
||||
playerBId: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PlayerVersusDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/Todo": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1386,6 +1563,32 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
gamesLost?: number;
|
||||
};
|
||||
PlayerOptionDTO: {
|
||||
/** Format: int32 */
|
||||
id?: number;
|
||||
name?: string | null;
|
||||
/** Format: int32 */
|
||||
sets?: number;
|
||||
};
|
||||
PlayerVersusDTO: {
|
||||
/** Format: int32 */
|
||||
playerAId?: number;
|
||||
playerA?: string | null;
|
||||
/** Format: int32 */
|
||||
playerBId?: number;
|
||||
playerB?: string | null;
|
||||
/** Format: int32 */
|
||||
sets?: number;
|
||||
/** Format: int32 */
|
||||
decidedSets?: number;
|
||||
/** Format: int32 */
|
||||
winsA?: number;
|
||||
/** Format: int32 */
|
||||
winsB?: number;
|
||||
/** Format: int32 */
|
||||
unknownGameSets?: number;
|
||||
games?: components["schemas"]["VersusGameStatsDTO"][] | null;
|
||||
};
|
||||
ResultDTO: {
|
||||
/** Format: int32 */
|
||||
gameId?: number;
|
||||
@@ -1398,6 +1601,48 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
rank?: number;
|
||||
};
|
||||
SheetExportRequestDTO: {
|
||||
tabs?: components["schemas"]["SheetTableDTO"][] | null;
|
||||
};
|
||||
SheetExportResultDTO: {
|
||||
spreadsheetId?: string | null;
|
||||
spreadsheetUrl?: string | null;
|
||||
writer?: string | null;
|
||||
tabs?: components["schemas"]["SheetTabResultDTO"][] | null;
|
||||
warnings?: string[] | null;
|
||||
};
|
||||
SheetRowDTO: {
|
||||
player?: string | null;
|
||||
points?: number[] | null;
|
||||
/** Format: int32 */
|
||||
total?: number;
|
||||
};
|
||||
SheetsConfigDTO: {
|
||||
writer?: string | null;
|
||||
configured?: boolean;
|
||||
spreadsheetId?: string | null;
|
||||
/** Format: int32 */
|
||||
maxTabs?: number;
|
||||
/** Format: int32 */
|
||||
maxRowsPerTab?: number;
|
||||
};
|
||||
SheetTableDTO: {
|
||||
name?: string | null;
|
||||
/** Format: int32 */
|
||||
eventId?: number;
|
||||
header?: string[] | null;
|
||||
rows?: components["schemas"]["SheetRowDTO"][] | null;
|
||||
footer?: string[] | null;
|
||||
};
|
||||
SheetTabResultDTO: {
|
||||
requestedName?: string | null;
|
||||
name?: string | null;
|
||||
/** Format: int32 */
|
||||
rows?: number;
|
||||
/** Format: int32 */
|
||||
columns?: number;
|
||||
created?: boolean;
|
||||
};
|
||||
TimeRangeDTO: {
|
||||
/** Format: date-time */
|
||||
from?: string | null;
|
||||
@@ -1430,6 +1675,24 @@ export interface components {
|
||||
results?: components["schemas"]["ResultDTO"][] | null;
|
||||
slug?: string | null;
|
||||
};
|
||||
VersusGameStatsDTO: {
|
||||
/** Format: int32 */
|
||||
gameId?: number;
|
||||
game?: string | null;
|
||||
gameLongName?: string | null;
|
||||
/** Format: int32 */
|
||||
sets?: number;
|
||||
/** Format: int32 */
|
||||
decidedSets?: number;
|
||||
/** Format: int32 */
|
||||
winsA?: number;
|
||||
/** Format: int32 */
|
||||
winsB?: number;
|
||||
/** Format: int32 */
|
||||
gamesWonA?: number;
|
||||
/** Format: int32 */
|
||||
gamesWonB?: number;
|
||||
};
|
||||
WPBooking: {
|
||||
/** Format: int32 */
|
||||
wpEventId?: number;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { apiRequest, type RequestOptions } from './client';
|
||||
import type {
|
||||
SheetExportRequestDTO,
|
||||
SheetExportResultDTO,
|
||||
SheetsConfigDTO
|
||||
} from './schema-helpers';
|
||||
|
||||
/** SheetsController is `[Authorize]`, like the rest of the API. */
|
||||
function authed(options: RequestOptions): RequestOptions {
|
||||
return { ...options, token: options.token ?? session.token };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/Sheets/Config — whether the export is usable and which spreadsheet it points
|
||||
* at. The target is server configuration (it is reset each year), so the UI reads it
|
||||
* rather than offering it as an input. Carries no credentials.
|
||||
*/
|
||||
export function getSheetsConfig(options: RequestOptions = {}): Promise<SheetsConfigDTO> {
|
||||
return apiRequest<SheetsConfigDTO>('/api/Sheets/Config', authed(options));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/Sheets/Export — writes one tab per table, in the order given.
|
||||
*
|
||||
* Existing tabs of the same name are cleared and rewritten in place, keeping their
|
||||
* formatting; tabs not named in the request are left untouched. Re-running the same export
|
||||
* is a no-op beyond the provenance timestamp.
|
||||
*/
|
||||
export function exportToSheets(
|
||||
request: SheetExportRequestDTO,
|
||||
options: RequestOptions = {}
|
||||
): Promise<SheetExportResultDTO> {
|
||||
return apiRequest<SheetExportResultDTO>('/api/Sheets/Export', {
|
||||
...authed(options),
|
||||
method: 'POST',
|
||||
body: request
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { apiRequest, type RequestOptions } from './client';
|
||||
import type { MatchStatsDTO } from './schema-helpers';
|
||||
import { apiRequest, buildPath, type RequestOptions } from './client';
|
||||
import type { MatchStatsDTO, PlayerOptionDTO, PlayerVersusDTO } from './schema-helpers';
|
||||
|
||||
/** StatisticsController is `[Authorize]`, like the tournament endpoints. */
|
||||
function authed(options: RequestOptions): RequestOptions {
|
||||
@@ -27,3 +27,34 @@ export function getMatchStats(
|
||||
body: eventIds
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/Statistics/Players — the tournament players a versus lookup can report
|
||||
* on, alphabetically. **Not** the application accounts of `/api/Users`: these are
|
||||
* the players `set` rows point at, and `sets` counts their meetings in brackets
|
||||
* whose game is known — the same filter `getVersus` applies.
|
||||
*/
|
||||
export function listVersusPlayers(options: RequestOptions = {}): Promise<PlayerOptionDTO[]> {
|
||||
return apiRequest<PlayerOptionDTO[]>('/api/Statistics/Players', authed(options));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/Statistics/Versus/{a}/{b} — every recorded meeting between two players,
|
||||
* over every event, split per game. `winsA` is always the first id's side.
|
||||
*
|
||||
* A set only knows its bracket, and a bracket's game is nullable, so meetings we
|
||||
* cannot attribute to a game are excluded from `games` and the totals and counted
|
||||
* in `unknownGameSets` instead. Show that number: it is the difference between
|
||||
* "they never met" and "we cannot tell what they played".
|
||||
*/
|
||||
export function getVersus(
|
||||
playerAId: number,
|
||||
playerBId: number,
|
||||
options: RequestOptions = {}
|
||||
): Promise<PlayerVersusDTO> {
|
||||
const path = buildPath('/api/Statistics/Versus/{playerAId}/{playerBId}', {
|
||||
playerAId,
|
||||
playerBId
|
||||
});
|
||||
return apiRequest<PlayerVersusDTO>(path, authed(options));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { EventDTO, SheetTableDTO } from '$lib/api/schema-helpers';
|
||||
import type { RankingTable } from './results';
|
||||
|
||||
/**
|
||||
* Turns the generated ranking grid into one spreadsheet tab.
|
||||
*
|
||||
* The header and row order come straight from `RankingTable`, i.e. from `buildRanking` —
|
||||
* the same table the page shows and `buildCsv` exports. So the tab is the CSV, and the two
|
||||
* cannot drift.
|
||||
*
|
||||
* Pure, like the rest of `$lib/tournaments`: exercisable under plain Node.
|
||||
*/
|
||||
|
||||
export interface SheetTableOptions {
|
||||
/** Tab title. */
|
||||
name: string;
|
||||
/** The events scored into this table, for the provenance footer. */
|
||||
events: EventDTO[];
|
||||
/** ISO timestamp, stamped into the footer. */
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab a selection belongs in: the latest event in it, since a ranking day's table
|
||||
* aggregates every event up to that day. Falls back to the first event, then to a
|
||||
* generic label, so this always returns something usable as a title.
|
||||
*/
|
||||
export function suggestedTabName(events: EventDTO[]): string {
|
||||
if (events.length === 0) return 'Ranking';
|
||||
|
||||
const latest = [...events].sort((a, b) => {
|
||||
const aTime = a.date ? Date.parse(a.date) : NaN;
|
||||
const bTime = b.date ? Date.parse(b.date) : NaN;
|
||||
const aOk = Number.isFinite(aTime);
|
||||
const bOk = Number.isFinite(bTime);
|
||||
// Undated events cannot claim to be the latest.
|
||||
if (aOk && bOk) return bTime - aTime;
|
||||
if (aOk) return -1;
|
||||
if (bOk) return 1;
|
||||
return 0;
|
||||
})[0];
|
||||
|
||||
return latest.name?.trim() || `Event ${latest.id ?? 0}`;
|
||||
}
|
||||
|
||||
export function rankingToSheetTable(
|
||||
table: RankingTable,
|
||||
options: SheetTableOptions
|
||||
): SheetTableDTO {
|
||||
const names = options.events.map((event) => event.name ?? `#${event.id ?? 0}`);
|
||||
|
||||
return {
|
||||
name: options.name,
|
||||
// Provenance only — the tab is addressed by title. The latest event, matching
|
||||
// suggestedTabName, so the id and the default title agree.
|
||||
eventId: options.events.length ? (latestId(options.events) ?? 0) : 0,
|
||||
header: ['Players', ...table.games.map((game) => game.name ?? ''), 'Total'],
|
||||
rows: table.rows.map((row) => ({
|
||||
player: row.player,
|
||||
points: row.points,
|
||||
total: row.total
|
||||
})),
|
||||
// So a stale tab says so itself instead of looking current.
|
||||
footer: [
|
||||
names.length === 1
|
||||
? `Scored from ${names[0]}`
|
||||
: `Scored from ${names.length} events: ${names.join(', ')}`,
|
||||
`Written by LaDOSE on ${options.generatedAt}`
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function latestId(events: EventDTO[]): number | undefined {
|
||||
const name = suggestedTabName(events);
|
||||
return events.find((event) => (event.name?.trim() || `Event ${event.id ?? 0}`) === name)?.id;
|
||||
}
|
||||
@@ -18,9 +18,15 @@
|
||||
}
|
||||
|
||||
/** Tournaments is the app's reason to exist, so it leads. */
|
||||
const mainLinks: NavItem[] = [
|
||||
{ href: '/tournaments', label: 'Tournaments' },
|
||||
{ href: '/statistiques', label: 'Statistiques' }
|
||||
const mainLinks: NavItem[] = [{ href: '/tournaments', label: 'Tournaments' }];
|
||||
|
||||
/**
|
||||
* Statistics is two pages that answer different questions — a season's rankings,
|
||||
* one pairing's history — so it is a submenu rather than two top-level links.
|
||||
*/
|
||||
const statsLinks: NavItem[] = [
|
||||
{ href: '/statistiques/rankings', label: 'Rankings Statistiques' },
|
||||
{ href: '/statistiques/players', label: 'Players Statistiques' }
|
||||
];
|
||||
|
||||
/** Administration, tucked away: not what anyone opens the app to do. */
|
||||
@@ -38,8 +44,10 @@
|
||||
}
|
||||
|
||||
const settingsActive = $derived(settingsLinks.some((link) => isActive(link.href)));
|
||||
/** `/statistiques` itself redirects to rankings, so the parent path counts too. */
|
||||
const statsActive = $derived(isActive('/statistiques'));
|
||||
|
||||
let openMenu = $state<'settings' | 'mobile' | null>(null);
|
||||
let openMenu = $state<'stats' | 'settings' | 'mobile' | null>(null);
|
||||
|
||||
/** Close on outside click, on Escape, and whenever the route changes. */
|
||||
$effect(() => {
|
||||
@@ -94,27 +102,20 @@
|
||||
LaDOSE
|
||||
</a>
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<nav class="hidden items-center gap-1 sm:flex" aria-label="Main">
|
||||
{#each mainLinks as link (link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
class={isActive(link.href) ? navLinkActive : navLink}
|
||||
aria-current={isActive(link.href) ? 'page' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
{#snippet dropdown(id: 'stats' | 'settings', label: string, active: boolean, links: NavItem[])}
|
||||
<div class="relative" data-menu-root>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (openMenu = openMenu === 'settings' ? null : 'settings')}
|
||||
class="{settingsActive ? navLinkActive : navLink} inline-flex items-center gap-1"
|
||||
aria-expanded={openMenu === 'settings'}
|
||||
onclick={() => (openMenu = openMenu === id ? null : id)}
|
||||
class="{active ? navLinkActive : navLink} inline-flex items-center gap-1"
|
||||
aria-expanded={openMenu === id}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
Settings
|
||||
{label}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -122,16 +123,16 @@
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-3 transition-transform {openMenu === 'settings' ? 'rotate-180' : ''}"
|
||||
class="size-3 transition-transform {openMenu === id ? 'rotate-180' : ''}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if openMenu === 'settings'}
|
||||
<div class={menuPanel} role="menu">
|
||||
{#each settingsLinks as link (link.href)}
|
||||
{#if openMenu === id}
|
||||
<div class="{menuPanel} w-56" role="menu">
|
||||
{#each links as link (link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
role="menuitem"
|
||||
@@ -144,6 +145,22 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<nav class="hidden items-center gap-1 sm:flex" aria-label="Main">
|
||||
{#each mainLinks as link (link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
class={isActive(link.href) ? navLinkActive : navLink}
|
||||
aria-current={isActive(link.href) ? 'page' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
{@render dropdown('stats', 'Statistiques', statsActive, statsLinks)}
|
||||
{@render dropdown('settings', 'Settings', settingsActive, settingsLinks)}
|
||||
</nav>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
@@ -181,7 +198,7 @@
|
||||
</button>
|
||||
|
||||
{#if openMenu === 'mobile'}
|
||||
<div class={menuPanel} role="menu">
|
||||
<div class="{menuPanel} w-56" role="menu">
|
||||
{#each mainLinks as link (link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
@@ -193,6 +210,20 @@
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase">
|
||||
Statistiques
|
||||
</p>
|
||||
{#each statsLinks as link (link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
role="menuitem"
|
||||
class={isActive(link.href) ? menuItemActive : menuItem}
|
||||
aria-current={isActive(link.href) ? 'page' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
<p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase">
|
||||
Settings
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import type { PlayerOptionDTO } from '$lib/api/schema-helpers';
|
||||
import { field, label as labelClass, listRow, listRowSelected } from '$lib/ui/classes';
|
||||
|
||||
interface Props {
|
||||
heading: string;
|
||||
players: PlayerOptionDTO[];
|
||||
/** The chosen player id, or null. Bindable. */
|
||||
selectedId?: number | null;
|
||||
/** Taken on the other side: still listed, but not selectable. */
|
||||
excludeId?: number | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
heading,
|
||||
players,
|
||||
selectedId = $bindable(null),
|
||||
excludeId = null,
|
||||
disabled = false
|
||||
}: Props = $props();
|
||||
|
||||
/*
|
||||
* A filter box over a scrolling list rather than a <select>: there are several
|
||||
* hundred players, they are recognised by gamertag rather than by position, and a
|
||||
* native select on a phone would hide all of that behind a spinner.
|
||||
*/
|
||||
|
||||
let query = $state('');
|
||||
|
||||
/** Rendering every match would put hundreds of buttons in the DOM for no gain. */
|
||||
const LIMIT = 120;
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (needle === '') return players;
|
||||
return players.filter((player) => (player.name ?? '').toLowerCase().includes(needle));
|
||||
});
|
||||
|
||||
const shown = $derived(filtered.slice(0, LIMIT));
|
||||
const hidden = $derived(Math.max(0, filtered.length - shown.length));
|
||||
const selected = $derived(players.find((player) => player.id === selectedId) ?? null);
|
||||
|
||||
function choose(player: PlayerOptionDTO) {
|
||||
if (player.id === undefined || player.id === excludeId) return;
|
||||
selectedId = player.id === selectedId ? null : player.id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<p class={labelClass}>{heading}</p>
|
||||
|
||||
<p class="mt-1 truncate text-sm" aria-live="polite">
|
||||
{#if selected}
|
||||
<span class="font-semibold text-ink">{selected.name}</span>
|
||||
<span class="text-subtle">· {selected.sets ?? 0} sets</span>
|
||||
{:else}
|
||||
<span class="text-subtle">Nobody selected yet.</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<input
|
||||
bind:value={query}
|
||||
class="{field} mt-2"
|
||||
placeholder="Filter by gamertag"
|
||||
aria-label="{heading} — filter by gamertag"
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<ul class="mt-2 max-h-64 space-y-0.5 overflow-y-auto pr-1 text-sm">
|
||||
{#each shown as player (player.id)}
|
||||
{@const taken = player.id === excludeId}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="{player.id === selectedId ? listRowSelected : listRow} flex items-baseline gap-2"
|
||||
aria-pressed={player.id === selectedId}
|
||||
disabled={disabled || taken}
|
||||
onclick={() => choose(player)}
|
||||
>
|
||||
<span class="truncate">{player.name}</span>
|
||||
<span class="ml-auto shrink-0 text-xs text-subtle tabular-nums">
|
||||
{taken ? 'picked' : (player.sets ?? 0)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="px-2 py-6 text-center text-subtle">
|
||||
{players.length === 0 ? 'No player with recorded sets.' : 'No gamertag matches.'}
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
{#if hidden > 0}
|
||||
<li class="px-2 pt-2 text-xs text-subtle">
|
||||
{hidden} more match — narrow the filter to reach {hidden === 1 ? 'it' : 'them'}.
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -52,9 +52,16 @@ export const navLinkActive =
|
||||
export const iconButton =
|
||||
'inline-flex size-9 items-center justify-center rounded-lg border border-line-strong text-ink transition hover:bg-ink/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent';
|
||||
|
||||
/** Dropdown panel, e.g. the Settings menu in the navbar. */
|
||||
/**
|
||||
* Dropdown panel, e.g. the Statistiques and Settings menus in the navbar.
|
||||
*
|
||||
* `bg-overlay`, never `bg-surface`: a menu is painted over the page, so it has to be
|
||||
* opaque — the surface token is deliberately translucent in dark mode and let the
|
||||
* heading underneath read straight through the panel. No backdrop blur either, it
|
||||
* does nothing behind an opaque fill.
|
||||
*/
|
||||
export const menuPanel =
|
||||
'absolute right-0 z-50 mt-2 w-52 overflow-hidden rounded-xl border border-line bg-surface p-1 shadow-card backdrop-blur-lg';
|
||||
'absolute right-0 z-50 mt-2 w-52 overflow-hidden rounded-xl border border-line bg-overlay p-1 shadow-card';
|
||||
export const menuItem =
|
||||
'block w-full rounded-lg px-3 py-2 text-left text-sm text-muted transition hover:bg-ink/5 hover:text-ink focus:outline-none focus-visible:bg-ink/5 focus-visible:text-ink';
|
||||
export const menuItemActive =
|
||||
|
||||
@@ -22,9 +22,14 @@
|
||||
description: 'Import a start.gg tournament and score one event or a whole ranking season.'
|
||||
},
|
||||
{
|
||||
href: '/statistiques',
|
||||
title: 'Statistiques',
|
||||
description: 'Leaderboards, attendance over time and head-to-head records.'
|
||||
href: '/statistiques/rankings',
|
||||
title: 'Rankings Statistiques',
|
||||
description: 'Leaderboards, attendance over time and head-to-head records for a scope.'
|
||||
},
|
||||
{
|
||||
href: '/statistiques/players',
|
||||
title: 'Players Statistiques',
|
||||
description: 'Two players, every game they met in, and how the meetings went.'
|
||||
},
|
||||
{
|
||||
href: '/games',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* The statistics section is two pages now — Rankings and Players — so this path is
|
||||
* only a landing spot. Redirect rather than delete it: it is what the navbar linked
|
||||
* to before the split, and what any bookmark still points at.
|
||||
*/
|
||||
export const load = () => {
|
||||
redirect(307, '/statistiques/rankings');
|
||||
};
|
||||
@@ -0,0 +1,349 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { toErrorMessage } from '$lib/api/errors';
|
||||
import type { PlayerOptionDTO, PlayerVersusDTO } from '$lib/api/schema-helpers';
|
||||
import { getVersus, listVersusPlayers } from '$lib/api/statistics';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
alertError,
|
||||
alertWarning,
|
||||
card,
|
||||
cardHeading,
|
||||
ghost
|
||||
} from '$lib/ui/classes';
|
||||
import PlayerPicker from '$lib/ui/PlayerPicker.svelte';
|
||||
|
||||
/*
|
||||
* One question, all events: how often did these two actually meet, and in which
|
||||
* games. That is a different shape from the Rankings page — no event scope, no
|
||||
* leaderboard — which is why it is its own page rather than a fifth tab there.
|
||||
*
|
||||
* The scope is deliberately "everything ever imported": a pairing's history is
|
||||
* the point, and slicing it by season would just be the Rankings page again.
|
||||
*
|
||||
* A set records its bracket, and a bracket's game is nullable, so meetings the
|
||||
* database cannot attribute to a game are excluded by the API and reported
|
||||
* separately as `unknownGameSets`. They are shown, never folded into a total.
|
||||
*/
|
||||
|
||||
let players = $state<PlayerOptionDTO[]>([]);
|
||||
let playerAId = $state<number | null>(null);
|
||||
let playerBId = $state<number | null>(null);
|
||||
|
||||
let versus = $state<PlayerVersusDTO | null>(null);
|
||||
let loadingPlayers = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let inFlight: AbortController | null = null;
|
||||
|
||||
const games = $derived(versus?.games ?? []);
|
||||
const sets = $derived(versus?.sets ?? 0);
|
||||
const decided = $derived(versus?.decidedSets ?? 0);
|
||||
const winsA = $derived(versus?.winsA ?? 0);
|
||||
const winsB = $derived(versus?.winsB ?? 0);
|
||||
const unknown = $derived(versus?.unknownGameSets ?? 0);
|
||||
/** Undecided meetings: recorded, but with equal scores, so nobody won them. */
|
||||
const undecided = $derived(Math.max(0, sets - decided));
|
||||
|
||||
let started = false;
|
||||
$effect(() => {
|
||||
if (!session.isLoggedIn) {
|
||||
goto('/login', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
if (!started) {
|
||||
started = true;
|
||||
void refreshPlayers();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Reads only the two ids, so writing `versus` / `loading` below cannot re-trigger
|
||||
* it. One request per complete pairing, and the previous one is aborted — picking
|
||||
* a third player mid-flight must not race an older answer into view.
|
||||
*/
|
||||
$effect(() => {
|
||||
const a = playerAId;
|
||||
const b = playerBId;
|
||||
|
||||
if (a === null || b === null || a === b) {
|
||||
inFlight?.abort();
|
||||
inFlight = null;
|
||||
versus = null;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
void compare(a, b);
|
||||
});
|
||||
|
||||
async function refreshPlayers() {
|
||||
loadingPlayers = true;
|
||||
error = null;
|
||||
try {
|
||||
players = await listVersusPlayers();
|
||||
} catch (cause) {
|
||||
error = toErrorMessage(cause, 'Could not load the player list.');
|
||||
} finally {
|
||||
loadingPlayers = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function compare(a: number, b: number) {
|
||||
inFlight?.abort();
|
||||
const controller = new AbortController();
|
||||
inFlight = controller;
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await getVersus(a, b, { signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
versus = result;
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === 'AbortError') return;
|
||||
versus = null;
|
||||
error = toErrorMessage(cause, 'Could not load this pairing.');
|
||||
} finally {
|
||||
if (inFlight === controller) {
|
||||
inFlight = null;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function swap() {
|
||||
[playerAId, playerBId] = [playerBId, playerAId];
|
||||
}
|
||||
|
||||
function clear() {
|
||||
playerAId = 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. */
|
||||
function share(a: number, b: number): number {
|
||||
const total = a + b;
|
||||
return total === 0 ? 50 : (a / total) * 100;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Players Statistiques · LaDOSE</title>
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Players Statistiques</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Pick two players and see how often they met, game by game, across every event ever
|
||||
imported. For leaderboards and attendance, see
|
||||
<a href="/statistiques/rankings" class="underline decoration-line-strong hover:text-ink">
|
||||
Rankings Statistiques
|
||||
</a>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p role="alert" class="{alertError} mb-4">{error}</p>
|
||||
{/if}
|
||||
|
||||
<section class={card}>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<h2 class={cardHeading}>
|
||||
Pairing
|
||||
<span class="ml-1 font-normal text-muted normal-case">
|
||||
({players.length} players with recorded sets)
|
||||
</span>
|
||||
</h2>
|
||||
<button class={ghost} onclick={refreshPlayers} disabled={loadingPlayers}>
|
||||
{loadingPlayers ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-5 sm:grid-cols-2">
|
||||
<PlayerPicker
|
||||
heading="Player A"
|
||||
{players}
|
||||
bind:selectedId={playerAId}
|
||||
excludeId={playerBId}
|
||||
disabled={loadingPlayers}
|
||||
/>
|
||||
<PlayerPicker
|
||||
heading="Player B"
|
||||
{players}
|
||||
bind:selectedId={playerBId}
|
||||
excludeId={playerAId}
|
||||
disabled={loadingPlayers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-3 border-t border-line pt-4">
|
||||
<span class="text-xs text-muted">
|
||||
{#if loading}
|
||||
Reading their sets…
|
||||
{:else if loadingPlayers}
|
||||
Only players with at least one set in a bracket with a known game are listed.
|
||||
{:else}
|
||||
The count beside a name is their total sets, whoever the opponent was.
|
||||
{/if}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<button class={ghost} onclick={swap} disabled={playerAId === null && playerBId === null}>
|
||||
Swap
|
||||
</button>
|
||||
<button class={ghost} onclick={clear} disabled={playerAId === null && playerBId === null}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if versus && (playerAId !== null) && (playerBId !== null)}
|
||||
<!--
|
||||
Scoreline first: the whole page answers one question, so the total sits above
|
||||
the per-game split rather than under it.
|
||||
-->
|
||||
<section class="{card} mt-6">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold tracking-tight">
|
||||
{versus.playerA}
|
||||
<span class="text-subtle">vs</span>
|
||||
{versus.playerB}
|
||||
</h2>
|
||||
<p class="text-3xl font-semibold tracking-tight tabular-nums">
|
||||
<span class={winsA >= winsB ? '' : 'text-muted'}>{winsA}</span>
|
||||
<span class="text-subtle">–</span>
|
||||
<span class={winsB > winsA ? '' : 'text-muted'}>{winsB}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if sets === 0}
|
||||
<p class="mt-3 text-sm text-subtle">
|
||||
No recorded meeting in a bracket with a known game.
|
||||
{#if unknown > 0}
|
||||
They did meet {unknown} time{unknown === 1 ? '' : 's'}, but in
|
||||
{unknown === 1 ? 'a bracket' : 'brackets'} with no game attached.
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<!--
|
||||
A share meter, not a two-series bar: one measure (player A's share of the
|
||||
decided meetings) painted in the accent over an inset track. The score
|
||||
above carries the same numbers for anyone who cannot see it.
|
||||
-->
|
||||
<div
|
||||
class="mt-4 h-2 overflow-hidden rounded-full bg-inset"
|
||||
role="img"
|
||||
aria-label="{versus.playerA} won {winsA} of {decided} decided meetings against {versus.playerB}"
|
||||
>
|
||||
<div class="h-full rounded-full bg-accent" style="width: {share(winsA, winsB)}%"></div>
|
||||
</div>
|
||||
|
||||
<dl class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{#each [['Meetings', String(sets)], ['Games played', String(games.length)], ['Decided', String(decided)], [`${versus.playerA ?? 'A'} win rate`, percent(share(winsA, winsB))]] as const as [caption, value] (caption)}
|
||||
<div class="rounded-xl border border-line bg-inset p-3">
|
||||
<dt class="truncate text-xs tracking-wide text-muted uppercase">{caption}</dt>
|
||||
<dd class="mt-1 text-2xl font-semibold tracking-tight tabular-nums">{value}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if unknown > 0 && sets > 0}
|
||||
<p class="{alertWarning} mt-4">
|
||||
{unknown} further meeting{unknown === 1 ? '' : 's'} excluded: the bracket has no game
|
||||
attached, so {unknown === 1 ? 'it' : 'they'} cannot be filed under one. Set the game on
|
||||
those tournaments to see {unknown === 1 ? 'it' : 'them'} here.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if games.length}
|
||||
<section class="{card} mt-6">
|
||||
<h2 class={cardHeading}>Meetings per game</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
Most-played first. Games counts the individual games inside the decided sets — a
|
||||
DQ is stored as a negative score and counts as zero.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="w-full min-w-max text-sm">
|
||||
<thead class="text-left text-xs tracking-wide text-muted uppercase">
|
||||
<tr class="border-b border-line">
|
||||
<th class="py-2 pr-4 font-medium">Game</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Meetings</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Decided</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Record</th>
|
||||
<th class="px-2 py-2 text-right font-medium">Games</th>
|
||||
<th class="w-40 py-2 pl-2 font-medium">{versus.playerA} share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="tabular-nums">
|
||||
{#each games as row (row.gameId)}
|
||||
{@const rowWinsA = row.winsA ?? 0}
|
||||
{@const rowWinsB = row.winsB ?? 0}
|
||||
<tr class="border-b border-line/60 last:border-0">
|
||||
<td class="py-2 pr-4">
|
||||
<span class="font-medium">{row.game}</span>
|
||||
{#if row.gameLongName && row.gameLongName !== row.game}
|
||||
<span class="ml-1 text-xs text-subtle">{row.gameLongName}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-2 py-2 text-right font-semibold">{row.sets}</td>
|
||||
<td class="px-2 py-2 text-right {row.decidedSets === row.sets ? 'text-subtle' : ''}">
|
||||
{row.decidedSets}
|
||||
</td>
|
||||
<td class="px-2 py-2 text-right font-semibold">{rowWinsA}–{rowWinsB}</td>
|
||||
<td class="px-2 py-2 text-right text-subtle">
|
||||
{row.gamesWonA}–{row.gamesWonB}
|
||||
</td>
|
||||
<td class="py-2 pl-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-1.5 grow overflow-hidden rounded-full bg-inset"
|
||||
role="img"
|
||||
aria-label="{versus.playerA} won {rowWinsA} of {rowWinsA + rowWinsB} decided meetings in {row.game}"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-accent"
|
||||
style="width: {share(rowWinsA, rowWinsB)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="w-12 shrink-0 text-right text-xs text-muted">
|
||||
{rowWinsA + rowWinsB === 0 ? '—' : percent(share(rowWinsA, rowWinsB))}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if undecided > 0}
|
||||
<p class="mt-4 text-xs text-muted">
|
||||
{undecided} of these {sets} meetings {undecided === 1 ? 'has' : 'have'} equal scores
|
||||
— counted as a meeting, but won by nobody, so
|
||||
{undecided === 1 ? 'it is' : 'they are'} absent from the records above.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{:else if !loading}
|
||||
<p class="mt-6 text-sm text-subtle">
|
||||
{#if playerAId === null && playerBId === null}
|
||||
Pick a player on each side to see their history.
|
||||
{:else}
|
||||
Pick a second player to compare.
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</main>
|
||||
+6
-3
@@ -212,15 +212,18 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Statistiques · LaDOSE</title>
|
||||
<title>Rankings Statistiques · LaDOSE</title>
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-6xl px-4 py-10">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Statistiques</h1>
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Rankings Statistiques</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Pick a scope — a season, a year, everything — and see who turned up, who won, and how
|
||||
the games compare.
|
||||
the games compare. For one pairing's whole history, see
|
||||
<a href="/statistiques/players" class="underline decoration-line-strong hover:text-ink">
|
||||
Players Statistiques
|
||||
</a>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { toErrorMessage } from '$lib/api/errors';
|
||||
import type { EventDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
|
||||
import type {
|
||||
EventDTO,
|
||||
SheetExportResultDTO,
|
||||
SheetsConfigDTO,
|
||||
TournamentsResultDTO
|
||||
} from '$lib/api/schema-helpers';
|
||||
import { exportToSheets, getSheetsConfig } from '$lib/api/sheets';
|
||||
import { getResults, importSmashTournament, listEvents } from '$lib/api/tournaments';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
@@ -11,6 +17,7 @@
|
||||
playedGames,
|
||||
resultsForGame
|
||||
} from '$lib/tournaments/results';
|
||||
import { rankingToSheetTable, suggestedTabName } from '$lib/tournaments/sheet';
|
||||
import {
|
||||
alertError,
|
||||
alertNotice,
|
||||
@@ -34,6 +41,12 @@
|
||||
let tab = $state<'ranking' | 'game' | 'html'>('ranking');
|
||||
let selectedGameId = $state<number | null>(null);
|
||||
|
||||
/**
|
||||
* The ids `results` was actually computed for. Changing the selection afterwards must not
|
||||
* change what the export claims to be, since the tab title decides where the data lands.
|
||||
*/
|
||||
let generatedIds = $state<number[]>([]);
|
||||
|
||||
let loadingEvents = $state(false);
|
||||
let importing = $state(false);
|
||||
let generating = $state(false);
|
||||
@@ -41,12 +54,27 @@
|
||||
let error = $state<string | null>(null);
|
||||
let copied = $state(false);
|
||||
|
||||
// --- Google Sheets export -------------------------------------------------------
|
||||
let sheets = $state<SheetsConfigDTO | null>(null);
|
||||
/** Blank means "use the suggestion", which is shown as the input's placeholder. */
|
||||
let tabName = $state('');
|
||||
let pushing = $state(false);
|
||||
let pushResult = $state<SheetExportResultDTO | null>(null);
|
||||
|
||||
const ranking = $derived(buildRanking(results));
|
||||
const games = $derived(playedGames(results));
|
||||
const gameResults = $derived(resultsForGame(results, selectedGameId));
|
||||
const html = $derived(buildHtml(results));
|
||||
const selectedGame = $derived(games.find((g) => g.id === selectedGameId) ?? null);
|
||||
|
||||
/** The events behind the table on screen, not the current checkboxes. */
|
||||
const generatedEvents = $derived(
|
||||
events.filter((event) => event.id !== undefined && generatedIds.includes(event.id))
|
||||
);
|
||||
/** A ranking day's table aggregates up to that day, so the latest event names the tab. */
|
||||
const suggestedName = $derived(suggestedTabName(generatedEvents));
|
||||
const resolvedTabName = $derived(tabName.trim() || suggestedName);
|
||||
|
||||
let started = false;
|
||||
$effect(() => {
|
||||
if (!session.isLoggedIn) {
|
||||
@@ -56,6 +84,11 @@
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -137,8 +170,12 @@
|
||||
notice = null;
|
||||
try {
|
||||
results = await getResults(selectedIds);
|
||||
generatedIds = [...selectedIds];
|
||||
selectedGameId = playedGames(results)[0]?.id ?? null;
|
||||
tab = 'ranking';
|
||||
// A new table belongs in its own tab, so drop any title typed for the previous one.
|
||||
tabName = '';
|
||||
pushResult = null;
|
||||
} catch (cause) {
|
||||
report(cause, 'Could not compute the results for this selection.');
|
||||
} finally {
|
||||
@@ -166,6 +203,35 @@
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the table above into the club's spreadsheet, as one tab — the same grid the CSV
|
||||
* carries, so this replaces "download, then import by hand".
|
||||
*
|
||||
* The target spreadsheet is server configuration (it is replaced each year); only the tab
|
||||
* title is chosen here.
|
||||
*/
|
||||
async function pushToSheets() {
|
||||
if (pushing || !ranking.rows.length || !sheets?.configured) return;
|
||||
|
||||
pushing = true;
|
||||
error = null;
|
||||
notice = null;
|
||||
pushResult = null;
|
||||
|
||||
try {
|
||||
const table = rankingToSheetTable(ranking, {
|
||||
name: resolvedTabName,
|
||||
events: generatedEvents,
|
||||
generatedAt: new Date().toISOString()
|
||||
});
|
||||
pushResult = await exportToSheets({ tabs: [table] });
|
||||
} catch (cause) {
|
||||
report(cause, 'Could not write to the spreadsheet.');
|
||||
} finally {
|
||||
pushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -334,9 +400,79 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button class="{ghost} mt-4" onclick={exportCsv} disabled={!ranking.rows.length}>
|
||||
Export CSV
|
||||
</button>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-line pt-4">
|
||||
<button class={ghost} onclick={exportCsv} disabled={!ranking.rows.length}>
|
||||
Export CSV
|
||||
</button>
|
||||
|
||||
{#if sheets?.configured}
|
||||
<!--
|
||||
Same table, straight into the spreadsheet — the point being to skip the
|
||||
download-then-import step. Only the tab title is chosen here; the
|
||||
spreadsheet itself is server configuration, since it changes every year.
|
||||
-->
|
||||
<span class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<label class="flex items-center gap-2 text-xs text-muted">
|
||||
Tab
|
||||
<input
|
||||
bind:value={tabName}
|
||||
class="{field} w-44"
|
||||
placeholder={suggestedName}
|
||||
aria-label="Spreadsheet tab to write"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class={primary}
|
||||
onclick={pushToSheets}
|
||||
disabled={pushing || !ranking.rows.length}
|
||||
>
|
||||
{pushing ? 'Writing…' : 'Push to Google Sheets'}
|
||||
</button>
|
||||
</span>
|
||||
{:else if sheets}
|
||||
<span class="ml-auto text-xs text-subtle">
|
||||
Google Sheets export not configured on the server{sheets.writer
|
||||
? ` (writer: ${sheets.writer})`
|
||||
: ''} — see <code>.env.example</code>.
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sheets?.configured}
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
Writes this table to
|
||||
<span class="text-ink">{resolvedTabName}</span>, replacing that tab's contents and
|
||||
keeping its formatting. Other tabs are left alone, and re-running changes nothing
|
||||
but the timestamp.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if pushResult}
|
||||
{@const written = pushResult.tabs?.[0]}
|
||||
<div class="{alertNotice} mt-3">
|
||||
<p>
|
||||
Wrote {written?.rows ?? 0} rows to
|
||||
<span class="font-semibold">{written?.name}</span>
|
||||
{#if pushResult.spreadsheetUrl}
|
||||
in
|
||||
<a
|
||||
href={pushResult.spreadsheetUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="underline">the spreadsheet</a
|
||||
>
|
||||
{/if}
|
||||
{#if written && !written.created}· replaced an existing tab{/if}
|
||||
</p>
|
||||
{#if pushResult.warnings?.length}
|
||||
<ul class="mt-1 space-y-0.5 text-xs">
|
||||
{#each pushResult.warnings as warning (warning)}
|
||||
<li>{warning}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if tab === 'game'}
|
||||
<div class="mt-5 grid gap-5 sm:grid-cols-[14rem_1fr]">
|
||||
<ul class="max-h-80 space-y-1 overflow-y-auto pr-1 text-sm">
|
||||
|
||||
Reference in New Issue
Block a user