From 26379eef1a0b3893875bb6737c9ea8336063f8b2 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Tue, 17 Feb 2026 19:06:05 +0100 Subject: [PATCH] Add voter tooltips across results emojis and average --- API.md | 2 +- Contracts/Responses.cs | 2 +- Endpoints/ResultsWorkflowService.cs | 9 ++++++ GameList.Tests/ResultsTests.cs | 45 +++++++++++++++++++++++++++++ SPEC.md | 1 + TESTS.md | 2 +- wwwroot/data/i18n/faq/de.md | 4 +++ wwwroot/data/i18n/faq/en.md | 4 +++ wwwroot/data/i18n/translations.json | 4 +++ wwwroot/js/results-ui.js | 45 ++++++++++++++++++++++++----- 10 files changed, 107 insertions(+), 11 deletions(-) diff --git a/API.md b/API.md index 043c835..5348ec5 100644 --- a/API.md +++ b/API.md @@ -36,7 +36,7 @@ POST /api/votes/finalize — `{ final: bool }` toggles caller’s finalized stat Vote upsert includes conflict handling for concurrent writes against the unique `(PlayerId, SuggestionId)` index. ## Results (requires auth + Results phase + resultsOpen) -GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata +GET /api/results — leaderboard with totals, counts, averages, vote values, alphabetically sorted `voterNames`, caller’s vote, media/links, link metadata ## Admin (requires authenticated admin user) POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases diff --git a/Contracts/Responses.cs b/Contracts/Responses.cs index fb9b670..b64e877 100644 --- a/Contracts/Responses.cs +++ b/Contracts/Responses.cs @@ -28,7 +28,7 @@ public record AdminResetStateResponse(Phase Phase, bool ResultsOpen, DateTimeOff public record VoteStatusResponse(IReadOnlyList Voters, bool Ready, IReadOnlyList Waiting); -public record ResultItemDto(int Id, string Name, string? Author, int? MinPlayers, int? MaxPlayers, int Total, int Count, double Average, IReadOnlyList Votes, int? MyVote, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, string? Description, string? Genre, int? ParentSuggestionId, IReadOnlyList LinkedIds, IReadOnlyList LinkedTitles); +public record ResultItemDto(int Id, string Name, string? Author, int? MinPlayers, int? MaxPlayers, int Total, int Count, double Average, IReadOnlyList Votes, IReadOnlyList VoterNames, int? MyVote, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, string? Description, string? Genre, int? ParentSuggestionId, IReadOnlyList LinkedIds, IReadOnlyList LinkedTitles); public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin); diff --git a/Endpoints/ResultsWorkflowService.cs b/Endpoints/ResultsWorkflowService.cs index 4453869..af4dcca 100644 --- a/Endpoints/ResultsWorkflowService.cs +++ b/Endpoints/ResultsWorkflowService.cs @@ -32,6 +32,9 @@ internal sealed class ResultsWorkflowService(AppDbContext db) s.Votes.Count, Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score), Votes = s.Votes.Select(v => v.Score).ToList(), + VoterNames = s.Votes + .Select(v => v.Player!.DisplayName ?? v.Player!.Username) + .ToList(), MyVote = s.Votes .Where(v => v.PlayerId == playerId) .Select(v => (int?)v.Score) @@ -59,6 +62,11 @@ internal sealed class ResultsWorkflowService(AppDbContext db) .Where(nameLookup.ContainsKey) .Select(id => nameLookup[id]) .ToList(); + var voterNames = r.VoterNames + .Where(name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList(); return new ResultItemDto( r.Id, @@ -70,6 +78,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db) r.Count, r.Average, r.Votes, + voterNames, r.MyVote, r.ScreenshotUrl, r.YoutubeUrl, diff --git a/GameList.Tests/ResultsTests.cs b/GameList.Tests/ResultsTests.cs index 10140c3..7f909b6 100644 --- a/GameList.Tests/ResultsTests.cs +++ b/GameList.Tests/ResultsTests.cs @@ -95,6 +95,51 @@ public class ResultsTests Assert.Equal("High", results[0].GetProperty("name").GetString()); Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble()); Assert.Equal(1, results[0].GetProperty("count").GetInt32()); + Assert.Equal("player-name", results[0].GetProperty("voterNames")[0].GetString()); Assert.Equal(0, results[1].GetProperty("average").GetDouble()); + Assert.Equal(0, results[1].GetProperty("voterNames").GetArrayLength()); + } + + [Fact] + public async Task Results_payload_contains_alphabetically_sorted_voter_names() + { + await using var factory = new TestWebApplicationFactory(); + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + + var author = factory.CreateClientWithCookies(); + await author.RegisterAsync("author"); + var targetSuggestionId = await author.CreateSuggestionAsync("Target"); + + var zeta = factory.CreateClientWithCookies(); + await zeta.RegisterAsync("zeta"); + await zeta.AdvanceToVoteAsync("zeta-seed"); + await zeta.PostAsJsonAsync("/api/votes", new + { + SuggestionId = targetSuggestionId, + Score = 7 + }); + + var alpha = factory.CreateClientWithCookies(); + await alpha.RegisterAsync("alpha"); + await alpha.AdvanceToVoteAsync("alpha-seed"); + await alpha.PostAsJsonAsync("/api/votes", new + { + SuggestionId = targetSuggestionId, + Score = 8 + }); + + await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true }); + + var results = await alpha.GetFromJsonAsync>("/api/results"); + Assert.NotNull(results); + var target = results.Single(r => r.GetProperty("name").GetString() == "Target"); + + var voterNames = target + .GetProperty("voterNames") + .EnumerateArray() + .Select(n => n.GetString()) + .ToList(); + Assert.Equal(new[] { "alpha-name", "zeta-name" }, voterNames); } } diff --git a/SPEC.md b/SPEC.md index dbea247..e764b66 100644 --- a/SPEC.md +++ b/SPEC.md @@ -35,6 +35,7 @@ Help a small Discord group (4–8 players) pick a co-op game via phased flow: - Visible only after admin enables results; players auto-advance when opened - Admin controls results availability with a single toggle button whose label reflects enabled/disabled state - Leaderboard sorted by average score; shows totals, counts, player’s own vote, and links/media +- Average score and score emojis expose the same tooltip showing the game's voters in alphabetical order - When results are closed again, only accounts with at least one suggestion return to Vote; accounts without suggestions return to Suggest ## Non-functional diff --git a/TESTS.md b/TESTS.md index ad39e4a..6671378 100644 --- a/TESTS.md +++ b/TESTS.md @@ -67,7 +67,7 @@ stateDiagram-v2 - Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote. ### 5) Results -- GET /api/results: requires auth, resultsOpen=true, phase=Results; returns ordered leaderboard with totals/count/avg, caller’s vote, link metadata, and handles empty vote lists (Average=0). +- GET /api/results: requires auth, resultsOpen=true, phase=Results; returns ordered leaderboard with totals/count/avg, vote values, alphabetically sorted voter names, caller’s vote, link metadata, and handles empty vote lists (Average=0). - Phase mismatch and locked results return 400; unauthorized 401. ### 6) Admin Operations diff --git a/wwwroot/data/i18n/faq/de.md b/wwwroot/data/i18n/faq/de.md index 053a499..280caba 100644 --- a/wwwroot/data/i18n/faq/de.md +++ b/wwwroot/data/i18n/faq/de.md @@ -146,6 +146,10 @@ Die Ergebnisse bleiben verborgen, bis ein Admin sie freigibt. Danach werden alle Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf an einen Admin. +### Wie sehe ich, wer für ein Spiel abgestimmt hat? + +Fahre mit der Maus über den Durchschnittswert oder ein Bewertungs-Emoji in der Ergebniszeile, um die alphabetisch sortierte Liste der Abstimmenden zu sehen. + ## Admin-Tools (Für Hosts) ### Was können Admin-Konten tun? diff --git a/wwwroot/data/i18n/faq/en.md b/wwwroot/data/i18n/faq/en.md index de62a0a..f2ad7af 100644 --- a/wwwroot/data/i18n/faq/en.md +++ b/wwwroot/data/i18n/faq/en.md @@ -150,6 +150,10 @@ If needed, an admin can close the Results: players with at least one own suggest No. Suggestions and votes are read-only. Contact an admin for assistance. +### How can I see who voted for a game? + +Hover the average score or any score emoji in that result row to see the voter list (sorted alphabetically). + ## Admin Tools (For Hosts) ### What can admin accounts do? diff --git a/wwwroot/data/i18n/translations.json b/wwwroot/data/i18n/translations.json index 51a31ce..395cc5e 100644 --- a/wwwroot/data/i18n/translations.json +++ b/wwwroot/data/i18n/translations.json @@ -91,6 +91,8 @@ "results.average": "Ø", "results.votesList": "All votes", "results.myVote": "Your vote", + "results.votersTooltip": "Voted by: {users}", + "results.votersTooltipEmpty": "No votes yet", "results.links": "Links", "results.link.site": "Site ↗", "results.link.youtube": "YouTube ↗", @@ -264,6 +266,8 @@ "results.average": "Ø", "results.votesList": "Alle Stimmen", "results.myVote": "Deine Stimme", + "results.votersTooltip": "Abgestimmt von: {users}", + "results.votersTooltipEmpty": "Noch keine Stimmen", "results.links": "Links", "results.link.site": "Webseite ↗", "results.link.youtube": "YouTube ↗", diff --git a/wwwroot/js/results-ui.js b/wwwroot/js/results-ui.js index 67f2c94..9d37e80 100644 --- a/wwwroot/js/results-ui.js +++ b/wwwroot/js/results-ui.js @@ -63,6 +63,12 @@ export function renderResults() { const safeShot = safeUrl(r.screenshotUrl); const safeGameUrl = safeUrl(r.gameUrl); const safeYoutubeUrl = safeUrl(r.youtubeUrl); + const votersTooltip = buildVotersTooltip(r); + const safeVotersTooltip = escapeHtml(votersTooltip); + const averageScore = + r.average?.toFixed && typeof r.average === "number" + ? r.average.toFixed(1) + : r.average; row.innerHTML = ` ${medal} @@ -76,9 +82,9 @@ export function renderResults() { ${safeAuthor || "—"} - ${r.average?.toFixed ? r.average.toFixed(1) : r.average} - ${formatVotes(r.votes)} - ${formatMyVote(r.myVote)} + ${averageScore} + ${formatVotes(r.votes, votersTooltip)} + ${formatMyVote(r.myVote, votersTooltip)} ${safeGameUrl ? `${t("results.link.site")}
` : ""} ${safeYoutubeUrl ? `${t("results.link.youtube")}` : ""} @@ -110,13 +116,36 @@ function buildResultMeta(r) { return `
${bits.join(" • ")}
`; } -function formatVotes(votes) { - if (!Array.isArray(votes) || votes.length === 0) return "⚠️"; +function formatVotes(votes, tooltip) { + const safeTooltip = escapeHtml(tooltip); + if (!Array.isArray(votes) || votes.length === 0) { + return `⚠️`; + } const sorted = [...votes].sort((a, b) => a - b); - return sorted.map((v) => scoreToEmoji(v)).join(""); + return sorted + .map( + (v) => + `${scoreToEmoji(v)}`, + ) + .join(""); } -function formatMyVote(score) { +function formatMyVote(score, tooltip) { if (score == null || Number.isNaN(score)) return "—"; - return `${score} ${scoreToEmoji(score)}`; + const safeTooltip = escapeHtml(tooltip); + return `${score} ${scoreToEmoji(score)}`; +} + +function buildVotersTooltip(result) { + const voterNames = Array.isArray(result?.voterNames) + ? result.voterNames + .filter( + (name) => typeof name === "string" && name.trim().length > 0, + ) + .sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ) + : []; + if (voterNames.length === 0) return t("results.votersTooltipEmpty"); + return t("results.votersTooltip", { users: voterNames.join(", ") }); }