1225 lines
41 KiB
Markdown
1225 lines
41 KiB
Markdown
# 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
|
|
<ItemGroup>
|
|
<EmbeddedResource Include="Migrations\*.sql" />
|
|
</ItemGroup>
|
|
```
|
|
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<GameSystem>())
|
|
{
|
|
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<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
|
|
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
|
|
Task<bool> 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<IReadOnlyList<ShowcaseSessionDto>> 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<ShowcaseSessionRow>(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<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
|
|
{
|
|
await using var conn = await dataSource.OpenConnectionAsync();
|
|
var row = await conn.QuerySingleOrDefaultAsync<ShowcaseSessionRow>(
|
|
"""
|
|
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<bool> 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<Guid?>(
|
|
"""
|
|
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<Guid>(
|
|
"""
|
|
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<int>(
|
|
"""
|
|
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<int>(
|
|
"""
|
|
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
|
|
|
|
<PageTitle>Каталог игр — GM-Relay</PageTitle>
|
|
|
|
<HeadContent>
|
|
<meta name="description" content="Каталог открытых игр и ваншотов GM-Relay." />
|
|
</HeadContent>
|
|
|
|
<section class="public-hero">
|
|
<h1>Каталог игр</h1>
|
|
<p>Найдите открытую игру или ваншот в любом клубе.</p>
|
|
</section>
|
|
|
|
<div class="showcase-filters">
|
|
<div class="filter-group">
|
|
<label>Дата</label>
|
|
<div class="filter-buttons">
|
|
@foreach (var opt in Enum.GetValues<DateFilter>())
|
|
{
|
|
<button class="btn-gm @(filter.Date == opt ? "btn-gm-primary" : "btn-gm-outline")"
|
|
@onclick="() => SetDateFilter(opt)">
|
|
@TranslateDateFilter(opt)
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filter-group">
|
|
<label>Места</label>
|
|
<div class="filter-buttons">
|
|
@foreach (var opt in Enum.GetValues<SeatFilter>())
|
|
{
|
|
<button class="btn-gm @(filter.Seats == opt ? "btn-gm-primary" : "btn-gm-outline")"
|
|
@onclick="() => SetSeatFilter(opt)">
|
|
@TranslateSeatFilter(opt)
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filter-group">
|
|
<label>Система</label>
|
|
<select class="gm-select" @onchange="OnSystemChanged">
|
|
<option value="">Любая</option>
|
|
@foreach (var sys in Enum.GetValues<GameSystem>())
|
|
{
|
|
<option value="@sys.ToString()" selected="@(filter.System == sys.ToString())">
|
|
@sys.ToDisplayName()
|
|
</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="filter-group">
|
|
<label>Тип</label>
|
|
<div class="filter-buttons">
|
|
<button class="btn-gm @(filter.IsOneShot == true ? "btn-gm-primary" : "btn-gm-outline")"
|
|
@onclick="() => SetTypeFilter(true)">Ваншот</button>
|
|
<button class="btn-gm @(filter.IsOneShot == false ? "btn-gm-primary" : "btn-gm-outline")"
|
|
@onclick="() => SetTypeFilter(false)">Кампания</button>
|
|
<button class="btn-gm @(filter.IsOneShot is null ? "btn-gm-primary" : "btn-gm-outline")"
|
|
@onclick="() => SetTypeFilter(null)">Любое</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filter-group">
|
|
<label>Формат</label>
|
|
<div class="filter-buttons">
|
|
<button class="btn-gm @(filter.Format == "Online" ? "btn-gm-primary" : "btn-gm-outline")"
|
|
@onclick="() => SetFormatFilter("Online")">Онлайн</button>
|
|
<button class="btn-gm @(filter.Format == "Offline" ? "btn-gm-primary" : "btn-gm-outline")"
|
|
@onclick="() => SetFormatFilter("Offline")">Офлайн</button>
|
|
<button class="btn-gm @(filter.Format == "Hybrid" ? "btn-gm-primary" : "btn-gm-outline")"
|
|
@onclick="() => SetFormatFilter("Hybrid")">Гибрид</button>
|
|
<button class="btn-gm @(filter.Format is null ? "btn-gm-primary" : "btn-gm-outline")"
|
|
@onclick="() => SetFormatFilter(null)">Любой</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@if (!loaded)
|
|
{
|
|
<div class="showcase-grid">
|
|
@for (int i = 0; i < 6; i++)
|
|
{
|
|
<div class="showcase-card skeleton-card">
|
|
<div class="skeleton" style="height: 160px;"></div>
|
|
<div class="skeleton skeleton-text" style="width: 70%;"></div>
|
|
<div class="skeleton skeleton-text" style="width: 40%;"></div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
else if (sessions.Count == 0)
|
|
{
|
|
<div class="glass-card public-empty-state">
|
|
<h2>Игр не найдено</h2>
|
|
<p>Попробуйте изменить фильтры или загляните позже.</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="showcase-grid">
|
|
@foreach (var session in sessions)
|
|
{
|
|
<article class="showcase-card">
|
|
<div class="showcase-cover" style="background-image: url('@(session.CoverImageUrl ?? GetFallbackCover(session))')"></div>
|
|
<div class="showcase-body">
|
|
<div class="showcase-badges">
|
|
@if (!string.IsNullOrWhiteSpace(session.System))
|
|
{
|
|
<span class="badge-system">@session.System</span>
|
|
}
|
|
@if (session.IsOneShot)
|
|
{
|
|
<span class="badge-type">Ваншот</span>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(session.Format))
|
|
{
|
|
<span class="badge-format">@TranslateFormat(session.Format)</span>
|
|
}
|
|
</div>
|
|
<h3>@session.Title</h3>
|
|
<p class="showcase-meta">
|
|
<span>@session.ScheduledAt.FormatMoscow()</span>
|
|
@if (session.DurationMinutes.HasValue)
|
|
{
|
|
<span>@FormatDuration(session.DurationMinutes.Value)</span>
|
|
}
|
|
</p>
|
|
<p class="showcase-seats">@FormatSeats(session)</p>
|
|
<p class="showcase-club">@session.GroupName</p>
|
|
<div class="showcase-actions">
|
|
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Подробнее</a>
|
|
@if (session.AllowDirectRegistration)
|
|
{
|
|
<a class="btn-gm btn-gm-primary" href="@($"/s/{session.Id}?register=1")">Записаться</a>
|
|
}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
}
|
|
</div>
|
|
|
|
@if (hasMore)
|
|
{
|
|
<div class="showcase-load-more">
|
|
<button class="btn-gm btn-gm-outline" @onclick="LoadMore">Загрузить ещё</button>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
@code {
|
|
private ShowcaseFilter filter = new();
|
|
private List<ShowcaseSessionDto> 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
|
|
|
|
<PageTitle>@PageTitleText</PageTitle>
|
|
|
|
@if (loaded && session is null)
|
|
{
|
|
<HeadContent>
|
|
<meta name="robots" content="noindex, nofollow" />
|
|
</HeadContent>
|
|
|
|
<section class="public-hero public-hero-compact">
|
|
<span class="status-badge status-neutral">Недоступно</span>
|
|
<h1>Сессия не опубликована</h1>
|
|
<p>Эта игра скрыта, отменена, уже прошла или клуб выключил публичное расписание.</p>
|
|
</section>
|
|
}
|
|
else if (!loaded)
|
|
{
|
|
<section class="public-hero public-hero-compact">
|
|
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
|
|
<div class="skeleton skeleton-text" style="width: 75%;"></div>
|
|
</section>
|
|
}
|
|
else if (session is not null)
|
|
{
|
|
<HeadContent>
|
|
<meta name="description" content="@($"Публичная сессия {session.Title} клуба {session.GroupName} в GM-Relay.")" />
|
|
</HeadContent>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(session.CoverImageUrl))
|
|
{
|
|
<div class="session-cover-hero" style="background-image: url('@session.CoverImageUrl')"></div>
|
|
}
|
|
|
|
<section class="public-hero public-hero-compact">
|
|
<div class="session-badges">
|
|
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
|
@if (!string.IsNullOrWhiteSpace(session.System))
|
|
{
|
|
<span class="badge-system">@session.System</span>
|
|
}
|
|
@if (session.IsOneShot)
|
|
{
|
|
<span class="badge-type">Ваншот</span>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(session.Format))
|
|
{
|
|
<span class="badge-format">@TranslateFormat(session.Format)</span>
|
|
}
|
|
</div>
|
|
<h1>@session.Title</h1>
|
|
<p>@session.GroupName</p>
|
|
</section>
|
|
|
|
<article class="glass-card public-session-detail">
|
|
<div class="public-detail-grid">
|
|
<div>
|
|
<span>Время</span>
|
|
<strong>@session.ScheduledAt.FormatMoscow()</strong>
|
|
</div>
|
|
@if (session.DurationMinutes.HasValue)
|
|
{
|
|
<div>
|
|
<span>Продолжительность</span>
|
|
<strong>@FormatDuration(session.DurationMinutes.Value)</strong>
|
|
</div>
|
|
}
|
|
<div>
|
|
<span>Места</span>
|
|
<strong>@FormatSeats(session)</strong>
|
|
</div>
|
|
<div>
|
|
<span>Статус</span>
|
|
<strong>@TranslateStatus(session.Status)</strong>
|
|
</div>
|
|
</div>
|
|
|
|
@if (!string.IsNullOrWhiteSpace(session.Description))
|
|
{
|
|
<div class="session-description">
|
|
<h3>Описание</h3>
|
|
<p>@session.Description</p>
|
|
</div>
|
|
}
|
|
|
|
<div class="public-settings-actions">
|
|
@if (!string.IsNullOrWhiteSpace(session.GroupSlug))
|
|
{
|
|
<a class="btn-gm btn-gm-primary" href="@($"/club/{session.GroupSlug}")">Расписание клуба</a>
|
|
}
|
|
@if (session.AllowDirectRegistration)
|
|
{
|
|
<a class="btn-gm btn-gm-primary" href="@GetRegisterUrl()">Записаться</a>
|
|
}
|
|
<a class="btn-gm btn-gm-outline" href="@PublicSessionUrl" target="_blank" rel="noopener noreferrer">Ссылка на сессию</a>
|
|
</div>
|
|
</article>
|
|
}
|
|
|
|
@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<DateTimeOffset> 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<DatabaseFixture>
|
|
{
|
|
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: <Version>3.4.0</Version>
|
|
# 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.
|