From fd1fbbf5e792975088bb3bd9fe149f7da32f9c27 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 1 Mar 2026 16:52:11 +0000 Subject: [PATCH] feat(spa): keep player in sync across next-round and document issue-180 flow --- docs/issue-180-flow-log.md | 28 +++++++++++++++ .../player/player-shell.component.spec.ts | 24 +++++++++++++ .../features/player/player-shell.component.ts | 34 +++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 docs/issue-180-flow-log.md diff --git a/docs/issue-180-flow-log.md b/docs/issue-180-flow-log.md new file mode 100644 index 0000000..fae3376 --- /dev/null +++ b/docs/issue-180-flow-log.md @@ -0,0 +1,28 @@ +# Issue #180 – SPA gameplay flow evidence + +## Flow log (host + player, no page reload) + +1. Host opens SPA host shell and loads scoreboard (`GET /lobby/sessions/{code}/scoreboard`). +2. Host starts next round (`POST /lobby/sessions/{code}/rounds/next`). +3. Host shell refreshes session state in-place (`GET /lobby/sessions/{code}`) and clears old scoreboard/final leaderboard payloads. +4. Player shell performs periodic session refresh while online (3s cadence) and transitions from `scoreboard` to `lobby` without page reload. +5. Host finishes game (`POST /lobby/sessions/{code}/finish`) and renders final leaderboard directly in SPA shell. +6. Player shell reads `finished` state and renders final leaderboard in SPA (sorted by score). +7. Error/retry paths available: + - Host: next-round and finish-game retry buttons with explicit error feedback. + - Player: reconnect + submit retry feedback. + +## Test output snapshot + +Command: + +```bash +cd frontend/angular +npm test -- --run src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts +``` + +Result: + +- `host-shell.component.spec.ts`: 6 passed +- `player-shell.component.spec.ts`: 7 passed +- Total: 13 passed, 0 failed diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index 6481e4f..b08b759 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -198,6 +198,30 @@ describe('PlayerShellComponent gameplay wiring', () => { expect(fetchMock).toHaveBeenCalledTimes(3); }); + it('auto-refreshes player session to avoid host/player state desync between rounds', async () => { + vi.useFakeTimers(); + + const fetchMock: FetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: null }))) + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null }))); + + vi.stubGlobal('fetch', fetchMock); + + const component = new PlayerShellComponent(); + component.sessionCode = 'ABCD12'; + + await component.refreshSession(); + expect(component.session?.session.status).toBe('scoreboard'); + + await vi.advanceTimersByTimeAsync(3100); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(component.session?.session.status).toBe('lobby'); + + component.ngOnDestroy(); + }); + it('enters reconnecting state when network request fails while online', async () => { vi.stubGlobal('navigator', { onLine: true }); diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index 11b2f40..b592308 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -107,6 +107,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { private readonly sessionContextStore = createSessionContextStore(resolveLocalStorage()); private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); private reconnectTimer: ReturnType | null = null; + private stateSyncTimer: ReturnType | null = null; constructor() { if (typeof navigator !== 'undefined' && !navigator.onLine) { @@ -145,6 +146,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { window.removeEventListener('offline', this.handleOffline); } this.clearReconnectTimer(); + this.clearStateSyncTimer(); } private readonly handleOnline = (): void => { @@ -155,6 +157,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { private readonly handleOffline = (): void => { this.connectionState = 'offline'; this.clearReconnectTimer(); + this.clearStateSyncTimer(); }; private clearReconnectTimer(): void { @@ -164,6 +167,34 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } } + private clearStateSyncTimer(): void { + if (this.stateSyncTimer) { + clearTimeout(this.stateSyncTimer); + this.stateSyncTimer = null; + } + } + + private scheduleStateSync(): void { + this.clearStateSyncTimer(); + + if (!this.sessionCode.trim() || this.connectionState !== 'online' || !this.session) { + return; + } + + if (this.session.session.status === 'finished') { + return; + } + + this.stateSyncTimer = setTimeout(() => { + this.stateSyncTimer = null; + if (this.loading || this.connectionState !== 'online') { + this.scheduleStateSync(); + return; + } + void this.refreshSession(); + }, 3000); + } + get loadingMessage(): string { switch (this.loadingTransition) { case 'join': @@ -192,9 +223,12 @@ export class PlayerShellComponent implements OnInit, OnDestroy { private markOnline(): void { this.connectionState = 'online'; this.clearReconnectTimer(); + this.scheduleStateSync(); } private markConnectionIssue(error: unknown): void { + this.clearStateSyncTimer(); + if (typeof navigator !== 'undefined' && !navigator.onLine) { this.connectionState = 'offline'; return;