From 21130895d42b499c616941ae01ec0b560e4e9bb3 Mon Sep 17 00:00:00 2001 From: beo3000 Date: Mon, 29 Dec 2025 15:27:25 +0100 Subject: [PATCH] add statistics: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erfasste Metriken: - ThrowCount, PinCount, ClearedCount, GutterCount, CircleCount, StrikeCount, BellCount Widget zeigt: - Jahres-Übersicht (Spiele, Würfe, Kegel, Durchschnitt) - Top 5 Kegler Rangliste - Monatstrend-Tabelle --- .../DTOs/PlayerStatisticsDto.cs | 71 ++ src/Koogle.Application/DependencyInjection.cs | 1 + .../Training/TrainingGameLogicService.cs | 8 +- .../Games/Training/TrainingGameModel.cs | 5 + .../Interfaces/IPlayerStatisticsService.cs | 43 + .../Services/PlayerStatisticsService.cs | 215 ++++ .../Entities/PlayerGameStatistics.cs | 73 ++ .../IPlayerGameStatisticsRepository.cs | 44 + .../Data/AppDbContext.cs | 3 + .../PlayerGameStatisticsConfiguration.cs | 44 + ...140837_AddPlayerGameStatistics.Designer.cs | 1055 +++++++++++++++++ .../20251229140837_AddPlayerGameStatistics.cs | 93 ++ .../Migrations/AppDbContextModelSnapshot.cs | 92 ++ .../DependencyInjection.cs | 1 + .../PlayerGameStatisticsRepository.cs | 108 ++ .../Game/Training/TrainingBoard.razor | 15 + .../Components/Pages/Dashboard.razor | 5 + .../Shared/PlayerStatisticsWidget.razor | 176 +++ src/Koogle.Web/Store/GameState/GameEffects.cs | 39 +- 19 files changed, 2089 insertions(+), 2 deletions(-) create mode 100644 src/Koogle.Application/DTOs/PlayerStatisticsDto.cs create mode 100644 src/Koogle.Application/Interfaces/IPlayerStatisticsService.cs create mode 100644 src/Koogle.Application/Services/PlayerStatisticsService.cs create mode 100644 src/Koogle.Domain/Entities/PlayerGameStatistics.cs create mode 100644 src/Koogle.Domain/Interfaces/IPlayerGameStatisticsRepository.cs create mode 100644 src/Koogle.Infrastructure/Data/Configurations/PlayerGameStatisticsConfiguration.cs create mode 100644 src/Koogle.Infrastructure/Data/Migrations/20251229140837_AddPlayerGameStatistics.Designer.cs create mode 100644 src/Koogle.Infrastructure/Data/Migrations/20251229140837_AddPlayerGameStatistics.cs create mode 100644 src/Koogle.Infrastructure/Repositories/PlayerGameStatisticsRepository.cs create mode 100644 src/Koogle.Web/Components/Shared/PlayerStatisticsWidget.razor diff --git a/src/Koogle.Application/DTOs/PlayerStatisticsDto.cs b/src/Koogle.Application/DTOs/PlayerStatisticsDto.cs new file mode 100644 index 0000000..c7ff85a --- /dev/null +++ b/src/Koogle.Application/DTOs/PlayerStatisticsDto.cs @@ -0,0 +1,71 @@ +namespace Koogle.Application.DTOs; + +/// +/// Aggregated statistics for a player. +/// +public record PlayerStatisticsAggregateDto +{ + public Guid PersonId { get; init; } + public string PersonName { get; init; } = ""; + public int TotalGames { get; init; } + public int TotalThrows { get; init; } + public int TotalPins { get; init; } + public int TotalCleared { get; init; } + public int TotalGutters { get; init; } + public int TotalCircles { get; init; } + public int TotalStrikes { get; init; } + public int TotalBells { get; init; } + public double AveragePinsPerThrow { get; init; } + public double AveragePinsPerGame { get; init; } +} + +/// +/// Statistics for a single day. +/// +public record DayStatisticsDto +{ + public Guid DayId { get; init; } + public DateTime PostDate { get; init; } + public int TotalGames { get; init; } + public int TotalThrows { get; init; } + public int TotalPins { get; init; } + public List PlayerStats { get; init; } = []; +} + +/// +/// Statistics widget data for dashboard. +/// +public record StatisticsWidgetDto +{ + public int TotalGamesThisYear { get; init; } + public int TotalThrowsThisYear { get; init; } + public int TotalPinsThisYear { get; init; } + public double ClubAverageThisYear { get; init; } + public List TopPerformers { get; init; } = []; + public List MonthlyTrend { get; init; } = []; +} + +/// +/// Top performer data. +/// +public record TopPerformerDto +{ + public Guid PersonId { get; init; } + public string PersonName { get; init; } = ""; + public double Average { get; init; } + public int TotalPins { get; init; } + public int TotalThrows { get; init; } +} + +/// +/// Monthly statistics for trend chart. +/// +public record MonthlyStatsDto +{ + public int Year { get; init; } + public int Month { get; init; } + public int Games { get; init; } + public int Throws { get; init; } + public int Pins { get; init; } + public double Average { get; init; } +} diff --git a/src/Koogle.Application/DependencyInjection.cs b/src/Koogle.Application/DependencyInjection.cs index 9994457..4d6437f 100644 --- a/src/Koogle.Application/DependencyInjection.cs +++ b/src/Koogle.Application/DependencyInjection.cs @@ -37,6 +37,7 @@ namespace Koogle.Application services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Note: Game types are registered in Koogle.Web where Blazor components are defined // Use services.AddGameType() to register game types diff --git a/src/Koogle.Application/Games/Training/TrainingGameLogicService.cs b/src/Koogle.Application/Games/Training/TrainingGameLogicService.cs index 3fc6c3f..d4df1c9 100644 --- a/src/Koogle.Application/Games/Training/TrainingGameLogicService.cs +++ b/src/Koogle.Application/Games/Training/TrainingGameLogicService.cs @@ -63,6 +63,11 @@ public class TrainingGameLogicService : IGameLogicService stats.ClearedCount++; } + if (afterThrow.ThrowPanel.BellValue) + { + stats.BellCount++; + } + // Determine if player should rotate (round complete) var shouldRotate = afterThrow.ThrowPanel.IsRoundComplete(); @@ -101,7 +106,8 @@ public class TrainingGameLogicService : IGameLogicService CurrentPoints = kvp.Value.PinCount, // Total pins = "points" in training CustomStats = new Dictionary { - ["GutterCount"] = kvp.Value.GutterCount + ["GutterCount"] = kvp.Value.GutterCount, + ["BellCount"] = kvp.Value.BellCount } }); } diff --git a/src/Koogle.Application/Games/Training/TrainingGameModel.cs b/src/Koogle.Application/Games/Training/TrainingGameModel.cs index 3f39b5d..31fba44 100644 --- a/src/Koogle.Application/Games/Training/TrainingGameModel.cs +++ b/src/Koogle.Application/Games/Training/TrainingGameModel.cs @@ -46,6 +46,11 @@ public record TrainingPlayerStats /// public int GutterCount { get; set; } + /// + /// Number of bell hits (Klingel/Glocke). + /// + public int BellCount { get; set; } + /// /// Calculates the average pins per throw. /// diff --git a/src/Koogle.Application/Interfaces/IPlayerStatisticsService.cs b/src/Koogle.Application/Interfaces/IPlayerStatisticsService.cs new file mode 100644 index 0000000..c3ad599 --- /dev/null +++ b/src/Koogle.Application/Interfaces/IPlayerStatisticsService.cs @@ -0,0 +1,43 @@ +using Koogle.Application.DTOs; + +namespace Koogle.Application.Interfaces; + +/// +/// Service for recording and aggregating player game statistics. +/// +public interface IPlayerStatisticsService +{ + /// + /// Records throw statistics for a player. Called after each throw. + /// + Task RecordThrowAsync( + Guid gameId, + Guid personId, + Guid clubId, + int pinsKnocked, + bool isCleared, + bool isGutter, + bool isCircle, + bool isStrike, + bool isBell, + CancellationToken ct = default); + + /// + /// Gets aggregated statistics for a person across games. + /// + Task GetPersonAggregateAsync( + Guid personId, + DateTime? from = null, + DateTime? to = null, + CancellationToken ct = default); + + /// + /// Gets aggregated statistics for a day. + /// + Task GetDayStatisticsAsync(Guid dayId, CancellationToken ct = default); + + /// + /// Gets statistics for dashboard widget. + /// + Task GetDashboardStatisticsAsync(CancellationToken ct = default); +} diff --git a/src/Koogle.Application/Services/PlayerStatisticsService.cs b/src/Koogle.Application/Services/PlayerStatisticsService.cs new file mode 100644 index 0000000..11c921d --- /dev/null +++ b/src/Koogle.Application/Services/PlayerStatisticsService.cs @@ -0,0 +1,215 @@ +using Koogle.Application.DTOs; +using Koogle.Application.Interfaces; +using Koogle.Domain.Entities; +using Koogle.Domain.Interfaces; +using KoogleApp.Data; +using Microsoft.EntityFrameworkCore; + +namespace Koogle.Application.Services; + +/// +/// Service for recording and aggregating player game statistics. +/// +public class PlayerStatisticsService : IPlayerStatisticsService +{ + private readonly IPlayerGameStatisticsRepository _repository; + private readonly IDbContextFactory _contextFactory; + private readonly ICurrentClubContext _clubContext; + + public PlayerStatisticsService( + IPlayerGameStatisticsRepository repository, + IDbContextFactory contextFactory, + ICurrentClubContext clubContext) + { + _repository = repository; + _contextFactory = contextFactory; + _clubContext = clubContext; + } + + /// + public async Task RecordThrowAsync( + Guid gameId, + Guid personId, + Guid clubId, + int pinsKnocked, + bool isCleared, + bool isGutter, + bool isCircle, + bool isStrike, + bool isBell, + CancellationToken ct = default) + { + var existing = await _repository.GetByGameAndPersonAsync(gameId, personId, ct); + + var stats = existing ?? new PlayerGameStatistics + { + GameId = gameId, + PersonId = personId, + ClubId = clubId + }; + + stats.ThrowCount++; + stats.PinCount += pinsKnocked; + if (isCleared) stats.ClearedCount++; + if (isGutter) stats.GutterCount++; + if (isCircle) stats.CircleCount++; + if (isStrike) stats.StrikeCount++; + if (isBell) stats.BellCount++; + + await _repository.UpsertAsync(stats, ct); + } + + /// + public async Task GetPersonAggregateAsync( + Guid personId, + DateTime? from = null, + DateTime? to = null, + CancellationToken ct = default) + { + await using var context = await _contextFactory.CreateDbContextAsync(ct); + + var query = context.PlayerGameStatistics + .Where(x => x.PersonId == personId && x.ClubId == _clubContext.ClubId); + + if (from.HasValue) + query = query.Where(x => x.Game.StartedAt >= from.Value); + if (to.HasValue) + query = query.Where(x => x.Game.StartedAt <= to.Value); + + var stats = await query.ToListAsync(ct); + var person = await context.Persons.FindAsync([personId], ct); + + var totalThrows = stats.Sum(x => x.ThrowCount); + var totalPins = stats.Sum(x => x.PinCount); + + return new PlayerStatisticsAggregateDto + { + PersonId = personId, + PersonName = person?.Name ?? "Unknown", + TotalGames = stats.Count, + TotalThrows = totalThrows, + TotalPins = totalPins, + TotalCleared = stats.Sum(x => x.ClearedCount), + TotalGutters = stats.Sum(x => x.GutterCount), + TotalCircles = stats.Sum(x => x.CircleCount), + TotalStrikes = stats.Sum(x => x.StrikeCount), + TotalBells = stats.Sum(x => x.BellCount), + AveragePinsPerThrow = totalThrows > 0 ? (double)totalPins / totalThrows : 0, + AveragePinsPerGame = stats.Count > 0 ? (double)totalPins / stats.Count : 0 + }; + } + + /// + public async Task GetDayStatisticsAsync(Guid dayId, CancellationToken ct = default) + { + await using var context = await _contextFactory.CreateDbContextAsync(ct); + + var day = await context.Days.FindAsync([dayId], ct); + var stats = await _repository.GetByDayAsync(dayId, ct); + + var playerStats = stats + .GroupBy(x => new { x.PersonId, x.Person.Name }) + .Select(g => new PlayerStatisticsAggregateDto + { + PersonId = g.Key.PersonId, + PersonName = g.Key.Name, + TotalGames = g.Count(), + TotalThrows = g.Sum(x => x.ThrowCount), + TotalPins = g.Sum(x => x.PinCount), + TotalCleared = g.Sum(x => x.ClearedCount), + TotalGutters = g.Sum(x => x.GutterCount), + TotalCircles = g.Sum(x => x.CircleCount), + TotalStrikes = g.Sum(x => x.StrikeCount), + TotalBells = g.Sum(x => x.BellCount), + AveragePinsPerThrow = g.Sum(x => x.ThrowCount) > 0 + ? (double)g.Sum(x => x.PinCount) / g.Sum(x => x.ThrowCount) + : 0, + AveragePinsPerGame = g.Count() > 0 + ? (double)g.Sum(x => x.PinCount) / g.Count() + : 0 + }) + .OrderByDescending(p => p.AveragePinsPerThrow) + .ToList(); + + return new DayStatisticsDto + { + DayId = dayId, + PostDate = day?.PostDate ?? DateTime.MinValue, + TotalGames = stats.Select(x => x.GameId).Distinct().Count(), + TotalThrows = stats.Sum(x => x.ThrowCount), + TotalPins = stats.Sum(x => x.PinCount), + PlayerStats = playerStats + }; + } + + /// + public async Task GetDashboardStatisticsAsync(CancellationToken ct = default) + { + await using var context = await _contextFactory.CreateDbContextAsync(ct); + + var clubId = _clubContext.ClubId; + var currentYear = DateTime.Now.Year; + var yearStart = new DateTime(currentYear, 1, 1); + var yearEnd = new DateTime(currentYear, 12, 31, 23, 59, 59); + + // Get all stats for this year + var yearStats = await context.PlayerGameStatistics + .Where(x => x.ClubId == clubId + && x.Game.StartedAt >= yearStart + && x.Game.StartedAt <= yearEnd) + .Include(x => x.Person) + .Include(x => x.Game) + .ToListAsync(ct); + + var totalGames = yearStats.Select(x => x.GameId).Distinct().Count(); + var totalThrows = yearStats.Sum(x => x.ThrowCount); + var totalPins = yearStats.Sum(x => x.PinCount); + + // Top performers (by average, min 10 throws) + var topPerformers = yearStats + .GroupBy(x => new { x.PersonId, x.Person.Name }) + .Where(g => g.Sum(x => x.ThrowCount) >= 10) + .Select(g => new TopPerformerDto + { + PersonId = g.Key.PersonId, + PersonName = g.Key.Name, + TotalThrows = g.Sum(x => x.ThrowCount), + TotalPins = g.Sum(x => x.PinCount), + Average = g.Sum(x => x.ThrowCount) > 0 + ? (double)g.Sum(x => x.PinCount) / g.Sum(x => x.ThrowCount) + : 0 + }) + .OrderByDescending(p => p.Average) + .Take(5) + .ToList(); + + // Monthly trend + var monthlyTrend = yearStats + .Where(x => x.Game.StartedAt.HasValue) + .GroupBy(x => new { x.Game.StartedAt!.Value.Year, x.Game.StartedAt.Value.Month }) + .Select(g => new MonthlyStatsDto + { + Year = g.Key.Year, + Month = g.Key.Month, + Games = g.Select(x => x.GameId).Distinct().Count(), + Throws = g.Sum(x => x.ThrowCount), + Pins = g.Sum(x => x.PinCount), + Average = g.Sum(x => x.ThrowCount) > 0 + ? (double)g.Sum(x => x.PinCount) / g.Sum(x => x.ThrowCount) + : 0 + }) + .OrderBy(m => m.Year) + .ThenBy(m => m.Month) + .ToList(); + + return new StatisticsWidgetDto + { + TotalGamesThisYear = totalGames, + TotalThrowsThisYear = totalThrows, + TotalPinsThisYear = totalPins, + ClubAverageThisYear = totalThrows > 0 ? (double)totalPins / totalThrows : 0, + TopPerformers = topPerformers, + MonthlyTrend = monthlyTrend + }; + } +} diff --git a/src/Koogle.Domain/Entities/PlayerGameStatistics.cs b/src/Koogle.Domain/Entities/PlayerGameStatistics.cs new file mode 100644 index 0000000..a6a951c --- /dev/null +++ b/src/Koogle.Domain/Entities/PlayerGameStatistics.cs @@ -0,0 +1,73 @@ +namespace Koogle.Domain.Entities; + +/// +/// Statistics record for a player in a specific game. +/// Collected independently from game type during gameplay. +/// +public class PlayerGameStatistics : BaseEntity +{ + /// + /// ID of the game. + /// + public Guid GameId { get; set; } + + /// + /// Reference to the game. + /// + public Game Game { get; set; } = null!; + + /// + /// ID of the person/player. + /// + public Guid PersonId { get; set; } + + /// + /// Reference to the person. + /// + public Person Person { get; set; } = null!; + + /// + /// ID of the club (for multi-tenancy). + /// + public Guid ClubId { get; set; } + + /// + /// Reference to the club. + /// + public Club Club { get; set; } = null!; + + /// + /// Total throws made by this player in this game. + /// + public int ThrowCount { get; set; } + + /// + /// Total pins knocked down. + /// + public int PinCount { get; set; } + + /// + /// Number of times all remaining pins were cleared. + /// + public int ClearedCount { get; set; } + + /// + /// Number of gutter throws (Rinne). + /// + public int GutterCount { get; set; } + + /// + /// Number of circles (Kranz) - 8 outer pins, center standing. + /// + public int CircleCount { get; set; } + + /// + /// Number of strikes (all 9 pins down). + /// + public int StrikeCount { get; set; } + + /// + /// Number of bell hits. + /// + public int BellCount { get; set; } +} diff --git a/src/Koogle.Domain/Interfaces/IPlayerGameStatisticsRepository.cs b/src/Koogle.Domain/Interfaces/IPlayerGameStatisticsRepository.cs new file mode 100644 index 0000000..7f713ac --- /dev/null +++ b/src/Koogle.Domain/Interfaces/IPlayerGameStatisticsRepository.cs @@ -0,0 +1,44 @@ +using Koogle.Domain.Entities; + +namespace Koogle.Domain.Interfaces; + +/// +/// Repository for player game statistics. +/// +public interface IPlayerGameStatisticsRepository +{ + /// + /// Gets statistics for a player in a specific game. + /// + Task GetByGameAndPersonAsync(Guid gameId, Guid personId, CancellationToken ct = default); + + /// + /// Gets all statistics for a game. + /// + Task> GetByGameAsync(Guid gameId, CancellationToken ct = default); + + /// + /// Gets all statistics for a person. + /// + Task> GetByPersonAsync(Guid personId, CancellationToken ct = default); + + /// + /// Gets all statistics for a club. + /// + Task> GetByClubAsync(Guid clubId, CancellationToken ct = default); + + /// + /// Gets all statistics for a day. + /// + Task> GetByDayAsync(Guid dayId, CancellationToken ct = default); + + /// + /// Gets statistics for a person within a date range. + /// + Task> GetByPersonAndDateRangeAsync(Guid personId, DateTime from, DateTime to, CancellationToken ct = default); + + /// + /// Creates or updates statistics for a player in a game. + /// + Task UpsertAsync(PlayerGameStatistics entity, CancellationToken ct = default); +} diff --git a/src/Koogle.Infrastructure/Data/AppDbContext.cs b/src/Koogle.Infrastructure/Data/AppDbContext.cs index e9c0883..52894ff 100644 --- a/src/Koogle.Infrastructure/Data/AppDbContext.cs +++ b/src/Koogle.Infrastructure/Data/AppDbContext.cs @@ -33,6 +33,9 @@ public class AppDbContext : DbContext public DbSet UserProfileClubs => Set(); public DbSet ClubInvitations => Set(); + // Statistics + public DbSet PlayerGameStatistics => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/src/Koogle.Infrastructure/Data/Configurations/PlayerGameStatisticsConfiguration.cs b/src/Koogle.Infrastructure/Data/Configurations/PlayerGameStatisticsConfiguration.cs new file mode 100644 index 0000000..6d89030 --- /dev/null +++ b/src/Koogle.Infrastructure/Data/Configurations/PlayerGameStatisticsConfiguration.cs @@ -0,0 +1,44 @@ +using Koogle.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Koogle.Infrastructure.Data.Configurations; + +public class PlayerGameStatisticsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("PlayerGameStatistics"); + + builder.HasKey(x => x.Id); + + // Unique constraint: one stats record per player per game + builder.HasIndex(x => new { x.GameId, x.PersonId }).IsUnique(); + + // Game FK + builder.HasOne(x => x.Game) + .WithMany() + .HasForeignKey(x => x.GameId) + .OnDelete(DeleteBehavior.NoAction); + + // Person FK + builder.HasOne(x => x.Person) + .WithMany() + .HasForeignKey(x => x.PersonId) + .OnDelete(DeleteBehavior.NoAction); + + // Club FK + builder.HasOne(x => x.Club) + .WithMany() + .HasForeignKey(x => x.ClubId) + .OnDelete(DeleteBehavior.NoAction); + + // Indexes for common queries + builder.HasIndex(x => x.PersonId); + builder.HasIndex(x => x.ClubId); + builder.HasIndex(x => x.GameId); + + // Query filter for soft-delete + builder.HasQueryFilter(x => !x.IsDeleted && !x.Game.IsDeleted && !x.Person.IsDeleted); + } +} diff --git a/src/Koogle.Infrastructure/Data/Migrations/20251229140837_AddPlayerGameStatistics.Designer.cs b/src/Koogle.Infrastructure/Data/Migrations/20251229140837_AddPlayerGameStatistics.Designer.cs new file mode 100644 index 0000000..05b4054 --- /dev/null +++ b/src/Koogle.Infrastructure/Data/Migrations/20251229140837_AddPlayerGameStatistics.Designer.cs @@ -0,0 +1,1055 @@ +// +using System; +using KoogleApp.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Koogle.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251229140837_AddPlayerGameStatistics")] + partial class AddPlayerGameStatistics + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("app") + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Koogle.Domain.Entities.Club", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("ExpenseCalculation") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Clubs", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.ClubInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("MaxUses") + .HasColumnType("int"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UsedCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("ClubInvitations", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.Day", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId1") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("PostDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClubId1"); + + b.HasIndex("ClubId", "PostDate"); + + b.ToTable("Days", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.DayPerson", b => + { + b.Property("DayId") + .HasColumnType("uniqueidentifier"); + + b.Property("PersonId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("DayId", "PersonId"); + + b.HasIndex("ClubId"); + + b.HasIndex("DayId"); + + b.HasIndex("PersonId"); + + b.HasIndex("DayId", "ClubId"); + + b.HasIndex("PersonId", "ClubId"); + + b.ToTable("DayPersons", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.Expense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId1") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ExpenseType") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IsInverse") + .HasColumnType("bit"); + + b.Property("IsOneClick") + .HasColumnType("bit"); + + b.Property("IsVariable") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("ClubId1"); + + b.HasIndex("ClubId", "Name"); + + b.ToTable("Expenses", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.ExpenseTrigger", b => + { + b.Property("ClubId") + .HasColumnType("uniqueidentifier"); + + b.Property("ExpenseId") + .HasColumnType("uniqueidentifier"); + + b.Property("TriggerId") + .HasColumnType("uniqueidentifier"); + + b.Property("AssignedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier"); + + b.HasKey("ClubId", "ExpenseId", "TriggerId"); + + b.HasIndex("ClubId"); + + b.HasIndex("ExpenseId"); + + b.HasIndex("TriggerId"); + + b.HasIndex("ExpenseId", "ClubId"); + + b.ToTable("ExpenseTriggers", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId") + .HasColumnType("uniqueidentifier"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("DayId") + .HasColumnType("uniqueidentifier"); + + b.Property("GameData") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("GameType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("StartedAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("DayId"); + + b.HasIndex("DayId", "Status"); + + b.ToTable("Games", "app", t => + { + t.HasCheckConstraint("CK_Games_GameData_IsJson", "ISJSON([GameData]) > 0"); + }); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.GamePerson", b => + { + b.Property("GameId") + .HasColumnType("uniqueidentifier"); + + b.Property("PersonId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("GameId", "PersonId"); + + b.HasIndex("ClubId"); + + b.HasIndex("GameId"); + + b.HasIndex("PersonId"); + + b.ToTable("GamePersons", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId1") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PersonStatus") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("ClubId1"); + + b.ToTable("Persons", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.PersonExpense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("DayId") + .HasColumnType("uniqueidentifier"); + + b.Property("ExpenseId") + .HasColumnType("uniqueidentifier"); + + b.Property("ExpenseType") + .HasColumnType("int"); + + b.Property("GameId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PersonExpenseStatus") + .HasColumnType("int"); + + b.Property("PersonId") + .HasColumnType("uniqueidentifier"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("DayId"); + + b.HasIndex("ExpenseId"); + + b.HasIndex("GameId"); + + b.HasIndex("PersonId", "DayId"); + + b.ToTable("PersonExpenses", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.PlayerGameStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BellCount") + .HasColumnType("int"); + + b.Property("CircleCount") + .HasColumnType("int"); + + b.Property("ClearedCount") + .HasColumnType("int"); + + b.Property("ClubId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("GameId") + .HasColumnType("uniqueidentifier"); + + b.Property("GutterCount") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("PersonId") + .HasColumnType("uniqueidentifier"); + + b.Property("PinCount") + .HasColumnType("int"); + + b.Property("StrikeCount") + .HasColumnType("int"); + + b.Property("ThrowCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("GameId"); + + b.HasIndex("PersonId"); + + b.HasIndex("GameId", "PersonId") + .IsUnique(); + + b.ToTable("PlayerGameStatistics", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.Trigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ExpenseTriggerType") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("ExpenseTriggerType"); + + b.ToTable("Triggers", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.UserProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("CurrentClubId") + .HasColumnType("uniqueidentifier"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("IdentityUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("Locale") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("varchar(20)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("TimeZone") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("CurrentClubId"); + + b.HasIndex("IdentityUserId") + .IsUnique(); + + b.ToTable("UserProfiles", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.UserProfileClub", b => + { + b.Property("UserProfileId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId") + .HasColumnType("uniqueidentifier"); + + b.Property("ApprovedAt") + .HasColumnType("datetime2"); + + b.Property("ApprovedById") + .HasColumnType("uniqueidentifier"); + + b.Property("AssignedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId1") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDefault") + .HasColumnType("bit"); + + b.Property("RejectedAt") + .HasColumnType("datetime2"); + + b.Property("RejectedById") + .HasColumnType("uniqueidentifier"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("UserProfileId", "ClubId"); + + b.HasIndex("ClubId"); + + b.HasIndex("ClubId1"); + + b.HasIndex("UserProfileId") + .IsUnique() + .HasFilter("[IsDefault] = 1"); + + b.ToTable("UserProfileClubs", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.UserProfileClubRoleAssignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssignedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("AssignedById") + .HasColumnType("uniqueidentifier"); + + b.Property("ClubId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UserProfileId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserProfileId", "ClubId", "RoleId") + .IsUnique() + .HasFilter("[IsDeleted] = 0"); + + b.ToTable("UserProfileClubRoleAssignments", "app"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.ClubInvitation", b => + { + b.HasOne("Koogle.Domain.Entities.Club", "Club") + .WithMany() + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Club"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.Day", b => + { + b.HasOne("Koogle.Domain.Entities.Club", "Club") + .WithMany() + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Club", null) + .WithMany("Days") + .HasForeignKey("ClubId1"); + + b.Navigation("Club"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.DayPerson", b => + { + b.HasOne("Koogle.Domain.Entities.Club", "Club") + .WithMany() + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Day", "Day") + .WithMany("DayPersons") + .HasForeignKey("DayId", "ClubId") + .HasPrincipalKey("Id", "ClubId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Person", "Person") + .WithMany("DayPersons") + .HasForeignKey("PersonId", "ClubId") + .HasPrincipalKey("Id", "ClubId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Club"); + + b.Navigation("Day"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.Expense", b => + { + b.HasOne("Koogle.Domain.Entities.Club", "Club") + .WithMany() + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Club", null) + .WithMany("Expenses") + .HasForeignKey("ClubId1"); + + b.Navigation("Club"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.ExpenseTrigger", b => + { + b.HasOne("Koogle.Domain.Entities.Club", "Club") + .WithMany() + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Trigger", "Trigger") + .WithMany() + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Expense", "Expense") + .WithMany() + .HasForeignKey("ExpenseId", "ClubId") + .HasPrincipalKey("Id", "ClubId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Club"); + + b.Navigation("Expense"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.Game", b => + { + b.HasOne("Koogle.Domain.Entities.Club", "Club") + .WithMany() + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Day", "Day") + .WithMany() + .HasForeignKey("DayId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Club"); + + b.Navigation("Day"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.GamePerson", b => + { + b.HasOne("Koogle.Domain.Entities.Club", "Club") + .WithMany() + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Game", "Game") + .WithMany("GamePersons") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Person", "Person") + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Club"); + + b.Navigation("Game"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.Person", b => + { + b.HasOne("Koogle.Domain.Entities.Club", "Club") + .WithMany() + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Club", null) + .WithMany("Persons") + .HasForeignKey("ClubId1"); + + b.Navigation("Club"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.PersonExpense", b => + { + b.HasOne("Koogle.Domain.Entities.Club", "Club") + .WithMany() + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Day", "Day") + .WithMany() + .HasForeignKey("DayId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Expense", "Expense") + .WithMany() + .HasForeignKey("ExpenseId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Game", "Game") + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Koogle.Domain.Entities.Person", "Person") + .WithMany("Expenses") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Club"); + + b.Navigation("Day"); + + b.Navigation("Expense"); + + b.Navigation("Game"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.PlayerGameStatistics", b => + { + b.HasOne("Koogle.Domain.Entities.Club", "Club") + .WithMany() + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Game", "Game") + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Person", "Person") + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Club"); + + b.Navigation("Game"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.UserProfile", b => + { + b.HasOne("Koogle.Domain.Entities.Club", "CurrentClub") + .WithMany() + .HasForeignKey("CurrentClubId"); + + b.Navigation("CurrentClub"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.UserProfileClub", b => + { + b.HasOne("Koogle.Domain.Entities.Club", "Club") + .WithMany() + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Club", null) + .WithMany("Users") + .HasForeignKey("ClubId1"); + + b.HasOne("Koogle.Domain.Entities.UserProfile", "UserProfile") + .WithMany("Clubs") + .HasForeignKey("UserProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Club"); + + b.Navigation("UserProfile"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.UserProfileClubRoleAssignment", b => + { + b.HasOne("Koogle.Domain.Entities.UserProfileClub", "UserProfileClub") + .WithMany("RoleAssignments") + .HasForeignKey("UserProfileId", "ClubId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserProfileClub"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.Club", b => + { + b.Navigation("Days"); + + b.Navigation("Expenses"); + + b.Navigation("Persons"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.Day", b => + { + b.Navigation("DayPersons"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.Game", b => + { + b.Navigation("GamePersons"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.Person", b => + { + b.Navigation("DayPersons"); + + b.Navigation("Expenses"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.UserProfile", b => + { + b.Navigation("Clubs"); + }); + + modelBuilder.Entity("Koogle.Domain.Entities.UserProfileClub", b => + { + b.Navigation("RoleAssignments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Koogle.Infrastructure/Data/Migrations/20251229140837_AddPlayerGameStatistics.cs b/src/Koogle.Infrastructure/Data/Migrations/20251229140837_AddPlayerGameStatistics.cs new file mode 100644 index 0000000..33f5360 --- /dev/null +++ b/src/Koogle.Infrastructure/Data/Migrations/20251229140837_AddPlayerGameStatistics.cs @@ -0,0 +1,93 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Koogle.Infrastructure.Data.Migrations +{ + /// + public partial class AddPlayerGameStatistics : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PlayerGameStatistics", + schema: "app", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + GameId = table.Column(type: "uniqueidentifier", nullable: false), + PersonId = table.Column(type: "uniqueidentifier", nullable: false), + ClubId = table.Column(type: "uniqueidentifier", nullable: false), + ThrowCount = table.Column(type: "int", nullable: false), + PinCount = table.Column(type: "int", nullable: false), + ClearedCount = table.Column(type: "int", nullable: false), + GutterCount = table.Column(type: "int", nullable: false), + CircleCount = table.Column(type: "int", nullable: false), + StrikeCount = table.Column(type: "int", nullable: false), + BellCount = table.Column(type: "int", nullable: false), + IsDeleted = table.Column(type: "bit", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + CreatedById = table.Column(type: "uniqueidentifier", nullable: true), + ModifiedAt = table.Column(type: "datetime2", nullable: true), + ModifiedById = table.Column(type: "uniqueidentifier", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PlayerGameStatistics", x => x.Id); + table.ForeignKey( + name: "FK_PlayerGameStatistics_Clubs_ClubId", + column: x => x.ClubId, + principalSchema: "app", + principalTable: "Clubs", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_PlayerGameStatistics_Games_GameId", + column: x => x.GameId, + principalSchema: "app", + principalTable: "Games", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_PlayerGameStatistics_Persons_PersonId", + column: x => x.PersonId, + principalSchema: "app", + principalTable: "Persons", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_PlayerGameStatistics_ClubId", + schema: "app", + table: "PlayerGameStatistics", + column: "ClubId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayerGameStatistics_GameId", + schema: "app", + table: "PlayerGameStatistics", + column: "GameId"); + + migrationBuilder.CreateIndex( + name: "IX_PlayerGameStatistics_GameId_PersonId", + schema: "app", + table: "PlayerGameStatistics", + columns: new[] { "GameId", "PersonId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PlayerGameStatistics_PersonId", + schema: "app", + table: "PlayerGameStatistics", + column: "PersonId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PlayerGameStatistics", + schema: "app"); + } + } +} diff --git a/src/Koogle.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Koogle.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 0ad5b71..15a3d23 100644 --- a/src/Koogle.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Koogle.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -472,6 +472,71 @@ namespace Koogle.Infrastructure.Data.Migrations b.ToTable("PersonExpenses", "app"); }); + modelBuilder.Entity("Koogle.Domain.Entities.PlayerGameStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BellCount") + .HasColumnType("int"); + + b.Property("CircleCount") + .HasColumnType("int"); + + b.Property("ClearedCount") + .HasColumnType("int"); + + b.Property("ClubId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedById") + .HasColumnType("uniqueidentifier"); + + b.Property("GameId") + .HasColumnType("uniqueidentifier"); + + b.Property("GutterCount") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2"); + + b.Property("ModifiedById") + .HasColumnType("uniqueidentifier"); + + b.Property("PersonId") + .HasColumnType("uniqueidentifier"); + + b.Property("PinCount") + .HasColumnType("int"); + + b.Property("StrikeCount") + .HasColumnType("int"); + + b.Property("ThrowCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClubId"); + + b.HasIndex("GameId"); + + b.HasIndex("PersonId"); + + b.HasIndex("GameId", "PersonId") + .IsUnique(); + + b.ToTable("PlayerGameStatistics", "app"); + }); + modelBuilder.Entity("Koogle.Domain.Entities.Trigger", b => { b.Property("Id") @@ -874,6 +939,33 @@ namespace Koogle.Infrastructure.Data.Migrations b.Navigation("Person"); }); + modelBuilder.Entity("Koogle.Domain.Entities.PlayerGameStatistics", b => + { + b.HasOne("Koogle.Domain.Entities.Club", "Club") + .WithMany() + .HasForeignKey("ClubId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Game", "Game") + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Koogle.Domain.Entities.Person", "Person") + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Club"); + + b.Navigation("Game"); + + b.Navigation("Person"); + }); + modelBuilder.Entity("Koogle.Domain.Entities.UserProfile", b => { b.HasOne("Koogle.Domain.Entities.Club", "CurrentClub") diff --git a/src/Koogle.Infrastructure/DependencyInjection.cs b/src/Koogle.Infrastructure/DependencyInjection.cs index 9178384..7288b61 100644 --- a/src/Koogle.Infrastructure/DependencyInjection.cs +++ b/src/Koogle.Infrastructure/DependencyInjection.cs @@ -82,6 +82,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Services //services.AddScoped(); diff --git a/src/Koogle.Infrastructure/Repositories/PlayerGameStatisticsRepository.cs b/src/Koogle.Infrastructure/Repositories/PlayerGameStatisticsRepository.cs new file mode 100644 index 0000000..4f76248 --- /dev/null +++ b/src/Koogle.Infrastructure/Repositories/PlayerGameStatisticsRepository.cs @@ -0,0 +1,108 @@ +using Koogle.Domain.Entities; +using Koogle.Domain.Interfaces; +using KoogleApp.Data; +using Microsoft.EntityFrameworkCore; + +namespace Koogle.Infrastructure.Repositories; + +/// +/// Repository implementation for player game statistics. +/// +public class PlayerGameStatisticsRepository(IDbContextFactory contextFactory) : IPlayerGameStatisticsRepository +{ + /// + public async Task GetByGameAndPersonAsync(Guid gameId, Guid personId, CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + return await context.PlayerGameStatistics + .FirstOrDefaultAsync(x => x.GameId == gameId && x.PersonId == personId, ct); + } + + /// + public async Task> GetByGameAsync(Guid gameId, CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + return await context.PlayerGameStatistics + .Where(x => x.GameId == gameId) + .Include(x => x.Person) + .ToListAsync(ct); + } + + /// + public async Task> GetByPersonAsync(Guid personId, CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + return await context.PlayerGameStatistics + .Where(x => x.PersonId == personId) + .Include(x => x.Game) + .OrderByDescending(x => x.Game.StartedAt) + .ToListAsync(ct); + } + + /// + public async Task> GetByClubAsync(Guid clubId, CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + return await context.PlayerGameStatistics + .Where(x => x.ClubId == clubId) + .Include(x => x.Person) + .Include(x => x.Game) + .ToListAsync(ct); + } + + /// + public async Task> GetByDayAsync(Guid dayId, CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + return await context.PlayerGameStatistics + .Where(x => x.Game.DayId == dayId) + .Include(x => x.Person) + .Include(x => x.Game) + .ToListAsync(ct); + } + + /// + public async Task> GetByPersonAndDateRangeAsync( + Guid personId, DateTime from, DateTime to, CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + return await context.PlayerGameStatistics + .Where(x => x.PersonId == personId + && x.Game.StartedAt >= from + && x.Game.StartedAt <= to) + .Include(x => x.Game) + .OrderBy(x => x.Game.StartedAt) + .ToListAsync(ct); + } + + /// + public async Task UpsertAsync(PlayerGameStatistics entity, CancellationToken ct = default) + { + await using var context = await contextFactory.CreateDbContextAsync(ct); + + var existing = await context.PlayerGameStatistics + .FirstOrDefaultAsync(x => x.GameId == entity.GameId && x.PersonId == entity.PersonId, ct); + + if (existing != null) + { + existing.ThrowCount = entity.ThrowCount; + existing.PinCount = entity.PinCount; + existing.ClearedCount = entity.ClearedCount; + existing.GutterCount = entity.GutterCount; + existing.CircleCount = entity.CircleCount; + existing.StrikeCount = entity.StrikeCount; + existing.BellCount = entity.BellCount; + existing.ModifiedAt = DateTime.UtcNow; + context.PlayerGameStatistics.Update(existing); + } + else + { + entity.Id = Guid.NewGuid(); + entity.CreatedAt = DateTime.UtcNow; + await context.PlayerGameStatistics.AddAsync(entity, ct); + } + + await context.SaveChangesAsync(ct); + return existing ?? entity; + } +} diff --git a/src/Koogle.Web/Components/Game/Training/TrainingBoard.razor b/src/Koogle.Web/Components/Game/Training/TrainingBoard.razor index 9799390..4b58fa1 100644 --- a/src/Koogle.Web/Components/Game/Training/TrainingBoard.razor +++ b/src/Koogle.Web/Components/Game/Training/TrainingBoard.razor @@ -40,6 +40,7 @@ Kränze alle 9 Gossen + Glocke @@ -98,6 +99,18 @@ 0 } + + @if (context.BellCount > 0) + { + + @context.BellCount + + } + else + { + 0 + } + @context.Average.ToString("F1") @@ -189,6 +202,7 @@ CircleCount = stats.CircleCount, StrikeCount = stats.StrikeCount, GutterCount = stats.GutterCount, + BellCount = stats.BellCount, Average = stats.Average, IsCurrentPlayer = playerId == currentPlayerId }); @@ -218,6 +232,7 @@ public int CircleCount { get; init; } public int StrikeCount { get; init; } public int GutterCount { get; init; } + public int BellCount { get; init; } public double Average { get; init; } public bool IsCurrentPlayer { get; init; } } diff --git a/src/Koogle.Web/Components/Pages/Dashboard.razor b/src/Koogle.Web/Components/Pages/Dashboard.razor index 3dd6777..96085e4 100644 --- a/src/Koogle.Web/Components/Pages/Dashboard.razor +++ b/src/Koogle.Web/Components/Pages/Dashboard.razor @@ -126,6 +126,11 @@ else if (_dashboard is not null) + + + + + diff --git a/src/Koogle.Web/Components/Shared/PlayerStatisticsWidget.razor b/src/Koogle.Web/Components/Shared/PlayerStatisticsWidget.razor new file mode 100644 index 0000000..604a805 --- /dev/null +++ b/src/Koogle.Web/Components/Shared/PlayerStatisticsWidget.razor @@ -0,0 +1,176 @@ +@using Koogle.Application.DTOs +@using Koogle.Application.Interfaces + +@inject IPlayerStatisticsService StatisticsService +@inject ISnackbar Snackbar + + + + + Kegelstatistik @DateTime.Now.Year + + + + @if (_isLoading) + { + + } + else if (_stats != null) + { + @if (_stats.TotalThrowsThisYear == 0) + { + + Noch keine Statistiken vorhanden. Spiele ein paar Runden! + + } + else + { + + +
+ + @_stats.TotalGamesThisYear + Spiele +
+
+ +
+ + @_stats.TotalThrowsThisYear + Wuerfe +
+
+ +
+ + @_stats.TotalPinsThisYear + Kegel +
+
+ +
+ + @_stats.ClubAverageThisYear.ToString("F2") + Durchschnitt +
+
+
+ + @if (_stats.TopPerformers.Count > 0) + { + + Top Kegler + + @{ var rank = 1; } + @foreach (var performer in _stats.TopPerformers.Take(5)) + { + +
+
+ @rank +
+ @performer.PersonName + + @performer.TotalThrows Wuerfe, @performer.TotalPins Kegel + +
+
+ + @performer.Average.ToString("F2") + +
+
+ rank++; + } +
+ } + + @if (_stats.MonthlyTrend.Count > 0) + { + + Monatstrend + + + + Monat + Spiele + Wuerfe + Durchschnitt + + + + @foreach (var month in _stats.MonthlyTrend.TakeLast(6)) + { + + @GetMonthName(month.Month) + @month.Games + @month.Throws + + + @month.Average.ToString("F2") + + + + } + + + } + } + } +
+
+ +@code { + private StatisticsWidgetDto? _stats; + private bool _isLoading = true; + + protected override async Task OnInitializedAsync() + { + try + { + _stats = await StatisticsService.GetDashboardStatisticsAsync(); + } + catch (Exception ex) + { + Snackbar.Add($"Fehler beim Laden der Statistiken: {ex.Message}", Severity.Error); + } + finally + { + _isLoading = false; + } + } + + private static Color GetRankColor(int rank) => rank switch + { + 1 => Color.Warning, // Gold + 2 => Color.Default, // Silver + 3 => Color.Tertiary, // Bronze + _ => Color.Default + }; + + private static Color GetAverageColor(double average) => average switch + { + >= 7 => Color.Success, + >= 5 => Color.Warning, + _ => Color.Error + }; + + private static string GetMonthName(int month) => month switch + { + 1 => "Jan", + 2 => "Feb", + 3 => "Mär", + 4 => "Apr", + 5 => "Mai", + 6 => "Jun", + 7 => "Jul", + 8 => "Aug", + 9 => "Sep", + 10 => "Okt", + 11 => "Nov", + 12 => "Dez", + _ => month.ToString() + }; +} diff --git a/src/Koogle.Web/Store/GameState/GameEffects.cs b/src/Koogle.Web/Store/GameState/GameEffects.cs index 8c0b42b..dba2564 100644 --- a/src/Koogle.Web/Store/GameState/GameEffects.cs +++ b/src/Koogle.Web/Store/GameState/GameEffects.cs @@ -26,6 +26,7 @@ public class GameEffects private readonly GameHubService _hubService; private readonly IOptions _appSettings; private readonly IGameEventService _gameEventService; + private readonly IPlayerStatisticsService _playerStatisticsService; // Debounce timer for save operations private Timer? _saveDebounceTimer; @@ -42,7 +43,8 @@ public class GameEffects GameDefinitionRegistry gameRegistry, GameHubService hubService, IOptions appSettings, - IGameEventService gameEventService) + IGameEventService gameEventService, + IPlayerStatisticsService playerStatisticsService) { _logger = logger; _gameState = gameState; @@ -52,6 +54,7 @@ public class GameEffects _hubService = hubService; _appSettings = appSettings; _gameEventService = gameEventService; + _playerStatisticsService = playerStatisticsService; } /// @@ -431,6 +434,9 @@ public class GameEffects // Fire expense triggers for special throw events await FireThrowTriggersAsync(state, currentPlayerId.Value, action, afterThrowState, dispatcher); + // Record player statistics (game-type independent) + await RecordPlayerStatisticsAsync(state, currentPlayerId.Value, afterThrowState); + // Fire game-specific triggers (e.g., ExpensePoint from Scheiss-Spiel) if (gameLogicTriggers.Count > 0 && state.DayId.HasValue && state.ActiveGameId.HasValue) { @@ -1073,6 +1079,37 @@ public class GameEffects } } + /// + /// Records player statistics for the current throw. + /// + private async Task RecordPlayerStatisticsAsync( + GameState state, + Guid currentPlayerId, + AfterThrowState afterThrowState) + { + if (!state.DayId.HasValue || !state.ActiveGameId.HasValue) + return; + + try + { + await _playerStatisticsService.RecordThrowAsync( + state.ActiveGameId.Value, + currentPlayerId, + _clubContext.ClubId, + afterThrowState.PinsKnocked, + afterThrowState.IsCleared, + afterThrowState.IsGutter, + afterThrowState.IsCircle, + afterThrowState.IsStrike, + afterThrowState.ThrowPanel.BellValue); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error recording player statistics for player {PlayerId}", currentPlayerId); + // Don't fail throw recording if stats fail + } + } + /// /// Extracts PlayerOrder from game model if available (e.g., DeathBox randomizes player order). /// Returns null if the model doesn't have a PlayerOrder property.