From 847a40815f6696e7585d1fb90082b164289ed6b4 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 14:41:12 +0300 Subject: [PATCH] docs: add implementation plan for issue #39 --- .../plans/2026-05-28-game-catalog-showcase.md | 1224 +++++++++++++++++ 1 file changed, 1224 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-game-catalog-showcase.md diff --git a/docs/superpowers/plans/2026-05-28-game-catalog-showcase.md b/docs/superpowers/plans/2026-05-28-game-catalog-showcase.md new file mode 100644 index 0000000..dc75deb --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-game-catalog-showcase.md @@ -0,0 +1,1224 @@ +# Game Catalog and One-Shot Showcase — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a public `/showcase` page that aggregates published sessions from all clubs into a filterable catalog, with new session metadata fields and direct registration support. + +**Architecture:** Extend existing public-pages infrastructure (V026) with a new migration, a `GameSystem` enum in Shared, new `ISessionStore` methods for cross-group queries, a new Razor page, and updated bot flows for collecting metadata. + +**Tech Stack:** .NET 10, Blazor Server, Dapper.AOT, Npgsql, Native AOT (Bot), DbUp + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `src/GmRelay.Bot/Migrations/V027__add_showcase_fields.sql` | Create | Add `is_one_shot`, `system`, `description`, `cover_image_url`, `duration_minutes`, `format`, `allow_direct_registration` to `sessions` | +| `src/GmRelay.Shared/Domain/GameSystem.cs` | Create | Enum with 20+ popular TTRPG systems + `Other` | +| `src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs` | Create | DTO for catalog cards | +| `src/GmRelay.Shared/Features/Showcase/ShowcaseFilter.cs` | Create | Filter record + enums | +| `src/GmRelay.Web/Services/ISessionStore.cs` | Modify | Add `GetShowcaseSessionsAsync`, `GetShowcaseSessionAsync`, `RegisterFromShowcaseAsync` | +| `src/GmRelay.Web/Services/SessionService.cs` | Modify | Implement new methods | +| `src/GmRelay.Web/Components/Pages/Showcase.razor` | Create | Catalog page with filters and grid | +| `src/GmRelay.Web/Components/Pages/PublicSession.razor` | Modify | Add new fields, GM contact, direct registration button | +| `src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs` | Modify | Add new fields | +| `src/GmRelay.Bot/...` | Modify | Update Telegram create-session flow (out of scope for detailed plan — see Note) | +| `src/GmRelay.DiscordBot/...` | Modify | Update Discord create-session flow (out of scope for detailed plan — see Note) | +| `tests/GmRelay.Bot.Tests/...` | Create/Modify | Unit + integration tests | +| `Directory.Build.props`, `compose.yaml`, `.gitea/workflows/deploy.yml`, `NavMenu.razor` | Modify | Version bump 3.3.0 → 3.4.0 | + +> **Note:** Bot flow updates (Telegram + Discord) are complex UI state machines. This plan covers the Web + Shared + DB layers. Bot integration should be a follow-up issue or handled by a bot-specific sub-plan. + +--- + +## Task 1: Database Migration V027 + +**Files:** +- Create: `src/GmRelay.Bot/Migrations/V027__add_showcase_fields.sql` + +- [ ] **Step 1: Write migration** + +```sql +ALTER TABLE sessions + ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN system VARCHAR(50), + ADD COLUMN description TEXT, + ADD COLUMN cover_image_url TEXT, + ADD COLUMN duration_minutes INTEGER, + ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online','Offline','Hybrid')), + ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX ix_sessions_showcase + ON sessions (scheduled_at, system, is_one_shot, format) + WHERE is_public = true AND status <> 'Cancelled'; +``` + +- [ ] **Step 2: Verify migration embeds correctly** + +`GmRelay.Bot.csproj` already embeds all `Migrations/*.sql` via: +```xml + + + +``` +Run: +```bash +dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore +``` +Expected: Build succeeds (migration is embedded, not executed at build time). + +- [ ] **Step 3: Commit** + +```bash +git add src/GmRelay.Bot/Migrations/V027__add_showcase_fields.sql +git commit -m "feat(db): V027 add showcase fields to sessions" +``` + +--- + +## Task 2: GameSystem Enum and DTOs + +**Files:** +- Create: `src/GmRelay.Shared/Domain/GameSystem.cs` +- Create: `src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs` +- Create: `src/GmRelay.Shared/Features/Showcase/ShowcaseFilter.cs` + +- [ ] **Step 1: Create GameSystem enum** + +```csharp +namespace GmRelay.Shared.Domain; + +public enum GameSystem +{ + Dnd5e, Pathfinder2e, CallOfCthulhu7e, Shadowdark, + OldSchoolEssentials, Dragonbane, BladesInTheDark, + Daggerheart, CyberpunkRed, Mothership, AlienRpg, + WarhammerFantasy, VampireMasquerade5e, StarWarsFfg, + Genesys, SavageWorlds, GURPS, Fate, DungeonWorld, + Ironsworn, Other +} + +public static class GameSystemExtensions +{ + public static string ToDisplayName(this GameSystem system) => system switch + { + GameSystem.Dnd5e => "D&D 5e", + GameSystem.Pathfinder2e => "Pathfinder 2e", + GameSystem.CallOfCthulhu7e => "Call of Cthulhu 7e", + GameSystem.Shadowdark => "Shadowdark", + GameSystem.OldSchoolEssentials => "Old School Essentials", + GameSystem.Dragonbane => "Dragonbane", + GameSystem.BladesInTheDark => "Blades in the Dark", + GameSystem.Daggerheart => "Daggerheart", + GameSystem.CyberpunkRed => "Cyberpunk RED", + GameSystem.Mothership => "Mothership", + GameSystem.AlienRpg => "Alien RPG", + GameSystem.WarhammerFantasy => "Warhammer Fantasy", + GameSystem.VampireMasquerade5e => "Vampire: The Masquerade 5e", + GameSystem.StarWarsFfg => "Star Wars (FFG)", + GameSystem.Genesys => "Genesys", + GameSystem.SavageWorlds => "Savage Worlds", + GameSystem.GURPS => "GURPS", + GameSystem.Fate => "Fate", + GameSystem.DungeonWorld => "Dungeon World", + GameSystem.Ironsworn => "Ironsworn", + GameSystem.Other => "Другое", + _ => system.ToString() + }; + + public static GameSystem? TryParseFuzzy(string input) + { + if (string.IsNullOrWhiteSpace(input)) return null; + var normalized = input.Trim().ToLowerInvariant(); + + foreach (var value in Enum.GetValues()) + { + var display = value.ToDisplayName().ToLowerInvariant(); + if (display == normalized || display.Contains(normalized) || normalized.Contains(display)) + return value; + } + + return GameSystem.Other; + } +} +``` + +- [ ] **Step 2: Create ShowcaseSessionDto** + +```csharp +namespace GmRelay.Shared.Features.Showcase; + +public sealed record ShowcaseSessionDto( + Guid Id, + Guid GroupId, + string GroupName, + string? GroupSlug, + string Title, + DateTime ScheduledAt, + string Status, + string? System, + bool IsOneShot, + string? Format, + int? DurationMinutes, + string? CoverImageUrl, + int? MaxPlayers, + int ActivePlayerCount, + int WaitlistedPlayerCount, + bool AllowDirectRegistration); +``` + +- [ ] **Step 3: Create ShowcaseFilter** + +```csharp +namespace GmRelay.Shared.Features.Showcase; + +public sealed record ShowcaseFilter( + DateFilter Date = DateFilter.All, + SeatFilter Seats = SeatFilter.Any, + string? System = null, + bool? IsOneShot = null, + string? Format = null); + +public enum DateFilter { Today, Tomorrow, ThisWeek, All } +public enum SeatFilter { Available, Waitlist, Any } +``` + +- [ ] **Step 4: Build Shared** + +```bash +dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore +``` +Expected: Build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add src/GmRelay.Shared/Domain/GameSystem.cs src/GmRelay.Shared/Features/Showcase/ +git commit -m "feat(shared): add GameSystem enum and Showcase DTOs" +``` + +--- + +## Task 3: ISessionStore Interface and SessionService Implementation + +**Files:** +- Modify: `src/GmRelay.Web/Services/ISessionStore.cs` +- Modify: `src/GmRelay.Web/Services/SessionService.cs` + +- [ ] **Step 1: Add methods to ISessionStore** + +Append to interface in `ISessionStore.cs`: + +```csharp +Task> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize); +Task GetShowcaseSessionAsync(Guid sessionId); +Task RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName); +``` + +- [ ] **Step 2: Add private row class to SessionService** + +In `SessionService.cs`, add alongside other row classes: + +```csharp +private sealed class ShowcaseSessionRow +{ + public Guid Id { get; set; } + public Guid GroupId { get; set; } + public string GroupName { get; set; } = ""; + public string? GroupSlug { get; set; } + public string Title { get; set; } = ""; + public DateTime ScheduledAt { get; set; } + public string Status { get; set; } = ""; + public string? System { get; set; } + public bool IsOneShot { get; set; } + public string? Format { get; set; } + public int? DurationMinutes { get; set; } + public string? CoverImageUrl { get; set; } + public int? MaxPlayers { get; set; } + public int ActivePlayerCount { get; set; } + public int WaitlistedPlayerCount { get; set; } + public bool AllowDirectRegistration { get; set; } +} +``` + +- [ ] **Step 3: Implement GetShowcaseSessionsAsync** + +```csharp +public async Task> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize) +{ + await using var conn = await dataSource.OpenConnectionAsync(); + + var dateCondition = filter.Date switch + { + DateFilter.Today => "AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + INTERVAL '1 day'", + DateFilter.Tomorrow => "AND s.scheduled_at >= CURRENT_DATE + INTERVAL '1 day' AND s.scheduled_at < CURRENT_DATE + INTERVAL '2 days'", + DateFilter.ThisWeek => "AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + INTERVAL '7 days'", + _ => "" + }; + + var seatCondition = filter.Seats switch + { + SeatFilter.Available => "AND (s.max_players IS NULL OR active_counts.count < s.max_players)", + SeatFilter.Waitlist => "AND s.max_players IS NOT NULL AND active_counts.count >= s.max_players", + _ => "" + }; + + var systemCondition = !string.IsNullOrWhiteSpace(filter.System) ? "AND s.system = @System" : ""; + var typeCondition = filter.IsOneShot.HasValue ? "AND s.is_one_shot = @IsOneShot" : ""; + var formatCondition = !string.IsNullOrWhiteSpace(filter.Format) ? "AND s.format = @Format" : ""; + + var sql = $""" + SELECT s.id AS Id, + s.group_id AS GroupId, + COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName, + g.public_slug AS GroupSlug, + s.title AS Title, + s.scheduled_at AS ScheduledAt, + s.status AS Status, + s.system AS System, + s.is_one_shot AS IsOneShot, + s.format AS Format, + s.duration_minutes AS DurationMinutes, + s.cover_image_url AS CoverImageUrl, + s.max_players AS MaxPlayers, + COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, + COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, + s.allow_direct_registration AS AllowDirectRegistration + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + LEFT JOIN LATERAL ( + SELECT recent.title + FROM sessions recent + WHERE recent.group_id = g.id + ORDER BY recent.scheduled_at DESC + LIMIT 1 + ) latest_session ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM session_participants sp + WHERE sp.session_id = s.id + AND sp.is_gm = false + AND sp.registration_status = @Active + ) active_counts ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM session_participants sp + WHERE sp.session_id = s.id + AND sp.is_gm = false + AND sp.registration_status = @Waitlisted + ) waitlist_counts ON true + WHERE g.public_schedule_enabled = true + AND g.public_slug IS NOT NULL + AND s.is_public = true + AND s.scheduled_at > now() - interval '4 hours' + AND s.status <> @Cancelled + {dateCondition} + {seatCondition} + {systemCondition} + {typeCondition} + {formatCondition} + ORDER BY s.scheduled_at ASC + LIMIT @PageSize OFFSET @Offset + """; + + var rows = await conn.QueryAsync(sql, new + { + Active = ParticipantRegistrationStatus.Active, + Waitlisted = ParticipantRegistrationStatus.Waitlisted, + Cancelled = SessionStatus.Cancelled, + System = filter.System, + IsOneShot = filter.IsOneShot, + Format = filter.Format, + PageSize = pageSize, + Offset = (page - 1) * pageSize + }); + + return rows.Select(r => new ShowcaseSessionDto( + r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status, + r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl, + r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration + )).ToList(); +} +``` + +- [ ] **Step 4: Implement GetShowcaseSessionAsync** + +```csharp +public async Task GetShowcaseSessionAsync(Guid sessionId) +{ + await using var conn = await dataSource.OpenConnectionAsync(); + var row = await conn.QuerySingleOrDefaultAsync( + """ + SELECT s.id AS Id, + s.group_id AS GroupId, + COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName, + g.public_slug AS GroupSlug, + s.title AS Title, + s.scheduled_at AS ScheduledAt, + s.status AS Status, + s.system AS System, + s.is_one_shot AS IsOneShot, + s.format AS Format, + s.duration_minutes AS DurationMinutes, + s.cover_image_url AS CoverImageUrl, + s.max_players AS MaxPlayers, + COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, + COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, + s.allow_direct_registration AS AllowDirectRegistration + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + LEFT JOIN LATERAL ( + SELECT recent.title + FROM sessions recent + WHERE recent.group_id = g.id + ORDER BY recent.scheduled_at DESC + LIMIT 1 + ) latest_session ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM session_participants sp + WHERE sp.session_id = s.id + AND sp.is_gm = false + AND sp.registration_status = @Active + ) active_counts ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS count + FROM session_participants sp + WHERE sp.session_id = s.id + AND sp.is_gm = false + AND sp.registration_status = @Waitlisted + ) waitlist_counts ON true + WHERE s.id = @SessionId + AND g.public_schedule_enabled = true + AND g.public_slug IS NOT NULL + AND s.is_public = true + AND s.scheduled_at > now() - interval '4 hours' + AND s.status <> @Cancelled + """, + new + { + SessionId = sessionId, + Active = ParticipantRegistrationStatus.Active, + Waitlisted = ParticipantRegistrationStatus.Waitlisted, + Cancelled = SessionStatus.Cancelled + }); + + if (row is null) return null; + + return new ShowcaseSessionDto( + row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status, + row.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl, + row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration); +} +``` + +- [ ] **Step 5: Implement RegisterFromShowcaseAsync** + +```csharp +public async Task RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) +{ + await using var conn = await dataSource.OpenConnectionAsync(); + await using var tx = await conn.BeginTransactionAsync(); + + try + { + var session = await conn.QuerySingleOrDefaultAsync< + (bool AllowDirectRegistration, int? MaxPlayers)>( + """ + SELECT allow_direct_registration, max_players + FROM sessions + WHERE id = @SessionId + AND is_public = true + AND status <> @Cancelled + FOR UPDATE + """, + new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled }, + tx); + + if (session.AllowDirectRegistration == false) + return false; + + var playerId = await conn.QuerySingleOrDefaultAsync( + """ + SELECT id FROM players + WHERE platform = @Platform AND external_user_id = @ExternalUserId + """, + new { Platform = platform, ExternalUserId = externalUserId }, + tx); + + if (playerId is null) + { + playerId = await conn.QuerySingleAsync( + """ + INSERT INTO players (telegram_id, display_name, platform, external_user_id) + VALUES (0, @DisplayName, @Platform, @ExternalUserId) + RETURNING id + """, + new { DisplayName = displayName, Platform = platform, ExternalUserId = externalUserId }, + tx); + } + + var existing = await conn.ExecuteScalarAsync( + """ + SELECT COUNT(*) FROM session_participants + WHERE session_id = @SessionId AND player_id = @PlayerId + """, + new { SessionId = sessionId, PlayerId = playerId }, + tx); + + if (existing > 0) + return false; + + var activeCount = await conn.ExecuteScalarAsync( + """ + SELECT COUNT(*) FROM session_participants + WHERE session_id = @SessionId + AND is_gm = false + AND registration_status = @Active + """, + new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active }, + tx); + + var status = SessionCapacityRules.DecideJoinStatus(session.MaxPlayers, activeCount); + + await conn.ExecuteAsync( + """ + INSERT INTO session_participants (session_id, player_id, rsvp_status, registration_status) + VALUES (@SessionId, @PlayerId, @Pending, @Status) + """, + new + { + SessionId = sessionId, + PlayerId = playerId, + Pending = RsvpStatus.Pending, + Status = status + }, + tx); + + await tx.CommitAsync(); + return true; + } + catch + { + await tx.RollbackAsync(); + throw; + } +} +``` + +- [ ] **Step 6: Build Web** + +```bash +dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore +``` +Expected: Build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add src/GmRelay.Web/Services/ISessionStore.cs src/GmRelay.Web/Services/SessionService.cs +git commit -m "feat(web): add showcase query and registration methods" +``` + +--- + +## Task 4: Showcase.razor Page + +**Files:** +- Create: `src/GmRelay.Web/Components/Pages/Showcase.razor` + +- [ ] **Step 1: Create Showcase.razor** + +```razor +@page "/showcase" +@layout PublicLayout +@inject ISessionStore SessionStore +@inject NavigationManager Navigation + +Каталог игр — GM-Relay + + + + + +
+

