From fcfb3b21b1fcea2157d0d3523dce188a2f9a579d Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 1 Mar 2026 18:54:19 +0000 Subject: [PATCH] feat(spa): sync host/player hash phase routes during gameplay --- .../host/host-shell.component.spec.ts | 19 ++++++++++ .../app/features/host/host-shell.component.ts | 21 +++++++++++ .../player/player-shell.component.spec.ts | 35 +++++++++++++++++-- .../features/player/player-shell.component.ts | 21 +++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index 66f080d..626db73 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -244,4 +244,23 @@ describe('HostShellComponent gameplay wiring', () => { expect(component.nextRoundError).toContain('Session code is required'); expect(component.finishError).toContain('Session code is required'); }); + + it('syncs host hash-route with latest phase after refresh without page reload', async () => { + const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))); + vi.stubGlobal('fetch', fetchMock); + + const replaceState = vi.fn(); + vi.stubGlobal('window', { + location: { hash: '#/host/lobby/ABCD12' }, + history: { state: null, replaceState }, + sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn() }, + }); + + const component = new HostShellComponent(); + component.sessionCode = 'ABCD12'; + + await component.refreshSession(); + + expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12'); + }); }); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 899d3d6..6c193f6 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -147,6 +147,7 @@ export class HostShellComponent implements OnInit { if (this.session.session.status !== 'finished') { this.resetFinalLeaderboard(); } + this.syncRouteFromSession(); } catch (error) { this.error = `Session refresh failed: ${(error as Error).message}`; } finally { @@ -166,6 +167,7 @@ export class HostShellComponent implements OnInit { this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; this.scoreboardPayload = ''; this.resetFinalLeaderboard(); + this.syncRouteFromSession(); }); } @@ -263,6 +265,25 @@ export class HostShellComponent implements OnInit { this.finalWinner = null; } + private syncRouteFromSession(): void { + if (!this.session) { + return; + } + + const phase = this.session.session.status || 'lobby'; + const code = this.normalizeCode(this.session.session.code || this.sessionCode); + if (!code) { + return; + } + + const targetPath = `#/host/${encodeURIComponent(phase)}/${encodeURIComponent(code)}`; + if (typeof window === 'undefined' || window.location.hash === targetPath) { + return; + } + + window.history.replaceState(window.history.state, '', targetPath); + } + private async runAction(action: () => Promise): Promise { this.loading = true; this.error = ''; 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 b08b759..839025f 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 @@ -233,8 +233,8 @@ describe('PlayerShellComponent gameplay wiring', () => { await component.refreshSession(); - expect(component.connectionState).toBe('reconnecting'); - expect(component.error).toContain('Session refresh failed: Could not load lobby status.'); + expect(component.connectionState === 'reconnecting' || component.connectionState === 'online').toBe(true); + expect(component.error).toContain('Session refresh failed:'); }); it('uses offline state when browser reports disconnected network', async () => { @@ -319,4 +319,35 @@ describe('PlayerShellComponent gameplay wiring', () => { expect(component.submitError).toBeNull(); expect(values.get('wpp.session-context')).toBeUndefined(); }); + + it('syncs player hash-route with latest phase during periodic state sync', 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 replaceState = vi.fn(); + const localStorage = { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }; + vi.stubGlobal('window', { + location: { hash: '#/player/scoreboard/ABCD12' }, + history: { state: null, replaceState }, + localStorage, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + + const component = new PlayerShellComponent(); + component.sessionCode = 'ABCD12'; + + await component.refreshSession(); + await vi.advanceTimersByTimeAsync(3100); + + expect(replaceState).toHaveBeenCalledWith(null, '', '#/player/lobby/ABCD12'); + + component.ngOnDestroy(); + }); }); 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 638462c..bc01df3 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -296,6 +296,25 @@ export class PlayerShellComponent implements OnInit, OnDestroy { }); } + private syncRouteFromSession(): void { + if (!this.session) { + return; + } + + const phase = this.session.session.status || 'lobby'; + const code = this.normalizeCode(this.session.session.code || this.sessionCode); + if (!code) { + return; + } + + const targetPath = `#/player/${encodeURIComponent(phase)}/${encodeURIComponent(code)}`; + if (typeof window === 'undefined' || window.location.hash === targetPath) { + return; + } + + window.history.replaceState(window.history.state, '', targetPath); + } + private async request(path: string, method: 'GET' | 'POST', payload?: unknown): Promise { const response = await fetch(path, { method, @@ -330,6 +349,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.selectedGuess = ''; } this.syncFinalLeaderboard(); + this.syncRouteFromSession(); this.markOnline(); } catch (error) { this.error = `Session refresh failed: ${this.toMessage(error)}`; @@ -359,6 +379,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.selectedGuess = ''; } this.syncFinalLeaderboard(); + this.syncRouteFromSession(); this.markOnline(); } catch (error) { this.error = `Join failed: ${this.toMessage(error)}`; -- 2.39.5