feat(striscia): registra cambi e time out per set (#18)

La striscia salva ora storico cambi formazione e time out per ogni
set, con riferimento al punteggio al momento dell'evento anziché a
un timestamp: startTimeout accumula in set.timeouts, confermaCambi
in set.cambi. Il referto elenca entrambi ordinati per punteggio.
This commit is contained in:
2026-07-03 11:16:42 +02:00
parent 7cc3b8bb5e
commit f6f3dd11d4
5 changed files with 182 additions and 2 deletions
+5 -1
View File
@@ -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. 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/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/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. `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.
+16
View File
@@ -171,6 +171,13 @@ export function applyAction(state, action) {
case "startTimeout": { case "startTimeout": {
const team = action.team const team = action.team
if (team !== 'home' && team !== 'guest') break 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.timeout = true
s.timeoutTeam = team s.timeoutTeam = team
s.timeoutVideo = action.video ?? null s.timeoutVideo = action.video ?? null
@@ -209,6 +216,7 @@ export function applyAction(state, action) {
const cambi = action.cambi || [] const cambi = action.cambi || []
const form = s.sp.form[team].map((val) => String(val).trim()) const form = s.sp.form[team].map((val) => String(val).trim())
const formAggiornata = [...form] const formAggiornata = [...form]
const cambiApplicati = []
let valid = true let valid = true
for (const cambio of cambi) { for (const cambio of cambi) {
@@ -223,11 +231,19 @@ export function applyAction(state, action) {
const idx = formAggiornata.findIndex((val) => String(val).trim() === cout) const idx = formAggiornata.findIndex((val) => String(val).trim() === cout)
if (idx !== -1) { if (idx !== -1) {
formAggiornata.splice(idx, 1, cin) formAggiornata.splice(idx, 1, cin)
cambiApplicati.push({ in: cin, out: cout })
} }
} }
if (valid) { if (valid) {
s.sp.form[team] = formAggiornata 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 break
} }
+31 -1
View File
@@ -44,6 +44,31 @@ export function buildRefertoHtml(state, now = new Date()) {
</div>` </div>`
} }
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 <strong>${nomeTeam(e.team)}</strong> ${sul}`
: `Cambio <strong>${nomeTeam(e.team)}</strong>: entra ${e.in} per ${e.out} ${sul}`
return `<li class="evento evento-${e.tipo}">${testo}</li>`
}).join('')
return `
<div class="eventi-set">
<div class="eventi-label">Cambi e time out</div>
<ul class="eventi-lista">${righe}</ul>
</div>`
}
const setsHtml = setReali.map((s, i) => { const setsHtml = setReali.map((s, i) => {
let h = 0, g = 0 let h = 0, g = 0
const punti = [] const punti = []
@@ -68,7 +93,7 @@ export function buildRefertoHtml(state, now = new Date()) {
<div class="form-inizio"> <div class="form-inizio">
<div class="form-inizio-label">Formazione di partenza</div> <div class="form-inizio-label">Formazione di partenza</div>
${formazioneHtml(s.formInizio)} ${formazioneHtml(s.formInizio)}
</div> </div>${eventiHtml(s)}
<div class="punti-grid">${puntiHtml || '<em style="color:#999;font-size:11px">Nessun punto registrato</em>'}</div> <div class="punti-grid">${puntiHtml || '<em style="color:#999;font-size:11px">Nessun punto registrato</em>'}</div>
</div>` </div>`
}).join('') }).join('')
@@ -100,6 +125,11 @@ export function buildRefertoHtml(state, now = new Date()) {
.punto-h { background: #d0e8ff; color: #003a6e; } .punto-h { background: #d0e8ff; color: #003a6e; }
.punto-g { background: #ffddc0; color: #6e2700; } .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 { 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-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; } .form-row { display: flex; gap: 40px; }
+98
View File
@@ -532,6 +532,46 @@ describe('Game Logic (gameState.js)', () => {
expect(s.timeout).toBe(false) expect(s.timeout).toBe(false)
expect(s.timeoutTeam).toBe(null) 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("1")
expect(s.sp.form.home).not.toContain("10") 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)
})
}) })
// ============================================= // =============================================
+32
View File
@@ -97,6 +97,38 @@ describe('buildRefertoHtml (referto.js)', () => {
expect(html).toContain('Nessun punto registrato') 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 <strong>Rivali</strong> sul 3-1')
expect(html).toContain('Cambio <strong>Antoniana</strong>: 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', () => { it('header contiene nomi squadre, modalità e data iniettata', () => {
const striscia = [{ serv: 'h', ris: '', vinc: null }] const striscia = [{ serv: 'h', ris: '', vinc: null }]
const state = statoConSet(striscia) const state = statoConSet(striscia)