Add event-driven state sync with ETag optimization

This commit is contained in:
2026-02-18 19:58:57 +01:00
parent 5b921063ec
commit 3c7f3d2114
17 changed files with 493 additions and 30 deletions

3
API.md
View File

@@ -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`. 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) ## 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 GET /api/me — id, displayName, username, isAdmin, isOwner, currentPhase, votesFinal
## Player (requires auth) ## Player (requires auth)

View File

@@ -34,7 +34,7 @@ public record AuthSessionResponse(Guid Id, string Username, string? DisplayName,
public record AuthOptionsResponse(bool OwnerExists); 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); public record MeResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin, bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker);

View File

@@ -1,4 +1,5 @@
using GameList.Data; using GameList.Data;
using GameList.Infrastructure;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -8,14 +9,70 @@ public static class StateEndpoints
{ {
var group = app.MapGroup("/api").RequireAuthorization(); 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); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
var result = await service.GetStateAsync(player); 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) => 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);
}
} }

View File

@@ -9,9 +9,34 @@ internal sealed class StateWorkflowService(AppDbContext db)
{ {
public async Task<ServiceResult<StateSummaryResponse>> GetStateAsync(Player player) public async Task<ServiceResult<StateSummaryResponse>> 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 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<StateSummaryResponse>.Success(summary); return ServiceResult<StateSummaryResponse>.Success(summary);
} }

View File

@@ -27,6 +27,11 @@ public class StateTests
var state = await client.GetFromJsonAsync<JsonElement>("/api/state"); var state = await client.GetFromJsonAsync<JsonElement>("/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.Equal(nameof(Phase.Suggest), state.GetProperty("currentPhase").GetString());
Assert.False(state.GetProperty("votesFinal").GetBoolean()); Assert.False(state.GetProperty("votesFinal").GetBoolean());
Assert.True(state.GetProperty("hasJoker").GetBoolean()); Assert.True(state.GetProperty("hasJoker").GetBoolean());
@@ -335,6 +340,118 @@ public class StateTests
Assert.Equal(Phase.Results, phase); 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<long> 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;
}
}
} }

View File

@@ -1,4 +1,5 @@
using GameList.Data; using GameList.Data;
using GameList.Domain;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
namespace GameList.Infrastructure; namespace GameList.Infrastructure;
@@ -10,12 +11,22 @@ public class EnsurePlayerExistsMiddleware(RequestDelegate next)
if (context.User.Identity?.IsAuthenticated == true) if (context.User.Identity?.IsAuthenticated == true)
{ {
var id = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; 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(); await context.SignOutAsync();
context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return; 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); await next(context);

View File

@@ -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);
}
}

View File

@@ -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<long> _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<long> 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<long> CreateWaiter() => new(TaskCreationOptions.RunContinuationsAsynchronously);
}

View File

@@ -46,6 +46,7 @@ builder.Services.AddScoped<AdminWorkflowService>();
builder.Services.AddScoped<ResultsWorkflowService>(); builder.Services.AddScoped<ResultsWorkflowService>();
builder.Services.AddScoped<StateWorkflowService>(); builder.Services.AddScoped<StateWorkflowService>();
builder.Services.AddSingleton<AuthAttemptMonitor>(); builder.Services.AddSingleton<AuthAttemptMonitor>();
builder.Services.AddSingleton<StateChangeNotifier>();
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
@@ -152,6 +153,7 @@ app.UseGlobalExceptionLogging();
app.UseAuthentication(); app.UseAuthentication();
app.UseMiddleware<EnsurePlayerExistsMiddleware>(); app.UseMiddleware<EnsurePlayerExistsMiddleware>();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<StateChangeNotificationMiddleware>();
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();

View File

@@ -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. - 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. - Core invariants are DB-enforced: single owner account and non-joker suggestion cap.
- Gameplay phases: `Suggest`, `Vote`, `Results`. - 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`. - Storage: SQLite database under `App_Data/gamelist.db`.
- Migrations are deployment-time operations (`dotnet ef database update`); app startup does not auto-migrate. - 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. - Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.

View File

@@ -44,6 +44,8 @@ stateDiagram-v2
### 2) State & Phase Alignment (/api/state, /api/me) ### 2) State & Phase Alignment (/api/state, /api/me)
- /api/state returns player-specific phase, votesFinal, hasJoker, counts; unauthorized returns 401. - /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). - 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/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. - /me/phase/prev: admin only; moves back one step, clears votesFinal, rejects for player.

View File

