From 13c8bb61948520a8a822515c0d3df063b7c5df3a Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Wed, 4 Feb 2026 22:54:36 +0100 Subject: [PATCH] Add votes-final flag, warn on missing votes, and sync phases with results toggle --- Contracts/Dtos.cs | 1 + Data/AppDbContext.cs | 1 + .../20260204215138_VotesFinalFlag.Designer.cs | 227 ++++++++++++++++++ .../20260204215138_VotesFinalFlag.cs | 29 +++ Data/Migrations/AppDbContextModelSnapshot.cs | 5 + Domain/Player.cs | 1 + Endpoints/AdminEndpoints.cs | 13 +- Endpoints/EndpointHelpers.cs | 15 +- Endpoints/StateEndpoints.cs | 5 +- Endpoints/VoteEndpoints.cs | 13 + wwwroot/js/api.js | 1 + wwwroot/js/i18n.js | 4 + wwwroot/js/ui.js | 9 +- 13 files changed, 318 insertions(+), 6 deletions(-) create mode 100644 Data/Migrations/20260204215138_VotesFinalFlag.Designer.cs create mode 100644 Data/Migrations/20260204215138_VotesFinalFlag.cs 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(""); }