From f6f3dd11d4eb7271991fa0776b16ab4ce3baa537 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Fri, 3 Jul 2026 11:16:42 +0200 Subject: [PATCH] feat(striscia): registra cambi e time out per set (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 6 ++- src/gameState.js | 16 ++++++ src/referto.js | 32 +++++++++++- tests/unit/gameState.test.js | 98 ++++++++++++++++++++++++++++++++++++ tests/unit/referto.test.js | 32 ++++++++++++ 5 files changed, 182 insertions(+), 2 deletions(-) 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 ` +
    +
    Cambi e time out
    + +
    ` + } + const setsHtml = setReali.map((s, i) => { let h = 0, g = 0 const punti = [] @@ -68,7 +93,7 @@ export function buildRefertoHtml(state, now = new Date()) {
    Formazione di partenza
    ${formazioneHtml(s.formInizio)} -
    + ${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)