From 063de7ee3e884b8a8a452d87c0666a395a43d5bc Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 7 May 2026 13:12:39 +0300 Subject: [PATCH 1/7] feat(#14): add get_group_attendance_stats SQL function --- .../Migrations/V012__add_attendance_stats.sql | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/GmRelay.Bot/Migrations/V012__add_attendance_stats.sql diff --git a/src/GmRelay.Bot/Migrations/V012__add_attendance_stats.sql b/src/GmRelay.Bot/Migrations/V012__add_attendance_stats.sql new file mode 100644 index 0000000..693115b --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V012__add_attendance_stats.sql @@ -0,0 +1,66 @@ +-- ============================================================= +-- Attendance statistics view for GM analytics +-- Returns per-player aggregated metrics for a given game group. +-- NOTE: waitlist count reflects CURRENT registration_status only. +-- Full historical waitlist tracking will come with #15. +-- ============================================================= + +CREATE OR REPLACE FUNCTION get_group_attendance_stats(p_group_id UUID) +RETURNS TABLE ( + player_id UUID, + display_name VARCHAR, + telegram_username VARCHAR, + total_sessions BIGINT, + confirmed_count BIGINT, + declined_count BIGINT, + no_response_count BIGINT, + waitlisted_count BIGINT, + cancellation_affected_count BIGINT, + attendance_rate NUMERIC +) AS $$ +BEGIN + RETURN QUERY + WITH player_sessions AS ( + SELECT + sp.player_id, + s.id AS session_id, + sp.rsvp_status, + sp.registration_status, + s.status AS session_status, + s.scheduled_at + FROM session_participants sp + JOIN sessions s ON s.id = sp.session_id + WHERE s.group_id = p_group_id + ), + player_totals AS ( + SELECT + ps.player_id, + COUNT(*) FILTER (WHERE ps.session_status <> 'Cancelled') AS total_sessions, + COUNT(*) FILTER (WHERE ps.rsvp_status = 'Confirmed' AND ps.session_status <> 'Cancelled') AS confirmed_count, + COUNT(*) FILTER (WHERE ps.rsvp_status = 'Declined' AND ps.session_status <> 'Cancelled') AS declined_count, + COUNT(*) FILTER (WHERE ps.rsvp_status = 'Pending' AND ps.scheduled_at < NOW() AND ps.session_status <> 'Cancelled') AS no_response_count, + COUNT(*) FILTER (WHERE ps.registration_status = 'Waitlisted' AND ps.session_status <> 'Cancelled') AS waitlisted_count, + COUNT(*) FILTER (WHERE ps.session_status = 'Cancelled') AS cancellation_affected_count + FROM player_sessions ps + GROUP BY ps.player_id + ) + SELECT + pt.player_id, + p.display_name, + p.telegram_username, + pt.total_sessions, + pt.confirmed_count, + pt.declined_count, + pt.no_response_count, + pt.waitlisted_count, + pt.cancellation_affected_count, + ROUND( + 100.0 * pt.confirmed_count + / NULLIF(pt.total_sessions, 0), + 1 + ) AS attendance_rate + FROM player_totals pt + JOIN players p ON p.id = pt.player_id + ORDER BY pt.confirmed_count DESC, pt.total_sessions DESC; +END; +$$ LANGUAGE plpgsql STABLE; -- 2.52.0 From 116bed16a892832d9ea5875a6d19319fc7fd63ad Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 7 May 2026 13:26:01 +0300 Subject: [PATCH 2/7] feat(#14): add PlayerAttendanceStats record + interface method --- src/GmRelay.Web/Services/ISessionStore.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index d36000a..07e6999 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -27,4 +27,18 @@ public interface ISessionStore Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId); Task> GetSessionParticipantsAsync(Guid sessionId); Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId); + Task> GetGroupAttendanceStatsAsync(Guid groupId); + +public sealed record PlayerAttendanceStats( + Guid PlayerId, + string DisplayName, + string? TelegramUsername, + long TotalSessions, + long ConfirmedCount, + long DeclinedCount, + long NoResponseCount, + long WaitlistedCount, + long CancellationAffectedCount, + decimal AttendanceRate +); } -- 2.52.0 From ae6be912e328a2de7e2e84199daed5001a4fa566 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 7 May 2026 13:26:03 +0300 Subject: [PATCH 3/7] feat(#14): add GroupStats.razor attendance page --- .../Components/Pages/GroupStats.razor | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 src/GmRelay.Web/Components/Pages/GroupStats.razor diff --git a/src/GmRelay.Web/Components/Pages/GroupStats.razor b/src/GmRelay.Web/Components/Pages/GroupStats.razor new file mode 100644 index 0000000..c8d386b --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/GroupStats.razor @@ -0,0 +1,234 @@ +@page "/group/{GroupId:guid}/stats" +@using GmRelay.Web.Services +@using GmRelay.Shared.Domain +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@attribute [Authorize] +@inject ISessionStore SessionStore +@inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager Navigation + +Статистика — GM-Relay + +
+ + + + + @if (!string.IsNullOrEmpty(errorMessage)) + { +
+ ⚠️ @errorMessage +
+ } + + @if (stats is null) + { +
⏳ Загружаем статистику…
+ } + else if (stats.Count == 0) + { +
+
📈
+

Пока нет данных

+

После первых сессий здесь появится аналитика.

+
+ } + else + { +
+
+
+
@stats.Count
+
Игроков
+
+
+
@TotalSessions
+
Сессий
+
+
+
@AvgAttendanceRate%
+
Средняя посещаемость
+
+
+
@topPlayer?.DisplayName
+
Самый стабильный
+
+
+ +
+ + + + + + + + + + + + + + + @foreach (var s in sortedStats) + { + + + + + + + + + + + } + +
Игрок @(SortIndicator("player"))Всего @(SortIndicator("total"))✅ @(SortIndicator("confirmed"))❌ @(SortIndicator("declined"))💤 @(SortIndicator("noresponse"))⏳ @(SortIndicator("waitlist"))% @(SortIndicator("rate"))🚫 @(SortIndicator("cancelled"))
+
+ @s.DisplayName + @if (!string.IsNullOrEmpty(s.TelegramUsername)) + { + @@@s.TelegramUsername + } +
+
@s.TotalSessions@s.ConfirmedCount@s.DeclinedCount@s.NoResponseCount@s.WaitlistedCount + + @s.AttendanceRate% + + @s.CancellationAffectedCount
+
+
+ } +
+ + + +@code { + [Parameter] public Guid GroupId { get; set; } + private List? stats; + private List sortedStats = new(); + private string? errorMessage; + private string sortColumn = "confirmed"; + private bool sortDesc = true; + private int TotalSessions => stats?.Count > 0 ? (int)(stats.Max(s => s.TotalSessions)) : 0; + private int AvgAttendanceRate => stats?.Count > 0 ? (int)(stats.Average(s => s.AttendanceRate)) : 0; + private PlayerAttendanceStats? topPlayer => stats?.OrderByDescending(s => s.AttendanceRate).ThenByDescending(s => s.ConfirmedCount).FirstOrDefault(); + + protected override async Task OnInitializedAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + var user = authState.User; + if (!user.Identity?.IsAuthenticated ?? true) + { + Navigation.NavigateTo("/login"); + return; + } + var telegramIdClaim = user.FindFirst("telegram_id")?.Value + ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!long.TryParse(telegramIdClaim, out var telegramId)) + { + Navigation.NavigateTo("/login"); + return; + } + try + { + if (!await SessionStore.IsGroupManagerAsync(GroupId, telegramId)) + { + Navigation.NavigateTo("/access-denied"); + return; + } + stats = await SessionStore.GetGroupAttendanceStatsAsync(GroupId) ?? new(); + UpdateSortedStats(); + } + catch (Exception ex) + { + errorMessage = $"Ошибка загрузки статистики: {ex.Message}"; + } + } + + private void SortBy(string column) + { + if (sortColumn == column) + sortDesc = !sortDesc; + else + { + sortColumn = column; + sortDesc = true; + } + UpdateSortedStats(); + } + + private void UpdateSortedStats() + { + if (stats is null) { sortedStats = new(); return; } + IOrderedEnumerable ordered = sortColumn switch + { + "player" => sortDesc ? stats.OrderByDescending(s => s.DisplayName) : stats.OrderBy(s => s.DisplayName), + "total" => sortDesc ? stats.OrderByDescending(s => s.TotalSessions) : stats.OrderBy(s => s.TotalSessions), + "confirmed" => sortDesc ? stats.OrderByDescending(s => s.ConfirmedCount) : stats.OrderBy(s => s.ConfirmedCount), + "declined" => sortDesc ? stats.OrderByDescending(s => s.DeclinedCount) : stats.OrderBy(s => s.DeclinedCount), + "noresponse" => sortDesc ? stats.OrderByDescending(s => s.NoResponseCount) : stats.OrderBy(s => s.NoResponseCount), + "waitlist" => sortDesc ? stats.OrderByDescending(s => s.WaitlistedCount) : stats.OrderBy(s => s.WaitlistedCount), + "rate" => sortDesc ? stats.OrderByDescending(s => s.AttendanceRate) : stats.OrderBy(s => s.AttendanceRate), + "cancelled" => sortDesc ? stats.OrderByDescending(s => s.CancellationAffectedCount) : stats.OrderBy(s => s.CancellationAffectedCount), + _ => stats.OrderByDescending(s => s.ConfirmedCount) + }; + sortedStats = ordered.ToList(); + } + + private string SortIndicator(string column) => sortColumn == column ? (sortDesc ? "▼" : "▲") : ""; + + private string AttendanceBadgeClass(decimal rate) => rate switch + { + >= 75m => "rate-excellent", + >= 50m => "rate-good", + _ => "rate-poor" + }; +} -- 2.52.0 From 7e2747ec7394567cbddbb9a9b5ebdec9104d25a3 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 7 May 2026 11:05:38 +0000 Subject: [PATCH 4/7] feat: implement GetGroupAttendanceStatsAsync (#14) --- src/GmRelay.Web/Services/SessionService.cs | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 88145ee..55d4b59 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -169,6 +169,39 @@ public sealed class SessionService( new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList(); } + public async Task> GetGroupAttendanceStatsAsync(Guid groupId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + return (await conn.QueryAsync( + """ + SELECT + p.id AS PlayerId, + p.display_name AS DisplayName, + p.telegram_username AS TelegramUsername, + COUNT(DISTINCT s.id) AS TotalSessions, + COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount, + COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount, + COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Pending' THEN s.id END) AS NoResponseCount, + COUNT(DISTINCT CASE WHEN sp.registration_status = 'Waitlisted' THEN s.id END) AS WaitlistedCount, + COUNT(DISTINCT CASE WHEN s.status = 'Cancelled' AND sp.rsvp_status IN ('Confirmed','Declined') THEN s.id END) AS CancellationAffectedCount, + CASE WHEN COUNT(DISTINCT s.id) > 0 + THEN ROUND( + COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) + * 100.0 / COUNT(DISTINCT s.id), 2) + ELSE 0 + END AS AttendanceRate + FROM players p + JOIN session_participants sp ON sp.player_id = p.id + JOIN sessions s ON s.id = sp.session_id + WHERE s.group_id = @GroupId + AND s.scheduled_at <= now() + AND sp.is_gm = false + GROUP BY p.id, p.display_name, p.telegram_username + ORDER BY AttendanceRate DESC, ConfirmedCount DESC + """, + new { GroupId = groupId })).ToList(); + } + public async Task AddGroupCoGmAsync( Guid groupId, long ownerTelegramId, -- 2.52.0 From b03929174a6869b84c1f43e5f2215a983eed4a5a Mon Sep 17 00:00:00 2001 From: root Date: Thu, 7 May 2026 11:16:13 +0000 Subject: [PATCH 5/7] fix: move PlayerAttendanceStats out of interface scope The record was nested inside ISessionStore, making it ISessionStore.PlayerAttendanceStats. C# does not infer nested types in return signatures; callers and implementors failed with CS0246 / CS0738. Moving it to namespace scope resolves the build. --- src/GmRelay.Web/Services/ISessionStore.cs | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index 07e6999..2033608 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -2,6 +2,19 @@ using GmRelay.Shared.Domain; namespace GmRelay.Web.Services; +public sealed record PlayerAttendanceStats( + Guid PlayerId, + string DisplayName, + string? TelegramUsername, + long TotalSessions, + long ConfirmedCount, + long DeclinedCount, + long NoResponseCount, + long WaitlistedCount, + long CancellationAffectedCount, + decimal AttendanceRate +); + public interface ISessionStore { Task> GetGroupsForGmAsync(long gmId); @@ -28,17 +41,4 @@ public interface ISessionStore Task> GetSessionParticipantsAsync(Guid sessionId); Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId); Task> GetGroupAttendanceStatsAsync(Guid groupId); - -public sealed record PlayerAttendanceStats( - Guid PlayerId, - string DisplayName, - string? TelegramUsername, - long TotalSessions, - long ConfirmedCount, - long DeclinedCount, - long NoResponseCount, - long WaitlistedCount, - long CancellationAffectedCount, - decimal AttendanceRate -); } -- 2.52.0 From 4d3362d93f0eb4100247c1e64d3ae922705b70cb Mon Sep 17 00:00:00 2001 From: root Date: Thu, 7 May 2026 11:21:42 +0000 Subject: [PATCH 6/7] fix: GroupStats.razor syntax and missing using for Claims - Add @using System.Security.Claims - Fix quotation marks in @onclick lambdas (Razor parser error CS1026) --- .../Components/Pages/GroupStats.razor | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/GmRelay.Web/Components/Pages/GroupStats.razor b/src/GmRelay.Web/Components/Pages/GroupStats.razor index c8d386b..43ea763 100644 --- a/src/GmRelay.Web/Components/Pages/GroupStats.razor +++ b/src/GmRelay.Web/Components/Pages/GroupStats.razor @@ -3,6 +3,7 @@ @using GmRelay.Shared.Domain @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization +@using System.Security.Claims @attribute [Authorize] @inject ISessionStore SessionStore @inject AuthenticationStateProvider AuthStateProvider @@ -67,14 +68,14 @@ - - - - - - - - + + + + + + + + -- 2.52.0 From 706f20e4036b9e9f4ded06b6658b194b00a60b13 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 7 May 2026 11:26:22 +0000 Subject: [PATCH 7/7] fix: add GetGroupAttendanceStatsAsync stub to FakeSessionStore in tests Resolves CS0535 build failure in test project. --- tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs index 6a2a831..4d3634b 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -886,6 +886,9 @@ public sealed class AuthorizedSessionServiceTests return Task.CompletedTask; } + public Task> GetGroupAttendanceStatsAsync(Guid groupId) => + Task.FromResult(new List()); + private bool IsManager(Guid groupId, long telegramId) => IsOwner(groupId, telegramId) || managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId); -- 2.52.0
Игрок @(SortIndicator("player"))Всего @(SortIndicator("total"))✅ @(SortIndicator("confirmed"))❌ @(SortIndicator("declined"))💤 @(SortIndicator("noresponse"))⏳ @(SortIndicator("waitlist"))% @(SortIndicator("rate"))🚫 @(SortIndicator("cancelled"))Игрок @(sortColumn == "player" ? (sortDesc ? "▼" : "▲") : "")Всего @(sortColumn == "total" ? (sortDesc ? "▼" : "▲") : "")✅ @(sortColumn == "confirmed" ? (sortDesc ? "▼" : "▲") : "")❌ @(sortColumn == "declined" ? (sortDesc ? "▼" : "▲") : "")💤 @(sortColumn == "noresponse" ? (sortDesc ? "▼" : "▲") : "")⏳ @(sortColumn == "waitlist" ? (sortDesc ? "▼" : "▲") : "")% @(sortColumn == "rate" ? (sortDesc ? "▼" : "▲") : "")🚫 @(sortColumn == "cancelled" ? (sortDesc ? "▼" : "▲") : "")