feat(player): add reconnect/offline states in angular gameplay flow
CI / test-and-quality (push) Successful in 2m18s
CI / test-and-quality (pull_request) Successful in 2m29s

This commit is contained in:
2026-03-01 16:00:53 +00:00
parent 386ac5b7c1
commit f3ea19fcd7
2 changed files with 234 additions and 33 deletions
@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client';
@@ -12,6 +12,8 @@ interface SessionDetail {
players: Array<{ id: number; nickname: string; score: number }>;
}
type ConnectionState = 'online' | 'reconnecting' | 'offline';
@Component({
selector: 'app-player-shell',
standalone: true,
@@ -26,6 +28,15 @@ interface SessionDetail {
<button (click)="joinSession()" [disabled]="loading">Join</button>
</div>
<p *ngIf="connectionState === 'reconnecting'" class="error">
Reconnecting… trying to refresh session state.
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry now</button>
</p>
<p *ngIf="connectionState === 'offline'" class="error">
You are offline. Reconnect to continue gameplay.
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry now</button>
</p>
<div class="panel" *ngIf="session">
<p><strong>Status:</strong> {{ session.session.status }}</p>
<p *ngIf="session.round_question"><strong>Prompt:</strong> {{ session.round_question.prompt }}</p>
@@ -61,7 +72,7 @@ interface SessionDetail {
<p *ngIf="submitError" class="error">{{ submitError.message }}</p>
`,
})
export class PlayerShellComponent {
export class PlayerShellComponent implements OnInit, OnDestroy {
sessionCode = '';
nickname = '';
playerId = 0;
@@ -73,14 +84,122 @@ export class PlayerShellComponent {
submitError: { kind: 'lie' | 'guess'; message: string } | null = null;
session: SessionDetail | null = null;
finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = [];
connectionState: ConnectionState = 'online';
private readonly sessionContextStore = createSessionContextStore();
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
this.connectionState = 'offline';
}
if (typeof window !== 'undefined') {
window.addEventListener('online', this.handleOnline);
window.addEventListener('offline', this.handleOffline);
}
}
ngOnInit(): void {
const hashRoute = window.location.hash.replace(/^#\/?/, '');
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
const codeFromRoute = match?.[1] ?? '';
const persistedContext = this.sessionContextStore.get();
if (persistedContext) {
this.playerId = persistedContext.playerId;
this.sessionToken = persistedContext.token;
}
const candidate = codeFromRoute || persistedContext?.sessionCode || '';
if (!candidate) {
return;
}
this.sessionCode = this.normalizeCode(candidate);
void this.refreshSession();
}
ngOnDestroy(): void {
if (typeof window !== 'undefined') {
window.removeEventListener('online', this.handleOnline);
window.removeEventListener('offline', this.handleOffline);
}
this.clearReconnectTimer();
}
private readonly handleOnline = (): void => {
this.connectionState = 'reconnecting';
this.retryReconnect();
};
private readonly handleOffline = (): void => {
this.connectionState = 'offline';
this.clearReconnectTimer();
};
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
private normalizeCode(value: string): string {
return value.trim().toUpperCase();
}
private toMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
return 'Unknown error';
}
private markOnline(): void {
this.connectionState = 'online';
this.clearReconnectTimer();
}
private markConnectionIssue(error: unknown): void {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
this.connectionState = 'offline';
return;
}
const message = this.toMessage(error).toLowerCase();
if (
message.includes('fetch') ||
message.includes('network') ||
message.includes('failed to') ||
message.includes('could not load lobby status') ||
message.includes('session refresh failed')
) {
this.connectionState = 'reconnecting';
this.scheduleReconnect();
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer || !this.sessionCode.trim()) {
return;
}
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
void this.retryReconnect();
}, 2000);
}
async retryReconnect(): Promise<void> {
if (!this.sessionCode.trim() || this.loading) {
return;
}
await this.refreshSession();
}
private syncFinalLeaderboard(): void {
if (!this.session || this.session.session.status !== 'finished') {
this.finalLeaderboard = [];
@@ -128,8 +247,10 @@ export class PlayerShellComponent {
this.selectedGuess = '';
}
this.syncFinalLeaderboard();
this.markOnline();
} catch (error) {
this.error = `Session refresh failed: ${(error as Error).message}`;
this.error = `Session refresh failed: ${this.toMessage(error)}`;
this.markConnectionIssue(error);
} finally {
this.loading = false;
}
@@ -153,8 +274,10 @@ export class PlayerShellComponent {
this.selectedGuess = '';
}
this.syncFinalLeaderboard();
this.markOnline();
} catch (error) {
this.error = `Join failed: ${(error as Error).message}`;
this.error = `Join failed: ${this.toMessage(error)}`;
this.markConnectionIssue(error);
} finally {
this.loading = false;
}
@@ -177,8 +300,10 @@ export class PlayerShellComponent {
}
);
await this.refreshSession();
this.markOnline();
} catch (error) {
this.submitError = { kind: 'lie', message: `Lie submit failed: ${(error as Error).message}` };
this.submitError = { kind: 'lie', message: `Lie submit failed: ${this.toMessage(error)}` };
this.markConnectionIssue(error);
} finally {
this.loading = false;
}
@@ -201,8 +326,10 @@ export class PlayerShellComponent {
}
);
await this.refreshSession();
this.markOnline();
} catch (error) {
this.submitError = { kind: 'guess', message: `Guess submit failed: ${(error as Error).message}` };
this.submitError = { kind: 'guess', message: `Guess submit failed: ${this.toMessage(error)}` };
this.markConnectionIssue(error);
} finally {
this.loading = false;
}