Add voter tooltips across results emojis and average
This commit is contained in:
2
API.md
2
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.
|
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, 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)
|
## 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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
SPEC.md
1
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
|
- 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, player’s own vote, and links/media
|
- 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
|
- 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
|
||||||
|
|||||||
2
TESTS.md
2
TESTS.md
@@ -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, 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.
|
- Phase mismatch and locked results return 400; unauthorized 401.
|
||||||
|
|
||||||
### 6) Admin Operations
|
### 6) Admin Operations
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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 ↗",
|
"results.link.site": "Site ↗",
|
||||||
"results.link.youtube": "YouTube ↗",
|
"results.link.youtube": "YouTube ↗",
|
||||||
@@ -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 ↗",
|
"results.link.site": "Webseite ↗",
|
||||||
"results.link.youtube": "YouTube ↗",
|
"results.link.youtube": "YouTube ↗",
|
||||||
|
|||||||
@@ -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(", ") });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user