diff --git a/.env.example b/.env.example index 04fd547..954fee7 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,56 @@ #LADOSE_SMASH_API_KEY= #LADOSE_CHALLONGE_API_KEY= +# --- Google Sheets export ------------------------------------------------------------ +# Powers "Push to Google Sheets" next to Export CSV on /tournaments: writes the generated +# ranking table into one tab, named after the latest event in the selection. +# +# Writer: Disabled | Logging | ServiceAccount. +# Disabled the button says "not configured" and refuses (the default) +# Logging writes the payload to the API log instead of Google — use this to check +# a selection without touching a spreadsheet +# ServiceAccount writes for real +#LADOSE_SHEETS_WRITER=Disabled +# +# The target spreadsheet. THIS is the value to change each year when you start a new +# sheet — then share the new sheet with the service-account address below as Editor. +# It is the id from the sheet URL, not the whole URL: +# https://docs.google.com/spreadsheets/d//edit +#LADOSE_SHEETS_SPREADSHEET_ID= +# +# Path *inside the container* to the service-account JSON key. docker-compose.yml mounts +# ./secrets (git-ignored) at /run/secrets, so drop the key there and leave this as is. +# +# One-time Google setup: create a project, enable the Google Sheets API, create a service +# account, create a JSON key, save it as secrets/ladose-sheets-sa.json, then share the +# spreadsheet with the account's ...iam.gserviceaccount.com address as Editor. Only that +# last sharing step repeats when you switch to a new sheet. +#LADOSE_SHEETS_SA_CREDENTIALS_PATH=/run/secrets/ladose-sheets-sa.json + +# --- Discord bot ----------------------------------------------------------------------- +# LaDOSE.DiscordBot only runs when you ask for it: `docker compose --profile bot up`. +# These values are rendered into the container's settings.json at start — the bot reads +# that file and never the environment. +# +# From https://discord.com/developers/applications -> your application -> Bot -> Reset +# Token. Setting this is what enables the rendering; leave it commented out and the bot +# falls back to the placeholder settings.json baked into the image (which cannot connect). +# The bot also needs the MESSAGE CONTENT intent enabled on that same page: Program.cs asks +# for DiscordIntents.MessageContents and Discord refuses the connection otherwise. +#LADOSE_DISCORD_TOKEN= +# +# Where the bot finds LaDOSE.Api. Resolved inside the container, so this is the compose +# service name, not localhost, and LADOSE_API_PORT does not apply. +#LADOSE_BOT_REST_URL=http://api:5000 +# +# A LaDOSE.Api login for the bot — the commands that read events and rankings authenticate +# with it. Without it those commands log "Unable to contact services" and the rest of the +# bot still works. +#LADOSE_BOT_REST_USER= +#LADOSE_BOT_REST_PASSWORD= +# +# Challonge is shared with the api service: LADOSE_CHALLONGE_API_KEY above feeds both. + # --- Frontend ------------------------------------------------------------------------ # Only needed if the API is not on http://localhost:${LADOSE_API_PORT}. Resolved by the # browser, so container names like http://api:5000 will not work. diff --git a/.gitignore b/.gitignore index 76cb842..990c5b4 100644 --- a/.gitignore +++ b/.gitignore @@ -335,3 +335,10 @@ ASALocalRun/ .env.* !.env.example docker-compose.override.yml + +# Google service-account key for the Sheets export, mounted at /run/secrets. +# Full write access to the ranking spreadsheet — never commit it. +# `secrets/*`, not `secrets/`: excluding the directory itself would stop git from +# looking inside it at all, and the un-ignore below would never apply. +secrets/* +!secrets/.gitkeep diff --git a/LaDOSE.Src/LaDOSE.Api/Controllers/SheetsController.cs b/LaDOSE.Src/LaDOSE.Api/Controllers/SheetsController.cs new file mode 100644 index 0000000..df7fa58 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Api/Controllers/SheetsController.cs @@ -0,0 +1,101 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using LaDOSE.Business.Interface; +using LaDOSE.DTO; +using LaDOSE.Entity; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace LaDOSE.Api.Controllers +{ + /// + /// Pushes ranking tables into the club's Google Spreadsheet. + /// + /// The tables arrive already built: the browser reuses the same buildRanking() that produces + /// the CSV export, so the sheet and the CSV cannot drift. This controller resolves the target + /// from configuration, validates, and relays — see SheetsExportService. + /// + /// Returns IActionResult with explicit ProducesResponseType, like UsersController: the API has + /// no exception middleware, so an escaping throw reaches the browser as an HTML developer page + /// that the client can only report as a bare status. + /// + [Authorize] + [Produces("application/json")] + [Route("api/[controller]")] + public class SheetsController : ControllerBase + { + private readonly ISheetsExportService _service; + private readonly IMapper _mapper; + private readonly ILogger _logger; + + public SheetsController(IMapper mapper, ISheetsExportService service, ILogger logger) + { + _mapper = mapper; + _service = service; + _logger = logger; + } + + /// + /// Whether the export is usable and where it points, so the panel can render itself and + /// disable the button when the server is not set up. Carries no secret and no key path. + /// + [HttpGet("Config")] + [ProducesResponseType(typeof(SheetsConfigDTO), StatusCodes.Status200OK)] + public IActionResult GetConfig() + { + return Ok(_mapper.Map(_service.GetConfig())); + } + + /// + /// Writes one tab per table, in the order given — oldest event first, each tab holding the + /// cumulative result up to and including its own event. + /// + /// Existing tabs of the same name are cleared and rewritten in place, keeping their + /// formatting; anything typed into them by hand is lost. Tabs not named in the request are + /// never touched or deleted. + /// + [HttpPost("Export")] + [ProducesResponseType(typeof(SheetExportResultDTO), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task Export([FromBody] SheetExportRequestDTO dto, CancellationToken ct) + { + if (dto?.Tabs == null || dto.Tabs.Count == 0) + return BadRequest(new { message = "No table to write." }); + + var request = _mapper.Map(dto); + + try + { + var result = await _service.ExportAsync(request, ct); + + _logger.LogInformation( + "Sheets export by user {User}: {TabCount} tabs to spreadsheet {SpreadsheetId} via {Writer}", + User?.Identity?.Name, result.Tabs?.Count ?? 0, result.SpreadsheetId, result.Writer); + + return Ok(_mapper.Map(result)); + } + catch (SheetsExportException ex) + { + _logger.LogWarning(ex, "Sheets export refused ({StatusCode})", ex.StatusCode); + return StatusCode(ex.StatusCode, new { message = ex.Message }); + } + catch (OperationCanceledException) + { + // The caller navigated away or hit cancel; nothing to report to them. + throw; + } + catch (Exception ex) + { + // Anything unforeseen still has to arrive as JSON with a message. + _logger.LogError(ex, "Sheets export failed"); + return StatusCode(StatusCodes.Status502BadGateway, new { message = ex.Message }); + } + } + } +} diff --git a/LaDOSE.Src/LaDOSE.Api/Controllers/StatisticsController.cs b/LaDOSE.Src/LaDOSE.Api/Controllers/StatisticsController.cs index 9a07cbb..c876007 100644 --- a/LaDOSE.Src/LaDOSE.Api/Controllers/StatisticsController.cs +++ b/LaDOSE.Src/LaDOSE.Api/Controllers/StatisticsController.cs @@ -34,5 +34,29 @@ namespace LaDOSE.Api.Controllers var stats = await _service.GetMatchStats(ids); return _mapper.Map(stats); } + + /// + /// The players a versus lookup can report on: everyone with at least one set in a + /// bracket whose game is known, by display name. Not the application users — + /// these are tournament players, the entities set rows point at. + /// + [HttpGet("Players")] + public async Task> GetVersusPlayers() + { + var players = await _service.GetVersusPlayers(); + return _mapper.Map>(players); + } + + /// + /// Every recorded meeting between two players, across all events, split per game. + /// Sets played in a bracket with no game attached are excluded and only counted in + /// UnknownGameSets. Two identical or unknown ids return an empty breakdown, not an error. + /// + [HttpGet("Versus/{playerAId}/{playerBId}")] + public async Task GetVersus(int playerAId, int playerBId) + { + var versus = await _service.GetVersus(playerAId, playerBId); + return _mapper.Map(versus); + } } } diff --git a/LaDOSE.Src/LaDOSE.Api/LaDOSE.Api.csproj b/LaDOSE.Src/LaDOSE.Api/LaDOSE.Api.csproj index b2e6f39..18991cb 100644 --- a/LaDOSE.Src/LaDOSE.Api/LaDOSE.Api.csproj +++ b/LaDOSE.Src/LaDOSE.Api/LaDOSE.Api.csproj @@ -19,7 +19,7 @@ - + diff --git a/LaDOSE.Src/LaDOSE.Api/Startup.cs b/LaDOSE.Src/LaDOSE.Api/Startup.cs index 5647462..ad0a3c3 100644 --- a/LaDOSE.Src/LaDOSE.Api/Startup.cs +++ b/LaDOSE.Src/LaDOSE.Api/Startup.cs @@ -19,6 +19,7 @@ using AutoMapper; using LaDOSE.Api.Helpers; using LaDOSE.Business.Helper; using LaDOSE.Business.Provider.ChallongProvider; +using LaDOSE.Business.Provider.SheetsProvider; using LaDOSE.Business.Provider.SmashProvider; using LaDOSE.Entity.Challonge; using LaDOSE.Entity.Wordpress; @@ -69,7 +70,11 @@ namespace LaDOSE.Api }).AddNewtonsoftJson(x => { x.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore; - x.SerializerSettings.MaxDepth= 4; + // MaxDepth governs *reading*, so it caps how deep an inbound body may nest. + // ReferenceLoopHandling above is what tames the outbound Entity graph. + // The Sheets export body is 7 deep (root > tabs > tab > rows > row > points > + // number), so the previous value of 4 rejected it outright. + x.SerializerSettings.MaxDepth = 32; }); #if DEBUG services.AddOpenApi(); @@ -162,6 +167,19 @@ namespace LaDOSE.Api cfg.CreateMap(); cfg.CreateMap(); cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); + + // Sheets export. Two-way: the request arrives as a DTO and has to become a POCO, + // which plain CreateMap does not give. SheetExportRequest.SpreadsheetId has no DTO + // counterpart on purpose — SheetsExportService fills it from configuration. + cfg.CreateMapTwoWay(); + cfg.CreateMapTwoWay(); + cfg.CreateMapTwoWay(); + cfg.CreateMap(); + cfg.CreateMap(); + cfg.CreateMap(); }); IMapper mapper = mapperConfig.CreateMapper(); @@ -194,6 +212,45 @@ namespace LaDOSE.Api this.Configuration["ApiKey:SmashApiKey"])); services.AddScoped(); + #region Google Sheets export + + // Limits and the target spreadsheet. The spreadsheet is reset every year, so it lives + // in configuration rather than in code — see .env.example. + services.AddSingleton(new SheetsSettings + { + SpreadsheetId = this.Configuration["GoogleSheets:SpreadsheetId"], + MaxTabs = ReadInt("GoogleSheets:MaxTabs", 60), + MaxRowsPerTab = ReadInt("GoogleSheets:MaxRowsPerTab", 5000), + MaxColumns = ReadInt("GoogleSheets:MaxColumns", 200) + }); + + // One writer, chosen by configuration. "Logging" exercises the whole feature without + // touching Google; anything unrecognised disables the export with a message rather + // than a null reference. + services.AddScoped(p => + { + switch (this.Configuration["GoogleSheets:Writer"]) + { + case "ServiceAccount": + return new GoogleApiSheetsWriter( + this.Configuration["GoogleSheets:ServiceAccount:CredentialsPath"]); + case "Logging": + return new LoggingSheetsWriter( + p.GetRequiredService>()); + default: + return new DisabledSheetsWriter(); + } + }); + + services.AddScoped(); + + #endregion + } + + /// Configuration is all strings; a missing or unparsable value takes the default. + private int ReadInt(string key, int fallback) + { + return int.TryParse(this.Configuration[key], out var value) && value > 0 ? value : fallback; } diff --git a/LaDOSE.Src/LaDOSE.Api/appsettings.json b/LaDOSE.Src/LaDOSE.Api/appsettings.json index 1b8ab61..503eb29 100644 --- a/LaDOSE.Src/LaDOSE.Api/appsettings.json +++ b/LaDOSE.Src/LaDOSE.Api/appsettings.json @@ -1,7 +1,8 @@ { "Logging": { "LogLevel": { - "Default": "Warning" + "Default": "Warning", + "LaDOSE": "Information" } }, "ConnectionStrings": { @@ -13,7 +14,17 @@ }, "ApiKey": { "ChallongeApiKey": "Challonge ApiKey", - "SmashApiKey": "Smash" + "SmashApiKey": "SmashApiKey" + }, + "GoogleSheets": { + "Writer": "ServiceAccount", + "SpreadsheetId": "1FMS3ZesZC7yBNsJG3CpB5c8qzF95JHUpnsj2q45BSs4", + "MaxTabs": 60, + "MaxRowsPerTab": 5000, + "MaxColumns": 200, + "ServiceAccount": { + "CredentialsPath": "/home/tom/test-agent/LaDOSE/secrets/test.json" + } }, "AllowedHosts": "0.0.0.0", "Port": 5000, diff --git a/LaDOSE.Src/LaDOSE.DTO/LaDOSE.DTO.csproj b/LaDOSE.Src/LaDOSE.DTO/LaDOSE.DTO.csproj index 30e2de5..7d08244 100644 --- a/LaDOSE.Src/LaDOSE.DTO/LaDOSE.DTO.csproj +++ b/LaDOSE.Src/LaDOSE.DTO/LaDOSE.DTO.csproj @@ -6,7 +6,7 @@ - + diff --git a/LaDOSE.Src/LaDOSE.DTO/PlayerVersusDTO.cs b/LaDOSE.Src/LaDOSE.DTO/PlayerVersusDTO.cs new file mode 100644 index 0000000..f28eacb --- /dev/null +++ b/LaDOSE.Src/LaDOSE.DTO/PlayerVersusDTO.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; + +namespace LaDOSE.DTO +{ + /// + /// Every recorded meeting between two players, broken down per game. + /// Sets belonging to a bracket with no game attached are not in + /// nor in the totals — they are only counted in . + /// + public class PlayerVersusDTO + { + public int PlayerAId { get; set; } + + /// Gamertag, falling back to Name, else "#<id>". + public string PlayerA { get; set; } + + public int PlayerBId { get; set; } + + public string PlayerB { get; set; } + + /// Meetings in a bracket whose game is known — the sum of the rows in . + public int Sets { get; set; } + + /// Of those, the ones with a determinable winner. + public int DecidedSets { get; set; } + + public int WinsA { get; set; } + public int WinsB { get; set; } + + /// Meetings dropped because the bracket has no game attached. + public int UnknownGameSets { get; set; } + + /// One row per game they met in, most-played first. + public List Games { get; set; } + } + + /// One game's slice of a . + public class VersusGameStatsDTO + { + public int GameId { get; set; } + + /// Game name, or "#<id>" when the game row is gone. + public string Game { get; set; } + + public string GameLongName { get; set; } + + /// Meetings in this game, decided or not. + public int Sets { get; set; } + + /// Meetings with a determinable winner. Always equals WinsA + WinsB. + public int DecidedSets { get; set; } + + public int WinsA { get; set; } + public int WinsB { get; set; } + + /// Individual games won inside the decided sets. + public int GamesWonA { get; set; } + + public int GamesWonB { get; set; } + } + + /// + /// A player who can be picked for a versus lookup: one with at least one set in a + /// bracket whose game is known. + /// + public class PlayerOptionDTO + { + public int Id { get; set; } + + /// Gamertag, falling back to Name, else "#<id>". + public string Name { get; set; } + + /// Sets in a bracket with a known game, whoever the opponent was. + public int Sets { get; set; } + } +} diff --git a/LaDOSE.Src/LaDOSE.DTO/SheetExportDTO.cs b/LaDOSE.Src/LaDOSE.DTO/SheetExportDTO.cs new file mode 100644 index 0000000..f197879 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.DTO/SheetExportDTO.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; + +namespace LaDOSE.DTO +{ + /// + /// Body of POST /api/Sheets/Export. The target spreadsheet is deliberately absent: it comes + /// from server configuration only, so a caller cannot redirect the export. + /// + public class SheetExportRequestDTO + { + /// + /// One tab per selected event, oldest first. Tab N holds events 1..N merged, so the last + /// tab is the full cumulative ranking for the selection. + /// + public List Tabs { get; set; } + } + + /// One tab: a players x games grid with a Total column, as built by buildRanking(). + public class SheetTableDTO + { + /// Desired tab title, e.g. "Ranking #1301". Sanitised server-side. + public string Name { get; set; } + + /// Provenance and logging only; tabs are addressed by title. + public int EventId { get; set; } + + /// ["Players", <game names...>, "Total"]. + public List Header { get; set; } + + public List Rows { get; set; } + + /// Provenance lines written below the grid. + public List Footer { get; set; } + } + + public class SheetRowDTO + { + public string Player { get; set; } + + /// + /// Index-aligned with Header[1..^1]. Typed int, not string, so the values land in the + /// sheet as numbers and existing formulas and conditional formatting keep working. + /// + public List Points { get; set; } + + public int Total { get; set; } + } + + public class SheetExportResultDTO + { + public string SpreadsheetId { get; set; } + public string SpreadsheetUrl { get; set; } + public string Writer { get; set; } + public List Tabs { get; set; } + public List Warnings { get; set; } + } + + public class SheetTabResultDTO + { + public string RequestedName { get; set; } + public string Name { get; set; } + public int Rows { get; set; } + public int Columns { get; set; } + public bool Created { get; set; } + } + + /// + /// Response of GET /api/Sheets/Config. Never carries the credentials path or any secret — + /// only what the export panel needs to render itself. + /// + public class SheetsConfigDTO + { + /// "ServiceAccount" | "Logging" | "Disabled". + public string Writer { get; set; } + + public bool Configured { get; set; } + public string SpreadsheetId { get; set; } + public int MaxTabs { get; set; } + public int MaxRowsPerTab { get; set; } + } +} diff --git a/LaDOSE.Src/LaDOSE.DiscordBot/Dockerfile b/LaDOSE.Src/LaDOSE.DiscordBot/Dockerfile new file mode 100644 index 0000000..2771d3e --- /dev/null +++ b/LaDOSE.Src/LaDOSE.DiscordBot/Dockerfile @@ -0,0 +1,52 @@ +# Builds the LaDOSE.DiscordBot image. Context is LaDOSE.Src, same as LaDOSE.Src/Dockerfile, +# so the two share LaDOSE.Src/.dockerignore — hence the LaDOSE.DiscordBot/ prefix on every +# COPY below. docker-compose.yml passes `dockerfile: LaDOSE.DiscordBot/Dockerfile`. +# +# The context has to be the whole of LaDOSE.Src, not this directory: the bot pulls in +# LaDOSE.REST -> LaDOSE.DTO by ProjectReference, and Libraries/ChallongeCSharpDriver.dll +# by HintPath. +ARG DOTNET_VERSION=9.0 + +FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build +WORKDIR /src + +# Release here, unlike the API image. Nothing in this project is gated on Debug — the +# #if DEBUG / Condition="'$(Configuration)' == 'Debug'" pairs that force the API to build +# Debug (OpenAPI, Scalar) have no equivalent in LaDOSE.DiscordBot.csproj. +ARG BUILD_CONFIGURATION=Release + +# Project files first so this layer survives every .cs edit. Restore under the same +# Configuration as the publish below, matching LaDOSE.Src/Dockerfile. +COPY global.json ./ +COPY LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj LaDOSE.DiscordBot/ +COPY LaDOSE.REST/LaDOSE.REST.csproj LaDOSE.REST/ +COPY LaDOSE.DTO/LaDOSE.DTO.csproj LaDOSE.DTO/ +RUN dotnet restore LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj -p:Configuration=${BUILD_CONFIGURATION} + +COPY . . +RUN dotnet publish LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj -c ${BUILD_CONFIGURATION} --no-restore -o /app/out + +# The file on disk is Quotes.txt; Command/Public.cs:114 opens "quotes.txt". Windows does +# not care, Linux does — without this the !Quote command throws FileNotFoundException +# (LoadQuote, unlike LoadCards, does not catch it). A copy rather than a rename, so the +# name the .csproj lists keeps working too. +RUN if [ -f /app/out/Quotes.txt ] && [ ! -f /app/out/quotes.txt ]; then \ + cp /app/out/Quotes.txt /app/out/quotes.txt; \ + fi + +# runtime, not aspnet: this is a console app with no Kestrel and no listening port. +FROM mcr.microsoft.com/dotnet/runtime:${DOTNET_VERSION} +WORKDIR /app + +COPY --from=build /app/out/ ./ +COPY LaDOSE.DiscordBot/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# WORKDIR is load-bearing, not cosmetic: Program.cs points its ConfigurationBuilder at +# Directory.GetCurrentDirectory() for settings.json, and Public.cs reads questions.txt / +# answers.txt / quotes.txt by bare relative path. All four sit in /app. +# +# The entrypoint renders settings.json from the environment before handing over; see the +# script for why that indirection exists. +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["dotnet", "LaDOSE.DiscordBot.dll"] diff --git a/LaDOSE.Src/LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj b/LaDOSE.Src/LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj index a44ba00..00e2cf8 100644 --- a/LaDOSE.Src/LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj +++ b/LaDOSE.Src/LaDOSE.DiscordBot/LaDOSE.DiscordBot.csproj @@ -13,7 +13,7 @@ - + diff --git a/LaDOSE.Src/LaDOSE.DiscordBot/docker-entrypoint.sh b/LaDOSE.Src/LaDOSE.DiscordBot/docker-entrypoint.sh new file mode 100755 index 0000000..dd716fa --- /dev/null +++ b/LaDOSE.Src/LaDOSE.DiscordBot/docker-entrypoint.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# LaDOSE.DiscordBot reads its configuration from ./settings.json and from nothing else: +# Program.cs builds a ConfigurationBuilder with AddJsonFile("settings.json") and no +# AddEnvironmentVariables(), so a LADOSE_* variable would be invisible to it. This script +# renders that file at container start instead — the same indirection LaDOSE.WebApp's +# entrypoint uses to turn LADOSE_API_BASE_URL into /config.js. One image, any token. +# +# It writes only when LADOSE_DISCORD_TOKEN is set. Left unset, whatever settings.json is +# already at /app wins: the placeholder baked in by the build, or a file bind-mounted over +# it. Doing both — mounting settings.json read-only *and* setting the variable — fails +# here with EROFS rather than silently ignoring one of them. +set -eu + +settings_file=/app/settings.json + +# Drop control characters (newlines included) so a value cannot break out of its string +# literal, then escape backslashes before double quotes. A token containing " or \ stays +# inert data. +json_string() { + printf '%s' "${1:-}" | tr -d '\001-\037' | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +if [ -n "${LADOSE_DISCORD_TOKEN:-}" ]; then + rest_url=${LADOSE_BOT_REST_URL:-http://api:5000} + + # All five keys, always. Program.cs calls .ToString() on each of them at startup, so a + # missing one is a NullReferenceException before the bot ever reaches Discord. + cat >"$settings_file" <&2 +else + echo "settings.json: LADOSE_DISCORD_TOKEN unset, using the file already at $settings_file" >&2 +fi + +exec "$@" diff --git a/LaDOSE.Src/LaDOSE.Entity/LaDOSE.Entity.csproj b/LaDOSE.Src/LaDOSE.Entity/LaDOSE.Entity.csproj index 11a1002..70ff84f 100644 --- a/LaDOSE.Src/LaDOSE.Entity/LaDOSE.Entity.csproj +++ b/LaDOSE.Src/LaDOSE.Entity/LaDOSE.Entity.csproj @@ -7,7 +7,7 @@ - + diff --git a/LaDOSE.Src/LaDOSE.Entity/Sheets/SheetExport.cs b/LaDOSE.Src/LaDOSE.Entity/Sheets/SheetExport.cs new file mode 100644 index 0000000..6dea67e --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Entity/Sheets/SheetExport.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; + +namespace LaDOSE.Entity +{ + /// + /// One spreadsheet write: the tables to put in it, and which spreadsheet. + /// Not an entity — never mapped by EF. It arrives as SheetExportRequestDTO and is + /// mapped by AutoMapper, same pattern as , so property names + /// must stay identical to the DTO's. + /// + /// The tables are built client-side by $lib/tournaments/cumulative.ts, which reuses the + /// very same buildRanking() that produces the CSV export. That is deliberate: the sheet + /// and the CSV cannot drift because one function produces both. + /// + public class SheetExportRequest + { + /// + /// Resolved by SheetsExportService from configuration — never supplied by the caller, + /// so a client cannot aim the export at someone else's spreadsheet. + /// + public string SpreadsheetId { get; set; } + + /// + /// One tab per selected event, oldest first. Tab N holds events 1..N merged, so the + /// last tab is the full cumulative ranking for the scope. + /// + public List Tabs { get; set; } = new List(); + } + + /// One tab: a players x games grid with a Total column. + public class SheetTable + { + /// Desired tab title, e.g. "Ranking #1301". Sanitised before use. + public string Name { get; set; } + + /// + /// Provenance and logging only. Tabs are addressed by title, never by this — see the + /// note on tab identity in SheetsExportService. + /// + public int EventId { get; set; } + + /// ["Players", <game names...>, "Total"]. + public List Header { get; set; } = new List(); + + public List Rows { get; set; } = new List(); + + /// + /// Written below the grid, one line per entry, blank row in between. Carries which + /// events went into this tab so a stale or incomplete tab identifies itself. + /// + public List Footer { get; set; } = new List(); + } + + public class SheetRow + { + public string Player { get; set; } + + /// Index-aligned with Header[1..^1] — one entry per game column. + public List Points { get; set; } = new List(); + + public int Total { get; set; } + } + + public class SheetExportResult + { + public string SpreadsheetId { get; set; } + public string SpreadsheetUrl { get; set; } + + /// Which ISheetsWriter did the work — "ServiceAccount", "Logging", ... + public string Writer { get; set; } + + public List Tabs { get; set; } = new List(); + + /// Anything the user should know but that did not stop the write, e.g. renames. + public List Warnings { get; set; } = new List(); + } + + public class SheetTabResult + { + /// The title as asked for, before sanitisation. + public string RequestedName { get; set; } + + /// The title actually written to. + public string Name { get; set; } + + public int Rows { get; set; } + public int Columns { get; set; } + + /// False when an existing tab of that name was rewritten in place. + public bool Created { get; set; } + } + + /// What the UI needs to render the export panel. Never carries a secret. + public class SheetsConfig + { + public string Writer { get; set; } + + /// The writer has its credentials and a target spreadsheet is configured. + public bool Configured { get; set; } + + public string SpreadsheetId { get; set; } + public int MaxTabs { get; set; } + public int MaxRowsPerTab { get; set; } + } + + /// + /// Server-side limits and the target spreadsheet, read from the GoogleSheets configuration + /// section. Constructor-injected rather than IOptions, matching how the Challonge and Smash + /// providers take their keys. + /// + public class SheetsSettings + { + /// + /// The spreadsheet everything is written to. Reset each year — see .env.example. + /// + public string SpreadsheetId { get; set; } + + public int MaxTabs { get; set; } = 60; + public int MaxRowsPerTab { get; set; } = 5000; + + /// Google caps a tab at 100 chars; this caps the grid width. + public int MaxColumns { get; set; } = 200; + } +} diff --git a/LaDOSE.Src/LaDOSE.Entity/TournamentEntities/PlayerVersus.cs b/LaDOSE.Src/LaDOSE.Entity/TournamentEntities/PlayerVersus.cs new file mode 100644 index 0000000..3d2dcce --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Entity/TournamentEntities/PlayerVersus.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; + +namespace LaDOSE.Entity +{ + /// + /// Every recorded meeting between two players, broken down per game. + /// Not an entity: computed in memory by StatisticsService and mapped to + /// PlayerVersusDTO by AutoMapper, same as . + /// Property names must stay identical to the DTO's, the mapping is by convention. + /// + /// A carries no game of its own — the game comes from the + /// the set belongs to, and that Tournament.GameId is + /// nullable. Sets behind a bracket with no game are counted in + /// and excluded from everything else, because a + /// per-game breakdown cannot say anything about them. + /// + public class PlayerVersus + { + public int PlayerAId { get; set; } + + /// Gamertag, falling back to Name, else "#<id>". + public string PlayerA { get; set; } + + public int PlayerBId { get; set; } + + public string PlayerB { get; set; } + + /// Meetings in a bracket whose game is known — the sum of the rows below. + public int Sets { get; set; } + + /// Of those, the ones with a determinable winner (unequal scores). + public int DecidedSets { get; set; } + + public int WinsA { get; set; } + public int WinsB { get; set; } + + /// + /// Meetings dropped because the bracket has no game attached. Reported rather + /// than hidden: it is the difference between "they never met" and "we cannot + /// tell which game they met in". + /// + public int UnknownGameSets { get; set; } + + /// One row per game they actually met in, most-played first. + public List Games { get; set; } = new List(); + } + + /// One game's slice of a . + public class VersusGameStats + { + public int GameId { get; set; } + + /// Game.Name, or "#<id>" when the game row is gone. + public string Game { get; set; } + + public string GameLongName { get; set; } + + /// Meetings in this game, decided or not. + public int Sets { get; set; } + + /// Meetings with a determinable winner. Always equals WinsA + WinsB. + public int DecidedSets { get; set; } + + public int WinsA { get; set; } + public int WinsB { get; set; } + + /// Individual games won inside the decided sets (a DQ, stored as -1, clamps to 0). + public int GamesWonA { get; set; } + + public int GamesWonB { get; set; } + } + + /// + /// A player who can be picked for a versus lookup, i.e. one with at least one set + /// in a bracket whose game is known. Offering anyone else would mean offering + /// players the breakdown must then report as empty. + /// + public class PlayerOption + { + public int Id { get; set; } + + /// Gamertag, falling back to Name, else "#<id>". + public string Name { get; set; } + + /// Sets in a bracket with a known game, whoever the opponent was. + public int Sets { get; set; } + } +} diff --git a/LaDOSE.Src/LaDOSE.REST/LaDOSE.REST.csproj b/LaDOSE.Src/LaDOSE.REST/LaDOSE.REST.csproj index e0cc132..1bfdf02 100644 --- a/LaDOSE.Src/LaDOSE.REST/LaDOSE.REST.csproj +++ b/LaDOSE.Src/LaDOSE.REST/LaDOSE.REST.csproj @@ -7,7 +7,7 @@ - + diff --git a/LaDOSE.Src/LaDOSE.Service/Interface/ISheetsExportService.cs b/LaDOSE.Src/LaDOSE.Service/Interface/ISheetsExportService.cs new file mode 100644 index 0000000..6d86d2b --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Service/Interface/ISheetsExportService.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using LaDOSE.Entity; + +namespace LaDOSE.Business.Interface +{ + public interface ISheetsExportService + { + /// What the export panel needs to render. Never includes a secret. + SheetsConfig GetConfig(); + + /// + /// Resolves the target spreadsheet from configuration, validates the tables, sanitises + /// and de-duplicates the tab titles, then hands off to the configured + /// . Throws for anything + /// the caller can act on. + /// + Task ExportAsync(SheetExportRequest request, CancellationToken ct = default); + } + + /// + /// A failure with a message meant for the person who pressed the button, plus the status + /// the API should answer with. The API has no exception middleware, so anything that escapes + /// reaches the browser as an HTML developer page the client can only report as a bare status + /// — hence every foreseeable failure is raised as one of these instead. + /// + public class SheetsExportException : Exception + { + public SheetsExportException(int statusCode, string message) : base(message) + { + StatusCode = statusCode; + } + + public SheetsExportException(int statusCode, string message, Exception inner) : base(message, inner) + { + StatusCode = statusCode; + } + + public int StatusCode { get; } + } +} diff --git a/LaDOSE.Src/LaDOSE.Service/Interface/ISheetsWriter.cs b/LaDOSE.Src/LaDOSE.Service/Interface/ISheetsWriter.cs new file mode 100644 index 0000000..79f1348 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Service/Interface/ISheetsWriter.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; +using LaDOSE.Entity; + +namespace LaDOSE.Business.Interface +{ + /// + /// Writes tabular data into a Google Spreadsheet. Startup picks one implementation from + /// GoogleSheets:Writer, so nothing above this interface knows how Google is reached. + /// + /// Implementations must be idempotent: writing the same request twice leaves the same + /// spreadsheet. They must also leave tabs they were not asked about completely alone — + /// the spreadsheet holds hand-made summaries and charts, and deletion is irreversible + /// through the API. + /// + public interface ISheetsWriter + { + /// Reported to the UI so the panel can say what it is talking to. + string Name { get; } + + /// False when credentials are missing, so the API can answer 503 with a message. + bool IsConfigured { get; } + + Task WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default); + } +} diff --git a/LaDOSE.Src/LaDOSE.Service/Interface/IStatisticsService.cs b/LaDOSE.Src/LaDOSE.Service/Interface/IStatisticsService.cs index a71ff05..22d2fcc 100644 --- a/LaDOSE.Src/LaDOSE.Service/Interface/IStatisticsService.cs +++ b/LaDOSE.Src/LaDOSE.Service/Interface/IStatisticsService.cs @@ -12,5 +12,19 @@ namespace LaDOSE.Business.Interface /// A null or empty id list yields a well-formed, zeroed . /// Task GetMatchStats(List eventIds); + + /// + /// The players a versus lookup can say something about: those with at least one + /// set in a bracket whose game is known, by display name. + /// + Task> GetVersusPlayers(); + + /// + /// Every recorded meeting between two players, all events, broken down per game. + /// Sets whose bracket has no game are excluded and only counted in + /// . + /// Missing, equal or unknown ids yield a well-formed empty . + /// + Task GetVersus(int playerAId, int playerBId); } } diff --git a/LaDOSE.Src/LaDOSE.Service/LaDOSE.Business.csproj b/LaDOSE.Src/LaDOSE.Service/LaDOSE.Business.csproj index 2db5331..23ff289 100644 --- a/LaDOSE.Src/LaDOSE.Service/LaDOSE.Business.csproj +++ b/LaDOSE.Src/LaDOSE.Service/LaDOSE.Business.csproj @@ -8,10 +8,11 @@ + - + diff --git a/LaDOSE.Src/LaDOSE.Service/Provider/SheetsProvider/DisabledSheetsWriter.cs b/LaDOSE.Src/LaDOSE.Service/Provider/SheetsProvider/DisabledSheetsWriter.cs new file mode 100644 index 0000000..d35a9f5 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Service/Provider/SheetsProvider/DisabledSheetsWriter.cs @@ -0,0 +1,25 @@ +using System.Threading; +using System.Threading.Tasks; +using LaDOSE.Business.Interface; +using LaDOSE.Entity; + +namespace LaDOSE.Business.Provider.SheetsProvider +{ + /// + /// The default when GoogleSheets:Writer names nothing usable. Reports itself as + /// unconfigured so the API answers 503 with a message the user can act on, rather than + /// throwing a NullReferenceException that reaches the browser as an HTML developer page. + /// + public class DisabledSheetsWriter : ISheetsWriter + { + public string Name => "Disabled"; + + public bool IsConfigured => false; + + public Task WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default) + { + throw new SheetsExportException(503, + "Google Sheets export is not configured on the server. Set GoogleSheets:Writer."); + } + } +} diff --git a/LaDOSE.Src/LaDOSE.Service/Provider/SheetsProvider/GoogleApiSheetsWriter.cs b/LaDOSE.Src/LaDOSE.Service/Provider/SheetsProvider/GoogleApiSheetsWriter.cs new file mode 100644 index 0000000..4d8c701 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Service/Provider/SheetsProvider/GoogleApiSheetsWriter.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Google; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Services; +using Google.Apis.Sheets.v4; +using Google.Apis.Sheets.v4.Data; +using LaDOSE.Business.Interface; +using LaDOSE.Entity; + +namespace LaDOSE.Business.Provider.SheetsProvider +{ + /// + /// Writes to Google Sheets as a service account. + /// + /// Two HTTP calls: read the existing tab list, then one batchUpdate. That second call is + /// atomic — either every tab lands or none does — so a failure can never leave the ranking + /// half updated. + /// + /// Setup, once: create a service account, download its JSON key, point + /// GoogleSheets:ServiceAccount:CredentialsPath at it, and share the spreadsheet with the + /// account's ...iam.gserviceaccount.com address as Editor. That last step is the one people + /// forget, so a 403 says so explicitly and names the address. + /// + public class GoogleApiSheetsWriter : ISheetsWriter + { + private readonly string _credentialsPath; + + public GoogleApiSheetsWriter(string credentialsPath) + { + _credentialsPath = credentialsPath; + } + + public string Name => "ServiceAccount"; + + public bool IsConfigured => + !string.IsNullOrWhiteSpace(_credentialsPath) && File.Exists(_credentialsPath); + + public async Task WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default) + { + if (!IsConfigured) + { + throw new SheetsExportException(503, + $"Service-account key not found at '{_credentialsPath}'. " + + "Check GoogleSheets:ServiceAccount:CredentialsPath and that the file is mounted."); + } + + using var service = CreateService(out var accountEmail); + + var tabs = request.Tabs ?? new List(); + + try + { + // 1. What is already in the spreadsheet: titles, ids, and current grid sizes. + var get = service.Spreadsheets.Get(request.SpreadsheetId); + get.Fields = "spreadsheetId,spreadsheetUrl,sheets.properties(sheetId,title,index,gridProperties)"; + var spreadsheet = await get.ExecuteAsync(ct); + + var existing = (spreadsheet.Sheets ?? new List()) + .Select(sheet => sheet.Properties) + .Where(properties => properties != null) + .ToList(); + + // 2. One atomic batch for the whole export. + var batch = BuildBatch(existing, tabs, out var results); + if (batch.Requests.Count > 0) + { + await service.Spreadsheets.BatchUpdate(batch, request.SpreadsheetId).ExecuteAsync(ct); + } + + return new SheetExportResult + { + SpreadsheetId = spreadsheet.SpreadsheetId ?? request.SpreadsheetId, + SpreadsheetUrl = spreadsheet.SpreadsheetUrl ?? LoggingSheetsWriter.SheetUrl(request.SpreadsheetId), + Writer = Name, + Tabs = results, + Warnings = new List() + }; + } + catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.Forbidden) + { + throw new SheetsExportException(502, + $"Google refused access to spreadsheet '{request.SpreadsheetId}'. " + + $"Share it as Editor with {accountEmail ?? "the service account address"}.", ex); + } + catch (GoogleApiException ex) when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound) + { + throw new SheetsExportException(502, + $"No spreadsheet with id '{request.SpreadsheetId}'. " + + "Check GoogleSheets:SpreadsheetId — it is the id from the sheet URL, not the whole URL.", ex); + } + catch (GoogleApiException ex) + { + throw new SheetsExportException(502, $"Google rejected the write: {ex.Message}", ex); + } + } + + /// + /// Builds the credential from the key file explicitly. GoogleCredential.FromFile and + /// .FromJson are both deprecated, and ServiceAccountCredential is already an HTTP client + /// initializer, so there is nothing for GoogleCredential to add here. Doing it this way + /// also hands us the account's own address for the "share the sheet with…" message. + /// + private SheetsService CreateService(out string accountEmail) + { + ServiceAccountCredential credential; + try + { + var json = File.ReadAllText(_credentialsPath); + var parameters = Google.Apis.Json.NewtonsoftJsonSerializer.Instance + .Deserialize(json); + + if (parameters?.Type != JsonCredentialParameters.ServiceAccountCredentialType + || string.IsNullOrEmpty(parameters.ClientEmail) + || string.IsNullOrEmpty(parameters.PrivateKey)) + { + throw new InvalidOperationException( + "not a service-account key (expected \"type\": \"service_account\" with client_email and private_key)"); + } + + accountEmail = parameters.ClientEmail; + credential = new ServiceAccountCredential( + new ServiceAccountCredential.Initializer(parameters.ClientEmail) + { + ProjectId = parameters.ProjectId, + KeyId = parameters.PrivateKeyId, + Scopes = new[] { SheetsService.Scope.Spreadsheets } + }.FromPrivateKey(parameters.PrivateKey)); + } + catch (Exception ex) + { + throw new SheetsExportException(503, + $"Could not read the service-account key at '{_credentialsPath}': {ex.Message}", ex); + } + + return new SheetsService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "LaDOSE" + }); + } + + #region Batch construction + + /// + /// Turns "what the spreadsheet has" plus "what we want" into one ordered request list. + /// Deliberately static and free of I/O so the whole batch can be asserted in a test with + /// no network — the same split StatisticsService uses for its pure Aggregate. + /// + /// Order matters: create missing tabs, resize, reindex, then per tab clear and write. + /// No tab is ever deleted — unlisted tabs hold hand-made summaries and charts. + /// + public static BatchUpdateSpreadsheetRequest BuildBatch( + List existing, + List tabs, + out List results) + { + var requests = new List(); + results = new List(); + + var byTitle = (existing ?? new List()) + .Where(properties => properties.Title != null) + .GroupBy(properties => properties.Title, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + + // Ids for tabs that do not exist yet are only known after the batch runs, so the + // clear and write for those address the sheet by title through a placeholder id. + // AddSheet with an explicit SheetId avoids that entirely: we pick the ids ourselves. + var nextId = NextFreeSheetId(byTitle.Values); + + for (var index = 0; index < tabs.Count; index++) + { + var tab = tabs[index]; + var values = ToRowData(tab); + var rowCount = values.Count; + var columnCount = tab.Header?.Count ?? 0; + + byTitle.TryGetValue(tab.Name, out var properties); + var created = properties == null; + + int sheetId; + if (created) + { + sheetId = nextId++; + requests.Add(new Request + { + AddSheet = new AddSheetRequest + { + Properties = new SheetProperties + { + SheetId = sheetId, + Title = tab.Name, + Index = index, + GridProperties = new GridProperties + { + RowCount = Math.Max(rowCount, 1), + ColumnCount = Math.Max(columnCount, 1), + FrozenRowCount = 1 + } + } + } + }); + } + else + { + sheetId = properties.SheetId ?? 0; + + // Grow before writing: the API silently drops cells outside the grid, and a + // sheet created by hand defaults to 1000 x 26. + var haveRows = properties.GridProperties?.RowCount ?? 0; + var haveColumns = properties.GridProperties?.ColumnCount ?? 0; + if (haveRows < rowCount || haveColumns < columnCount) + { + requests.Add(new Request + { + UpdateSheetProperties = new UpdateSheetPropertiesRequest + { + Fields = "gridProperties.rowCount,gridProperties.columnCount", + Properties = new SheetProperties + { + SheetId = sheetId, + GridProperties = new GridProperties + { + RowCount = Math.Max(haveRows, rowCount), + ColumnCount = Math.Max(haveColumns, columnCount) + } + } + } + }); + } + + // Oldest event leftmost, so the tabs read in season order. + if (properties.Index != index) + { + requests.Add(new Request + { + UpdateSheetProperties = new UpdateSheetPropertiesRequest + { + Fields = "index", + Properties = new SheetProperties { SheetId = sheetId, Index = index } + } + }); + } + } + + // Clear the whole sheet first, then write. Fields = "userEnteredValue" is the + // equivalent of clearContents(): colours, notes and conditional formatting the + // user set up by hand all survive. Without the clear, cells beyond the new + // extent would linger from a previous, longer export. + requests.Add(new Request + { + UpdateCells = new UpdateCellsRequest + { + Range = new GridRange { SheetId = sheetId }, + Fields = "userEnteredValue" + } + }); + + requests.Add(new Request + { + UpdateCells = new UpdateCellsRequest + { + Start = new GridCoordinate { SheetId = sheetId, RowIndex = 0, ColumnIndex = 0 }, + Rows = values, + Fields = "userEnteredValue" + } + }); + + results.Add(new SheetTabResult + { + RequestedName = tab.Name, + Name = tab.Name, + Rows = rowCount, + Columns = columnCount, + Created = created + }); + } + + return new BatchUpdateSpreadsheetRequest { Requests = requests }; + } + + /// Sheet ids must be unique within the spreadsheet and are ours to choose. + private static int NextFreeSheetId(IEnumerable existing) + { + var used = existing.Select(properties => properties.SheetId ?? 0).DefaultIfEmpty(0).Max(); + return Math.Max(used + 1, 1); + } + + /// + /// Header, then one row per player, then the footer after a blank line. Points and totals + /// go in as numbers rather than text so the user's formulas keep working. + /// + private static List ToRowData(SheetTable tab) + { + var header = tab.Header ?? new List(); + var width = header.Count; + var rows = new List + { + new RowData { Values = header.Select(Text).ToList() } + }; + + foreach (var row in tab.Rows ?? new List()) + { + var cells = new List { Text(row.Player) }; + cells.AddRange((row.Points ?? new List()).Select(Number)); + cells.Add(Number(row.Total)); + rows.Add(new RowData { Values = cells }); + } + + var footer = (tab.Footer ?? new List()).Where(line => line != null).ToList(); + if (footer.Count > 0) + { + rows.Add(new RowData { Values = Blank(width) }); + foreach (var line in footer) + { + var cells = Blank(width); + if (cells.Count > 0) cells[0] = Text(line); + else cells.Add(Text(line)); + rows.Add(new RowData { Values = cells }); + } + } + + return rows; + } + + private static List Blank(int width) + { + return Enumerable.Range(0, Math.Max(width, 0)).Select(_ => Text(string.Empty)).ToList(); + } + + private static CellData Text(string value) + { + return new CellData { UserEnteredValue = new ExtendedValue { StringValue = value ?? string.Empty } }; + } + + private static CellData Number(int value) + { + return new CellData { UserEnteredValue = new ExtendedValue { NumberValue = value } }; + } + + #endregion + } +} diff --git a/LaDOSE.Src/LaDOSE.Service/Provider/SheetsProvider/LoggingSheetsWriter.cs b/LaDOSE.Src/LaDOSE.Service/Provider/SheetsProvider/LoggingSheetsWriter.cs new file mode 100644 index 0000000..1d27153 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Service/Provider/SheetsProvider/LoggingSheetsWriter.cs @@ -0,0 +1,88 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using LaDOSE.Business.Interface; +using LaDOSE.Entity; +using Microsoft.Extensions.Logging; + +namespace LaDOSE.Business.Provider.SheetsProvider +{ + /// + /// Writes the export to the log instead of to Google, and reports success. + /// + /// This exists so the whole feature — button, ordering, cumulative prefixes, payload shape — + /// can be exercised end to end before any Google account, key or sharing exists. Set + /// GoogleSheets:Writer to "Logging" and read the API log. It is also the safe way to try a + /// selection without touching the real spreadsheet. + /// + public class LoggingSheetsWriter : ISheetsWriter + { + private readonly ILogger _logger; + + public LoggingSheetsWriter(ILogger logger) + { + _logger = logger; + } + + public string Name => "Logging"; + + public bool IsConfigured => true; + + public Task WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default) + { + var tabs = request.Tabs ?? new List(); + + _logger.LogInformation( + "Sheets export (Logging writer): spreadsheet {SpreadsheetId}, {TabCount} tabs", + request.SpreadsheetId, tabs.Count); + + foreach (var tab in tabs) + { + var header = tab.Header ?? new List(); + var rows = tab.Rows ?? new List(); + + _logger.LogInformation(" [{Name}] event {EventId}: {RowCount} rows x {ColumnCount} columns | {Header}", + tab.Name, tab.EventId, rows.Count, header.Count, string.Join(" | ", header)); + + foreach (var row in rows) + { + _logger.LogInformation(" {Player} | {Points} | {Total}", + row.Player, string.Join(" | ", row.Points ?? new List()), row.Total); + } + + foreach (var line in tab.Footer ?? new List()) + { + _logger.LogInformation(" -- {Line}", line); + } + } + + return Task.FromResult(new SheetExportResult + { + SpreadsheetId = request.SpreadsheetId, + SpreadsheetUrl = SheetUrl(request.SpreadsheetId), + Writer = Name, + Tabs = tabs.Select(tab => new SheetTabResult + { + RequestedName = tab.Name, + Name = tab.Name, + Rows = (tab.Rows?.Count ?? 0) + 1, + Columns = tab.Header?.Count ?? 0, + // Nothing was inspected, so this is a claim rather than an observation. + Created = true + }).ToList(), + Warnings = new List + { + "Writer is 'Logging': nothing was written to Google. The payload is in the API log." + } + }); + } + + internal static string SheetUrl(string spreadsheetId) + { + return string.IsNullOrWhiteSpace(spreadsheetId) + ? null + : $"https://docs.google.com/spreadsheets/d/{spreadsheetId}/edit"; + } + } +} diff --git a/LaDOSE.Src/LaDOSE.Service/Service/SheetsExportService.cs b/LaDOSE.Src/LaDOSE.Service/Service/SheetsExportService.cs new file mode 100644 index 0000000..7a134aa --- /dev/null +++ b/LaDOSE.Src/LaDOSE.Service/Service/SheetsExportService.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using LaDOSE.Business.Interface; +using LaDOSE.Entity; + +namespace LaDOSE.Business.Service +{ + /// + /// Everything about a spreadsheet export that is not Google-specific: resolving the target, + /// validating the tables, and making the tab titles legal and unique. Kept out of the writers + /// so the rules are stated once and can be tested without a network, the same way + /// StatisticsService keeps its aggregation in a pure static method. + /// + public class SheetsExportService : ISheetsExportService + { + private readonly SheetsSettings _settings; + private readonly ISheetsWriter _writer; + + public SheetsExportService(SheetsSettings settings, ISheetsWriter writer) + { + _settings = settings ?? new SheetsSettings(); + _writer = writer; + } + + public SheetsConfig GetConfig() + { + return new SheetsConfig + { + Writer = _writer?.Name ?? "Disabled", + Configured = _writer != null + && _writer.IsConfigured + && !string.IsNullOrWhiteSpace(_settings.SpreadsheetId), + SpreadsheetId = _settings.SpreadsheetId ?? string.Empty, + MaxTabs = _settings.MaxTabs, + MaxRowsPerTab = _settings.MaxRowsPerTab + }; + } + + public async Task ExportAsync(SheetExportRequest request, CancellationToken ct = default) + { + if (_writer == null || !_writer.IsConfigured) + { + throw new SheetsExportException(503, + "Google Sheets export is not configured on the server."); + } + + if (string.IsNullOrWhiteSpace(_settings.SpreadsheetId)) + { + throw new SheetsExportException(400, + "No spreadsheet configured on the server. Set GoogleSheets:SpreadsheetId."); + } + + var tabs = request?.Tabs?.Where(tab => tab != null).ToList() ?? new List(); + if (tabs.Count == 0) + { + throw new SheetsExportException(400, "No table to write."); + } + + Validate(tabs); + + var warnings = new List(); + NameTabs(tabs, warnings); + + // The caller never names the spreadsheet; it is resolved here, from configuration. + var resolved = new SheetExportRequest + { + SpreadsheetId = _settings.SpreadsheetId.Trim(), + Tabs = tabs + }; + + var result = await _writer.WriteTablesAsync(resolved, ct); + result.Writer = _writer.Name; + result.Warnings = (result.Warnings ?? new List()).Concat(warnings).ToList(); + return result; + } + + #region Validation + + private void Validate(List tabs) + { + if (tabs.Count > _settings.MaxTabs) + { + throw new SheetsExportException(400, + $"{tabs.Count} tabs requested, the limit is {_settings.MaxTabs}. Narrow the selection."); + } + + foreach (var tab in tabs) + { + var header = tab.Header ?? new List(); + + // "Players", at least one game, "Total". + if (header.Count < 3) + { + throw new SheetsExportException(400, + $"Tab '{tab.Name}' has no game column — nothing was scored for it."); + } + + if (header.Count > _settings.MaxColumns) + { + throw new SheetsExportException(400, + $"Tab '{tab.Name}' has {header.Count} columns, the limit is {_settings.MaxColumns}."); + } + + var rows = tab.Rows ?? new List(); + if (rows.Count > _settings.MaxRowsPerTab) + { + throw new SheetsExportException(400, + $"Tab '{tab.Name}' has {rows.Count} rows, the limit is {_settings.MaxRowsPerTab}."); + } + + var expected = header.Count - 2; + foreach (var row in rows) + { + var points = row?.Points?.Count ?? 0; + if (points != expected) + { + throw new SheetsExportException(400, + $"Tab '{tab.Name}' row '{row?.Player}' has {points} point columns, " + + $"header declares {expected}."); + } + } + } + } + + #endregion + + #region Tab naming + + /// + /// Google rejects these in a tab title. Replaced rather than stripped so "Ranking 13/14" + /// stays readable as "Ranking 13-14". + /// + private static readonly Regex Forbidden = new Regex(@"[:\\/?*\[\]]", RegexOptions.Compiled); + + private static readonly Regex Whitespace = new Regex(@"\s+", RegexOptions.Compiled); + + private const int MaxTitleLength = 100; + + /// + /// Makes every title legal and unique, in place, recording each change. Tab identity is + /// the title, so a rename means the next export writes somewhere else — which is exactly + /// why every rename is reported rather than applied quietly. + /// + private static void NameTabs(List tabs, List warnings) + { + var taken = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var tab in tabs) + { + var requested = tab.Name ?? string.Empty; + var name = Sanitise(requested, tab.EventId); + + if (!taken.Add(name)) + { + var suffix = 2; + string candidate; + do + { + candidate = Truncate($"{name} ({suffix})"); + suffix++; + } while (!taken.Add(candidate)); + + name = candidate; + } + + if (!string.Equals(name, requested, StringComparison.Ordinal)) + { + warnings.Add($"Tab renamed: '{requested}' -> '{name}'"); + } + + tab.Name = name; + } + } + + /// + /// Trim, replace what Google forbids, collapse runs of whitespace, drop leading and + /// trailing apostrophes (Sheets uses them to quote a title), cap the length, and fall + /// back to the event id when nothing usable survives. + /// + public static string Sanitise(string requested, int eventId) + { + var name = (requested ?? string.Empty).Trim(); + name = Forbidden.Replace(name, "-"); + name = Whitespace.Replace(name, " ").Trim(); + name = name.Trim('\''); + name = Truncate(name).Trim(); + + return string.IsNullOrWhiteSpace(name) ? $"Event {eventId}" : name; + } + + private static string Truncate(string value) + { + return value.Length <= MaxTitleLength ? value : value.Substring(0, MaxTitleLength); + } + + #endregion + } +} diff --git a/LaDOSE.Src/LaDOSE.Service/Service/StatisticsService.cs b/LaDOSE.Src/LaDOSE.Service/Service/StatisticsService.cs index 173db8e..c77ae49 100644 --- a/LaDOSE.Src/LaDOSE.Service/Service/StatisticsService.cs +++ b/LaDOSE.Src/LaDOSE.Service/Service/StatisticsService.cs @@ -19,9 +19,15 @@ namespace LaDOSE.Business.Service /// - There is no winner column. The winner is inferred from the scores, and start.gg /// encodes a DQ as -1, so games are clamped at 0. /// + /// - A Set has no game either. The game belongs to the the set + /// was played in, and Tournament.GameId is nullable, so any per-game breakdown has + /// to decide what to do with brackets that have none. drops + /// them and reports how many it dropped. + /// /// The database work and the aggregation are deliberately separated: - /// issues one query per table and hands the loaded lists to the pure static - /// , which is unit-testable without a database. + /// and issue one query per table and hand the loaded lists to the pure + /// static / , which are unit-testable + /// without a database. /// public class StatisticsService : IStatisticsService { @@ -78,6 +84,108 @@ namespace LaDOSE.Business.Service return Task.FromResult(Aggregate(requested, events, tournaments, sets, players)); } + public Task> GetVersusPlayers() + { + // Same filter GetVersus applies, so the picker cannot offer a player whose + // every meeting would then be dropped as "game unknown". Self-sets are + // excluded here too, otherwise they would be counted in both slots. + var usable = from s in _context.Set + join t in _context.Tournament on s.TournamentId equals t.Id + where t.GameId != null && s.Player1Id != 0 && s.Player2Id != 0 + && s.Player1Id != s.Player2Id + select s; + + // Counted in the database, one group-by per slot: the set table is the + // largest one here and there is no reason to pull it into memory. + var asPlayer1 = usable + .GroupBy(s => s.Player1Id) + .Select(g => new { PlayerId = g.Key, Sets = g.Count() }) + .ToList(); + + var asPlayer2 = usable + .GroupBy(s => s.Player2Id) + .Select(g => new { PlayerId = g.Key, Sets = g.Count() }) + .ToList(); + + var setsByPlayer = new Dictionary(); + foreach (var row in asPlayer1.Concat(asPlayer2)) + { + setsByPlayer.TryGetValue(row.PlayerId, out var running); + setsByPlayer[row.PlayerId] = running + row.Sets; + } + + if (setsByPlayer.Count == 0) + { + return Task.FromResult(new List()); + } + + var playerIds = setsByPlayer.Keys.ToList(); + var nameById = _context.Player + .Where(p => playerIds.Contains(p.Id)) + .ToList() + .ToDictionary(p => p.Id, DisplayName); + + // A set can reference a player row that no longer exists; keep it as "#id" + // rather than hiding a real opponent from the picker. + var options = setsByPlayer + .Select(pair => new PlayerOption + { + Id = pair.Key, + Name = ResolveName(pair.Key, nameById), + Sets = pair.Value + }) + .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(p => p.Id) + .ToList(); + + return Task.FromResult(options); + } + + public Task GetVersus(int playerAId, int playerBId) + { + // Nothing to look up, and nothing exceptional either: a caller that has not + // picked two distinct players gets an empty breakdown, not a 500. + if (playerAId == 0 || playerBId == 0 || playerAId == playerBId) + { + return Task.FromResult(new PlayerVersus + { + PlayerAId = playerAId, + PlayerBId = playerBId + }); + } + + // Either seating: the set rows record whoever start.gg listed first. + var sets = _context.Set + .Where(s => (s.Player1Id == playerAId && s.Player2Id == playerBId) + || (s.Player1Id == playerBId && s.Player2Id == playerAId)) + .ToList(); + + var tournamentIds = sets.Select(s => s.TournamentId).Distinct().ToList(); + var tournaments = tournamentIds.Count == 0 + ? new List() + : _context.Tournament + .Where(t => tournamentIds.Contains(t.Id)) + .ToList(); + + var gameIds = tournaments + .Where(t => t.GameId.HasValue) + .Select(t => t.GameId.Value) + .Distinct() + .ToList(); + + var games = gameIds.Count == 0 + ? new List() + : _context.Game + .Where(g => gameIds.Contains(g.Id)) + .ToList(); + + var players = _context.Player + .Where(p => p.Id == playerAId || p.Id == playerBId) + .ToList(); + + return Task.FromResult(AggregateVersus(playerAId, playerBId, sets, tournaments, games, players)); + } + /// /// Pure aggregation over already-loaded rows. No database, no I/O, deterministic. /// @@ -224,6 +332,142 @@ namespace LaDOSE.Business.Service return result; } + /// + /// Pure aggregation of the meetings between two players, per game. No database, + /// no I/O, deterministic. + /// + /// Winners are inferred from the scores exactly as in : equal + /// scores mean undecided. Undecided meetings still count in Sets — they happened — + /// but contribute to no win and no game count. + /// + /// Left-hand player; WinsA is always their side. + /// Right-hand player. + /// Candidate sets; the pairing is re-checked here. + /// Tournaments of those sets, for Tournament.GameId. + /// Games used to resolve names; may be incomplete. + /// Players used to resolve display names; may be incomplete. + public static PlayerVersus AggregateVersus( + int playerAId, + int playerBId, + IEnumerable sets, + IEnumerable tournaments, + IEnumerable games, + IEnumerable players) + { + var result = new PlayerVersus + { + PlayerAId = playerAId, + PlayerBId = playerBId + }; + + if (playerAId == 0 || playerBId == 0 || playerAId == playerBId) + { + return result; + } + + var nameById = new Dictionary(); + foreach (var player in (players ?? Enumerable.Empty()).Where(p => p != null)) + { + nameById[player.Id] = DisplayName(player); + } + + result.PlayerA = ResolveName(playerAId, nameById); + result.PlayerB = ResolveName(playerBId, nameById); + + var gameIdByTournament = new Dictionary(); + foreach (var tournament in (tournaments ?? Enumerable.Empty()).Where(t => t != null)) + { + gameIdByTournament[tournament.Id] = tournament.GameId; + } + + var gameById = new Dictionary(); + foreach (var game in (games ?? Enumerable.Empty()).Where(g => g != null)) + { + gameById[game.Id] = game; + } + + var perGame = new Dictionary(); + + foreach (var set in (sets ?? Enumerable.Empty()).Where(s => s != null)) + { + // The query already restricts the pairing; checking again keeps this + // method correct on its own, which is the point of it being pure. + var aIsPlayer1 = set.Player1Id == playerAId && set.Player2Id == playerBId; + var bIsPlayer1 = set.Player1Id == playerBId && set.Player2Id == playerAId; + if (!aIsPlayer1 && !bIsPlayer1) + { + continue; + } + + // No game on the bracket, nothing to file this meeting under. Counted so + // the caller can say "3 meetings we cannot attribute" instead of losing them. + if (!gameIdByTournament.TryGetValue(set.TournamentId, out var gameId) || !gameId.HasValue) + { + result.UnknownGameSets++; + continue; + } + + var row = GetOrAddGame(perGame, gameId.Value, gameById); + row.Sets++; + + var scoreA = aIsPlayer1 ? set.Player1Score : set.Player2Score; + var scoreB = aIsPlayer1 ? set.Player2Score : set.Player1Score; + + // No winner column: equal scores (including 0-0 and -1 / -1) are undecided. + if (scoreA == scoreB) + { + continue; + } + + row.DecidedSets++; + // A DQ is stored as -1. Clamp so it never produces negative games. + row.GamesWonA += Math.Max(0, scoreA); + row.GamesWonB += Math.Max(0, scoreB); + if (scoreA > scoreB) + { + row.WinsA++; + } + else + { + row.WinsB++; + } + } + + result.Games = perGame.Values + .OrderByDescending(g => g.Sets) + .ThenByDescending(g => g.DecidedSets) + .ThenBy(g => g.Game, StringComparer.Ordinal) + .ThenBy(g => g.GameId) + .ToList(); + + // Totals are derived from the rows, so the header cannot disagree with the table. + result.Sets = result.Games.Sum(g => g.Sets); + result.DecidedSets = result.Games.Sum(g => g.DecidedSets); + result.WinsA = result.Games.Sum(g => g.WinsA); + result.WinsB = result.Games.Sum(g => g.WinsB); + + return result; + } + + private static VersusGameStats GetOrAddGame(Dictionary perGame, int gameId, + Dictionary gameById) + { + if (!perGame.TryGetValue(gameId, out var row)) + { + gameById.TryGetValue(gameId, out var game); + var name = string.IsNullOrWhiteSpace(game?.Name) ? $"#{gameId}" : game.Name; + row = new VersusGameStats + { + GameId = gameId, + Game = name, + GameLongName = string.IsNullOrWhiteSpace(game?.LongName) ? name : game.LongName + }; + perGame[gameId] = row; + } + + return row; + } + private static PlayerMatchStats GetOrAdd(Dictionary stats, int playerId, Dictionary nameById) { diff --git a/LaDOSE.Src/LaDOSE.WebApp/.env.example b/LaDOSE.Src/LaDOSE.WebApp/.env.example deleted file mode 100644 index 37d479e..0000000 --- a/LaDOSE.Src/LaDOSE.WebApp/.env.example +++ /dev/null @@ -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 diff --git a/LaDOSE.Src/LaDOSE.WebApp/README.md b/LaDOSE.Src/LaDOSE.WebApp/README.md index c1d4194..75a7b43 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/README.md +++ b/LaDOSE.Src/LaDOSE.WebApp/README.md @@ -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(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, \.** 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. diff --git a/LaDOSE.Src/LaDOSE.WebApp/openapi.json b/LaDOSE.Src/LaDOSE.WebApp/openapi.json index 97a73a4..82c7a96 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/openapi.json +++ b/LaDOSE.Src/LaDOSE.WebApp/openapi.json @@ -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" }, diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema-helpers.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema-helpers.ts index 2a50b54..7285f46 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema-helpers.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema-helpers.ts @@ -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; diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema.d.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema.d.ts index 6f6a090..2b4e539 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema.d.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/schema.d.ts @@ -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; diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/sheets.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/sheets.ts new file mode 100644 index 0000000..4f910ed --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/sheets.ts @@ -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 { + return apiRequest('/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 { + return apiRequest('/api/Sheets/Export', { + ...authed(options), + method: 'POST', + body: request + }); +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/statistics.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/statistics.ts index bc48983..6d1c31e 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/statistics.ts +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/api/statistics.ts @@ -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 { + return apiRequest('/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 { + const path = buildPath('/api/Statistics/Versus/{playerAId}/{playerBId}', { + playerAId, + playerBId + }); + return apiRequest(path, authed(options)); +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/tournaments/sheet.ts b/LaDOSE.Src/LaDOSE.WebApp/src/lib/tournaments/sheet.ts new file mode 100644 index 0000000..e8923f2 --- /dev/null +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/tournaments/sheet.ts @@ -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; +} diff --git a/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/Navbar.svelte b/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/Navbar.svelte index 9f4d15f..eaca7ae 100644 --- a/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/Navbar.svelte +++ b/LaDOSE.Src/LaDOSE.WebApp/src/lib/ui/Navbar.svelte @@ -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 - -