@@ -22,6 +22,7 @@ import {
updatePhaseNav, updatePhaseNav,
configureUiRuntime, configureUiRuntime,
} from "./js/ui.js"; } from "./js/ui.js";
import { api } from "./js/api.js";
import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js"; import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js";
import { setupAuthHandlers } from "./js/app-auth-handlers.js"; import { setupAuthHandlers } from "./js/app-auth-handlers.js";
import { setupAdminHandlers } from "./js/app-admin-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_MIN_MS = 3000;
const REFRESH_MAX_MS = 20000; const REFRESH_MAX_MS = 20000;
const EVENTS_RECONNECT_MIN_MS = 1000;
const EVENTS_RECONNECT_MAX_MS = 15000;
let refreshInFlight = null; let refreshInFlight = null;
let refreshTimerId = null; let refreshTimerId = null;
let refreshSchedulerStarted = false; let refreshSchedulerStarted = false;
let unchangedRefreshCycles = 0; let unchangedRefreshCycles = 0;
let nextRefreshDelayMs = REFRESH_MIN_MS; let nextRefreshDelayMs = REFRESH_MIN_MS;
let stateEventSource = null;
let eventsReconnectTimerId = null;
let eventsReconnectDelayMs = EVENTS_RECONNECT_MIN_MS;
async function runSerializedRefresh() { async function runSerializedRefresh() {
if (refreshInFlight) return refreshInFlight; if (refreshInFlight) return refreshInFlight;
@@ -47,11 +53,79 @@ async function refreshWithUiErrorHandling() {
try { try {
const changed = await runSerializedRefresh(); const changed = await runSerializedRefresh();
updateRefreshCadence(changed === true); updateRefreshCadence(changed === true);
if (state.isAuthenticated) {
ensureStateEventStream();
} else {
closeStateEventStream();
}
} catch (err) { } catch (err) {
// Back off after transient failures to avoid hammering server/dependencies. // Back off after transient failures to avoid hammering server/dependencies.
nextRefreshDelayMs = Math.min(nextRefreshDelayMs * 2, REFRESH_MAX_MS); 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() { function scheduleNextRefresh() {
@@ -119,6 +193,9 @@ function setupHandlers() {
setupAdminHandlers({ runSerializedRefresh }); setupAdminHandlers({ runSerializedRefresh });
setupVoteNavigationHandlers({ runSerializedRefresh }); setupVoteNavigationHandlers({ runSerializedRefresh });
setupLanguageSwitchers(); setupLanguageSwitchers();
document.getElementById("logout")?.addEventListener("click", () => {
closeStateEventStream();
});
onLanguageChange(() => { onLanguageChange(() => {
updateLanguageButtons(); updateLanguageButtons();

View File

@@ -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. 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. 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 ## Spiele vorschlagen
### Wie viele Spiele kann ich vorschlagen? ### Wie viele Spiele kann ich vorschlagen?

View File

@@ -28,6 +28,10 @@ Each player progresses independently through the phases:
Click **"Next"** to move forward. Admins can move themselves backward if needed. 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. 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 ## Suggesting Games
### How many games can I suggest? ### How many games can I suggest?

View File

@@ -18,24 +18,53 @@ async function request(path, { method = "GET", body } = {}) {
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
if (!res.ok) { 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}`; let msg = `${res.status}`;
try { try {
const data = await res.json(); const data = await res.json();
msg = msg = data.error || data.detail || data.title || JSON.stringify(data);
data.error || data.detail || data.title || JSON.stringify(data);
} catch { } catch {
/* ignore */ /* ignore */
} }
const err = new Error(msg); const err = new Error(msg);
err.status = res.status; err.status = res.status;
throw err; return err;
}
return res.status === 204 ? null : res.json();
} }
export const api = { export const api = {
state: () => request("/api/state"), state: (ifNoneMatch) => requestState(ifNoneMatch),
stateEventsUrl: () => withBase("/api/events/state"),
me: () => request("/api/me"), me: () => request("/api/me"),
authOptions: () => request("/api/auth/options"), authOptions: () => request("/api/auth/options"),
register: (payload) => register: (payload) =>

View File

@@ -18,14 +18,27 @@ import {
import { state, clearUserState } from "./state.js"; import { state, clearUserState } from "./state.js";
export async function loadState() { 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.isAuthenticated = true;
state.me = me; state.me = {
state.hasJoker = me.hasJoker ?? false; 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.prevPhase = state.phase;
state.phase = stateData.currentPhase; state.phase = stateData.currentPhase;
state.resultsOpen = stateData.resultsOpen; state.resultsOpen = stateData.resultsOpen;
state.votesFinal = stateData.votesFinal ?? me?.votesFinal ?? false; state.votesFinal = stateData.votesFinal ?? false;
state.counts = stateData; state.counts = stateData;
if (state.prevPhase !== state.phase && state.phase === "Vote") { if (state.prevPhase !== state.phase && state.phase === "Vote") {
state.votesRendered = false; state.votesRendered = false;
@@ -34,6 +47,7 @@ export async function loadState() {
renderWelcome(); renderWelcome();
renderPhasePill(); renderPhasePill();
renderCounts(); renderCounts();
return true;
} }
export async function loadSuggestData() { export async function loadSuggestData() {
@@ -105,7 +119,19 @@ export async function refreshPhaseData() {
try { try {
const prevPhase = state.phase; const prevPhase = state.phase;
const prevResultsOpen = state.resultsOpen; 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([ await Promise.all([
loadSuggestData(), loadSuggestData(),
loadSuggestionsData(), loadSuggestionsData(),
@@ -117,9 +143,6 @@ export async function refreshPhaseData() {
state.votesRendered = false; state.votesRendered = false;
await loadVoteData(); await loadVoteData();
} }
const adminCard = document.getElementById("admin-card");
const adminPanelVisible =
!!adminCard && !adminCard.classList.contains("hidden");
if (state.me?.isAdmin && adminPanelVisible) { if (state.me?.isAdmin && adminPanelVisible) {
state.adminVoteStatus = await adminApi.voteStatus(); state.adminVoteStatus = await adminApi.voteStatus();
} }

View File

@@ -17,6 +17,7 @@ export const state = {
votesRendered: false, votesRendered: false,
adminVoteStatus: null, adminVoteStatus: null,
adminStatusSelectActive: false, adminStatusSelectActive: false,
stateEtag: null,
}; };
export function clearUserState() { export function clearUserState() {
@@ -34,7 +35,9 @@ export function clearUserState() {
state.myVotes = []; state.myVotes = [];
state.results = []; state.results = [];
state.votesRendered = false; state.votesRendered = false;
state.adminVoteStatus = null;
state.adminStatusSelectActive = false; state.adminStatusSelectActive = false;
state.stateEtag = null;
const adminCard = document.getElementById("admin-card"); const adminCard = document.getElementById("admin-card");
if (adminCard) adminCard.classList.add("hidden"); if (adminCard) adminCard.classList.add("hidden");
} }