Add votes-final flag, warn on missing votes, and sync phases with results toggle

This commit is contained in:
2026-02-04 22:54:36 +01:00
parent 91692856f9
commit 13c8bb6194
13 changed files with 318 additions and 6 deletions

View File

@@ -5,3 +5,4 @@ public record SuggestionRequest(string Name, string? Genre, string? Description,
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers);
public record VoteRequest(int SuggestionId, int Score);
public record ResultsOpenRequest(bool ResultsOpen);
public record VoteFinalizeRequest(bool Final);

View File

@@ -27,6 +27,7 @@ public class AppDbContext : DbContext
builder.Property(p => p.PasswordSalt).IsRequired();
builder.Property(p => p.IsAdmin).HasDefaultValue(false);
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
builder.Property(p => p.VotesFinal).HasDefaultValue(false);
builder.HasMany(p => p.Suggestions)
.WithOne(s => s.Player!)
.HasForeignKey(s => s.PlayerId)

View File

@@ -0,0 +1,227 @@
// <auto-generated />
using System;
using GameList.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace GameList.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260204215138_VotesFinalFlag")]
partial class VotesFinalFlag
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("GameList.Domain.AppState", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ResultsOpen")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AppState");
b.HasData(
new
{
Id = 1,
ResultsOpen = false,
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
});
});
modelBuilder.Entity("GameList.Domain.Player", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrentPhase")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("DisplayName")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<bool>("IsAdmin")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<string>("NormalizedUsername")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<byte[]>("PasswordHash")
.IsRequired()
.HasColumnType("BLOB");
b.Property<byte[]>("PasswordSalt")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<bool>("VotesFinal")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.HasKey("Id");
b.HasIndex("NormalizedUsername")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("GameUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int?>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<int?>("MinPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<string>("ScreenshotUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("YoutubeUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlayerId");
b.ToTable("Suggestions");
});
modelBuilder.Entity("GameList.Domain.Vote", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<int>("Score")
.HasColumnType("INTEGER");
b.Property<int>("SuggestionId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SuggestionId");
b.HasIndex("PlayerId", "SuggestionId")
.IsUnique();
b.ToTable("Votes");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.HasOne("GameList.Domain.Player", "Player")
.WithMany("Suggestions")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
});
modelBuilder.Entity("GameList.Domain.Vote", b =>
{
b.HasOne("GameList.Domain.Player", "Player")
.WithMany("Votes")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("GameList.Domain.Suggestion", "Suggestion")
.WithMany("Votes")
.HasForeignKey("SuggestionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
b.Navigation("Suggestion");
});
modelBuilder.Entity("GameList.Domain.Player", b =>
{
b.Navigation("Suggestions");
b.Navigation("Votes");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.Navigation("Votes");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GameList.Data.Migrations
{
/// <inheritdoc />
public partial class VotesFinalFlag : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "VotesFinal",
table: "Players",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "VotesFinal",
table: "Players");
}
}
}

View File

@@ -86,6 +86,11 @@ namespace GameList.Data.Migrations
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<bool>("VotesFinal")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.HasKey("Id");
b.HasIndex("NormalizedUsername")

View File

@@ -21,6 +21,7 @@ public class Player
public DateTimeOffset? LastLoginAt { get; set; }
public bool IsAdmin { get; set; }
public Phase CurrentPhase { get; set; } = Phase.Suggest;
public bool VotesFinal { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;

View File

@@ -19,6 +19,16 @@ public static class AdminEndpoints
state.ResultsOpen = request.ResultsOpen;
state.UpdatedAt = DateTimeOffset.UtcNow;
if (request.ResultsOpen)
{
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Results));
}
else
{
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote)
.SetProperty(x => x.VotesFinal, false));
}
await db.SaveChangesAsync();
var currentState = await db.AppState.AsNoTracking().FirstAsync();
return Results.Ok(new { currentState.ResultsOpen, currentState.UpdatedAt });
@@ -31,7 +41,8 @@ public static class AdminEndpoints
await db.Votes.ExecuteDeleteAsync();
await db.Suggestions.ExecuteDeleteAsync();
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest));
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest)
.SetProperty(x => x.VotesFinal, false));
var state = await db.AppState.FirstAsync();
state.ResultsOpen = false;
state.UpdatedAt = DateTimeOffset.UtcNow;

View File

@@ -23,13 +23,26 @@ internal static class EndpointHelpers
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) return Phase.Suggest;
var state = await db.AppState.FirstAsync();
// Auto-upgrade any legacy Reveal phase to Vote to avoid blank screens
if (player.CurrentPhase == Phase.Reveal)
{
player.CurrentPhase = Phase.Vote;
await db.SaveChangesAsync();
}
// Keep phases aligned with results availability
if (state.ResultsOpen && player.CurrentPhase != Phase.Results)
{
player.CurrentPhase = Phase.Results;
}
else if (!state.ResultsOpen && player.CurrentPhase == Phase.Results)
{
player.CurrentPhase = Phase.Vote;
player.VotesFinal = false;
}
await db.SaveChangesAsync();
return player.CurrentPhase;
}

