From 3c7f3d2114b8295f4e93e6a45d88b1450b4af6aa Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Wed, 18 Feb 2026 19:58:57 +0100 Subject: [PATCH] Add event-driven state sync with ETag optimization --- API.md | 3 +- Contracts/Responses.cs | 2 +- Endpoints/StateEndpoints.cs | 68 +++++++++- Endpoints/StateWorkflowService.cs | 29 ++++- GameList.Tests/StateTests.cs | 117 ++++++++++++++++++ .../EnsurePlayerExistsMiddleware.cs | 13 +- .../StateChangeNotificationMiddleware.cs | 31 +++++ Infrastructure/StateChangeNotifier.cs | 69 +++++++++++ Program.cs | 2 + README.md | 1 + TESTS.md | 2 + wwwroot/app.js | 79 +++++++++++- wwwroot/data/i18n/faq/de.md | 4 + wwwroot/data/i18n/faq/en.md | 4 + wwwroot/js/api.js | 57 ++++++--- wwwroot/js/data.js | 39 ++++-- wwwroot/js/state.js | 3 + 17 files changed, 493 insertions(+), 30 deletions(-) create mode 100644 Infrastructure/StateChangeNotificationMiddleware.cs create mode 100644 Infrastructure/StateChangeNotifier.cs diff --git a/API.md b/API.md index 5348ec5..6ac4cc0 100644 --- a/API.md +++ b/API.md @@ -14,7 +14,8 @@ The first account created with a valid `adminKey` becomes both `IsAdmin=true` an Owner bootstrap is also enforced by a database uniqueness constraint (`IsOwner=true` can only exist once), so concurrent owner registration races fail safely with `400`. ## State (requires auth) -GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes) +GET /api/state — returns caller identity (`id`, `username`, `displayName`, `isAdmin`, `isOwner`) plus currentPhase, votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes). Supports conditional reads with `ETag`/`If-None-Match`; unchanged state returns HTTP `304`. +GET /api/events/state — server-sent events stream for state invalidation (`ready` and `state` events with monotonic version payload) for event-driven client refresh. GET /api/me — id, displayName, username, isAdmin, isOwner, currentPhase, votesFinal ## Player (requires auth) diff --git a/Contracts/Responses.cs b/Contracts/Responses.cs index b64e877..546206a 100644 --- a/Contracts/Responses.cs +++ b/Contracts/Responses.cs @@ -34,7 +34,7 @@ public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, public record AuthOptionsResponse(bool OwnerExists); -public record StateSummaryResponse(Phase CurrentPhase, bool VotesFinal, bool HasJoker, bool ResultsOpen, DateTimeOffset UpdatedAt, int Players, int Suggestions, int Votes); +public record StateSummaryResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin, bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker, bool ResultsOpen, DateTimeOffset UpdatedAt, int Players, int Suggestions, int Votes); public record MeResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin, bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker); diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs index fdbaad4..6b7f451 100644 --- a/Endpoints/StateEndpoints.cs +++ b/Endpoints/StateEndpoints.cs @@ -1,4 +1,5 @@ using GameList.Data; +using GameList.Infrastructure; namespace GameList.Endpoints; @@ -8,14 +9,70 @@ public static class StateEndpoints { var group = app.MapGroup("/api").RequireAuthorization(); - group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => + group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service, StateChangeNotifier notifier) => { + ctx.Response.Headers.CacheControl = "private, no-cache"; + if (notifier.MatchesCurrentEtag(ctx.Request.Headers.IfNoneMatch)) + { + ctx.Response.Headers.ETag = notifier.CurrentEtag; + return Results.StatusCode(StatusCodes.Status304NotModified); + } + var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return EndpointHelpers.UnauthorizedError(); var result = await service.GetStateAsync(player); - return result.ToHttpResult(Results.Ok); + return result.ToHttpResult(payload => + { + ctx.Response.Headers.ETag = notifier.CurrentEtag; + return Results.Ok(payload); + }); + }); + + group.MapGet("/events/state", async (HttpContext ctx, AppDbContext db, StateChangeNotifier notifier) => + { + var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); + if (player is null) + return EndpointHelpers.UnauthorizedError(); + + ctx.Response.ContentType = "text/event-stream"; + ctx.Response.Headers.CacheControl = "no-cache"; + ctx.Response.Headers["X-Accel-Buffering"] = "no"; + + var observedVersion = notifier.CurrentVersion; + await WriteStateEventAsync(ctx, "ready", observedVersion, ctx.RequestAborted); + + while (!ctx.RequestAborted.IsCancellationRequested) + { + try + { + var changeTask = notifier.WaitForChangeAsync(observedVersion, ctx.RequestAborted); + var heartbeatTask = Task.Delay(TimeSpan.FromSeconds(20), ctx.RequestAborted); + var completed = await Task.WhenAny(changeTask, heartbeatTask); + + if (completed == changeTask) + { + observedVersion = await changeTask; + await WriteStateEventAsync(ctx, "state", observedVersion, ctx.RequestAborted); + } + else + { + await ctx.Response.WriteAsync(": ping\n\n", ctx.RequestAborted); + await ctx.Response.Body.FlushAsync(ctx.RequestAborted); + } + } + catch (OperationCanceledException) + { + break; + } + catch (IOException) + { + break; + } + } + + return Results.Empty; }); group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => @@ -49,4 +106,11 @@ public static class StateEndpoints }); } + + private static async Task WriteStateEventAsync(HttpContext ctx, string eventName, long version, CancellationToken cancellationToken) + { + await ctx.Response.WriteAsync($"event: {eventName}\n", cancellationToken); + await ctx.Response.WriteAsync($"data: {version}\n\n", cancellationToken); + await ctx.Response.Body.FlushAsync(cancellationToken); + } } diff --git a/Endpoints/StateWorkflowService.cs b/Endpoints/StateWorkflowService.cs index 4344ce0..efdd908 100644 --- a/Endpoints/StateWorkflowService.cs +++ b/Endpoints/StateWorkflowService.cs @@ -9,9 +9,34 @@ internal sealed class StateWorkflowService(AppDbContext db) { public async Task> GetStateAsync(Player player) { - var state = await db.AppState.AsNoTracking().SingleAsync(); + var state = await db.AppState + .AsNoTracking() + .Select(s => new + { + s.ResultsOpen, + s.UpdatedAt, + Players = db.Players.Count(), + Suggestions = db.Suggestions.Count(), + Votes = db.Votes.Count() + }) + .SingleAsync(); + var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen); - var summary = new StateSummaryResponse(phase, player.VotesFinal, player.HasJoker, state.ResultsOpen, state.UpdatedAt, await db.Players.CountAsync(), await db.Suggestions.CountAsync(), await db.Votes.CountAsync()); + var summary = new StateSummaryResponse( + player.Id, + player.Username, + player.DisplayName, + player.IsAdmin, + player.IsOwner, + phase, + player.VotesFinal, + player.HasJoker, + state.ResultsOpen, + state.UpdatedAt, + state.Players, + state.Suggestions, + state.Votes + ); return ServiceResult.Success(summary); } diff --git a/GameList.Tests/StateTests.cs b/GameList.Tests/StateTests.cs index b873894..60d2fe5 100644 --- a/GameList.Tests/StateTests.cs +++ b/GameList.Tests/StateTests.cs @@ -27,6 +27,11 @@ public class StateTests var state = await client.GetFromJsonAsync("/api/state"); + Assert.True(Guid.TryParse(state.GetProperty("id").GetString(), out _)); + Assert.Equal("payload", state.GetProperty("username").GetString()); + Assert.Equal("payload-name", state.GetProperty("displayName").GetString()); + Assert.False(state.GetProperty("isAdmin").GetBoolean()); + Assert.False(state.GetProperty("isOwner").GetBoolean()); Assert.Equal(nameof(Phase.Suggest), state.GetProperty("currentPhase").GetString()); Assert.False(state.GetProperty("votesFinal").GetBoolean()); Assert.True(state.GetProperty("hasJoker").GetBoolean()); @@ -335,6 +340,118 @@ public class StateTests Assert.Equal(Phase.Results, phase); } + + [Fact] + public async Task State_endpoint_supports_conditional_get_with_etag() + { + await using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("etag"); + + var first = await client.GetAsync("/api/state"); + first.EnsureSuccessStatusCode(); + var firstEtag = first.Headers.ETag?.ToString(); + Assert.False(string.IsNullOrWhiteSpace(firstEtag)); + + var conditional = new HttpRequestMessage(HttpMethod.Get, "/api/state"); + conditional.Headers.TryAddWithoutValidation("If-None-Match", firstEtag); + var notModified = await client.SendAsync(conditional); + + Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode); + Assert.Equal(firstEtag, notModified.Headers.ETag?.ToString()); + + await client.CreateSuggestionAsync("etag-changed"); + + var stale = new HttpRequestMessage(HttpMethod.Get, "/api/state"); + stale.Headers.TryAddWithoutValidation("If-None-Match", firstEtag); + var changed = await client.SendAsync(stale); + + changed.EnsureSuccessStatusCode(); + Assert.NotEqual(firstEtag, changed.Headers.ETag?.ToString()); + } + + [Fact] + public async Task State_events_endpoint_emits_state_change_after_mutation() + { + await using var factory = new TestWebApplicationFactory(); + var watcher = factory.CreateClientWithCookies(); + await watcher.RegisterAsync("watcher"); + + using var streamResponse = await watcher.GetAsync("/api/events/state", HttpCompletionOption.ResponseHeadersRead); + streamResponse.EnsureSuccessStatusCode(); + Assert.Equal("text/event-stream", streamResponse.Content.Headers.ContentType?.MediaType); + + await using var stream = await streamResponse.Content.ReadAsStreamAsync(); + using var reader = new StreamReader(stream); + + var readyVersion = await ReadSseEventVersionAsync(reader, "ready", TimeSpan.FromSeconds(2)); + + var mutator = factory.CreateClientWithCookies(); + var register = await mutator.RegisterAsync("mutator"); + register.EnsureSuccessStatusCode(); + + var changedVersion = await ReadSseEventVersionAsync(reader, "state", TimeSpan.FromSeconds(3)); + Assert.True(changedVersion > readyVersion); + } + + [Fact] + public async Task Login_does_not_invalidate_state_etag() + { + await using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("quietetag"); + + var first = await client.GetAsync("/api/state"); + first.EnsureSuccessStatusCode(); + var firstEtag = first.Headers.ETag?.ToString(); + Assert.False(string.IsNullOrWhiteSpace(firstEtag)); + + var loginClient = factory.CreateClientWithCookies(); + var login = await loginClient.LoginAsync("quietetag", "Pass123!"); + login.EnsureSuccessStatusCode(); + + var conditional = new HttpRequestMessage(HttpMethod.Get, "/api/state"); + conditional.Headers.TryAddWithoutValidation("If-None-Match", firstEtag); + var notModified = await client.SendAsync(conditional); + + Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode); + Assert.Equal(firstEtag, notModified.Headers.ETag?.ToString()); + } + + private static async Task ReadSseEventVersionAsync(StreamReader reader, string expectedEventName, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + var eventName = string.Empty; + + while (true) + { + var line = await reader.ReadLineAsync(cts.Token); + if (line is null) + throw new Xunit.Sdk.XunitException("SSE stream closed unexpectedly."); + + if (line.Length == 0) + { + eventName = string.Empty; + continue; + } + + if (line.StartsWith("event: ", StringComparison.Ordinal)) + { + eventName = line["event: ".Length..].Trim(); + continue; + } + + if (!line.StartsWith("data: ", StringComparison.Ordinal)) + continue; + + if (!string.Equals(eventName, expectedEventName, StringComparison.Ordinal)) + continue; + + var payload = line["data: ".Length..].Trim(); + if (long.TryParse(payload, out var version)) + return version; + } + } } diff --git a/Infrastructure/EnsurePlayerExistsMiddleware.cs b/Infrastructure/EnsurePlayerExistsMiddleware.cs index ef19a7e..47af876 100644 --- a/Infrastructure/EnsurePlayerExistsMiddleware.cs +++ b/Infrastructure/EnsurePlayerExistsMiddleware.cs @@ -1,4 +1,5 @@ using GameList.Data; +using GameList.Domain; using Microsoft.AspNetCore.Authentication; namespace GameList.Infrastructure; @@ -10,12 +11,22 @@ public class EnsurePlayerExistsMiddleware(RequestDelegate next) if (context.User.Identity?.IsAuthenticated == true) { var id = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var playerId) || await db.Players.FindAsync(playerId) is null) + if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var playerId)) { await context.SignOutAsync(); context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } + + var player = await db.Players.FindAsync(playerId); + if (player is null) + { + await context.SignOutAsync(); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + context.Items[nameof(Player)] = player; } await next(context); diff --git a/Infrastructure/StateChangeNotificationMiddleware.cs b/Infrastructure/StateChangeNotificationMiddleware.cs new file mode 100644 index 0000000..4db1f46 --- /dev/null +++ b/Infrastructure/StateChangeNotificationMiddleware.cs @@ -0,0 +1,31 @@ +namespace GameList.Infrastructure; + +public sealed class StateChangeNotificationMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context, StateChangeNotifier notifier) + { + await next(context); + + if (ShouldNotify(context)) + notifier.NotifyChange(); + } + + private static bool ShouldNotify(HttpContext context) + { + if (context.Response.StatusCode >= StatusCodes.Status400BadRequest) + return false; + + if (!HttpMethods.IsPost(context.Request.Method) + && !HttpMethods.IsPut(context.Request.Method) + && !HttpMethods.IsDelete(context.Request.Method)) + return false; + + var path = context.Request.Path; + + return path.StartsWithSegments("/api/suggestions", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/api/votes", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/api/admin", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/api/me/phase", StringComparison.OrdinalIgnoreCase) + || path.StartsWithSegments("/api/auth/register", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Infrastructure/StateChangeNotifier.cs b/Infrastructure/StateChangeNotifier.cs new file mode 100644 index 0000000..da94f66 --- /dev/null +++ b/Infrastructure/StateChangeNotifier.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Primitives; + +namespace GameList.Infrastructure; + +public sealed class StateChangeNotifier +{ + private readonly string _instanceId = Guid.NewGuid().ToString("N"); + private long _version = 1; + private TaskCompletionSource _nextChange = CreateWaiter(); + + public long CurrentVersion => Interlocked.Read(ref _version); + + public string CurrentEtag => $"\"{_instanceId}:{CurrentVersion}\""; + + public long NotifyChange() + { + var newVersion = Interlocked.Increment(ref _version); + + while (true) + { + var waiter = Volatile.Read(ref _nextChange); + var replacement = CreateWaiter(); + if (Interlocked.CompareExchange(ref _nextChange, replacement, waiter) != waiter) + continue; + + waiter.TrySetResult(newVersion); + return newVersion; + } + } + + public bool MatchesCurrentEtag(StringValues ifNoneMatchValues) + { + if (StringValues.IsNullOrEmpty(ifNoneMatchValues)) + return false; + + var current = CurrentEtag; + foreach (var raw in ifNoneMatchValues) + { + if (string.IsNullOrWhiteSpace(raw)) + continue; + + var parts = raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + foreach (var part in parts) + { + if (part == "*" || string.Equals(part, current, StringComparison.Ordinal)) + return true; + } + } + + return false; + } + + public async Task WaitForChangeAsync(long observedVersion, CancellationToken cancellationToken) + { + while (true) + { + var current = CurrentVersion; + if (current > observedVersion) + return current; + + var waiter = Volatile.Read(ref _nextChange); + var signaled = await waiter.Task.WaitAsync(cancellationToken); + if (signaled > observedVersion) + return signaled; + } + } + + private static TaskCompletionSource CreateWaiter() => new(TaskCreationOptions.RunContinuationsAsynchronously); +} diff --git a/Program.cs b/Program.cs index 8748b25..51734fc 100644 --- a/Program.cs +++ b/Program.cs @@ -46,6 +46,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); @@ -152,6 +153,7 @@ app.UseGlobalExceptionLogging(); app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); +app.UseMiddleware(); app.UseDefaultFiles(); app.UseStaticFiles(); diff --git a/README.md b/README.md index c371c32..60796b3 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS - Owner model: first valid admin-key registration becomes `owner`; admins can grant/revoke admin role for non-owner accounts. - Core invariants are DB-enforced: single owner account and non-joker suggestion cap. - Gameplay phases: `Suggest`, `Vote`, `Results`. +- Realtime sync: `/api/events/state` (SSE) plus `ETag`-based conditional `/api/state` reads to reduce polling load. - Storage: SQLite database under `App_Data/gamelist.db`. - Migrations are deployment-time operations (`dotnet ef database update`); app startup does not auto-migrate. - Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement. diff --git a/TESTS.md b/TESTS.md index 6671378..a217323 100644 --- a/TESTS.md +++ b/TESTS.md @@ -44,6 +44,8 @@ stateDiagram-v2 ### 2) State & Phase Alignment (/api/state, /api/me) - /api/state returns player-specific phase, votesFinal, hasJoker, counts; unauthorized returns 401. +- /api/state supports `ETag`/`If-None-Match` and returns 304 when unchanged. +- /api/events/state (SSE) emits invalidation events after successful state mutations. - GetPhase auto-upgrades legacy Reveal -> Vote and realigns when resultsOpen toggles (to Results and back to Vote clearing votesFinal). - /me/phase/next: moves Suggest->Vote, Vote->Results only when resultsOpen true; clears votesFinal; rejects when results locked. - /me/phase/prev: admin only; moves back one step, clears votesFinal, rejects for player. diff --git a/wwwroot/app.js b/wwwroot/app.js index 473e22f..3541126 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -22,6 +22,7 @@ import { updatePhaseNav, configureUiRuntime, } from "./js/ui.js"; +import { api } from "./js/api.js"; import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js"; import { setupAuthHandlers } from "./js/app-auth-handlers.js"; import { setupAdminHandlers } from "./js/app-admin-handlers.js"; @@ -29,11 +30,16 @@ import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js"; const REFRESH_MIN_MS = 3000; const REFRESH_MAX_MS = 20000; +const EVENTS_RECONNECT_MIN_MS = 1000; +const EVENTS_RECONNECT_MAX_MS = 15000; let refreshInFlight = null; let refreshTimerId = null; let refreshSchedulerStarted = false; let unchangedRefreshCycles = 0; let nextRefreshDelayMs = REFRESH_MIN_MS; +let stateEventSource = null; +let eventsReconnectTimerId = null; +let eventsReconnectDelayMs = EVENTS_RECONNECT_MIN_MS; async function runSerializedRefresh() { if (refreshInFlight) return refreshInFlight; @@ -47,13 +53,81 @@ async function refreshWithUiErrorHandling() { try { const changed = await runSerializedRefresh(); updateRefreshCadence(changed === true); + if (state.isAuthenticated) { + ensureStateEventStream(); + } else { + closeStateEventStream(); + } } catch (err) { // Back off after transient failures to avoid hammering server/dependencies. nextRefreshDelayMs = Math.min(nextRefreshDelayMs * 2, REFRESH_MAX_MS); - if (!handleAuthError(err, clearUserState)) toast(err.message, true); + if (handleAuthError(err, clearUserState)) { + closeStateEventStream(); + return; + } + + toast(err.message, true); } } +function closeStateEventStream() { + if (eventsReconnectTimerId !== null) { + window.clearTimeout(eventsReconnectTimerId); + eventsReconnectTimerId = null; + } + + if (stateEventSource) { + stateEventSource.close(); + stateEventSource = null; + } +} + +function scheduleStateEventReconnect() { + if (eventsReconnectTimerId !== null || !state.isAuthenticated) return; + + eventsReconnectTimerId = window.setTimeout(() => { + eventsReconnectTimerId = null; + ensureStateEventStream(); + }, eventsReconnectDelayMs); + + eventsReconnectDelayMs = Math.min( + Math.round(eventsReconnectDelayMs * 1.8), + EVENTS_RECONNECT_MAX_MS, + ); +} + +function ensureStateEventStream() { + if (!state.isAuthenticated || typeof window.EventSource === "undefined") { + closeStateEventStream(); + return; + } + + if (stateEventSource) return; + + stateEventSource = new EventSource(api.stateEventsUrl(), { + withCredentials: true, + }); + + stateEventSource.onopen = () => { + eventsReconnectDelayMs = EVENTS_RECONNECT_MIN_MS; + }; + + stateEventSource.onerror = () => { + if (!stateEventSource) return; + stateEventSource.close(); + stateEventSource = null; + scheduleStateEventReconnect(); + }; + + stateEventSource.addEventListener("state", () => { + unchangedRefreshCycles = 0; + nextRefreshDelayMs = baseRefreshDelayForPhase(); + if (!document.hidden && !state.adminStatusSelectActive) { + refreshWithUiErrorHandling(); + } + }); +} + function scheduleNextRefresh() { refreshTimerId = window.setTimeout(async () => { if (!document.hidden && !state.adminStatusSelectActive) { @@ -119,6 +193,9 @@ function setupHandlers() { setupAdminHandlers({ runSerializedRefresh }); setupVoteNavigationHandlers({ runSerializedRefresh }); setupLanguageSwitchers(); + document.getElementById("logout")?.addEventListener("click", () => { + closeStateEventStream(); + }); onLanguageChange(() => { updateLanguageButtons(); diff --git a/wwwroot/data/i18n/faq/de.md b/wwwroot/data/i18n/faq/de.md index 280caba..84a958c 100644 --- a/wwwroot/data/i18n/faq/de.md +++ b/wwwroot/data/i18n/faq/de.md @@ -27,6 +27,10 @@ Jeder Spieler durchläuft die Phasen unabhängig voneinander: Klicke auf **„Weiter"**, um fortzufahren. Admins können sich bei Bedarf auch wieder zurücksetzen. In der **Vorschlagsphase** bleibt **„Weiter"** deaktiviert, bis dein Konto mindestens einen eigenen Spielvorschlag hat. +### Muss ich die Seite manuell aktualisieren? + +Normalerweise nicht. Pick'n'Play erhält Live-Updates vom Server und nutzt nur dann periodische Prüfungen, wenn der Live-Kanal vorübergehend nicht verfügbar ist. + ## Spiele vorschlagen ### Wie viele Spiele kann ich vorschlagen? diff --git a/wwwroot/data/i18n/faq/en.md b/wwwroot/data/i18n/faq/en.md index f2ad7af..4e5f58c 100644 --- a/wwwroot/data/i18n/faq/en.md +++ b/wwwroot/data/i18n/faq/en.md @@ -28,6 +28,10 @@ Each player progresses independently through the phases: Click **"Next"** to move forward. Admins can move themselves backward if needed. In the **Suggest** phase, **Next** stays disabled until your account has at least one own game suggestion. +### Do I need to refresh the page manually? + +Usually no. Pick'n'Play receives live server updates and falls back to periodic checks if the live channel is temporarily unavailable. + ## Suggesting Games ### How many games can I suggest? diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index 34b712f..ac5f3b1 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -18,24 +18,53 @@ async function request(path, { method = "GET", body } = {}) { body: body ? JSON.stringify(body) : undefined, }); - if (!res.ok) { - let msg = `${res.status}`; - try { - const data = await res.json(); - msg = - data.error || data.detail || data.title || JSON.stringify(data); - } catch { - /* ignore */ - } - const err = new Error(msg); - err.status = res.status; - throw err; - } + if (!res.ok) throw await toApiError(res); return res.status === 204 ? null : res.json(); } +async function requestState(ifNoneMatch) { + const headers = { ...defaultHeaders }; + if (ifNoneMatch) headers["If-None-Match"] = ifNoneMatch; + + const res = await fetch(withBase("/api/state"), { + method: "GET", + credentials: "same-origin", + headers, + }); + + if (res.status === 304) { + return { + notModified: true, + etag: res.headers.get("ETag"), + data: null, + }; + } + + if (!res.ok) throw await toApiError(res); + + return { + notModified: false, + etag: res.headers.get("ETag"), + data: await res.json(), + }; +} + +async function toApiError(res) { + let msg = `${res.status}`; + try { + const data = await res.json(); + msg = data.error || data.detail || data.title || JSON.stringify(data); + } catch { + /* ignore */ + } + const err = new Error(msg); + err.status = res.status; + return err; +} + export const api = { - state: () => request("/api/state"), + state: (ifNoneMatch) => requestState(ifNoneMatch), + stateEventsUrl: () => withBase("/api/events/state"), me: () => request("/api/me"), authOptions: () => request("/api/auth/options"), register: (payload) => diff --git a/wwwroot/js/data.js b/wwwroot/js/data.js index 652ff30..aab9826 100644 --- a/wwwroot/js/data.js +++ b/wwwroot/js/data.js @@ -18,14 +18,27 @@ import { import { state, clearUserState } from "./state.js"; export async function loadState() { - const [me, stateData] = await Promise.all([api.me(), api.state()]); + const stateResponse = await api.state(state.stateEtag); + if (stateResponse?.etag) state.stateEtag = stateResponse.etag; + if (stateResponse?.notModified) return false; + + const stateData = stateResponse.data; state.isAuthenticated = true; - state.me = me; - state.hasJoker = me.hasJoker ?? false; + state.me = { + id: stateData.id, + username: stateData.username, + displayName: stateData.displayName, + isAdmin: stateData.isAdmin, + isOwner: stateData.isOwner, + currentPhase: stateData.currentPhase, + votesFinal: stateData.votesFinal, + hasJoker: stateData.hasJoker, + }; + state.hasJoker = stateData.hasJoker ?? false; state.prevPhase = state.phase; state.phase = stateData.currentPhase; state.resultsOpen = stateData.resultsOpen; - state.votesFinal = stateData.votesFinal ?? me?.votesFinal ?? false; + state.votesFinal = stateData.votesFinal ?? false; state.counts = stateData; if (state.prevPhase !== state.phase && state.phase === "Vote") { state.votesRendered = false; @@ -34,6 +47,7 @@ export async function loadState() { renderWelcome(); renderPhasePill(); renderCounts(); + return true; } export async function loadSuggestData() { @@ -105,7 +119,19 @@ export async function refreshPhaseData() { try { const prevPhase = state.phase; const prevResultsOpen = state.resultsOpen; - await loadState(); + const stateChanged = await loadState(); + const adminCard = document.getElementById("admin-card"); + const adminPanelVisible = + !!adminCard && !adminCard.classList.contains("hidden"); + + if (!stateChanged) { + if (state.me?.isAdmin && adminPanelVisible) { + state.adminVoteStatus = await adminApi.voteStatus(); + } + updatePhaseNav(); + return false; + } + await Promise.all([ loadSuggestData(), loadSuggestionsData(), @@ -117,9 +143,6 @@ export async function refreshPhaseData() { state.votesRendered = false; await loadVoteData(); } - const adminCard = document.getElementById("admin-card"); - const adminPanelVisible = - !!adminCard && !adminCard.classList.contains("hidden"); if (state.me?.isAdmin && adminPanelVisible) { state.adminVoteStatus = await adminApi.voteStatus(); } diff --git a/wwwroot/js/state.js b/wwwroot/js/state.js index 4b996a8..3b33bac 100644 --- a/wwwroot/js/state.js +++ b/wwwroot/js/state.js @@ -17,6 +17,7 @@ export const state = { votesRendered: false, adminVoteStatus: null, adminStatusSelectActive: false, + stateEtag: null, }; export function clearUserState() { @@ -34,7 +35,9 @@ export function clearUserState() { state.myVotes = []; state.results = []; state.votesRendered = false; + state.adminVoteStatus = null; state.adminStatusSelectActive = false; + state.stateEtag = null; const adminCard = document.getElementById("admin-card"); if (adminCard) adminCard.classList.add("hidden"); }