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 363a878..ff3e0c8 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 d1f2b85..c2338db 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,7 @@ interface SessionDetail {
}
type ConnectionState = 'online' | 'reconnecting' | 'offline';
+type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null;
@Component({
selector: 'app-player-shell',
@@ -31,12 +32,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 +75,11 @@ type ConnectionState = 'online' | 'reconnecting' | 'offline';
{{ error }}
{{ submitError.message }}
+
+
+
+
+
`,
})
export class PlayerShellComponent implements OnInit, OnDestroy {
@@ -85,10 +95,12 @@ 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 controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
private reconnectTimer: ReturnType
| null = null;
+ private stateSyncTimer: ReturnType | null = null;
constructor() {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
@@ -127,6 +139,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
window.removeEventListener('offline', this.handleOffline);
}
this.clearReconnectTimer();
+ this.clearStateSyncTimer();
}
private readonly handleOnline = (): void => {
@@ -137,6 +150,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
private readonly handleOffline = (): void => {
this.connectionState = 'offline';
this.clearReconnectTimer();
+ this.clearStateSyncTimer();
};
private clearReconnectTimer(): void {
@@ -146,6 +160,48 @@ 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':
+ 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();
}
@@ -160,9 +216,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;
@@ -200,6 +259,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 +309,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 +328,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 +357,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.markConnectionIssue(error);
} finally {
this.loading = false;
+ this.loadingTransition = null;
}
}