add prediction scoring implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
866
docs/superpowers/plans/2026-03-12-prediction-scoring.md
Normal file
866
docs/superpowers/plans/2026-03-12-prediction-scoring.md
Normal 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**
|
||||
Reference in New Issue
Block a user