From 634bd617e7135ef45424bd9525d8709f1f8b4b35 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 1 Mar 2026 15:31:23 +0000 Subject: [PATCH] feat(spa): render final leaderboard summary in host shell --- ...A-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md | 2 +- .../host/host-shell.component.spec.ts | 13 +++++- .../app/features/host/host-shell.component.ts | 40 ++++++++++++++++--- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/docs/ISSUE-180-SPA-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md b/docs/ISSUE-180-SPA-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md index 80758ee..cffc004 100644 --- a/docs/ISSUE-180-SPA-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md +++ b/docs/ISSUE-180-SPA-NEXT-ROUND-FINAL-LEADERBOARD-EVIDENCE.md @@ -3,7 +3,7 @@ ## Flow log (Angular SPA) 1. Host reaches `reveal` phase and runs `loadScoreboard()` (`GET /lobby/sessions/:code/scoreboard`). 2. Host can start next round directly in SPA via `startNextRound()` (`POST /lobby/sessions/:code/rounds/next`) and then session hydrate (`GET /lobby/sessions/:code`) without page reload. -3. Host can finish game directly in SPA via `finishGame()` (`POST /lobby/sessions/:code/finish`), rendering final leaderboard payload in the same shell. +3. Host can finish game directly in SPA via `finishGame()` (`POST /lobby/sessions/:code/finish`), rendering winner + sorted final leaderboard (plus raw payload for debug) in the same shell. 4. Player SPA renders final leaderboard from refreshed finished-session payload (sorted by score desc, nickname tiebreak) without leaving SPA route. 5. Error/retry paths are implemented and covered: - scoreboard failure -> `scoreboardError` + retry button 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 53fdaf6..3a2156d 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 @@ -135,6 +135,8 @@ describe('HostShellComponent gameplay wiring', () => { const component = new HostShellComponent(); component.sessionCode = ' abcd12 '; component.scoreboardPayload = '{"leaderboard":[]}'; + component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}'; + component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }]; await component.startNextRound(); @@ -150,6 +152,8 @@ describe('HostShellComponent gameplay wiring', () => { ); expect(component.session?.session.status).toBe('lobby'); expect(component.scoreboardPayload).toBe(''); + expect(component.finalLeaderboardPayload).toBe(''); + expect(component.finalLeaderboard).toEqual([]); expect(component.nextRoundError).toBe(''); }); @@ -161,7 +165,10 @@ describe('HostShellComponent gameplay wiring', () => { jsonResponse(200, { session: { code: 'ABCD12', status: 'finished', current_round: 2 }, winner: { id: 1, nickname: 'Luna', score: 320 }, - leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }], + leaderboard: [ + { id: 2, nickname: 'Mads', score: 120 }, + { id: 1, nickname: 'Luna', score: 320 }, + ], }) ) .mockResolvedValueOnce( @@ -189,6 +196,8 @@ describe('HostShellComponent gameplay wiring', () => { expect.objectContaining({ method: 'POST', body: JSON.stringify({}) }) ); expect(component.finishError).toBe(''); - expect(component.finalLeaderboardPayload).toContain('"status": "finished"'); + expect(component.finalLeaderboardPayload).toContain('\"status\": \"finished\"'); + expect(component.finalWinner?.nickname).toBe('Luna'); + expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']); }); }); 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 b14efcd..12f4927 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -11,10 +11,16 @@ interface SessionDetail { players: Array<{ id: number; nickname: string; score: number }>; } +interface LeaderboardEntry { + id: number; + nickname: string; + score: number; +} + interface LeaderboardResponse { session: { code: string; status: string; current_round: number }; - leaderboard: Array<{ id: number; nickname: string; score: number }>; - winner?: { id: number; nickname: string; score: number } | null; + leaderboard: LeaderboardEntry[]; + winner?: LeaderboardEntry | null; } @Component({ @@ -53,6 +59,13 @@ interface LeaderboardResponse {
  • {{ p.nickname }}: {{ p.score }}
  • {{ scoreboardPayload }}
    +
    +

    Final leaderboard

    +

    Winner: {{ finalWinner.nickname }} ({{ finalWinner.score }} pts)

    +
      +
    1. {{ entry.nickname }}: {{ entry.score }}
    2. +
    +
    {{ finalLeaderboardPayload }}
    `, @@ -68,6 +81,8 @@ export class HostShellComponent { finishError = ''; scoreboardPayload = ''; finalLeaderboardPayload = ''; + finalLeaderboard: LeaderboardEntry[] = []; + finalWinner: LeaderboardEntry | null = null; session: SessionDetail | null = null; private readonly controller = createVerticalSliceController(createApiClient()); @@ -110,7 +125,7 @@ export class HostShellComponent { this.sessionCode = this.session.session.code; this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; if (this.session.session.status !== 'finished') { - this.finalLeaderboardPayload = ''; + this.resetFinalLeaderboard(); } } catch (error) { this.error = `Session refresh failed: ${(error as Error).message}`; @@ -129,7 +144,7 @@ export class HostShellComponent { this.sessionCode = this.session.session.code; this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; this.scoreboardPayload = ''; - this.finalLeaderboardPayload = ''; + this.resetFinalLeaderboard(); }); } @@ -183,7 +198,7 @@ export class HostShellComponent { const code = this.normalizeCode(this.sessionCode); await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {}); this.scoreboardPayload = ''; - this.finalLeaderboardPayload = ''; + this.resetFinalLeaderboard(); await this.refreshSession(); } catch (error) { this.nextRoundError = `Next round failed: ${(error as Error).message}`; @@ -200,6 +215,13 @@ export class HostShellComponent { const code = this.normalizeCode(this.sessionCode); const payload = await this.request(`/lobby/sessions/${encodeURIComponent(code)}/finish`, 'POST', {}); this.finalLeaderboardPayload = JSON.stringify(payload, null, 2); + this.finalLeaderboard = [...payload.leaderboard].sort((a, b) => { + if (b.score !== a.score) { + return b.score - a.score; + } + return a.nickname.localeCompare(b.nickname); + }); + this.finalWinner = payload.winner ?? this.finalLeaderboard[0] ?? null; await this.refreshSession(); } catch (error) { this.finishError = `Finish game failed: ${(error as Error).message}`; @@ -208,6 +230,14 @@ export class HostShellComponent { } } + + + private resetFinalLeaderboard(): void { + this.finalLeaderboardPayload = ''; + this.finalLeaderboard = []; + this.finalWinner = null; + } + private async runAction(action: () => Promise): Promise { this.loading = true; this.error = ''; -- 2.39.5