This commit is contained in:
@@ -27,6 +27,56 @@
|
|||||||
#LADOSE_SMASH_API_KEY=
|
#LADOSE_SMASH_API_KEY=
|
||||||
#LADOSE_CHALLONGE_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 ------------------------------------------------------------------------
|
# --- Frontend ------------------------------------------------------------------------
|
||||||
# Only needed if the API is not on http://localhost:${LADOSE_API_PORT}. Resolved by the
|
# 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.
|
# browser, so container names like http://api:5000 will not work.
|
||||||
|
|||||||
@@ -335,3 +335,10 @@ ASALocalRun/
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
docker-compose.override.yml
|
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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,5 +34,29 @@ namespace LaDOSE.Api.Controllers
|
|||||||
var stats = await _service.GetMatchStats(ids);
|
var stats = await _service.GetMatchStats(ids);
|
||||||
return _mapper.Map<MatchStatsDTO>(stats);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.18" 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="Scalar.AspNetCore" Version="2.16.17" Condition="'$(Configuration)' == 'Debug'" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.7" />
|
<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" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ using AutoMapper;
|
|||||||
using LaDOSE.Api.Helpers;
|
using LaDOSE.Api.Helpers;
|
||||||
using LaDOSE.Business.Helper;
|
using LaDOSE.Business.Helper;
|
||||||
using LaDOSE.Business.Provider.ChallongProvider;
|
using LaDOSE.Business.Provider.ChallongProvider;
|
||||||
|
using LaDOSE.Business.Provider.SheetsProvider;
|
||||||
using LaDOSE.Business.Provider.SmashProvider;
|
using LaDOSE.Business.Provider.SmashProvider;
|
||||||
using LaDOSE.Entity.Challonge;
|
using LaDOSE.Entity.Challonge;
|
||||||
using LaDOSE.Entity.Wordpress;
|
using LaDOSE.Entity.Wordpress;
|
||||||
@@ -69,7 +70,11 @@ namespace LaDOSE.Api
|
|||||||
}).AddNewtonsoftJson(x =>
|
}).AddNewtonsoftJson(x =>
|
||||||
{
|
{
|
||||||
x.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
|
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
|
#if DEBUG
|
||||||
services.AddOpenApi();
|
services.AddOpenApi();
|
||||||
@@ -162,6 +167,19 @@ namespace LaDOSE.Api
|
|||||||
cfg.CreateMap<MatchCoverage, LaDOSE.DTO.MatchCoverageDTO>();
|
cfg.CreateMap<MatchCoverage, LaDOSE.DTO.MatchCoverageDTO>();
|
||||||
cfg.CreateMap<PlayerMatchStats, LaDOSE.DTO.PlayerMatchStatsDTO>();
|
cfg.CreateMap<PlayerMatchStats, LaDOSE.DTO.PlayerMatchStatsDTO>();
|
||||||
cfg.CreateMap<HeadToHead, LaDOSE.DTO.HeadToHeadDTO>();
|
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();
|
IMapper mapper = mapperConfig.CreateMapper();
|
||||||
@@ -194,6 +212,45 @@ namespace LaDOSE.Api
|
|||||||
this.Configuration["ApiKey:SmashApiKey"]));
|
this.Configuration["ApiKey:SmashApiKey"]));
|
||||||
services.AddScoped<IExternalProviderService, ExternalProviderService>();
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Warning"
|
"Default": "Warning",
|
||||||
|
"LaDOSE": "Information"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
@@ -13,7 +14,17 @@
|
|||||||
},
|
},
|
||||||
"ApiKey": {
|
"ApiKey": {
|
||||||
"ChallongeApiKey": "Challonge 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",
|
"AllowedHosts": "0.0.0.0",
|
||||||
"Port": 5000,
|
"Port": 5000,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
+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 "$@"
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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,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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<PackageReference Include="RestSharp" Version="112.1.0" />
|
<PackageReference Include="RestSharp" Version="112.1.0" />
|
||||||
</ItemGroup>
|
</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,5 +12,19 @@ namespace LaDOSE.Business.Interface
|
|||||||
/// A null or empty id list yields a well-formed, zeroed <see cref="MatchStats"/>.
|
/// A null or empty id list yields a well-formed, zeroed <see cref="MatchStats"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<MatchStats> GetMatchStats(List<int> eventIds);
|
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,10 +8,11 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Google.Apis.Sheets.v4" Version="1.75.0.4178" />
|
||||||
<PackageReference Include="GraphQL.Client" Version="6.1.0" />
|
<PackageReference Include="GraphQL.Client" Version="6.1.0" />
|
||||||
<PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="6.1.0" />
|
<PackageReference Include="GraphQL.Client.Serializer.Newtonsoft" Version="6.1.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
|
<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>
|
||||||
|
|
||||||
<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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,15 @@ namespace LaDOSE.Business.Service
|
|||||||
/// - There is no winner column. The winner is inferred from the scores, and start.gg
|
/// - 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.
|
/// 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"/>
|
/// The database work and the aggregation are deliberately separated: <see cref="GetMatchStats"/>
|
||||||
/// issues one query per table and hands the loaded lists to the pure static
|
/// and <see cref="GetVersus"/> issue one query per table and hand the loaded lists to the pure
|
||||||
/// <see cref="Aggregate"/>, which is unit-testable without a database.
|
/// static <see cref="Aggregate"/> / <see cref="AggregateVersus"/>, which are unit-testable
|
||||||
|
/// without a database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class StatisticsService : IStatisticsService
|
public class StatisticsService : IStatisticsService
|
||||||
{
|
{
|
||||||
@@ -78,6 +84,108 @@ namespace LaDOSE.Business.Service
|
|||||||
return Task.FromResult(Aggregate(requested, events, tournaments, sets, players));
|
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>
|
/// <summary>
|
||||||
/// Pure aggregation over already-loaded rows. No database, no I/O, deterministic.
|
/// Pure aggregation over already-loaded rows. No database, no I/O, deterministic.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -224,6 +332,142 @@ namespace LaDOSE.Business.Service
|
|||||||
return result;
|
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,
|
private static PlayerMatchStats GetOrAdd(Dictionary<int, PlayerMatchStats> stats, int playerId,
|
||||||
Dictionary<int, string> nameById)
|
Dictionary<int, string> nameById)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# Base URL of LaDOSE.Api. Defaults to http://localhost:5000 when unset,
|
|
||||||
# which matches the Kestrel binding the API uses in development.
|
|
||||||
#
|
|
||||||
# Read by `vite dev` and inlined at build time (`npm run build`, or the Dockerfile's
|
|
||||||
# `--build-arg VITE_API_BASE_URL=...`). Baking it is optional.
|
|
||||||
VITE_API_BASE_URL=http://localhost:5000
|
|
||||||
|
|
||||||
# Container runtime only, and the reason one image serves every environment:
|
|
||||||
# docker-entrypoint.sh turns this into /config.js on start, which app.html loads
|
|
||||||
# before the bundle. Not a Vite variable, so it does NOT belong in a .env file.
|
|
||||||
#
|
|
||||||
# podman run -e LADOSE_API_BASE_URL=https://api.ladose.net ladose-webapp
|
|
||||||
#
|
|
||||||
# Precedence: /config.js > baked VITE_API_BASE_URL > http://localhost:5000.
|
|
||||||
# Leave it unset and /config.js is `{}`, so the baked value stays in charge.
|
|
||||||
# LADOSE_API_BASE_URL=https://api.ladose.net
|
|
||||||
@@ -43,7 +43,7 @@ DTO field surfaces as a TypeScript error rather than a runtime 404.
|
|||||||
|
|
||||||
| Module | Purpose |
|
| Module | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `src/lib/api/schema.d.ts` | Generated types — all 23 API paths and every DTO |
|
| `src/lib/api/schema.d.ts` | Generated types — all 31 API paths and every DTO |
|
||||||
| `src/lib/api/schema-helpers.ts` | Friendly aliases (`ApplicationUserDTO`, `LoginRequest`, …) |
|
| `src/lib/api/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/client.ts` | `apiRequest` — bearer auth, JSON, `ApiError`; paths constrained to real routes |
|
||||||
| `src/lib/api/users.ts` | `login` / `register` against `/Users/auth` and `/Users/register` |
|
| `src/lib/api/users.ts` | `login` / `register` against `/Users/auth` and `/Users/register` |
|
||||||
@@ -51,10 +51,13 @@ DTO field surfaces as a TypeScript error rather than a runtime 404.
|
|||||||
| `src/lib/api/tournaments.ts` | `listEvents` / `importSmashTournament` / `getResults`, authenticated from the session |
|
| `src/lib/api/tournaments.ts` | `listEvents` / `importSmashTournament` / `getResults`, authenticated from the session |
|
||||||
| `src/lib/api/games.ts` | `listGames` / `saveGame` / `deleteGame` / `searchSmashGames` |
|
| `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/admin-users.ts` | `listUsers` / `listRoles` / `addUser` / `deleteUser` — all Admin-only |
|
||||||
| `src/lib/api/statistics.ts` | `getMatchStats` against `POST /api/Statistics/Matches` — set-level win/loss and head to head |
|
| `src/lib/api/statistics.ts` | `getMatchStats` (set-level win/loss and head to head), `listVersusPlayers` / `getVersus` (one pairing, per game) |
|
||||||
|
| `src/lib/api/sheets.ts` | `getSheetsConfig` / `exportToSheets` — writes a ranking table into the club's Google Spreadsheet |
|
||||||
| `src/lib/tournaments/results.ts` | Pure reshaping of `TournamentsResultDTO`: ranking grid, per-game placements, WordPress HTML, CSV |
|
| `src/lib/tournaments/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/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/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/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/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 |
|
| `src/lib/stores/session.svelte.ts` | Signed-in user, persisted to `localStorage`, drops expired JWTs |
|
||||||
@@ -73,8 +76,13 @@ const one = await apiRequest<GameDTO>(buildPath('/api/Game/{id}', { id: 3 }));
|
|||||||
|
|
||||||
- `/login` — username + password, posts to `POST /Users/auth`, stores the returned JWT
|
- `/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
|
- `/` — guarded; greets the signed-in user with **Hello, \<name\>.** and offers sign-out
|
||||||
- `/tournaments` — guarded; the start.gg half of the old Avalonia `TournamentResultView`
|
- `/tournaments` — guarded; the start.gg half of the old Avalonia `TournamentResultView`,
|
||||||
- `/statistiques` — guarded; standings, attendance and match statistics over a chosen scope
|
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`
|
- `/games` — guarded; the game catalogue editor, from the Avalonia `GamesView`
|
||||||
- `/users` — **Admin only**; add and remove accounts
|
- `/users` — **Admin only**; add and remove accounts
|
||||||
|
|
||||||
@@ -92,16 +100,48 @@ Ports the Smash.gg (start.gg) column of `LaDOSE.DesktopApp.Avalonia`:
|
|||||||
selection with every matching event name (e.g. `Ranking #13\d{2}`).
|
selection with every matching event name (e.g. `Ranking #13\d{2}`).
|
||||||
3. **Generate results** — `POST /api/Tournament/GetResults` with the selected ids;
|
3. **Generate results** — `POST /api/Tournament/GetResults` with the selected ids;
|
||||||
the API applies the point rules in `ExternalProviderService`. Three views:
|
the API applies the point rules in `ExternalProviderService`. Three views:
|
||||||
- *Ranking* — players × games with totals, highest first, plus CSV export
|
- *Ranking* — players × games with totals, highest first, plus CSV export and the
|
||||||
|
Google Sheets push below
|
||||||
- *By game* — placements and points for one game
|
- *By game* — placements and points for one game
|
||||||
- *HTML* — the podium table for the WordPress recap, with copy-to-clipboard
|
- *HTML* — the podium table for the WordPress recap, with copy-to-clipboard
|
||||||
|
|
||||||
The Challonge half of the Avalonia view (date range, Challonge tournament list,
|
The Challonge half of the Avalonia view (date range, Challonge tournament list,
|
||||||
`ParseChallonge`) is deliberately not ported.
|
`ParseChallonge`) is deliberately not ported.
|
||||||
|
|
||||||
### `/statistiques`
|
#### Google Sheets export
|
||||||
|
|
||||||
Pick a scope — everything, the last 6/12 events, or a regex over event names — then
|
Next to **Export CSV** on the *Ranking* view: **Push to Google Sheets** writes that same
|
||||||
|
table straight into the club's spreadsheet, as one tab. It replaces the
|
||||||
|
download-then-import-by-hand step — the tab is the CSV, because `rankingToSheetTable`
|
||||||
|
reads the very same `RankingTable` that `buildCsv` does, so the two cannot drift.
|
||||||
|
|
||||||
|
Because `GetResults` merges everything it is given, selecting `Ranking #1301`, `#1302` and
|
||||||
|
`#1303` produces the cumulative table for that ranking day — which is why the tab title
|
||||||
|
defaults to the **latest** event in the selection (`Ranking #1303`). Override it in the
|
||||||
|
**Tab** box; leaving it blank uses the suggestion shown as the placeholder.
|
||||||
|
|
||||||
|
- The tab is **cleared and rewritten in place**. Formatting, notes and conditional
|
||||||
|
formatting survive (the write only sets `userEnteredValue`), but anything typed into it
|
||||||
|
by hand is lost — keep hand analysis in its own tab.
|
||||||
|
- Tabs the export does not name are **never touched or deleted**.
|
||||||
|
- Re-running changes nothing but the provenance footer's timestamp.
|
||||||
|
- Points and totals are written as **numbers**, so formulas over them keep working.
|
||||||
|
- Titles are sanitised server-side (Google forbids `: \ / ? * [ ]`, caps at 100 chars);
|
||||||
|
any rename is reported back in the success banner.
|
||||||
|
- Tab identity is the **title**, so renaming an event makes the next push write a new tab
|
||||||
|
beside the old one.
|
||||||
|
- The provenance footer, below the grid, names every event scored into the tab — so a
|
||||||
|
stale tab says so itself.
|
||||||
|
|
||||||
|
Server configuration lives in the `GoogleSheets` section — writer, target spreadsheet and
|
||||||
|
limits. The target is config-only because the sheet is replaced each year; see
|
||||||
|
`.env.example` for the one-time Google setup and the yearly swap. Set
|
||||||
|
`GoogleSheets:Writer` to `Logging` to see the exact payload in the API log without
|
||||||
|
touching a spreadsheet.
|
||||||
|
|
||||||
|
### `/statistiques/rankings`
|
||||||
|
|
||||||
|
**Rankings Statistiques.** Pick a scope — everything, the last 6/12 events, or a regex over event names — then
|
||||||
**Compute statistics**. Two independent sources feed the page, and they are kept apart
|
**Compute statistics**. Two independent sources feed the page, and they are kept apart
|
||||||
on purpose:
|
on purpose:
|
||||||
|
|
||||||
@@ -133,6 +173,31 @@ Other things worth knowing:
|
|||||||
- `aggregate.ts` is pure, so it can be exercised under plain Node with fixtures, the
|
- `aggregate.ts` is pure, so it can be exercised under plain Node with fixtures, the
|
||||||
same way `src/lib/tournaments/results.ts` is.
|
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`
|
### `/games`
|
||||||
|
|
||||||
Ports `GamesView`: the list on the left (ordered by `Order`), an editor on the right.
|
Ports `GamesView`: the list on the left (ordered by `Order`), an editor on the right.
|
||||||
@@ -190,6 +255,9 @@ How roles work:
|
|||||||
call site supplies its own fallback message.
|
call site supplies its own fallback message.
|
||||||
- `GetResults` only fills `slug` when **one** event id is requested, so the
|
- `GetResults` only fills `slug` when **one** event id is requested, so the
|
||||||
"Voir le Bracket" links appear only for a single-event export.
|
"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.
|
- 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
|
- `src/routes/+layout.ts` sets `ssr = false`: the JWT lives in the browser, so there
|
||||||
is nothing meaningful to render on the server.
|
is nothing meaningful to render on the server.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
"url": "http://localhost:5000/"
|
"url": "http://localhost:5055/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -440,6 +440,87 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Sheets/Config": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Sheets"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SheetsConfigDTO"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/Sheets/Export": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Sheets"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json-patch+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SheetExportRequestDTO"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SheetExportRequestDTO"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SheetExportRequestDTO"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/*+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SheetExportRequestDTO"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SheetExportResultDTO"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"content": {
|
||||||
|
"application/json": { }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Bad Gateway",
|
||||||
|
"content": {
|
||||||
|
"application/json": { }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable",
|
||||||
|
"content": {
|
||||||
|
"application/json": { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Statistics/Matches": {
|
"/api/Statistics/Matches": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -500,6 +581,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Statistics/Players": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Statistics"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PlayerOptionDTO"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/Statistics/Versus/{playerAId}/{playerBId}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Statistics"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "playerAId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "playerBId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PlayerVersusDTO"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Todo": {
|
"/api/Todo": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1578,6 +1720,71 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"PlayerOptionDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"sets": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PlayerVersusDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"playerAId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"playerA": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"playerBId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"playerB": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"sets": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"decidedSets": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"winsA": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"winsB": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"unknownGameSets": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"games": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/VersusGameStatsDTO"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"ResultDTO": {
|
"ResultDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1607,6 +1814,152 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SheetExportRequestDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tabs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SheetTableDTO"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SheetExportResultDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"spreadsheetId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"spreadsheetUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"writer": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SheetTabResultDTO"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"warnings": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SheetRowDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"player": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"points": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SheetsConfigDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"writer": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"configured": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"spreadsheetId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"maxTabs": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"maxRowsPerTab": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SheetTableDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"eventId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SheetRowDTO"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SheetTabResultDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"requestedName": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"TimeRangeDTO": {
|
"TimeRangeDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1709,6 +2062,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"VersusGameStatsDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"gameId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"game": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"gameLongName": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"sets": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"decidedSets": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"winsA": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"winsB": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"gamesWonA": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"gamesWonB": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"WPBooking": {
|
"WPBooking": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1905,6 +2299,9 @@
|
|||||||
{
|
{
|
||||||
"name": "Game"
|
"name": "Game"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Sheets"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Statistics"
|
"name": "Statistics"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ export type MatchStatsDTO = Schemas['MatchStatsDTO'];
|
|||||||
export type MatchCoverageDTO = Schemas['MatchCoverageDTO'];
|
export type MatchCoverageDTO = Schemas['MatchCoverageDTO'];
|
||||||
export type PlayerMatchStatsDTO = Schemas['PlayerMatchStatsDTO'];
|
export type PlayerMatchStatsDTO = Schemas['PlayerMatchStatsDTO'];
|
||||||
export type HeadToHeadDTO = Schemas['HeadToHeadDTO'];
|
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. */
|
/** The body `UsersController.Authenticate` binds — only these two fields are read. */
|
||||||
export type LoginRequest = Pick<ApplicationUserDTO, 'username' | 'password'>;
|
export type LoginRequest = Pick<ApplicationUserDTO, 'username' | 'password'>;
|
||||||
|
|||||||
+263
@@ -440,6 +440,110 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/Sheets/Config": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SheetsConfigDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/Sheets/Export": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json-patch+json": components["schemas"]["SheetExportRequestDTO"];
|
||||||
|
"application/json": components["schemas"]["SheetExportRequestDTO"];
|
||||||
|
"text/json": components["schemas"]["SheetExportRequestDTO"];
|
||||||
|
"application/*+json": components["schemas"]["SheetExportRequestDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SheetExportResultDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Request */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad Gateway */
|
||||||
|
502: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Service Unavailable */
|
||||||
|
503: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/Statistics/Matches": {
|
"/api/Statistics/Matches": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -482,6 +586,79 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/Statistics/Players": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlayerOptionDTO"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/Statistics/Versus/{playerAId}/{playerBId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
playerAId: number;
|
||||||
|
playerBId: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlayerVersusDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/Todo": {
|
"/api/Todo": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1386,6 +1563,32 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
gamesLost?: number;
|
gamesLost?: number;
|
||||||
};
|
};
|
||||||
|
PlayerOptionDTO: {
|
||||||
|
/** Format: int32 */
|
||||||
|
id?: number;
|
||||||
|
name?: string | null;
|
||||||
|
/** Format: int32 */
|
||||||
|
sets?: number;
|
||||||
|
};
|
||||||
|
PlayerVersusDTO: {
|
||||||
|
/** Format: int32 */
|
||||||
|
playerAId?: number;
|
||||||
|
playerA?: string | null;
|
||||||
|
/** Format: int32 */
|
||||||
|
playerBId?: number;
|
||||||
|
playerB?: string | null;
|
||||||
|
/** Format: int32 */
|
||||||
|
sets?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
decidedSets?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
winsA?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
winsB?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
unknownGameSets?: number;
|
||||||
|
games?: components["schemas"]["VersusGameStatsDTO"][] | null;
|
||||||
|
};
|
||||||
ResultDTO: {
|
ResultDTO: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
gameId?: number;
|
gameId?: number;
|
||||||
@@ -1398,6 +1601,48 @@ export interface components {
|
|||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
rank?: number;
|
rank?: number;
|
||||||
};
|
};
|
||||||
|
SheetExportRequestDTO: {
|
||||||
|
tabs?: components["schemas"]["SheetTableDTO"][] | null;
|
||||||
|
};
|
||||||
|
SheetExportResultDTO: {
|
||||||
|
spreadsheetId?: string | null;
|
||||||
|
spreadsheetUrl?: string | null;
|
||||||
|
writer?: string | null;
|
||||||
|
tabs?: components["schemas"]["SheetTabResultDTO"][] | null;
|
||||||
|
warnings?: string[] | null;
|
||||||
|
};
|
||||||
|
SheetRowDTO: {
|
||||||
|
player?: string | null;
|
||||||
|
points?: number[] | null;
|
||||||
|
/** Format: int32 */
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
SheetsConfigDTO: {
|
||||||
|
writer?: string | null;
|
||||||
|
configured?: boolean;
|
||||||
|
spreadsheetId?: string | null;
|
||||||
|
/** Format: int32 */
|
||||||
|
maxTabs?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
maxRowsPerTab?: number;
|
||||||
|
};
|
||||||
|
SheetTableDTO: {
|
||||||
|
name?: string | null;
|
||||||
|
/** Format: int32 */
|
||||||
|
eventId?: number;
|
||||||
|
header?: string[] | null;
|
||||||
|
rows?: components["schemas"]["SheetRowDTO"][] | null;
|
||||||
|
footer?: string[] | null;
|
||||||
|
};
|
||||||
|
SheetTabResultDTO: {
|
||||||
|
requestedName?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
/** Format: int32 */
|
||||||
|
rows?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
columns?: number;
|
||||||
|
created?: boolean;
|
||||||
|
};
|
||||||
TimeRangeDTO: {
|
TimeRangeDTO: {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
from?: string | null;
|
from?: string | null;
|
||||||
@@ -1430,6 +1675,24 @@ export interface components {
|
|||||||
results?: components["schemas"]["ResultDTO"][] | null;
|
results?: components["schemas"]["ResultDTO"][] | null;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
};
|
};
|
||||||
|
VersusGameStatsDTO: {
|
||||||
|
/** Format: int32 */
|
||||||
|
gameId?: number;
|
||||||
|
game?: string | null;
|
||||||
|
gameLongName?: string | null;
|
||||||
|
/** Format: int32 */
|
||||||
|
sets?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
decidedSets?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
winsA?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
winsB?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
gamesWonA?: number;
|
||||||
|
/** Format: int32 */
|
||||||
|
gamesWonB?: number;
|
||||||
|
};
|
||||||
WPBooking: {
|
WPBooking: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
wpEventId?: number;
|
wpEventId?: number;
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { session } from '$lib/stores/session.svelte';
|
||||||
|
import { apiRequest, type RequestOptions } from './client';
|
||||||
|
import type {
|
||||||
|
SheetExportRequestDTO,
|
||||||
|
SheetExportResultDTO,
|
||||||
|
SheetsConfigDTO
|
||||||
|
} from './schema-helpers';
|
||||||
|
|
||||||
|
/** SheetsController is `[Authorize]`, like the rest of the API. */
|
||||||
|
function authed(options: RequestOptions): RequestOptions {
|
||||||
|
return { ...options, token: options.token ?? session.token };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/Sheets/Config — whether the export is usable and which spreadsheet it points
|
||||||
|
* at. The target is server configuration (it is reset each year), so the UI reads it
|
||||||
|
* rather than offering it as an input. Carries no credentials.
|
||||||
|
*/
|
||||||
|
export function getSheetsConfig(options: RequestOptions = {}): Promise<SheetsConfigDTO> {
|
||||||
|
return apiRequest<SheetsConfigDTO>('/api/Sheets/Config', authed(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/Sheets/Export — writes one tab per table, in the order given.
|
||||||
|
*
|
||||||
|
* Existing tabs of the same name are cleared and rewritten in place, keeping their
|
||||||
|
* formatting; tabs not named in the request are left untouched. Re-running the same export
|
||||||
|
* is a no-op beyond the provenance timestamp.
|
||||||
|
*/
|
||||||
|
export function exportToSheets(
|
||||||
|
request: SheetExportRequestDTO,
|
||||||
|
options: RequestOptions = {}
|
||||||
|
): Promise<SheetExportResultDTO> {
|
||||||
|
return apiRequest<SheetExportResultDTO>('/api/Sheets/Export', {
|
||||||
|
...authed(options),
|
||||||
|
method: 'POST',
|
||||||
|
body: request
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { session } from '$lib/stores/session.svelte';
|
import { session } from '$lib/stores/session.svelte';
|
||||||
import { apiRequest, type RequestOptions } from './client';
|
import { apiRequest, buildPath, type RequestOptions } from './client';
|
||||||
import type { MatchStatsDTO } from './schema-helpers';
|
import type { MatchStatsDTO, PlayerOptionDTO, PlayerVersusDTO } from './schema-helpers';
|
||||||
|
|
||||||
/** StatisticsController is `[Authorize]`, like the tournament endpoints. */
|
/** StatisticsController is `[Authorize]`, like the tournament endpoints. */
|
||||||
function authed(options: RequestOptions): RequestOptions {
|
function authed(options: RequestOptions): RequestOptions {
|
||||||
@@ -27,3 +27,34 @@ export function getMatchStats(
|
|||||||
body: eventIds
|
body: eventIds
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/Statistics/Players — the tournament players a versus lookup can report
|
||||||
|
* on, alphabetically. **Not** the application accounts of `/api/Users`: these are
|
||||||
|
* the players `set` rows point at, and `sets` counts their meetings in brackets
|
||||||
|
* whose game is known — the same filter `getVersus` applies.
|
||||||
|
*/
|
||||||
|
export function listVersusPlayers(options: RequestOptions = {}): Promise<PlayerOptionDTO[]> {
|
||||||
|
return apiRequest<PlayerOptionDTO[]>('/api/Statistics/Players', authed(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/Statistics/Versus/{a}/{b} — every recorded meeting between two players,
|
||||||
|
* over every event, split per game. `winsA` is always the first id's side.
|
||||||
|
*
|
||||||
|
* A set only knows its bracket, and a bracket's game is nullable, so meetings we
|
||||||
|
* cannot attribute to a game are excluded from `games` and the totals and counted
|
||||||
|
* in `unknownGameSets` instead. Show that number: it is the difference between
|
||||||
|
* "they never met" and "we cannot tell what they played".
|
||||||
|
*/
|
||||||
|
export function getVersus(
|
||||||
|
playerAId: number,
|
||||||
|
playerBId: number,
|
||||||
|
options: RequestOptions = {}
|
||||||
|
): Promise<PlayerVersusDTO> {
|
||||||
|
const path = buildPath('/api/Statistics/Versus/{playerAId}/{playerBId}', {
|
||||||
|
playerAId,
|
||||||
|
playerBId
|
||||||
|
});
|
||||||
|
return apiRequest<PlayerVersusDTO>(path, authed(options));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { EventDTO, SheetTableDTO } from '$lib/api/schema-helpers';
|
||||||
|
import type { RankingTable } from './results';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the generated ranking grid into one spreadsheet tab.
|
||||||
|
*
|
||||||
|
* The header and row order come straight from `RankingTable`, i.e. from `buildRanking` —
|
||||||
|
* the same table the page shows and `buildCsv` exports. So the tab is the CSV, and the two
|
||||||
|
* cannot drift.
|
||||||
|
*
|
||||||
|
* Pure, like the rest of `$lib/tournaments`: exercisable under plain Node.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SheetTableOptions {
|
||||||
|
/** Tab title. */
|
||||||
|
name: string;
|
||||||
|
/** The events scored into this table, for the provenance footer. */
|
||||||
|
events: EventDTO[];
|
||||||
|
/** ISO timestamp, stamped into the footer. */
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab a selection belongs in: the latest event in it, since a ranking day's table
|
||||||
|
* aggregates every event up to that day. Falls back to the first event, then to a
|
||||||
|
* generic label, so this always returns something usable as a title.
|
||||||
|
*/
|
||||||
|
export function suggestedTabName(events: EventDTO[]): string {
|
||||||
|
if (events.length === 0) return 'Ranking';
|
||||||
|
|
||||||
|
const latest = [...events].sort((a, b) => {
|
||||||
|
const aTime = a.date ? Date.parse(a.date) : NaN;
|
||||||
|
const bTime = b.date ? Date.parse(b.date) : NaN;
|
||||||
|
const aOk = Number.isFinite(aTime);
|
||||||
|
const bOk = Number.isFinite(bTime);
|
||||||
|
// Undated events cannot claim to be the latest.
|
||||||
|
if (aOk && bOk) return bTime - aTime;
|
||||||
|
if (aOk) return -1;
|
||||||
|
if (bOk) return 1;
|
||||||
|
return 0;
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
return latest.name?.trim() || `Event ${latest.id ?? 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rankingToSheetTable(
|
||||||
|
table: RankingTable,
|
||||||
|
options: SheetTableOptions
|
||||||
|
): SheetTableDTO {
|
||||||
|
const names = options.events.map((event) => event.name ?? `#${event.id ?? 0}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: options.name,
|
||||||
|
// Provenance only — the tab is addressed by title. The latest event, matching
|
||||||
|
// suggestedTabName, so the id and the default title agree.
|
||||||
|
eventId: options.events.length ? (latestId(options.events) ?? 0) : 0,
|
||||||
|
header: ['Players', ...table.games.map((game) => game.name ?? ''), 'Total'],
|
||||||
|
rows: table.rows.map((row) => ({
|
||||||
|
player: row.player,
|
||||||
|
points: row.points,
|
||||||
|
total: row.total
|
||||||
|
})),
|
||||||
|
// So a stale tab says so itself instead of looking current.
|
||||||
|
footer: [
|
||||||
|
names.length === 1
|
||||||
|
? `Scored from ${names[0]}`
|
||||||
|
: `Scored from ${names.length} events: ${names.join(', ')}`,
|
||||||
|
`Written by LaDOSE on ${options.generatedAt}`
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestId(events: EventDTO[]): number | undefined {
|
||||||
|
const name = suggestedTabName(events);
|
||||||
|
return events.find((event) => (event.name?.trim() || `Event ${event.id ?? 0}`) === name)?.id;
|
||||||
|
}
|
||||||
@@ -18,9 +18,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Tournaments is the app's reason to exist, so it leads. */
|
/** Tournaments is the app's reason to exist, so it leads. */
|
||||||
const mainLinks: NavItem[] = [
|
const mainLinks: NavItem[] = [{ href: '/tournaments', label: 'Tournaments' }];
|
||||||
{ href: '/tournaments', label: 'Tournaments' },
|
|
||||||
{ href: '/statistiques', label: 'Statistiques' }
|
/**
|
||||||
|
* 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. */
|
/** Administration, tucked away: not what anyone opens the app to do. */
|
||||||
@@ -38,8 +44,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const settingsActive = $derived(settingsLinks.some((link) => isActive(link.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<'settings' | 'mobile' | null>(null);
|
let openMenu = $state<'stats' | 'settings' | 'mobile' | null>(null);
|
||||||
|
|
||||||
/** Close on outside click, on Escape, and whenever the route changes. */
|
/** Close on outside click, on Escape, and whenever the route changes. */
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -94,27 +102,20 @@
|
|||||||
LaDOSE
|
LaDOSE
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop navigation -->
|
<!--
|
||||||
<nav class="hidden items-center gap-1 sm:flex" aria-label="Main">
|
Both desktop dropdowns are the same control with a different list, so they
|
||||||
{#each mainLinks as link (link.href)}
|
share one snippet: two hand-copied panels drift the moment one is touched.
|
||||||
<a
|
-->
|
||||||
href={link.href}
|
{#snippet dropdown(id: 'stats' | 'settings', label: string, active: boolean, links: NavItem[])}
|
||||||
class={isActive(link.href) ? navLinkActive : navLink}
|
|
||||||
aria-current={isActive(link.href) ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<div class="relative" data-menu-root>
|
<div class="relative" data-menu-root>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (openMenu = openMenu === 'settings' ? null : 'settings')}
|
onclick={() => (openMenu = openMenu === id ? null : id)}
|
||||||
class="{settingsActive ? navLinkActive : navLink} inline-flex items-center gap-1"
|
class="{active ? navLinkActive : navLink} inline-flex items-center gap-1"
|
||||||
aria-expanded={openMenu === 'settings'}
|
aria-expanded={openMenu === id}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
>
|
>
|
||||||
Settings
|
{label}
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -122,16 +123,16 @@
|
|||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
class="size-3 transition-transform {openMenu === 'settings' ? 'rotate-180' : ''}"
|
class="size-3 transition-transform {openMenu === id ? 'rotate-180' : ''}"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<path d="m6 9 6 6 6-6" />
|
<path d="m6 9 6 6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if openMenu === 'settings'}
|
{#if openMenu === id}
|
||||||
<div class={menuPanel} role="menu">
|
<div class="{menuPanel} w-56" role="menu">
|
||||||
{#each settingsLinks as link (link.href)}
|
{#each links as link (link.href)}
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -144,6 +145,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<div class="ml-auto flex items-center gap-2">
|
||||||
@@ -181,7 +198,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if openMenu === 'mobile'}
|
{#if openMenu === 'mobile'}
|
||||||
<div class={menuPanel} role="menu">
|
<div class="{menuPanel} w-56" role="menu">
|
||||||
{#each mainLinks as link (link.href)}
|
{#each mainLinks as link (link.href)}
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
@@ -193,6 +210,20 @@
|
|||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase">
|
||||||
|
Statistiques
|
||||||
|
</p>
|
||||||
|
{#each statsLinks as link (link.href)}
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
role="menuitem"
|
||||||
|
class={isActive(link.href) ? menuItemActive : menuItem}
|
||||||
|
aria-current={isActive(link.href) ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
|
||||||
<p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase">
|
<p class="mt-1 px-3 pt-2 pb-1 text-xs tracking-wide text-subtle uppercase">
|
||||||
Settings
|
Settings
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PlayerOptionDTO } from '$lib/api/schema-helpers';
|
||||||
|
import { field, label as labelClass, listRow, listRowSelected } from '$lib/ui/classes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
heading: string;
|
||||||
|
players: PlayerOptionDTO[];
|
||||||
|
/** The chosen player id, or null. Bindable. */
|
||||||
|
selectedId?: number | null;
|
||||||
|
/** Taken on the other side: still listed, but not selectable. */
|
||||||
|
excludeId?: number | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
heading,
|
||||||
|
players,
|
||||||
|
selectedId = $bindable(null),
|
||||||
|
excludeId = null,
|
||||||
|
disabled = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A filter box over a scrolling list rather than a <select>: there are several
|
||||||
|
* hundred players, they are recognised by gamertag rather than by position, and a
|
||||||
|
* native select on a phone would hide all of that behind a spinner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let query = $state('');
|
||||||
|
|
||||||
|
/** Rendering every match would put hundreds of buttons in the DOM for no gain. */
|
||||||
|
const LIMIT = 120;
|
||||||
|
|
||||||
|
const filtered = $derived.by(() => {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
if (needle === '') return players;
|
||||||
|
return players.filter((player) => (player.name ?? '').toLowerCase().includes(needle));
|
||||||
|
});
|
||||||
|
|
||||||
|
const shown = $derived(filtered.slice(0, LIMIT));
|
||||||
|
const hidden = $derived(Math.max(0, filtered.length - shown.length));
|
||||||
|
const selected = $derived(players.find((player) => player.id === selectedId) ?? null);
|
||||||
|
|
||||||
|
function choose(player: PlayerOptionDTO) {
|
||||||
|
if (player.id === undefined || player.id === excludeId) return;
|
||||||
|
selectedId = player.id === selectedId ? null : player.id;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class={labelClass}>{heading}</p>
|
||||||
|
|
||||||
|
<p class="mt-1 truncate text-sm" aria-live="polite">
|
||||||
|
{#if selected}
|
||||||
|
<span class="font-semibold text-ink">{selected.name}</span>
|
||||||
|
<span class="text-subtle">· {selected.sets ?? 0} sets</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-subtle">Nobody selected yet.</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:value={query}
|
||||||
|
class="{field} mt-2"
|
||||||
|
placeholder="Filter by gamertag"
|
||||||
|
aria-label="{heading} — filter by gamertag"
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ul class="mt-2 max-h-64 space-y-0.5 overflow-y-auto pr-1 text-sm">
|
||||||
|
{#each shown as player (player.id)}
|
||||||
|
{@const taken = player.id === excludeId}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="{player.id === selectedId ? listRowSelected : listRow} flex items-baseline gap-2"
|
||||||
|
aria-pressed={player.id === selectedId}
|
||||||
|
disabled={disabled || taken}
|
||||||
|
onclick={() => choose(player)}
|
||||||
|
>
|
||||||
|
<span class="truncate">{player.name}</span>
|
||||||
|
<span class="ml-auto shrink-0 text-xs text-subtle tabular-nums">
|
||||||
|
{taken ? 'picked' : (player.sets ?? 0)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="px-2 py-6 text-center text-subtle">
|
||||||
|
{players.length === 0 ? 'No player with recorded sets.' : 'No gamertag matches.'}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if hidden > 0}
|
||||||
|
<li class="px-2 pt-2 text-xs text-subtle">
|
||||||
|
{hidden} more match — narrow the filter to reach {hidden === 1 ? 'it' : 'them'}.
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
@@ -52,9 +52,16 @@ export const navLinkActive =
|
|||||||
export const iconButton =
|
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';
|
'inline-flex size-9 items-center justify-center rounded-lg border border-line-strong text-ink transition hover:bg-ink/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent';
|
||||||
|
|
||||||
/** Dropdown panel, e.g. the Settings menu in the navbar. */
|
/**
|
||||||
|
* Dropdown panel, e.g. the Statistiques and Settings menus in the navbar.
|
||||||
|
*
|
||||||
|
* `bg-overlay`, never `bg-surface`: a menu is painted over the page, so it has to be
|
||||||
|
* opaque — the surface token is deliberately translucent in dark mode and let the
|
||||||
|
* heading underneath read straight through the panel. No backdrop blur either, it
|
||||||
|
* does nothing behind an opaque fill.
|
||||||
|
*/
|
||||||
export const menuPanel =
|
export const menuPanel =
|
||||||
'absolute right-0 z-50 mt-2 w-52 overflow-hidden rounded-xl border border-line bg-surface p-1 shadow-card backdrop-blur-lg';
|
'absolute right-0 z-50 mt-2 w-52 overflow-hidden rounded-xl border border-line bg-overlay p-1 shadow-card';
|
||||||
export const menuItem =
|
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';
|
'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 =
|
export const menuItemActive =
|
||||||
|
|||||||
@@ -22,9 +22,14 @@
|
|||||||
description: 'Import a start.gg tournament and score one event or a whole ranking season.'
|
description: 'Import a start.gg tournament and score one event or a whole ranking season.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/statistiques',
|
href: '/statistiques/rankings',
|
||||||
title: 'Statistiques',
|
title: 'Rankings Statistiques',
|
||||||
description: 'Leaderboards, attendance over time and head-to-head records.'
|
description: 'Leaderboards, attendance over time and head-to-head records for a scope.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/statistiques/players',
|
||||||
|
title: 'Players Statistiques',
|
||||||
|
description: 'Two players, every game they met in, and how the meetings went.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/games',
|
href: '/games',
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The statistics section is two pages now — Rankings and Players — so this path is
|
||||||
|
* only a landing spot. Redirect rather than delete it: it is what the navbar linked
|
||||||
|
* to before the split, and what any bookmark still points at.
|
||||||
|
*/
|
||||||
|
export const load = () => {
|
||||||
|
redirect(307, '/statistiques/rankings');
|
||||||
|
};
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { toErrorMessage } from '$lib/api/errors';
|
||||||
|
import type { PlayerOptionDTO, PlayerVersusDTO } from '$lib/api/schema-helpers';
|
||||||
|
import { getVersus, listVersusPlayers } from '$lib/api/statistics';
|
||||||
|
import { session } from '$lib/stores/session.svelte';
|
||||||
|
import {
|
||||||
|
alertError,
|
||||||
|
alertWarning,
|
||||||
|
card,
|
||||||
|
cardHeading,
|
||||||
|
ghost
|
||||||
|
} from '$lib/ui/classes';
|
||||||
|
import PlayerPicker from '$lib/ui/PlayerPicker.svelte';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* One question, all events: how often did these two actually meet, and in which
|
||||||
|
* games. That is a different shape from the Rankings page — no event scope, no
|
||||||
|
* leaderboard — which is why it is its own page rather than a fifth tab there.
|
||||||
|
*
|
||||||
|
* The scope is deliberately "everything ever imported": a pairing's history is
|
||||||
|
* the point, and slicing it by season would just be the Rankings page again.
|
||||||
|
*
|
||||||
|
* A set records its bracket, and a bracket's game is nullable, so meetings the
|
||||||
|
* database cannot attribute to a game are excluded by the API and reported
|
||||||
|
* separately as `unknownGameSets`. They are shown, never folded into a total.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let players = $state<PlayerOptionDTO[]>([]);
|
||||||
|
let playerAId = $state<number | null>(null);
|
||||||
|
let playerBId = $state<number | null>(null);
|
||||||
|
|
||||||
|
let versus = $state<PlayerVersusDTO | null>(null);
|
||||||
|
let loadingPlayers = $state(false);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
let inFlight: AbortController | null = null;
|
||||||
|
|
||||||
|
const games = $derived(versus?.games ?? []);
|
||||||
|
const sets = $derived(versus?.sets ?? 0);
|
||||||
|
const decided = $derived(versus?.decidedSets ?? 0);
|
||||||
|
const winsA = $derived(versus?.winsA ?? 0);
|
||||||
|
const winsB = $derived(versus?.winsB ?? 0);
|
||||||
|
const unknown = $derived(versus?.unknownGameSets ?? 0);
|
||||||
|
/** Undecided meetings: recorded, but with equal scores, so nobody won them. */
|
||||||
|
const undecided = $derived(Math.max(0, sets - decided));
|
||||||
|
|
||||||
|
let started = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (!session.isLoggedIn) {
|
||||||
|
goto('/login', { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!started) {
|
||||||
|
started = true;
|
||||||
|
void refreshPlayers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Reads only the two ids, so writing `versus` / `loading` below cannot re-trigger
|
||||||
|
* it. One request per complete pairing, and the previous one is aborted — picking
|
||||||
|
* a third player mid-flight must not race an older answer into view.
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
const a = playerAId;
|
||||||
|
const b = playerBId;
|
||||||
|
|
||||||
|
if (a === null || b === null || a === b) {
|
||||||
|
inFlight?.abort();
|
||||||
|
inFlight = null;
|
||||||
|
versus = null;
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void compare(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshPlayers() {
|
||||||
|
loadingPlayers = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
players = await listVersusPlayers();
|
||||||
|
} catch (cause) {
|
||||||
|
error = toErrorMessage(cause, 'Could not load the player list.');
|
||||||
|
} finally {
|
||||||
|
loadingPlayers = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compare(a: number, b: number) {
|
||||||
|
inFlight?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
inFlight = controller;
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getVersus(a, b, { signal: controller.signal });
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
versus = result;
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof DOMException && cause.name === 'AbortError') return;
|
||||||
|
versus = null;
|
||||||
|
error = toErrorMessage(cause, 'Could not load this pairing.');
|
||||||
|
} finally {
|
||||||
|
if (inFlight === controller) {
|
||||||
|
inFlight = null;
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function swap() {
|
||||||
|
[playerAId, playerBId] = [playerBId, playerAId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
playerAId = null;
|
||||||
|
playerBId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Percentages get one decimal only under 100, so the column stays narrow. */
|
||||||
|
function percent(value: number): string {
|
||||||
|
return value >= 99.95 ? '100%' : `${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First player's share of the decided meetings; 50 when nothing is decided. */
|
||||||
|
function share(a: number, b: number): number {
|
||||||
|
const total = a + b;
|
||||||
|
return total === 0 ? 50 : (a / total) * 100;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Players Statistiques · LaDOSE</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
|
||||||
|
<header class="mb-8">
|
||||||
|
<h1 class="text-3xl font-semibold tracking-tight">Players Statistiques</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted">
|
||||||
|
Pick two players and see how often they met, game by game, across every event ever
|
||||||
|
imported. For leaderboards and attendance, see
|
||||||
|
<a href="/statistiques/rankings" class="underline decoration-line-strong hover:text-ink">
|
||||||
|
Rankings Statistiques
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p role="alert" class="{alertError} mb-4">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class={card}>
|
||||||
|
<div class="flex items-baseline justify-between gap-3">
|
||||||
|
<h2 class={cardHeading}>
|
||||||
|
Pairing
|
||||||
|
<span class="ml-1 font-normal text-muted normal-case">
|
||||||
|
({players.length} players with recorded sets)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<button class={ghost} onclick={refreshPlayers} disabled={loadingPlayers}>
|
||||||
|
{loadingPlayers ? 'Loading…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-5 sm:grid-cols-2">
|
||||||
|
<PlayerPicker
|
||||||
|
heading="Player A"
|
||||||
|
{players}
|
||||||
|
bind:selectedId={playerAId}
|
||||||
|
excludeId={playerBId}
|
||||||
|
disabled={loadingPlayers}
|
||||||
|
/>
|
||||||
|
<PlayerPicker
|
||||||
|
heading="Player B"
|
||||||
|
{players}
|
||||||
|
bind:selectedId={playerBId}
|
||||||
|
excludeId={playerAId}
|
||||||
|
disabled={loadingPlayers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center justify-between gap-3 border-t border-line pt-4">
|
||||||
|
<span class="text-xs text-muted">
|
||||||
|
{#if loading}
|
||||||
|
Reading their sets…
|
||||||
|
{:else if loadingPlayers}
|
||||||
|
Only players with at least one set in a bracket with a known game are listed.
|
||||||
|
{:else}
|
||||||
|
The count beside a name is their total sets, whoever the opponent was.
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class={ghost} onclick={swap} disabled={playerAId === null && playerBId === null}>
|
||||||
|
Swap
|
||||||
|
</button>
|
||||||
|
<button class={ghost} onclick={clear} disabled={playerAId === null && playerBId === null}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if versus && (playerAId !== null) && (playerBId !== null)}
|
||||||
|
<!--
|
||||||
|
Scoreline first: the whole page answers one question, so the total sits above
|
||||||
|
the per-game split rather than under it.
|
||||||
|
-->
|
||||||
|
<section class="{card} mt-6">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
|
<h2 class="text-lg font-semibold tracking-tight">
|
||||||
|
{versus.playerA}
|
||||||
|
<span class="text-subtle">vs</span>
|
||||||
|
{versus.playerB}
|
||||||
|
</h2>
|
||||||
|
<p class="text-3xl font-semibold tracking-tight tabular-nums">
|
||||||
|
<span class={winsA >= winsB ? '' : 'text-muted'}>{winsA}</span>
|
||||||
|
<span class="text-subtle">–</span>
|
||||||
|
<span class={winsB > winsA ? '' : 'text-muted'}>{winsB}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if sets === 0}
|
||||||
|
<p class="mt-3 text-sm text-subtle">
|
||||||
|
No recorded meeting in a bracket with a known game.
|
||||||
|
{#if unknown > 0}
|
||||||
|
They did meet {unknown} time{unknown === 1 ? '' : 's'}, but in
|
||||||
|
{unknown === 1 ? 'a bracket' : 'brackets'} with no game attached.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<!--
|
||||||
|
A share meter, not a two-series bar: one measure (player A's share of the
|
||||||
|
decided meetings) painted in the accent over an inset track. The score
|
||||||
|
above carries the same numbers for anyone who cannot see it.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="mt-4 h-2 overflow-hidden rounded-full bg-inset"
|
||||||
|
role="img"
|
||||||
|
aria-label="{versus.playerA} won {winsA} of {decided} decided meetings against {versus.playerB}"
|
||||||
|
>
|
||||||
|
<div class="h-full rounded-full bg-accent" style="width: {share(winsA, winsB)}%"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{#each [['Meetings', String(sets)], ['Games played', String(games.length)], ['Decided', String(decided)], [`${versus.playerA ?? 'A'} win rate`, percent(share(winsA, winsB))]] as const as [caption, value] (caption)}
|
||||||
|
<div class="rounded-xl border border-line bg-inset p-3">
|
||||||
|
<dt class="truncate text-xs tracking-wide text-muted uppercase">{caption}</dt>
|
||||||
|
<dd class="mt-1 text-2xl font-semibold tracking-tight tabular-nums">{value}</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if unknown > 0 && sets > 0}
|
||||||
|
<p class="{alertWarning} mt-4">
|
||||||
|
{unknown} further meeting{unknown === 1 ? '' : 's'} excluded: the bracket has no game
|
||||||
|
attached, so {unknown === 1 ? 'it' : 'they'} cannot be filed under one. Set the game on
|
||||||
|
those tournaments to see {unknown === 1 ? 'it' : 'them'} here.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if games.length}
|
||||||
|
<section class="{card} mt-6">
|
||||||
|
<h2 class={cardHeading}>Meetings per game</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted">
|
||||||
|
Most-played first. Games counts the individual games inside the decided sets — a
|
||||||
|
DQ is stored as a negative score and counts as zero.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 overflow-x-auto">
|
||||||
|
<table class="w-full min-w-max text-sm">
|
||||||
|
<thead class="text-left text-xs tracking-wide text-muted uppercase">
|
||||||
|
<tr class="border-b border-line">
|
||||||
|
<th class="py-2 pr-4 font-medium">Game</th>
|
||||||
|
<th class="px-2 py-2 text-right font-medium">Meetings</th>
|
||||||
|
<th class="px-2 py-2 text-right font-medium">Decided</th>
|
||||||
|
<th class="px-2 py-2 text-right font-medium">Record</th>
|
||||||
|
<th class="px-2 py-2 text-right font-medium">Games</th>
|
||||||
|
<th class="w-40 py-2 pl-2 font-medium">{versus.playerA} share</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="tabular-nums">
|
||||||
|
{#each games as row (row.gameId)}
|
||||||
|
{@const rowWinsA = row.winsA ?? 0}
|
||||||
|
{@const rowWinsB = row.winsB ?? 0}
|
||||||
|
<tr class="border-b border-line/60 last:border-0">
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
<span class="font-medium">{row.game}</span>
|
||||||
|
{#if row.gameLongName && row.gameLongName !== row.game}
|
||||||
|
<span class="ml-1 text-xs text-subtle">{row.gameLongName}</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-right font-semibold">{row.sets}</td>
|
||||||
|
<td class="px-2 py-2 text-right {row.decidedSets === row.sets ? 'text-subtle' : ''}">
|
||||||
|
{row.decidedSets}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 text-right font-semibold">{rowWinsA}–{rowWinsB}</td>
|
||||||
|
<td class="px-2 py-2 text-right text-subtle">
|
||||||
|
{row.gamesWonA}–{row.gamesWonB}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pl-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="h-1.5 grow overflow-hidden rounded-full bg-inset"
|
||||||
|
role="img"
|
||||||
|
aria-label="{versus.playerA} won {rowWinsA} of {rowWinsA + rowWinsB} decided meetings in {row.game}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-accent"
|
||||||
|
style="width: {share(rowWinsA, rowWinsB)}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="w-12 shrink-0 text-right text-xs text-muted">
|
||||||
|
{rowWinsA + rowWinsB === 0 ? '—' : percent(share(rowWinsA, rowWinsB))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if undecided > 0}
|
||||||
|
<p class="mt-4 text-xs text-muted">
|
||||||
|
{undecided} of these {sets} meetings {undecided === 1 ? 'has' : 'have'} equal scores
|
||||||
|
— counted as a meeting, but won by nobody, so
|
||||||
|
{undecided === 1 ? 'it is' : 'they are'} absent from the records above.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{:else if !loading}
|
||||||
|
<p class="mt-6 text-sm text-subtle">
|
||||||
|
{#if playerAId === null && playerBId === null}
|
||||||
|
Pick a player on each side to see their history.
|
||||||
|
{:else}
|
||||||
|
Pick a second player to compare.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
+6
-3
@@ -212,15 +212,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Statistiques · LaDOSE</title>
|
<title>Rankings Statistiques · LaDOSE</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main id="main" class="mx-auto max-w-6xl px-4 py-10">
|
<main id="main" class="mx-auto max-w-6xl px-4 py-10">
|
||||||
<header class="mb-8">
|
<header class="mb-8">
|
||||||
<h1 class="text-3xl font-semibold tracking-tight">Statistiques</h1>
|
<h1 class="text-3xl font-semibold tracking-tight">Rankings Statistiques</h1>
|
||||||
<p class="mt-1 text-sm text-muted">
|
<p class="mt-1 text-sm text-muted">
|
||||||
Pick a scope — a season, a year, everything — and see who turned up, who won, and how
|
Pick a scope — a season, a year, everything — and see who turned up, who won, and how
|
||||||
the games compare.
|
the games compare. For one pairing's whole history, see
|
||||||
|
<a href="/statistiques/players" class="underline decoration-line-strong hover:text-ink">
|
||||||
|
Players Statistiques
|
||||||
|
</a>.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { toErrorMessage } from '$lib/api/errors';
|
import { toErrorMessage } from '$lib/api/errors';
|
||||||
import type { EventDTO, TournamentsResultDTO } from '$lib/api/schema-helpers';
|
import type {
|
||||||
|
EventDTO,
|
||||||
|
SheetExportResultDTO,
|
||||||
|
SheetsConfigDTO,
|
||||||
|
TournamentsResultDTO
|
||||||
|
} from '$lib/api/schema-helpers';
|
||||||
|
import { exportToSheets, getSheetsConfig } from '$lib/api/sheets';
|
||||||
import { getResults, importSmashTournament, listEvents } from '$lib/api/tournaments';
|
import { getResults, importSmashTournament, listEvents } from '$lib/api/tournaments';
|
||||||
import { session } from '$lib/stores/session.svelte';
|
import { session } from '$lib/stores/session.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +17,7 @@
|
|||||||
playedGames,
|
playedGames,
|
||||||
resultsForGame
|
resultsForGame
|
||||||
} from '$lib/tournaments/results';
|
} from '$lib/tournaments/results';
|
||||||
|
import { rankingToSheetTable, suggestedTabName } from '$lib/tournaments/sheet';
|
||||||
import {
|
import {
|
||||||
alertError,
|
alertError,
|
||||||
alertNotice,
|
alertNotice,
|
||||||
@@ -34,6 +41,12 @@
|
|||||||
let tab = $state<'ranking' | 'game' | 'html'>('ranking');
|
let tab = $state<'ranking' | 'game' | 'html'>('ranking');
|
||||||
let selectedGameId = $state<number | null>(null);
|
let selectedGameId = $state<number | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ids `results` was actually computed for. Changing the selection afterwards must not
|
||||||
|
* change what the export claims to be, since the tab title decides where the data lands.
|
||||||
|
*/
|
||||||
|
let generatedIds = $state<number[]>([]);
|
||||||
|
|
||||||
let loadingEvents = $state(false);
|
let loadingEvents = $state(false);
|
||||||
let importing = $state(false);
|
let importing = $state(false);
|
||||||
let generating = $state(false);
|
let generating = $state(false);
|
||||||
@@ -41,12 +54,27 @@
|
|||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
|
|
||||||
|
// --- Google Sheets export -------------------------------------------------------
|
||||||
|
let sheets = $state<SheetsConfigDTO | null>(null);
|
||||||
|
/** Blank means "use the suggestion", which is shown as the input's placeholder. */
|
||||||
|
let tabName = $state('');
|
||||||
|
let pushing = $state(false);
|
||||||
|
let pushResult = $state<SheetExportResultDTO | null>(null);
|
||||||
|
|
||||||
const ranking = $derived(buildRanking(results));
|
const ranking = $derived(buildRanking(results));
|
||||||
const games = $derived(playedGames(results));
|
const games = $derived(playedGames(results));
|
||||||
const gameResults = $derived(resultsForGame(results, selectedGameId));
|
const gameResults = $derived(resultsForGame(results, selectedGameId));
|
||||||
const html = $derived(buildHtml(results));
|
const html = $derived(buildHtml(results));
|
||||||
const selectedGame = $derived(games.find((g) => g.id === selectedGameId) ?? null);
|
const selectedGame = $derived(games.find((g) => g.id === selectedGameId) ?? null);
|
||||||
|
|
||||||
|
/** The events behind the table on screen, not the current checkboxes. */
|
||||||
|
const generatedEvents = $derived(
|
||||||
|
events.filter((event) => event.id !== undefined && generatedIds.includes(event.id))
|
||||||
|
);
|
||||||
|
/** A ranking day's table aggregates up to that day, so the latest event names the tab. */
|
||||||
|
const suggestedName = $derived(suggestedTabName(generatedEvents));
|
||||||
|
const resolvedTabName = $derived(tabName.trim() || suggestedName);
|
||||||
|
|
||||||
let started = false;
|
let started = false;
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!session.isLoggedIn) {
|
if (!session.isLoggedIn) {
|
||||||
@@ -56,6 +84,11 @@
|
|||||||
if (!started) {
|
if (!started) {
|
||||||
started = true;
|
started = true;
|
||||||
void refreshEvents();
|
void refreshEvents();
|
||||||
|
// Not reaching it just leaves the export saying "not configured"; the page must
|
||||||
|
// not otherwise care.
|
||||||
|
void getSheetsConfig()
|
||||||
|
.then((value) => (sheets = value))
|
||||||
|
.catch(() => (sheets = null));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,8 +170,12 @@
|
|||||||
notice = null;
|
notice = null;
|
||||||
try {
|
try {
|
||||||
results = await getResults(selectedIds);
|
results = await getResults(selectedIds);
|
||||||
|
generatedIds = [...selectedIds];
|
||||||
selectedGameId = playedGames(results)[0]?.id ?? null;
|
selectedGameId = playedGames(results)[0]?.id ?? null;
|
||||||
tab = 'ranking';
|
tab = 'ranking';
|
||||||
|
// A new table belongs in its own tab, so drop any title typed for the previous one.
|
||||||
|
tabName = '';
|
||||||
|
pushResult = null;
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
report(cause, 'Could not compute the results for this selection.');
|
report(cause, 'Could not compute the results for this selection.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -166,6 +203,35 @@
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the table above into the club's spreadsheet, as one tab — the same grid the CSV
|
||||||
|
* carries, so this replaces "download, then import by hand".
|
||||||
|
*
|
||||||
|
* The target spreadsheet is server configuration (it is replaced each year); only the tab
|
||||||
|
* title is chosen here.
|
||||||
|
*/
|
||||||
|
async function pushToSheets() {
|
||||||
|
if (pushing || !ranking.rows.length || !sheets?.configured) return;
|
||||||
|
|
||||||
|
pushing = true;
|
||||||
|
error = null;
|
||||||
|
notice = null;
|
||||||
|
pushResult = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const table = rankingToSheetTable(ranking, {
|
||||||
|
name: resolvedTabName,
|
||||||
|
events: generatedEvents,
|
||||||
|
generatedAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
pushResult = await exportToSheets({ tabs: [table] });
|
||||||
|
} catch (cause) {
|
||||||
|
report(cause, 'Could not write to the spreadsheet.');
|
||||||
|
} finally {
|
||||||
|
pushing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -334,9 +400,79 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<button class="{ghost} mt-4" onclick={exportCsv} disabled={!ranking.rows.length}>
|
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-line pt-4">
|
||||||
|
<button class={ghost} onclick={exportCsv} disabled={!ranking.rows.length}>
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#if sheets?.configured}
|
||||||
|
<!--
|
||||||
|
Same table, straight into the spreadsheet — the point being to skip the
|
||||||
|
download-then-import step. Only the tab title is chosen here; the
|
||||||
|
spreadsheet itself is server configuration, since it changes every year.
|
||||||
|
-->
|
||||||
|
<span class="ml-auto flex flex-wrap items-center gap-2">
|
||||||
|
<label class="flex items-center gap-2 text-xs text-muted">
|
||||||
|
Tab
|
||||||
|
<input
|
||||||
|
bind:value={tabName}
|
||||||
|
class="{field} w-44"
|
||||||
|
placeholder={suggestedName}
|
||||||
|
aria-label="Spreadsheet tab to write"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class={primary}
|
||||||
|
onclick={pushToSheets}
|
||||||
|
disabled={pushing || !ranking.rows.length}
|
||||||
|
>
|
||||||
|
{pushing ? 'Writing…' : 'Push to Google Sheets'}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{:else if sheets}
|
||||||
|
<span class="ml-auto text-xs text-subtle">
|
||||||
|
Google Sheets export not configured on the server{sheets.writer
|
||||||
|
? ` (writer: ${sheets.writer})`
|
||||||
|
: ''} — see <code>.env.example</code>.
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if sheets?.configured}
|
||||||
|
<p class="mt-2 text-xs text-muted">
|
||||||
|
Writes this table to
|
||||||
|
<span class="text-ink">{resolvedTabName}</span>, replacing that tab's contents and
|
||||||
|
keeping its formatting. Other tabs are left alone, and re-running changes nothing
|
||||||
|
but the timestamp.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pushResult}
|
||||||
|
{@const written = pushResult.tabs?.[0]}
|
||||||
|
<div class="{alertNotice} mt-3">
|
||||||
|
<p>
|
||||||
|
Wrote {written?.rows ?? 0} rows to
|
||||||
|
<span class="font-semibold">{written?.name}</span>
|
||||||
|
{#if pushResult.spreadsheetUrl}
|
||||||
|
in
|
||||||
|
<a
|
||||||
|
href={pushResult.spreadsheetUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="underline">the spreadsheet</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if written && !written.created}· replaced an existing tab{/if}
|
||||||
|
</p>
|
||||||
|
{#if pushResult.warnings?.length}
|
||||||
|
<ul class="mt-1 space-y-0.5 text-xs">
|
||||||
|
{#each pushResult.warnings as warning (warning)}
|
||||||
|
<li>{warning}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else if tab === 'game'}
|
{:else if tab === 'game'}
|
||||||
<div class="mt-5 grid gap-5 sm:grid-cols-[14rem_1fr]">
|
<div class="mt-5 grid gap-5 sm:grid-cols-[14rem_1fr]">
|
||||||
<ul class="max-h-80 space-y-1 overflow-y-auto pr-1 text-sm">
|
<ul class="max-h-80 space-y-1 overflow-y-auto pr-1 text-sm">
|
||||||
|
|||||||
@@ -9,6 +9,14 @@
|
|||||||
# Then: webapp on http://localhost:8080, API on http://localhost:5000,
|
# Then: webapp on http://localhost:8080, API on http://localhost:5000,
|
||||||
# Scalar API reference on http://localhost:5000/scalar.
|
# Scalar API reference on http://localhost:5000/scalar.
|
||||||
#
|
#
|
||||||
|
# LaDOSE.DiscordBot is here too, behind the "bot" profile, so it stays out of the way
|
||||||
|
# until you have a Discord token to give it — an unconfigured bot cannot start at all,
|
||||||
|
# it can only crash-loop.
|
||||||
|
#
|
||||||
|
# docker compose --profile bot up --build everything, bot included
|
||||||
|
# docker compose --profile bot up -d bot just the bot
|
||||||
|
# docker compose logs -f bot follow it
|
||||||
|
#
|
||||||
# The database is NOT part of this stack — it stays wherever appsettings.json points.
|
# The database is NOT part of this stack — it stays wherever appsettings.json points.
|
||||||
# Copy .env.example to .env to change the ports, the connection string or the API keys.
|
# Copy .env.example to .env to change the ports, the connection string or the API keys.
|
||||||
name: ladose
|
name: ladose
|
||||||
@@ -35,6 +43,17 @@ services:
|
|||||||
JWTTokenSecret: ${LADOSE_JWT_SECRET:-dev-only-secret-not-for-any-deployed-environment}
|
JWTTokenSecret: ${LADOSE_JWT_SECRET:-dev-only-secret-not-for-any-deployed-environment}
|
||||||
ApiKey__SmashApiKey: ${LADOSE_SMASH_API_KEY:-}
|
ApiKey__SmashApiKey: ${LADOSE_SMASH_API_KEY:-}
|
||||||
ApiKey__ChallongeApiKey: ${LADOSE_CHALLONGE_API_KEY:-}
|
ApiKey__ChallongeApiKey: ${LADOSE_CHALLONGE_API_KEY:-}
|
||||||
|
# Rankings export to Google Sheets. "Disabled" out of the box, so the button
|
||||||
|
# reports "not configured" rather than failing. Set Writer=Logging to see the
|
||||||
|
# payload in `docker compose logs -f api` without touching a spreadsheet.
|
||||||
|
GoogleSheets__Writer: ${LADOSE_SHEETS_WRITER:-Disabled}
|
||||||
|
GoogleSheets__SpreadsheetId: ${LADOSE_SHEETS_SPREADSHEET_ID:-}
|
||||||
|
GoogleSheets__ServiceAccount__CredentialsPath: ${LADOSE_SHEETS_SA_CREDENTIALS_PATH:-}
|
||||||
|
volumes:
|
||||||
|
# Where the service-account key lives, read-only. Compose creates ./secrets if it
|
||||||
|
# is missing, so this is harmless when the export is Disabled or set to Logging.
|
||||||
|
# git ignores the directory; put ladose-sheets-sa.json in it.
|
||||||
|
- ./secrets:/run/secrets:ro
|
||||||
ports:
|
ports:
|
||||||
# Container side is pinned at 5000: Program.cs reads AllowedHosts/Port straight
|
# Container side is pinned at 5000: Program.cs reads AllowedHosts/Port straight
|
||||||
# from appsettings.json, through a ConfigurationBuilder that ignores env vars.
|
# from appsettings.json, through a ConfigurationBuilder that ignores env vars.
|
||||||
@@ -78,3 +97,47 @@ services:
|
|||||||
- node_modules/
|
- node_modules/
|
||||||
- build/
|
- build/
|
||||||
- .svelte-kit/
|
- .svelte-kit/
|
||||||
|
|
||||||
|
bot:
|
||||||
|
profiles:
|
||||||
|
# Opt-in: `docker compose up` without --profile bot leaves this service alone.
|
||||||
|
# LADOSE_DISCORD_TOKEN has no usable default — without one the bot exits on the
|
||||||
|
# first connection attempt and `restart` below turns that into a loop — so it does
|
||||||
|
# not belong in the default `up` the way the API and the webapp do.
|
||||||
|
- bot
|
||||||
|
build:
|
||||||
|
# Context is LaDOSE.Src, shared with the api service: the bot references
|
||||||
|
# LaDOSE.REST/LaDOSE.DTO and Libraries/ChallongeCSharpDriver.dll, none of which are
|
||||||
|
# reachable from a context rooted at LaDOSE.DiscordBot.
|
||||||
|
context: ./LaDOSE.Src
|
||||||
|
dockerfile: LaDOSE.DiscordBot/Dockerfile
|
||||||
|
# No BUILD_CONFIGURATION override: unlike the API, nothing in this project is
|
||||||
|
# compiled out of a Release build, so the Dockerfile's Release default is right.
|
||||||
|
environment:
|
||||||
|
# Program.cs reads settings.json and never looks at the environment, so
|
||||||
|
# LaDOSE.DiscordBot/docker-entrypoint.sh renders that file from these five values at
|
||||||
|
# container start. Setting LADOSE_DISCORD_TOKEN is what switches the rendering on.
|
||||||
|
LADOSE_DISCORD_TOKEN: ${LADOSE_DISCORD_TOKEN:-}
|
||||||
|
# Same key the api service gets — one bot and one API against the same Challonge
|
||||||
|
# account.
|
||||||
|
LADOSE_CHALLONGE_API_KEY: ${LADOSE_CHALLONGE_API_KEY:-}
|
||||||
|
# Resolved *inside* the container, unlike the webapp's LADOSE_API_BASE_URL, so the
|
||||||
|
# compose service name is the right answer and the host port mapping is irrelevant.
|
||||||
|
LADOSE_BOT_REST_URL: ${LADOSE_BOT_REST_URL:-http://api:5000}
|
||||||
|
# Credentials of the LaDOSE.Api account the bot logs in as (RestService.Connect).
|
||||||
|
LADOSE_BOT_REST_USER: ${LADOSE_BOT_REST_USER:-}
|
||||||
|
LADOSE_BOT_REST_PASSWORD: ${LADOSE_BOT_REST_PASSWORD:-}
|
||||||
|
depends_on:
|
||||||
|
# Ordering only, and only useful for the commands that call the API. The bot's own
|
||||||
|
# startup does not touch it: WebService swallows a failed Connect with "Unable to
|
||||||
|
# contact services", so a late API is recoverable.
|
||||||
|
- api
|
||||||
|
restart: unless-stopped
|
||||||
|
develop:
|
||||||
|
watch:
|
||||||
|
- action: rebuild
|
||||||
|
path: ./LaDOSE.Src
|
||||||
|
ignore:
|
||||||
|
- LaDOSE.WebApp/
|
||||||
|
- "**/bin/"
|
||||||
|
- "**/obj/"
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# docker-compose.yml mounts this directory at /run/secrets (read-only) for the API.
|
||||||
|
#
|
||||||
|
# Put the Google service-account JSON key here as ladose-sheets-sa.json and set
|
||||||
|
# LADOSE_SHEETS_SA_CREDENTIALS_PATH=/run/secrets/ladose-sheets-sa.json in .env.
|
||||||
|
#
|
||||||
|
# Everything else in this directory is git-ignored. See .env.example for the setup steps.
|
||||||
Reference in New Issue
Block a user