Compare commits

...

26 Commits

Author SHA1 Message Date
root 706f20e403 fix: add GetGroupAttendanceStatsAsync stub to FakeSessionStore in tests
PR Checks / test-and-build (pull_request) Successful in 3m14s
Resolves CS0535 build failure in test project.
2026-05-07 11:26:22 +00:00
root 4d3362d93f fix: GroupStats.razor syntax and missing using for Claims
PR Checks / test-and-build (pull_request) Failing after 3m14s
- Add @using System.Security.Claims
- Fix quotation marks in @onclick lambdas (Razor parser error CS1026)
2026-05-07 11:21:42 +00:00
root b03929174a fix: move PlayerAttendanceStats out of interface scope
PR Checks / test-and-build (pull_request) Failing after 2m53s
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.
2026-05-07 11:16:13 +00:00
root 7e2747ec73 feat: implement GetGroupAttendanceStatsAsync (#14)
PR Checks / test-and-build (pull_request) Failing after 2m57s
2026-05-07 11:05:38 +00:00
Toutsu ae6be912e3 feat(#14): add GroupStats.razor attendance page
PR Checks / test-and-build (pull_request) Failing after 3m14s
2026-05-07 13:26:03 +03:00
Toutsu 116bed16a8 feat(#14): add PlayerAttendanceStats record + interface method 2026-05-07 13:26:01 +03:00
Toutsu 063de7ee3e feat(#14): add get_group_attendance_stats SQL function 2026-05-07 13:12:39 +03:00
Toutsu 5c4ec562d0 Merge pull request 'feat(#13): календарная подписка по URL' (#44) from issue-13-calendar-sub into main
Deploy Telegram Bot / build-and-push (push) Failing after 16m3s
Deploy Telegram Bot / deploy (push) Has been skipped
Reviewed-on: #44
2026-05-07 10:59:50 +03:00
Toutsu dbd481566c fix(#13): bump version label in NavMenu to v1.10.1
PR Checks / test-and-build (pull_request) Successful in 3m57s
2026-05-07 10:32:23 +03:00
Toutsu 3f4571d3a7 chore(#13): bump version to 1.10.1
PR Checks / test-and-build (pull_request) Failing after 4m26s
2026-05-07 10:25:25 +03:00
Toutsu 8c1e7991cd feat(#13): add calendar subscription link to Telegram export 2026-05-07 10:22:35 +03:00
Toutsu c1fdba510b feat(#13): add Web:BaseUrl config for calendar subscription links 2026-05-07 10:21:07 +03:00
Toutsu 435399dcf2 fix(#13): revert ExportCalendarHandler subscription logic (cross-project ref) 2026-05-07 10:18:25 +03:00
Toutsu ddaa0f4279 feat(#13): register CalendarSubscriptionService and add public /calendar/{token}.ics endpoint 2026-05-07 10:16:02 +03:00
Toutsu b205967f1a feat(#13): add CalendarSubscriptionService with token generation and ICS rendering 2026-05-07 10:15:06 +03:00
Toutsu 7457315d6f feat(#13): add SubscriptionNotFoundException 2026-05-07 10:13:45 +03:00
Toutsu 59f9904d66 feat(#13): add CalendarSubscriptionFilter enum 2026-05-07 10:12:34 +03:00
root 3b91a009ea feat(#13): add calendar subscriptions migration 2026-05-07 06:59:56 +00:00
root a6ae5aac31 refactor(#22): merge platform-neutral batch rendering PR
Deploy Telegram Bot / build-and-push (push) Successful in 5m2s
Deploy Telegram Bot / deploy (push) Successful in 14s
2026-05-06 10:35:40 +00:00
root dc26b4d7e4 test: trigger pr-checks workflow
PR Checks / test-and-build (pull_request) Successful in 4m24s
2026-05-06 10:25:02 +00:00
root bc6136d91e chore(web): bump NavMenu version label to v1.10.0
Deploy Telegram Bot / build-and-push (push) Successful in 4m13s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-06 10:24:32 +00:00
root 2e95841ca8 fix(tests): avoid xUnit2013 analyzer error on collection count
Deploy Telegram Bot / build-and-push (push) Successful in 22s
Deploy Telegram Bot / deploy (push) Successful in 14s
2026-05-06 10:14:13 +00:00
root a7c8127f90 fix(tests): add missing using and fix xUnit2013 analyzer error
Deploy Telegram Bot / build-and-push (push) Successful in 21s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-06 10:06:27 +00:00
root cad4e5c30e fix(ci): remove --no-build from dotnet test step
Deploy Telegram Bot / build-and-push (push) Successful in 21s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-06 09:52:46 +00:00
root 77647e4bb8 fix(ci): use ubuntu runner + setup-dotnet instead of container image
Deploy Telegram Bot / build-and-push (push) Successful in 19s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-06 09:46:52 +00:00
root 17c631aef2 ci: add PR checks workflow — test + build, no publish
Deploy Telegram Bot / build-and-push (push) Successful in 21s
Deploy Telegram Bot / deploy (push) Successful in 10s
2026-05-06 09:40:11 +00:00
18 changed files with 581 additions and 21 deletions
+33
View File
@@ -0,0 +1,33 @@
name: PR Checks
on:
pull_request:
branches:
- main
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore
- name: Build Shared
run: dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore
- name: Build Bot (compile check)
run: dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore
- name: Build Web (compile check)
run: dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore
- name: Run tests
run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.10.0</Version>
<Version>1.10.1</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
@@ -1,9 +1,11 @@
using System.Text;
using Dapper;
using GmRelay.Shared.Domain;
using Microsoft.Extensions.Configuration;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
@@ -11,20 +13,21 @@ internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime Schedu
public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient)
ITelegramBotClient botClient,
IConfiguration configuration)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<CalendarSessionDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
WHERE g.telegram_chat_id = @ChatId
AND s.status = @Planned
AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC",
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
+ " FROM sessions s"
+ " JOIN game_groups g ON s.group_id = g.id"
+ " WHERE g.telegram_chat_id = @ChatId"
+ " AND s.status = @Planned"
+ " AND s.scheduled_at > NOW()"
+ " ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
@@ -54,8 +57,6 @@ public sealed class ExportCalendarHandler(
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}");
// Escape special chars according to iCal standards (RFC 5545) -- simple escaping for summary
// In a fuller implementation we'd escape \r\n, commas, etc. But titles are mostly plain text.
sb.AppendLine("END:VEVENT");
}
@@ -66,11 +67,45 @@ public sealed class ExportCalendarHandler(
var inputFile = InputFile.FromStream(stream, "schedule.ics");
// Create calendar subscription
string? subscriptionUrl = null;
var baseUrl = configuration["Web:BaseUrl"];
var senderId = message.From?.Id;
if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue)
{
try
{
var token = Guid.NewGuid().ToString("N");
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
@"SELECT id FROM game_groups WHERE telegram_chat_id = @ChatId",
new { ChatId = message.Chat.Id });
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
new { token, userTelegramId = senderId.Value, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
}
catch
{
// Non-critical: if subscription creation fails, still send the file
}
}
var replyMarkup = subscriptionUrl is not null
? new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithUrl("🔗 Подписаться на календарь", subscriptionUrl) }
})
: null;
await botClient.SendDocument(
chatId: message.Chat.Id,
document: inputFile,
caption: "📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: replyMarkup,
messageThreadId: message.MessageThreadId,
cancellationToken: cancellationToken);
}
@@ -0,0 +1,11 @@
CREATE TABLE calendar_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token TEXT UNIQUE NOT NULL,
user_telegram_id BIGINT NOT NULL,
group_id UUID REFERENCES game_groups(id) ON DELETE CASCADE,
filter_type SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);
CREATE INDEX ix_calendar_subscriptions_user_telegram_id ON calendar_subscriptions (user_telegram_id);
@@ -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;
+3
View File
@@ -9,5 +9,8 @@
"Telegram": {
"BotToken": "",
"MiniAppUrl": ""
},
"Web": {
"BaseUrl": ""
}
}
@@ -0,0 +1,7 @@
namespace GmRelay.Shared.Domain;
public enum CalendarSubscriptionFilter
{
AllMyGroups = 0,
SpecificGroup = 1
}
@@ -57,3 +57,4 @@ public static class SessionBatchViewBuilder
return $"✋ На {dateTitle}";
}
}
// trigger pr
@@ -2,10 +2,10 @@
<div class="nav-header">
<a class="nav-brand" href="">
<span class="nav-brand-icon">🎲</span>
<span class="nav-brand-icon">🐢</span>
<span class="nav-brand-text">GM-Relay</span>
</a>
<button class="nav-toggle" @onclick="ToggleMenu" aria-label="Навигационное меню">
<button class="nav-toggle" @onclick="ToggleMenu" aria-label="Переключить меню">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
@@ -23,7 +23,7 @@
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
Панель управления
Главная страница
</NavLink>
<NavLink class="nav-item" href="templates" @onclick="CloseMenu">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -52,11 +52,11 @@
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
Выйти
Выход
</button>
</form>
<div class="nav-version">v1.9.9</div>
<div class="nav-version">v1.10.1</div>
</div>
</Authorized>
<NotAuthorized>
@@ -67,7 +67,7 @@
<polyline points="10 17 15 12 10 7"/>
<line x1="15" y1="12" x2="3" y2="12"/>
</svg>
Войти
Вход
</NavLink>
</div>
</NotAuthorized>
@@ -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"
};
}
+19
View File
@@ -23,6 +23,7 @@ builder.AddNpgsqlDataSource("gmrelaydb");
builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>();
builder.Services.AddScoped<CalendarSubscriptionService>();
// Add Bot Client
builder.Services.AddSingleton<ITelegramBotClient>(sp =>
@@ -145,6 +146,24 @@ app.MapPost("/auth/logout", async (HttpContext context) =>
return Results.Redirect("/");
});
// Public calendar subscription endpoint (no auth required)
app.MapGet("/calendar/{token}.ics", async (
string token,
CalendarSubscriptionService service,
CancellationToken ct) =>
{
try
{
var ics = await service.GetIcsAsync(token, ct);
var bytes = System.Text.Encoding.UTF8.GetBytes(ics);
return Results.File(bytes, "text/calendar", "schedule.ics");
}
catch (SubscriptionNotFoundException)
{
return Results.NotFound();
}
});
app.Run();
static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name)
@@ -0,0 +1,93 @@
using System.Text;
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
namespace GmRelay.Web.Services;
public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource)
{
private const string IcsProdId = "-//GM-Relay//TTRPG Schedule//EN";
public string GenerateToken() => Guid.NewGuid().ToString("N");
public async Task<string> CreateSubscriptionAsync(
long userTelegramId,
Guid? groupId,
CalendarSubscriptionFilter filter,
CancellationToken ct = default)
{
var token = GenerateToken();
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
new { token, userTelegramId, groupId, filterType = (int)filter });
return token;
}
public async Task<string> GetIcsAsync(string token, CancellationToken ct = default)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var subscription = await connection.QueryFirstOrDefaultAsync<SubscriptionRecord>(
@"SELECT id, user_telegram_id as UserTelegramId, group_id as GroupId, filter_type as FilterType
FROM calendar_subscriptions
WHERE token = @token
AND (expires_at IS NULL OR expires_at > now())",
new { token });
if (subscription is null)
throw new SubscriptionNotFoundException();
var sessions = await connection.QueryAsync<CalendarSessionDto>(
subscription.FilterType == (int)CalendarSubscriptionFilter.SpecificGroup && subscription.GroupId.HasValue
? @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
FROM sessions s
WHERE s.group_id = @GroupId
AND s.status = @Planned
AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC"
: @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt
FROM sessions s
WHERE s.status = @Planned
AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC",
new { subscription.GroupId, Planned = SessionStatus.Planned });
var sb = new StringBuilder();
sb.AppendLine("BEGIN:VCALENDAR");
sb.AppendLine("VERSION:2.0");
sb.AppendLine($"PRODID:{IcsProdId}");
sb.AppendLine("CALSCALE:GREGORIAN");
sb.AppendLine("METHOD:PUBLISH");
foreach (var s in sessions)
{
var dtStart = FormatIcsDate(s.ScheduledAt);
var dtEnd = FormatIcsDate(s.ScheduledAt.AddHours(4));
sb.AppendLine("BEGIN:VEVENT");
sb.AppendLine($"UID:{s.Id}@gmrelay");
sb.AppendLine($"DTSTAMP:{FormatIcsDate(DateTime.UtcNow)}");
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{EscapeIcsText(s.Title)}");
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
return sb.ToString();
}
private static string FormatIcsDate(DateTime dt) => dt.ToUniversalTime().ToString("yyyyMMddTHHmmssZ");
private static string EscapeIcsText(string text) => text
.Replace("\\", "\\\\")
.Replace(";", "\\;")
.Replace(",", "\\,")
.Replace("\n", "\\n")
.Replace("\r", "");
private sealed record SubscriptionRecord(Guid Id, long UserTelegramId, Guid? GroupId, int FilterType);
private sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
}
+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,
@@ -0,0 +1,6 @@
namespace GmRelay.Web.Services;
public sealed class SubscriptionNotFoundException : Exception
{
public SubscriptionNotFoundException() : base("Calendar subscription not found.") { }
}
@@ -51,8 +51,8 @@ public sealed class SessionBatchViewBuilderTests
Assert.Equal(2, session.ActivePlayerCount);
Assert.Equal(4, session.MaxPlayers);
Assert.Equal(2, session.ActivePlayers.Count);
Assert.Equal(1, session.WaitlistedPlayers.Count);
Assert.True(session.ActivePlayers.Count == 2);
Assert.True(session.WaitlistedPlayers.Count == 1);
}
[Fact]
@@ -66,8 +66,8 @@ public sealed class SessionBatchViewBuilderTests
var actions = result.Sessions[0].AvailableActions;
Assert.Equal(2, actions.Count);
Assert.Equal("join_session", actions[0].ActionKey);
Assert.Equal("leave_session", actions[1].ActionKey);
Assert.Equal("join_session", result.Sessions[0].AvailableActions[0].ActionKey);
Assert.Equal("leave_session", result.Sessions[0].AvailableActions[1].ActionKey);
Assert.Equal(sessionId, actions[0].SessionId);
Assert.Equal(sessionId, actions[1].SessionId);
}
@@ -1,3 +1,4 @@
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Telegram.Bot.Types.ReplyMarkups;
@@ -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);