Add event-driven state sync with ETag optimization
This commit is contained in:
3
API.md
3
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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,34 @@ internal sealed class StateWorkflowService(AppDbContext db)
|
||||
{
|
||||
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 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ public class StateTests
|
||||
|
||||
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.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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
31
Infrastructure/StateChangeNotificationMiddleware.cs
Normal file
31
Infrastructure/StateChangeNotificationMiddleware.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
69
Infrastructure/StateChangeNotifier.cs
Normal file
69
Infrastructure/StateChangeNotifier.cs
Normal 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);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ builder.Services.AddScoped<AdminWorkflowService>();
|
||||
builder.Services.AddScoped<ResultsWorkflowService>();
|
||||
builder.Services.AddScoped<StateWorkflowService>();
|
||||
builder.Services.AddSingleton<AuthAttemptMonitor>();
|
||||
builder.Services.AddSingleton<StateChangeNotifier>();
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
|
||||
|
||||
@@ -152,6 +153,7 @@ app.UseGlobalExceptionLogging();
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<EnsurePlayerExistsMiddleware>();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<StateChangeNotificationMiddleware>();
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
@@ -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.
|
||||
|
||||
2
TESTS.md
2
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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user