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:
beo3000 2025-12-29 15:27:25 +01:00
parent f5d2ceb628
commit 21130895d4
19 changed files with 2089 additions and 2 deletions

View File

@ -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; }
}

View File

@ -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

View File

@ -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
}
});
}

View File

@ -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>

View File

@ -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);
}

View File

@ -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
};
}
}

View File

@ -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; }
}

View File

@ -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);
}

View File

@ -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);

View File

@ -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);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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");
}
}
}

View File

@ -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")

View File

@ -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>();

View File

@ -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;
}
}

View File

@ -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; }
}

View File

@ -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">

View File

@ -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()
};
}

View File

@ -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.