Merge pull request 'feat(#14): attendance statistics page' (#45) from issue-14-attendance-stats into main
Deploy Telegram Bot / build-and-push (push) Successful in 3m57s
Deploy Telegram Bot / deploy (push) Successful in 12s

feat(#14): attendance statistics page
This commit was merged in pull request #45.
This commit is contained in:
2026-05-07 14:32:40 +03:00
5 changed files with 351 additions and 0 deletions
@@ -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;
@@ -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
<PageTitle>Статистика — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li><a href="/group/@GroupId">Сессии группы</a></li>
<li class="active">Статистика</li>
</ul>
<div class="page-header animate-fade-in">
<h2>📊 Статистика посещаемости</h2>
<p class="page-subtitle">Надёжность состава и качество расписания</p>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
⚠️ @errorMessage
</div>
}
@if (stats is null)
{
<div class="loading-spinner">⏳ Загружаем статистику…</div>
}
else if (stats.Count == 0)
{
<div class="empty-state">
<div class="empty-icon">📈</div>
<h3>Пока нет данных</h3>
<p>После первых сессий здесь появится аналитика.</p>
</div>
}
else
{
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="stats-summary" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
<div class="stat-card">
<div class="stat-value">@stats.Count</div>
<div class="stat-label">Игроков</div>
</div>
<div class="stat-card">
<div class="stat-value">@TotalSessions</div>
<div class="stat-label">Сессий</div>
</div>
<div class="stat-card">
<div class="stat-value">@AvgAttendanceRate%</div>
<div class="stat-label">Средняя посещаемость</div>
</div>
<div class="stat-card">
<div class="stat-value">@topPlayer?.DisplayName</div>
<div class="stat-label">Самый стабильный</div>
</div>
</div>
<div class="table-responsive">
<table class="gm-table" style="width: 100%;">
<thead>
<tr>
<th @onclick="@(() => SortBy("player"))" style="cursor:pointer;" class="sortable">Игрок @(sortColumn == "player" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("total"))" style="cursor:pointer; text-align:center;" class="sortable">Всего @(sortColumn == "total" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("confirmed"))" style="cursor:pointer; text-align:center;" class="sortable">✅ @(sortColumn == "confirmed" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("declined"))" style="cursor:pointer; text-align:center;" class="sortable">❌ @(sortColumn == "declined" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("noresponse"))" style="cursor:pointer; text-align:center;" class="sortable">💤 @(sortColumn == "noresponse" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("waitlist"))" style="cursor:pointer; text-align:center;" class="sortable">⏳ @(sortColumn == "waitlist" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("rate"))" style="cursor:pointer; text-align:center;" class="sortable">% @(sortColumn == "rate" ? (sortDesc ? "▼" : "▲") : "")</th>
<th @onclick="@(() => SortBy("cancelled"))" style="cursor:pointer; text-align:center;" class="sortable">🚫 @(sortColumn == "cancelled" ? (sortDesc ? "▼" : "▲") : "")</th>
</tr>
</thead>
<tbody>
@foreach (var s in sortedStats)
{
<tr>
<td>
<div class="player-info">
<span class="player-name">@s.DisplayName</span>
@if (!string.IsNullOrEmpty(s.TelegramUsername))
{
<span class="player-username">@@@s.TelegramUsername</span>
}
</div>
</td>
<td style="text-align:center;">@s.TotalSessions</td>
<td style="text-align:center;">@s.ConfirmedCount</td>
<td style="text-align:center;">@s.DeclinedCount</td>
<td style="text-align:center;">@s.NoResponseCount</td>
<td style="text-align:center;">@s.WaitlistedCount</td>
<td style="text-align:center;">
<span class="rate-badge @AttendanceBadgeClass(s.AttendanceRate)">
@s.AttendanceRate%
</span>
</td>
<td style="text-align:center;">@s.CancellationAffectedCount</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
<style>
.stat-card {
background: var(--card-bg-secondary, rgba(255,255,255,0.05));
border-radius: 0.75rem;
padding: 1rem;
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--accent-color, #7cb97a);
}
.stat-label {
font-size: 0.8rem;
color: var(--text-muted, #94a3b8);
margin-top: 0.25rem;
}
.player-info {
display: flex;
flex-direction: column;
}
.player-name {
font-weight: 500;
}
.player-username {
font-size: 0.8rem;
color: var(--text-muted, #94a3b8);
}
.rate-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
}
.rate-excellent { background: rgba(34,197,94,0.15); color: #22c55e; }
.rate-good { background: rgba(234,179,8,0.15); color: #eab308; }
.rate-poor { background: rgba(239,68,68,0.15); color: #ef4444; }
</style>
@code {
[Parameter] public Guid GroupId { get; set; }
private List<PlayerAttendanceStats>? stats;
private List<PlayerAttendanceStats> 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<PlayerAttendanceStats> 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"
};
}
+14
View File
@@ -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<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
@@ -27,4 +40,5 @@ public interface ISessionStore
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId);
}
@@ -169,6 +169,39 @@ public sealed class SessionService(
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList();
}
public async Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<PlayerAttendanceStats>(
"""
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,
@@ -886,6 +886,9 @@ public sealed class AuthorizedSessionServiceTests
return Task.CompletedTask;
}
public Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId) =>
Task.FromResult(new List<PlayerAttendanceStats>());
private bool IsManager(Guid groupId, long telegramId) =>
IsOwner(groupId, telegramId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);