fix: timer now in sync with backend

This commit is contained in:
Lala, Shahd
2026-05-14 19:13:03 +00:00
parent 1e6cd34f61
commit 3fa687c450
4 changed files with 47 additions and 133 deletions
+6
View File
@@ -1,5 +1,10 @@
export type GameTurn = 'white' | 'black'; export type GameTurn = 'white' | 'black';
export interface ClockState {
whiteRemainingMs: number;
blackRemainingMs: number;
}
export type GameStatus = export type GameStatus =
| 'started' | 'started'
| 'check' | 'check'
@@ -26,6 +31,7 @@ export interface GameState {
moves: string[]; moves: string[];
undoAvailable: boolean; undoAvailable: boolean;
redoAvailable: boolean; redoAvailable: boolean;
clock: ClockState | null;
} }
export interface GameFull { export interface GameFull {
+5 -11
View File
@@ -20,30 +20,24 @@
</p> </p>
</div> </div>
} }
@if (facade.isGameFinished && facade.gameCompletionMessage) {
<div class="game-completion-alert alert alert-success mb-3">
<h2 class="completion-title">{{ facade.gameCompletionMessage }}</h2>
<p class="completion-subtitle mb-0">
<a routerLink="/" class="completion-link">Start a new game</a>
</p>
</div>
}
<div class="container-fluid"> <div class="container-fluid">
<div class="row g-3"> <div class="row g-3">
<!-- Left Sidebar - Dummy Timers --> <!-- Left Sidebar - Timers -->
@if (hasTimer) {
<div class="col-lg-3 col-md-6 col-12 order-lg-1 order-2"> <div class="col-lg-3 col-md-6 col-12 order-lg-1 order-2">
<section class="timer-card"> <section class="timer-card">
<h2>Timers</h2> <h2>Timers</h2>
<div class="player-timer" [class.active-timer]="facade.state.turn === 'white'"> <div class="player-timer" [class.active-timer]="facade.state.turn === 'white'">
<p class="timer-label">White</p> <p class="timer-label">White</p>
<p class="timer-value">{{ formatTimer(whiteTimerSeconds) }}</p> <p class="timer-value">{{ formatTimer(whiteTimerMs) }}</p>
</div> </div>
<div class="player-timer" [class.active-timer]="facade.state.turn === 'black'"> <div class="player-timer" [class.active-timer]="facade.state.turn === 'black'">
<p class="timer-label">Black</p> <p class="timer-label">Black</p>
<p class="timer-value">{{ formatTimer(blackTimerSeconds) }}</p> <p class="timer-value">{{ formatTimer(blackTimerMs) }}</p>
</div> </div>
</section> </section>
</div> </div>
}
<!-- Center - Chess Board --> <!-- Center - Chess Board -->
<div class="col-lg-6 col-md-12 col-12 order-lg-2 order-1"> <div class="col-lg-6 col-md-12 col-12 order-lg-2 order-1">
+31 -122
View File
@@ -8,16 +8,8 @@ import { InputCardComponent } from '../../components/input-card/input-card.compo
import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component'; import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component';
import { GameFacade } from './game.facade'; import { GameFacade } from './game.facade';
type TimerTurn = 'white' | 'black';
type BoardTheme = 'arabian' | 'classic'; type BoardTheme = 'arabian' | 'classic';
interface TimerSnapshot {
whiteSeconds: number;
blackSeconds: number;
turn: TimerTurn;
savedAt: number;
}
@Component({ @Component({
selector: 'app-game', selector: 'app-game',
standalone: true, standalone: true,
@@ -27,26 +19,28 @@ interface TimerSnapshot {
styleUrl: './game.component.css' styleUrl: './game.component.css'
}) })
export class GameComponent implements OnInit, OnDestroy { export class GameComponent implements OnInit, OnDestroy {
private static readonly TIMER_START_SECONDS = 10 * 60;
private static readonly BOARD_THEME_STORAGE_KEY = 'nowchess.boardTheme'; private static readonly BOARD_THEME_STORAGE_KEY = 'nowchess.boardTheme';
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
readonly facade = inject(GameFacade); readonly facade = inject(GameFacade);
whiteTimerSeconds = GameComponent.TIMER_START_SECONDS; whiteTimerMs: number | null = null;
blackTimerSeconds = GameComponent.TIMER_START_SECONDS; blackTimerMs: number | null = null;
exportType: 'fen' | 'pgn' = 'fen'; exportType: 'fen' | 'pgn' = 'fen';
boardTheme: BoardTheme = 'arabian'; boardTheme: BoardTheme = 'arabian';
isDarkMode = false; isDarkMode = false;
exportValue = ''; exportValue = '';
exportNotice = ''; exportNotice = '';
private timerIntervalId: number | null = null; private timerIntervalId: number | null = null;
private activeGameId = '';
get hasTimer(): boolean {
return this.facade.state?.clock != null;
}
ngOnInit(): void { ngOnInit(): void {
this.applyIncomingTheme(); this.applyIncomingTheme();
this.syncThemeFromDocument(); this.syncThemeFromDocument();
this.boardTheme = this.resolveStoredBoardTheme(); this.boardTheme = this.resolveStoredBoardTheme();
this.startDummyTimers(); this.startClock();
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => { this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => {
const id = paramMap.get('gameId'); const id = paramMap.get('gameId');
@@ -56,8 +50,6 @@ export class GameComponent implements OnInit, OnDestroy {
return; return;
} }
this.activeGameId = id;
this.restoreTimers(id);
this.facade.setGameId(id); this.facade.setGameId(id);
this.syncExportValue(); this.syncExportValue();
}); });
@@ -67,8 +59,6 @@ export class GameComponent implements OnInit, OnDestroy {
if (this.timerIntervalId !== null) { if (this.timerIntervalId !== null) {
window.clearInterval(this.timerIntervalId); window.clearInterval(this.timerIntervalId);
} }
this.persistTimers(this.resolveCurrentTurn());
} }
private syncThemeFromDocument(): void { private syncThemeFromDocument(): void {
@@ -122,40 +112,42 @@ export class GameComponent implements OnInit, OnDestroy {
}); });
} }
formatTimer(totalSeconds: number): string { formatTimer(ms: number | null): string {
const safeSeconds = Math.max(0, totalSeconds); if (ms === null) {
const minutes = Math.floor(safeSeconds / 60) return '--:--';
.toString() }
.padStart(2, '0'); if (ms < 0) {
const seconds = (safeSeconds % 60).toString().padStart(2, '0'); return '';
}
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
return `${minutes}:${seconds}`; return `${minutes}:${seconds}`;
} }
private startDummyTimers(): void { private startClock(): void {
if (this.timerIntervalId !== null) { if (this.timerIntervalId !== null) {
return; return;
} }
this.timerIntervalId = window.setInterval(() => this.tickClock(), 200);
this.timerIntervalId = window.setInterval(() => {
this.tickDummyTimers();
this.syncExportValue();
}, 1000);
} }
private tickDummyTimers(): void { private tickClock(): void {
const state = this.facade.state; const state = this.facade.state;
if (!state || this.facade.loading || this.facade.isGameFinished) { const clock = state?.clock;
if (!clock || this.facade.isGameFinished) {
this.whiteTimerMs = null;
this.blackTimerMs = null;
return; return;
} }
if (state.turn === 'white') { const elapsed = Math.max(0, Date.now() - this.facade.clockSyncedAt);
this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - 1); const activeIsWhite = state!.turn === 'white';
this.persistTimers('white'); this.whiteTimerMs =
return; clock.whiteRemainingMs < 0 ? -1 : Math.max(0, clock.whiteRemainingMs - (activeIsWhite ? elapsed : 0));
} this.blackTimerMs =
clock.blackRemainingMs < 0 ? -1 : Math.max(0, clock.blackRemainingMs - (!activeIsWhite ? elapsed : 0));
this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - 1); this.syncExportValue();
this.persistTimers('black');
} }
private syncExportValue(): void { private syncExportValue(): void {
@@ -168,89 +160,6 @@ export class GameComponent implements OnInit, OnDestroy {
this.exportValue = this.exportType === 'fen' ? state.fen : state.pgn; this.exportValue = this.exportType === 'fen' ? state.fen : state.pgn;
} }
private restoreTimers(gameId: string): void {
const fallbackTurn = this.resolveCurrentTurn();
const rawSnapshot = localStorage.getItem(this.getTimerStorageKey(gameId));
if (!rawSnapshot) {
this.resetTimers();
this.persistTimers(fallbackTurn);
return;
}
const snapshot = this.parseSnapshot(rawSnapshot);
if (!snapshot) {
this.resetTimers();
this.persistTimers(fallbackTurn);
return;
}
this.applySnapshot(snapshot);
this.persistTimers(snapshot.turn);
}
private parseSnapshot(rawSnapshot: string): TimerSnapshot | null {
try {
const parsed = JSON.parse(rawSnapshot) as Partial<TimerSnapshot>;
if (
typeof parsed.whiteSeconds !== 'number' ||
typeof parsed.blackSeconds !== 'number' ||
(parsed.turn !== 'white' && parsed.turn !== 'black') ||
typeof parsed.savedAt !== 'number'
) {
return null;
}
return {
whiteSeconds: Math.max(0, Math.floor(parsed.whiteSeconds)),
blackSeconds: Math.max(0, Math.floor(parsed.blackSeconds)),
turn: parsed.turn,
savedAt: parsed.savedAt
};
} catch {
return null;
}
}
private applySnapshot(snapshot: TimerSnapshot): void {
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - snapshot.savedAt) / 1000));
this.whiteTimerSeconds = snapshot.whiteSeconds;
this.blackTimerSeconds = snapshot.blackSeconds;
if (snapshot.turn === 'white') {
this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - elapsedSeconds);
return;
}
this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - elapsedSeconds);
}
private persistTimers(turn: TimerTurn): void {
if (!this.activeGameId) {
return;
}
const snapshot: TimerSnapshot = {
whiteSeconds: this.whiteTimerSeconds,
blackSeconds: this.blackTimerSeconds,
turn,
savedAt: Date.now()
};
localStorage.setItem(this.getTimerStorageKey(this.activeGameId), JSON.stringify(snapshot));
}
private resolveCurrentTurn(): TimerTurn {
return this.facade.state?.turn ?? 'white';
}
private resetTimers(): void {
this.whiteTimerSeconds = GameComponent.TIMER_START_SECONDS;
this.blackTimerSeconds = GameComponent.TIMER_START_SECONDS;
}
private getTimerStorageKey(gameId: string): string {
return `nowchess.timer.${gameId}`;
}
private resolveStoredBoardTheme(): BoardTheme { private resolveStoredBoardTheme(): BoardTheme {
const stored = localStorage.getItem(GameComponent.BOARD_THEME_STORAGE_KEY); const stored = localStorage.getItem(GameComponent.BOARD_THEME_STORAGE_KEY);
return stored === 'classic' ? 'classic' : 'arabian'; return stored === 'classic' ? 'classic' : 'arabian';
+5
View File
@@ -13,6 +13,7 @@ import { GameStreamService } from '../../services/game-stream.service';
export class GameFacade implements OnDestroy { export class GameFacade implements OnDestroy {
gameId = ''; gameId = '';
game: GameFull | null = null; game: GameFull | null = null;
clockSyncedAt = 0;
errorMessage = ''; errorMessage = '';
moveInput = ''; moveInput = '';
fenInput = ''; fenInput = '';
@@ -119,6 +120,7 @@ export class GameFacade implements OnDestroy {
next: (state) => { next: (state) => {
if (this.game) { if (this.game) {
this.game = { ...this.game, state }; this.game = { ...this.game, state };
this.clockSyncedAt = Date.now();
this.updateGameCompletion(); this.updateGameCompletion();
} }
this.moveInput = ''; this.moveInput = '';
@@ -207,6 +209,7 @@ export class GameFacade implements OnDestroy {
.subscribe({ .subscribe({
next: (game) => { next: (game) => {
this.game = game; this.game = game;
this.clockSyncedAt = Date.now();
this.loading = false; this.loading = false;
this.updateGameCompletion(); this.updateGameCompletion();
this.startStreaming(); this.startStreaming();
@@ -232,6 +235,7 @@ export class GameFacade implements OnDestroy {
private applyStreamEvent(event: GameStreamEvent): void { private applyStreamEvent(event: GameStreamEvent): void {
if (event.type === 'gameFull') { if (event.type === 'gameFull') {
this.game = event.game; this.game = event.game;
this.clockSyncedAt = Date.now();
this.boardSelection = this.boardSelectionService.clearSelection(); this.boardSelection = this.boardSelectionService.clearSelection();
this.updateGameCompletion(); this.updateGameCompletion();
this.tryMakeBotMove(); this.tryMakeBotMove();
@@ -241,6 +245,7 @@ export class GameFacade implements OnDestroy {
if (event.type === 'gameState' && this.game) { if (event.type === 'gameState' && this.game) {
const moveCountBefore = this.game.state.moves.length; const moveCountBefore = this.game.state.moves.length;
this.game = { ...this.game, state: event.state }; this.game = { ...this.game, state: event.state };
this.clockSyncedAt = Date.now();
this.updateGameCompletion(); this.updateGameCompletion();
if (event.state.moves.length !== moveCountBefore) { if (event.state.moves.length !== moveCountBefore) {
this.boardSelection = this.boardSelectionService.clearSelection(); this.boardSelection = this.boardSelectionService.clearSelection();