View File

@@ -20,6 +20,7 @@ public static class StateEndpoints
var summary = new
{
CurrentPhase = phase,
player.VotesFinal,
state.ResultsOpen,
state.UpdatedAt,
Players = await db.Players.CountAsync(),
@@ -34,7 +35,7 @@ public static class StateEndpoints
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id);
return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin, CurrentPhase = phase });
return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin, CurrentPhase = phase, player.VotesFinal });
});
app.MapPost("/api/me/phase/next", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
@@ -52,6 +53,7 @@ public static class StateEndpoints
}
player.CurrentPhase = next;
player.VotesFinal = false; // moving forward clears any prior finalize
await db.SaveChangesAsync();
return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen });
});
@@ -67,6 +69,7 @@ public static class StateEndpoints
}
player.CurrentPhase = PrevPhase(player.CurrentPhase);
player.VotesFinal = false;
await db.SaveChangesAsync();
var appState = await db.AppState.AsNoTracking().FirstAsync();
return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen });

View File

@@ -62,5 +62,18 @@ public static class VoteEndpoints
await db.SaveChangesAsync();
return Results.Ok(new { vote.Id, vote.Score });
});
app.MapPost("/api/votes/finalize", async ([FromBody] VoteFinalizeRequest request, HttpContext ctx, AppDbContext db) =>
{
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) return Results.Unauthorized();
var phase = await EndpointHelpers.GetPhase(db, player.Id);
if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
player.VotesFinal = request.Final;
await db.SaveChangesAsync();
return Results.Ok(new { player.VotesFinal });
});
}
}

View File

@@ -44,6 +44,7 @@ export const api = {
myVotes: () => request("/api/votes/mine"),
vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }),
finalizeVotes: (final) => request("/api/votes/finalize", { method: "POST", body: { final } }),
results: () => request("/api/results"),
nextPhase: () => request("/api/me/phase/next", { method: "POST" }),

View File

@@ -76,6 +76,8 @@ const translations = {
"card.openScreenshot": "Open screenshot",
"vote.saved": "Saved vote",
"vote.missing": "Missing",
"vote.missingWarn": "You havent voted yet. Slide to set a score.",
"results.rank": "Rank",
"results.game": "Game",
@@ -193,6 +195,8 @@ const translations = {
"card.openScreenshot": "Screenshot öffnen",
"vote.saved": "Stimme gespeichert",
"vote.missing": "Fehlt",
"vote.missingWarn": "Du hast hier noch nicht abgestimmt. Schiebe den Regler.",
"results.rank": "Rang",
"results.game": "Spiel",

View File

@@ -145,11 +145,12 @@ export function renderVotes() {
});
const hasVote = Object.prototype.hasOwnProperty.call(votesMap, s.id);
const current = hasVote ? votesMap[s.id] : 5; // start neutral when no prior vote
const displayScore = hasVote ? current : "—";
const displayEmoji = hasVote ? scoreToEmoji(current) : neutralEmoji();
const displayScore = hasVote ? current : t("vote.missing");
const displayEmoji = hasVote ? scoreToEmoji(current) : "⚠️";
const footer = document.createElement("div");
footer.className = "vote-controls";
footer.innerHTML = `
<div class="warning-text ${hasVote ? "hidden" : ""}" id="warn-${s.id}">${t("vote.missingWarn")}</div>
<input class="full-slider" type="range" min="0" max="10" value="${current}" data-id="${s.id}">
<span class="score" id="score-${s.id}">${displayScore}</span>
<span class="score-emoji" id="emoji-${s.id}">${displayEmoji}</span>`;
@@ -162,6 +163,8 @@ export function renderVotes() {
$("score-" + e.target.dataset.id).textContent = val;
const emojiEl = $("emoji-" + e.target.dataset.id);
if (emojiEl) emojiEl.textContent = scoreToEmoji(val);
const warn = $("warn-" + e.target.dataset.id);
if (warn) warn.classList.add("hidden");
});
input.addEventListener("change", async (e) => {
const suggestionId = Number(e.target.dataset.id);
@@ -627,7 +630,7 @@ export function neutralEmoji() {
}
function formatVotes(votes) {
if (!Array.isArray(votes) || votes.length === 0) return "";
if (!Array.isArray(votes) || votes.length === 0) return "⚠️";
const sorted = [...votes].sort((a, b) => a - b);
return sorted.map((v) => scoreToEmoji(v)).join("");
}