diff --git a/SIBR.Storage.API/Controllers/GameStatsController.cs b/SIBR.Storage.API/Controllers/GameStatsController.cs new file mode 100644 index 0000000..951ecbe --- /dev/null +++ b/SIBR.Storage.API/Controllers/GameStatsController.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using CsvHelper; +using Microsoft.AspNetCore.Mvc; +using NodaTime; +using Serilog; +using SIBR.Storage.API.Models; +using SIBR.Storage.API.Utils; +using SIBR.Storage.Data; +using SIBR.Storage.Data.Models; +using SIBR.Storage.Data.Query; + +namespace SIBR.Storage.API.Controllers +{ + [ApiController] + [ApiVersion("1.0")] + [Route("v{version:apiVersion}")] + public class GameStatsController : ControllerBase + { + private readonly GameStore _gameStore; + private readonly UpdateStore _store; + + public GameStatsController(GameStore gameStore, UpdateStore store) + { + _gameStore = gameStore; + _store = store; + } + + [Route("games/stats")] + public async Task GetGameStats([FromQuery] GameStatsOptions opts) + { + var game = await _gameStore.GetGames(new GameStore.GameQueryOptions { GameId = opts.Game, Count = 1 }).FirstAsync(); + var cutoff = CutoffTime(game.EndTime); + + var gameStats = await GetVersion(UpdateType.GameStatsheet, new [] { game.Statsheet }, cutoff).FirstOrDefaultAsync(); + if (gameStats is null) + return Ok(new ApiResponse() { Data = new ApiGameStats[] {} }); + + var teamSheets = new [] { gameStats.Data.GetProperty("awayTeamStats").GetGuid(), gameStats.Data.GetProperty("homeTeamStats").GetGuid() }; + var teamStats = await GetVersion(UpdateType.TeamStatsheet, teamSheets, cutoff).ToListAsync(); + + var playerSheets = teamStats.SelectMany(sheet => sheet.Data.GetProperty("playerStats").EnumerateArray().Select(el => el.GetGuid())); + var playerStats = await GetVersion(UpdateType.PlayerStatsheet, playerSheets.ToArray(), cutoff).ToListAsync(); + + return Ok(new ApiResponse() { Data = new ApiGameStats[] + { + new ApiGameStats + { + GameId = game.GameId, + Timestamp = gameStats.Timestamp, + GameStats = gameStats.Data, + TeamStats = teamStats.Select(v => v.Data).ToArray(), + PlayerStats = playerStats.Select(v => v.Data).ToArray(), + } + }}); + } + + private Instant? CutoffTime(Instant? time) + { + if (time is null) + return null; + + // We want the last recorded statsheet before the next game begins, so take the end time and round up to the next hour. + var dt = (time ?? Instant.MaxValue).InUtc(); + var cutoff = dt.Date + TimeAdjusters.TruncateToHour(dt.TimeOfDay); + return cutoff.InUtc().ToInstant() + Duration.FromHours(1); + } + + private IAsyncEnumerable GetVersion(UpdateType type, Guid[] ids, Instant? before) + { + return _store.ExportAllUpdatesRaw(type, new UpdateStore.EntityVersionQuery + { + Ids = ids, + Before = before, + Order = SortOrder.Desc, + Count = ids.Length, + }); + } + + public class GameStatsOptions + { + public Guid Game { get; set; } + } + } +} diff --git a/SIBR.Storage.API/Models/ApiGameStats.cs b/SIBR.Storage.API/Models/ApiGameStats.cs new file mode 100644 index 0000000..e689a27 --- /dev/null +++ b/SIBR.Storage.API/Models/ApiGameStats.cs @@ -0,0 +1,16 @@ +using System; +using System.Text.Json; +using NodaTime; +using SIBR.Storage.Data.Models; + +namespace SIBR.Storage.API.Models +{ + public class ApiGameStats + { + public Guid GameId { get; set; } + public Instant Timestamp { get; set; } + public JsonElement GameStats { get; set; } + public JsonElement[] TeamStats { get; set; } + public JsonElement[] PlayerStats { get; set; } + } +} diff --git a/SIBR.Storage.Data/Models/Read/GameView.cs b/SIBR.Storage.Data/Models/Read/GameView.cs index 8007a17..dba92bf 100644 --- a/SIBR.Storage.Data/Models/Read/GameView.cs +++ b/SIBR.Storage.Data/Models/Read/GameView.cs @@ -7,6 +7,7 @@ namespace SIBR.Storage.Data.Models public class GameView: IGameData { public Guid GameId { get; set; } + public Guid Statsheet { get; set; } public Instant? StartTime { get; set; } public Instant? EndTime { get; set; } public JsonElement Data { get; set; } diff --git a/SIBR.Storage.Data/Schema/r__games_view.sql b/SIBR.Storage.Data/Schema/r__games_view.sql index a2652c8..d436158 100644 --- a/SIBR.Storage.Data/Schema/r__games_view.sql +++ b/SIBR.Storage.Data/Schema/r__games_view.sql @@ -18,6 +18,7 @@ create view games_view as (jsonb_array_length(data->'outcomes') > 0) as has_outcomes, (data->>'gameStart')::bool as has_started, (data->>'gameComplete')::bool as has_finished, + (data->>'statsheet')::uuid as statsheet, (data->>'homeTeam')::uuid as home_team, (data->>'awayTeam')::uuid as away_team, (data->>'homePitcher')::uuid as home_pitcher, diff --git a/SIBR.Storage.Data/Stores/GameStore.cs b/SIBR.Storage.Data/Stores/GameStore.cs index 580b290..52eea7f 100644 --- a/SIBR.Storage.Data/Stores/GameStore.cs +++ b/SIBR.Storage.Data/Stores/GameStore.cs @@ -25,6 +25,7 @@ public IAsyncEnumerable GetGames(GameQueryOptions opts) else q.OrderBy("season", "tournament", "day"); + if (opts.GameId != null) q.Where("game_id", opts.GameId.Value); if (opts.Season != null) q.Where("season", opts.Season.Value); if (opts.Tournament != null) q.Where("tournament", opts.Tournament.Value); if (opts.Day != null) q.Where("day", opts.Day.Value); @@ -43,6 +44,7 @@ public IAsyncEnumerable GetGames(GameQueryOptions opts) public class GameQueryOptions { + public Guid? GameId; public int? Tournament; public int? Season; public int? Day;