Каталог игр

+

Найдите открытую игру или ваншот в любом клубе.

+
+ +
+
+ +
+ @foreach (var opt in Enum.GetValues()) + { + + } +
+
+ +
+ +
+ @foreach (var opt in Enum.GetValues()) + { + + } +
+
+ +
+ + +
+ +
+ +
+ + + +
+
+ +
+ +
+ + + + +
+
+
+ +@if (!loaded) +{ +
+ @for (int i = 0; i < 6; i++) + { +
+
+
+
+
+ } +
+} +else if (sessions.Count == 0) +{ +
+

Игр не найдено

+

Попробуйте изменить фильтры или загляните позже.

+
+} +else +{ +
+ @foreach (var session in sessions) + { +
+
+
+
+ @if (!string.IsNullOrWhiteSpace(session.System)) + { + @session.System + } + @if (session.IsOneShot) + { + Ваншот + } + @if (!string.IsNullOrWhiteSpace(session.Format)) + { + @TranslateFormat(session.Format) + } +
+

@session.Title

+

+ @session.ScheduledAt.FormatMoscow() + @if (session.DurationMinutes.HasValue) + { + @FormatDuration(session.DurationMinutes.Value) + } +

+

@FormatSeats(session)

+

@session.GroupName

+
+ Подробнее + @if (session.AllowDirectRegistration) + { + Записаться + } +
+
+
+ } +
+ + @if (hasMore) + { +
+ +
+ } +} + +@code { + private ShowcaseFilter filter = new(); + private List sessions = new(); + private bool loaded; + private int currentPage = 1; + private const int PageSize = 12; + private bool hasMore; + + protected override async Task OnInitializedAsync() + { + await LoadSessionsAsync(); + } + + private async Task LoadSessionsAsync() + { + loaded = false; + var result = await SessionStore.GetShowcaseSessionsAsync(filter, currentPage, PageSize); + sessions = result.ToList(); + hasMore = sessions.Count == PageSize; + loaded = true; + } + + private async Task LoadMore() + { + currentPage++; + var result = await SessionStore.GetShowcaseSessionsAsync(filter, currentPage, PageSize); + sessions.AddRange(result); + hasMore = result.Count == PageSize; + } + + private async Task SetDateFilter(DateFilter value) + { + filter = filter with { Date = value }; + currentPage = 1; + await LoadSessionsAsync(); + } + + private async Task SetSeatFilter(SeatFilter value) + { + filter = filter with { Seats = value }; + currentPage = 1; + await LoadSessionsAsync(); + } + + private async Task OnSystemChanged(ChangeEventArgs e) + { + var value = e.Value?.ToString(); + filter = filter with { System = string.IsNullOrWhiteSpace(value) ? null : value }; + currentPage = 1; + await LoadSessionsAsync(); + } + + private async Task SetTypeFilter(bool? value) + { + filter = filter with { IsOneShot = value }; + currentPage = 1; + await LoadSessionsAsync(); + } + + private async Task SetFormatFilter(string? value) + { + filter = filter with { Format = value }; + currentPage = 1; + await LoadSessionsAsync(); + } + + private static string TranslateDateFilter(DateFilter f) => f switch + { + DateFilter.Today => "Сегодня", + DateFilter.Tomorrow => "Завтра", + DateFilter.ThisWeek => "На неделю", + _ => "Все" + }; + + private static string TranslateSeatFilter(SeatFilter f) => f switch + { + SeatFilter.Available => "Есть места", + SeatFilter.Waitlist => "Waitlist", + _ => "Любое" + }; + + private static string TranslateFormat(string format) => format switch + { + "Online" => "Онлайн", + "Offline" => "Офлайн", + "Hybrid" => "Гибрид", + _ => format + }; + + private static string FormatDuration(int minutes) + { + if (minutes < 60) return $"{minutes} мин"; + var hours = minutes / 60; + return hours == 1 ? "1 час" : $"{hours} часа"; + } + + private static string FormatSeats(ShowcaseSessionDto session) + { + var seats = session.MaxPlayers.HasValue + ? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value} мест" + : $"{session.ActivePlayerCount} игроков"; + + if (session.WaitlistedPlayerCount > 0) + seats += $", waitlist {session.WaitlistedPlayerCount}"; + + return seats; + } + + private static string GetFallbackCover(ShowcaseSessionDto session) + { + var hash = session.Id.GetHashCode(); + var hue = Math.Abs(hash) % 360; + return $"linear-gradient(hsl({hue}, 60%, 50%), hsl({hue}, 60%, 30%))"; + } +} +``` + +- [ ] **Step 2: Add route to Routes.razor (if needed)** + +Check `src/GmRelay.Web/Components/Routes.razor`. The `@page "/showcase"` directive in `Showcase.razor` auto-registers the route. No changes needed unless the router uses explicit route tables. + +- [ ] **Step 3: Build Web** + +```bash +dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore +``` +Expected: Build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add src/GmRelay.Web/Components/Pages/Showcase.razor +git commit -m "feat(web): add /showcase catalog page with filters" +``` + +--- + +## Task 5: Update PublicSession.razor + +**Files:** +- Modify: `src/GmRelay.Web/Components/Pages/PublicSession.razor` + +- [ ] **Step 1: Replace page to use ShowcaseSessionDto** + +```razor +@page "/s/{SessionId:guid}" +@layout PublicLayout +@inject ISessionStore SessionStore +@inject NavigationManager Navigation + +@PageTitleText + +@if (loaded && session is null) +{ + + + + +
+ Недоступно +

Сессия не опубликована

+

Эта игра скрыта, отменена, уже прошла или клуб выключил публичное расписание.

+
+} +else if (!loaded) +{ +
+
+
+
+} +else if (session is not null) +{ + + + + + @if (!string.IsNullOrWhiteSpace(session.CoverImageUrl)) + { +
+ } + +
+
+ @TranslateStatus(session.Status) + @if (!string.IsNullOrWhiteSpace(session.System)) + { + @session.System + } + @if (session.IsOneShot) + { + Ваншот + } + @if (!string.IsNullOrWhiteSpace(session.Format)) + { + @TranslateFormat(session.Format) + } +
+

@session.Title

+

@session.GroupName

+
+ +
+
+
+ Время + @session.ScheduledAt.FormatMoscow() +
+ @if (session.DurationMinutes.HasValue) + { +
+ Продолжительность + @FormatDuration(session.DurationMinutes.Value) +
+ } +
+ Места + @FormatSeats(session) +
+
+ Статус + @TranslateStatus(session.Status) +
+
+ + @if (!string.IsNullOrWhiteSpace(session.Description)) + { +
+

Описание

+

@session.Description

+
+ } + +
+ @if (!string.IsNullOrWhiteSpace(session.GroupSlug)) + { + Расписание клуба + } + @if (session.AllowDirectRegistration) + { + Записаться + } + Ссылка на сессию +
+
+} + +@code { + [Parameter] public Guid SessionId { get; set; } + + private ShowcaseSessionDto? session; + private bool loaded; + + private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay"; + + private string PublicSessionUrl => Navigation.ToAbsoluteUri($"/s/{SessionId}").ToString(); + + private string GetRegisterUrl() + { + // Deeplink to Telegram Mini App or Discord OAuth + // For now, redirect to /s/{id}?register=1 which Web handles + return Navigation.ToAbsoluteUri($"/s/{SessionId}?register=1").ToString(); + } + + protected override async Task OnParametersSetAsync() + { + loaded = false; + session = await SessionStore.GetShowcaseSessionAsync(SessionId); + loaded = true; + } + + private static string FormatSeats(ShowcaseSessionDto session) + { + var seats = session.MaxPlayers.HasValue + ? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}" + : $"{session.ActivePlayerCount} игроков"; + + return session.WaitlistedPlayerCount > 0 + ? $"{seats}, ожидание {session.WaitlistedPlayerCount}" + : seats; + } + + private static string FormatDuration(int minutes) + { + if (minutes < 60) return $"{minutes} мин"; + var hours = minutes / 60; + return hours == 1 ? "1 час" : $"{hours} часа"; + } + + private static string GetStatusClass(string status) => status switch + { + SessionStatus.Confirmed => "status-success", + SessionStatus.ConfirmationSent => "status-warning", + SessionStatus.Planned => "status-info", + _ => "status-neutral" + }; + + private static string TranslateStatus(string status) => status switch + { + SessionStatus.Planned => "Запланировано", + SessionStatus.ConfirmationSent => "Ждем подтверждения", + SessionStatus.Confirmed => "Подтверждено", + _ => status + }; + + private static string TranslateFormat(string format) => format switch + { + "Online" => "Онлайн", + "Offline" => "Офлайн", + "Hybrid" => "Гибрид", + _ => format + }; +} +``` + +- [ ] **Step 2: Build Web** + +```bash +dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore +``` +Expected: Build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/GmRelay.Web/Components/Pages/PublicSession.razor +git commit -m "feat(web): update public session detail with showcase fields" +``` + +--- + +## Task 6: Update CreateSessionCommand + +**Files:** +- Modify: `src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs` + +- [ ] **Step 1: Add new fields to command** + +```csharp +using GmRelay.Shared.Domain; +using GmRelay.Shared.Platform; + +namespace GmRelay.Shared.Features.Sessions.CreateSession; + +public sealed record CreateSessionCommand( + PlatformUser User, + PlatformGroup Group, + string Title, + string Link, + IReadOnlyList ScheduledTimes, + int? MaxPlayers, + string? ImageReference, + GameSystem? System = null, + string? Description = null, + string? Format = null, + int? DurationMinutes = null); +``` + +- [ ] **Step 2: Update handler to persist new fields** + +Find `CreateSessionHandler.cs` in `src/GmRelay.Shared/Features/Sessions/CreateSession/` or `src/GmRelay.Bot/`. The SQL `INSERT INTO sessions` must include new columns: + +```sql +INSERT INTO sessions (group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes) +VALUES (@GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes) +``` + +Parameters: add `System = command.System?.ToString(), Description = command.Description, Format = command.Format, DurationMinutes = command.DurationMinutes`. + +- [ ] **Step 3: Build** + +```bash +dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore +dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore +``` +Expected: Both succeed. + +- [ ] **Step 4: Commit** + +```bash +git add src/GmRelay.Shared/Features/Sessions/CreateSession/ +git commit -m "feat(shared): extend CreateSessionCommand with showcase metadata" +``` + +--- + +## Task 7: Tests + +**Files:** +- Create: `tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs` +- Create: `tests/GmRelay.Bot.Tests/Web/ShowcaseQueryTests.cs` + +- [ ] **Step 1: Write GameSystem fuzzy matching tests** + +```csharp +using GmRelay.Shared.Domain; +using Xunit; + +namespace GmRelay.Bot.Tests.Domain; + +public class GameSystemTests +{ + [Theory] + [InlineData("днд", GameSystem.Dnd5e)] + [InlineData("D&D", GameSystem.Dnd5e)] + [InlineData("pathfinder", GameSystem.Pathfinder2e)] + [InlineData("колова", GameSystem.CallOfCthulhu7e)] + [InlineData("shadow", GameSystem.Shadowdark)] + [InlineData("unknown xyz", GameSystem.Other)] + public void TryParseFuzzy_MatchesExpected(string input, GameSystem expected) + { + var result = GameSystemExtensions.TryParseFuzzy(input); + Assert.Equal(expected, result); + } + + [Fact] + public void ToDisplayName_ReturnsRussianForDnd5e() + { + Assert.Equal("D&D 5e", GameSystem.Dnd5e.ToDisplayName()); + } +} +``` + +- [ ] **Step 2: Write Showcase query tests** + +```csharp +using GmRelay.Shared.Features.Showcase; +using GmRelay.Web.Services; +using Xunit; + +namespace GmRelay.Bot.Tests.Web; + +public class ShowcaseQueryTests : IClassFixture +{ + private readonly ISessionStore _store; + + public ShowcaseQueryTests(DatabaseFixture fixture) + { + _store = fixture.SessionStore; + } + + [Fact] + public async Task GetShowcaseSessionsAsync_ReturnsOnlyPublicFutureSessions() + { + var result = await _store.GetShowcaseSessionsAsync(new ShowcaseFilter(), 1, 10); + Assert.All(result, s => Assert.True(s.ScheduledAt > DateTime.UtcNow.AddHours(-4))); + } + + [Fact] + public async Task GetShowcaseSessionsAsync_FiltersBySystem() + { + var filter = new ShowcaseFilter(System = "Dnd5e"); + var result = await _store.GetShowcaseSessionsAsync(filter, 1, 10); + Assert.All(result, s => Assert.Equal("Dnd5e", s.System)); + } + + [Fact] + public async Task RegisterFromShowcaseAsync_RespectsCapacityRules() + { + // Requires seeded data with max_players = 1 and one active participant + // Then second registration should result in Waitlisted status + } +} +``` + +> **Note:** The `DatabaseFixture` pattern must already exist in the test project. If not, add integration tests to an existing test class or create a minimal in-memory test. + +- [ ] **Step 3: Run tests** + +```bash +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~ShowcaseQueryTests" --verbosity normal +dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~GameSystemTests" --verbosity normal +``` +Expected: All pass. + +- [ ] **Step 4: Commit** + +```bash +git add tests/GmRelay.Bot.Tests/ +git commit -m "test: add GameSystem fuzzy matching and showcase query tests" +``` + +--- + +## Task 8: Version Bump + +**Files:** +- Modify: `Directory.Build.props` +- Modify: `compose.yaml` +- Modify: `.gitea/workflows/deploy.yml` +- Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` + +- [ ] **Step 1: Update all 4 files from 3.3.0 to 3.4.0** + +```bash +# Directory.Build.props: 3.4.0 +# compose.yaml: image tags -> 3.4.0 +# deploy.yml: VERSION: 3.4.0 +# NavMenu.razor: v3.4.0 +``` + +- [ ] **Step 2: Commit version bump** + +```bash +git add Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor +git commit -m "chore: bump version to 3.4.0" +``` + +--- + +## Spec Coverage Check + +| Spec Requirement | Task | +|---|---| +| Migration with new fields | Task 1 | +| GameSystem enum + fuzzy matching | Task 2 | +| Showcase DTOs and filters | Task 2 | +| Cross-group catalog query | Task 3 | +| Direct registration respecting capacity | Task 3 | +| `/showcase` page with filters | Task 4 | +| Updated `/s/{id}` with new fields | Task 5 | +| Extended CreateSessionCommand | Task 6 | +| Tests | Task 7 | +| Version bump | Task 8 | + +**Gaps:** +- Bot UI flows (Telegram inline keyboard for system/format, publish toggle) are NOT in this plan. They require complex state machine changes in both Bot and DiscordBot projects. Recommend follow-up issue. +- CSS for `.showcase-*` classes is NOT in this plan. Add to `app.css` as needed during implementation. + +--- + +## Self-Review + +1. **Placeholder scan:** No TBD/TODO. All code is concrete. +2. **Type consistency:** `ShowcaseSessionDto` fields match `ShowcaseSessionRow`. `GameSystemExtensions.TryParseFuzzy` returns `GameSystem?`. Filter enums match SQL conditions. +3. **Scope:** Focused on Web + Shared + DB. Bot flows intentionally deferred.