diff --git a/CLAUDE.md b/CLAUDE.md
index e6cc6a9..75fff7d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -40,11 +40,15 @@ Display (Vue) ──WebSocket──┤── websocket-handler.js ── gam
Punteggio, set e servizio si ricavano sempre dalla `striscia`: al primo punto di ogni set `applyAction('incPunt')` salva in `formInizio` uno snapshot della formazione di partenza (usato dal referto); `decPunt` lo rimuove se il set torna a 0-0.
+Ogni voce della `striscia` accumula anche lo storico di cambi e time out del set, popolato lazy (nessun campo se il set non ha eventi), senza timestamp — il "quando" è il punteggio al momento dell'evento, non l'orologio:
+- `set.timeouts`: array di `{ team, punteggio: { home, guest } }`, appeso da `startTimeout` (solo se il time out non è già attivo, per non duplicare l'evento su un restart).
+- `set.cambi`: array di `{ team, in, out, punteggio }`, uno per ogni sostituzione effettivamente applicata da `confermaCambi` (nulla viene registrato se la validazione del cambio fallisce).
+
`src/websocket-handler.js` riceve i messaggi WebSocket, valida che il mittente sia un `controller` (non un `display`), chiama `applyAction`, fa il broadcast del nuovo stato a tutti i client, poi invoca `onStateChange` per persistere su disco.
`src/wsMixin.js` è il mixin Vue lato client: gestisce connessione/riconnessione WebSocket (backoff esponenziale) ed espone i computed `punt`/`servHome`/`set`.
-`src/referto.js` genera il referto di gara: `buildRefertoHtml(state, now)` è una funzione pura che restituisce l'HTML (testabile), mentre `generaReferto(state)` è il wrapper che apre la finestra con `window.open` e avvia la stampa.
+`src/referto.js` genera il referto di gara: `buildRefertoHtml(state, now)` è una funzione pura che restituisce l'HTML (testabile), mentre `generaReferto(state)` è il wrapper che apre la finestra con `window.open` e avvia la stampa. Per ogni set, oltre alla formazione di partenza e alla griglia punti, mostra (se presenti) l'elenco di `set.timeouts` e `set.cambi` ordinato per punteggio crescente.
`src/persist.js` carica/salva lo stato su `.segnapunti/state.json`. `src/server-utils.js` ricava gli IP di rete (`getNetworkIPs`, con sorgente iniettabile) e stampa gli URL all'avvio. `src/spot-utils.js` espone `listSpotVideos(spotDir)`, che elenca i `.mp4` (ordinati) della cartella `spot/`; ritorna `[]` se la cartella manca.
diff --git a/src/gameState.js b/src/gameState.js
index d27491e..6df447f 100644
--- a/src/gameState.js
+++ b/src/gameState.js
@@ -171,6 +171,13 @@ export function applyAction(state, action) {
case "startTimeout": {
const team = action.team
if (team !== 'home' && team !== 'guest') break
+ if (!s.timeout) {
+ const setCorrente = s.sp.striscia.at(-1)
+ ;(setCorrente.timeouts ??= []).push({
+ team,
+ punteggio: punteggio(s.sp.striscia),
+ })
+ }
s.timeout = true
s.timeoutTeam = team
s.timeoutVideo = action.video ?? null
@@ -209,6 +216,7 @@ export function applyAction(state, action) {
const cambi = action.cambi || []
const form = s.sp.form[team].map((val) => String(val).trim())
const formAggiornata = [...form]
+ const cambiApplicati = []
let valid = true
for (const cambio of cambi) {
@@ -223,11 +231,19 @@ export function applyAction(state, action) {
const idx = formAggiornata.findIndex((val) => String(val).trim() === cout)
if (idx !== -1) {
formAggiornata.splice(idx, 1, cin)
+ cambiApplicati.push({ in: cin, out: cout })
}
}
if (valid) {
s.sp.form[team] = formAggiornata
+ if (cambiApplicati.length > 0) {
+ const setCorrente = s.sp.striscia.at(-1)
+ const puntAttuale = punteggio(s.sp.striscia)
+ ;(setCorrente.cambi ??= []).push(
+ ...cambiApplicati.map(c => ({ team, ...c, punteggio: { ...puntAttuale } }))
+ )
+ }
}
break
}
diff --git a/src/referto.js b/src/referto.js
index c08e5ec..0c636c8 100644
--- a/src/referto.js
+++ b/src/referto.js
@@ -44,6 +44,31 @@ export function buildRefertoHtml(state, now = new Date()) {
`
}
+ const nomeTeam = (team) => team === 'home' ? nomi.home : nomi.guest
+
+ const eventiHtml = (s) => {
+ const eventi = [
+ ...(s.timeouts ?? []).map(t => ({ tipo: 'timeout', ...t })),
+ ...(s.cambi ?? []).map(c => ({ tipo: 'cambio', ...c })),
+ ].sort((a, b) => (a.punteggio.home + a.punteggio.guest) - (b.punteggio.home + b.punteggio.guest))
+
+ if (eventi.length === 0) return ''
+
+ const righe = eventi.map(e => {
+ const sul = `sul ${e.punteggio.home}-${e.punteggio.guest}`
+ const testo = e.tipo === 'timeout'
+ ? `Time out ${nomeTeam(e.team)} ${sul}`
+ : `Cambio ${nomeTeam(e.team)}: entra ${e.in} per ${e.out} ${sul}`
+ return `
${testo}`
+ }).join('')
+
+ return `
+ `
+ }
+
const setsHtml = setReali.map((s, i) => {
let h = 0, g = 0
const punti = []
@@ -68,7 +93,7 @@ export function buildRefertoHtml(state, now = new Date()) {
+ ${eventiHtml(s)}
${puntiHtml || 'Nessun punto registrato'}
`
}).join('')
@@ -100,6 +125,11 @@ export function buildRefertoHtml(state, now = new Date()) {
.punto-h { background: #d0e8ff; color: #003a6e; }
.punto-g { background: #ffddc0; color: #6e2700; }
+ .eventi-set { padding: 8px 10px; border-bottom: 1px solid #eee; }
+ .eventi-label { font-size: 11px; font-weight: bold; letter-spacing: 0.5px; text-transform: uppercase; color: #888; margin-bottom: 5px; }
+ .eventi-lista { list-style: none; }
+ .evento { font-size: 12px; padding: 2px 0; }
+
.form-inizio { padding: 8px 10px; border-bottom: 1px solid #eee; background: #fafafa; }
.form-inizio-label { font-size: 11px; font-weight: bold; letter-spacing: 0.5px; text-transform: uppercase; color: #888; margin-bottom: 7px; }
.form-row { display: flex; gap: 40px; }
diff --git a/tests/unit/gameState.test.js b/tests/unit/gameState.test.js
index 147fa1d..fdbe7c8 100644
--- a/tests/unit/gameState.test.js
+++ b/tests/unit/gameState.test.js
@@ -532,6 +532,46 @@ describe('Game Logic (gameState.js)', () => {
expect(s.timeout).toBe(false)
expect(s.timeoutTeam).toBe(null)
})
+
+ it('startTimeout dovrebbe registrare l\'evento nel set corrente con il punteggio', () => {
+ let s = state
+ for (let i = 0; i < 3; i++) s = applyAction(s, { type: 'incPunt', team: 'home' })
+ s = applyAction(s, { type: 'incPunt', team: 'guest' })
+ s = applyAction(s, { type: 'startTimeout', team: 'guest', startedAt: 1000 })
+ expect(s.sp.striscia.at(-1).timeouts).toEqual([
+ { team: 'guest', punteggio: { home: 3, guest: 1 } },
+ ])
+ })
+
+ it('startTimeout su time out già attivo non dovrebbe registrare un secondo evento', () => {
+ let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 })
+ s = applyAction(s, { type: 'startTimeout', team: 'home', startedAt: 2000 })
+ expect(s.sp.striscia.at(-1).timeouts).toHaveLength(1)
+ })
+
+ it('time out successivi dovrebbero accumularsi nel set corrente', () => {
+ let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 })
+ s = applyAction(s, { type: 'stopTimeout' })
+ s = applyAction(s, { type: 'incPunt', team: 'guest' })
+ s = applyAction(s, { type: 'startTimeout', team: 'guest', startedAt: 2000 })
+ expect(s.sp.striscia.at(-1).timeouts).toEqual([
+ { team: 'home', punteggio: { home: 0, guest: 0 } },
+ { team: 'guest', punteggio: { home: 0, guest: 1 } },
+ ])
+ })
+
+ it('un nuovo set non dovrebbe ereditare i time out del precedente', () => {
+ let s = applyAction(state, { type: 'startTimeout', team: 'home', startedAt: 1000 })
+ s = applyAction(s, { type: 'stopTimeout' })
+ s = applyAction(s, { type: 'nuovoSet', team: 'home' })
+ expect(s.sp.striscia.at(-1).timeouts).toBeUndefined()
+ expect(s.sp.striscia.at(-2).timeouts).toHaveLength(1)
+ })
+
+ it('startTimeout ignorato non dovrebbe registrare eventi', () => {
+ const s = applyAction(state, { type: 'startTimeout', team: 'arbitro', startedAt: 1000 })
+ expect(s.sp.striscia.at(-1).timeouts).toBeUndefined()
+ })
})
// =============================================
@@ -705,6 +745,64 @@ describe('Game Logic (gameState.js)', () => {
expect(s.sp.form.home).not.toContain("1")
expect(s.sp.form.home).not.toContain("10")
})
+
+ it('dovrebbe registrare il cambio nel set corrente con il punteggio', () => {
+ let s = state
+ for (let i = 0; i < 2; i++) s = applyAction(s, { type: 'incPunt', team: 'home' })
+ s = applyAction(s, { type: 'incPunt', team: 'guest' })
+ s = applyAction(s, {
+ type: 'confermaCambi',
+ team: 'home',
+ cambi: [{ in: "10", out: "3" }]
+ })
+ expect(s.sp.striscia.at(-1).cambi).toEqual([
+ { team: 'home', in: "10", out: "3", punteggio: { home: 2, guest: 1 } },
+ ])
+ })
+
+ it('dovrebbe registrare ogni cambio di una doppia sostituzione', () => {
+ const s = applyAction(state, {
+ type: 'confermaCambi',
+ team: 'guest',
+ cambi: [
+ { in: "10", out: "1" },
+ { in: "11", out: "2" }
+ ]
+ })
+ expect(s.sp.striscia.at(-1).cambi).toEqual([
+ { team: 'guest', in: "10", out: "1", punteggio: { home: 0, guest: 0 } },
+ { team: 'guest', in: "11", out: "2", punteggio: { home: 0, guest: 0 } },
+ ])
+ })
+
+ it('non dovrebbe registrare nulla se la validazione fallisce', () => {
+ const s = applyAction(state, {
+ type: 'confermaCambi',
+ team: 'home',
+ cambi: [{ in: "abc", out: "1" }]
+ })
+ expect(s.sp.striscia.at(-1).cambi).toBeUndefined()
+ })
+
+ it('non dovrebbe registrare nulla se tutti i cambi sono vuoti', () => {
+ const s = applyAction(state, {
+ type: 'confermaCambi',
+ team: 'home',
+ cambi: [{ in: "", out: "" }]
+ })
+ expect(s.sp.striscia.at(-1).cambi).toBeUndefined()
+ })
+
+ it('un nuovo set non dovrebbe ereditare i cambi del precedente', () => {
+ let s = applyAction(state, {
+ type: 'confermaCambi',
+ team: 'home',
+ cambi: [{ in: "10", out: "3" }]
+ })
+ s = applyAction(s, { type: 'nuovoSet', team: 'home' })
+ expect(s.sp.striscia.at(-1).cambi).toBeUndefined()
+ expect(s.sp.striscia.at(-2).cambi).toHaveLength(1)
+ })
})
// =============================================
diff --git a/tests/unit/referto.test.js b/tests/unit/referto.test.js
index 0ea4fb9..5cd8e58 100644
--- a/tests/unit/referto.test.js
+++ b/tests/unit/referto.test.js
@@ -97,6 +97,38 @@ describe('buildRefertoHtml (referto.js)', () => {
expect(html).toContain('Nessun punto registrato')
})
+ it('elenca time out e cambi del set con squadra e punteggio', () => {
+ const striscia = [{
+ serv: 'h', ris: 'hhhg', vinc: null,
+ timeouts: [{ team: 'guest', punteggio: { home: 3, guest: 1 } }],
+ cambi: [{ team: 'home', in: '10', out: '3', punteggio: { home: 2, guest: 0 } }],
+ }]
+ const html = buildRefertoHtml(statoConSet(striscia), NOW)
+ expect(html).toContain('Cambi e time out')
+ expect(html).toContain('Time out Rivali sul 3-1')
+ expect(html).toContain('Cambio Antoniana: entra 10 per 3 sul 2-0')
+ })
+
+ it('ordina gli eventi per punteggio crescente', () => {
+ const striscia = [{
+ serv: 'h', ris: 'hhhgg', vinc: null,
+ timeouts: [{ team: 'home', punteggio: { home: 3, guest: 2 } }],
+ cambi: [{ team: 'guest', in: '9', out: '4', punteggio: { home: 1, guest: 0 } }],
+ }]
+ const html = buildRefertoHtml(statoConSet(striscia), NOW)
+ const idxCambio = html.indexOf('entra 9 per 4')
+ const idxTimeout = html.indexOf('Time out')
+ expect(idxCambio).toBeGreaterThan(-1)
+ expect(idxTimeout).toBeGreaterThan(-1)
+ expect(idxCambio).toBeLessThan(idxTimeout)
+ })
+
+ it('omette la sezione eventi se il set non ne ha', () => {
+ const striscia = [{ serv: 'h', ris: 'h', vinc: null }]
+ const html = buildRefertoHtml(statoConSet(striscia), NOW)
+ expect(html).not.toContain('Cambi e time out')
+ })
+
it('header contiene nomi squadre, modalità e data iniettata', () => {
const striscia = [{ serv: 'h', ris: '', vinc: null }]
const state = statoConSet(striscia)