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;
diff --git a/src/GmRelay.Web/Components/Pages/GroupStats.razor b/src/GmRelay.Web/Components/Pages/GroupStats.razor
new file mode 100644
index 0000000..43ea763
--- /dev/null
+++ b/src/GmRelay.Web/Components/Pages/GroupStats.razor
@@ -0,0 +1,235 @@
+@page "/group/{GroupId:guid}/stats"
+@using GmRelay.Web.Services
+@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
+@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
+
Самый стабильный
+
+
+
+
+
+
+
+ | SortBy("player"))" style="cursor:pointer;" class="sortable">Игрок @(sortColumn == "player" ? (sortDesc ? "▼" : "▲") : "") |
+ SortBy("total"))" style="cursor:pointer; text-align:center;" class="sortable">Всего @(sortColumn == "total" ? (sortDesc ? "▼" : "▲") : "") |
+ SortBy("confirmed"))" style="cursor:pointer; text-align:center;" class="sortable">✅ @(sortColumn == "confirmed" ? (sortDesc ? "▼" : "▲") : "") |
+ SortBy("declined"))" style="cursor:pointer; text-align:center;" class="sortable">❌ @(sortColumn == "declined" ? (sortDesc ? "▼" : "▲") : "") |
+ SortBy("noresponse"))" style="cursor:pointer; text-align:center;" class="sortable">💤 @(sortColumn == "noresponse" ? (sortDesc ? "▼" : "▲") : "") |
+ SortBy("waitlist"))" style="cursor:pointer; text-align:center;" class="sortable">⏳ @(sortColumn == "waitlist" ? (sortDesc ? "▼" : "▲") : "") |
+ SortBy("rate"))" style="cursor:pointer; text-align:center;" class="sortable">% @(sortColumn == "rate" ? (sortDesc ? "▼" : "▲") : "") |
+ SortBy("cancelled"))" style="cursor:pointer; text-align:center;" class="sortable">🚫 @(sortColumn == "cancelled" ? (sortDesc ? "▼" : "▲") : "") |
+
+
+
+ @foreach (var s in sortedStats)
+ {
+
+ |
+
+ @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"
+ };
+}
diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs
index d36000a..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);
@@ -27,4 +40,5 @@ 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);
}
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,
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);