From 9712fe125be4be39ece6b57fbd96451952e1d660 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Wed, 20 May 2026 12:23:25 +0300 Subject: [PATCH] feat(discord): add DiscordRescheduleVotingRenderer and replace inline helper --- .../Sessions/DiscordRescheduleHandler.cs | 42 +----------- .../Sessions/DiscordRescheduleVoteHandler.cs | 3 +- .../DiscordRescheduleVotingRenderer.cs | 67 +++++++++++++++++++ 3 files changed, 71 insertions(+), 41 deletions(-) create mode 100644 src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs index c31de08..b843a04 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleHandler.cs @@ -2,6 +2,7 @@ namespace GmRelay.DiscordBot.Features.Sessions; using Dapper; using GmRelay.DiscordBot.Infrastructure.Discord; +using GmRelay.DiscordBot.Rendering; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; @@ -89,7 +90,7 @@ public sealed class DiscordRescheduleHandler( var optionDtos = options.Select((o, i) => new RescheduleOptionDto(Guid.NewGuid(), i + 1, o)).ToList(); // 7. Build and send Discord vote message BEFORE transaction - var (embed, actionRow) = BuildVoteMessage(session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []); + var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(session.Title, session.CurrentScheduledAt, deadline, optionDtos, participants, []); var channelIdUlong = ulong.Parse(channelId); @@ -149,45 +150,6 @@ public sealed class DiscordRescheduleHandler( return new DiscordRescheduleResult(proposalId, optionDtos, deadline); } - // internal for now — temporary duplication until DiscordRescheduleVotingRenderer is extracted in Task 6 - internal static (EmbedProperties Embed, ActionRowProperties ActionRow) BuildVoteMessage( - string title, DateTime currentTime, DateTimeOffset deadline, - IReadOnlyList options, IReadOnlyList participants, IReadOnlyList votes) - { - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)"); - sb.AppendLine($"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)"); - sb.AppendLine(); - sb.AppendLine("Выберите один из вариантов:"); - - foreach (var option in options.OrderBy(o => o.DisplayOrder)) - { - var count = votes.Count(v => v.OptionId == option.OptionId); - sb.AppendLine($"{option.DisplayOrder}. **{option.ProposedAt.FormatMoscow()}** (МСК) — {count} голосов"); - } - - if (participants.Count > 0) - { - sb.AppendLine(); - sb.AppendLine($"Голосов: {votes.Count}/{participants.Count}"); - } - - var embed = new EmbedProperties() - .WithTitle($"🔄 Перенос сессии «{title}»") - .WithDescription(sb.ToString()) - .WithColor(new NetCord.Color(0xFEE75C)); - - var actionRow = new ActionRowProperties(); - foreach (var option in options.OrderBy(o => o.DisplayOrder)) - { - actionRow.Add(new ButtonProperties( - $"reschedule_vote:{option.OptionId}", - $"{option.DisplayOrder}. {option.ProposedAt.ToOffset(TimeSpan.FromHours(3)):dd.MM HH:mm}", - ButtonStyle.Primary)); - } - - return (embed, actionRow); - } } internal sealed record RescheduleSessionInfoDto(string Title, DateTime CurrentScheduledAt); diff --git a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs index 95390ee..5bf8885 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/DiscordRescheduleVoteHandler.cs @@ -1,6 +1,7 @@ namespace GmRelay.DiscordBot.Features.Sessions; using Dapper; +using GmRelay.DiscordBot.Rendering; using GmRelay.Shared.Domain; using GmRelay.Shared.Features.Sessions.RescheduleSession; using GmRelay.Shared.Platform; @@ -105,7 +106,7 @@ public sealed class DiscordRescheduleVoteHandler( await transaction.CommitAsync(ct); // 5. Re-render and update Discord vote message - var (embed, actionRow) = DiscordRescheduleHandler.BuildVoteMessage( + var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render( proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt, options, participants, votes); diff --git a/src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs b/src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs new file mode 100644 index 0000000..ac63401 --- /dev/null +++ b/src/GmRelay.DiscordBot/Rendering/DiscordRescheduleVotingRenderer.cs @@ -0,0 +1,67 @@ +namespace GmRelay.DiscordBot.Rendering; + +using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Sessions.RescheduleSession; +using NetCord; +using NetCord.Rest; + +public static class DiscordRescheduleVotingRenderer +{ + public static (EmbedProperties Embed, ActionRowProperties ActionRow) Render( + string title, + DateTime currentTime, + DateTimeOffset deadline, + IReadOnlyList options, + IReadOnlyList participants, + IReadOnlyList votes) + { + var votesByOption = votes.GroupBy(v => v.OptionId).ToDictionary(g => g.Key, g => g.ToList()); + var votedPlayerIds = votes.Select(v => v.PlayerId).ToHashSet(); + var pending = participants.Where(p => !votedPlayerIds.Contains(p.PlayerId)).Select(p => p.DisplayName).ToList(); + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"📅 Текущее время: {currentTime.FormatMoscow()} (МСК)"); + sb.AppendLine($"⏳ Дедлайн: {deadline.FormatMoscow()} (МСК)"); + sb.AppendLine(); + sb.AppendLine("Выберите один из вариантов:"); + + foreach (var option in options.OrderBy(o => o.DisplayOrder)) + { + var optionVotes = votesByOption.GetValueOrDefault(option.OptionId, []); + sb.AppendLine($"{option.DisplayOrder}. **{option.ProposedAt.FormatMoscow()}** (МСК) — {optionVotes.Count} голосов"); + if (optionVotes.Count > 0) + { + sb.AppendLine($" {string.Join(", ", optionVotes.Select(v => v.DisplayName))}"); + } + } + + if (pending.Count > 0) + { + sb.AppendLine(); + sb.AppendLine($"Не проголосовали: {string.Join(", ", pending)}"); + } + + sb.AppendLine(); + sb.AppendLine($"Голосов: {votedPlayerIds.Count}/{participants.Count}"); + sb.AppendLine("Правило: побеждает вариант с большинством голосов к дедлайну; при ничьей перенос не применяется."); + + var embed = new EmbedProperties() + .WithTitle($"🔄 Перенос сессии «{title}»") + .WithDescription(sb.ToString()) + .WithColor(new Color(0xFEE75C)); + + var actionRow = new ActionRowProperties(); + foreach (var option in options.OrderBy(o => o.DisplayOrder)) + { + actionRow.Add(new ButtonProperties( + $"reschedule_vote:{option.OptionId}", + $"{option.DisplayOrder}. {FormatButtonTime(option.ProposedAt)}", + ButtonStyle.Primary)); + } + + return (embed, actionRow); + } + + private static string FormatButtonTime(DateTimeOffset utc) + => utc.ToOffset(TimeSpan.FromHours(3)).ToString("dd.MM HH:mm", System.Globalization.CultureInfo.InvariantCulture); +}