From d26d2b1a0977fb648d4ff29d0e767c6203940104 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 1 Mar 2026 16:55:33 +0000 Subject: [PATCH] feat(player): add reconnect loading and fallback join state (#187) --- .../player/player-shell.component.spec.ts | 68 +++++++++++++++++++ .../features/player/player-shell.component.ts | 59 +++++++++++++++- 2 files changed, 125 insertions(+), 2 deletions(-) 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 363a878..6481e4f 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 @@ -227,4 +227,72 @@ describe('PlayerShellComponent gameplay wiring', () => { expect(component.connectionState).toBe('offline'); expect(component.error).toContain('Session refresh failed'); }); + + it('tracks loading transition message for join action', async () => { + let resolveJoin: ((value: Response) => void) | null = null; + const fetchMock: FetchMock = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveJoin = resolve; + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const component = new PlayerShellComponent(); + component.sessionCode = 'ABCD12'; + component.nickname = 'Luna'; + + const joinPromise = component.joinSession(); + + expect(component.loading).toBe(true); + expect(component.loadingMessage).toBe('Joining session… restoring your player state.'); + + resolveJoin?.(jsonResponse(201, sessionDetailPayload('lobby', { roundQuestionId: null }))); + await joinPromise; + + expect(component.loading).toBe(false); + expect(component.loadingTransition).toBeNull(); + }); + + it('returnToJoin clears persisted session context and transient state', () => { + const values = new Map(); + const localStorage = { + getItem: vi.fn((key: string) => values.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + values.set(key, value); + }), + removeItem: vi.fn((key: string) => { + values.delete(key); + }), + }; + + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + localStorage, + }); + + values.set('wpp.session-context', JSON.stringify({ sessionCode: 'ABCD12', playerId: 9, token: 'tok-1' })); + + const component = new PlayerShellComponent(); + component.sessionCode = 'ABCD12'; + component.playerId = 9; + component.sessionToken = 'tok-1'; + component.error = 'Session refresh failed'; + component.submitError = { kind: 'guess', message: 'Guess submit failed' }; + component.session = { + session: { code: 'ABCD12', status: 'guess', current_round: 1 }, + round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }] }, + players: [], + }; + + component.returnToJoin(); + + expect(component.playerId).toBe(0); + expect(component.sessionToken).toBe(''); + expect(component.session).toBeNull(); + expect(component.error).toBe(''); + expect(component.submitError).toBeNull(); + expect(values.get('wpp.session-context')).toBeUndefined(); + }); }); 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 d1f2b85..11b2f40 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -13,6 +13,14 @@ interface SessionDetail { } type ConnectionState = 'online' | 'reconnecting' | 'offline'; +type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null; + +function resolveLocalStorage(): Storage | undefined { + if (typeof window === 'undefined') { + return undefined; + } + return window.localStorage; +} @Component({ selector: 'app-player-shell', @@ -31,12 +39,16 @@ type ConnectionState = 'online' | 'reconnecting' | 'offline';

Reconnecting… trying to refresh session state. +

You are offline. Reconnect to continue gameplay. +

+

{{ loadingMessage }}

+

Status: {{ session.session.status }}

Prompt: {{ session.round_question.prompt }}

@@ -70,6 +82,11 @@ type ConnectionState = 'online' | 'reconnecting' | 'offline';

{{ error }}

{{ submitError.message }}

+ +
+ + +
`, }) export class PlayerShellComponent implements OnInit, OnDestroy { @@ -85,8 +102,9 @@ export class PlayerShellComponent implements OnInit, OnDestroy { session: SessionDetail | null = null; finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = []; connectionState: ConnectionState = 'online'; + loadingTransition: LoadingTransition = null; - private readonly sessionContextStore = createSessionContextStore(); + private readonly sessionContextStore = createSessionContextStore(resolveLocalStorage()); private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); private reconnectTimer: ReturnType | null = null; @@ -131,7 +149,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { private readonly handleOnline = (): void => { this.connectionState = 'reconnecting'; - this.retryReconnect(); + void this.retryReconnect(); }; private readonly handleOffline = (): void => { @@ -146,6 +164,20 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } } + get loadingMessage(): string { + switch (this.loadingTransition) { + case 'join': + return 'Joining session… restoring your player state.'; + case 'submit-lie': + return 'Submitting lie… waiting for guess phase.'; + case 'submit-guess': + return 'Submitting guess… waiting for reveal.'; + case 'refresh': + default: + return 'Loading latest session state…'; + } + } + private normalizeCode(value: string): string { return value.trim().toUpperCase(); } @@ -200,6 +232,21 @@ export class PlayerShellComponent implements OnInit, OnDestroy { await this.refreshSession(); } + returnToJoin(): void { + this.loadingTransition = null; + this.clearReconnectTimer(); + this.connectionState = typeof navigator !== 'undefined' && !navigator.onLine ? 'offline' : 'online'; + this.session = null; + this.finalLeaderboard = []; + this.selectedGuess = ''; + this.lieText = ''; + this.submitError = null; + this.error = ''; + this.playerId = 0; + this.sessionToken = ''; + this.sessionContextStore.clear(); + } + private syncFinalLeaderboard(): void { if (!this.session || this.session.session.status !== 'finished') { this.finalLeaderboard = []; @@ -235,6 +282,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { async refreshSession(): Promise { this.loading = true; + this.loadingTransition = 'refresh'; this.error = ''; try { const state = await this.controller.hydrateLobby(this.sessionCode); @@ -253,11 +301,13 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.markConnectionIssue(error); } finally { this.loading = false; + this.loadingTransition = null; } } async joinSession(): Promise { this.loading = true; + this.loadingTransition = 'join'; this.error = ''; try { const state = await this.controller.joinLobby(this.sessionCode, this.nickname); @@ -280,6 +330,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.markConnectionIssue(error); } finally { this.loading = false; + this.loadingTransition = null; } } @@ -288,6 +339,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { return; } this.loading = true; + this.loadingTransition = 'submit-lie'; this.submitError = null; try { await this.request( @@ -306,6 +358,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.markConnectionIssue(error); } finally { this.loading = false; + this.loadingTransition = null; } } @@ -314,6 +367,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { return; } this.loading = true; + this.loadingTransition = 'submit-guess'; this.submitError = null; try { await this.request( @@ -332,6 +386,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.markConnectionIssue(error); } finally { this.loading = false; + this.loadingTransition = null; } } }