add prediction scoring implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 21:45:17 +01:00
parent f0dc35610e
commit 8372769c9b

View File

@@ -0,0 +1,866 @@
# Prediction Scoring Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow the host to enter actual ESC results and score player predictions against them.
**Architecture:** Extend `GameManager` with actual results storage and prediction scoring. Add a new WS message type `submit_actual_results`. Extend `GameState`, `LeaderboardEntry` schemas with prediction data. Add a host-side form and player-side result indicators.
**Tech Stack:** Zod, Hono WebSocket, React, shadcn/ui, Vitest
---
## File Structure
| Action | File | Responsibility |
|--------|------|---------------|
| Modify | `packages/shared/src/game-types.ts` | Add `actualResultsSchema`, `predictionPoints` to leaderboard, `actualResults` to game state |
| Modify | `packages/shared/src/ws-messages.ts` | Add `submit_actual_results` client message |
| Modify | `packages/server/src/games/game-manager.ts` | `setActualResults`, `getPredictionScore`, update `buildLeaderboard` |
| Modify | `packages/server/src/ws/handler.ts` | Handle `submit_actual_results` |
| Modify | `packages/server/tests/game-manager.test.ts` | Tests for prediction scoring |
| Create | `packages/client/src/components/actual-results-form.tsx` | Host form to enter actual ESC results |
| Modify | `packages/client/src/components/predictions-form.tsx` | Show correct/incorrect markers when results are in |
| Modify | `packages/client/src/components/leaderboard.tsx` | Add P: column, update scoring explanation |
| Modify | `packages/client/src/routes/host.$roomCode.tsx` | Show `ActualResultsForm` in scoring/ended |
| Modify | `packages/client/src/routes/play.$roomCode.tsx` | Pass `actualResults` to predictions form in scoring/ended |
| Modify | `packages/client/src/routes/display.$roomCode.tsx` | Show actual results summary |
---
## Chunk 1: Prediction Scoring
### Task 1: Extend shared types
**Files:**
- Modify: `packages/shared/src/game-types.ts`
- [ ] **Step 1: Add `actualResultsSchema` and extend `LeaderboardEntry` and `GameState`**
In `packages/shared/src/game-types.ts`, add after the `Prediction` type block:
```ts
// ─── Actual Results ─────────────────────────────────────────────────
export const actualResultsSchema = z.object({
winner: z.string(),
second: z.string(),
third: z.string(),
last: z.string(),
})
export type ActualResults = z.infer<typeof actualResultsSchema>
```
Update `leaderboardEntrySchema` to add `predictionPoints`:
```ts
export const leaderboardEntrySchema = z.object({
playerId: z.string(),
displayName: z.string(),
juryPoints: z.number(),
bingoPoints: z.number(),
predictionPoints: z.number(),
totalPoints: z.number(),
})
```
Update `gameStateSchema` to add `actualResults`:
```ts
export const gameStateSchema = z.object({
lineup: lineupSchema,
myPrediction: predictionSchema.nullable(),
predictionsLocked: z.boolean(),
predictionSubmitted: z.record(z.string(), z.boolean()),
// Jury
currentJuryRound: juryRoundSchema.nullable(),
juryResults: z.array(juryResultSchema),
myJuryVote: z.number().nullable(),
// Bingo
myBingoCard: bingoCardSchema.nullable(),
bingoAnnouncements: z.array(z.object({
playerId: z.string(),
displayName: z.string(),
})),
// Predictions
actualResults: actualResultsSchema.nullable(),
// Leaderboard
leaderboard: z.array(leaderboardEntrySchema),
})
```
- [ ] **Step 2: Verify build**
Run: `bun run --filter './packages/shared' build 2>&1 || echo 'no build script, check tsc'`
Expected: No type errors in shared package
- [ ] **Step 3: Commit**
```bash
git add packages/shared/src/game-types.ts
git commit -m "add actual results schema, prediction points to leaderboard and game state"
```
### Task 2: Add WS message type
**Files:**
- Modify: `packages/shared/src/ws-messages.ts`
- [ ] **Step 1: Add `submitActualResultsMessage`**
After the `tapBingoSquareMessage` definition, add:
```ts
export const submitActualResultsMessage = z.object({
type: z.literal("submit_actual_results"),
winner: z.string(),
second: z.string(),
third: z.string(),
last: z.string(),
})
```
Add it to the `clientMessage` discriminated union array:
```ts
export const clientMessage = z.discriminatedUnion("type", [
joinRoomMessage,
reconnectMessage,
advanceActMessage,
revertActMessage,
endRoomMessage,
submitPredictionMessage,
openJuryVoteMessage,
closeJuryVoteMessage,
submitJuryVoteMessage,
tapBingoSquareMessage,
submitActualResultsMessage,
])
```
- [ ] **Step 2: Commit**
```bash
git add packages/shared/src/ws-messages.ts
git commit -m "add submit_actual_results WS message type"
```
### Task 3: Add prediction scoring to GameManager — tests first
**Files:**
- Modify: `packages/server/tests/game-manager.test.ts`
- [ ] **Step 1: Write failing tests for prediction scoring**
Add a new `describe("prediction scoring")` block at the end of the test file:
```ts
describe("prediction scoring", () => {
it("returns 0 for all when no actual results set", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
expect(gm.getPredictionScore("p1")).toBe(0)
})
it("scores correct winner", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
gm.setActualResults("SE", "CH", "DE", "AL")
expect(gm.getPredictionScore("p1")).toBe(25) // prediction_winner
})
it("scores correct second place", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "XX", "IT", "FR", "UK")
gm.setActualResults("SE", "IT", "DE", "AL")
expect(gm.getPredictionScore("p1")).toBe(10) // prediction_top3
})
it("scores correct third place", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "XX", "YY", "FR", "UK")
gm.setActualResults("SE", "IT", "FR", "AL")
expect(gm.getPredictionScore("p1")).toBe(10) // prediction_top3
})
it("scores correct last place", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "XX", "YY", "ZZ", "UK")
gm.setActualResults("SE", "IT", "FR", "UK")
expect(gm.getPredictionScore("p1")).toBe(15) // prediction_nul_points
})
it("scores perfect prediction (all correct)", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
gm.setActualResults("SE", "IT", "FR", "UK")
expect(gm.getPredictionScore("p1")).toBe(60) // 25 + 10 + 10 + 15
})
it("scores 0 for all wrong", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "AA", "BB", "CC", "DD")
gm.setActualResults("SE", "IT", "FR", "UK")
expect(gm.getPredictionScore("p1")).toBe(0)
})
it("returns 0 for player with no prediction", () => {
const gm = new GameManager()
gm.setActualResults("SE", "IT", "FR", "UK")
expect(gm.getPredictionScore("p1")).toBe(0)
})
it("getActualResults returns null before setting", () => {
const gm = new GameManager()
expect(gm.getActualResults()).toBeNull()
})
it("getActualResults returns results after setting", () => {
const gm = new GameManager()
gm.setActualResults("SE", "IT", "FR", "UK")
expect(gm.getActualResults()).toEqual({ winner: "SE", second: "IT", third: "FR", last: "UK" })
})
it("setActualResults overwrites previous results", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
gm.setActualResults("AA", "BB", "CC", "DD")
expect(gm.getPredictionScore("p1")).toBe(0)
gm.setActualResults("SE", "IT", "FR", "UK")
expect(gm.getPredictionScore("p1")).toBe(60)
})
it("prediction points appear in leaderboard", () => {
const gm = new GameManager()
gm.submitPrediction("p1", "SE", "IT", "FR", "UK")
gm.setActualResults("SE", "IT", "FR", "UK")
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
expect(state.leaderboard[0]!.predictionPoints).toBe(60)
expect(state.leaderboard[0]!.totalPoints).toBe(60)
})
it("actualResults included in game state", () => {
const gm = new GameManager()
gm.setActualResults("SE", "IT", "FR", "UK")
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
expect(state.actualResults).toEqual({ winner: "SE", second: "IT", third: "FR", last: "UK" })
})
it("actualResults null in game state when not set", () => {
const gm = new GameManager()
const state = gm.getGameStateForPlayer("p1", ["p1"], { p1: "Alice" })
expect(state.actualResults).toBeNull()
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `bun test`
Expected: New tests FAIL (methods don't exist yet)
### Task 4: Implement prediction scoring in GameManager
**Files:**
- Modify: `packages/server/src/games/game-manager.ts`
- [ ] **Step 1: Add actual results storage and scoring methods**
Add a new private field after `private bingoAnnouncements`:
```ts
// ─── Prediction Scoring ─────────────────────────────────────────
private actualResults: { winner: string; second: string; third: string; last: string } | null = null
```
Add methods after `getBingoScore`:
```ts
setActualResults(winner: string, second: string, third: string, last: string): void {
this.actualResults = { winner, second, third, last }
}
getActualResults(): { winner: string; second: string; third: string; last: string } | null {
return this.actualResults
}
getPredictionScore(playerId: string): number {
if (!this.actualResults) return 0
const prediction = this.predictions.get(playerId)
if (!prediction) return 0
let score = 0
if (prediction.first === this.actualResults.winner) score += scoringConfig.prediction_winner
if (prediction.second === this.actualResults.second) score += scoringConfig.prediction_top3
if (prediction.third === this.actualResults.third) score += scoringConfig.prediction_top3
if (prediction.last === this.actualResults.last) score += scoringConfig.prediction_nul_points
return score
}
```
- [ ] **Step 2: Update `buildLeaderboard` to include prediction points**
Change the `buildLeaderboard` method's return type and body:
```ts
private buildLeaderboard(
playerIds: string[],
displayNames: Record<string, string>,
): { playerId: string; displayName: string; juryPoints: number; bingoPoints: number; predictionPoints: number; totalPoints: number }[] {
return playerIds
.map((id) => {
const juryPoints = this.getJuryScore(id)
const bingoPoints = this.getBingoScore(id)
const predictionPoints = this.getPredictionScore(id)
return {
playerId: id,
displayName: displayNames[id] ?? "Unknown",
juryPoints,
bingoPoints,
predictionPoints,
totalPoints: juryPoints + bingoPoints + predictionPoints,
}
})
.sort((a, b) => b.totalPoints - a.totalPoints)
}
```
- [ ] **Step 3: Add `actualResults` to both game state builder methods**
In `getGameStateForPlayer`, add `actualResults: this.actualResults,` after the `bingoAnnouncements` line.
In `getGameStateForDisplay`, add `actualResults: this.actualResults,` after the `bingoAnnouncements` line.
- [ ] **Step 4: Run tests to verify they pass**
Run: `bun test`
Expected: All tests PASS (60 existing + 13 new = 73)
- [ ] **Step 5: Commit**
```bash
git add packages/server/src/games/game-manager.ts packages/server/tests/game-manager.test.ts
git commit -m "add prediction scoring to GameManager with tests"
```
### Task 5: Add WS handler for submit_actual_results
**Files:**
- Modify: `packages/server/src/ws/handler.ts`
- [ ] **Step 1: Add handler case**
In the `switch (msg.type)` block, add a new case before the closing `}` of the switch (after `tap_bingo_square`):
```ts
case "submit_actual_results": {
if (!sessionId) {
sendError(ws, "Not joined")
return
}
const room = roomManager.getRoom(roomCode)
if (room?.currentAct !== "scoring" && room?.currentAct !== "ended") {
sendError(ws, "Results can only be entered during Scoring or Ended")
return
}
if (!roomManager.isHost(roomCode, sessionId)) {
sendError(ws, "Only the host can enter actual results")
return
}
const gm = roomManager.getGameManager(roomCode)
if (!gm) {
sendError(ws, "Room not found")
return
}
const allPicks = [msg.winner, msg.second, msg.third, msg.last]
for (const code of allPicks) {
if (!gm.isValidCountry(code)) {
sendError(ws, `Invalid country: ${code}`)
return
}
}
if (new Set(allPicks).size !== 4) {
sendError(ws, "All 4 picks must be different countries")
return
}
gm.setActualResults(msg.winner, msg.second, msg.third, msg.last)
broadcastGameStateToAll(roomCode)
break
}
```
- [ ] **Step 2: Run tests and verify client builds**
Run: `bun test`
Expected: All 73 tests pass
Run: `bun run --filter './packages/client' build`
Expected: Build succeeds (client doesn't use the new types yet, but shared types must compile)
- [ ] **Step 3: Commit**
```bash
git add packages/server/src/ws/handler.ts
git commit -m "add submit_actual_results WS handler with validation"
```
### Task 6: Create ActualResultsForm component
**Files:**
- Create: `packages/client/src/components/actual-results-form.tsx`
- [ ] **Step 1: Create the component**
This reuses the same slot-picker pattern as `PredictionsForm`. Create `packages/client/src/components/actual-results-form.tsx`:
```tsx
import { useState } from "react"
import type { Entry, ActualResults } from "@celebrate-esc/shared"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
type SlotKey = "winner" | "second" | "third" | "last"
const SLOTS: { key: SlotKey; label: string }[] = [
{ key: "winner", label: "Winner" },
{ key: "second", label: "2nd Place" },
{ key: "third", label: "3rd Place" },
{ key: "last", label: "Last Place" },
]
function formatEntry(entry: Entry): string {
return `${entry.country.flag} ${entry.artist}${entry.song}`
}
interface ActualResultsFormProps {
entries: Entry[]
existingResults: ActualResults | null
onSubmit: (results: { winner: string; second: string; third: string; last: string }) => void
}
export function ActualResultsForm({ entries, existingResults, onSubmit }: ActualResultsFormProps) {
const [slots, setSlots] = useState<Record<SlotKey, string | null>>(() => {
if (existingResults) {
return {
winner: existingResults.winner,
second: existingResults.second,
third: existingResults.third,
last: existingResults.last,
}
}
return { winner: null, second: null, third: null, last: null }
})
const [pickerForEntry, setPickerForEntry] = useState<string | null>(null)
const assignedCodes = new Set(Object.values(slots).filter(Boolean))
const emptySlots = SLOTS.filter((s) => !slots[s.key])
const allFilled = SLOTS.every((s) => slots[s.key])
function findEntry(code: string): Entry | undefined {
return entries.find((e) => e.country.code === code)
}
function assignToSlot(entryCode: string, slotKey: SlotKey) {
setSlots((prev) => ({ ...prev, [slotKey]: entryCode }))
setPickerForEntry(null)
}
function removeFromSlot(slotKey: SlotKey) {
setSlots((prev) => ({ ...prev, [slotKey]: null }))
}
return (
<Card>
<CardHeader>
<CardTitle>Actual Results</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
{SLOTS.map((slot) => {
const code = slots[slot.key]
const entry = code ? findEntry(code) : null
return (
<div
key={slot.key}
className={`flex items-center justify-between rounded-md border p-2 ${
code ? "border-primary/30 bg-primary/5" : "border-dashed"
}`}
>
<div className="flex items-center gap-2">
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
{entry ? (
<span className="text-sm">{formatEntry(entry)}</span>
) : (
<span className="text-sm text-muted-foreground">Tap an entry below</span>
)}
</div>
{code && (
<button
type="button"
onClick={() => removeFromSlot(slot.key)}
className="text-muted-foreground hover:text-foreground"
aria-label={`Remove ${slot.label}`}
>
</button>
)}
</div>
)
})}
</div>
{allFilled && (
<Button
onClick={() =>
onSubmit({
winner: slots.winner!,
second: slots.second!,
third: slots.third!,
last: slots.last!,
})
}
>
{existingResults ? "Update Results" : "Submit Results"}
</Button>
)}
<div className="flex flex-col gap-1">
<h4 className="text-sm font-medium text-muted-foreground">Entries</h4>
{entries.map((entry) => {
const isAssigned = assignedCodes.has(entry.country.code)
const isPickerOpen = pickerForEntry === entry.country.code
return (
<div key={entry.country.code}>
<button
type="button"
disabled={isAssigned}
onClick={() => {
if (emptySlots.length === 1) {
assignToSlot(entry.country.code, emptySlots[0]!.key)
} else {
setPickerForEntry(isPickerOpen ? null : entry.country.code)
}
}}
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
isAssigned
? "border-transparent bg-muted/50 text-muted-foreground line-through opacity-50"
: isPickerOpen
? "border-primary bg-primary/5"
: "hover:bg-muted"
}`}
>
{formatEntry(entry)}
</button>
{isPickerOpen && !isAssigned && (
<div className="mt-1 ml-4 flex gap-1">
{emptySlots.map((slot) => (
<button
type="button"
key={slot.key}
onClick={() => assignToSlot(entry.country.code, slot.key)}
className="rounded-md border px-2 py-1 text-xs hover:bg-primary hover:text-primary-foreground"
>
{slot.label}
</button>
))}
</div>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
)
}
```
- [ ] **Step 2: Commit**
```bash
git add packages/client/src/components/actual-results-form.tsx
git commit -m "add ActualResultsForm component"
```
### Task 7: Update PredictionsForm to show correct/incorrect markers
**Files:**
- Modify: `packages/client/src/components/predictions-form.tsx`
- [ ] **Step 1: Add `actualResults` prop and markers**
Update the `PredictionsFormProps` interface to add:
```ts
import type { Entry, Prediction, ActualResults } from "@celebrate-esc/shared"
interface PredictionsFormProps {
entries: Entry[]
existingPrediction: Prediction | null
locked: boolean
actualResults?: ActualResults | null
onSubmit: (prediction: { first: string; second: string; third: string; last: string }) => void
}
```
Update the function signature:
```ts
export function PredictionsForm({ entries, existingPrediction, locked, actualResults, onSubmit }: PredictionsFormProps) {
```
In the locked state when `existingPrediction` exists (the block starting at line 67), update the rendered slot items to show correctness. Replace the existing locked-with-prediction return block with:
```tsx
return (
<Card>
<CardHeader>
<CardTitle>Your Predictions {actualResults ? "(scored)" : "(locked)"}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{SLOTS.map((slot) => {
const entry = findEntry(existingPrediction[slot.key])
const isCorrect = actualResults
? slot.key === "first" ? existingPrediction.first === actualResults.winner
: slot.key === "second" ? existingPrediction.second === actualResults.second
: slot.key === "third" ? existingPrediction.third === actualResults.third
: existingPrediction.last === actualResults.last
: null
return (
<div key={slot.key} className="flex items-center gap-2 rounded-md border p-2">
<span className="w-20 text-xs font-medium text-muted-foreground">{slot.label}</span>
<span className="text-sm">{entry ? formatEntry(entry) : "—"}</span>
{isCorrect !== null && (
<span className={isCorrect ? "ml-auto text-green-600" : "ml-auto text-red-500"}>
{isCorrect ? "✓" : "✗"}
</span>
)}
</div>
)
})}
</CardContent>
</Card>
)
```
- [ ] **Step 2: Commit**
```bash
git add packages/client/src/components/predictions-form.tsx
git commit -m "show correct/incorrect markers on locked predictions when results are in"
```
### Task 8: Update Leaderboard component
**Files:**
- Modify: `packages/client/src/components/leaderboard.tsx`
- [ ] **Step 1: Add `resultsEntered` prop and P: column**
Add a `resultsEntered` boolean prop to `LeaderboardProps`:
```ts
interface LeaderboardProps {
entries: LeaderboardEntry[]
resultsEntered?: boolean
}
export function Leaderboard({ entries, resultsEntered }: LeaderboardProps) {
```
In the score display section (the `div` with `gap-3 text-xs`), add `P:` before `J:`. Show `P:?` when results are not yet entered:
```tsx
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span title="Prediction points">P:{resultsEntered ? entry.predictionPoints : "?"}</span>
<span title="Jury points">J:{entry.juryPoints}</span>
<span title="Bingo points">B:{entry.bingoPoints}</span>
<span className="text-sm font-bold text-foreground">{entry.totalPoints}</span>
</div>
```
Update the scoring explanation `ul` to add predictions:
```tsx
<ul className="flex flex-col gap-0.5">
<li><strong>P</strong> = Prediction points 25 for correct winner, 10 each for 2nd/3rd, 15 for last place</li>
<li><strong>J</strong> = Jury points rate each act 1-12, earn up to 5 pts per round for matching the group consensus</li>
<li><strong>B</strong> = Bingo points 2 pts per tapped trope + 10 bonus for a full bingo line</li>
</ul>
```
- [ ] **Step 2: Commit**
```bash
git add packages/client/src/components/leaderboard.tsx
git commit -m "add prediction points to leaderboard display and explanation"
```
**Note for Tasks 9, 10, 11:** All `<Leaderboard>` usages must pass `resultsEntered={!!gameState.actualResults}` or `resultsEntered={!!gameState?.actualResults}`.
### Task 9: Wire up host route
**Files:**
- Modify: `packages/client/src/routes/host.$roomCode.tsx`
- [ ] **Step 1: Import and add ActualResultsForm**
Add import at top:
```ts
import { ActualResultsForm } from "@/components/actual-results-form"
```
In the Host tab's `CardContent`, add the `ActualResultsForm` after the jury host block and before the leaderboard block. Find the line:
```tsx
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
<Leaderboard entries={gameState.leaderboard} />
)}
```
Add before it:
```tsx
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
<ActualResultsForm
entries={gameState.lineup.entries}
existingResults={gameState.actualResults}
onSubmit={(results) => send({ type: "submit_actual_results", ...results })}
/>
)}
```
- [ ] **Step 2: Also pass `actualResults` to PredictionsForm in the Play tab**
In the Play tab, update the predictions block for scoring/ended to show locked predictions with results. Find:
```tsx
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && (
<Leaderboard entries={gameState.leaderboard} />
)}
```
Add before that block (in the Play tab):
```tsx
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && gameState.myPrediction && (
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={true}
actualResults={gameState.actualResults}
onSubmit={() => {}}
/>
)}
```
- [ ] **Step 3: Commit**
```bash
git add packages/client/src/routes/host.$roomCode.tsx
git commit -m "wire ActualResultsForm and prediction results in host route"
```
### Task 10: Wire up player route
**Files:**
- Modify: `packages/client/src/routes/play.$roomCode.tsx`
- [ ] **Step 1: Show scored predictions in scoring/ended**
In the player view, find the scoring act block:
```tsx
{gameState && room.currentAct === "scoring" && (
<Leaderboard entries={gameState.leaderboard} />
)}
```
Add before it:
```tsx
{gameState && (room.currentAct === "scoring" || room.currentAct === "ended") && gameState.myPrediction && (
<PredictionsForm
entries={gameState.lineup.entries}
existingPrediction={gameState.myPrediction}
locked={true}
actualResults={gameState.actualResults}
onSubmit={() => {}}
/>
)}
```
- [ ] **Step 2: Commit**
```bash
git add packages/client/src/routes/play.$roomCode.tsx
git commit -m "show scored predictions in player route during scoring/ended"
```
### Task 11: Update display route
**Files:**
- Modify: `packages/client/src/routes/display.$roomCode.tsx`
- [ ] **Step 1: Read current display route**
Read `packages/client/src/routes/display.$roomCode.tsx` to understand current structure.
- [ ] **Step 2: Add actual results summary to display**
When actual results are entered and the act is scoring/ended, show a summary card. The exact placement depends on the current display route structure. Add in the scoring/ended section:
```tsx
{gameState?.actualResults && (
<Card>
<CardHeader>
<CardTitle>Actual Results</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1 text-sm">
{[
{ label: "Winner", code: gameState.actualResults.winner },
{ label: "2nd", code: gameState.actualResults.second },
{ label: "3rd", code: gameState.actualResults.third },
{ label: "Last", code: gameState.actualResults.last },
].map(({ label, code }) => {
const entry = gameState.lineup.entries.find((e) => e.country.code === code)
return (
<div key={label} className="flex items-center gap-2">
<span className="w-16 text-xs font-medium text-muted-foreground">{label}</span>
<span>{entry ? `${entry.country.flag} ${entry.country.name}` : code}</span>
</div>
)
})}
</CardContent>
</Card>
)}
```
Add this import at the top of the file (these are not currently imported in the display route):
```ts
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
```
- [ ] **Step 3: Commit**
```bash
git add packages/client/src/routes/display.$roomCode.tsx
git commit -m "show actual results summary on display in scoring/ended"
```
### Task 12: Final verification
- [ ] **Step 1: Run all tests**
Run: `bun test`
Expected: All 73 tests pass
- [ ] **Step 2: Build client**
Run: `bun run --filter './packages/client' build`
Expected: Build succeeds with 0 errors
- [ ] **Step 3: Commit any remaining fixes if needed**