Add voter tooltips across results emojis and average

This commit is contained in:
2026-02-17 19:06:05 +01:00
parent 4d62d0bf50
commit 26379eef1a
10 changed files with 107 additions and 11 deletions

2
API.md
View File

@@ -36,7 +36,7 @@ POST /api/votes/finalize — `{ final: bool }` toggles callers finalized stat
Vote upsert includes conflict handling for concurrent writes against the unique `(PlayerId, SuggestionId)` index. Vote upsert includes conflict handling for concurrent writes against the unique `(PlayerId, SuggestionId)` index.
## Results (requires auth + Results phase + resultsOpen) ## Results (requires auth + Results phase + resultsOpen)
GET /api/results — leaderboard with totals, counts, averages, callers vote, media/links, link metadata GET /api/results — leaderboard with totals, counts, averages, vote values, alphabetically sorted `voterNames`, callers vote, media/links, link metadata
## Admin (requires authenticated admin user) ## Admin (requires authenticated admin user)
POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases

View File

@@ -28,7 +28,7 @@ public record AdminResetStateResponse(Phase Phase, bool ResultsOpen, DateTimeOff
public record VoteStatusResponse(IReadOnlyList<VoteStatusDto> Voters, bool Ready, IReadOnlyList<string> Waiting); public record VoteStatusResponse(IReadOnlyList<VoteStatusDto> Voters, bool Ready, IReadOnlyList<string> Waiting);
public record ResultItemDto(int Id, string Name, string? Author, int? MinPlayers, int? MaxPlayers, int Total, int Count, double Average, IReadOnlyList<int> Votes, int? MyVote, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, string? Description, string? Genre, int? ParentSuggestionId, IReadOnlyList<int> LinkedIds, IReadOnlyList<string> LinkedTitles); public record ResultItemDto(int Id, string Name, string? Author, int? MinPlayers, int? MaxPlayers, int Total, int Count, double Average, IReadOnlyList<int> Votes, IReadOnlyList<string> VoterNames, int? MyVote, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, string? Description, string? Genre, int? ParentSuggestionId, IReadOnlyList<int> LinkedIds, IReadOnlyList<string> LinkedTitles);
public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin); public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin);

View File

@@ -32,6 +32,9 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
s.Votes.Count, s.Votes.Count,
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score), Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
Votes = s.Votes.Select(v => v.Score).ToList(), Votes = s.Votes.Select(v => v.Score).ToList(),
VoterNames = s.Votes
.Select(v => v.Player!.DisplayName ?? v.Player!.Username)
.ToList(),
MyVote = s.Votes MyVote = s.Votes
.Where(v => v.PlayerId == playerId) .Where(v => v.PlayerId == playerId)
.Select(v => (int?)v.Score) .Select(v => (int?)v.Score)
@@ -59,6 +62,11 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
.Where(nameLookup.ContainsKey) .Where(nameLookup.ContainsKey)
.Select(id => nameLookup[id]) .Select(id => nameLookup[id])
.ToList(); .ToList();
var voterNames = r.VoterNames
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
return new ResultItemDto( return new ResultItemDto(
r.Id, r.Id,
@@ -70,6 +78,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
r.Count, r.Count,
r.Average, r.Average,
r.Votes, r.Votes,
voterNames,
r.MyVote, r.MyVote,
r.ScreenshotUrl, r.ScreenshotUrl,
r.YoutubeUrl, r.YoutubeUrl,

View File

@@ -95,6 +95,51 @@ public class ResultsTests
Assert.Equal("High", results[0].GetProperty("name").GetString()); Assert.Equal("High", results[0].GetProperty("name").GetString());
Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble()); Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble());
Assert.Equal(1, results[0].GetProperty("count").GetInt32()); 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("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<List<JsonElement>>("/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);
} }
} }

View File

@@ -35,6 +35,7 @@ Help a small Discord group (48 players) pick a co-op game via phased flow:
- Visible only after admin enables results; players auto-advance when opened - 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 - Admin controls results availability with a single toggle button whose label reflects enabled/disabled state
- Leaderboard sorted by average score; shows totals, counts, players own vote, and links/media - Leaderboard sorted by average score; shows totals, counts, players 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 - When results are closed again, only accounts with at least one suggestion return to Vote; accounts without suggestions return to Suggest
## Non-functional ## Non-functional

View File

@@ -67,7 +67,7 @@ stateDiagram-v2
- Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote. - Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote.
### 5) Results ### 5) Results
- GET /api/results: requires auth, resultsOpen=true, phase=Results; returns ordered leaderboard with totals/count/avg, callers 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, callers vote, link metadata, and handles empty vote lists (Average=0).
- Phase mismatch and locked results return 400; unauthorized 401. - Phase mismatch and locked results return 400; unauthorized 401.
### 6) Admin Operations ### 6) Admin Operations

