Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f934e69c90 | ||
|
|
937b8554dd | ||
|
|
c9a3c252e1 | ||
|
|
a9860f4c94 | ||
|
|
d9e05fb487 | ||
|
|
e10663c8c0 | ||
|
|
cd03b39c20 | ||
|
|
9d8a7b3100 | ||
|
|
e99479d8fb |
@@ -0,0 +1,83 @@
|
||||
# Copy to .env next to docker-compose.yml and edit. Compose reads it automatically;
|
||||
# git ignores .env. Every value here is optional — the defaults in docker-compose.yml
|
||||
# are what you get without it.
|
||||
|
||||
# --- Ports on your machine -----------------------------------------------------------
|
||||
# Container-side ports are fixed (API 5000, nginx 80); only these move.
|
||||
#LADOSE_WEB_PORT=8080
|
||||
#LADOSE_API_PORT=5000
|
||||
|
||||
# --- Database ------------------------------------------------------------------------
|
||||
# Overrides ConnectionStrings:DbContext from LaDOSE.Api/appsettings.json. The stack has
|
||||
# no Postgres of its own, so this has to point at a reachable server.
|
||||
#
|
||||
# Postgres on the machine running the containers:
|
||||
#LADOSE_DB_CONNECTION=Host=host.docker.internal;Username=tom;Password=tom;Database=ladoseapi
|
||||
#
|
||||
# Postgres elsewhere on the LAN — check the name resolves *inside* the container
|
||||
# (`docker compose run --rm api getent hosts kafka.local`) and use the IP if it does not:
|
||||
#LADOSE_DB_CONNECTION=Host=kafka.local;Username=tom;Password=tom;Database=ladoseapi
|
||||
#
|
||||
# Sql/dump_20240316.sql then Sql/2026-08-05_roles.sql populate an empty database.
|
||||
|
||||
# --- Secrets -------------------------------------------------------------------------
|
||||
# appsettings.json carries placeholders for these. Any value works for JWT locally, but
|
||||
# it must be at least 32 characters: Startup.cs feeds it to HMAC-SHA256 as raw ASCII.
|
||||
#LADOSE_JWT_SECRET=dev-only-secret-not-for-any-deployed-environment
|
||||
#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/<THIS PART>/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.
|
||||
#LADOSE_API_BASE_URL=http://localhost:5000
|
||||
@@ -4,7 +4,7 @@ on: [push]
|
||||
|
||||
jobs:
|
||||
Build:
|
||||
runs-on: ubuntu-latest-real
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update
|
||||
run: |
|
||||
@@ -54,10 +54,15 @@ jobs:
|
||||
retention-days: 30
|
||||
overwrite: true
|
||||
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(echo $(date +'%Y-%m-%d'))" >> $GITHUB_OUTPUT
|
||||
- name: Release
|
||||
if: github.ref_name == 'master'
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
env:
|
||||
with:
|
||||
tag_name: release-${{ steps.date.outputs.date }}
|
||||
files: |-
|
||||
build-winx64.zip
|
||||
build-linux64.zip
|
||||
|
||||
+14
@@ -328,3 +328,17 @@ ASALocalRun/
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local docker-compose overrides: connection string, API keys, ports.
|
||||
# .env.example is documentation and stays tracked.
|
||||
.env
|
||||
.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
|
||||
|
||||
@@ -1,2 +1,22 @@
|
||||
*/*/bin*
|
||||
*/*/obj*
|
||||
# Context for LaDOSE.Src/Dockerfile.
|
||||
#
|
||||
# The previous patterns here were */*/bin* and */*/obj*, which matched nothing: this
|
||||
# context is rooted at LaDOSE.Src, so build output sits one level down (LaDOSE.Api/bin),
|
||||
# not two.
|
||||
**/bin/
|
||||
**/obj/
|
||||
|
||||
# The frontend is a separate image with its own context (LaDOSE.WebApp/Dockerfile).
|
||||
# Its node_modules alone was adding ~118 MB to every API build.
|
||||
LaDOSE.WebApp/node_modules/
|
||||
LaDOSE.WebApp/build/
|
||||
LaDOSE.WebApp/.svelte-kit/
|
||||
|
||||
# Local state and editor noise. Note Libraries/ is NOT excluded: LaDOSE.Business
|
||||
# references ChallongeCSharpDriver.dll from there by HintPath.
|
||||
.git
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.user
|
||||
*.suo
|
||||
|
||||
+40
-11
@@ -1,15 +1,44 @@
|
||||
FROM microsoft/dotnet:sdk AS build-env
|
||||
# Builds the LaDOSE.Api image. Context is LaDOSE.Src (see .dockerignore next to this file).
|
||||
#
|
||||
# Only LaDOSE.Api is published. LaDOSE.linux.sln also carries the Avalonia desktop app,
|
||||
# the Discord bot and LinuxTest, and `dotnet publish <sln> -o out` flattens every project
|
||||
# into that one directory — which is why the previous version of this file copied from
|
||||
# /app/LaDOSE.Api/out/ and found nothing there.
|
||||
ARG DOTNET_VERSION=9.0
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Debug is deliberate for local work, and docker-compose.yml passes it: the OpenAPI
|
||||
# document and the Scalar UI are gated behind `#if DEBUG` in Startup.cs *and* behind
|
||||
# Condition="'$(Configuration)' == 'Debug'" on their PackageReferences in
|
||||
# LaDOSE.Api.csproj. A Release image therefore serves no /openapi/v1.json, which is
|
||||
# exactly what LaDOSE.WebApp's `npm run api:sync` reads. Default stays Release.
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
|
||||
# Project files first so this layer survives every .cs edit. Restore has to run under
|
||||
# the same Configuration as the publish below, or the conditional PackageReferences
|
||||
# above make the two disagree about which packages the assets file should contain.
|
||||
COPY global.json ./
|
||||
COPY LaDOSE.Api/LaDOSE.Api.csproj LaDOSE.Api/
|
||||
COPY LaDOSE.DTO/LaDOSE.DTO.csproj LaDOSE.DTO/
|
||||
COPY LaDOSE.Entity/LaDOSE.Entity.csproj LaDOSE.Entity/
|
||||
COPY LaDOSE.Service/LaDOSE.Business.csproj LaDOSE.Service/
|
||||
RUN dotnet restore LaDOSE.Api/LaDOSE.Api.csproj -p:Configuration=${BUILD_CONFIGURATION}
|
||||
|
||||
# Libraries/ChallongeCSharpDriver.dll is a HintPath reference from LaDOSE.Business,
|
||||
# so the build needs the whole tree, not just the projects listed above.
|
||||
COPY . .
|
||||
RUN dotnet publish LaDOSE.Api/LaDOSE.Api.csproj -c ${BUILD_CONFIGURATION} --no-restore -o /app/out
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/out/ ./
|
||||
|
||||
# Copy everything else and build
|
||||
COPY . ./
|
||||
|
||||
RUN dotnet publish LaDOSE.linux.sln -c Release -o out
|
||||
|
||||
# Build runtime image
|
||||
FROM microsoft/dotnet:aspnetcore-runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build-env /app/LaDOSE.Api/out/ .
|
||||
# Fixed in the image on purpose. Program.cs binds Kestrel from appsettings.json's
|
||||
# AllowedHosts/Port through a ConfigurationBuilder that reads *only* that file, so a
|
||||
# Port env var would not move the listener — remap on the host side instead.
|
||||
# Everything Startup.cs reads does honour env vars (ConnectionStrings__DbContext,
|
||||
# ApiKey__SmashApiKey, ApiKey__ChallongeApiKey, JWTTokenSecret).
|
||||
EXPOSE 5000
|
||||
ENTRYPOINT ["dotnet", "LaDOSE.Api.dll"]
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
[Route("api/[controller]")]
|
||||
public class SheetsController : ControllerBase
|
||||
{
|
||||
private readonly ISheetsExportService _service;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ILogger<SheetsController> _logger;
|
||||
|
||||
public SheetsController(IMapper mapper, ISheetsExportService service, ILogger<SheetsController> logger)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_service = service;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("Config")]
|
||||
[ProducesResponseType(typeof(SheetsConfigDTO), StatusCodes.Status200OK)]
|
||||
public IActionResult GetConfig()
|
||||
{
|
||||
return Ok(_mapper.Map<SheetsConfigDTO>(_service.GetConfig()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost("Export")]
|
||||
[ProducesResponseType(typeof(SheetExportResultDTO), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status502BadGateway)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public async Task<IActionResult> 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<SheetExportRequest>(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<SheetExportResultDTO>(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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutoMapper;
|
||||
using LaDOSE.Business.Interface;
|
||||
using LaDOSE.DTO;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LaDOSE.Api.Controllers
|
||||
{
|
||||
[Authorize]
|
||||
[Produces("application/json")]
|
||||
[Route("api/[controller]")]
|
||||
public class StatisticsController : Controller
|
||||
{
|
||||
private readonly IStatisticsService _service;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public StatisticsController(IMapper mapper, IStatisticsService service)
|
||||
{
|
||||
_mapper = mapper;
|
||||
_service = service;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Match statistics for the given Event ids. Body is a bare JSON array of ids,
|
||||
/// like TournamentController.GetResults.
|
||||
/// A null or empty array returns a zeroed, empty MatchStatsDTO rather than an error.
|
||||
/// Read Coverage before trusting the numbers: many brackets have no set rows at all.
|
||||
/// </summary>
|
||||
[HttpPost("Matches")]
|
||||
public async Task<MatchStatsDTO> GetMatchStats([FromBody] List<int> ids)
|
||||
{
|
||||
var stats = await _service.GetMatchStats(ids);
|
||||
return _mapper.Map<MatchStatsDTO>(stats);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("Players")]
|
||||
public async Task<List<PlayerOptionDTO>> GetVersusPlayers()
|
||||
{
|
||||
var players = await _service.GetVersusPlayers();
|
||||
return _mapper.Map<List<PlayerOptionDTO>>(players);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("Versus/{playerAId}/{playerBId}")]
|
||||
public async Task<PlayerVersusDTO> GetVersus(int playerAId, int playerBId)
|
||||
{
|
||||
var versus = await _service.GetVersus(playerAId, playerBId);
|
||||
return _mapper.Map<PlayerVersusDTO>(versus);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Linq;
|
||||
@@ -9,6 +9,7 @@ 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.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -33,12 +34,32 @@ namespace LaDOSE.Api.Controllers
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>Public view of a user: no password, no hash, no salt.</summary>
|
||||
private static ApplicationUserDTO ToDto(ApplicationUser user)
|
||||
{
|
||||
return new ApplicationUserDTO
|
||||
{
|
||||
Id = user.Id,
|
||||
Username = user.Username,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
Roles = user.Names()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The id the JWT was issued for; null if the request is not authenticated.</summary>
|
||||
private int? CurrentUserId()
|
||||
{
|
||||
return int.TryParse(User?.Identity?.Name, out var id) ? id : (int?)null;
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("auth")]
|
||||
public IActionResult Authenticate([FromBody]ApplicationUser userDto)
|
||||
[ProducesResponseType(typeof(ApplicationUserDTO), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public IActionResult Authenticate([FromBody]ApplicationUserDTO userDto)
|
||||
{
|
||||
var user = _userService.Authenticate(userDto.Username, userDto.Password);
|
||||
var user = _userService.Authenticate(userDto?.Username, userDto?.Password);
|
||||
|
||||
if (user == null)
|
||||
return BadRequest(new { message = "Username or password is incorrect" });
|
||||
@@ -47,6 +68,9 @@ namespace LaDOSE.Api.Controllers
|
||||
var key = Encoding.ASCII.GetBytes(this._configuration["JWTTokenSecret"]);
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
// Only the user id goes in the token. Roles are read from the database on
|
||||
// every request instead, so granting or revoking Admin takes effect at
|
||||
// once rather than whenever the current token happens to expire.
|
||||
Subject = new ClaimsIdentity(new Claim[]
|
||||
{
|
||||
new Claim(ClaimTypes.Name, user.Id.ToString()),
|
||||
@@ -60,29 +84,62 @@ namespace LaDOSE.Api.Controllers
|
||||
var tokenString = tokenHandler.WriteToken(token);
|
||||
|
||||
// return basic user info (without password) and token to store client side
|
||||
return Ok(new ApplicationUserDTO
|
||||
{
|
||||
Id = user.Id,
|
||||
Username = user.Username,
|
||||
FirstName = user.FirstName,
|
||||
LastName = user.LastName,
|
||||
Token = tokenString,
|
||||
Expire = token.ValidTo
|
||||
});
|
||||
var dto = ToDto(user);
|
||||
dto.Token = tokenString;
|
||||
dto.Expire = token.ValidTo;
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("register")]
|
||||
public IActionResult Register([FromBody]ApplicationUser userDto)
|
||||
/// <summary>Every account, for the admin user-management screen.</summary>
|
||||
[Authorize(Roles = Roles.Admin)]
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(List<ApplicationUserDTO>), StatusCodes.Status200OK)]
|
||||
public IActionResult GetUsers()
|
||||
{
|
||||
// map dto to entity
|
||||
var users = _userService.GetAll()
|
||||
.OrderBy(user => user.Username)
|
||||
.Select(ToDto)
|
||||
.ToList();
|
||||
|
||||
return Ok(users);
|
||||
}
|
||||
|
||||
/// <summary>The role names that may be assigned, from the applicationrole table.</summary>
|
||||
[Authorize(Roles = Roles.Admin)]
|
||||
[HttpGet("Roles")]
|
||||
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
|
||||
public IActionResult GetRoles()
|
||||
{
|
||||
return Ok(_userService.GetAllRoles().Select(role => role.Name).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an account. This replaces the old anonymous <c>register</c> endpoint —
|
||||
/// only an admin may create users now, so the very first admin has to be promoted
|
||||
/// directly in the database (see Sql/2026-08-05_roles.sql).
|
||||
/// </summary>
|
||||
[Authorize(Roles = Roles.Admin)]
|
||||
[HttpPost("AddUser")]
|
||||
[ProducesResponseType(typeof(ApplicationUserDTO), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public IActionResult AddUser([FromBody]ApplicationUserDTO userDto)
|
||||
{
|
||||
if (userDto == null)
|
||||
return BadRequest(new { message = "No user supplied" });
|
||||
|
||||
try
|
||||
{
|
||||
// save
|
||||
_userService.Create(userDto, userDto.Password);
|
||||
return Ok();
|
||||
var created = _userService.Create(
|
||||
new ApplicationUser
|
||||
{
|
||||
Username = userDto.Username?.Trim(),
|
||||
FirstName = userDto.FirstName,
|
||||
LastName = userDto.LastName
|
||||
},
|
||||
userDto.Password,
|
||||
userDto.Roles);
|
||||
|
||||
return Ok(ToDto(created));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -91,7 +148,35 @@ namespace LaDOSE.Api.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an account. Refuses to delete the caller: since only an admin can reach
|
||||
/// this, and an admin cannot remove themselves, at least one admin always survives
|
||||
/// — which matters because there is no anonymous way back in any more.
|
||||
/// </summary>
|
||||
[Authorize(Roles = Roles.Admin)]
|
||||
[HttpDelete("{id}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult DeleteUser(int id)
|
||||
{
|
||||
var user = _userService.GetById(id);
|
||||
if (user == null)
|
||||
return NotFound(new { message = "User not found" });
|
||||
|
||||
if (CurrentUserId() == id)
|
||||
return BadRequest(new { message = "You cannot delete your own account" });
|
||||
|
||||
try
|
||||
{
|
||||
_userService.Delete(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#if DEBUG
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
|
||||
namespace LaDOSE.Api.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// The controllers in this project are attribute-routed but do not carry [ApiController].
|
||||
/// Without it MVC never sets ApiExplorer visibility, so ApiExplorer yields no descriptions
|
||||
/// and the generated OpenAPI document comes out with an empty "paths" object.
|
||||
/// This convention opts the attribute-routed actions into ApiExplorer for the
|
||||
/// OpenAPI/Scalar tooling only, without pulling in the [ApiController] behaviours
|
||||
/// (automatic 400 responses, [FromBody] inference) that would change runtime binding.
|
||||
/// </summary>
|
||||
public class ApiExplorerVisibilityConvention : IControllerModelConvention
|
||||
{
|
||||
public void Apply(ControllerModel controller)
|
||||
{
|
||||
// Default the controller to hidden, then opt in action by action.
|
||||
controller.ApiExplorer.IsVisible ??= false;
|
||||
|
||||
foreach (var action in controller.Actions)
|
||||
{
|
||||
if (action.ApiExplorer.IsVisible != null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// An action with a [Route] but no verb attribute (e.g. BotEventController's
|
||||
// CreateBotEvent) matches every HTTP method, so ApiExplorer reports an empty
|
||||
// method and OpenAPI generation throws "Unsupported HTTP method".
|
||||
// Only document actions that pin down a verb.
|
||||
action.ApiExplorer.IsVisible = action.Attributes
|
||||
.OfType<IActionHttpMethodProvider>()
|
||||
.Any(provider => provider.HttpMethods?.Any() == true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
<LangVersion>12</LangVersion>
|
||||
</PropertyGroup>
|
||||
@@ -15,8 +15,11 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.12" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.12" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="1.6.17" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.18" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.16.17" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Business.Interface;
|
||||
@@ -18,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;
|
||||
@@ -25,6 +27,9 @@ using Result = LaDOSE.Entity.Challonge.Result;
|
||||
using LaDOSE.Entity.BotEvent;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
#if DEBUG
|
||||
using Scalar.AspNetCore;
|
||||
#endif
|
||||
|
||||
namespace LaDOSE.Api
|
||||
{
|
||||
@@ -55,11 +60,25 @@ namespace LaDOSE.Api
|
||||
}
|
||||
|
||||
services.AddCors();
|
||||
services.AddMvc().AddNewtonsoftJson(x =>
|
||||
services.AddMvc(options =>
|
||||
{
|
||||
#if DEBUG
|
||||
// Make the attribute-routed controllers visible to ApiExplorer so the
|
||||
// OpenAPI document is actually populated. See ApiExplorerVisibilityConvention.
|
||||
options.Conventions.Add(new ApiExplorerVisibilityConvention());
|
||||
#endif
|
||||
}).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();
|
||||
#endif
|
||||
// services.AddDbContextPool<LaDOSEDbContext>( // replace "YourDbContext" with the class name of your DbContext
|
||||
//
|
||||
// options => options.UseMySql($"Server={MySqlServer};Database={MySqlDatabase};User={MySqlUser};Password={MySqlPassword};", // replace with your Connection String
|
||||
@@ -92,6 +111,18 @@ namespace LaDOSE.Api
|
||||
{
|
||||
// return unauthorized if user no longer exists
|
||||
context.Fail("Unauthorized");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Roles are attached here, from the database, rather than being
|
||||
// signed into the token: a promotion or demotion then applies to
|
||||
// the caller's very next request instead of waiting 16 minutes.
|
||||
if (context.Principal.Identity is ClaimsIdentity identity)
|
||||
{
|
||||
foreach (var role in user.Names())
|
||||
{
|
||||
identity.AddClaim(new Claim(identity.RoleClaimType, role));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
@@ -130,6 +161,26 @@ namespace LaDOSE.Api
|
||||
cfg.CreateMapTwoWay<Game, LaDOSE.DTO.GameDTO>();
|
||||
cfg.CreateMapTwoWay<Todo, LaDOSE.DTO.TodoDTO>();
|
||||
|
||||
// Match statistics: plain POCO aggregates computed by StatisticsService,
|
||||
// mapped by name (same pattern as TournamentsResult above).
|
||||
cfg.CreateMap<MatchStats, LaDOSE.DTO.MatchStatsDTO>();
|
||||
cfg.CreateMap<MatchCoverage, LaDOSE.DTO.MatchCoverageDTO>();
|
||||
cfg.CreateMap<PlayerMatchStats, LaDOSE.DTO.PlayerMatchStatsDTO>();
|
||||
cfg.CreateMap<HeadToHead, LaDOSE.DTO.HeadToHeadDTO>();
|
||||
cfg.CreateMap<PlayerVersus, LaDOSE.DTO.PlayerVersusDTO>();
|
||||
cfg.CreateMap<VersusGameStats, LaDOSE.DTO.VersusGameStatsDTO>();
|
||||
cfg.CreateMap<PlayerOption, LaDOSE.DTO.PlayerOptionDTO>();
|
||||
|
||||
// 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<SheetExportRequest, LaDOSE.DTO.SheetExportRequestDTO>();
|
||||
cfg.CreateMapTwoWay<SheetTable, LaDOSE.DTO.SheetTableDTO>();
|
||||
cfg.CreateMapTwoWay<SheetRow, LaDOSE.DTO.SheetRowDTO>();
|
||||
cfg.CreateMap<SheetExportResult, LaDOSE.DTO.SheetExportResultDTO>();
|
||||
cfg.CreateMap<SheetTabResult, LaDOSE.DTO.SheetTabResultDTO>();
|
||||
cfg.CreateMap<SheetsConfig, LaDOSE.DTO.SheetsConfigDTO>();
|
||||
|
||||
});
|
||||
IMapper mapper = mapperConfig.CreateMapper();
|
||||
services.AddSingleton(mapper);
|
||||
@@ -149,6 +200,7 @@ namespace LaDOSE.Api
|
||||
services.AddScoped<IBotEventService, BotEventService>();
|
||||
|
||||
services.AddScoped<IPlayerService, PlayerService>();
|
||||
services.AddScoped<IStatisticsService, StatisticsService>();
|
||||
services.AddTransient<IChallongeProvider>(p => new ChallongeProvider( p.GetRequiredService<IGameService>(),
|
||||
p.GetRequiredService<IEventService>(),
|
||||
p.GetRequiredService<IPlayerService>(),
|
||||
@@ -160,6 +212,45 @@ namespace LaDOSE.Api
|
||||
this.Configuration["ApiKey:SmashApiKey"]));
|
||||
services.AddScoped<IExternalProviderService, ExternalProviderService>();
|
||||
|
||||
#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<ISheetsWriter>(p =>
|
||||
{
|
||||
switch (this.Configuration["GoogleSheets:Writer"])
|
||||
{
|
||||
case "ServiceAccount":
|
||||
return new GoogleApiSheetsWriter(
|
||||
this.Configuration["GoogleSheets:ServiceAccount:CredentialsPath"]);
|
||||
case "Logging":
|
||||
return new LoggingSheetsWriter(
|
||||
p.GetRequiredService<ILogger<LoggingSheetsWriter>>());
|
||||
default:
|
||||
return new DisabledSheetsWriter();
|
||||
}
|
||||
});
|
||||
|
||||
services.AddScoped<ISheetsExportService, SheetsExportService>();
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>Configuration is all strings; a missing or unparsable value takes the default.</summary>
|
||||
private int ReadInt(string key, int fallback)
|
||||
{
|
||||
return int.TryParse(this.Configuration[key], out var value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +276,17 @@ namespace LaDOSE.Api
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseEndpoints(x => x.MapControllers());
|
||||
app.UseEndpoints(x =>
|
||||
{
|
||||
x.MapControllers();
|
||||
#if DEBUG
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
x.MapOpenApi();
|
||||
x.MapScalarApiReference();
|
||||
}
|
||||
#endif
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning"
|
||||
"Default": "Warning",
|
||||
"LaDOSE": "Information"
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DbContext":"Host=descartes.local;Username=tom;Password=tom;Database=ladoseapi"
|
||||
"DbContext":"Host=kafka.local;Username=tom;Password=tom;Database=ladoseapi"
|
||||
},
|
||||
"CertificateSettings": {
|
||||
"fileName": "localhost.pfx",
|
||||
"password": "YourSecurePassword"
|
||||
},
|
||||
"MySql": {
|
||||
"Server": "localhost",
|
||||
"Database": "ladoseapi",
|
||||
"User": "dev",
|
||||
"Password": "dev"
|
||||
},
|
||||
"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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LaDOSE.DTO
|
||||
{
|
||||
@@ -9,8 +10,13 @@ namespace LaDOSE.DTO
|
||||
public string FirstName { get; set; }
|
||||
public string LastName { get; set; }
|
||||
public string Username { get; set; }
|
||||
|
||||
/// <summary>Only ever read from a request; never populated on a response.</summary>
|
||||
public string Password { get; set; }
|
||||
|
||||
/// <summary>Role names held by the user, e.g. <c>["Admin"]</c>. Empty means a plain user.</summary>
|
||||
public List<string> Roles { get; set; }
|
||||
|
||||
public string Token { get; set; }
|
||||
public DateTime Expire { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
namespace LaDOSE.DTO
|
||||
using System;
|
||||
|
||||
namespace LaDOSE.DTO
|
||||
{
|
||||
public class EventDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Event date, mapped by convention from Event.Date. Used for time-series charts.</summary>
|
||||
public DateTime Date { get; set; }
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LaDOSE.DTO
|
||||
{
|
||||
public class MatchStatsDTO
|
||||
{
|
||||
public MatchCoverageDTO Coverage { get; set; }
|
||||
public List<PlayerMatchStatsDTO> Players { get; set; }
|
||||
public List<HeadToHeadDTO> HeadToHead { get; set; }
|
||||
}
|
||||
|
||||
public class MatchCoverageDTO
|
||||
{
|
||||
/// <summary>Requested event ids that actually exist.</summary>
|
||||
public int Events { get; set; }
|
||||
|
||||
/// <summary>Tournaments (brackets) belonging to those events.</summary>
|
||||
public int Brackets { get; set; }
|
||||
|
||||
/// <summary>Of those brackets, how many have at least one set row.</summary>
|
||||
public int BracketsWithSets { get; set; }
|
||||
|
||||
/// <summary>Total set rows in scope, including unusable ones.</summary>
|
||||
public int Sets { get; set; }
|
||||
|
||||
/// <summary>Sets with a determinable winner.</summary>
|
||||
public int DecidedSets { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerMatchStatsDTO
|
||||
{
|
||||
public int PlayerId { get; set; }
|
||||
|
||||
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||
public string Player { get; set; }
|
||||
|
||||
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
|
||||
public int Sets { get; set; }
|
||||
|
||||
public int Wins { get; set; }
|
||||
public int Losses { get; set; }
|
||||
public int GamesWon { get; set; }
|
||||
public int GamesLost { get; set; }
|
||||
}
|
||||
|
||||
public class HeadToHeadDTO
|
||||
{
|
||||
public int PlayerAId { get; set; }
|
||||
public string PlayerA { get; set; }
|
||||
public int PlayerBId { get; set; }
|
||||
public string PlayerB { get; set; }
|
||||
public int WinsA { get; set; }
|
||||
public int WinsB { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LaDOSE.DTO
|
||||
{
|
||||
/// <summary>
|
||||
/// Every recorded meeting between two players, broken down per game.
|
||||
/// Sets belonging to a bracket with no game attached are not in <see cref="Games"/>
|
||||
/// nor in the totals — they are only counted in <see cref="UnknownGameSets"/>.
|
||||
/// </summary>
|
||||
public class PlayerVersusDTO
|
||||
{
|
||||
public int PlayerAId { get; set; }
|
||||
|
||||
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||
public string PlayerA { get; set; }
|
||||
|
||||
public int PlayerBId { get; set; }
|
||||
|
||||
public string PlayerB { get; set; }
|
||||
|
||||
/// <summary>Meetings in a bracket whose game is known — the sum of the rows in <see cref="Games"/>.</summary>
|
||||
public int Sets { get; set; }
|
||||
|
||||
/// <summary>Of those, the ones with a determinable winner.</summary>
|
||||
public int DecidedSets { get; set; }
|
||||
|
||||
public int WinsA { get; set; }
|
||||
public int WinsB { get; set; }
|
||||
|
||||
/// <summary>Meetings dropped because the bracket has no game attached.</summary>
|
||||
public int UnknownGameSets { get; set; }
|
||||
|
||||
/// <summary>One row per game they met in, most-played first.</summary>
|
||||
public List<VersusGameStatsDTO> Games { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One game's slice of a <see cref="PlayerVersusDTO"/>.</summary>
|
||||
public class VersusGameStatsDTO
|
||||
{
|
||||
public int GameId { get; set; }
|
||||
|
||||
/// <summary>Game name, or "#<id>" when the game row is gone.</summary>
|
||||
public string Game { get; set; }
|
||||
|
||||
public string GameLongName { get; set; }
|
||||
|
||||
/// <summary>Meetings in this game, decided or not.</summary>
|
||||
public int Sets { get; set; }
|
||||
|
||||
/// <summary>Meetings with a determinable winner. Always equals WinsA + WinsB.</summary>
|
||||
public int DecidedSets { get; set; }
|
||||
|
||||
public int WinsA { get; set; }
|
||||
public int WinsB { get; set; }
|
||||
|
||||
/// <summary>Individual games won inside the decided sets.</summary>
|
||||
public int GamesWonA { get; set; }
|
||||
|
||||
public int GamesWonB { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A player who can be picked for a versus lookup: one with at least one set in a
|
||||
/// bracket whose game is known.
|
||||
/// </summary>
|
||||
public class PlayerOptionDTO
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Sets in a bracket with a known game, whoever the opponent was.</summary>
|
||||
public int Sets { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LaDOSE.DTO
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class SheetExportRequestDTO
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public List<SheetTableDTO> Tabs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One tab: a players x games grid with a Total column, as built by buildRanking().</summary>
|
||||
public class SheetTableDTO
|
||||
{
|
||||
/// <summary>Desired tab title, e.g. "Ranking #1301". Sanitised server-side.</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Provenance and logging only; tabs are addressed by title.</summary>
|
||||
public int EventId { get; set; }
|
||||
|
||||
/// <summary>["Players", <game names...>, "Total"].</summary>
|
||||
public List<string> Header { get; set; }
|
||||
|
||||
public List<SheetRowDTO> Rows { get; set; }
|
||||
|
||||
/// <summary>Provenance lines written below the grid.</summary>
|
||||
public List<string> Footer { get; set; }
|
||||
}
|
||||
|
||||
public class SheetRowDTO
|
||||
{
|
||||
public string Player { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public List<int> 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<SheetTabResultDTO> Tabs { get; set; }
|
||||
public List<string> 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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response of GET /api/Sheets/Config. Never carries the credentials path or any secret —
|
||||
/// only what the export panel needs to render itself.
|
||||
/// </summary>
|
||||
public class SheetsConfigDTO
|
||||
{
|
||||
/// <summary>"ServiceAccount" | "Logging" | "Disabled".</summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
|
||||
@@ -1,58 +1,55 @@
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using DSharpPlus.CommandsNext;
|
||||
using DSharpPlus.CommandsNext.Attributes;
|
||||
using LaDOSE.DiscordBot.Service;
|
||||
using LaDOSE.DTO;
|
||||
|
||||
namespace LaDOSE.DiscordBot.Command
|
||||
{
|
||||
public class BotEvent : BaseCommandModule
|
||||
{
|
||||
private WebService dep;
|
||||
public BotEvent(WebService d)
|
||||
{
|
||||
dep = d;
|
||||
}
|
||||
|
||||
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
|
||||
[Command("newevent")]
|
||||
public async Task NewEventAsync(CommandContext ctx, string command)
|
||||
{
|
||||
|
||||
await ctx.RespondAsync(dep.RestService.CreateBotEvent(command).ToString());
|
||||
}
|
||||
[RequireRolesAttribute(RoleCheckMode.Any,"Staff")]
|
||||
[Command("staffs")]
|
||||
public async Task StaffAsync(CommandContext ctx)
|
||||
{
|
||||
BotEventDTO currentEvent = dep.RestService.GetLastBotEvent();
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
|
||||
var present = currentEvent.Results.Where(x => x.Result).ToList();
|
||||
var absent = currentEvent.Results.Where(x => !x.Result).ToList();
|
||||
|
||||
stringBuilder.AppendLine($"Pour {currentEvent.Name} : ");
|
||||
present.ForEach(x => stringBuilder.AppendLine($":white_check_mark: {x.Name}"));
|
||||
absent.ForEach(x => stringBuilder.AppendLine($":x: {x.Name}"));
|
||||
|
||||
await ctx.RespondAsync(stringBuilder.ToString());
|
||||
|
||||
}
|
||||
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
|
||||
[Command("present")]
|
||||
public async Task PresentAsync(CommandContext ctx)
|
||||
{
|
||||
await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = true }).ToString());
|
||||
|
||||
|
||||
}
|
||||
[RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
|
||||
[Command("absent")]
|
||||
public async Task AbsentAsync(CommandContext ctx)
|
||||
{
|
||||
await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = false }).ToString());
|
||||
}
|
||||
}
|
||||
// public class BotEvent : BaseCommandModule
|
||||
// {
|
||||
// private WebService dep;
|
||||
// public BotEvent(WebService d)
|
||||
// {
|
||||
// dep = d;
|
||||
// }
|
||||
//
|
||||
// [Command("newevent")]
|
||||
// public async Task NewEventAsync(CommandContext ctx, string command)
|
||||
// {
|
||||
//
|
||||
// await ctx.RespondAsync(dep.RestService.CreateBotEvent(command).ToString());
|
||||
// }
|
||||
// [RequireRolesAttribute(RoleCheckMode.Any,"Staff")]
|
||||
// [Command("staffs")]
|
||||
// public async Task StaffAsync(CommandContext ctx)
|
||||
// {
|
||||
// BotEventDTO currentEvent = dep.RestService.GetLastBotEvent();
|
||||
// StringBuilder stringBuilder = new StringBuilder();
|
||||
//
|
||||
// var present = currentEvent.Results.Where(x => x.Result).ToList();
|
||||
// var absent = currentEvent.Results.Where(x => !x.Result).ToList();
|
||||
//
|
||||
// stringBuilder.AppendLine($"Pour {currentEvent.Name} : ");
|
||||
// present.ForEach(x => stringBuilder.AppendLine($":white_check_mark: {x.Name}"));
|
||||
// absent.ForEach(x => stringBuilder.AppendLine($":x: {x.Name}"));
|
||||
//
|
||||
// await ctx.RespondAsync(stringBuilder.ToString());
|
||||
//
|
||||
// }
|
||||
// [RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
|
||||
// [Command("present")]
|
||||
// public async Task PresentAsync(CommandContext ctx)
|
||||
// {
|
||||
// await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = true }).ToString());
|
||||
//
|
||||
//
|
||||
// }
|
||||
// [RequireRolesAttribute(RoleCheckMode.Any, "Staff")]
|
||||
// [Command("absent")]
|
||||
// public async Task AbsentAsync(CommandContext ctx)
|
||||
// {
|
||||
// await ctx.RespondAsync(dep.RestService.ResultBotEvent(new DTO.BotEventSendDTO() { DiscordId = ctx.Member.Id.ToString(), DiscordName = ctx.Member.DisplayName, Present = false }).ToString());
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -2,14 +2,15 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DSharpPlus.CommandsNext;
|
||||
using DSharpPlus.CommandsNext.Attributes;
|
||||
using DSharpPlus.Commands;
|
||||
using DSharpPlus.Commands.ArgumentModifiers;
|
||||
using DSharpPlus.Commands.Processors.TextCommands;
|
||||
using DSharpPlus.Entities;
|
||||
|
||||
namespace LaDOSE.DiscordBot.Command
|
||||
{
|
||||
|
||||
public class Hokuto : BaseCommandModule
|
||||
public class Hokuto
|
||||
{
|
||||
|
||||
private static List<string> Games = new List<string> { "2X", "3.3", "Karnov" };
|
||||
@@ -21,14 +22,21 @@ namespace LaDOSE.DiscordBot.Command
|
||||
|
||||
|
||||
[Command("hokuto")]
|
||||
public async Task HokutoUserAsync(CommandContext ctx, params DiscordMember[] user)
|
||||
public async ValueTask HokutoUserAsync(TextCommandContext ctx)
|
||||
{
|
||||
|
||||
|
||||
var i = r.Next(0, 3);
|
||||
if (user!=null && user.Length>0)
|
||||
if (ctx.Message.MentionedUsers is { Count: 1 } )
|
||||
{
|
||||
await ctx.RespondAsync(ctx.User?.Mention + " vs " + user[0].Mention + " : " + Games[i].ToString());
|
||||
foreach (var arg in ctx.Message.MentionedUsers)
|
||||
{
|
||||
if (arg is DiscordUser member)
|
||||
{
|
||||
await ctx.RespondAsync(ctx.User?.Mention + " vs " + member.Mention + " : " + Games[i].ToString());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -4,12 +4,11 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DSharpPlus.CommandsNext;
|
||||
using DSharpPlus.CommandsNext.Attributes;
|
||||
using DSharpPlus.Commands;
|
||||
|
||||
namespace LaDOSE.DiscordBot.Command
|
||||
{
|
||||
public class Public : BaseCommandModule
|
||||
public class Public
|
||||
{
|
||||
|
||||
private static List<string> Quotes { get; set; }
|
||||
|
||||
@@ -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"]
|
||||
@@ -2,17 +2,18 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DSharpPlus" Version="4.5.0" />
|
||||
<PackageReference Include="DSharpPlus.CommandsNext" Version="4.5.0" />
|
||||
<PackageReference Include="DSharpPlus.Interactivity" Version="4.5.0" />
|
||||
<PackageReference Include="DSharpPlus" Version="5.0.0-alpha.5" />
|
||||
<PackageReference Include="DSharpPlus.Commands" Version="5.0.0-alpha.5" />
|
||||
<PackageReference Include="DSharpPlus.Interactivity" Version="5.0.0-alpha.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -4,8 +4,12 @@ using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DSharpPlus;
|
||||
using DSharpPlus.Commands;
|
||||
using DSharpPlus.Commands.Processors.SlashCommands;
|
||||
using DSharpPlus.Commands.Processors.TextCommands;
|
||||
using DSharpPlus.Commands.Processors.TextCommands.Parsing;
|
||||
using DSharpPlus.Entities;
|
||||
using DSharpPlus.Interactivity;
|
||||
using DSharpPlus.CommandsNext;
|
||||
using DSharpPlus.EventArgs;
|
||||
using DSharpPlus.Interactivity.Extensions;
|
||||
//using DSharpPlus.SlashCommands;
|
||||
@@ -20,7 +24,6 @@ namespace LaDOSE.DiscordBot
|
||||
{
|
||||
class Program
|
||||
{
|
||||
static DiscordClient discord;
|
||||
|
||||
//static InteractivityModule Interactivity { get; set; }
|
||||
static void Main(string[] args)
|
||||
@@ -43,106 +46,36 @@ namespace LaDOSE.DiscordBot
|
||||
var restUser = builder["REST:User"].ToString();
|
||||
var restPassword = builder["REST:Password"].ToString();
|
||||
|
||||
var service = new ServiceCollection()
|
||||
.AddSingleton(typeof(WebService), new WebService(new Uri(restUrl), restUser, restPassword))
|
||||
.BuildServiceProvider();
|
||||
|
||||
|
||||
Console.WriteLine($"LaDOSE.Net Discord Bot");
|
||||
|
||||
|
||||
discord = new DiscordClient(new DiscordConfiguration
|
||||
{
|
||||
Token = discordToken,
|
||||
TokenType = TokenType.Bot,
|
||||
//AutoReconnect = true,
|
||||
//MinimumLogLevel = LogLevel.Debug,
|
||||
//MessageCacheSize = 0,
|
||||
});
|
||||
|
||||
discord.UseInteractivity(new InteractivityConfiguration
|
||||
{
|
||||
|
||||
// default pagination behaviour to just ignore the reactions
|
||||
//PaginationBehaviour = TimeoutBehaviour.Ignore,
|
||||
|
||||
// default pagination timeout to 5 minutes
|
||||
//PaginationTimeout = TimeSpan.FromMinutes(5),
|
||||
|
||||
// default timeout for other actions to 2 minutes
|
||||
Timeout = TimeSpan.FromMinutes(2)
|
||||
});
|
||||
|
||||
DiscordClientBuilder builder2 =
|
||||
DiscordClientBuilder.CreateDefault(discordToken, DiscordIntents.AllUnprivileged | DiscordIntents.MessageContents | DiscordIntents.GuildMessages| TextCommandProcessor.RequiredIntents | SlashCommandProcessor.RequiredIntents);
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
|
||||
var _cnext = discord.UseCommandsNext(new CommandsNextConfiguration()
|
||||
// Setup the commands extension
|
||||
builder2.UseCommands((IServiceProvider serviceProvider, CommandsExtension extension) =>
|
||||
{
|
||||
//CaseSensitive = false,
|
||||
//EnableDefaultHelp = true,
|
||||
//EnableDms = false,
|
||||
//EnableMentionPrefix = true,
|
||||
StringPrefixes = new List<string>() { "/", "!" },
|
||||
//IgnoreExtraArguments = true,
|
||||
Services = service
|
||||
extension.AddCommands([typeof(Hokuto), typeof(Public)]);
|
||||
TextCommandProcessor textCommandProcessor = new();
|
||||
extension.AddProcessor(textCommandProcessor);
|
||||
}, new CommandsConfiguration()
|
||||
{
|
||||
// The default value is true, however it's shown here for clarity
|
||||
RegisterDefaultCommandProcessors = true,
|
||||
UseDefaultCommandErrorHandler = false
|
||||
// DebugGuildId = Environment.GetEnvironmentVariable("DEBUG_GUILD_ID") ?? 0,
|
||||
});
|
||||
|
||||
DiscordClient client = builder2.Build();
|
||||
|
||||
//var slashCommands = discord.UseSlashCommands(new SlashCommandsConfiguration() {Services = service});
|
||||
//slashCommands.RegisterCommands<SlashCommand>(guildId:null);
|
||||
|
||||
|
||||
//_cnext.RegisterCommands<Result>();
|
||||
_cnext.RegisterCommands<Public>();
|
||||
//_cnext.RegisterCommands<Shutdown>();
|
||||
//_cnext.RegisterCommands<Todo>();
|
||||
_cnext.RegisterCommands<Hokuto>();
|
||||
_cnext.RegisterCommands<BotEvent>();
|
||||
|
||||
foreach (var registeredCommandsKey in discord.GetCommandsNext().RegisteredCommands.Keys)
|
||||
{
|
||||
Console.WriteLine(registeredCommandsKey);
|
||||
}
|
||||
|
||||
|
||||
discord.Ready += (sender, eventArgs) =>
|
||||
{
|
||||
Console.WriteLine($"Bot READY.");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
discord.GuildAvailable += (sender, eventArgs) =>
|
||||
{
|
||||
|
||||
Console.WriteLine($"Joined Guild " + eventArgs.Guild.Name);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
await discord.ConnectAsync();
|
||||
// We can specify a status for our bot. Let's set it to "playing" and set the activity to "with fire".
|
||||
DiscordActivity status = new("Street Fighter", DiscordActivityType.Playing);
|
||||
await client.ConnectAsync(status,DiscordUserStatus.Online);
|
||||
|
||||
|
||||
await Task.Delay(Timeout.Infinite);
|
||||
|
||||
//while (!cts.IsCancellationRequested)
|
||||
//{
|
||||
// await Task.Delay(200);
|
||||
// //if(discord.GetConnectionsAsync().Result.Count)
|
||||
//}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
//internal class SlashCommand : ApplicationCommandModule
|
||||
//{
|
||||
// [SlashCommand("test", "A slash command made to test the DSharpPlusSlashCommands library!")]
|
||||
|
||||
// public async Task TestCommand(InteractionContext ctx)
|
||||
// {
|
||||
|
||||
// await ctx.CreateResponseAsync("Lol");
|
||||
|
||||
// }
|
||||
//}
|
||||
|
||||
}
|
||||
+49
@@ -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" <<EOF
|
||||
{
|
||||
"Discord": {
|
||||
"Token": "$(json_string "${LADOSE_DISCORD_TOKEN}")"
|
||||
},
|
||||
"Challonge": {
|
||||
"Token": "$(json_string "${LADOSE_CHALLONGE_API_KEY:-}")"
|
||||
},
|
||||
"REST": {
|
||||
"Url": "$(json_string "${rest_url}")",
|
||||
"User": "$(json_string "${LADOSE_BOT_REST_USER:-}")",
|
||||
"Password": "$(json_string "${LADOSE_BOT_REST_PASSWORD:-}")"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
# The token is deliberately not echoed.
|
||||
echo "settings.json: rendered from the environment (REST:Url=$rest_url)" >&2
|
||||
else
|
||||
echo "settings.json: LADOSE_DISCORD_TOKEN unset, using the file already at $settings_file" >&2
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace LaDOSE.Entity
|
||||
{
|
||||
/// <summary>
|
||||
/// A role a user can hold, stored in the pre-existing <c>applicationrole</c> table.
|
||||
/// Rows are reference data seeded by Sql/2026-08-05_roles.sql, not created at runtime.
|
||||
/// </summary>
|
||||
public class ApplicationRole
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(50)]
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
|
||||
@@ -14,6 +15,14 @@ namespace LaDOSE.Entity
|
||||
public string Password { get; set; }
|
||||
public byte[] PasswordHash { get; set; }
|
||||
public byte[] PasswordSalt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rows of the <c>applicationuserrole</c> join table for this user. Only populated
|
||||
/// when the query asks for it — see UserService, which includes it (and the role
|
||||
/// itself) everywhere the role matters; authorization reads this on every request.
|
||||
/// Prefer the <see cref="Roles.Names"/> / <see cref="Roles.IsAdmin"/> helpers.
|
||||
/// </summary>
|
||||
public List<ApplicationUserRole> UserRoles { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace LaDOSE.Entity
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>applicationuserrole</c> join table. Declared explicitly rather than left
|
||||
/// implicit so its columns match the table that already exists in the database
|
||||
/// (<c>userid</c>, <c>roleid</c>, no surrogate key).
|
||||
/// </summary>
|
||||
public class ApplicationUserRole
|
||||
{
|
||||
public int UserId { get; set; }
|
||||
public ApplicationUser User { get; set; }
|
||||
|
||||
public int RoleId { get; set; }
|
||||
public ApplicationRole Role { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ namespace LaDOSE.Entity.Context
|
||||
{
|
||||
public DbSet<Game> Game { get; set; }
|
||||
public DbSet<ApplicationUser> ApplicationUser { get; set; }
|
||||
public DbSet<ApplicationRole> ApplicationRole { get; set; }
|
||||
|
||||
public DbSet<Todo> Todo { get; set; }
|
||||
|
||||
@@ -49,6 +50,27 @@ namespace LaDOSE.Entity.Context
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
|
||||
#region Users and roles
|
||||
|
||||
// Maps onto the applicationrole / applicationuserrole tables that already exist
|
||||
// in the schema. The join table is configured explicitly rather than as a
|
||||
// many-to-many skip navigation so its columns are exactly userid + roleid,
|
||||
// with the pair as the key and no surrogate id.
|
||||
modelBuilder.Entity<ApplicationUserRole>(join =>
|
||||
{
|
||||
join.HasKey(ur => new { ur.UserId, ur.RoleId });
|
||||
|
||||
join.HasOne(ur => ur.User)
|
||||
.WithMany(u => u.UserRoles)
|
||||
.HasForeignKey(ur => ur.UserId);
|
||||
|
||||
join.HasOne(ur => ur.Role)
|
||||
.WithMany()
|
||||
.HasForeignKey(ur => ur.RoleId);
|
||||
});
|
||||
|
||||
#endregion
|
||||
|
||||
modelBuilder.Entity<Event>()
|
||||
.HasMany(s => s.Tournaments);
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LaDOSE.Entity
|
||||
{
|
||||
/// <summary>
|
||||
/// The role names the API knows about. They must exist as rows in
|
||||
/// <c>applicationrole</c> — see Sql/2026-08-05_roles.sql.
|
||||
/// </summary>
|
||||
public static class Roles
|
||||
{
|
||||
/// <summary>May manage user accounts.</summary>
|
||||
public const string Admin = "Admin";
|
||||
|
||||
/// <summary>May use everything else. The absence of a role means the same thing.</summary>
|
||||
public const string User = "User";
|
||||
|
||||
public static readonly string[] All = { Admin, User };
|
||||
|
||||
/// <summary>Case-insensitive, so 'admin' typed into the SQL seed still counts.</summary>
|
||||
public static bool IsAdmin(this ApplicationUser user)
|
||||
{
|
||||
return user.Names().Any(name => Admin.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The user's role names. Empty when the user holds no role, and also when the
|
||||
/// query did not include them — callers that authorize must load them.
|
||||
/// </summary>
|
||||
public static List<string> Names(this ApplicationUser user)
|
||||
{
|
||||
return user?.UserRoles?
|
||||
.Select(userRole => userRole.Role?.Name)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.OrderBy(name => name)
|
||||
.ToList()
|
||||
?? new List<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LaDOSE.Entity
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="MatchStats"/>, 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.
|
||||
/// </summary>
|
||||
public class SheetExportRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolved by SheetsExportService from configuration — never supplied by the caller,
|
||||
/// so a client cannot aim the export at someone else's spreadsheet.
|
||||
/// </summary>
|
||||
public string SpreadsheetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public List<SheetTable> Tabs { get; set; } = new List<SheetTable>();
|
||||
}
|
||||
|
||||
/// <summary>One tab: a players x games grid with a Total column.</summary>
|
||||
public class SheetTable
|
||||
{
|
||||
/// <summary>Desired tab title, e.g. "Ranking #1301". Sanitised before use.</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provenance and logging only. Tabs are addressed by title, never by this — see the
|
||||
/// note on tab identity in SheetsExportService.
|
||||
/// </summary>
|
||||
public int EventId { get; set; }
|
||||
|
||||
/// <summary>["Players", <game names...>, "Total"].</summary>
|
||||
public List<string> Header { get; set; } = new List<string>();
|
||||
|
||||
public List<SheetRow> Rows { get; set; } = new List<SheetRow>();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public List<string> Footer { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public class SheetRow
|
||||
{
|
||||
public string Player { get; set; }
|
||||
|
||||
/// <summary>Index-aligned with Header[1..^1] — one entry per game column.</summary>
|
||||
public List<int> Points { get; set; } = new List<int>();
|
||||
|
||||
public int Total { get; set; }
|
||||
}
|
||||
|
||||
public class SheetExportResult
|
||||
{
|
||||
public string SpreadsheetId { get; set; }
|
||||
public string SpreadsheetUrl { get; set; }
|
||||
|
||||
/// <summary>Which ISheetsWriter did the work — "ServiceAccount", "Logging", ...</summary>
|
||||
public string Writer { get; set; }
|
||||
|
||||
public List<SheetTabResult> Tabs { get; set; } = new List<SheetTabResult>();
|
||||
|
||||
/// <summary>Anything the user should know but that did not stop the write, e.g. renames.</summary>
|
||||
public List<string> Warnings { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public class SheetTabResult
|
||||
{
|
||||
/// <summary>The title as asked for, before sanitisation.</summary>
|
||||
public string RequestedName { get; set; }
|
||||
|
||||
/// <summary>The title actually written to.</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
public int Rows { get; set; }
|
||||
public int Columns { get; set; }
|
||||
|
||||
/// <summary>False when an existing tab of that name was rewritten in place.</summary>
|
||||
public bool Created { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>What the UI needs to render the export panel. Never carries a secret.</summary>
|
||||
public class SheetsConfig
|
||||
{
|
||||
public string Writer { get; set; }
|
||||
|
||||
/// <summary>The writer has its credentials and a target spreadsheet is configured.</summary>
|
||||
public bool Configured { get; set; }
|
||||
|
||||
public string SpreadsheetId { get; set; }
|
||||
public int MaxTabs { get; set; }
|
||||
public int MaxRowsPerTab { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class SheetsSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// The spreadsheet everything is written to. Reset each year — see .env.example.
|
||||
/// </summary>
|
||||
public string SpreadsheetId { get; set; }
|
||||
|
||||
public int MaxTabs { get; set; } = 60;
|
||||
public int MaxRowsPerTab { get; set; } = 5000;
|
||||
|
||||
/// <summary>Google caps a tab at 100 chars; this caps the grid width.</summary>
|
||||
public int MaxColumns { get; set; } = 200;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LaDOSE.Entity
|
||||
{
|
||||
/// <summary>
|
||||
/// Aggregate over the persisted <see cref="Set"/> rows of one or more Events.
|
||||
/// Not an entity: it is never mapped by EF, it is computed in memory by
|
||||
/// StatisticsService and mapped to MatchStatsDTO by AutoMapper (same pattern as
|
||||
/// <see cref="Challonge.TournamentsResult"/>).
|
||||
/// Property names must stay identical to the DTO's, the mapping is by convention.
|
||||
/// </summary>
|
||||
public class MatchStats
|
||||
{
|
||||
/// <summary>How much of the requested scope actually has set data behind it.</summary>
|
||||
public MatchCoverage Coverage { get; set; } = new MatchCoverage();
|
||||
|
||||
public List<PlayerMatchStats> Players { get; set; } = new List<PlayerMatchStats>();
|
||||
|
||||
public List<HeadToHead> HeadToHead { get; set; } = new List<HeadToHead>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set coverage is sparse: Challonge-imported events have no sets at all, and events
|
||||
/// imported before SmashProvider.GetSets existed were never backfilled. These counters
|
||||
/// let a caller say "12 of 40 brackets have match data" instead of implying completeness.
|
||||
/// </summary>
|
||||
public class MatchCoverage
|
||||
{
|
||||
/// <summary>Requested event ids that actually exist.</summary>
|
||||
public int Events { get; set; }
|
||||
|
||||
/// <summary>Tournaments (brackets) belonging to those events.</summary>
|
||||
public int Brackets { get; set; }
|
||||
|
||||
/// <summary>Of those brackets, how many have at least one set row.</summary>
|
||||
public int BracketsWithSets { get; set; }
|
||||
|
||||
/// <summary>Total set rows in scope, including the ones skipped as unusable.</summary>
|
||||
public int Sets { get; set; }
|
||||
|
||||
/// <summary>Sets with a determinable winner (unequal scores, two distinct real players).</summary>
|
||||
public int DecidedSets { get; set; }
|
||||
}
|
||||
|
||||
public class PlayerMatchStats
|
||||
{
|
||||
public int PlayerId { get; set; }
|
||||
|
||||
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||
public string Player { get; set; }
|
||||
|
||||
/// <summary>Decided sets only. Always equals Wins + Losses.</summary>
|
||||
public int Sets { get; set; }
|
||||
|
||||
public int Wins { get; set; }
|
||||
public int Losses { get; set; }
|
||||
public int GamesWon { get; set; }
|
||||
public int GamesLost { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row per unordered pair of players with at least one decided set.
|
||||
/// A is always the lower PlayerId so WinsA / WinsB are unambiguous.
|
||||
/// </summary>
|
||||
public class HeadToHead
|
||||
{
|
||||
public int PlayerAId { get; set; }
|
||||
public string PlayerA { get; set; }
|
||||
public int PlayerBId { get; set; }
|
||||
public string PlayerB { get; set; }
|
||||
public int WinsA { get; set; }
|
||||
public int WinsB { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LaDOSE.Entity
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="MatchStats"/>.
|
||||
/// Property names must stay identical to the DTO's, the mapping is by convention.
|
||||
///
|
||||
/// A <see cref="Set"/> carries no game of its own — the game comes from the
|
||||
/// <see cref="Tournament"/> the set belongs to, and that Tournament.GameId is
|
||||
/// nullable. Sets behind a bracket with no game are counted in
|
||||
/// <see cref="UnknownGameSets"/> and excluded from everything else, because a
|
||||
/// per-game breakdown cannot say anything about them.
|
||||
/// </summary>
|
||||
public class PlayerVersus
|
||||
{
|
||||
public int PlayerAId { get; set; }
|
||||
|
||||
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||
public string PlayerA { get; set; }
|
||||
|
||||
public int PlayerBId { get; set; }
|
||||
|
||||
public string PlayerB { get; set; }
|
||||
|
||||
/// <summary>Meetings in a bracket whose game is known — the sum of the rows below.</summary>
|
||||
public int Sets { get; set; }
|
||||
|
||||
/// <summary>Of those, the ones with a determinable winner (unequal scores).</summary>
|
||||
public int DecidedSets { get; set; }
|
||||
|
||||
public int WinsA { get; set; }
|
||||
public int WinsB { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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".
|
||||
/// </summary>
|
||||
public int UnknownGameSets { get; set; }
|
||||
|
||||
/// <summary>One row per game they actually met in, most-played first.</summary>
|
||||
public List<VersusGameStats> Games { get; set; } = new List<VersusGameStats>();
|
||||
}
|
||||
|
||||
/// <summary>One game's slice of a <see cref="PlayerVersus"/>.</summary>
|
||||
public class VersusGameStats
|
||||
{
|
||||
public int GameId { get; set; }
|
||||
|
||||
/// <summary>Game.Name, or "#<id>" when the game row is gone.</summary>
|
||||
public string Game { get; set; }
|
||||
|
||||
public string GameLongName { get; set; }
|
||||
|
||||
/// <summary>Meetings in this game, decided or not.</summary>
|
||||
public int Sets { get; set; }
|
||||
|
||||
/// <summary>Meetings with a determinable winner. Always equals WinsA + WinsB.</summary>
|
||||
public int DecidedSets { get; set; }
|
||||
|
||||
public int WinsA { get; set; }
|
||||
public int WinsB { get; set; }
|
||||
|
||||
/// <summary>Individual games won inside the decided sets (a DQ, stored as -1, clamps to 0).</summary>
|
||||
public int GamesWonA { get; set; }
|
||||
|
||||
public int GamesWonB { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class PlayerOption
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>Gamertag, falling back to Name, else "#<id>".</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Sets in a bracket with a known game, whoever the opponent was.</summary>
|
||||
public int Sets { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
<LangVersion>12</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="RestSharp" Version="112.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Entity;
|
||||
|
||||
namespace LaDOSE.Business.Interface
|
||||
{
|
||||
public interface ISheetsExportService
|
||||
{
|
||||
/// <summary>What the export panel needs to render. Never includes a secret.</summary>
|
||||
SheetsConfig GetConfig();
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the target spreadsheet from configuration, validates the tables, sanitises
|
||||
/// and de-duplicates the tab titles, then hands off to the configured
|
||||
/// <see cref="ISheetsWriter"/>. Throws <see cref="SheetsExportException"/> for anything
|
||||
/// the caller can act on.
|
||||
/// </summary>
|
||||
Task<SheetExportResult> ExportAsync(SheetExportRequest request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Entity;
|
||||
|
||||
namespace LaDOSE.Business.Interface
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface ISheetsWriter
|
||||
{
|
||||
/// <summary>Reported to the UI so the panel can say what it is talking to.</summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>False when credentials are missing, so the API can answer 503 with a message.</summary>
|
||||
bool IsConfigured { get; }
|
||||
|
||||
Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Entity;
|
||||
|
||||
namespace LaDOSE.Business.Interface
|
||||
{
|
||||
public interface IStatisticsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Aggregate the persisted sets of the given Events into per-player records,
|
||||
/// head-to-head records and a coverage report.
|
||||
/// A null or empty id list yields a well-formed, zeroed <see cref="MatchStats"/>.
|
||||
/// </summary>
|
||||
Task<MatchStats> GetMatchStats(List<int> eventIds);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<List<PlayerOption>> GetVersusPlayers();
|
||||
|
||||
/// <summary>
|
||||
/// Every recorded meeting between two players, all events, broken down per game.
|
||||
/// Sets whose bracket has no game are excluded and only counted in
|
||||
/// <see cref="PlayerVersus.UnknownGameSets"/>.
|
||||
/// Missing, equal or unknown ids yield a well-formed empty <see cref="PlayerVersus"/>.
|
||||
/// </summary>
|
||||
Task<PlayerVersus> GetVersus(int playerAId, int playerBId);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@ namespace LaDOSE.Business.Interface
|
||||
ApplicationUser Authenticate(string username, string password);
|
||||
IEnumerable<ApplicationUser> GetAll();
|
||||
ApplicationUser GetById(int id);
|
||||
ApplicationUser Create(ApplicationUser user, string password);
|
||||
ApplicationUser Create(ApplicationUser user, string password, IEnumerable<string> roleNames = null);
|
||||
void Update(ApplicationUser user, string password = null);
|
||||
void Delete(int id);
|
||||
|
||||
/// <summary>The roles that exist in the database — reference data, not created at runtime.</summary>
|
||||
IEnumerable<ApplicationRole> GetAllRoles();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<AssemblyName>LaDOSE.Business</AssemblyName>
|
||||
<RootNamespace>LaDOSE.Business</RootNamespace>
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Apis.Sheets.v4" Version="1.75.0.4178" />
|
||||
<PackageReference Include="GraphQL.Client" Version="6.1.0" />
|
||||
<PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="6.1.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Business.Interface;
|
||||
using LaDOSE.Entity;
|
||||
|
||||
namespace LaDOSE.Business.Provider.SheetsProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class DisabledSheetsWriter : ISheetsWriter
|
||||
{
|
||||
public string Name => "Disabled";
|
||||
|
||||
public bool IsConfigured => false;
|
||||
|
||||
public Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
|
||||
{
|
||||
throw new SheetsExportException(503,
|
||||
"Google Sheets export is not configured on the server. Set GoogleSheets:Writer.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<SheetExportResult> 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<SheetTable>();
|
||||
|
||||
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<Sheet>())
|
||||
.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<string>()
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private SheetsService CreateService(out string accountEmail)
|
||||
{
|
||||
ServiceAccountCredential credential;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_credentialsPath);
|
||||
var parameters = Google.Apis.Json.NewtonsoftJsonSerializer.Instance
|
||||
.Deserialize<JsonCredentialParameters>(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
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static BatchUpdateSpreadsheetRequest BuildBatch(
|
||||
List<SheetProperties> existing,
|
||||
List<SheetTable> tabs,
|
||||
out List<SheetTabResult> results)
|
||||
{
|
||||
var requests = new List<Request>();
|
||||
results = new List<SheetTabResult>();
|
||||
|
||||
var byTitle = (existing ?? new List<SheetProperties>())
|
||||
.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 };
|
||||
}
|
||||
|
||||
/// <summary>Sheet ids must be unique within the spreadsheet and are ours to choose.</summary>
|
||||
private static int NextFreeSheetId(IEnumerable<SheetProperties> existing)
|
||||
{
|
||||
var used = existing.Select(properties => properties.SheetId ?? 0).DefaultIfEmpty(0).Max();
|
||||
return Math.Max(used + 1, 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static List<RowData> ToRowData(SheetTable tab)
|
||||
{
|
||||
var header = tab.Header ?? new List<string>();
|
||||
var width = header.Count;
|
||||
var rows = new List<RowData>
|
||||
{
|
||||
new RowData { Values = header.Select(Text).ToList() }
|
||||
};
|
||||
|
||||
foreach (var row in tab.Rows ?? new List<SheetRow>())
|
||||
{
|
||||
var cells = new List<CellData> { Text(row.Player) };
|
||||
cells.AddRange((row.Points ?? new List<int>()).Select(Number));
|
||||
cells.Add(Number(row.Total));
|
||||
rows.Add(new RowData { Values = cells });
|
||||
}
|
||||
|
||||
var footer = (tab.Footer ?? new List<string>()).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<CellData> 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class LoggingSheetsWriter : ISheetsWriter
|
||||
{
|
||||
private readonly ILogger<LoggingSheetsWriter> _logger;
|
||||
|
||||
public LoggingSheetsWriter(ILogger<LoggingSheetsWriter> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Name => "Logging";
|
||||
|
||||
public bool IsConfigured => true;
|
||||
|
||||
public Task<SheetExportResult> WriteTablesAsync(SheetExportRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var tabs = request.Tabs ?? new List<SheetTable>();
|
||||
|
||||
_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<string>();
|
||||
var rows = tab.Rows ?? new List<SheetRow>();
|
||||
|
||||
_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<int>()), row.Total);
|
||||
}
|
||||
|
||||
foreach (var line in tab.Footer ?? new List<string>())
|
||||
{
|
||||
_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<string>
|
||||
{
|
||||
"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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<SheetExportResult> 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<SheetTable>();
|
||||
if (tabs.Count == 0)
|
||||
{
|
||||
throw new SheetsExportException(400, "No table to write.");
|
||||
}
|
||||
|
||||
Validate(tabs);
|
||||
|
||||
var warnings = new List<string>();
|
||||
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<string>()).Concat(warnings).ToList();
|
||||
return result;
|
||||
}
|
||||
|
||||
#region Validation
|
||||
|
||||
private void Validate(List<SheetTable> 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<string>();
|
||||
|
||||
// "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<SheetRow>();
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Google rejects these in a tab title. Replaced rather than stripped so "Ranking 13/14"
|
||||
/// stays readable as "Ranking 13-14".
|
||||
/// </summary>
|
||||
private static readonly Regex Forbidden = new Regex(@"[:\\/?*\[\]]", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex Whitespace = new Regex(@"\s+", RegexOptions.Compiled);
|
||||
|
||||
private const int MaxTitleLength = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static void NameTabs(List<SheetTable> tabs, List<string> warnings)
|
||||
{
|
||||
var taken = new HashSet<string>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using LaDOSE.Business.Interface;
|
||||
using LaDOSE.Entity;
|
||||
using LaDOSE.Entity.Context;
|
||||
|
||||
namespace LaDOSE.Business.Service
|
||||
{
|
||||
/// <summary>
|
||||
/// Match statistics over the persisted Set rows.
|
||||
///
|
||||
/// Two things to know about the data:
|
||||
/// - There is no Set -> Player relationship in the EF model (the navigation properties
|
||||
/// and their configuration are commented out), so player names are resolved with a
|
||||
/// separate query plus an in-memory dictionary. Nothing here Includes a player.
|
||||
/// - 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 <see cref="Tournament"/> 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. <see cref="GetVersus"/> drops
|
||||
/// them and reports how many it dropped.
|
||||
///
|
||||
/// The database work and the aggregation are deliberately separated: <see cref="GetMatchStats"/>
|
||||
/// and <see cref="GetVersus"/> issue one query per table and hand the loaded lists to the pure
|
||||
/// static <see cref="Aggregate"/> / <see cref="AggregateVersus"/>, which are unit-testable
|
||||
/// without a database.
|
||||
/// </summary>
|
||||
public class StatisticsService : IStatisticsService
|
||||
{
|
||||
protected LaDOSEDbContext _context;
|
||||
|
||||
public StatisticsService(LaDOSEDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public Task<MatchStats> GetMatchStats(List<int> eventIds)
|
||||
{
|
||||
var requested = (eventIds ?? new List<int>()).Distinct().ToList();
|
||||
if (requested.Count == 0)
|
||||
{
|
||||
// Well-formed and empty, never null and never a 500.
|
||||
return Task.FromResult(new MatchStats());
|
||||
}
|
||||
|
||||
// One query per table. No query per set and no query per player.
|
||||
var events = _context.Event
|
||||
.Where(e => requested.Contains(e.Id))
|
||||
.ToList();
|
||||
|
||||
var existingEventIds = events.Select(e => e.Id).ToList();
|
||||
|
||||
var tournaments = existingEventIds.Count == 0
|
||||
? new List<Tournament>()
|
||||
: _context.Tournament
|
||||
.Where(t => existingEventIds.Contains(t.EventId))
|
||||
.ToList();
|
||||
|
||||
var tournamentIds = tournaments.Select(t => t.Id).Distinct().ToList();
|
||||
|
||||
// _context.Set is the DbSet<Set> property, not DbContext.Set<T>().
|
||||
var sets = tournamentIds.Count == 0
|
||||
? new List<Set>()
|
||||
: _context.Set
|
||||
.Where(s => tournamentIds.Contains(s.TournamentId))
|
||||
.ToList();
|
||||
|
||||
var playerIds = sets.Select(s => s.Player1Id)
|
||||
.Concat(sets.Select(s => s.Player2Id))
|
||||
.Where(id => id != 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var players = playerIds.Count == 0
|
||||
? new List<Player>()
|
||||
: _context.Player
|
||||
.Where(p => playerIds.Contains(p.Id))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(Aggregate(requested, events, tournaments, sets, players));
|
||||
}
|
||||
|
||||
public Task<List<PlayerOption>> 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<int, int>();
|
||||
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<PlayerOption>());
|
||||
}
|
||||
|
||||
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<PlayerVersus> 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<Tournament>()
|
||||
: _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<Game>()
|
||||
: _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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure aggregation over already-loaded rows. No database, no I/O, deterministic.
|
||||
/// </summary>
|
||||
/// <param name="requestedEventIds">The event ids the caller asked for.</param>
|
||||
/// <param name="events">Events that exist (used for the coverage count).</param>
|
||||
/// <param name="tournaments">Candidate tournaments; filtered here on EventId.</param>
|
||||
/// <param name="sets">Candidate sets; filtered here on TournamentId.</param>
|
||||
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
|
||||
public static MatchStats Aggregate(
|
||||
IEnumerable<int> requestedEventIds,
|
||||
IEnumerable<Event> events,
|
||||
IEnumerable<Tournament> tournaments,
|
||||
IEnumerable<Set> sets,
|
||||
IEnumerable<Player> players)
|
||||
{
|
||||
var result = new MatchStats();
|
||||
|
||||
var requested = new HashSet<int>((requestedEventIds ?? Enumerable.Empty<int>()));
|
||||
if (requested.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Coverage: events -------------------------------------------------------
|
||||
var existingEventIds = new HashSet<int>((events ?? Enumerable.Empty<Event>())
|
||||
.Where(e => e != null && requested.Contains(e.Id))
|
||||
.Select(e => e.Id));
|
||||
result.Coverage.Events = existingEventIds.Count;
|
||||
|
||||
// Coverage: brackets ----------------------------------------------------
|
||||
var scopedTournamentIds = new HashSet<int>((tournaments ?? Enumerable.Empty<Tournament>())
|
||||
.Where(t => t != null && existingEventIds.Contains(t.EventId))
|
||||
.Select(t => t.Id));
|
||||
result.Coverage.Brackets = scopedTournamentIds.Count;
|
||||
|
||||
var scopedSets = (sets ?? Enumerable.Empty<Set>())
|
||||
.Where(s => s != null && scopedTournamentIds.Contains(s.TournamentId))
|
||||
.ToList();
|
||||
result.Coverage.Sets = scopedSets.Count;
|
||||
result.Coverage.BracketsWithSets = scopedSets.Select(s => s.TournamentId).Distinct().Count();
|
||||
|
||||
// Name resolution -------------------------------------------------------
|
||||
var nameById = new Dictionary<int, string>();
|
||||
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
|
||||
{
|
||||
nameById[player.Id] = DisplayName(player);
|
||||
}
|
||||
|
||||
var stats = new Dictionary<int, PlayerMatchStats>();
|
||||
var h2h = new Dictionary<(int, int), HeadToHead>();
|
||||
|
||||
foreach (var set in scopedSets)
|
||||
{
|
||||
// Unusable rows still count in Coverage.Sets but nowhere else.
|
||||
if (set.Player1Id == 0 || set.Player2Id == 0 || set.Player1Id == set.Player2Id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// No winner column: equal scores (including 0-0 and -1 / -1) are undecided.
|
||||
if (set.Player1Score == set.Player2Score)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Coverage.DecidedSets++;
|
||||
|
||||
var p1 = set.Player1Id;
|
||||
var p2 = set.Player2Id;
|
||||
|
||||
// A DQ is stored as -1. Clamp so it never produces negative games.
|
||||
var games1 = Math.Max(0, set.Player1Score);
|
||||
var games2 = Math.Max(0, set.Player2Score);
|
||||
|
||||
var stat1 = GetOrAdd(stats, p1, nameById);
|
||||
var stat2 = GetOrAdd(stats, p2, nameById);
|
||||
|
||||
stat1.GamesWon += games1;
|
||||
stat1.GamesLost += games2;
|
||||
stat2.GamesWon += games2;
|
||||
stat2.GamesLost += games1;
|
||||
|
||||
var winnerId = set.Player1Score > set.Player2Score ? p1 : p2;
|
||||
if (winnerId == p1)
|
||||
{
|
||||
stat1.Wins++;
|
||||
stat2.Losses++;
|
||||
}
|
||||
else
|
||||
{
|
||||
stat2.Wins++;
|
||||
stat1.Losses++;
|
||||
}
|
||||
|
||||
// Lower PlayerId is always A, so WinsA / WinsB are unambiguous.
|
||||
var aId = Math.Min(p1, p2);
|
||||
var bId = Math.Max(p1, p2);
|
||||
var key = (aId, bId);
|
||||
if (!h2h.TryGetValue(key, out var pair))
|
||||
{
|
||||
pair = new HeadToHead
|
||||
{
|
||||
PlayerAId = aId,
|
||||
PlayerA = ResolveName(aId, nameById),
|
||||
PlayerBId = bId,
|
||||
PlayerB = ResolveName(bId, nameById)
|
||||
};
|
||||
h2h[key] = pair;
|
||||
}
|
||||
|
||||
if (winnerId == aId)
|
||||
{
|
||||
pair.WinsA++;
|
||||
}
|
||||
else
|
||||
{
|
||||
pair.WinsB++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sets is derived, so Sets == Wins + Losses cannot drift.
|
||||
foreach (var stat in stats.Values)
|
||||
{
|
||||
stat.Sets = stat.Wins + stat.Losses;
|
||||
Debug.Assert(stat.Sets == stat.Wins + stat.Losses, "Sets must equal Wins + Losses");
|
||||
}
|
||||
|
||||
result.Players = stats.Values
|
||||
.OrderByDescending(p => p.Wins)
|
||||
.ThenByDescending(p => p.Sets)
|
||||
.ThenBy(p => p.Player, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.PlayerId)
|
||||
.ToList();
|
||||
|
||||
// Not capped: bounded by the number of sets in scope.
|
||||
result.HeadToHead = h2h.Values
|
||||
.OrderByDescending(p => p.WinsA + p.WinsB)
|
||||
.ThenBy(p => p.PlayerA, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.PlayerB, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.PlayerAId)
|
||||
.ThenBy(p => p.PlayerBId)
|
||||
.ToList();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Aggregate"/>: equal
|
||||
/// scores mean undecided. Undecided meetings still count in Sets — they happened —
|
||||
/// but contribute to no win and no game count.
|
||||
/// </summary>
|
||||
/// <param name="playerAId">Left-hand player; WinsA is always their side.</param>
|
||||
/// <param name="playerBId">Right-hand player.</param>
|
||||
/// <param name="sets">Candidate sets; the pairing is re-checked here.</param>
|
||||
/// <param name="tournaments">Tournaments of those sets, for Tournament.GameId.</param>
|
||||
/// <param name="games">Games used to resolve names; may be incomplete.</param>
|
||||
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
|
||||
public static PlayerVersus AggregateVersus(
|
||||
int playerAId,
|
||||
int playerBId,
|
||||
IEnumerable<Set> sets,
|
||||
IEnumerable<Tournament> tournaments,
|
||||
IEnumerable<Game> games,
|
||||
IEnumerable<Player> players)
|
||||
{
|
||||
var result = new PlayerVersus
|
||||
{
|
||||
PlayerAId = playerAId,
|
||||
PlayerBId = playerBId
|
||||
};
|
||||
|
||||
if (playerAId == 0 || playerBId == 0 || playerAId == playerBId)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var nameById = new Dictionary<int, string>();
|
||||
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
|
||||
{
|
||||
nameById[player.Id] = DisplayName(player);
|
||||
}
|
||||
|
||||
result.PlayerA = ResolveName(playerAId, nameById);
|
||||
result.PlayerB = ResolveName(playerBId, nameById);
|
||||
|
||||
var gameIdByTournament = new Dictionary<int, int?>();
|
||||
foreach (var tournament in (tournaments ?? Enumerable.Empty<Tournament>()).Where(t => t != null))
|
||||
{
|
||||
gameIdByTournament[tournament.Id] = tournament.GameId;
|
||||
}
|
||||
|
||||
var gameById = new Dictionary<int, Game>();
|
||||
foreach (var game in (games ?? Enumerable.Empty<Game>()).Where(g => g != null))
|
||||
{
|
||||
gameById[game.Id] = game;
|
||||
}
|
||||
|
||||
var perGame = new Dictionary<int, VersusGameStats>();
|
||||
|
||||
foreach (var set in (sets ?? Enumerable.Empty<Set>()).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<int, VersusGameStats> perGame, int gameId,
|
||||
Dictionary<int, Game> 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<int, PlayerMatchStats> stats, int playerId,
|
||||
Dictionary<int, string> nameById)
|
||||
{
|
||||
if (!stats.TryGetValue(playerId, out var stat))
|
||||
{
|
||||
stat = new PlayerMatchStats
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Player = ResolveName(playerId, nameById)
|
||||
};
|
||||
stats[playerId] = stat;
|
||||
}
|
||||
|
||||
return stat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gamertag, falling back to Name, else "#<id>" (a set can reference a player row
|
||||
/// that no longer exists, and Gamertag is nullable in the database).
|
||||
/// </summary>
|
||||
private static string ResolveName(int playerId, Dictionary<int, string> nameById)
|
||||
{
|
||||
return nameById.TryGetValue(playerId, out var name) ? name : $"#{playerId}";
|
||||
}
|
||||
|
||||
private static string DisplayName(Player player)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(player.Gamertag))
|
||||
{
|
||||
return player.Gamertag;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(player.Name))
|
||||
{
|
||||
return player.Name;
|
||||
}
|
||||
|
||||
return $"#{player.Id}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using LaDOSE.Business.Interface;
|
||||
using LaDOSE.Entity;
|
||||
using LaDOSE.Entity.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LaDOSE.Business.Service
|
||||
{
|
||||
@@ -20,8 +21,9 @@ namespace LaDOSE.Business.Service
|
||||
{
|
||||
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
|
||||
return null;
|
||||
var p = _context.ApplicationUser.ToList();
|
||||
var user = _context.ApplicationUser.SingleOrDefault(x => x.Username == username);
|
||||
var user = _context.ApplicationUser
|
||||
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||
.SingleOrDefault(x => x.Username == username);
|
||||
|
||||
// check if username exists
|
||||
if (user == null)
|
||||
@@ -37,23 +39,44 @@ namespace LaDOSE.Business.Service
|
||||
|
||||
public IEnumerable<ApplicationUser> GetAll()
|
||||
{
|
||||
return _context.ApplicationUser;
|
||||
return _context.ApplicationUser
|
||||
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Roles are included because authorization reads them on every authenticated
|
||||
/// request (see the OnTokenValidated handler in Startup).
|
||||
/// </summary>
|
||||
public ApplicationUser GetById(int id)
|
||||
{
|
||||
return _context.ApplicationUser.Find(id);
|
||||
return _context.ApplicationUser
|
||||
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||
.SingleOrDefault(x => x.Id == id);
|
||||
}
|
||||
|
||||
public ApplicationUser Create(ApplicationUser user, string password)
|
||||
public IEnumerable<ApplicationRole> GetAllRoles()
|
||||
{
|
||||
return _context.ApplicationRole.OrderBy(x => x.Name).ToList();
|
||||
}
|
||||
|
||||
public ApplicationUser Create(ApplicationUser user, string password, IEnumerable<string> roleNames = null)
|
||||
{
|
||||
// validation
|
||||
if (string.IsNullOrWhiteSpace(user?.Username))
|
||||
throw new Exception("Username is required");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
throw new Exception("Password is required");
|
||||
|
||||
if (_context.ApplicationUser.Any(x => x.Username == user.Username))
|
||||
throw new Exception("Username \"" + user.Username + "\" is already taken");
|
||||
|
||||
// EF fills userid/roleid on the join rows from these navigations when it saves.
|
||||
user.UserRoles = ResolveRoles(roleNames)
|
||||
.Select(role => new ApplicationUserRole { Role = role })
|
||||
.ToList();
|
||||
|
||||
byte[] passwordHash, passwordSalt;
|
||||
CreatePasswordHash(password, out passwordHash, out passwordSalt);
|
||||
|
||||
@@ -66,6 +89,35 @@ namespace LaDOSE.Business.Service
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns role names into the existing rows of <c>applicationrole</c>. An unknown
|
||||
/// name is an error rather than a new role, so a typo cannot quietly produce an
|
||||
/// account with no privileges — or, worse, a second spelling of "Admin".
|
||||
/// </summary>
|
||||
private List<ApplicationRole> ResolveRoles(IEnumerable<string> roleNames)
|
||||
{
|
||||
var wanted = (roleNames ?? Enumerable.Empty<string>())
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(name => name.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (wanted.Count == 0)
|
||||
return new List<ApplicationRole>();
|
||||
|
||||
var known = _context.ApplicationRole.ToList();
|
||||
var resolved = new List<ApplicationRole>();
|
||||
foreach (var name in wanted)
|
||||
{
|
||||
var role = known.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (role == null)
|
||||
throw new Exception($"Unknown role \"{name}\"");
|
||||
resolved.Add(role);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
public void Update(ApplicationUser userParam, string password = null)
|
||||
{
|
||||
var user = _context.ApplicationUser.Find(userParam.Id);
|
||||
@@ -101,13 +153,19 @@ namespace LaDOSE.Business.Service
|
||||
|
||||
public void Delete(int id)
|
||||
{
|
||||
var user = _context.ApplicationUser.Find(id);
|
||||
if (user != null)
|
||||
{
|
||||
// applicationuserrole's foreign keys are ON DELETE RESTRICT, so the join rows
|
||||
// have to go first — clearing the collection makes EF delete them.
|
||||
var user = _context.ApplicationUser
|
||||
.Include(x => x.UserRoles).ThenInclude(ur => ur.Role)
|
||||
.SingleOrDefault(x => x.Id == id);
|
||||
|
||||
if (user == null)
|
||||
return;
|
||||
|
||||
user.UserRoles?.Clear();
|
||||
_context.ApplicationUser.Remove(user);
|
||||
_context.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
// private helper methods
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Keep the build context small and free of local state.
|
||||
node_modules
|
||||
build
|
||||
.svelte-kit
|
||||
.git
|
||||
.vscode
|
||||
|
||||
# Real URLs live here and must never reach the image. .env.example is documentation.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Consumed by `podman build` itself, never copied in.
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Local noise
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
@@ -0,0 +1 @@
|
||||
lts/*
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Build the SPA. adapter-static writes /app/build (pages == assets).
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Lockfile first: this layer is reused until the dependencies actually change.
|
||||
# Every build tool lives in devDependencies, so no --omit=dev here.
|
||||
#
|
||||
# --legacy-peer-deps works around a conflict that predates this Dockerfile:
|
||||
# openapi-typescript@7.13.0 peer-requires typescript@^5.x while the project is on ^6.0.3,
|
||||
# so plain `npm ci` fails ERESOLVE on every npm version tested (10.8, 10.9, 11.17).
|
||||
# The flag only skips peer *validation* — the tree installed is still exactly
|
||||
# package-lock.json (verified: typescript 6.0.3, lockfile unmodified).
|
||||
# Drop the flag once package.json resolves that conflict.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
|
||||
# Optional baked-in default. Precedence at runtime is
|
||||
# /config.js > VITE_API_BASE_URL > the 'http://localhost:5000' in src/lib/api/client.ts.
|
||||
# Declared after `npm ci` so passing it does not invalidate the dependency layer.
|
||||
# client.ts uses `??`, so an empty string would beat its default: unset instead of exporting "".
|
||||
ARG VITE_API_BASE_URL
|
||||
|
||||
# Same gate as the README's documented `npm run check`: a type regression fails the image.
|
||||
RUN npm run check
|
||||
RUN if [ -z "${VITE_API_BASE_URL:-}" ]; then unset VITE_API_BASE_URL; fi; npm run build
|
||||
|
||||
# Serve it. openapi.json / schema.d.ts are committed, so nothing here touches the live API.
|
||||
FROM nginx:1.27-alpine
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
COPY --from=build /app/build/ ./
|
||||
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,277 @@
|
||||
# LaDOSE.WebApp
|
||||
|
||||
Svelte 5 + SvelteKit front-end for `LaDOSE.Api`, styled with Tailwind CSS v4.
|
||||
It ships as a static SPA (`@sveltejs/adapter-static`) and talks to the API over
|
||||
JWT bearer auth, so it can be served from any static host.
|
||||
|
||||
## Requirements
|
||||
|
||||
Node ≥ 22.12 (the toolchain uses Vite 8). An `.nvmrc` is checked in:
|
||||
|
||||
```bash
|
||||
nvm use # resolves lts/*
|
||||
npm install
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
The API must be up — it serves on `http://localhost:5000` in development:
|
||||
|
||||
```bash
|
||||
cd ../LaDOSE.Api && dotnet run # terminal 1
|
||||
npm run dev # terminal 2 -> http://localhost:5173
|
||||
```
|
||||
|
||||
Point the app at a different API with `VITE_API_BASE_URL` (see `.env.example`).
|
||||
`LaDOSE.Api` already allows any origin with credentials, so no dev proxy is needed.
|
||||
|
||||
## Typed API access
|
||||
|
||||
`src/lib/api/schema.d.ts` is **generated** from the API's OpenAPI document — never
|
||||
edit it by hand. Regenerate whenever a C# controller or DTO changes:
|
||||
|
||||
```bash
|
||||
npm run api:sync # fetch openapi.json from the running API, then re-emit types
|
||||
```
|
||||
|
||||
That is `api:fetch` (curl `/openapi/v1.json`, override the host with `LADOSE_API_URL`)
|
||||
followed by `api:types` (`openapi-typescript`). `openapi.json` is committed so the
|
||||
types can be rebuilt without a running API.
|
||||
|
||||
Because paths and DTOs come from the generated types, a renamed route or a changed
|
||||
DTO field surfaces as a TypeScript error rather than a runtime 404.
|
||||
|
||||
| Module | Purpose |
|
||||
| --- | --- |
|
||||
| `src/lib/api/schema.d.ts` | Generated types — all 31 API paths and every DTO |
|
||||
| `src/lib/api/schema-helpers.ts` | Friendly aliases (`ApplicationUserDTO`, `LoginRequest`, …) |
|
||||
| `src/lib/api/client.ts` | `apiRequest` — bearer auth, JSON, `ApiError`; paths constrained to real routes |
|
||||
| `src/lib/api/users.ts` | `login` against `/Users/auth` — the one unauthenticated call |
|
||||
| `src/lib/api/errors.ts` | `toErrorMessage` — message to show, or redirect to `/login` on a 401 |
|
||||
| `src/lib/api/tournaments.ts` | `listEvents` / `importSmashTournament` / `getResults`, authenticated from the session |
|
||||
| `src/lib/api/games.ts` | `listGames` / `saveGame` / `deleteGame` / `searchSmashGames` |
|
||||
| `src/lib/api/admin-users.ts` | `listUsers` / `listRoles` / `addUser` / `deleteUser` — all Admin-only |
|
||||
| `src/lib/api/statistics.ts` | `getMatchStats` (set-level win/loss and head to head), `listVersusPlayers` / `getVersus` (one pairing, per game) |
|
||||
| `src/lib/api/sheets.ts` | `getSheetsConfig` / `exportToSheets` — writes a ranking table into the club's Google Spreadsheet |
|
||||
| `src/lib/tournaments/results.ts` | Pure reshaping of `TournamentsResultDTO`: ranking grid, per-game placements, WordPress HTML, CSV |
|
||||
| `src/lib/tournaments/sheet.ts` | Pure: ranking grid → one spreadsheet tab, plus the tab title a selection suggests |
|
||||
| `src/lib/statistics/aggregate.ts` | Pure aggregation for `/statistiques`: standings, per-game and per-event summaries, CSV |
|
||||
| `src/lib/statistics/load.ts` | `loadEventResults` — per-event `GetResults` fan-out with progress, partial failure and abort |
|
||||
| `src/lib/ui/PlayerPicker.svelte` | Filterable player list used twice on `/statistiques/players` |
|
||||
| `src/lib/games/draft.ts` | `GameDTO` ⇄ editor form, including the blank-to-NULL rules |
|
||||
| `src/lib/ui/classes.ts` | The Tailwind class strings shared by the pages |
|
||||
| `src/lib/stores/session.svelte.ts` | Signed-in user, persisted to `localStorage`, drops expired JWTs |
|
||||
|
||||
Calling another endpoint takes one line, and the path is checked at compile time:
|
||||
|
||||
```ts
|
||||
import { apiRequest, buildPath, session } from '$lib';
|
||||
import type { GameDTO } from '$lib';
|
||||
|
||||
const games = await apiRequest<GameDTO[]>('/api/Game', { token: session.token });
|
||||
const one = await apiRequest<GameDTO>(buildPath('/api/Game/{id}', { id: 3 }));
|
||||
```
|
||||
|
||||
## Routes
|
||||
|
||||
- `/login` — username + password, posts to `POST /Users/auth`, stores the returned JWT
|
||||
- `/` — guarded; greets the signed-in user with **Hello, \<name\>.** and offers sign-out
|
||||
- `/tournaments` — guarded; the start.gg half of the old Avalonia `TournamentResultView`,
|
||||
and the one-click push of the generated ranking table into the club's Google Spreadsheet
|
||||
- `/statistiques` — redirects to `/statistiques/rankings` (the path the navbar used before
|
||||
the section became two pages)
|
||||
- `/statistiques/rankings` — guarded; standings, attendance and match statistics over a
|
||||
chosen scope
|
||||
- `/statistiques/players` — guarded; two players, every game they met in, all events
|
||||
- `/games` — guarded; the game catalogue editor, from the Avalonia `GamesView`
|
||||
- `/users` — **Admin only**; add and remove accounts
|
||||
|
||||
`session.displayName` prefers `firstName lastName` and falls back to `username`.
|
||||
|
||||
### `/tournaments`
|
||||
|
||||
Ports the Smash.gg (start.gg) column of `LaDOSE.DesktopApp.Avalonia`:
|
||||
|
||||
1. **Import** — a slug (`start.gg/tournament/<slug>`) is sent to
|
||||
`GET /api/Tournament/ParseSmash/{slug}`, which pulls the brackets, placements and
|
||||
sets into the database. Every bracket must be `COMPLETED` or the API throws.
|
||||
2. **Events** — `GET /api/Event`, newest first. Tick one event for a single
|
||||
tournament, or several to aggregate a ranking season. The regex box replaces the
|
||||
selection with every matching event name (e.g. `Ranking #13\d{2}`).
|
||||
3. **Generate results** — `POST /api/Tournament/GetResults` with the selected ids;
|
||||
the API applies the point rules in `ExternalProviderService`. Three views:
|
||||
- *Ranking* — players × games with totals, highest first, plus CSV export and the
|
||||
Google Sheets push below
|
||||
- *By game* — placements and points for one game
|
||||
- *HTML* — the podium table for the WordPress recap, with copy-to-clipboard
|
||||
|
||||
The Challonge half of the Avalonia view (date range, Challonge tournament list,
|
||||
`ParseChallonge`) is deliberately not ported.
|
||||
|
||||
#### Google Sheets export
|
||||
|
||||
Next to **Export CSV** on the *Ranking* view: **Push to Google Sheets** writes that same
|
||||
table straight into the club's spreadsheet, as one tab. It replaces the
|
||||
download-then-import-by-hand step — the tab is the CSV, because `rankingToSheetTable`
|
||||
reads the very same `RankingTable` that `buildCsv` does, so the two cannot drift.
|
||||
|
||||
Because `GetResults` merges everything it is given, selecting `Ranking #1301`, `#1302` and
|
||||
`#1303` produces the cumulative table for that ranking day — which is why the tab title
|
||||
defaults to the **latest** event in the selection (`Ranking #1303`). Override it in the
|
||||
**Tab** box; leaving it blank uses the suggestion shown as the placeholder.
|
||||
|
||||
- The tab is **cleared and rewritten in place**. Formatting, notes and conditional
|
||||
formatting survive (the write only sets `userEnteredValue`), but anything typed into it
|
||||
by hand is lost — keep hand analysis in its own tab.
|
||||
- Tabs the export does not name are **never touched or deleted**.
|
||||
- Re-running changes nothing but the provenance footer's timestamp.
|
||||
- Points and totals are written as **numbers**, so formulas over them keep working.
|
||||
- Titles are sanitised server-side (Google forbids `: \ / ? * [ ]`, caps at 100 chars);
|
||||
any rename is reported back in the success banner.
|
||||
- Tab identity is the **title**, so renaming an event makes the next push write a new tab
|
||||
beside the old one.
|
||||
- The provenance footer, below the grid, names every event scored into the tab — so a
|
||||
stale tab says so itself.
|
||||
|
||||
Server configuration lives in the `GoogleSheets` section — writer, target spreadsheet and
|
||||
limits. The target is config-only because the sheet is replaced each year; see
|
||||
`.env.example` for the one-time Google setup and the yearly swap. Set
|
||||
`GoogleSheets:Writer` to `Logging` to see the exact payload in the API log without
|
||||
touching a spreadsheet.
|
||||
|
||||
### `/statistiques/rankings`
|
||||
|
||||
**Rankings Statistiques.** Pick a scope — everything, the last 6/12 events, or a regex over event names — then
|
||||
**Compute statistics**. Two independent sources feed the page, and they are kept apart
|
||||
on purpose:
|
||||
|
||||
- **Points, placements, attendance** come from `POST /api/Tournament/GetResults`, called
|
||||
**one event at a time**. The endpoint merges everything it is given and never says
|
||||
which event a row came from, so per-event calls are the only way to get a time series
|
||||
— and they contain the damage, because it throws a 500 on any bracket missing a rank-1
|
||||
or rank-2 row. One broken import is reported as a skipped event instead of taking the
|
||||
whole scope down with it.
|
||||
- **Set-level win rates and head to head** come from `POST /api/Statistics/Matches`, in
|
||||
a single call for the whole scope.
|
||||
|
||||
The **Matches** tab leads with its coverage line, and it matters: brackets imported
|
||||
before set rows were persisted contribute placements but no matches, so those figures
|
||||
can describe a fraction of the scope while the standings above cover all of it. Win
|
||||
rates count **decided sets only**, so a player with no resolvable set is left out rather
|
||||
than shown at 0%.
|
||||
|
||||
Other things worth knowing:
|
||||
|
||||
- Rank `999` is the service's "unplaced" sentinel (the participation bucket in
|
||||
`ExternalProviderService`), so it never counts as a placement or a podium, and
|
||||
**Best** shows `—`.
|
||||
- Player names are merged case-insensitively, as everywhere else in the app.
|
||||
- Brackets are counted per event, so a bracket name reused every month counts once
|
||||
per event rather than once overall.
|
||||
- Undated events sort last in the chart and the Events tab — `GET /api/Event` returns
|
||||
newest first, which is the least misleading place to put them.
|
||||
- `aggregate.ts` is pure, so it can be exercised under plain Node with fixtures, the
|
||||
same way `src/lib/tournaments/results.ts` is.
|
||||
|
||||
### `/statistiques/players`
|
||||
|
||||
**Players Statistiques.** Pick two players and the page answers one question: how often
|
||||
did they meet, and in which games. There is no event scope — the point of a pairing is
|
||||
its whole history, and slicing it by season would only be the Rankings page again.
|
||||
|
||||
- The pickers come from `GET /api/Statistics/Players`, which lists **tournament
|
||||
players** (the rows `set` points at), not the application accounts of `/users`. Only
|
||||
players with at least one set in a bracket whose game is known are offered, so the
|
||||
list can never suggest a player the breakdown must then report as empty.
|
||||
- The breakdown comes from `GET /api/Statistics/Versus/{a}/{b}`, one request per
|
||||
complete pairing, aborted and re-issued when either side changes. `winsA` is always
|
||||
the first id's side.
|
||||
- **A set carries no game.** The game belongs to the `Tournament` the set was played
|
||||
in, and `Tournament.GameId` is nullable, so meetings in a bracket with no game cannot
|
||||
be filed under one. The API excludes them from `games` and from the totals and counts
|
||||
them in `unknownGameSets`; the page reports that number instead of hiding it — it is
|
||||
the difference between "they never met" and "we cannot tell what they played".
|
||||
- **Meetings** counts every recorded set, **Decided** only those with unequal scores.
|
||||
A set with equal scores (including 0-0, and the `-1 / -1` a double DQ leaves behind)
|
||||
happened, but nobody won it, so it is in neither record nor game counts. A single DQ
|
||||
is stored as `-1` and clamps to zero games.
|
||||
- `StatisticsService.AggregateVersus` is a pure static method over already-loaded rows,
|
||||
like `Aggregate` beside it, so the win inference is testable without a database.
|
||||
|
||||
### `/games`
|
||||
|
||||
Ports `GamesView`: the list on the left (ordered by `Order`), an editor on the right.
|
||||
|
||||
- **Save** — `POST /api/Game`. `AddOrUpdate` inserts when `id` is 0 and otherwise
|
||||
**replaces every column**, so the form always sends a complete `GameDTO`; blank
|
||||
text fields are sent as `null`.
|
||||
- **New game** — starts an empty draft with `id` 0 and the next free `Order`. The
|
||||
desktop app instead posted a blank row immediately and let you fill it in after.
|
||||
- **Delete** — `DELETE /api/Game/{id}`, behind a confirm.
|
||||
- **Find on start.gg** — `GET /api/Game/smash/{name}` searches start.gg's videogame
|
||||
catalogue using the long name (falling back to the name); picking a match fills
|
||||
`smashId`. The ids listed are **start.gg** videogame ids, not LaDOSE game ids.
|
||||
|
||||
`smashId` is what bracket imports match on: a game without one collects its results
|
||||
under a synthetic "GAME NOT FOUND" entry.
|
||||
|
||||
Unlike the desktop form, `imgUrl` is editable here — it is part of `GameDTO` and was
|
||||
otherwise only reachable through the database.
|
||||
|
||||
### `/users` (Admin only)
|
||||
|
||||
Lists every account with its roles, creates accounts, and deletes them.
|
||||
|
||||
`POST /Users/register` used to be `[AllowAnonymous]` so that the first account could be
|
||||
created. That route is gone; account creation is now `POST /Users/AddUser` and requires
|
||||
the **Admin** role, so before this
|
||||
page is reachable at all, one account has to be promoted directly in the database:
|
||||
|
||||
```bash
|
||||
# edit the username in the file first, then:
|
||||
psql "$LADOSE_DB" -v ON_ERROR_STOP=1 -f ../../Sql/2026-08-05_roles.sql
|
||||
```
|
||||
|
||||
How roles work:
|
||||
|
||||
- They live in the pre-existing `applicationrole` / `applicationuserrole` tables. The
|
||||
script seeds `Admin` and `User`; no schema change was needed.
|
||||
- Only user management checks a role. **Every other endpoint is unchanged** — a plain
|
||||
or role-less account can still use tournaments, games and the rest.
|
||||
- The JWT carries only the user id. Roles are read from the database on every request
|
||||
(`OnTokenValidated` in `Startup.cs`), so granting or revoking Admin applies to the
|
||||
caller's next request instead of whenever their 16-minute token expires.
|
||||
- `session.isAdmin` hides the link and the page, but that is cosmetic — the API is
|
||||
what enforces access, and a non-admin calling these endpoints gets a 403.
|
||||
- The API refuses to delete the caller's own account. Since only an admin can reach the
|
||||
endpoint, that is what guarantees at least one admin always remains.
|
||||
|
||||
## Notes
|
||||
|
||||
- The API issues 16-minute tokens. A lapsed token is treated as signed out on load;
|
||||
there is no refresh flow yet, so long sessions will need a re-login. The guarded
|
||||
pages turn a 401 into a redirect back to `/login` (see `toErrorMessage`).
|
||||
- The API has no exception middleware, so an unhandled server error arrives as an
|
||||
HTML developer page. `ApiError` then carries only the status, which is why each
|
||||
call site supplies its own fallback message.
|
||||
- `GetResults` only fills `slug` when **one** event id is requested, so the
|
||||
"Voir le Bracket" links appear only for a single-event export.
|
||||
- The Sheets export posts a 7-deep body, so `Startup.cs` sets Newtonsoft's `MaxDepth` to
|
||||
32. `MaxDepth` governs *reading*; at the previous value of 4 the request was rejected
|
||||
before it reached the controller.
|
||||
- Player names are merged case-insensitively across brackets, matching the desktop app.
|
||||
- `src/routes/+layout.ts` sets `ssr = false`: the JWT lives in the browser, so there
|
||||
is nothing meaningful to render on the server.
|
||||
|
||||
## Checks
|
||||
|
||||
```bash
|
||||
npm run check # svelte-check (types + template diagnostics)
|
||||
npm run lint # eslint, including type-aware rules
|
||||
npm run test # vitest over the pure modules in $lib
|
||||
npm run build # static build into ./build
|
||||
```
|
||||
|
||||
`npm run test` covers `$lib/statistics`, `$lib/tournaments`, `$lib/games/draft` and the
|
||||
small shared helpers beside them (`csv`, `events`, `format`). Those modules are pure by
|
||||
design, so they run under plain Node with no API and no database.
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# Vite inlines import.meta.env.VITE_* at build time, so runtime configuration cannot
|
||||
# come from an env var the app reads directly. Instead we write /config.js here, which
|
||||
# src/app.html loads synchronously ahead of the bundle. One image, any environment.
|
||||
set -eu
|
||||
|
||||
config_file=/usr/share/nginx/html/config.js
|
||||
|
||||
# Drop control characters (newlines included) so the value cannot break out of the
|
||||
# string literal, then escape backslashes before double quotes. A URL containing
|
||||
# & ? " or a trailing slash stays inert data.
|
||||
value=$(printf '%s' "${LADOSE_API_BASE_URL:-}" | tr -d '\001-\037')
|
||||
|
||||
if [ -z "$value" ]; then
|
||||
# Empty object, not an empty string: lets the app keep its baked-in default.
|
||||
printf 'window.__LADOSE_CONFIG__ = {};\n' >"$config_file"
|
||||
echo "config.js: LADOSE_API_BASE_URL unset, deferring to the built-in default" >&2
|
||||
else
|
||||
escaped=$(printf '%s' "$value" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
|
||||
printf 'window.__LADOSE_CONFIG__ = { apiBaseUrl: "%s" };\n' "$escaped" >"$config_file"
|
||||
echo "config.js: apiBaseUrl=$value" >&2
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,81 @@
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
|
||||
/**
|
||||
* Lint only. Formatting is deliberately not enforced here — there is no Prettier in
|
||||
* this project, so any stylistic rule would fight the existing hand-kept style.
|
||||
*
|
||||
* Type-aware linting is on (`projectService`), which is what makes
|
||||
* `no-floating-promises` able to see that an `async` handler's promise is dropped.
|
||||
*/
|
||||
export default ts.config(
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommendedTypeChecked,
|
||||
...svelte.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser },
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
tsconfigRootDir: import.meta.dirname
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: ts.parser
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
// The app ships no logging of its own; `hooks.client.ts` is the one exception
|
||||
// and opts in explicitly below.
|
||||
'no-console': 'error',
|
||||
/*
|
||||
* Off: this rule wants every href and goto() wrapped in `resolve()`, which
|
||||
* matters only for an app served under a base path. This one is served from
|
||||
* the root (see nginx.conf) and sets no `base`, so it would be 14 wrappers
|
||||
* buying nothing. Revisit if the app ever moves under a sub-path.
|
||||
*/
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
/*
|
||||
* The regex placeholders on the event pickers need a literal `{` in an
|
||||
* attribute, which in Svelte can only be written as a mustache.
|
||||
*/
|
||||
'svelte/no-useless-mustaches': ['error', { ignoreStringEscape: true }],
|
||||
// This is the rule that catches an `async` function used directly as an
|
||||
// event handler, where a rejection becomes an unhandled rejection.
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['src/hooks.client.ts'],
|
||||
rules: { 'no-console': 'off' }
|
||||
},
|
||||
{
|
||||
// This file and the Vite config are build tooling, outside the app's tsconfig
|
||||
// project, so type-aware rules cannot resolve them.
|
||||
files: ['eslint.config.js', 'vite.config.ts'],
|
||||
...ts.configs.disableTypeChecked
|
||||
},
|
||||
{
|
||||
// Generated from openapi.json by `npm run api:types`; not ours to lint.
|
||||
ignores: [
|
||||
'src/lib/api/schema.d.ts',
|
||||
'build/',
|
||||
'.svelte-kit/',
|
||||
'node_modules/',
|
||||
'static/config.js'
|
||||
]
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
# Copied to /etc/nginx/conf.d/default.conf, replacing the image's default server.
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
server_tokens off;
|
||||
|
||||
# The build emits no .gz/.br, so compress on the fly.
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
application/javascript
|
||||
application/json
|
||||
application/manifest+json
|
||||
application/wasm
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/javascript
|
||||
text/plain
|
||||
text/xml;
|
||||
|
||||
# Rewritten by docker-entrypoint.sh on every container start. Exact match, so the
|
||||
# _app/immutable rule below can never apply to it.
|
||||
location = /config.js {
|
||||
add_header Cache-Control "no-store" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Content-hashed filenames: safe forever.
|
||||
location /_app/immutable/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Deploy marker the client polls. Stale copies mean updates are never noticed.
|
||||
location = /_app/version.json {
|
||||
add_header Cache-Control "no-cache" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# The shell. Same reason as version.json: it names the current immutable bundle.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache" always;
|
||||
}
|
||||
|
||||
# Nothing is prerendered (+layout.ts sets ssr = false, prerender = false) and the
|
||||
# adapter's fallback is index.html, so every unknown path belongs to the client
|
||||
# router. Without this, /login, /games, /tournaments, /users and /statistiques 404
|
||||
# on deep-link or hard refresh. The internal redirect re-enters `location =
|
||||
# /index.html`, so fallback responses pick up no-cache too.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+4138
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "ladose.webapp",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"api:fetch": "curl -fsS ${LADOSE_API_URL:-http://localhost:5000}/openapi/v1.json -o openapi.json",
|
||||
"api:types": "openapi-typescript openapi.json -o src/lib/api/schema.d.ts",
|
||||
"api:sync": "npm run api:fetch && npm run api:types",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-svelte": "^3.22.0",
|
||||
"globals": "^17.9.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19 || ^22.12 || >=24"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/*
|
||||
* Colour is expressed twice over:
|
||||
*
|
||||
* 1. the LaDOSE brand ramp below — fixed values, theme-independent;
|
||||
* 2. semantic tokens (canvas, surface, ink, accent, …) that every component
|
||||
* actually uses, and that swap wholesale between light and dark.
|
||||
*
|
||||
* Components must only ever reach for the semantic layer. Adding `dark:` next to
|
||||
* a hundred hard-coded shades would double every future edit; one set of tokens
|
||||
* that changes underneath them does not.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
--color-ladose-50: #f2f6ff;
|
||||
--color-ladose-100: #e4ebff;
|
||||
--color-ladose-200: #c3d4ff;
|
||||
--color-ladose-300: #9db4ff;
|
||||
--color-ladose-400: #6f8fff;
|
||||
--color-ladose-500: #4b6cff;
|
||||
--color-ladose-600: #3450e6;
|
||||
--color-ladose-700: #2a3fb8;
|
||||
--color-ladose-800: #1e2d80;
|
||||
--color-ladose-900: #131a3d;
|
||||
--color-ladose-950: #0b1027;
|
||||
}
|
||||
|
||||
/*
|
||||
* `inline` is load-bearing. Without it Tailwind emits `--color-surface:
|
||||
* var(--app-surface)` into :root and the utility resolves through that copy, so
|
||||
* redefining --app-surface in a more specific selector does nothing. `inline`
|
||||
* substitutes the reference straight into the utility, which is what makes
|
||||
* runtime theme switching work at all in Tailwind v4.
|
||||
*/
|
||||
@theme inline {
|
||||
--color-canvas: var(--app-canvas);
|
||||
--color-surface: var(--app-surface);
|
||||
--color-overlay: var(--app-overlay);
|
||||
--color-inset: var(--app-inset);
|
||||
--color-line: var(--app-line);
|
||||
--color-line-strong: var(--app-line-strong);
|
||||
--color-ink: var(--app-ink);
|
||||
--color-muted: var(--app-muted);
|
||||
--color-subtle: var(--app-subtle);
|
||||
--color-accent: var(--app-accent);
|
||||
--color-accent-hover: var(--app-accent-hover);
|
||||
--color-on-accent: var(--app-on-accent);
|
||||
--color-danger: var(--app-danger);
|
||||
--color-danger-soft: var(--app-danger-soft);
|
||||
--color-success: var(--app-success);
|
||||
--color-success-soft: var(--app-success-soft);
|
||||
--color-warning: var(--app-warning);
|
||||
--color-warning-soft: var(--app-warning-soft);
|
||||
|
||||
--shadow-card: var(--app-shadow);
|
||||
}
|
||||
|
||||
/*
|
||||
* Palette values live here once each. The three blocks that follow only
|
||||
* re-point the --app-* aliases, so a colour is never written twice.
|
||||
*/
|
||||
:root {
|
||||
--light-canvas: #f6f7fb;
|
||||
--light-surface: #ffffff;
|
||||
--light-overlay: #ffffff;
|
||||
--light-inset: #f1f3f9;
|
||||
--light-line: #dfe3ee;
|
||||
--light-line-strong: #c6cddf;
|
||||
--light-ink: #101430;
|
||||
--light-muted: #4c5470;
|
||||
--light-subtle: #767e99;
|
||||
--light-accent: var(--color-ladose-600);
|
||||
--light-accent-hover: var(--color-ladose-700);
|
||||
--light-on-accent: #ffffff;
|
||||
--light-danger: #b42318;
|
||||
--light-danger-soft: rgb(180 35 24 / 0.09);
|
||||
--light-success: #067647;
|
||||
--light-success-soft: rgb(6 118 71 / 0.09);
|
||||
--light-warning: #b54708;
|
||||
--light-warning-soft: rgb(181 71 8 / 0.09);
|
||||
--light-shadow: 0 1px 2px rgb(16 24 40 / 0.06), 0 10px 28px rgb(16 24 40 / 0.08);
|
||||
--light-grad-from: #ffffff;
|
||||
--light-grad-to: #eaeef8;
|
||||
|
||||
/* Translucent surfaces on purpose: they sit over the canvas gradient and
|
||||
keep the depth the app had before tokens existed. */
|
||||
--dark-canvas: var(--color-ladose-950);
|
||||
--dark-surface: rgb(255 255 255 / 0.05);
|
||||
/* Menus and popovers sit over content, so they need to be opaque. */
|
||||
--dark-overlay: #171e3d;
|
||||
--dark-inset: rgb(11 16 39 / 0.6);
|
||||
--dark-line: rgb(255 255 255 / 0.1);
|
||||
--dark-line-strong: rgb(255 255 255 / 0.16);
|
||||
--dark-ink: var(--color-ladose-50);
|
||||
--dark-muted: rgb(195 212 255 / 0.7);
|
||||
--dark-subtle: rgb(195 212 255 / 0.45);
|
||||
--dark-accent: var(--color-ladose-500);
|
||||
--dark-accent-hover: var(--color-ladose-600);
|
||||
--dark-on-accent: #ffffff;
|
||||
--dark-danger: #fca5a5;
|
||||
--dark-danger-soft: rgb(239 68 68 / 0.12);
|
||||
--dark-success: #6ee7b7;
|
||||
--dark-success-soft: rgb(16 185 129 / 0.12);
|
||||
--dark-warning: #fcd34d;
|
||||
--dark-warning-soft: rgb(245 158 11 / 0.12);
|
||||
--dark-shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 16px 40px rgb(0 0 0 / 0.35);
|
||||
--dark-grad-from: var(--color-ladose-900);
|
||||
--dark-grad-to: var(--color-ladose-950);
|
||||
}
|
||||
|
||||
/* Layer 1 — light is the unconditional default. */
|
||||
:root {
|
||||
--app-color-scheme: light;
|
||||
--app-canvas: var(--light-canvas);
|
||||
--app-surface: var(--light-surface);
|
||||
--app-overlay: var(--light-overlay);
|
||||
--app-inset: var(--light-inset);
|
||||
--app-line: var(--light-line);
|
||||
--app-line-strong: var(--light-line-strong);
|
||||
--app-ink: var(--light-ink);
|
||||
--app-muted: var(--light-muted);
|
||||
--app-subtle: var(--light-subtle);
|
||||
--app-accent: var(--light-accent);
|
||||
--app-accent-hover: var(--light-accent-hover);
|
||||
--app-on-accent: var(--light-on-accent);
|
||||
--app-danger: var(--light-danger);
|
||||
--app-danger-soft: var(--light-danger-soft);
|
||||
--app-success: var(--light-success);
|
||||
--app-success-soft: var(--light-success-soft);
|
||||
--app-warning: var(--light-warning);
|
||||
--app-warning-soft: var(--light-warning-soft);
|
||||
--app-shadow: var(--light-shadow);
|
||||
--app-grad-from: var(--light-grad-from);
|
||||
--app-grad-to: var(--light-grad-to);
|
||||
}
|
||||
|
||||
/*
|
||||
* Layer 2 — follow the OS to dark, unless the user explicitly chose light.
|
||||
* The :not() is what lets an explicit light choice win on a dark machine: a bare
|
||||
* :root in layer 1 could never outrank a later media block on its own.
|
||||
*/
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
--app-color-scheme: dark;
|
||||
--app-canvas: var(--dark-canvas);
|
||||
--app-surface: var(--dark-surface);
|
||||
--app-overlay: var(--dark-overlay);
|
||||
--app-inset: var(--dark-inset);
|
||||
--app-line: var(--dark-line);
|
||||
--app-line-strong: var(--dark-line-strong);
|
||||
--app-ink: var(--dark-ink);
|
||||
--app-muted: var(--dark-muted);
|
||||
--app-subtle: var(--dark-subtle);
|
||||
--app-accent: var(--dark-accent);
|
||||
--app-accent-hover: var(--dark-accent-hover);
|
||||
--app-on-accent: var(--dark-on-accent);
|
||||
--app-danger: var(--dark-danger);
|
||||
--app-danger-soft: var(--dark-danger-soft);
|
||||
--app-success: var(--dark-success);
|
||||
--app-success-soft: var(--dark-success-soft);
|
||||
--app-warning: var(--dark-warning);
|
||||
--app-warning-soft: var(--dark-warning-soft);
|
||||
--app-shadow: var(--dark-shadow);
|
||||
--app-grad-from: var(--dark-grad-from);
|
||||
--app-grad-to: var(--dark-grad-to);
|
||||
}
|
||||
}
|
||||
|
||||
/* Layer 3 — an explicit dark choice beats an OS light preference. */
|
||||
:root[data-theme='dark'] {
|
||||
--app-color-scheme: dark;
|
||||
--app-canvas: var(--dark-canvas);
|
||||
--app-surface: var(--dark-surface);
|
||||
--app-overlay: var(--dark-overlay);
|
||||
--app-inset: var(--dark-inset);
|
||||
--app-line: var(--dark-line);
|
||||
--app-line-strong: var(--dark-line-strong);
|
||||
--app-ink: var(--dark-ink);
|
||||
--app-muted: var(--dark-muted);
|
||||
--app-subtle: var(--dark-subtle);
|
||||
--app-accent: var(--dark-accent);
|
||||
--app-accent-hover: var(--dark-accent-hover);
|
||||
--app-on-accent: var(--dark-on-accent);
|
||||
--app-danger: var(--dark-danger);
|
||||
--app-danger-soft: var(--dark-danger-soft);
|
||||
--app-success: var(--dark-success);
|
||||
--app-success-soft: var(--dark-success-soft);
|
||||
--app-warning: var(--dark-warning);
|
||||
--app-warning-soft: var(--dark-warning-soft);
|
||||
--app-shadow: var(--dark-shadow);
|
||||
--app-grad-from: var(--dark-grad-from);
|
||||
--app-grad-to: var(--dark-grad-to);
|
||||
}
|
||||
|
||||
/*
|
||||
* The canvas has to be painted by CSS on html/body, not by a class on a Svelte
|
||||
* element: ssr is off, so that element does not exist until the bundle hydrates.
|
||||
* Anything that relies on hydration to paint the background flashes white on
|
||||
* every cold load of every route.
|
||||
*/
|
||||
html {
|
||||
color-scheme: var(--app-color-scheme);
|
||||
background-color: var(--app-canvas);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100svh;
|
||||
background-image: radial-gradient(ellipse at top, var(--app-grad-from), var(--app-grad-to));
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
color: var(--app-ink);
|
||||
}
|
||||
|
||||
/* Height of the fixed navbar, so pages that centre themselves can subtract it. */
|
||||
:root {
|
||||
--nav-h: 3.5rem;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build-time configuration Vite inlines into the bundle. Declared so the reads in
|
||||
* `$lib/api/client` are typed rather than `any` — see `resolveBaseUrl` for how
|
||||
* this relates to the runtime `window.__LADOSE_CONFIG__` tier.
|
||||
*/
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
/** Shape of the object `static/config.js` defines, rewritten at container start. */
|
||||
interface Window {
|
||||
__LADOSE_CONFIG__?: { apiBaseUrl?: string };
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
<!--
|
||||
Runtime configuration, written by the container entrypoint from
|
||||
LADOSE_API_BASE_URL. Synchronous and ahead of the app bundle so
|
||||
src/lib/api/client.ts can read it at module scope. Absent in `vite dev`,
|
||||
which is why the client falls back to VITE_API_BASE_URL.
|
||||
-->
|
||||
<script src="%sveltekit.assets%/config.js"></script>
|
||||
<!--
|
||||
Theme, ahead of the head placeholder below that emits the render-blocking
|
||||
stylesheet, so data-theme is already on <html> when its selectors are first
|
||||
matched. Do not name that placeholder literally anywhere above it: SvelteKit
|
||||
substitutes the first textual occurrence, so a mention in prose swallows the
|
||||
real one and the built page ships with no stylesheet.
|
||||
Must stay a plain synchronous inline script: type=module, defer, async or
|
||||
an external file all run after first paint, which is the flash we are
|
||||
avoiding. Only ever writes 'light' or 'dark' — the CSS keys off those two
|
||||
exact values, so a literal data-theme="system" would match neither.
|
||||
-->
|
||||
<script>
|
||||
try {
|
||||
var t = localStorage.getItem('ladose.theme');
|
||||
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
|
||||
} catch (e) {
|
||||
/* localStorage throws in Safari private mode and with cookies blocked */
|
||||
}
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { HandleClientError } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Last resort for errors no page caught — an uncaught error in an `$effect`, or a
|
||||
* failed `load`. Without this they surface nowhere at all: the app logs nothing
|
||||
* anywhere else, so a blank panel is the only symptom a user could report.
|
||||
*
|
||||
* There is no telemetry backend to ship these to, so the console is the whole
|
||||
* story. It is the one place in the app where `console.error` is intentional.
|
||||
*/
|
||||
export const handleError: HandleClientError = ({ error, status, message }) => {
|
||||
if (status !== 404) console.error('[LaDOSE]', error);
|
||||
|
||||
// What `+error.svelte` renders. Deliberately generic: `error` can carry API
|
||||
// internals, and the pages already surface anything the user can act on.
|
||||
return {
|
||||
message: status === 404 ? message : 'An unexpected error occurred.'
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { apiRequest, buildPath, type RequestOptions } from './client';
|
||||
import type { ApplicationUserDTO, NewUserRequest } from './schema-helpers';
|
||||
|
||||
/**
|
||||
* User administration. Every endpoint here is `[Authorize(Roles = "Admin")]`, so a
|
||||
* non-admin gets a 403 — the UI hides the page, but the API is what enforces it.
|
||||
*
|
||||
* `login` and the session live in `users.ts`; this module is only the admin screen.
|
||||
*/
|
||||
/** GET /Users — every account, ordered by username. Never includes password material. */
|
||||
export function listUsers(options: RequestOptions = {}): Promise<ApplicationUserDTO[]> {
|
||||
return apiRequest<ApplicationUserDTO[]>('/Users', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /Users/Roles — the role names that exist in `applicationrole`. Assigning a name
|
||||
* that is not in this list is rejected by the API rather than creating a new role.
|
||||
*/
|
||||
export function listRoles(options: RequestOptions = {}): Promise<string[]> {
|
||||
return apiRequest<string[]>('/Users/Roles', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /Users/AddUser — replaces the old anonymous `register`. Returns the created
|
||||
* user. A duplicate username or an unknown role name comes back as a 400 carrying
|
||||
* the reason in `message`.
|
||||
*/
|
||||
export function addUser(
|
||||
user: NewUserRequest,
|
||||
options: RequestOptions = {}
|
||||
): Promise<ApplicationUserDTO> {
|
||||
return apiRequest<ApplicationUserDTO>('/Users/AddUser', {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: user
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /Users/{id} — 204 on success. The API refuses to delete the caller's own
|
||||
* account (400), which is what guarantees an admin always remains.
|
||||
*/
|
||||
export async function deleteUser(id: number, options: RequestOptions = {}): Promise<void> {
|
||||
await apiRequest<void>(buildPath('/Users/{id}', { id }), {
|
||||
...options,
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import type { ApiPath } from './schema-helpers';
|
||||
|
||||
/**
|
||||
* Base URL of LaDOSE.Api, resolved in this order:
|
||||
*
|
||||
* 1. `window.__LADOSE_CONFIG__.apiBaseUrl` — written by the container entrypoint
|
||||
* from `LADOSE_API_BASE_URL` (see Dockerfile). This is what lets one image
|
||||
* serve several environments: Vite inlines `import.meta.env` at *build* time,
|
||||
* so without it the URL would be frozen into the bundle.
|
||||
* 2. `VITE_API_BASE_URL` — baked at build time, for `vite dev` and for anyone
|
||||
* who prefers one image per environment.
|
||||
* 3. the Kestrel binding the API uses in development (LaDOSE.Api/appsettings.json).
|
||||
*/
|
||||
function resolveBaseUrl(): string {
|
||||
// `typeof window` rather than a browser guard: this runs at module scope, and
|
||||
// the static build still evaluates this module while generating the shell.
|
||||
const runtime =
|
||||
typeof window !== 'undefined' ? window.__LADOSE_CONFIG__?.apiBaseUrl : undefined;
|
||||
|
||||
// Vite replaces this with a string literal at build time, or leaves it undefined
|
||||
// when the variable was not set (see ImportMetaEnv in app.d.ts).
|
||||
const baked = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
const configured = runtime?.trim() || baked || 'http://localhost:5000';
|
||||
return configured.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export const API_BASE_URL = resolveBaseUrl();
|
||||
|
||||
/** Thrown for any non-2xx response, carrying the API's message when it sent one. */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The shape LaDOSE.Api uses for its error payloads: `new { message = "..." }`. */
|
||||
function extractMessage(body: unknown, status: number): string {
|
||||
if (body && typeof body === 'object' && 'message' in body) {
|
||||
const { message } = body as { message?: unknown };
|
||||
if (typeof message === 'string' && message.length > 0) return message;
|
||||
}
|
||||
return `Request failed with status ${status}`;
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'DELETE';
|
||||
body?: unknown;
|
||||
/**
|
||||
* JWT sent as `Authorization: Bearer ...`.
|
||||
*
|
||||
* Left out, the session store's token is used — almost every endpoint on
|
||||
* LaDOSE.Api is `[Authorize]`, so authenticated is the useful default. Pass
|
||||
* `null` to send the request unauthenticated (`POST /Users/auth` is the only
|
||||
* caller that needs to), or a string to override the stored token.
|
||||
*/
|
||||
token?: string | null;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls LaDOSE.Api. `path` is constrained to the paths in the generated OpenAPI
|
||||
* types, so a typo or a route removed on the server is a compile error.
|
||||
* Templated paths (e.g. `/api/Game/{id}`) are built with `buildPath`, whose
|
||||
* branded return type is the only other thing accepted here — widening this to
|
||||
* `string` would silently re-admit routes the server no longer serves.
|
||||
*/
|
||||
export async function apiRequest<TResponse>(
|
||||
path: ApiPath | BuiltPath,
|
||||
options: RequestOptions = {}
|
||||
): Promise<TResponse> {
|
||||
const { method = 'GET', body, fetch: fetchImpl = globalThis.fetch, signal } = options;
|
||||
const token = options.token === undefined ? session.token : options.token;
|
||||
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(`${API_BASE_URL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal
|
||||
});
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause;
|
||||
// fetch only rejects on transport failures — the API being down, DNS, CORS.
|
||||
const error = new ApiError(0, `Could not reach LaDOSE.Api at ${API_BASE_URL}`);
|
||||
error.cause = cause;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isJson = response.headers.get('content-type')?.includes('json') ?? false;
|
||||
const payload: unknown = isJson ? await response.json().catch(() => null) : null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, extractMessage(payload, response.status));
|
||||
}
|
||||
|
||||
return payload as TResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* A path already filled in by `buildPath`. Branded so `apiRequest` can accept it
|
||||
* without accepting `string`, which would defeat the `ApiPath` constraint.
|
||||
*/
|
||||
export type BuiltPath = string & { readonly __apiPath: unique symbol };
|
||||
|
||||
/** Fills a templated OpenAPI path, e.g. buildPath('/api/Game/{id}', { id: 3 }). */
|
||||
export function buildPath(
|
||||
template: ApiPath,
|
||||
params: Record<string, string | number>
|
||||
): BuiltPath {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
const value = params[key];
|
||||
if (value === undefined) throw new Error(`Missing route parameter "${key}" for ${template}`);
|
||||
return encodeURIComponent(String(value));
|
||||
}) as BuiltPath;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { ApiError } from './client';
|
||||
|
||||
/**
|
||||
* Turns a failed call into something to show the user.
|
||||
*
|
||||
* A 401 means the 16-minute JWT lapsed mid-session: there is nothing useful to
|
||||
* display, so the session is dropped and the user is sent back to `/login`, and
|
||||
* this returns null.
|
||||
*/
|
||||
export function toErrorMessage(cause: unknown, fallback: string): string | null {
|
||||
if (cause instanceof ApiError && cause.status === 401) {
|
||||
session.clear();
|
||||
void goto('/login', { replaceState: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
// ApiError already carries the API's own `message`, or "unreachable" for status 0.
|
||||
return cause instanceof ApiError ? cause.message : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds `toErrorMessage` to a page's error state, so every page reports failures
|
||||
* the same way:
|
||||
*
|
||||
* const report = errorReporter((message) => (error = message));
|
||||
* ...
|
||||
* catch (cause) { report(cause, 'Could not load the accounts.'); }
|
||||
*
|
||||
* Pages used to split between a hand-copied `report` wrapper and calling
|
||||
* `toErrorMessage` inline — two conventions for one behaviour.
|
||||
*/
|
||||
export function errorReporter(
|
||||
set: (message: string | null) => void
|
||||
): (cause: unknown, fallback: string) => void {
|
||||
return (cause, fallback) => set(toErrorMessage(cause, fallback));
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/** GameController is `[Authorize]`; `apiRequest` supplies the session JWT. */
|
||||
import { apiRequest, buildPath, type RequestOptions } from './client';
|
||||
import type { GameDTO } from './schema-helpers';
|
||||
|
||||
/** GET /api/Game — every game, in database order (sort by `order` for display). */
|
||||
export function listGames(options: RequestOptions = {}): Promise<GameDTO[]> {
|
||||
return apiRequest<GameDTO[]>('/api/Game', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/Game — `AddOrUpdate`: an `id` of 0 inserts, anything else updates.
|
||||
* The update replaces every column, so send a full DTO rather than a patch.
|
||||
* Returns the saved game with its assigned id.
|
||||
*/
|
||||
export function saveGame(game: GameDTO, options: RequestOptions = {}): Promise<GameDTO> {
|
||||
return apiRequest<GameDTO>('/api/Game', { ...options, method: 'POST', body: game });
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/Game/{id} — 204 on success. The service swallows `DbUpdateException`,
|
||||
* so a game still referenced by tournaments comes back as a 404 rather than a 409.
|
||||
*/
|
||||
export async function deleteGame(id: number, options: RequestOptions = {}): Promise<void> {
|
||||
await apiRequest<void>(buildPath('/api/Game/{id}', { id }), {
|
||||
...options,
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/Game/smash/{name} — searches start.gg's videogame catalogue by name.
|
||||
* The `id` of each match is a **start.gg** videogame id, i.e. a candidate value for
|
||||
* `GameDTO.smashId`, not a LaDOSE game id.
|
||||
*/
|
||||
export function searchSmashGames(
|
||||
name: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<GameDTO[]> {
|
||||
return apiRequest<GameDTO[]>(buildPath('/api/Game/smash/{name}', { name }), options);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { components, paths } from './schema';
|
||||
|
||||
/**
|
||||
* Re-exports of the DTOs generated from LaDOSE.Api's OpenAPI document
|
||||
* (see `npm run api:types`). Import these instead of hand-writing shapes so the
|
||||
* client breaks at compile time when the C# DTOs change.
|
||||
*/
|
||||
export type Schemas = components['schemas'];
|
||||
|
||||
export type ApplicationUserDTO = Schemas['ApplicationUserDTO'];
|
||||
export type GameDTO = Schemas['GameDTO'];
|
||||
export type EventDTO = Schemas['EventDTO'];
|
||||
export type TodoDTO = Schemas['TodoDTO'];
|
||||
export type WPEventDTO = Schemas['WPEventDTO'];
|
||||
export type TournamentDTO = Schemas['TournamentDTO'];
|
||||
export type TournamentsResultDTO = Schemas['TournamentsResultDTO'];
|
||||
export type ResultDTO = Schemas['ResultDTO'];
|
||||
export type ParticipentDTO = Schemas['ParticipentDTO'];
|
||||
export type MatchStatsDTO = Schemas['MatchStatsDTO'];
|
||||
export type MatchCoverageDTO = Schemas['MatchCoverageDTO'];
|
||||
export type PlayerMatchStatsDTO = Schemas['PlayerMatchStatsDTO'];
|
||||
export type HeadToHeadDTO = Schemas['HeadToHeadDTO'];
|
||||
export type PlayerVersusDTO = Schemas['PlayerVersusDTO'];
|
||||
export type VersusGameStatsDTO = Schemas['VersusGameStatsDTO'];
|
||||
export type PlayerOptionDTO = Schemas['PlayerOptionDTO'];
|
||||
|
||||
export type SheetsConfigDTO = Schemas['SheetsConfigDTO'];
|
||||
export type SheetExportRequestDTO = Schemas['SheetExportRequestDTO'];
|
||||
export type SheetTableDTO = Schemas['SheetTableDTO'];
|
||||
export type SheetRowDTO = Schemas['SheetRowDTO'];
|
||||
export type SheetExportResultDTO = Schemas['SheetExportResultDTO'];
|
||||
export type SheetTabResultDTO = Schemas['SheetTabResultDTO'];
|
||||
|
||||
/** The body `UsersController.Authenticate` binds — only these two fields are read. */
|
||||
export type LoginRequest = Pick<ApplicationUserDTO, 'username' | 'password'>;
|
||||
|
||||
/** The body `UsersController.AddUser` binds. `roles` must name rows of `applicationrole`. */
|
||||
export type NewUserRequest = Pick<
|
||||
ApplicationUserDTO,
|
||||
'username' | 'password' | 'firstName' | 'lastName' | 'roles'
|
||||
>;
|
||||
|
||||
/**
|
||||
* A logged-in user is the auth response with the fields the API always fills in
|
||||
* narrowed to non-optional, so pages don't have to null-check `token`/`username`.
|
||||
*/
|
||||
export type AuthenticatedUser = ApplicationUserDTO & {
|
||||
username: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
/** Every path exposed by LaDOSE.Api, e.g. `'/Users/auth'` or `'/api/Game'`. */
|
||||
export type ApiPath = keyof paths;
|
||||
+1761
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
/** SheetsController is `[Authorize]`, like the rest of the API. */
|
||||
import { apiRequest, type RequestOptions } from './client';
|
||||
import type {
|
||||
SheetExportRequestDTO,
|
||||
SheetExportResultDTO,
|
||||
SheetsConfigDTO
|
||||
} from './schema-helpers';
|
||||
|
||||
/**
|
||||
* GET /api/Sheets/Config — whether the export is usable and which spreadsheet it points
|
||||
* at. The target is server configuration (it is reset each year), so the UI reads it
|
||||
* rather than offering it as an input. Carries no credentials.
|
||||
*/
|
||||
export function getSheetsConfig(options: RequestOptions = {}): Promise<SheetsConfigDTO> {
|
||||
return apiRequest<SheetsConfigDTO>('/api/Sheets/Config', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/Sheets/Export — writes one tab per table, in the order given.
|
||||
*
|
||||
* Existing tabs of the same name are cleared and rewritten in place, keeping their
|
||||
* formatting; tabs not named in the request are left untouched. Re-running the same export
|
||||
* is a no-op beyond the provenance timestamp.
|
||||
*/
|
||||
export function exportToSheets(
|
||||
request: SheetExportRequestDTO,
|
||||
options: RequestOptions = {}
|
||||
): Promise<SheetExportResultDTO> {
|
||||
return apiRequest<SheetExportResultDTO>('/api/Sheets/Export', {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: request
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** StatisticsController is `[Authorize]`, like the tournament endpoints. */
|
||||
import { apiRequest, buildPath, type RequestOptions } from './client';
|
||||
import type { MatchStatsDTO, PlayerOptionDTO, PlayerVersusDTO } from './schema-helpers';
|
||||
|
||||
/**
|
||||
* POST /api/Statistics/Matches — set-level statistics (win/loss, games, head to
|
||||
* head) for the given events, read from the `set` rows an import persisted.
|
||||
*
|
||||
* Unlike `GetResults` this takes every event in one call: the service tolerates
|
||||
* brackets with no set rows instead of throwing, and reports what it could see in
|
||||
* `coverage`. Always show that coverage — many older brackets were imported
|
||||
* without sets, so a low `bracketsWithSets` means these numbers describe only a
|
||||
* slice of the scope.
|
||||
*/
|
||||
export function getMatchStats(
|
||||
eventIds: number[],
|
||||
options: RequestOptions = {}
|
||||
): Promise<MatchStatsDTO> {
|
||||
return apiRequest<MatchStatsDTO>('/api/Statistics/Matches', {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: eventIds
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/Statistics/Players — the tournament players a versus lookup can report
|
||||
* on, alphabetically. **Not** the application accounts of `/api/Users`: these are
|
||||
* the players `set` rows point at, and `sets` counts their meetings in brackets
|
||||
* whose game is known — the same filter `getVersus` applies.
|
||||
*/
|
||||
export function listVersusPlayers(options: RequestOptions = {}): Promise<PlayerOptionDTO[]> {
|
||||
return apiRequest<PlayerOptionDTO[]>('/api/Statistics/Players', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/Statistics/Versus/{a}/{b} — every recorded meeting between two players,
|
||||
* over every event, split per game. `winsA` is always the first id's side.
|
||||
*
|
||||
* A set only knows its bracket, and a bracket's game is nullable, so meetings we
|
||||
* cannot attribute to a game are excluded from `games` and the totals and counted
|
||||
* in `unknownGameSets` instead. Show that number: it is the difference between
|
||||
* "they never met" and "we cannot tell what they played".
|
||||
*/
|
||||
export function getVersus(
|
||||
playerAId: number,
|
||||
playerBId: number,
|
||||
options: RequestOptions = {}
|
||||
): Promise<PlayerVersusDTO> {
|
||||
const path = buildPath('/api/Statistics/Versus/{playerAId}/{playerBId}', {
|
||||
playerAId,
|
||||
playerBId
|
||||
});
|
||||
return apiRequest<PlayerVersusDTO>(path, options);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* TournamentController and EventController are both `[Authorize]`. `apiRequest`
|
||||
* defaults the JWT to the session store's, so nothing here passes one explicitly.
|
||||
*/
|
||||
import { apiRequest, buildPath, type RequestOptions } from './client';
|
||||
import type { EventDTO, TournamentsResultDTO } from './schema-helpers';
|
||||
|
||||
/**
|
||||
* GET /api/Event — every imported event, newest first (the controller orders by
|
||||
* `Date` descending). A start.gg tournament becomes one Event holding one
|
||||
* Tournament per bracket.
|
||||
*/
|
||||
export function listEvents(options: RequestOptions = {}): Promise<EventDTO[]> {
|
||||
return apiRequest<EventDTO[]>('/api/Event', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/Tournament/ParseSmash/{slug} — pulls a start.gg tournament (brackets,
|
||||
* placements, sets) into the database. Returns false only for an empty slug;
|
||||
* re-importing an already-known slug is a no-op that still returns true.
|
||||
* The API throws (500) when the tournament has an unfinished bracket.
|
||||
*/
|
||||
export function importSmashTournament(
|
||||
slug: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<boolean> {
|
||||
const path = buildPath('/api/Tournament/ParseSmash/{tournamentSlug}', {
|
||||
tournamentSlug: slug
|
||||
});
|
||||
return apiRequest<boolean>(path, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/Tournament/GetResults — scores the given events with the point rules
|
||||
* in ExternalProviderService and returns the merged participants/games/results.
|
||||
* Pass one id for a single tournament or several to aggregate a ranking season.
|
||||
* Note: the API only fills `slug` when exactly one id is requested.
|
||||
*/
|
||||
export function getResults(
|
||||
eventIds: number[],
|
||||
options: RequestOptions = {}
|
||||
): Promise<TournamentsResultDTO> {
|
||||
return apiRequest<TournamentsResultDTO>('/api/Tournament/GetResults', {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: eventIds
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { apiRequest } from './client';
|
||||
import type { ApplicationUserDTO, AuthenticatedUser, LoginRequest } from './schema-helpers';
|
||||
|
||||
/**
|
||||
* POST /Users/auth — returns the user plus a JWT valid for 16 minutes
|
||||
* (see UsersController.Authenticate). Throws ApiError(400) on bad credentials.
|
||||
*/
|
||||
export async function login(credentials: LoginRequest): Promise<AuthenticatedUser> {
|
||||
const user = await apiRequest<ApplicationUserDTO>('/Users/auth', {
|
||||
method: 'POST',
|
||||
body: credentials,
|
||||
// The only unauthenticated endpoint: opt out of the session token that
|
||||
// `apiRequest` would otherwise attach, so signing in as a second user while
|
||||
// a stale session is still in memory sends only the credentials.
|
||||
token: null
|
||||
});
|
||||
|
||||
// The generated DTO marks every field optional because the C# properties are
|
||||
// nullable reference types; the auth path always populates these two.
|
||||
if (!user?.token || !user.username) {
|
||||
throw new Error('LaDOSE.Api returned an authentication response without a token.');
|
||||
}
|
||||
|
||||
return user as AuthenticatedUser;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,74 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
|
||||
/**
|
||||
* The client-side route guard, in one place.
|
||||
*
|
||||
* Every protected page used to carry its own copy of this effect plus a `started`
|
||||
* latch — five verbatim copies, which meant a new protected route shipped unguarded
|
||||
* whenever someone forgot to paste it.
|
||||
*
|
||||
* This is presentation only. The API authorises every request independently and
|
||||
* re-reads the caller's roles from the database, so a guard here never decides
|
||||
* access; it only avoids rendering a shell that can produce nothing but 401s
|
||||
* and 403s.
|
||||
*/
|
||||
|
||||
export interface Guard {
|
||||
/**
|
||||
* Whether the page may render. Gate the page's `<main>` on this: `goto` is
|
||||
* asynchronous, so without it an unauthenticated deep link paints the whole
|
||||
* page for a frame before the redirect lands.
|
||||
*/
|
||||
readonly ready: boolean;
|
||||
}
|
||||
|
||||
interface GuardOptions {
|
||||
/** Also require the Admin role, sending non-admins to the dashboard. */
|
||||
admin?: boolean;
|
||||
/** Run once, the first time the guard admits the user. For the initial fetch. */
|
||||
onReady?: () => void;
|
||||
}
|
||||
|
||||
function guard(options: GuardOptions = {}): Guard {
|
||||
const { admin = false, onReady } = options;
|
||||
|
||||
let ready = $state(false);
|
||||
let started = false;
|
||||
|
||||
$effect(() => {
|
||||
if (!session.isLoggedIn) {
|
||||
ready = false;
|
||||
void goto('/login', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (admin && !session.isAdmin) {
|
||||
ready = false;
|
||||
void goto('/', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
|
||||
ready = true;
|
||||
if (!started) {
|
||||
started = true;
|
||||
onReady?.();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
get ready() {
|
||||
return ready;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Requires a signed-in user. Call once, at the top level of a page component. */
|
||||
export function requireSession(onReady?: () => void): Guard {
|
||||
return guard({ onReady });
|
||||
}
|
||||
|
||||
/** Requires a signed-in user holding the Admin role. */
|
||||
export function requireAdmin(onReady?: () => void): Guard {
|
||||
return guard({ admin: true, onReady });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { quote, toCsv } from './csv';
|
||||
|
||||
describe('quote', () => {
|
||||
it('wraps every field, including numbers', () => {
|
||||
expect(quote('Bob')).toBe('"Bob"');
|
||||
expect(quote(12)).toBe('"12"');
|
||||
expect(quote('')).toBe('""');
|
||||
});
|
||||
|
||||
it('doubles inner quotes rather than escaping them', () => {
|
||||
expect(quote('He said "hi"')).toBe('"He said ""hi"""');
|
||||
});
|
||||
|
||||
it('leaves separators alone — quoting is what makes them safe', () => {
|
||||
expect(quote('Smash; Melee')).toBe('"Smash; Melee"');
|
||||
expect(quote('line\nbreak')).toBe('"line\nbreak"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCsv', () => {
|
||||
it('emits CRLF between rows and a trailing CRLF', () => {
|
||||
expect(toCsv(['A', 'B'], [['1', '2']])).toBe('"A";"B"\r\n"1";"2"\r\n');
|
||||
});
|
||||
|
||||
it('writes a header-only document when there are no rows', () => {
|
||||
expect(toCsv(['A'], [])).toBe('"A"\r\n');
|
||||
});
|
||||
|
||||
it('is semicolon separated, which is what Excel expects here', () => {
|
||||
expect(toCsv(['A', 'B'], []).includes(';')).toBe(true);
|
||||
expect(toCsv(['A', 'B'], []).includes(',')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* The one CSV dialect this app writes.
|
||||
*
|
||||
* Excel is picky: semicolon separated, every field quoted, inner quotes doubled,
|
||||
* CRLF line endings and a trailing newline. `buildCsv` (tournaments) and
|
||||
* `standingsCsv` (statistics) both used to carry their own copy of this — they
|
||||
* were character-identical, and a comment in one pointed at the other rather than
|
||||
* sharing it.
|
||||
*
|
||||
* Pure: exercisable under plain Node.
|
||||
*/
|
||||
|
||||
const SEPARATOR = ';';
|
||||
const NEWLINE = '\r\n';
|
||||
|
||||
/** One field, quoted, with inner quotes doubled. */
|
||||
export function quote(value: string | number): string {
|
||||
return `"${String(value).replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
/** A full document: header row, then one row per record, CRLF-terminated throughout. */
|
||||
export function toCsv(header: readonly (string | number)[], rows: readonly (string | number)[][]): string {
|
||||
const lines = [header.map(quote).join(SEPARATOR)];
|
||||
for (const row of rows) lines.push(row.map(quote).join(SEPARATOR));
|
||||
return lines.join(NEWLINE) + NEWLINE;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Client-side file download.
|
||||
*
|
||||
* The rankings and tournaments pages each carried their own copy of this. Both
|
||||
* clicked an anchor that was never in the document and revoked the object URL on
|
||||
* the very next line — browsers that hand the blob to a download manager
|
||||
* asynchronously can produce a silently empty file. This appends the anchor,
|
||||
* clicks it, removes it, and defers the revoke to the next task.
|
||||
*/
|
||||
|
||||
/** Triggers a download of `content` as `filename`. Browser only. */
|
||||
export function downloadText(filename: string, content: string, mimeType: string): void {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
// Revoking synchronously can cut the download off before the browser has read
|
||||
// the blob; one task later is enough and still frees the memory.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
/** `downloadText` with the CSV content type the exports use. */
|
||||
export function downloadCsv(filename: string, content: string): void {
|
||||
downloadText(filename, content, 'text/csv;charset=utf-8');
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { EventDTO } from '$lib/api/schema-helpers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compareByDate, eventIds, identified, matchingEventIds, toggleId } from './events';
|
||||
|
||||
function event(partial: Partial<EventDTO>): EventDTO {
|
||||
return { id: 1, name: 'Ranking #1301', date: '2026-01-10T00:00:00', ...partial };
|
||||
}
|
||||
|
||||
describe('identified / eventIds', () => {
|
||||
it('drops events the API gave no id', () => {
|
||||
const events = [event({ id: 1 }), event({ id: undefined }), event({ id: 3 })];
|
||||
expect(eventIds(events)).toEqual([1, 3]);
|
||||
expect(identified(events)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps id 0, which is a real id and not "missing"', () => {
|
||||
expect(eventIds([event({ id: 0 })])).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleId', () => {
|
||||
it('adds then removes', () => {
|
||||
expect(toggleId([], 3)).toEqual([3]);
|
||||
expect(toggleId([1, 3], 3)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('ignores undefined instead of pushing a hole into the selection', () => {
|
||||
expect(toggleId([1], undefined)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const selected = [1];
|
||||
toggleId(selected, 2);
|
||||
expect(selected).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchingEventIds', () => {
|
||||
const events = [
|
||||
event({ id: 1, name: 'Ranking #1301' }),
|
||||
event({ id: 2, name: 'Ranking #1302' }),
|
||||
event({ id: 3, name: 'Tournoi de Noël' }),
|
||||
event({ id: 4, name: undefined })
|
||||
];
|
||||
|
||||
it('selects by regular expression', () => {
|
||||
expect(matchingEventIds(events, String.raw`Ranking #13\d{2}`).ids).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('is case sensitive', () => {
|
||||
expect(matchingEventIds(events, 'ranking').ids).toEqual([]);
|
||||
});
|
||||
|
||||
it('never matches an event with no name', () => {
|
||||
expect(matchingEventIds(events, '').ids).toEqual([]);
|
||||
expect(matchingEventIds(events, '.*').ids).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('reports an invalid pattern instead of throwing', () => {
|
||||
const match = matchingEventIds(events, 'Ranking #13(');
|
||||
expect(match.ids).toEqual([]);
|
||||
expect(match.error).toContain('not a valid regular expression');
|
||||
});
|
||||
|
||||
it('treats a blank pattern as no request at all', () => {
|
||||
expect(matchingEventIds(events, ' ')).toEqual({ ids: [], error: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareByDate', () => {
|
||||
const older = '2026-01-01T00:00:00';
|
||||
const newer = '2026-06-01T00:00:00';
|
||||
|
||||
it('orders ascending by default and descending on request', () => {
|
||||
expect(compareByDate(older, newer, 'asc')).toBeLessThan(0);
|
||||
expect(compareByDate(older, newer, 'desc')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('ties on equal dates, so callers can add their own tiebreak', () => {
|
||||
expect(compareByDate(older, older, 'asc')).toBe(0);
|
||||
});
|
||||
|
||||
it('sorts undated last in both directions — they cannot claim to be latest', () => {
|
||||
for (const direction of ['asc', 'desc'] as const) {
|
||||
expect(compareByDate(older, null, direction)).toBeLessThan(0);
|
||||
expect(compareByDate(null, older, direction)).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats an unparseable date as undated', () => {
|
||||
expect(compareByDate(older, 'not a date', 'asc')).toBeLessThan(0);
|
||||
expect(compareByDate(null, undefined, 'asc')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Event-selection helpers shared by the tournaments and rankings pages, which both
|
||||
* present the same "tick events, or select all / recent / by pattern" scope picker.
|
||||
*
|
||||
* `EventDTO.id` is optional because the generated DTO mirrors nullable C# reference
|
||||
* types, so every consumer used to repeat `.filter(e => e.id !== undefined)` followed
|
||||
* by an `as number` cast. `eventIds` narrows properly and removes the cast.
|
||||
*
|
||||
* Pure: exercisable under plain Node.
|
||||
*/
|
||||
|
||||
import type { EventDTO } from '$lib/api/schema-helpers';
|
||||
|
||||
/** An event the API gave an id, so it can be requested. */
|
||||
export type IdentifiedEvent = EventDTO & { id: number };
|
||||
|
||||
export function hasId(event: EventDTO): event is IdentifiedEvent {
|
||||
return event.id !== undefined;
|
||||
}
|
||||
|
||||
/** The requestable events of a list, in order. */
|
||||
export function identified(events: EventDTO[]): IdentifiedEvent[] {
|
||||
return events.filter(hasId);
|
||||
}
|
||||
|
||||
/** Just the ids — what the scoring endpoints take. */
|
||||
export function eventIds(events: EventDTO[]): number[] {
|
||||
return identified(events).map((event) => event.id);
|
||||
}
|
||||
|
||||
/** Adds or removes an id, ignoring the undefined case. Returns a new array. */
|
||||
export function toggleId(selected: number[], id: number | undefined): number[] {
|
||||
if (id === undefined) return selected;
|
||||
return selected.includes(id) ? selected.filter((value) => value !== id) : [...selected, id];
|
||||
}
|
||||
|
||||
export interface PatternMatch {
|
||||
/** Ids of the events whose name matched. Empty when the pattern was invalid. */
|
||||
ids: number[];
|
||||
/** Set when the pattern would not compile — show it instead of the match count. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects events by regular expression on their name, used for things like
|
||||
* `Ranking #13\d{2}`. Case-sensitive, and events with no name never match.
|
||||
*
|
||||
* An invalid pattern is a user typo, not a bug, so it comes back as a message
|
||||
* rather than throwing. A blank pattern selects nothing and reports nothing —
|
||||
* callers treat it as "no request made" and leave the selection alone.
|
||||
*/
|
||||
export function matchingEventIds(events: EventDTO[], pattern: string): PatternMatch {
|
||||
const value = pattern.trim();
|
||||
if (!value) return { ids: [], error: null };
|
||||
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(value);
|
||||
} catch {
|
||||
return { ids: [], error: `"${value}" is not a valid regular expression.` };
|
||||
}
|
||||
|
||||
return {
|
||||
ids: identified(events)
|
||||
.filter((event) => event.name && regex.test(event.name))
|
||||
.map((event) => event.id),
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares events by date. Events the API gave no date for cannot claim to be the
|
||||
* latest, so they always sort last regardless of direction.
|
||||
*
|
||||
* `'desc'` is newest first (which event names a spreadsheet tab); `'asc'` is oldest
|
||||
* first (which way a chart reads).
|
||||
*/
|
||||
export function compareByDate(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
direction: 'asc' | 'desc' = 'asc'
|
||||
): number {
|
||||
const aTime = a ? Date.parse(a) : NaN;
|
||||
const bTime = b ? Date.parse(b) : NaN;
|
||||
const aOk = Number.isFinite(aTime);
|
||||
const bOk = Number.isFinite(bTime);
|
||||
|
||||
if (aOk && bOk) return direction === 'asc' ? aTime - bTime : bTime - aTime;
|
||||
if (aOk) return -1;
|
||||
if (bOk) return 1;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fullName, percent } from './format';
|
||||
|
||||
describe('percent', () => {
|
||||
it('keeps one decimal below 100', () => {
|
||||
expect(percent(0)).toBe('0.0%');
|
||||
expect(percent(66.666)).toBe('66.7%');
|
||||
expect(percent(99.9)).toBe('99.9%');
|
||||
});
|
||||
|
||||
it('collapses to a bare 100% from 99.95 up, so the column never widens', () => {
|
||||
expect(percent(99.95)).toBe('100%');
|
||||
expect(percent(100)).toBe('100%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fullName', () => {
|
||||
it('joins both names', () => {
|
||||
expect(fullName('Ada', 'Lovelace')).toBe('Ada Lovelace');
|
||||
});
|
||||
|
||||
it('uses whichever half is present', () => {
|
||||
expect(fullName('Ada', null)).toBe('Ada');
|
||||
expect(fullName(undefined, 'Lovelace')).toBe('Lovelace');
|
||||
});
|
||||
|
||||
it('falls back when there is no name at all', () => {
|
||||
expect(fullName(null, null)).toBe('');
|
||||
expect(fullName(null, undefined, 'ada')).toBe('ada');
|
||||
expect(fullName('', '', 'ada')).toBe('ada');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Small display helpers that were duplicated across routes.
|
||||
*
|
||||
* Pure: exercisable under plain Node.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A percentage already on a 0..100 scale, rendered for a narrow table column:
|
||||
* one decimal below 100, and a bare "100%" from 99.95 up so the column never
|
||||
* widens to "100.0%".
|
||||
*/
|
||||
export function percent(value: number): string {
|
||||
return value >= 99.95 ? '100%' : `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** First and last name if either is set, otherwise the fallback (usually the username). */
|
||||
export function fullName(
|
||||
firstName: string | null | undefined,
|
||||
lastName: string | null | undefined,
|
||||
fallback = ''
|
||||
): string {
|
||||
const full = [firstName, lastName].filter(Boolean).join(' ').trim();
|
||||
return full.length > 0 ? full : fallback;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { GameDTO } from '$lib/api/schema-helpers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { blankDraft, isDirty, nextOrder, toDraft, toDto } from './draft';
|
||||
|
||||
describe('toDraft / toDto', () => {
|
||||
it('replaces every null with a value the inputs can bind to', () => {
|
||||
const draft = toDraft({ id: 3, name: null, longName: null, order: undefined, smashId: undefined });
|
||||
expect(draft).toEqual({
|
||||
id: 3,
|
||||
name: '',
|
||||
longName: '',
|
||||
order: 0,
|
||||
imgUrl: '',
|
||||
wordPressTag: '',
|
||||
wordPressTagOs: '',
|
||||
smashId: null
|
||||
});
|
||||
});
|
||||
|
||||
it('stores blank and whitespace-only text as NULL, not as an empty string', () => {
|
||||
const dto = toDto({ ...blankDraft, name: 'SF6', longName: ' ', imgUrl: '' });
|
||||
expect(dto.longName).toBeNull();
|
||||
expect(dto.imgUrl).toBeNull();
|
||||
expect(dto.name).toBe('SF6');
|
||||
});
|
||||
|
||||
it('trims text on the way out', () => {
|
||||
expect(toDto({ ...blankDraft, name: ' SF6 ' }).name).toBe('SF6');
|
||||
});
|
||||
|
||||
it('round-trips a full game', () => {
|
||||
const game: GameDTO = {
|
||||
id: 4,
|
||||
name: 'SF6',
|
||||
longName: 'Street Fighter 6',
|
||||
order: 2,
|
||||
imgUrl: 'https://example.test/sf6.png',
|
||||
wordPressTag: 'sf6',
|
||||
wordPressTagOs: 'sf6-os',
|
||||
smashId: 43868
|
||||
};
|
||||
expect(toDto(toDraft(game))).toEqual(game);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDirty', () => {
|
||||
/*
|
||||
* The editor used to compare `JSON.stringify` output, which is key-order
|
||||
* dependent: an identical draft built with its keys in another order read as
|
||||
* unsaved changes.
|
||||
*/
|
||||
it('is false for equal drafts regardless of key order', () => {
|
||||
const a = { ...blankDraft, name: 'SF6', order: 2 };
|
||||
const reordered = {
|
||||
order: 2,
|
||||
smashId: null,
|
||||
wordPressTagOs: '',
|
||||
wordPressTag: '',
|
||||
imgUrl: '',
|
||||
longName: '',
|
||||
name: 'SF6',
|
||||
id: 0
|
||||
};
|
||||
expect(JSON.stringify(a)).not.toBe(JSON.stringify(reordered));
|
||||
expect(isDirty(a, reordered)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects a change in any field', () => {
|
||||
const base = { ...blankDraft, name: 'SF6' };
|
||||
expect(isDirty(base, { ...base, name: 'SFV' })).toBe(true);
|
||||
expect(isDirty(base, { ...base, order: 9 })).toBe(true);
|
||||
expect(isDirty(base, { ...base, smashId: 1 })).toBe(true);
|
||||
expect(isDirty(base, { ...base })).toBe(false);
|
||||
});
|
||||
|
||||
it('distinguishes null from 0 for the nullable numbers', () => {
|
||||
expect(isDirty({ ...blankDraft, smashId: null }, { ...blankDraft, smashId: 0 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextOrder', () => {
|
||||
it('sorts a new game after the current last one', () => {
|
||||
expect(nextOrder([{ order: 1 }, { order: 5 }, { order: 3 }])).toBe(6);
|
||||
});
|
||||
|
||||
it('starts at 1 for an empty catalogue', () => {
|
||||
expect(nextOrder([])).toBe(1);
|
||||
});
|
||||
|
||||
it('treats a missing order as 0', () => {
|
||||
expect(nextOrder([{ order: undefined }])).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { GameDTO } from '$lib/api/schema-helpers';
|
||||
|
||||
/**
|
||||
* The shape the game editor binds to. Every field is present and non-null so the
|
||||
* inputs never see `undefined`, and `POST /api/Game` always receives a whole DTO —
|
||||
* `AddOrUpdate` replaces every column, so a partial body would blank the rest.
|
||||
*
|
||||
* `id` 0 marks an unsaved game: the API inserts on 0 and updates otherwise.
|
||||
*/
|
||||
export interface Draft {
|
||||
id: number;
|
||||
name: string;
|
||||
longName: string;
|
||||
/** Null while the number input sits empty; `Game.Order` is a non-nullable int. */
|
||||
order: number | null;
|
||||
imgUrl: string;
|
||||
wordPressTag: string;
|
||||
wordPressTagOs: string;
|
||||
smashId: number | null;
|
||||
}
|
||||
|
||||
export const blankDraft: Draft = {
|
||||
id: 0,
|
||||
name: '',
|
||||
longName: '',
|
||||
order: 0,
|
||||
imgUrl: '',
|
||||
wordPressTag: '',
|
||||
wordPressTagOs: '',
|
||||
smashId: null
|
||||
};
|
||||
|
||||
export function toDraft(game: GameDTO): Draft {
|
||||
return {
|
||||
id: game.id ?? 0,
|
||||
name: game.name ?? '',
|
||||
longName: game.longName ?? '',
|
||||
order: game.order ?? 0,
|
||||
imgUrl: game.imgUrl ?? '',
|
||||
wordPressTag: game.wordPressTag ?? '',
|
||||
wordPressTagOs: game.wordPressTagOs ?? '',
|
||||
smashId: game.smashId ?? null
|
||||
};
|
||||
}
|
||||
|
||||
/** Blank text is stored as NULL rather than as an empty string. */
|
||||
export function toDto(draft: Draft): GameDTO {
|
||||
const text = (value: string) => (value.trim() === '' ? null : value.trim());
|
||||
|
||||
return {
|
||||
id: draft.id,
|
||||
name: text(draft.name),
|
||||
longName: text(draft.longName),
|
||||
order: draft.order ?? 0,
|
||||
imgUrl: text(draft.imgUrl),
|
||||
wordPressTag: text(draft.wordPressTag),
|
||||
wordPressTagOs: text(draft.wordPressTagOs),
|
||||
smashId: draft.smashId
|
||||
};
|
||||
}
|
||||
|
||||
/** A new game sorts after the current last one. */
|
||||
export function nextOrder(games: GameDTO[]): number {
|
||||
return games.reduce((max, game) => Math.max(max, game.order ?? 0), 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the editor holds unsaved changes.
|
||||
*
|
||||
* Field by field rather than by comparing `JSON.stringify` output, which is
|
||||
* key-order dependent: it would report a clean draft as dirty the moment
|
||||
* `blankDraft` and `toDraft` listed their keys in a different order.
|
||||
*/
|
||||
export function isDirty(a: Draft, b: Draft): boolean {
|
||||
return (
|
||||
a.id !== b.id ||
|
||||
a.name !== b.name ||
|
||||
a.longName !== b.longName ||
|
||||
a.order !== b.order ||
|
||||
a.imgUrl !== b.imgUrl ||
|
||||
a.wordPressTag !== b.wordPressTag ||
|
||||
a.wordPressTagOs !== b.wordPressTagOs ||
|
||||
a.smashId !== b.smashId
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { EventDTO, ResultDTO } from '$lib/api/schema-helpers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { aggregate, formatMonth, standingsCsv, type EventResult } from './aggregate';
|
||||
|
||||
/** The sentinel ExternalProviderService emits for a player it could not place. */
|
||||
const UNRANKED = 999;
|
||||
|
||||
const sf6 = { id: 1, name: 'SF6', longName: 'Street Fighter 6' };
|
||||
const ssbu = { id: 2, name: 'SSBU', longName: 'Smash Ultimate' };
|
||||
|
||||
function row(partial: Partial<ResultDTO>): ResultDTO {
|
||||
return { gameId: 1, player: 'Bob', rank: 1, point: 10, tournamentUrl: 'SF6', ...partial };
|
||||
}
|
||||
|
||||
function scored(event: Partial<EventDTO>, results: ResultDTO[]): EventResult {
|
||||
return {
|
||||
event: { id: 1, name: 'Ranking #1301', date: '2026-01-10T00:00:00', ...event },
|
||||
result: { games: [sf6, ssbu], results }
|
||||
};
|
||||
}
|
||||
|
||||
describe('aggregate — standings', () => {
|
||||
it('sums points across events and counts distinct events and games', () => {
|
||||
const stats = aggregate([
|
||||
scored({ id: 1 }, [row({ player: 'Bob', point: 10 })]),
|
||||
scored({ id: 2 }, [row({ player: 'Bob', point: 7, gameId: 2, tournamentUrl: 'SSBU' })])
|
||||
]);
|
||||
|
||||
expect(stats.standings).toHaveLength(1);
|
||||
expect(stats.standings[0]).toMatchObject({
|
||||
player: 'Bob',
|
||||
points: 17,
|
||||
entries: 2,
|
||||
events: 2,
|
||||
games: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('merges names that differ only in case or padding, keeping the first spelling', () => {
|
||||
const stats = aggregate([
|
||||
scored({}, [row({ player: 'Bob' }), row({ player: ' BOB ', rank: 2, point: 7 })])
|
||||
]);
|
||||
expect(stats.standings).toHaveLength(1);
|
||||
expect(stats.standings[0]).toMatchObject({ player: 'Bob', points: 17, entries: 2 });
|
||||
});
|
||||
|
||||
it('counts podium places and their total', () => {
|
||||
const stats = aggregate([
|
||||
scored({}, [
|
||||
row({ player: 'Bob', rank: 1 }),
|
||||
row({ player: 'Bob', rank: 2 }),
|
||||
row({ player: 'Bob', rank: 3 }),
|
||||
row({ player: 'Bob', rank: 4 })
|
||||
])
|
||||
]);
|
||||
expect(stats.standings[0]).toMatchObject({
|
||||
firsts: 1,
|
||||
seconds: 1,
|
||||
thirds: 1,
|
||||
podiums: 3,
|
||||
entries: 4
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* 999 is a "we could not place this player" bucket, not a placement. Reading it
|
||||
* as one would report a best rank of 999 for anyone who only ever participated.
|
||||
*/
|
||||
it('never reads the 999 sentinel as a placement', () => {
|
||||
const stats = aggregate([scored({}, [row({ player: 'Bob', rank: UNRANKED })])]);
|
||||
expect(stats.standings[0].bestRank).toBeNull();
|
||||
expect(stats.standings[0].podiums).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the best real rank when a player also has unranked entries', () => {
|
||||
const stats = aggregate([
|
||||
scored({}, [row({ player: 'Bob', rank: UNRANKED }), row({ player: 'Bob', rank: 4 })])
|
||||
]);
|
||||
expect(stats.standings[0].bestRank).toBe(4);
|
||||
});
|
||||
|
||||
it('sorts by points, then firsts, then podiums, then name', () => {
|
||||
const stats = aggregate([
|
||||
scored({}, [
|
||||
row({ player: 'Amy', point: 10, rank: 4 }),
|
||||
row({ player: 'Zoe', point: 10, rank: 1 }),
|
||||
row({ player: 'Cid', point: 20, rank: 4 })
|
||||
])
|
||||
]);
|
||||
// Cid leads on points; Zoe beats Amy on firsts at equal points.
|
||||
expect(stats.standings.map((s) => s.player)).toEqual(['Cid', 'Zoe', 'Amy']);
|
||||
});
|
||||
|
||||
it('ignores rows with no player name', () => {
|
||||
const stats = aggregate([scored({}, [row({ player: ' ' }), row({ player: 'Bob' })])]);
|
||||
expect(stats.standings.map((s) => s.player)).toEqual(['Bob']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregate — games', () => {
|
||||
it('counts entries, distinct players and brackets per game', () => {
|
||||
const stats = aggregate([
|
||||
scored({}, [
|
||||
row({ gameId: 1, player: 'Bob', tournamentUrl: 'SF6 A' }),
|
||||
row({ gameId: 1, player: 'Amy', tournamentUrl: 'SF6 A' }),
|
||||
row({ gameId: 1, player: 'Amy', tournamentUrl: 'SF6 B' })
|
||||
])
|
||||
]);
|
||||
|
||||
const game = stats.games.find((g) => g.gameId === 1);
|
||||
expect(game).toMatchObject({ name: 'SF6', entries: 3, players: 2, brackets: 2 });
|
||||
expect(game?.averageField).toBeCloseTo(1.5);
|
||||
});
|
||||
|
||||
it('scopes brackets to their event, since bracket names repeat monthly', () => {
|
||||
const stats = aggregate([
|
||||
scored({ id: 1 }, [row({ tournamentUrl: 'SF6' })]),
|
||||
scored({ id: 2 }, [row({ tournamentUrl: 'SF6' })])
|
||||
]);
|
||||
expect(stats.games[0].brackets).toBe(2);
|
||||
});
|
||||
|
||||
it('reports the highest scoring player of each game', () => {
|
||||
const stats = aggregate([
|
||||
scored({}, [
|
||||
row({ gameId: 1, player: 'Bob', point: 5 }),
|
||||
row({ gameId: 1, player: 'Amy', point: 12 })
|
||||
])
|
||||
]);
|
||||
expect(stats.games[0]).toMatchObject({ topPlayer: 'Amy', topPoints: 12 });
|
||||
});
|
||||
|
||||
it('falls back to a placeholder name for a game the payload never described', () => {
|
||||
const stats = aggregate([
|
||||
{
|
||||
event: { id: 1, name: 'Ranking' },
|
||||
result: { games: [], results: [row({ gameId: 42 })] }
|
||||
}
|
||||
]);
|
||||
expect(stats.games[0]).toMatchObject({ gameId: 42, name: '#42' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregate — attendance and totals', () => {
|
||||
it('orders attendance oldest first, so a chart reads left to right', () => {
|
||||
const stats = aggregate([
|
||||
scored({ id: 2, name: 'June', date: '2026-06-01T00:00:00' }, [row({})]),
|
||||
scored({ id: 1, name: 'January', date: '2026-01-01T00:00:00' }, [row({})])
|
||||
]);
|
||||
expect(stats.attendance.map((a) => a.name)).toEqual(['January', 'June']);
|
||||
});
|
||||
|
||||
it('places undated events last, keeping their incoming order', () => {
|
||||
const stats = aggregate([
|
||||
scored({ id: 3, name: 'Undated A', date: undefined }, [row({})]),
|
||||
scored({ id: 1, name: 'January', date: '2026-01-01T00:00:00' }, [row({})]),
|
||||
scored({ id: 4, name: 'Undated B', date: undefined }, [row({})])
|
||||
]);
|
||||
expect(stats.attendance.map((a) => a.name)).toEqual(['January', 'Undated A', 'Undated B']);
|
||||
});
|
||||
|
||||
it('totals events, entries, distinct players and points across the scope', () => {
|
||||
const stats = aggregate([
|
||||
scored({ id: 1 }, [row({ player: 'Bob', point: 10 }), row({ player: 'Amy', point: 7 })]),
|
||||
scored({ id: 2 }, [row({ player: 'Bob', point: 3 })])
|
||||
]);
|
||||
expect(stats.totals).toMatchObject({ events: 2, entries: 3, players: 2, points: 20 });
|
||||
});
|
||||
|
||||
it('returns an empty aggregate for an empty scope', () => {
|
||||
const stats = aggregate([]);
|
||||
expect(stats.standings).toEqual([]);
|
||||
expect(stats.games).toEqual([]);
|
||||
expect(stats.attendance).toEqual([]);
|
||||
expect(stats.totals).toMatchObject({ events: 0, entries: 0, players: 0, points: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('standingsCsv', () => {
|
||||
it('writes the header and one quoted row per player', () => {
|
||||
const stats = aggregate([scored({}, [row({ player: 'Bob', point: 10, rank: 1 })])]);
|
||||
const lines = standingsCsv(stats.standings).split('\r\n');
|
||||
|
||||
expect(lines[0]).toBe(
|
||||
'"Player";"Points";"Entries";"Events";"Games";"1st";"2nd";"3rd";"Podiums";"Best rank"'
|
||||
);
|
||||
expect(lines[1]).toBe('"Bob";"10";"1";"1";"1";"1";"0";"0";"1";"1"');
|
||||
expect(lines[2]).toBe('');
|
||||
});
|
||||
|
||||
it('renders an absent best rank as an empty field rather than "null"', () => {
|
||||
const stats = aggregate([scored({}, [row({ player: 'Bob', rank: UNRANKED })])]);
|
||||
expect(standingsCsv(stats.standings)).toContain(';""\r\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMonth', () => {
|
||||
it('is empty for a missing or unparseable date', () => {
|
||||
expect(formatMonth(null)).toBe('');
|
||||
expect(formatMonth('not a date')).toBe('');
|
||||
});
|
||||
|
||||
it('renders a month and year', () => {
|
||||
expect(formatMonth('2026-08-05T00:00:00')).toMatch(/2026/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { EventDTO, GameDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
|
||||
import { toCsv } from '$lib/csv';
|
||||
import { compareByDate } from '$lib/events';
|
||||
|
||||
/**
|
||||
* Aggregation for the Statistiques page. Pure: it takes results already fetched
|
||||
* per event and reshapes them, so it can be exercised under plain Node the same
|
||||
* way `$lib/tournaments/results.ts` is.
|
||||
*
|
||||
* Why per event rather than one batched call: `POST /api/Tournament/GetResults`
|
||||
* merges everything it is given and never says which event a row came from, so
|
||||
* per-event scoping is the only way to get a time series. It also contains the
|
||||
* blast radius — the endpoint throws on a bracket with no rank-1 row, and one bad
|
||||
* event would otherwise take the whole batch with it.
|
||||
*/
|
||||
|
||||
/** One event's scored results, as returned for that event alone. */
|
||||
export interface EventResult {
|
||||
event: EventDTO;
|
||||
result: TournamentsResultDTO;
|
||||
}
|
||||
|
||||
export interface PlayerStanding {
|
||||
player: string;
|
||||
points: number;
|
||||
/** Bracket entries, i.e. result rows — a player counts once per bracket entered. */
|
||||
entries: number;
|
||||
events: number;
|
||||
games: number;
|
||||
firsts: number;
|
||||
seconds: number;
|
||||
thirds: number;
|
||||
podiums: number;
|
||||
/** Best placement seen, ignoring the 999 "rank unknown" sentinel. */
|
||||
bestRank: number | null;
|
||||
}
|
||||
|
||||
export interface GameSummary {
|
||||
gameId: number;
|
||||
name: string;
|
||||
longName: string;
|
||||
entries: number;
|
||||
players: number;
|
||||
brackets: number;
|
||||
/** Highest scoring player in this game over the scope. */
|
||||
topPlayer: string | null;
|
||||
topPoints: number;
|
||||
/** Mean entries per bracket — 0 when the game has no bracket in scope. */
|
||||
averageField: number;
|
||||
}
|
||||
|
||||
export interface EventAttendance {
|
||||
eventId: number;
|
||||
name: string;
|
||||
/** ISO date from EventDTO.date, or null when the API did not supply one. */
|
||||
date: string | null;
|
||||
players: number;
|
||||
entries: number;
|
||||
games: number;
|
||||
brackets: number;
|
||||
}
|
||||
|
||||
export interface Totals {
|
||||
events: number;
|
||||
brackets: number;
|
||||
entries: number;
|
||||
players: number;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface Aggregate {
|
||||
standings: PlayerStanding[];
|
||||
games: GameSummary[];
|
||||
attendance: EventAttendance[];
|
||||
totals: Totals;
|
||||
}
|
||||
|
||||
/**
|
||||
* The service emits 999 for players it could not place (the participation bucket
|
||||
* in ExternalProviderService), so it must never be read as a placement.
|
||||
*/
|
||||
const UNRANKED = 999;
|
||||
|
||||
/** Names come from different brackets and only ever match case-insensitively. */
|
||||
function key(name: string): string {
|
||||
return name.trim().toUpperCase();
|
||||
}
|
||||
|
||||
function displayName(name: string): string {
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
interface PlayerAccumulator {
|
||||
player: string;
|
||||
points: number;
|
||||
entries: number;
|
||||
events: Set<number>;
|
||||
games: Set<number>;
|
||||
firsts: number;
|
||||
seconds: number;
|
||||
thirds: number;
|
||||
bestRank: number | null;
|
||||
}
|
||||
|
||||
interface GameAccumulator {
|
||||
game: GameDTO;
|
||||
entries: number;
|
||||
players: Set<string>;
|
||||
brackets: Set<string>;
|
||||
pointsByPlayer: Map<string, { player: string; points: number }>;
|
||||
}
|
||||
|
||||
export function aggregate(loaded: EventResult[]): Aggregate {
|
||||
const players = new Map<string, PlayerAccumulator>();
|
||||
const games = new Map<number, GameAccumulator>();
|
||||
const attendance: EventAttendance[] = [];
|
||||
let totalBrackets = 0;
|
||||
let totalEntries = 0;
|
||||
let totalPoints = 0;
|
||||
|
||||
for (const { event, result } of loaded) {
|
||||
const eventId = event.id ?? 0;
|
||||
const rows = result.results ?? [];
|
||||
|
||||
const eventPlayers = new Set<string>();
|
||||
const eventGames = new Set<number>();
|
||||
const eventBrackets = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
const name = row.player?.trim();
|
||||
if (!name) continue;
|
||||
|
||||
const gameId = row.gameId ?? 0;
|
||||
const points = row.point ?? 0;
|
||||
const rank = row.rank ?? UNRANKED;
|
||||
const bracket = `${gameId}::${row.tournamentUrl ?? ''}`;
|
||||
|
||||
eventPlayers.add(key(name));
|
||||
eventGames.add(gameId);
|
||||
eventBrackets.add(bracket);
|
||||
totalEntries++;
|
||||
totalPoints += points;
|
||||
|
||||
let player = players.get(key(name));
|
||||
if (!player) {
|
||||
player = {
|
||||
player: displayName(name),
|
||||
points: 0,
|
||||
entries: 0,
|
||||
events: new Set(),
|
||||
games: new Set(),
|
||||
firsts: 0,
|
||||
seconds: 0,
|
||||
thirds: 0,
|
||||
bestRank: null
|
||||
};
|
||||
players.set(key(name), player);
|
||||
}
|
||||
player.points += points;
|
||||
player.entries++;
|
||||
player.events.add(eventId);
|
||||
player.games.add(gameId);
|
||||
if (rank === 1) player.firsts++;
|
||||
else if (rank === 2) player.seconds++;
|
||||
else if (rank === 3) player.thirds++;
|
||||
if (rank !== UNRANKED && (player.bestRank === null || rank < player.bestRank)) {
|
||||
player.bestRank = rank;
|
||||
}
|
||||
|
||||
let game = games.get(gameId);
|
||||
if (!game) {
|
||||
const meta = (result.games ?? []).find((candidate) => candidate.id === gameId);
|
||||
game = {
|
||||
game: meta ?? { id: gameId, name: `#${gameId}`, longName: `#${gameId}` },
|
||||
entries: 0,
|
||||
players: new Set(),
|
||||
brackets: new Set(),
|
||||
pointsByPlayer: new Map()
|
||||
};
|
||||
games.set(gameId, game);
|
||||
}
|
||||
game.entries++;
|
||||
game.players.add(key(name));
|
||||
// Bracket names repeat every month, so scope them to the event.
|
||||
game.brackets.add(`${eventId}::${bracket}`);
|
||||
const forPlayer = game.pointsByPlayer.get(key(name)) ?? {
|
||||
player: displayName(name),
|
||||
points: 0
|
||||
};
|
||||
forPlayer.points += points;
|
||||
game.pointsByPlayer.set(key(name), forPlayer);
|
||||
}
|
||||
|
||||
totalBrackets += eventBrackets.size;
|
||||
attendance.push({
|
||||
eventId,
|
||||
name: event.name ?? `#${eventId}`,
|
||||
date: event.date ?? null,
|
||||
players: eventPlayers.size,
|
||||
entries: rows.length,
|
||||
games: eventGames.size,
|
||||
brackets: eventBrackets.size
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
standings: buildStandings(players),
|
||||
games: buildGameSummaries(games),
|
||||
attendance: sortByDate(attendance),
|
||||
totals: {
|
||||
events: loaded.length,
|
||||
brackets: totalBrackets,
|
||||
entries: totalEntries,
|
||||
players: players.size,
|
||||
points: totalPoints
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildStandings(players: Map<string, PlayerAccumulator>): PlayerStanding[] {
|
||||
return [...players.values()]
|
||||
.map((p) => ({
|
||||
player: p.player,
|
||||
points: p.points,
|
||||
entries: p.entries,
|
||||
events: p.events.size,
|
||||
games: p.games.size,
|
||||
firsts: p.firsts,
|
||||
seconds: p.seconds,
|
||||
thirds: p.thirds,
|
||||
podiums: p.firsts + p.seconds + p.thirds,
|
||||
bestRank: p.bestRank
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.points - a.points ||
|
||||
b.firsts - a.firsts ||
|
||||
b.podiums - a.podiums ||
|
||||
a.player.localeCompare(b.player)
|
||||
);
|
||||
}
|
||||
|
||||
function buildGameSummaries(games: Map<number, GameAccumulator>): GameSummary[] {
|
||||
return [...games.values()]
|
||||
.map((g) => {
|
||||
const top = [...g.pointsByPlayer.values()].sort(
|
||||
(a, b) => b.points - a.points || a.player.localeCompare(b.player)
|
||||
)[0];
|
||||
return {
|
||||
gameId: g.game.id ?? 0,
|
||||
name: g.game.name ?? `#${g.game.id ?? 0}`,
|
||||
longName: g.game.longName ?? g.game.name ?? `#${g.game.id ?? 0}`,
|
||||
entries: g.entries,
|
||||
players: g.players.size,
|
||||
brackets: g.brackets.size,
|
||||
topPlayer: top?.player ?? null,
|
||||
topPoints: top?.points ?? 0,
|
||||
averageField: g.brackets.size === 0 ? 0 : g.entries / g.brackets.size
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.entries - a.entries || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Oldest first, so a chart reads left to right. Events the API gave no date for
|
||||
* keep their incoming order and sort last — `GET /api/Event` returns newest
|
||||
* first, so that is still the least misleading placement for them.
|
||||
*/
|
||||
function sortByDate(attendance: EventAttendance[]): EventAttendance[] {
|
||||
return attendance
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
compareByDate(a.entry.date, b.entry.date, 'asc') || a.index - b.index
|
||||
)
|
||||
.map(({ entry }) => entry);
|
||||
}
|
||||
|
||||
/** `2026-08-05T00:00:00` → `Aug 2026`. Falls back to the event name's own text. */
|
||||
export function formatMonth(date: string | null): string {
|
||||
if (!date) return '';
|
||||
const parsed = Date.parse(date);
|
||||
if (!Number.isFinite(parsed)) return '';
|
||||
return new Date(parsed).toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
/** Semicolon-separated, quoted — the dialect `$lib/csv` defines. */
|
||||
export function standingsCsv(standings: PlayerStanding[]): string {
|
||||
const header = [
|
||||
'Player',
|
||||
'Points',
|
||||
'Entries',
|
||||
'Events',
|
||||
'Games',
|
||||
'1st',
|
||||
'2nd',
|
||||
'3rd',
|
||||
'Podiums',
|
||||
'Best rank'
|
||||
];
|
||||
|
||||
return toCsv(
|
||||
header,
|
||||
standings.map((row) => [
|
||||
row.player,
|
||||
row.points,
|
||||
row.entries,
|
||||
row.events,
|
||||
row.games,
|
||||
row.firsts,
|
||||
row.seconds,
|
||||
row.thirds,
|
||||
row.podiums,
|
||||
row.bestRank ?? ''
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import type { EventDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/*
|
||||
* `loadEventResults` exists to contain the blast radius of one bad event, so what
|
||||
* matters here is the failure behaviour: a broken event must not take the others
|
||||
* with it, while an abort or a 401 must stop everything.
|
||||
*/
|
||||
|
||||
// Typed so the assertions below read the call arguments without falling back to `any`.
|
||||
const getResults = vi.hoisted(() =>
|
||||
vi.fn<(ids: number[], options?: { signal?: AbortSignal }) => Promise<TournamentsResultDTO>>()
|
||||
);
|
||||
vi.mock('$lib/api/tournaments', () => ({ getResults }));
|
||||
|
||||
const { loadEventResults } = await import('./load');
|
||||
|
||||
function events(count: number): EventDTO[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `Event ${i + 1}`,
|
||||
date: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getResults.mockReset();
|
||||
});
|
||||
|
||||
describe('loadEventResults', () => {
|
||||
it('requests one event at a time and returns them all', async () => {
|
||||
getResults.mockImplementation((ids: number[]) =>
|
||||
Promise.resolve({ results: [{ player: `P${ids[0]}` }] })
|
||||
);
|
||||
|
||||
const outcome = await loadEventResults(events(3));
|
||||
|
||||
expect(outcome.loaded).toHaveLength(3);
|
||||
expect(outcome.failed).toEqual([]);
|
||||
// One id per call — batching is what this module exists to avoid.
|
||||
expect(getResults.mock.calls.map((c) => c[0])).toEqual([[1], [2], [3]]);
|
||||
});
|
||||
|
||||
it('skips events with no id rather than requesting undefined', async () => {
|
||||
getResults.mockResolvedValue({ results: [] });
|
||||
await loadEventResults([{ id: 1, name: 'A' }, { name: 'B' }]);
|
||||
expect(getResults).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('isolates a failing event and reports it, keeping the rest', async () => {
|
||||
getResults.mockImplementation((ids: number[]) =>
|
||||
ids[0] === 2
|
||||
? Promise.reject(new ApiError(500, 'Bracket has no rank-1 row'))
|
||||
: Promise.resolve({ results: [] })
|
||||
);
|
||||
|
||||
const outcome = await loadEventResults(events(3));
|
||||
|
||||
expect(outcome.loaded).toHaveLength(2);
|
||||
expect(outcome.failed).toHaveLength(1);
|
||||
expect(outcome.failed[0].event.id).toBe(2);
|
||||
expect(outcome.failed[0].message).toBe('Bracket has no rank-1 row');
|
||||
});
|
||||
|
||||
it('stops everything on a 401 — retrying N times would just burn requests', async () => {
|
||||
getResults.mockRejectedValue(new ApiError(401, 'Unauthorized'));
|
||||
await expect(loadEventResults(events(3))).rejects.toThrow(ApiError);
|
||||
});
|
||||
|
||||
it('propagates an abort rather than recording it as a failed event', async () => {
|
||||
getResults.mockRejectedValue(new DOMException('Aborted', 'AbortError'));
|
||||
await expect(loadEventResults(events(2))).rejects.toThrow(DOMException);
|
||||
});
|
||||
|
||||
it('makes no request at all when the signal is already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const outcome = await loadEventResults(events(3), { signal: controller.signal });
|
||||
|
||||
expect(getResults).not.toHaveBeenCalled();
|
||||
expect(outcome.loaded).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports progress once per settled event, failures included', async () => {
|
||||
getResults.mockImplementation((ids: number[]) =>
|
||||
ids[0] === 2 ? Promise.reject(new ApiError(500, 'boom')) : Promise.resolve({ results: [] })
|
||||
);
|
||||
|
||||
const seen: number[] = [];
|
||||
await loadEventResults(events(3), { onProgress: (done, total) => {
|
||||
expect(total).toBe(3);
|
||||
seen.push(done);
|
||||
} });
|
||||
|
||||
expect(seen).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('never runs more requests at once than the concurrency allows', async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
getResults.mockImplementation(async () => {
|
||||
inFlight++;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await Promise.resolve();
|
||||
inFlight--;
|
||||
return { results: [] };
|
||||
});
|
||||
|
||||
await loadEventResults(events(10), { concurrency: 3 });
|
||||
|
||||
expect(peak).toBeLessThanOrEqual(3);
|
||||
expect(getResults).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
it('handles an empty scope without spawning workers', async () => {
|
||||
const outcome = await loadEventResults([]);
|
||||
expect(outcome).toEqual({ loaded: [], failed: [] });
|
||||
expect(getResults).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ApiError } from '$lib/api/client';
|
||||
import type { EventDTO } from '$lib/api/schema-helpers';
|
||||
import { getResults } from '$lib/api/tournaments';
|
||||
import type { EventResult } from './aggregate';
|
||||
|
||||
/**
|
||||
* Fetches scored results one event at a time.
|
||||
*
|
||||
* `POST /api/Tournament/GetResults` throws a 500 when any bracket in the request
|
||||
* is missing a rank-1 or rank-2 row, so asking for thirty events in one call means
|
||||
* one broken import hides all thirty. Per-event requests cost more round trips but
|
||||
* degrade to "we skipped event #97" instead of "statistics are unavailable".
|
||||
*/
|
||||
|
||||
export interface FailedEvent {
|
||||
event: EventDTO;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LoadOutcome {
|
||||
loaded: EventResult[];
|
||||
failed: FailedEvent[];
|
||||
}
|
||||
|
||||
export interface LoadOptions {
|
||||
/** Called after each event settles, for a progress indicator. */
|
||||
onProgress?: (done: number, total: number) => void;
|
||||
/** Abort further requests, e.g. when the user changes scope mid-load. */
|
||||
signal?: AbortSignal;
|
||||
/** Parallel requests. Enough to be quick, few enough to be polite to the API. */
|
||||
concurrency?: number;
|
||||
}
|
||||
|
||||
export async function loadEventResults(
|
||||
events: EventDTO[],
|
||||
options: LoadOptions = {}
|
||||
): Promise<LoadOutcome> {
|
||||
const { onProgress, signal, concurrency = 6 } = options;
|
||||
|
||||
const targets = events.filter((event) => event.id !== undefined);
|
||||
const loaded: EventResult[] = [];
|
||||
const failed: FailedEvent[] = [];
|
||||
let done = 0;
|
||||
let next = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < targets.length) {
|
||||
if (signal?.aborted) return;
|
||||
const event = targets[next++];
|
||||
|
||||
try {
|
||||
const result = await getResults([event.id as number], { signal });
|
||||
loaded.push({ event, result });
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause;
|
||||
// A 401 has to stop everything: retrying N times just burns requests.
|
||||
if (cause instanceof ApiError && cause.status === 401) throw cause;
|
||||
failed.push({
|
||||
event,
|
||||
message: cause instanceof ApiError ? cause.message : 'Request failed'
|
||||
});
|
||||
}
|
||||
|
||||
onProgress?.(++done, targets.length);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, targets.length) }, worker);
|
||||
await Promise.all(workers);
|
||||
|
||||
return { loaded, failed };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { browser } from '$app/environment';
|
||||
import type { AuthenticatedUser } from '$lib/api/schema-helpers';
|
||||
|
||||
const STORAGE_KEY = 'ladose.session';
|
||||
|
||||
/**
|
||||
* Restores the session written by a previous visit, discarding it if the JWT expired.
|
||||
*
|
||||
* Every localStorage access sits inside the try: reading the property at all throws
|
||||
* in Safari private mode and with cookies blocked, and this runs in the `#user`
|
||||
* field initialiser, so an escaping error would fail module init and blank the app.
|
||||
*/
|
||||
function restore(): AuthenticatedUser | null {
|
||||
if (!browser) return null;
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
const user = JSON.parse(raw) as AuthenticatedUser;
|
||||
if (!user?.token || !user.username) return null;
|
||||
// `roles` is optional in the DTO but `isAdmin` calls `.some` on it. A
|
||||
// hand-edited entry could hold a string, so drop anything that is neither
|
||||
// absent nor an array instead of throwing at render time.
|
||||
if (user.roles != null && !Array.isArray(user.roles)) return null;
|
||||
if (isExpired(user)) {
|
||||
forget();
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
} catch {
|
||||
forget();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort removal: storage being unavailable is not worth failing a sign-out over. */
|
||||
function forget(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* localStorage throws in Safari private mode and with cookies blocked */
|
||||
}
|
||||
}
|
||||
|
||||
/** The API issues short-lived tokens (16 min), so treat a lapsed one as logged out. */
|
||||
function isExpired(user: AuthenticatedUser): boolean {
|
||||
if (!user.expire) return false;
|
||||
const expiresAt = Date.parse(user.expire);
|
||||
return Number.isFinite(expiresAt) && expiresAt <= Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the signed-in user for the lifetime of the tab and mirrors it into
|
||||
* localStorage so a refresh doesn't bounce the user back to /login.
|
||||
*/
|
||||
class Session {
|
||||
#user = $state<AuthenticatedUser | null>(restore());
|
||||
|
||||
get user(): AuthenticatedUser | null {
|
||||
return this.#user;
|
||||
}
|
||||
|
||||
get isLoggedIn(): boolean {
|
||||
return this.#user !== null;
|
||||
}
|
||||
|
||||
/** Bearer token for `apiRequest`, or null when signed out. */
|
||||
get token(): string | null {
|
||||
return this.#user?.token ?? null;
|
||||
}
|
||||
|
||||
/** Role names the API reported at sign-in, e.g. `['Admin']`. */
|
||||
get roles(): string[] {
|
||||
return this.#user?.roles ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to offer the admin-only screens. This is presentation only — the API
|
||||
* re-reads the caller's roles from the database on every request, so hiding a link
|
||||
* is a convenience and never the thing that keeps a non-admin out.
|
||||
*/
|
||||
get isAdmin(): boolean {
|
||||
return this.roles.some((role) => role.toLowerCase() === 'admin');
|
||||
}
|
||||
|
||||
/** Name to greet the user with; falls back to the username. */
|
||||
get displayName(): string {
|
||||
if (!this.#user) return '';
|
||||
const full = [this.#user.firstName, this.#user.lastName].filter(Boolean).join(' ').trim();
|
||||
return full.length > 0 ? full : this.#user.username;
|
||||
}
|
||||
|
||||
/**
|
||||
* The in-memory session is the source of truth; localStorage only survives a
|
||||
* refresh. Persisting is therefore best-effort — a storage failure must not
|
||||
* surface as a failed sign-in when authentication actually succeeded.
|
||||
*/
|
||||
start(user: AuthenticatedUser): void {
|
||||
this.#user = user;
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
||||
} catch {
|
||||
/* localStorage throws in Safari private mode and with cookies blocked */
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#user = null;
|
||||
if (browser) forget();
|
||||
}
|
||||
}
|
||||
|
||||
export const session = new Session();
|
||||
@@ -0,0 +1,91 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
/** Must match the key read by the inline script in src/app.html, byte for byte. */
|
||||
const STORAGE_KEY = 'ladose.theme';
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
/** The two themes the CSS actually defines; 'system' resolves to one of these. */
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
function readStored(): Theme {
|
||||
if (!browser) return 'system';
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw === 'light' || raw === 'dark' ? raw : 'system';
|
||||
} catch {
|
||||
// Safari private mode, or cookies blocked.
|
||||
return 'system';
|
||||
}
|
||||
}
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
return browser && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Light/dark preference.
|
||||
*
|
||||
* The initial `data-theme` attribute is stamped by the inline script in
|
||||
* `src/app.html` before first paint; this store only mirrors that state and takes
|
||||
* over once the user touches the toggle. It deliberately does not re-stamp on
|
||||
* construction — that would cost a style recalc on every boot to no effect.
|
||||
*
|
||||
* `app.css` keys off `[data-theme='light']` and `[data-theme='dark']` only, so
|
||||
* 'system' is represented by the *absence* of the attribute and never written as
|
||||
* a literal value.
|
||||
*/
|
||||
class ThemeStore {
|
||||
#preference = $state<Theme>(readStored());
|
||||
#systemDark = $state(systemPrefersDark());
|
||||
|
||||
constructor() {
|
||||
if (!browser) return;
|
||||
|
||||
// Reflect a live OS switch while the preference is 'system'.
|
||||
const query = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
query.addEventListener('change', (event) => {
|
||||
this.#systemDark = event.matches;
|
||||
});
|
||||
}
|
||||
|
||||
/** What the user chose: an explicit theme, or 'system' to follow the OS. */
|
||||
get preference(): Theme {
|
||||
return this.#preference;
|
||||
}
|
||||
|
||||
/** What is actually on screen. Drives the toggle's icon and label. */
|
||||
get resolved(): ResolvedTheme {
|
||||
if (this.#preference !== 'system') return this.#preference;
|
||||
return this.#systemDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
set(preference: Theme): void {
|
||||
this.#preference = preference;
|
||||
if (!browser) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
if (preference === 'system') {
|
||||
delete root.dataset.theme;
|
||||
} else {
|
||||
root.dataset.theme = preference;
|
||||
}
|
||||
|
||||
try {
|
||||
if (preference === 'system') localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, preference);
|
||||
} catch {
|
||||
// Preference is still applied for this tab; it just will not persist.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips to the opposite of what is currently on screen. Starting from 'system'
|
||||
* this pins an explicit choice, which is what someone clicking a toggle means.
|
||||
*/
|
||||
toggle(): void {
|
||||
this.set(this.resolved === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
export const theme = new ThemeStore();
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { GameDTO, ResultDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildCsv, buildHtml, buildRanking, playedGames, rankingHeader, resultsForGame } from './results';
|
||||
|
||||
const sf6: GameDTO = { id: 1, name: 'SF6', longName: 'Street Fighter 6', order: 1 };
|
||||
const ssbu: GameDTO = { id: 2, name: 'SSBU', longName: 'Smash Ultimate', order: 2 };
|
||||
|
||||
function result(partial: Partial<ResultDTO>): ResultDTO {
|
||||
return { gameId: 1, player: 'Bob', rank: 1, point: 10, ...partial };
|
||||
}
|
||||
|
||||
function payload(partial: Partial<TournamentsResultDTO> = {}): TournamentsResultDTO {
|
||||
return {
|
||||
games: [sf6, ssbu],
|
||||
participents: [{ name: 'Bob' }, { name: 'Alice' }],
|
||||
results: [
|
||||
result({ gameId: 1, player: 'Bob', rank: 1, point: 10 }),
|
||||
result({ gameId: 1, player: 'Alice', rank: 2, point: 7 }),
|
||||
result({ gameId: 2, player: 'Alice', rank: 1, point: 10 })
|
||||
],
|
||||
...partial
|
||||
};
|
||||
}
|
||||
|
||||
describe('playedGames', () => {
|
||||
it('returns only games that have results, in Game.Order', () => {
|
||||
const games = playedGames(payload({ games: [ssbu, sf6] }));
|
||||
expect(games.map((g) => g.name)).toEqual(['SF6', 'SSBU']);
|
||||
});
|
||||
|
||||
it('drops a game nobody entered', () => {
|
||||
const only = playedGames(payload({ results: [result({ gameId: 1 })] }));
|
||||
expect(only.map((g) => g.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('handles a null payload', () => {
|
||||
expect(playedGames(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRanking', () => {
|
||||
it('sums points per game and sorts by total, highest first', () => {
|
||||
const table = buildRanking(payload());
|
||||
expect(table.rows.map((r) => [r.player, r.total])).toEqual([
|
||||
['Alice', 17],
|
||||
['Bob', 10]
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps points index-aligned with games — the whole grid depends on it', () => {
|
||||
const table = buildRanking(payload());
|
||||
expect(table.games.map((g) => g.name)).toEqual(['SF6', 'SSBU']);
|
||||
|
||||
const alice = table.rows.find((r) => r.player === 'Alice');
|
||||
expect(alice?.points).toEqual([7, 10]);
|
||||
expect(alice?.points).toHaveLength(table.games.length);
|
||||
});
|
||||
|
||||
it('merges spellings that differ only in case', () => {
|
||||
const table = buildRanking(
|
||||
payload({
|
||||
participents: [{ name: 'Bob' }, { name: 'BOB' }],
|
||||
results: [
|
||||
result({ player: 'Bob', point: 10 }),
|
||||
result({ player: 'BOB', point: 5, rank: 2 })
|
||||
]
|
||||
})
|
||||
);
|
||||
expect(table.rows).toHaveLength(1);
|
||||
expect(table.rows[0]).toMatchObject({ player: 'Bob', total: 15 });
|
||||
});
|
||||
|
||||
it('breaks a tie on total by player name', () => {
|
||||
const table = buildRanking(
|
||||
payload({
|
||||
participents: [{ name: 'Zoe' }, { name: 'Amy' }],
|
||||
results: [result({ player: 'Zoe' }), result({ player: 'Amy' })]
|
||||
})
|
||||
);
|
||||
expect(table.rows.map((r) => r.player)).toEqual(['Amy', 'Zoe']);
|
||||
});
|
||||
|
||||
it('ignores unnamed participants', () => {
|
||||
const table = buildRanking(payload({ participents: [{ name: ' ' }, { name: 'Bob' }] }));
|
||||
expect(table.rows.map((r) => r.player)).toEqual(['Bob']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resultsForGame', () => {
|
||||
it('returns one game, best rank first', () => {
|
||||
const rows = resultsForGame(payload(), 1);
|
||||
expect(rows.map((r) => r.player)).toEqual(['Bob', 'Alice']);
|
||||
});
|
||||
|
||||
it('is empty for no selection', () => {
|
||||
expect(resultsForGame(payload(), null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCsv', () => {
|
||||
it('uses the same header as the spreadsheet tab', () => {
|
||||
const table = buildRanking(payload());
|
||||
expect(rankingHeader(table)).toEqual(['Players', 'SF6', 'SSBU', 'Total']);
|
||||
expect(buildCsv(table).split('\r\n')[0]).toBe('"Players";"SF6";"SSBU";"Total"');
|
||||
});
|
||||
|
||||
it('writes one row per player with a trailing newline', () => {
|
||||
expect(buildCsv(buildRanking(payload()))).toBe(
|
||||
'"Players";"SF6";"SSBU";"Total"\r\n"Alice";"7";"10";"17"\r\n"Bob";"10";"0";"10"\r\n'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHtml', () => {
|
||||
it('escapes interpolated values — this feeds an {@html} block', () => {
|
||||
const html = buildHtml(
|
||||
payload({
|
||||
games: [{ ...sf6, longName: 'Fight & <b>Win</b>' }],
|
||||
results: [result({ gameId: 1, player: '<script>alert(1)</script>' })]
|
||||
})
|
||||
);
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>');
|
||||
expect(html).toContain('Fight & <b>Win</b>');
|
||||
});
|
||||
|
||||
it('omits the bracket link when the API gave no slug', () => {
|
||||
expect(buildHtml(payload())).not.toContain('start.gg');
|
||||
});
|
||||
|
||||
it('links to the bracket when a slug is present', () => {
|
||||
const html = buildHtml(
|
||||
payload({
|
||||
slug: 'ladose-13',
|
||||
results: [result({ gameId: 1, tournamentUrl: 'Street Fighter 6' })]
|
||||
})
|
||||
);
|
||||
expect(html).toContain('https://start.gg/tournament/ladose-13/event/Street-Fighter-6');
|
||||
});
|
||||
|
||||
it('spans a lone trailing game across both columns', () => {
|
||||
const html = buildHtml(payload({ games: [sf6], results: [result({ gameId: 1 })] }));
|
||||
expect(html).toContain('colspan="2"');
|
||||
});
|
||||
|
||||
it('returns an empty string for a null payload', () => {
|
||||
expect(buildHtml(null)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { GameDTO, ResultDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
|
||||
import { toCsv } from '$lib/csv';
|
||||
|
||||
/**
|
||||
* Scoring lives on the server (ExternalProviderService applies the point rules);
|
||||
* everything here only reshapes `TournamentsResultDTO` for display, mirroring the
|
||||
* Avalonia TournamentResultViewModel: a players x games ranking grid, a per-game
|
||||
* breakdown, the WordPress HTML summary and a CSV export.
|
||||
*/
|
||||
|
||||
export interface RankingRow {
|
||||
player: string;
|
||||
/** Points per game, index-aligned with `RankingTable.games`. */
|
||||
points: number[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface RankingTable {
|
||||
games: GameDTO[];
|
||||
rows: RankingRow[];
|
||||
}
|
||||
|
||||
/** Player names come from different brackets, so they only match case-insensitively. */
|
||||
function sameName(a: string, b: string): boolean {
|
||||
return a.toUpperCase() === b.toUpperCase();
|
||||
}
|
||||
|
||||
/** Games that actually have results, in the display order configured on Game.Order. */
|
||||
export function playedGames(result: TournamentsResultDTO | null): GameDTO[] {
|
||||
if (!result?.games) return [];
|
||||
|
||||
const scored = new Set((result.results ?? []).map((r) => r.gameId));
|
||||
const byId = new Map<number, GameDTO>();
|
||||
for (const game of result.games) {
|
||||
if (game.id !== undefined && scored.has(game.id) && !byId.has(game.id)) byId.set(game.id, game);
|
||||
}
|
||||
|
||||
return [...byId.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ranking grid: one row per participant with their points in each game
|
||||
* and a total, highest total first. Duplicate spellings of a name are merged.
|
||||
*/
|
||||
export function buildRanking(result: TournamentsResultDTO | null): RankingTable {
|
||||
const games = playedGames(result);
|
||||
if (!result) return { games, rows: [] };
|
||||
|
||||
const players: string[] = [];
|
||||
for (const participant of result.participents ?? []) {
|
||||
const name = participant.name?.trim();
|
||||
if (name && !players.some((known) => sameName(known, name))) players.push(name);
|
||||
}
|
||||
players.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const results = result.results ?? [];
|
||||
const rows = players.map((player) => {
|
||||
const points = games.map((game) =>
|
||||
results
|
||||
.filter((r) => r.gameId === game.id && r.player && sameName(r.player, player))
|
||||
.reduce((sum, r) => sum + (r.point ?? 0), 0)
|
||||
);
|
||||
return { player, points, total: points.reduce((sum, p) => sum + p, 0) };
|
||||
});
|
||||
|
||||
rows.sort((a, b) => b.total - a.total || a.player.localeCompare(b.player));
|
||||
return { games, rows };
|
||||
}
|
||||
|
||||
/** The placements of a single game, best rank first — the "By game" view. */
|
||||
export function resultsForGame(
|
||||
result: TournamentsResultDTO | null,
|
||||
gameId: number | null
|
||||
): ResultDTO[] {
|
||||
if (!result?.results || gameId === null) return [];
|
||||
|
||||
return result.results
|
||||
.filter((r) => r.gameId === gameId)
|
||||
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0) || (b.point ?? 0) - (a.point ?? 0));
|
||||
}
|
||||
|
||||
/** start.gg event slugs: the bracket name with spaces and dots turned into dashes. */
|
||||
function bracketSlug(tournamentUrl: string): string {
|
||||
return tournamentUrl.replaceAll(' ', '-').replaceAll('.', '-');
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* The podium table pasted into the WordPress recap post: two games per row, each
|
||||
* cell listing the top 3 and linking to the start.gg bracket. The bracket link
|
||||
* needs `slug`, which the API only returns for a single-event request.
|
||||
*/
|
||||
export function buildHtml(result: TournamentsResultDTO | null): string {
|
||||
if (!result) return '';
|
||||
|
||||
const games = playedGames(result);
|
||||
const results = result.results ?? [];
|
||||
const parts: string[] = [
|
||||
'<table class="table table-responsive-md table-dark table-striped mt-lg-4 mt-3">'
|
||||
];
|
||||
|
||||
let columns = 0;
|
||||
for (const game of games) {
|
||||
const forGame = results.filter((r) => r.gameId === game.id);
|
||||
const top3 = [...forGame]
|
||||
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))
|
||||
.slice(0, 3)
|
||||
.map((r) => r.player ?? '');
|
||||
if (top3.length === 0) continue;
|
||||
|
||||
if (columns % 2 === 0) parts.push('<tr>');
|
||||
columns++;
|
||||
// A lone game on the last row spans both columns.
|
||||
const span = columns === games.length && columns % 2 !== 0 ? 2 : 1;
|
||||
|
||||
parts.push(
|
||||
`<td colspan="${span}" width="50%">` +
|
||||
'<span style="color: #ff0000;">' +
|
||||
`<strong>${escapeHtml(game.longName ?? game.name ?? '')} (${forGame.length} participants) :</strong>` +
|
||||
'</span>'
|
||||
);
|
||||
parts.push(
|
||||
'<br>' + top3.map((player, i) => ` ${i + 1}/ ${escapeHtml(player)}<br>`).join('')
|
||||
);
|
||||
|
||||
const tournamentUrl = forGame[0]?.tournamentUrl;
|
||||
if (result.slug && tournamentUrl) {
|
||||
const href = `https://start.gg/tournament/${result.slug}/event/${bracketSlug(tournamentUrl)}`;
|
||||
parts.push(`<a href="${escapeHtml(href)}" target="_blank">Voir le Bracket</a>`);
|
||||
}
|
||||
parts.push('</td>');
|
||||
|
||||
if (columns % 2 === 0) parts.push('</tr>');
|
||||
}
|
||||
|
||||
if (columns % 2 !== 0) parts.push('</tr>');
|
||||
parts.push('</table>');
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* The column titles of the ranking grid. Shared so the CSV and the spreadsheet tab
|
||||
* (`$lib/tournaments/sheet`) cannot drift apart — they used to be two literals.
|
||||
*/
|
||||
export function rankingHeader(table: RankingTable): string[] {
|
||||
return ['Players', ...table.games.map((g) => g.name ?? ''), 'Total'];
|
||||
}
|
||||
|
||||
/** The ranking grid as CSV, in the dialect `$lib/csv` defines. */
|
||||
export function buildCsv(table: RankingTable): string {
|
||||
return toCsv(
|
||||
rankingHeader(table),
|
||||
table.rows.map((row) => [row.player, ...row.points, row.total])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { EventDTO } from '$lib/api/schema-helpers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { RankingTable } from './results';
|
||||
import { rankingToSheetTable, suggestedTabName } from './sheet';
|
||||
|
||||
const jan: EventDTO = { id: 1, name: 'Ranking #1301', date: '2026-01-10T00:00:00' };
|
||||
const jun: EventDTO = { id: 2, name: 'Ranking #1306', date: '2026-06-10T00:00:00' };
|
||||
const undated: EventDTO = { id: 3, name: 'Tournoi surprise' };
|
||||
|
||||
const table: RankingTable = {
|
||||
games: [{ id: 1, name: 'SF6' }],
|
||||
rows: [{ player: 'Bob', points: [10], total: 10 }]
|
||||
};
|
||||
|
||||
describe('suggestedTabName', () => {
|
||||
it('names the tab after the latest event, whatever the input order', () => {
|
||||
expect(suggestedTabName([jan, jun])).toBe('Ranking #1306');
|
||||
expect(suggestedTabName([jun, jan])).toBe('Ranking #1306');
|
||||
});
|
||||
|
||||
it('never lets an undated event claim to be the latest', () => {
|
||||
expect(suggestedTabName([jan, undated])).toBe('Ranking #1301');
|
||||
});
|
||||
|
||||
it('falls back to a generic label for an empty selection', () => {
|
||||
expect(suggestedTabName([])).toBe('Ranking');
|
||||
});
|
||||
|
||||
it('falls back to the id when the event has no usable name', () => {
|
||||
expect(suggestedTabName([{ id: 7, name: ' ' }])).toBe('Event 7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankingToSheetTable', () => {
|
||||
const options = { name: 'Ranking #1306', generatedAt: '2026-08-07T10:00:00.000Z' };
|
||||
|
||||
it('reuses the ranking header, so the tab and the CSV cannot drift', () => {
|
||||
const sheet = rankingToSheetTable(table, { ...options, events: [jun] });
|
||||
expect(sheet.header).toEqual(['Players', 'SF6', 'Total']);
|
||||
});
|
||||
|
||||
it('stamps the latest event id for provenance', () => {
|
||||
expect(rankingToSheetTable(table, { ...options, events: [jan, jun] }).eventId).toBe(2);
|
||||
});
|
||||
|
||||
/*
|
||||
* `latestId` used to find its event by comparing computed *display names*, so two
|
||||
* events sharing a name returned whichever came first in the array rather than the
|
||||
* latest one.
|
||||
*/
|
||||
it('picks the latest event by date even when two share a name', () => {
|
||||
const sheet = rankingToSheetTable(table, {
|
||||
...options,
|
||||
events: [
|
||||
{ id: 10, name: 'Ranking', date: '2026-01-01T00:00:00' },
|
||||
{ id: 20, name: 'Ranking', date: '2026-09-01T00:00:00' }
|
||||
]
|
||||
});
|
||||
expect(sheet.eventId).toBe(20);
|
||||
});
|
||||
|
||||
it('picks the latest even when every event falls back to the same generic label', () => {
|
||||
const sheet = rankingToSheetTable(table, {
|
||||
...options,
|
||||
events: [
|
||||
{ id: 0, name: null, date: '2026-01-01T00:00:00' },
|
||||
{ id: 0, name: null, date: '2026-09-01T00:00:00' }
|
||||
]
|
||||
});
|
||||
expect(sheet.eventId).toBe(0);
|
||||
});
|
||||
|
||||
it('uses eventId 0 for an empty selection', () => {
|
||||
expect(rankingToSheetTable(table, { ...options, events: [] }).eventId).toBe(0);
|
||||
});
|
||||
|
||||
it('names its sources in the footer so a stale tab says so', () => {
|
||||
const one = rankingToSheetTable(table, { ...options, events: [jun] });
|
||||
expect(one.footer?.[0]).toBe('Scored from Ranking #1306');
|
||||
|
||||
const many = rankingToSheetTable(table, { ...options, events: [jan, jun] });
|
||||
expect(many.footer?.[0]).toBe('Scored from 2 events: Ranking #1301, Ranking #1306');
|
||||
expect(many.footer?.[1]).toContain('2026-08-07T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('carries the rows through unchanged', () => {
|
||||
const sheet = rankingToSheetTable(table, { ...options, events: [jun] });
|
||||
expect(sheet.rows).toEqual([{ player: 'Bob', points: [10], total: 10 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { EventDTO, SheetTableDTO } from '$lib/api/schema-helpers';
|
||||
import { compareByDate } from '$lib/events';
|
||||
import { rankingHeader, 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 latest event of a selection — the one that names the tab, since a ranking day's
|
||||
* table aggregates every event up to that day. Undated events cannot claim to be the
|
||||
* latest, so they sort last. Returns undefined only for an empty list.
|
||||
*/
|
||||
function latestEvent(events: EventDTO[]): EventDTO | undefined {
|
||||
return [...events].sort((a, b) => compareByDate(a.date, b.date, 'desc'))[0];
|
||||
}
|
||||
|
||||
/** The title `latest` would be given, falling back to a generic but usable label. */
|
||||
function tabNameOf(event: EventDTO): string {
|
||||
return event.name?.trim() || `Event ${event.id ?? 0}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab a selection belongs in. Falls back to a generic label, so this always
|
||||
* returns something usable as a title.
|
||||
*/
|
||||
export function suggestedTabName(events: EventDTO[]): string {
|
||||
const latest = latestEvent(events);
|
||||
return latest ? tabNameOf(latest) : 'Ranking';
|
||||
}
|
||||
|
||||
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: latestEvent(options.events)?.id ?? 0,
|
||||
header: rankingHeader(table),
|
||||
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}`
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<script lang="ts">
|
||||
import type { EventAttendance } from '$lib/statistics/aggregate';
|
||||
import { formatMonth } from '$lib/statistics/aggregate';
|
||||
|
||||
interface Props {
|
||||
data: EventAttendance[];
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
/*
|
||||
* One series — unique players per event — so there is no legend: the heading
|
||||
* above the chart already says what is plotted, and a one-swatch legend would
|
||||
* just restate it. Columns rather than a line because the events are discrete
|
||||
* monthly rankings; a line would imply attendance existed between them.
|
||||
*
|
||||
* Marks are painted with the theme's own accent token, which is a different
|
||||
* (validated) step in light and dark, so the chart is not a flipped light chart.
|
||||
* Axes and labels wear text/line tokens, never the data colour.
|
||||
*/
|
||||
|
||||
// Rendered 1:1 against the measured container width: a viewBox that scales would
|
||||
// shrink the tick labels on a phone and blur the hairlines.
|
||||
let width = $state(880);
|
||||
const height = 260;
|
||||
const pad = { top: 14, right: 10, bottom: 28, left: 40 };
|
||||
|
||||
let host: HTMLDivElement | undefined = $state();
|
||||
$effect(() => {
|
||||
if (!host || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const measured = entries[0]?.contentRect.width ?? 0;
|
||||
if (measured > 0) width = Math.max(320, measured);
|
||||
});
|
||||
observer.observe(host);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
const plot = $derived({
|
||||
x: pad.left,
|
||||
y: pad.top,
|
||||
w: Math.max(10, width - pad.left - pad.right),
|
||||
h: Math.max(10, height - pad.top - pad.bottom)
|
||||
});
|
||||
|
||||
/** Clean axis ceiling — 0 / 20 / 40 rather than 0 / 17 / 34. */
|
||||
function niceTicks(max: number, wanted = 4): number[] {
|
||||
if (max <= 0) return [0, 1];
|
||||
const raw = max / wanted;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(raw));
|
||||
const step = [1, 2, 2.5, 5, 10].map((m) => m * magnitude).find((s) => s >= raw) ?? magnitude * 10;
|
||||
const ticks: number[] = [];
|
||||
for (let value = 0; value <= max + step / 2; value += step) ticks.push(Math.round(value));
|
||||
return ticks;
|
||||
}
|
||||
|
||||
const peak = $derived(Math.max(0, ...data.map((entry) => entry.players)));
|
||||
const ticks = $derived(niceTicks(peak));
|
||||
const scaleMax = $derived(ticks[ticks.length - 1] || 1);
|
||||
|
||||
const slot = $derived(data.length > 0 ? plot.w / data.length : plot.w);
|
||||
/** Capped at 24px, and 2px of the gap is the surface showing between neighbours. */
|
||||
const barWidth = $derived(Math.max(2, Math.min(24, slot - 2)));
|
||||
|
||||
interface Bar {
|
||||
entry: EventAttendance;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
slotX: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const bars = $derived<Bar[]>(
|
||||
data.map((entry, index) => {
|
||||
const barHeight = scaleMax === 0 ? 0 : (entry.players / scaleMax) * plot.h;
|
||||
const slotX = plot.x + index * slot;
|
||||
return {
|
||||
entry,
|
||||
x: slotX + (slot - barWidth) / 2,
|
||||
y: plot.y + plot.h - barHeight,
|
||||
w: barWidth,
|
||||
h: barHeight,
|
||||
slotX,
|
||||
label: formatMonth(entry.date) || entry.name
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
/** Square at the baseline, 4px rounded at the data end. */
|
||||
function columnPath(bar: Bar): string {
|
||||
const r = Math.min(4, bar.w / 2, bar.h);
|
||||
const base = bar.y + bar.h;
|
||||
if (r <= 0.5) return `M${bar.x} ${bar.y}h${bar.w}v${bar.h}h${-bar.w}Z`;
|
||||
return [
|
||||
`M${bar.x} ${base}`,
|
||||
`V${bar.y + r}`,
|
||||
`a${r} ${r} 0 0 1 ${r} ${-r}`,
|
||||
`h${bar.w - 2 * r}`,
|
||||
`a${r} ${r} 0 0 1 ${r} ${r}`,
|
||||
`V${base}`,
|
||||
'Z'
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/** Label the ends plus a sparse interior: a label on every column is unreadable. */
|
||||
const labelEvery = $derived(Math.max(1, Math.ceil(data.length / Math.max(1, Math.floor(width / 90)))));
|
||||
function showLabel(index: number): boolean {
|
||||
if (data.length <= 1) return true;
|
||||
if (index === 0 || index === data.length - 1) return true;
|
||||
return index % labelEvery === 0 && index > 0 && index < data.length - 1;
|
||||
}
|
||||
|
||||
let hovered = $state<number | null>(null);
|
||||
const active = $derived(hovered === null ? null : bars[hovered]);
|
||||
</script>
|
||||
|
||||
<div bind:this={host} class="relative w-full">
|
||||
{#if data.length === 0}
|
||||
<p class="py-10 text-center text-sm text-subtle">No event in this scope.</p>
|
||||
{:else}
|
||||
<svg
|
||||
{width}
|
||||
{height}
|
||||
role="img"
|
||||
aria-label="Unique players per event, oldest first. The same numbers are in the table below."
|
||||
class="block"
|
||||
>
|
||||
<!-- Gridlines: hairline, solid, one step off the surface. -->
|
||||
{#each ticks as tick (tick)}
|
||||
{@const y = plot.y + plot.h - (scaleMax === 0 ? 0 : (tick / scaleMax) * plot.h)}
|
||||
<line
|
||||
x1={plot.x}
|
||||
x2={plot.x + plot.w}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="var(--app-line)"
|
||||
stroke-width="1"
|
||||
shape-rendering="crispEdges"
|
||||
/>
|
||||
<text
|
||||
x={plot.x - 8}
|
||||
{y}
|
||||
dy="0.32em"
|
||||
text-anchor="end"
|
||||
font-size="11"
|
||||
fill="var(--app-subtle)"
|
||||
>
|
||||
{tick}
|
||||
</text>
|
||||
{/each}
|
||||
|
||||
{#each bars as bar, index (bar.entry.eventId)}
|
||||
<path
|
||||
d={columnPath(bar)}
|
||||
fill="var(--app-accent)"
|
||||
opacity={hovered === null || hovered === index ? 1 : 0.45}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#each bars as bar, index (bar.entry.eventId)}
|
||||
{#if showLabel(index)}
|
||||
<text
|
||||
x={bar.x + bar.w / 2}
|
||||
y={plot.y + plot.h + 18}
|
||||
text-anchor="middle"
|
||||
font-size="11"
|
||||
fill="var(--app-subtle)"
|
||||
>
|
||||
{bar.label}
|
||||
</text>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Hit targets are the full slot height, so thin columns stay hoverable. -->
|
||||
{#each bars as bar, index (bar.entry.eventId)}
|
||||
<rect
|
||||
x={bar.slotX}
|
||||
y={plot.y}
|
||||
width={Math.max(slot, 6)}
|
||||
height={plot.h}
|
||||
fill="transparent"
|
||||
onmouseenter={() => (hovered = index)}
|
||||
onmouseleave={() => (hovered = null)}
|
||||
role="presentation"
|
||||
/>
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
{#if active}
|
||||
<div
|
||||
class="pointer-events-none absolute z-10 w-44 rounded-lg border border-line bg-overlay p-2 text-xs shadow-card"
|
||||
style="left: {Math.min(
|
||||
Math.max(active.x + active.w / 2 - 88, 0),
|
||||
Math.max(width - 176, 0)
|
||||
)}px; top: {Math.max(active.y - 92, 0)}px"
|
||||
>
|
||||
<p class="font-semibold text-ink">{active.entry.name}</p>
|
||||
{#if formatMonth(active.entry.date)}
|
||||
<p class="text-subtle">{formatMonth(active.entry.date)}</p>
|
||||
{/if}
|
||||
<dl class="mt-1 space-y-0.5 text-muted">
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt>Players</dt>
|
||||
<dd class="font-semibold text-ink">{active.entry.players}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt>Entries</dt>
|
||||
<dd>{active.entry.entries}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-2">
|
||||
<dt>Brackets</dt>
|
||||
<dd>{active.entry.brackets}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,245 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import ThemeToggle from '$lib/ui/ThemeToggle.svelte';
|
||||
import {
|
||||
iconButton,
|
||||
menuItem,
|
||||
menuItemActive,
|
||||
menuPanel,
|
||||
navLink,
|
||||
navLinkActive
|
||||
} from '$lib/ui/classes';
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Tournaments is the app's reason to exist, so it leads. */
|
||||
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. */
|
||||
const settingsLinks = $derived<NavItem[]>([
|
||||
{ href: '/games', label: 'Games' },
|
||||
...(session.isAdmin ? [{ href: '/users', label: 'Users' }] : [])
|
||||
]);
|
||||
|
||||
const path = $derived(page.url.pathname);
|
||||
const isLoginPage = $derived(path === '/login');
|
||||
const showNav = $derived(!isLoginPage && session.isLoggedIn);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return path === href || path.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
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<'stats' | 'settings' | 'mobile' | null>(null);
|
||||
|
||||
/** Close on outside click, on Escape, and whenever the route changes. */
|
||||
$effect(() => {
|
||||
if (openMenu === null) return;
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!(event.target instanceof Element)) return;
|
||||
if (!event.target.closest('[data-menu-root]')) openMenu = null;
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') openMenu = null;
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Reading `path` subscribes this effect to navigation.
|
||||
void path;
|
||||
openMenu = null;
|
||||
});
|
||||
|
||||
async function signOut() {
|
||||
openMenu = null;
|
||||
session.clear();
|
||||
await goto('/login', { replaceState: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="#main"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-accent focus:px-3 focus:py-2 focus:text-sm focus:font-semibold focus:text-on-accent"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-40 h-(--nav-h) border-b border-line bg-canvas/80 backdrop-blur-md"
|
||||
>
|
||||
<div class="mx-auto flex h-full max-w-6xl items-center gap-2 px-4">
|
||||
{#if showNav}
|
||||
<a
|
||||
href="/"
|
||||
class="mr-1 shrink-0 rounded-lg px-1 text-base font-semibold tracking-tight text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
aria-current={path === '/' ? 'page' : undefined}
|
||||
>
|
||||
LaDOSE
|
||||
</a>
|
||||
|
||||
<!--
|
||||
One menu entry. Rendered by both dropdowns and by every section of the
|
||||
mobile panel, which used to hold three hand-copied versions of this.
|
||||
-->
|
||||
{#snippet menuLink(link: NavItem)}
|
||||
<a
|
||||
href={link.href}
|
||||
role="menuitem"
|
||||
class={isActive(link.href) ? menuItemActive : menuItem}
|
||||
aria-current={isActive(link.href) ? 'page' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<!--
|
||||
Both desktop dropdowns are the same control with a different list, so they
|
||||
share one snippet: two hand-copied panels drift the moment one is touched.
|
||||
-->
|
||||
{#snippet dropdown(id: 'stats' | 'settings', label: string, active: boolean, links: NavItem[])}
|
||||
<div class="relative" data-menu-root>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (openMenu = openMenu === id ? null : id)}
|
||||
class="{active ? navLinkActive : navLink} inline-flex items-center gap-1"
|
||||
aria-expanded={openMenu === id}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
{label}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-3 transition-transform {openMenu === id ? 'rotate-180' : ''}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if openMenu === id}
|
||||
<div class="{menuPanel} w-56" role="menu">
|
||||
{#each links as link (link.href)}
|
||||
{@render menuLink(link)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<nav class="hidden items-center gap-1 sm:flex" aria-label="Main">
|
||||
{#each mainLinks as link (link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
class={isActive(link.href) ? navLinkActive : navLink}
|
||||
aria-current={isActive(link.href) ? 'page' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
{@render dropdown('stats', 'Statistiques', statsActive, statsLinks)}
|
||||
{@render dropdown('settings', 'Settings', settingsActive, settingsLinks)}
|
||||
</nav>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<span class="hidden text-sm text-muted md:inline">{session.displayName}</span>
|
||||
<ThemeToggle />
|
||||
<button type="button" onclick={signOut} class="hidden {navLink} sm:inline-block">
|
||||
Sign out
|
||||
</button>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div class="relative sm:hidden" data-menu-root>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (openMenu = openMenu === 'mobile' ? null : 'mobile')}
|
||||
class={iconButton}
|
||||
aria-expanded={openMenu === 'mobile'}
|
||||
aria-haspopup="menu"
|
||||
aria-label="Menu"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if openMenu === 'mobile'}
|
||||
<path d="M6 6l12 12M18 6 6 18" />
|
||||
{:else}
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if openMenu === 'mobile'}
|
||||
<div class="{menuPanel} w-56" role="menu">
|
||||
{#each mainLinks as link (link.href)}
|
||||
{@render menuLink(link)}
|
||||
{/each}
|
||||
|
||||
<p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase">
|
||||
Statistiques
|
||||
</p>
|
||||
{#each statsLinks as link (link.href)}
|
||||
{@render menuLink(link)}
|
||||
{/each}
|
||||
|
||||
<p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase">
|
||||
Settings
|
||||
</p>
|
||||
{#each settingsLinks as link (link.href)}
|
||||
{@render menuLink(link)}
|
||||
{/each}
|
||||
|
||||
<div class="mt-1 border-t border-line pt-1">
|
||||
<button type="button" role="menuitem" onclick={signOut} class={menuItem}>
|
||||
Sign out{session.displayName ? ` (${session.displayName})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Signed out, or on /login: the page owns its own branding, but the
|
||||
theme toggle has to stay reachable. -->
|
||||
<div class="ml-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import type { PlayerOptionDTO } from '$lib/api/schema-helpers';
|
||||
import { field, label as labelClass, listRow, listRowSelected } from '$lib/ui/classes';
|
||||
|
||||
interface Props {
|
||||
heading: string;
|
||||
players: PlayerOptionDTO[];
|
||||
/** The chosen player id, or null. Bindable. */
|
||||
selectedId?: number | null;
|
||||
/** Taken on the other side: still listed, but not selectable. */
|
||||
excludeId?: number | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
heading,
|
||||
players,
|
||||
selectedId = $bindable(null),
|
||||
excludeId = null,
|
||||
disabled = false
|
||||
}: Props = $props();
|
||||
|
||||
/*
|
||||
* A filter box over a scrolling list rather than a <select>: there are several
|
||||
* hundred players, they are recognised by gamertag rather than by position, and a
|
||||
* native select on a phone would hide all of that behind a spinner.
|
||||
*/
|
||||
|
||||
let query = $state('');
|
||||
|
||||
/** Rendering every match would put hundreds of buttons in the DOM for no gain. */
|
||||
const LIMIT = 120;
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (needle === '') return players;
|
||||
return players.filter((player) => (player.name ?? '').toLowerCase().includes(needle));
|
||||
});
|
||||
|
||||
const shown = $derived(filtered.slice(0, LIMIT));
|
||||
const hidden = $derived(Math.max(0, filtered.length - shown.length));
|
||||
const selected = $derived(players.find((player) => player.id === selectedId) ?? null);
|
||||
|
||||
function choose(player: PlayerOptionDTO) {
|
||||
if (player.id === undefined || player.id === excludeId) return;
|
||||
selectedId = player.id === selectedId ? null : player.id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<p class={labelClass}>{heading}</p>
|
||||
|
||||
<p class="mt-1 truncate text-sm" aria-live="polite">
|
||||
{#if selected}
|
||||
<span class="font-semibold text-ink">{selected.name}</span>
|
||||
<span class="text-subtle">· {selected.sets ?? 0} sets</span>
|
||||
{:else}
|
||||
<span class="text-subtle">Nobody selected yet.</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<input
|
||||
bind:value={query}
|
||||
class="{field} mt-2"
|
||||
placeholder="Filter by gamertag"
|
||||
aria-label="{heading} — filter by gamertag"
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<ul class="mt-2 max-h-64 space-y-0.5 overflow-y-auto pr-1 text-sm">
|
||||
{#each shown as player (player.id)}
|
||||
{@const taken = player.id === excludeId}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="{player.id === selectedId ? listRowSelected : listRow} flex items-baseline gap-2"
|
||||
aria-pressed={player.id === selectedId}
|
||||
disabled={disabled || taken}
|
||||
onclick={() => choose(player)}
|
||||
>
|
||||
<span class="truncate">{player.name}</span>
|
||||
<span class="ml-auto shrink-0 text-xs text-subtle tabular-nums">
|
||||
{taken ? 'picked' : (player.sets ?? 0)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="px-2 py-6 text-center text-subtle">
|
||||
{players.length === 0 ? 'No player with recorded sets.' : 'No gamertag matches.'}
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
{#if hidden > 0}
|
||||
<li class="px-2 pt-2 text-xs text-subtle">
|
||||
{hidden} more match — narrow the filter to reach {hidden === 1 ? 'it' : 'them'}.
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { iconButton } from '$lib/ui/classes';
|
||||
|
||||
const isDark = $derived(theme.resolved === 'dark');
|
||||
const nextLabel = $derived(isDark ? 'Switch to light theme' : 'Switch to dark theme');
|
||||
// "Following your system" matters: it is the difference between a pinned
|
||||
// preference and one that will change under the user later.
|
||||
const title = $derived(
|
||||
theme.preference === 'system'
|
||||
? `Following your system (${theme.resolved}). ${nextLabel}.`
|
||||
: nextLabel
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => theme.toggle()}
|
||||
class={iconButton}
|
||||
aria-label={nextLabel}
|
||||
{title}
|
||||
>
|
||||
{#if isDark}
|
||||
<!-- Sun: clicking moves to light -->
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Moon: clicking moves to dark -->
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.6 6.6 0 0 0 9.8 9.8Z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The Tailwind class strings shared by the app's pages, expressed entirely in the
|
||||
* semantic tokens defined in `src/app.css`. Nothing here may name a fixed colour:
|
||||
* these six constants cover most of the app's themed surface, so a hard-coded
|
||||
* shade in this file is a hard-coded shade almost everywhere.
|
||||
*/
|
||||
|
||||
export const card =
|
||||
'rounded-2xl border border-line bg-surface p-5 shadow-card backdrop-blur';
|
||||
|
||||
export const field =
|
||||
'w-full rounded-lg border border-line bg-inset px-3 py-2 text-sm text-ink outline-none transition placeholder:text-subtle focus:border-accent focus:ring-2 focus:ring-accent/40 disabled:opacity-60';
|
||||
|
||||
export const primary =
|
||||
'rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-on-accent transition hover:bg-accent-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-canvas disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
export const ghost =
|
||||
'rounded-lg border border-line-strong px-3 py-2 text-sm font-medium text-ink transition hover:bg-ink/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
export const danger =
|
||||
'rounded-lg border border-danger/30 px-3 py-2 text-sm font-medium text-danger transition hover:bg-danger-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-danger disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
export const label = 'block text-xs font-medium tracking-wide text-muted uppercase';
|
||||
|
||||
/** Section heading inside a `card`. */
|
||||
export const cardHeading = 'text-sm font-semibold tracking-wide text-ink uppercase';
|
||||
|
||||
/** Feedback banners. Rendered identically on every page, so they live here. */
|
||||
export const alertError = 'rounded-lg bg-danger-soft px-3 py-2 text-sm text-danger';
|
||||
export const alertNotice = 'rounded-lg bg-success-soft px-3 py-2 text-sm text-success';
|
||||
export const alertWarning = 'rounded-lg bg-warning-soft px-3 py-2 text-sm text-warning';
|
||||
|
||||
/**
|
||||
* Selectable row in a list (games, events, tab strips). `selected` is the quiet
|
||||
* treatment; `activeTab` is the loud one used where the choice drives a panel.
|
||||
*/
|
||||
export const listRow =
|
||||
'w-full rounded-lg px-2 py-1.5 text-left transition text-muted hover:bg-ink/5';
|
||||
export const listRowSelected = 'w-full rounded-lg px-2 py-1.5 text-left transition bg-ink/10 text-ink';
|
||||
|
||||
export const tab = 'rounded-lg px-3 py-1.5 text-sm font-medium transition text-muted hover:bg-ink/5';
|
||||
export const tabActive =
|
||||
'rounded-lg px-3 py-1.5 text-sm font-medium transition bg-accent text-on-accent';
|
||||
|
||||
/** Navbar links. `navLinkActive` also carries `aria-current="page"` at the call site. */
|
||||
export const navLink =
|
||||
'rounded-lg px-3 py-1.5 text-sm font-medium text-muted transition hover:bg-ink/5 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent';
|
||||
export const navLinkActive =
|
||||
'rounded-lg px-3 py-1.5 text-sm font-medium text-ink transition bg-ink/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent';
|
||||
|
||||
/** Square button for an icon only — the theme toggle and the menu button. */
|
||||
export const iconButton =
|
||||
'inline-flex size-9 items-center justify-center rounded-lg border border-line-strong text-ink transition hover:bg-ink/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent';
|
||||
|
||||
/**
|
||||
* Dropdown panel, e.g. the Statistiques and Settings menus in the navbar.
|
||||
*
|
||||
* `bg-overlay`, never `bg-surface`: a menu is painted over the page, so it has to be
|
||||
* opaque — the surface token is deliberately translucent in dark mode and let the
|
||||
* heading underneath read straight through the panel. No backdrop blur either, it
|
||||
* does nothing behind an opaque fill.
|
||||
*/
|
||||
export const menuPanel =
|
||||
'absolute right-0 z-50 mt-2 w-52 overflow-hidden rounded-xl border border-line bg-overlay p-1 shadow-card';
|
||||
export const menuItem =
|
||||
'block w-full rounded-lg px-3 py-2 text-left text-sm text-muted transition hover:bg-ink/5 hover:text-ink focus:outline-none focus-visible:bg-ink/5 focus-visible:text-ink';
|
||||
export const menuItemActive =
|
||||
'block w-full rounded-lg px-3 py-2 text-left text-sm text-ink transition bg-ink/10 focus:outline-none';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user