feat: stable teamId in store + shared collective DTOs (B-prep)

- session store gains a persisted teamId (crypto.randomUUID, preserved
  across reset) used as the team's stable identity for the collective
- src/types.ts: TeamSnapshot/SubmissionDTO/ScoreDTO/LeaderboardRow/WsEvent,
  the contract shared with the api backend

2 new tests; suite 88/88 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-16 19:14:09 -07:00
co-authored by Claude Opus 4.8
parent 7c141dcd89
commit ca023cdaa5
3 changed files with 88 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useSession } from './session'
describe('session store · teamId', () => {
beforeEach(() => {
sessionStorage.clear()
})
it('has a non-empty stable team id', () => {
const id = useSession.getState().teamId
expect(id).toBeTruthy()
expect(typeof id).toBe('string')
})
it('preserves the team id across reset()', () => {
const before = useSession.getState().teamId
useSession.getState().setTeam({ name: 'x' })
useSession.getState().reset()
expect(useSession.getState().teamId).toBe(before)
expect(useSession.getState().team.name).toBe('')
})
})
+8
View File
@@ -47,7 +47,14 @@ export interface Submission {
submittedAt: string | null submittedAt: string | null
} }
/** Stable per-browser identity, generated once and persisted. */
function genTeamId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
return `team-${Math.abs(Date.now() ^ (Math.floor(Math.random() * 1e9))).toString(36)}`
}
export interface SessionState { export interface SessionState {
teamId: string
team: Team team: Team
device: Device device: Device
phases: Record<PhaseKey, boolean> phases: Record<PhaseKey, boolean>
@@ -66,6 +73,7 @@ export interface SessionState {
} }
const initial = { const initial = {
teamId: genTeamId(),
team: { name: '', members: [] as string[], kit: 'KIT-01' }, team: { name: '', members: [] as string[], kit: 'KIT-01' },
device: { connected: false, port: null, uptimeS: 0 }, device: { connected: false, port: null, uptimeS: 0 },
phases: { reg: false, setup: false, m1: false, m2: false, add: false } as Record<PhaseKey, boolean>, phases: { reg: false, setup: false, m1: false, m2: false, add: false } as Record<PhaseKey, boolean>,
+58
View File
@@ -0,0 +1,58 @@
// Shared DTOs for the collective backend. Mirrored by api/src/types.ts —
// keep the two in sync by hand (low churn, single-day event).
import type { PhaseKey, SessionStats, AddLayers } from '@/store/session'
/** A team's projection pushed up to the collective. */
export interface TeamSnapshot {
id: string
name: string
kit: string
members: string[]
phases: Record<PhaseKey, boolean>
stats: SessionStats
deviceConnected: boolean
updatedAt: string
}
export interface SubmissionDTO {
teamId: string
teamName: string
code: string
add: AddLayers
submittedAt: string
}
/** Lightweight queue row for the judge (no full ADD payload). */
export interface SubmissionSummary {
teamId: string
teamName: string
submittedAt: string
scored: boolean
}
export interface ScoreInput {
teamId: string
judge: string
rubric: Record<string, number>
total: number
notes: string
}
export interface ScoreDTO extends ScoreInput {
id: number
createdAt: string
}
export interface LeaderboardRow {
teamId: string
teamName: string
avgTotal: number
scoreCount: number
}
/** WebSocket events broadcast by the hub to admin/judge clients. */
export type WsEvent =
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[] }
| { type: 'team:update'; team: TeamSnapshot }
| { type: 'submission:new'; submission: SubmissionSummary }
| { type: 'score:new'; teamId: string; total: number }