View File

@@ -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. 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) ## Admin-Tools (Für Hosts)
### Was können Admin-Konten tun? ### Was können Admin-Konten tun?

View File

@@ -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. 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) ## Admin Tools (For Hosts)
### What can admin accounts do? ### What can admin accounts do?

View File

@@ -91,6 +91,8 @@
"results.average": "Ø", "results.average": "Ø",
"results.votesList": "All votes", "results.votesList": "All votes",
"results.myVote": "Your vote", "results.myVote": "Your vote",
"results.votersTooltip": "Voted by: {users}",
"results.votersTooltipEmpty": "No votes yet",
"results.links": "Links", "results.links": "Links",
"results.link.site": "Site&nbsp;↗", "results.link.site": "Site&nbsp;↗",
"results.link.youtube": "YouTube&nbsp;↗", "results.link.youtube": "YouTube&nbsp;↗",
@@ -264,6 +266,8 @@
"results.average": "Ø", "results.average": "Ø",
"results.votesList": "Alle Stimmen", "results.votesList": "Alle Stimmen",
"results.myVote": "Deine Stimme", "results.myVote": "Deine Stimme",
"results.votersTooltip": "Abgestimmt von: {users}",
"results.votersTooltipEmpty": "Noch keine Stimmen",
"results.links": "Links", "results.links": "Links",
"results.link.site": "Webseite&nbsp;↗", "results.link.site": "Webseite&nbsp;↗",
"results.link.youtube": "YouTube&nbsp;↗", "results.link.youtube": "YouTube&nbsp;↗",

View File

@@ -63,6 +63,12 @@ export function renderResults() {
const safeShot = safeUrl(r.screenshotUrl); const safeShot = safeUrl(r.screenshotUrl);
const safeGameUrl = safeUrl(r.gameUrl); const safeGameUrl = safeUrl(r.gameUrl);
const safeYoutubeUrl = safeUrl(r.youtubeUrl); 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 = ` row.innerHTML = `
<td class="rank-cell"><span class="medal">${medal}</span></td> <td class="rank-cell"><span class="medal">${medal}</span></td>
<td class="game-cell"> <td class="game-cell">
@@ -76,9 +82,9 @@ export function renderResults() {
</div> </div>
</td> </td>
<td class="author-cell">${safeAuthor || "—"}</td> <td class="author-cell">${safeAuthor || "—"}</td>
<td>${r.average?.toFixed ? r.average.toFixed(1) : r.average}</td> <td><span title="${safeVotersTooltip}">${averageScore}</span></td>
<td>${formatVotes(r.votes)}</td> <td>${formatVotes(r.votes, votersTooltip)}</td>
<td>${formatMyVote(r.myVote)}</td> <td>${formatMyVote(r.myVote, votersTooltip)}</td>
<td> <td>
${safeGameUrl ? `<a class="link compact" href="${safeGameUrl}" target="_blank" rel="noopener">${t("results.link.site")}</a><br>` : ""} ${safeGameUrl ? `<a class="link compact" href="${safeGameUrl}" target="_blank" rel="noopener">${t("results.link.site")}</a><br>` : ""}
${safeYoutubeUrl ? `<a class="link compact" href="${safeYoutubeUrl}" target="_blank" rel="noopener">${t("results.link.youtube")}</a>` : ""} ${safeYoutubeUrl ? `<a class="link compact" href="${safeYoutubeUrl}" target="_blank" rel="noopener">${t("results.link.youtube")}</a>` : ""}
@@ -110,13 +116,36 @@ function buildResultMeta(r) {
return `<div class="muted small">${bits.join(" • ")}</div>`; return `<div class="muted small">${bits.join(" • ")}</div>`;
} }
function formatVotes(votes) { function formatVotes(votes, tooltip) {
if (!Array.isArray(votes) || votes.length === 0) return "⚠️"; const safeTooltip = escapeHtml(tooltip);
if (!Array.isArray(votes) || votes.length === 0) {
return `<span class="score-emoji" title="${safeTooltip}">⚠️</span>`;
}
const sorted = [...votes].sort((a, b) => a - b); const sorted = [...votes].sort((a, b) => a - b);
return sorted.map((v) => scoreToEmoji(v)).join(""); return sorted
.map(
(v) =>
`<span class="score-emoji" title="${safeTooltip}">${scoreToEmoji(v)}</span>`,
)
.join("");
} }
function formatMyVote(score) { function formatMyVote(score, tooltip) {
if (score == null || Number.isNaN(score)) return "—"; if (score == null || Number.isNaN(score)) return "—";
return `${score} ${scoreToEmoji(score)}`; const safeTooltip = escapeHtml(tooltip);
return `${score} <span class="score-emoji" title="${safeTooltip}">${scoreToEmoji(score)}</span>`;
}
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(", ") });
} }