diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs
index 16a9384..0c5e5d6 100644
--- a/Contracts/Dtos.cs
+++ b/Contracts/Dtos.cs
@@ -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);
diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs
index 93ffc27..5e4e5f6 100644
--- a/Data/AppDbContext.cs
+++ b/Data/AppDbContext.cs
@@ -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)
diff --git a/Data/Migrations/20260204215138_VotesFinalFlag.Designer.cs b/Data/Migrations/20260204215138_VotesFinalFlag.Designer.cs
new file mode 100644
index 0000000..6b1293f
--- /dev/null
+++ b/Data/Migrations/20260204215138_VotesFinalFlag.Designer.cs
@@ -0,0 +1,227 @@
+//
+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
+ {
+ ///
+ 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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ResultsOpen")
+ .HasColumnType("INTEGER");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CurrentPhase")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("DisplayName")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("IsAdmin")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(false);
+
+ b.Property("LastLoginAt")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUsername")
+ .IsRequired()
+ .HasMaxLength(24)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("BLOB");
+
+ b.Property("PasswordSalt")
+ .IsRequired()
+ .HasColumnType("BLOB");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasMaxLength(24)
+ .HasColumnType("TEXT");
+
+ b.Property("VotesFinal")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(false);
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedUsername")
+ .IsUnique();
+
+ b.ToTable("Players");
+ });
+
+ modelBuilder.Entity("GameList.Domain.Suggestion", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Description")
+ .HasMaxLength(500)
+ .HasColumnType("TEXT");
+
+ b.Property("GameUrl")
+ .HasMaxLength(2048)
+ .HasColumnType("TEXT");
+
+ b.Property("Genre")
+ .HasMaxLength(50)
+ .HasColumnType("TEXT");
+
+ b.Property("MaxPlayers")
+ .HasColumnType("INTEGER");
+
+ b.Property("MinPlayers")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("TEXT");
+
+ b.Property("PlayerId")
+ .HasColumnType("TEXT");
+
+ b.Property("ScreenshotUrl")
+ .HasMaxLength(2048)
+ .HasColumnType("TEXT");
+
+ b.Property("YoutubeUrl")
+ .HasMaxLength(2048)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("PlayerId");
+
+ b.ToTable("Suggestions");
+ });
+
+ modelBuilder.Entity("GameList.Domain.Vote", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("PlayerId")
+ .HasColumnType("TEXT");
+
+ b.Property("Score")
+ .HasColumnType("INTEGER");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/Data/Migrations/20260204215138_VotesFinalFlag.cs b/Data/Migrations/20260204215138_VotesFinalFlag.cs
new file mode 100644
index 0000000..5b213b0
--- /dev/null
+++ b/Data/Migrations/20260204215138_VotesFinalFlag.cs
@@ -0,0 +1,29 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace GameList.Data.Migrations
+{
+ ///
+ public partial class VotesFinalFlag : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "VotesFinal",
+ table: "Players",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "VotesFinal",
+ table: "Players");
+ }
+ }
+}
diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs
index fbe5c70..46e77d6 100644
--- a/Data/Migrations/AppDbContextModelSnapshot.cs
+++ b/Data/Migrations/AppDbContextModelSnapshot.cs
@@ -86,6 +86,11 @@ namespace GameList.Data.Migrations
.HasMaxLength(24)
.HasColumnType("TEXT");
+ b.Property("VotesFinal")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(false);
+
b.HasKey("Id");
b.HasIndex("NormalizedUsername")
diff --git a/Domain/Player.cs b/Domain/Player.cs
index 67442b9..33a209b 100644
--- a/Domain/Player.cs
+++ b/Domain/Player.cs
@@ -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;
diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs
index 33032fc..6af0228 100644
--- a/Endpoints/AdminEndpoints.cs
+++ b/Endpoints/AdminEndpoints.cs
@@ -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;
diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs
index df6d537..4f3a02a 100644
--- a/Endpoints/EndpointHelpers.cs
+++ b/Endpoints/EndpointHelpers.cs
@@ -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;
}
diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs
index 12d0de4..3b0331a 100644
--- a/Endpoints/StateEndpoints.cs
+++ b/Endpoints/StateEndpoints.cs
@@ -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 });
diff --git a/Endpoints/VoteEndpoints.cs b/Endpoints/VoteEndpoints.cs
index d0c2ce0..6ae1f34 100644
--- a/Endpoints/VoteEndpoints.cs
+++ b/Endpoints/VoteEndpoints.cs
@@ -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 });
+ });
}
}
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js
index f0596ba..33d8f92 100644
--- a/wwwroot/js/api.js
+++ b/wwwroot/js/api.js
@@ -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" }),
diff --git a/wwwroot/js/i18n.js b/wwwroot/js/i18n.js
index 11693f5..037cf3e 100644
--- a/wwwroot/js/i18n.js
+++ b/wwwroot/js/i18n.js
@@ -76,6 +76,8 @@ const translations = {
"card.openScreenshot": "Open screenshot",
"vote.saved": "Saved vote",
+ "vote.missing": "Missing",
+ "vote.missingWarn": "You haven’t 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",
diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js
index 09e7ebf..6453d51 100644
--- a/wwwroot/js/ui.js
+++ b/wwwroot/js/ui.js
@@ -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 = `
+ ${t("vote.missingWarn")}
${displayScore}
${displayEmoji}`;
@@ -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("");
}