add statistics:
Erfasste Metriken: - ThrowCount, PinCount, ClearedCount, GutterCount, CircleCount, StrikeCount, BellCount Widget zeigt: - Jahres-Übersicht (Spiele, Würfe, Kegel, Durchschnitt) - Top 5 Kegler Rangliste - Monatstrend-Tabelle
This commit is contained in:
parent
f5d2ceb628
commit
21130895d4
|
|
@ -0,0 +1,71 @@
|
|||
namespace Koogle.Application.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated statistics for a player.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics for a single day.
|
||||
/// </summary>
|
||||
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<PlayerStatisticsAggregateDto> PlayerStats { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics widget data for dashboard.
|
||||
/// </summary>
|
||||
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<TopPerformerDto> TopPerformers { get; init; } = [];
|
||||
public List<MonthlyStatsDto> MonthlyTrend { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Top performer data.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monthly statistics for trend chart.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ namespace Koogle.Application
|
|||
services.AddScoped<ITriggerService, TriggerService>();
|
||||
services.AddScoped<IGameEventService, GameEventService>();
|
||||
services.AddScoped<IGamePersistenceService, GamePersistenceService>();
|
||||
services.AddScoped<IPlayerStatisticsService, PlayerStatisticsService>();
|
||||
|
||||
// Note: Game types are registered in Koogle.Web where Blazor components are defined
|
||||
// Use services.AddGameType<TDefinition, TLogicService>() to register game types
|
||||
|
|
|
|||
|
|
@ -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<string, object>
|
||||
{
|
||||
["GutterCount"] = kvp.Value.GutterCount
|
||||
["GutterCount"] = kvp.Value.GutterCount,
|
||||
["BellCount"] = kvp.Value.BellCount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ public record TrainingPlayerStats
|
|||
/// </summary>
|
||||
public int GutterCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bell hits (Klingel/Glocke).
|
||||
/// </summary>
|
||||
public int BellCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the average pins per throw.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
using Koogle.Application.DTOs;
|
||||
|
||||
namespace Koogle.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Service for recording and aggregating player game statistics.
|
||||
/// </summary>
|
||||
public interface IPlayerStatisticsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Records throw statistics for a player. Called after each throw.
|
||||
/// </summary>
|
||||
Task RecordThrowAsync(
|
||||
Guid gameId,
|
||||
Guid personId,
|
||||
Guid clubId,
|
||||
int pinsKnocked,
|
||||
bool isCleared,
|
||||
bool isGutter,
|
||||
bool isCircle,
|
||||
bool isStrike,
|
||||
bool isBell,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregated statistics for a person across games.
|
||||
/// </summary>
|
||||
Task<PlayerStatisticsAggregateDto> GetPersonAggregateAsync(
|
||||
Guid personId,
|
||||
DateTime? from = null,
|
||||
DateTime? to = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregated statistics for a day.
|
||||
/// </summary>
|
||||
Task<DayStatisticsDto> GetDayStatisticsAsync(Guid dayId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics for dashboard widget.
|
||||
/// </summary>
|
||||
Task<StatisticsWidgetDto> GetDashboardStatisticsAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Service for recording and aggregating player game statistics.
|
||||
/// </summary>
|
||||
public class PlayerStatisticsService : IPlayerStatisticsService
|
||||
{
|
||||
private readonly IPlayerGameStatisticsRepository _repository;
|
||||
private readonly IDbContextFactory<AppDbContext> _contextFactory;
|
||||
private readonly ICurrentClubContext _clubContext;
|
||||
|
||||
public PlayerStatisticsService(
|
||||
IPlayerGameStatisticsRepository repository,
|
||||
IDbContextFactory<AppDbContext> contextFactory,
|
||||
ICurrentClubContext clubContext)
|
||||
{
|
||||
_repository = repository;
|
||||
_contextFactory = contextFactory;
|
||||
_clubContext = clubContext;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PlayerStatisticsAggregateDto> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DayStatisticsDto> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<StatisticsWidgetDto> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
namespace Koogle.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Statistics record for a player in a specific game.
|
||||
/// Collected independently from game type during gameplay.
|
||||
/// </summary>
|
||||
public class PlayerGameStatistics : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// ID of the game.
|
||||
/// </summary>
|
||||
public Guid GameId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the game.
|
||||
/// </summary>
|
||||
public Game Game { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// ID of the person/player.
|
||||
/// </summary>
|
||||
public Guid PersonId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the person.
|
||||
/// </summary>
|
||||
public Person Person { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// ID of the club (for multi-tenancy).
|
||||
/// </summary>
|
||||
public Guid ClubId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the club.
|
||||
/// </summary>
|
||||
public Club Club { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Total throws made by this player in this game.
|
||||
/// </summary>
|
||||
public int ThrowCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total pins knocked down.
|
||||
/// </summary>
|
||||
public int PinCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of times all remaining pins were cleared.
|
||||
/// </summary>
|
||||
public int ClearedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of gutter throws (Rinne).
|
||||
/// </summary>
|
||||
public int GutterCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of circles (Kranz) - 8 outer pins, center standing.
|
||||
/// </summary>
|
||||
public int CircleCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of strikes (all 9 pins down).
|
||||
/// </summary>
|
||||
public int StrikeCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of bell hits.
|
||||
/// </summary>
|
||||
public int BellCount { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
using Koogle.Domain.Entities;
|
||||
|
||||
namespace Koogle.Domain.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for player game statistics.
|
||||
/// </summary>
|
||||
public interface IPlayerGameStatisticsRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets statistics for a player in a specific game.
|
||||
/// </summary>
|
||||
Task<PlayerGameStatistics?> GetByGameAndPersonAsync(Guid gameId, Guid personId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all statistics for a game.
|
||||
/// </summary>
|
||||
Task<List<PlayerGameStatistics>> GetByGameAsync(Guid gameId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all statistics for a person.
|
||||
/// </summary>
|
||||
Task<List<PlayerGameStatistics>> GetByPersonAsync(Guid personId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all statistics for a club.
|
||||
/// </summary>
|
||||
Task<List<PlayerGameStatistics>> GetByClubAsync(Guid clubId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all statistics for a day.
|
||||
/// </summary>
|
||||
Task<List<PlayerGameStatistics>> GetByDayAsync(Guid dayId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics for a person within a date range.
|
||||
/// </summary>
|
||||
Task<List<PlayerGameStatistics>> GetByPersonAndDateRangeAsync(Guid personId, DateTime from, DateTime to, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates statistics for a player in a game.
|
||||
/// </summary>
|
||||
Task<PlayerGameStatistics> UpsertAsync(PlayerGameStatistics entity, CancellationToken ct = default);
|
||||
}
|
||||
|
|
@ -33,6 +33,9 @@ public class AppDbContext : DbContext
|
|||
public DbSet<UserProfileClub> UserProfileClubs => Set<UserProfileClub>();
|
||||
public DbSet<ClubInvitation> ClubInvitations => Set<ClubInvitation>();
|
||||
|
||||
// Statistics
|
||||
public DbSet<PlayerGameStatistics> PlayerGameStatistics => Set<PlayerGameStatistics>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
|
|
|||
|
|
@ -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<PlayerGameStatistics>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlayerGameStatistics> 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);
|
||||
}
|
||||
}
|
||||
1055
src/Koogle.Infrastructure/Data/Migrations/20251229140837_AddPlayerGameStatistics.Designer.cs
generated
Normal file
1055
src/Koogle.Infrastructure/Data/Migrations/20251229140837_AddPlayerGameStatistics.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,93 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Koogle.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPlayerGameStatistics : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlayerGameStatistics",
|
||||
schema: "app",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
GameId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
PersonId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ClubId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ThrowCount = table.Column<int>(type: "int", nullable: false),
|
||||
PinCount = table.Column<int>(type: "int", nullable: false),
|
||||
ClearedCount = table.Column<int>(type: "int", nullable: false),
|
||||
GutterCount = table.Column<int>(type: "int", nullable: false),
|
||||
CircleCount = table.Column<int>(type: "int", nullable: false),
|
||||
StrikeCount = table.Column<int>(type: "int", nullable: false),
|
||||
BellCount = table.Column<int>(type: "int", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CreatedById = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||
ModifiedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
ModifiedById = table.Column<Guid>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlayerGameStatistics",
|
||||
schema: "app");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -472,6 +472,71 @@ namespace Koogle.Infrastructure.Data.Migrations
|
|||
b.ToTable("PersonExpenses", "app");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Koogle.Domain.Entities.PlayerGameStatistics", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("BellCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("CircleCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("ClearedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<Guid>("ClubId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("CreatedById")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("GameId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("GutterCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<Guid?>("ModifiedById")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("PinCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("StrikeCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("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<Guid>("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")
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ public static class DependencyInjection
|
|||
services.AddScoped<IDayRepository, DayRepository>();
|
||||
services.AddScoped<IPersonExpenseRepository, PersonExpenseRepository>();
|
||||
services.AddScoped<ITriggerRepository, TriggerRepository>();
|
||||
services.AddScoped<IPlayerGameStatisticsRepository, PlayerGameStatisticsRepository>();
|
||||
|
||||
// Services
|
||||
//services.AddScoped<IEmailService, StubEmailService>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
using Koogle.Domain.Entities;
|
||||
using Koogle.Domain.Interfaces;
|
||||
using KoogleApp.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Koogle.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository implementation for player game statistics.
|
||||
/// </summary>
|
||||
public class PlayerGameStatisticsRepository(IDbContextFactory<AppDbContext> contextFactory) : IPlayerGameStatisticsRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PlayerGameStatistics?> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<PlayerGameStatistics>> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<PlayerGameStatistics>> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<PlayerGameStatistics>> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<PlayerGameStatistics>> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<PlayerGameStatistics>> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PlayerGameStatistics> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
<MudTh Style="text-align: right">Kränze</MudTh>
|
||||
<MudTh Style="text-align: right">alle 9</MudTh>
|
||||
<MudTh Style="text-align: right">Gossen</MudTh>
|
||||
<MudTh Style="text-align: right">Glocke</MudTh>
|
||||
<MudTh Style="text-align: right">⌀</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
|
|
@ -98,6 +99,18 @@
|
|||
<span>0</span>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd Style="text-align: right">
|
||||
@if (context.BellCount > 0)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Filled">
|
||||
@context.BellCount
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>0</span>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd Style="text-align: right; font-weight: 600">
|
||||
@context.Average.ToString("F1")
|
||||
</MudTd>
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,11 @@ else if (_dashboard is not null)
|
|||
<PendingMembershipsWidget />
|
||||
</MudItem>
|
||||
|
||||
<!-- Player Statistics Widget -->
|
||||
<MudItem xs="12" md="6">
|
||||
<PlayerStatisticsWidget />
|
||||
</MudItem>
|
||||
|
||||
<!-- Top Penalty Recipients -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="2">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
@using Koogle.Application.DTOs
|
||||
@using Koogle.Application.Interfaces
|
||||
|
||||
@inject IPlayerStatisticsService StatisticsService
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard Elevation="2">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Kegelstatistik @DateTime.Now.Year</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
@if (_isLoading)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
||||
}
|
||||
else if (_stats != null)
|
||||
{
|
||||
@if (_stats.TotalThrowsThisYear == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
Noch keine Statistiken vorhanden. Spiele ein paar Runden!
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="6" sm="3">
|
||||
<div class="d-flex flex-column align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.SportsScore" Size="Size.Medium" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h5" Class="mt-1">@_stats.TotalGamesThisYear</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Spiele</MudText>
|
||||
</div>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<div class="d-flex flex-column align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Gesture" Size="Size.Medium" Color="Color.Info" />
|
||||
<MudText Typo="Typo.h5" Class="mt-1">@_stats.TotalThrowsThisYear</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Wuerfe</MudText>
|
||||
</div>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<div class="d-flex flex-column align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Adjust" Size="Size.Medium" Color="Color.Success" />
|
||||
<MudText Typo="Typo.h5" Class="mt-1">@_stats.TotalPinsThisYear</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Kegel</MudText>
|
||||
</div>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<div class="d-flex flex-column align-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.TrendingUp" Size="Size.Medium" Color="Color.Warning" />
|
||||
<MudText Typo="Typo.h5" Class="mt-1">@_stats.ClubAverageThisYear.ToString("F2")</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Durchschnitt</MudText>
|
||||
</div>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@if (_stats.TopPerformers.Count > 0)
|
||||
{
|
||||
<MudDivider Class="my-4" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Top Kegler</MudText>
|
||||
<MudList T="TopPerformerDto" Dense="true">
|
||||
@{ var rank = 1; }
|
||||
@foreach (var performer in _stats.TopPerformers.Take(5))
|
||||
{
|
||||
<MudListItem T="TopPerformerDto">
|
||||
<div class="d-flex justify-space-between align-center" style="width: 100%">
|
||||
<div class="d-flex align-center">
|
||||
<MudAvatar Size="Size.Small" Color="@GetRankColor(rank)" Class="mr-2">@rank</MudAvatar>
|
||||
<div>
|
||||
<MudText Typo="Typo.body2">@performer.PersonName</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
@performer.TotalThrows Wuerfe, @performer.TotalPins Kegel
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Success">
|
||||
@performer.Average.ToString("F2")
|
||||
</MudChip>
|
||||
</div>
|
||||
</MudListItem>
|
||||
rank++;
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
|
||||
@if (_stats.MonthlyTrend.Count > 0)
|
||||
{
|
||||
<MudDivider Class="my-4" />
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Monatstrend</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Monat</th>
|
||||
<th style="text-align: right">Spiele</th>
|
||||
<th style="text-align: right">Wuerfe</th>
|
||||
<th style="text-align: right">Durchschnitt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var month in _stats.MonthlyTrend.TakeLast(6))
|
||||
{
|
||||
<tr>
|
||||
<td>@GetMonthName(month.Month)</td>
|
||||
<td style="text-align: right">@month.Games</td>
|
||||
<td style="text-align: right">@month.Throws</td>
|
||||
<td style="text-align: right">
|
||||
<MudChip T="string" Size="Size.Small"
|
||||
Color="@GetAverageColor(month.Average)"
|
||||
Variant="Variant.Text">
|
||||
@month.Average.ToString("F2")
|
||||
</MudChip>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
}
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@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()
|
||||
};
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ public class GameEffects
|
|||
private readonly GameHubService _hubService;
|
||||
private readonly IOptions<AppSettings> _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> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records player statistics for the current throw.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts PlayerOrder from game model if available (e.g., DeathBox randomizes player order).
|
||||
/// Returns null if the model doesn't have a PlayerOrder property.
|
||||
|
|
|
|||
Loading…
Reference in New Issue