Compare commits
10 Commits
ff6a305554
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bd90a2efe4 | |||
| 93372a1299 | |||
| 82c997d475 | |||
| 35c49247a8 | |||
| fec140df62 | |||
| c6eb493f25 | |||
| 7cf84a94d9 | |||
| 00a8300a75 | |||
| ec19eb62c3 | |||
| 34c9a61e79 |
@@ -7,6 +7,37 @@ e questo progetto aderisce al [Versionamento Semantico](https://semver.org/lang/
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] - 2026-07-08
|
||||
|
||||
### Aggiunto
|
||||
- Time out con spot pubblicitari: sul controller una modal fa scegliere squadra e video (o nessuno) tra i file `.mp4` della cartella `spot/`; il display copre il tabellone con un overlay a countdown, riproduce il video scelto (con fallback su testo "TIME OUT <squadra>" se manca o finisce in anticipo) e sfuma nell'ultimo secondo; il server chiude automaticamente il time out dopo 30s anche in caso di disconnessione del controller; video elencati da `/spot/list` e serviti da `/spot/<file>.mp4`
|
||||
- Storico di cambi e time out registrato per ogni set nella `striscia` (popolato lazy, ordinato per punteggio, usato dal referto)
|
||||
- Modalità **Amichevole**: i set si vincono normalmente ma la partita non termina mai automaticamente, per giocare set illimitati
|
||||
- Dialog dedicato a fine partita che blocca ulteriori punti e apre le azioni post-match (referto, chiusura)
|
||||
- Prototipo di generazione referto di gara stampabile (`src/referto.js`): formazioni di partenza, griglia punti, cambi e time out per set
|
||||
- Layout esteso "da regia" per il controller in orientamento landscape (tablet), con pannelli squadra affiancati
|
||||
- Indicatore ON/OFF sul pulsante Striscia del controller; config si riapre automaticamente dopo un reset
|
||||
- Supporto WSL2 in sviluppo: `getNetworkIPs()` interroga PowerShell per ottenere gli IP reali della rete Windows
|
||||
- Flusso di sviluppo containerizzato con hot reload: `docker-compose.dev.yml` e nuovo target `dev` nel Dockerfile multi-stage
|
||||
- Suite di test estesa a oltre il 99% di copertura statement, con nuovi test e2e (time out, referto, accessibilità, visual regression)
|
||||
|
||||
### Modificato
|
||||
- `punteggio`/`setVinti`/`servizio` derivati sempre dalla `striscia` invece di essere memorizzati nello stato
|
||||
- Struttura dati `striscia` compattata da array a stringa
|
||||
- Il controller rispetta `state.order` (scambio lato squadre) come il display
|
||||
- Logica di connessione WebSocket lato client estratta in `src/wsMixin.js` riusabile
|
||||
- Dev server esposto su tutte le interfacce di rete (`vite --host`)
|
||||
- Persistenza dati passata da named volume a bind mount (`./.segnapunti`) per accesso diretto ai file dall'host
|
||||
|
||||
### Rimosso
|
||||
- Sezione shortcut da tastiera dal README (funzionalità non più implementata)
|
||||
- Emoji da codice, UI e commenti (ora vietate salvo richiesta esplicita)
|
||||
|
||||
### Corretto
|
||||
- Immagine Docker di produzione: mancava la copia di `src/spot-utils.js`, causando errori nel serving degli spot video
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] - 2026-05-12
|
||||
|
||||
### Aggiunto
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Guida per Claude Code su questo repository.
|
||||
|
||||
## Scopo del progetto
|
||||
|
||||
**Segnapunti Anto** è una PWA per il segnapunti pallavolo in tempo reale. Un server Express/WebSocket gestisce lo stato di gioco; due interfacce Vue separate — **display** (tabellone pubblico) e **controller** (pannello operatore) — si sincronizzano via WebSocket.
|
||||
**Segnapunti Anto**: PWA segnapunti pallavolo in tempo reale. Server Express/WebSocket gestisce lo stato di gioco; due interfacce Vue — **display** (tabellone pubblico) e **controller** (pannello operatore) — sincronizzate via WebSocket.
|
||||
|
||||
## Comandi
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server — display: :5173/display, controller: :5173/controller
|
||||
npm run dev # Vite dev — display: :5173/display, controller: :5173/controller
|
||||
npm run serve # Build + avvio produzione — display: :3000/display, controller: :3000/controller
|
||||
|
||||
npm run test # Vitest in modalità watch
|
||||
npm run test:all # Tutte le suite Vitest in una sola esecuzione
|
||||
npm run test # Vitest watch
|
||||
npm run test:all # Tutte le suite in una sola esecuzione
|
||||
npm run test:unit # Solo unit + integration
|
||||
npm run test:component # Test componenti Vue (happy-dom)
|
||||
npm run test:component # Componenti Vue (happy-dom)
|
||||
npm run test:stress # Load test (50+ client concorrenti)
|
||||
npm run test:e2e # Playwright E2E (richiede npm run serve attivo)
|
||||
npm run test:e2e # Playwright E2E (richiede `npm run serve` attivo)
|
||||
npm run test:e2e:ui # Playwright con UI interattiva
|
||||
```
|
||||
|
||||
Per eseguire un singolo file di test: `npx vitest run tests/unit/gameState.test.js`
|
||||
Singolo file di test: `npx vitest run tests/unit/gameState.test.js`
|
||||
|
||||
## Architettura
|
||||
|
||||
@@ -32,39 +32,41 @@ Display (Vue) ──WebSocket──┤── websocket-handler.js ── gam
|
||||
│ └── persist.js ── .segnapunti/state.json
|
||||
```
|
||||
|
||||
**Tutta la logica di gioco** è in `src/gameState.js` come funzioni pure esportate:
|
||||
- `createInitialState()` — restituisce lo stato iniziale
|
||||
**`src/gameState.js`** — logica di gioco, funzioni pure:
|
||||
- `createInitialState()` — stato iniziale
|
||||
- `applyAction(state, action)` — reducer immutabile (deep-clone via `structuredClone`)
|
||||
- `checkVittoria(state)` — condizioni di vittoria set (25 punti, vantaggio di 2; 15 punti nel set decisivo)
|
||||
- `punteggio` / `setVinti` / `servizio` — derivano punteggio corrente, set vinti e chi serve dalla `striscia` (lo stato NON memorizza questi valori direttamente)
|
||||
- `checkVittoria(state)` — vittoria set: 25 punti/vantaggio 2 (15 nel set decisivo)
|
||||
- `punteggio` / `setVinti` / `servizio` — derivati dalla `striscia`, NON memorizzati direttamente nello stato
|
||||
|
||||
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).
|
||||
Ogni voce della `striscia` accumula anche lo storico di cambi/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`: `{ team, punteggio: { home, guest } }`, appeso da `startTimeout` (solo se non già attivo, per non duplicare l'evento su un restart).
|
||||
- `set.cambi`: `{ team, in, out, punteggio }`, uno per sostituzione applicata da `confermaCambi` (nulla se la validazione 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, valida che il mittente sia `controller` (non `display`), chiama `applyAction`, fa broadcast del nuovo stato, poi invoca `onStateChange` per persistere.
|
||||
|
||||
`src/wsMixin.js` è il mixin Vue lato client: gestisce connessione/riconnessione WebSocket (backoff esponenziale) ed espone i computed `punt`/`servHome`/`set`.
|
||||
**`src/wsMixin.js`** — mixin Vue lato client: connessione/riconnessione WebSocket (backoff esponenziale), 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. 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/referto.js`** — genera il referto di gara: `buildRefertoHtml(state, now)` è pura (testabile, ritorna HTML); `generaReferto(state)` apre la finestra (`window.open`) e avvia la stampa. Per ogni set mostra formazione di partenza, griglia punti e (se presenti) `set.timeouts`/`set.cambi` ordinati 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`** — `getNetworkIPs` (sorgente iniettabile), stampa gli URL all'avvio.
|
||||
**`src/spot-utils.js`** — `listSpotVideos(spotDir)`: elenca i `.mp4` (ordinati) di `spot/`; `[]` se la cartella manca.
|
||||
|
||||
**Time out / spot video** — le action `startTimeout` (richiede `team`, `video` opzionale) e `stopTimeout` impostano/azzerano `state.timeout`, `timeoutTeam`, `timeoutVideo` e `timeoutStartedAt`. Il timestamp `startedAt` lo aggiunge `websocket-handler.js` (non il client), così `gameState.js` resta puro e deterministico; lo stesso handler arma un timer server-side di 30 secondi che invia automaticamente `stopTimeout` (sopravvive a refresh o disconnessione del controller). Sul controller il pulsante Time Out apre una modal per scegliere la squadra e uno dei video `.mp4` della cartella `spot/` nella root (non versionata), oppure nessun video; durante il time out il pulsante mostra il countdown e ripremerlo chiude in anticipo. Il display copre il tabellone con un overlay a tutto schermo con countdown: riproduce il video scelto (riallineandosi al punto giusto in caso di riconnessione a metà time out) e ripiega sulla scritta "TIME OUT <squadra>" se non c'è video o se il video finisce prima dei 30 secondi, con dissolvenza nell'ultimo secondo. La lista è esposta dall'endpoint HTTP `/spot/list` (JSON) e i file sono serviti staticamente da `/spot/<file>.mp4`, sia in produzione (`server.js`) sia in sviluppo (`vite-plugin-websocket.js`). I video partono con audio; se il browser blocca l'autoplay con suono si ripiega automaticamente su muto — per avere l'audio reale il browser del display (schermo senza input) va avviato in kiosk con autoplay consentito (es. Chrome `--autoplay-policy=no-user-gesture-required`).
|
||||
**Time out / spot video** — `startTimeout` (`team`, `video` opzionale) e `stopTimeout` impostano/azzerano `state.timeout`, `timeoutTeam`, `timeoutVideo`, `timeoutStartedAt`. `startedAt` è aggiunto da `websocket-handler.js` (non dal client), così `gameState.js` resta puro/deterministico; lo stesso handler arma un timer server-side di 30s che invia automaticamente `stopTimeout` (sopravvive a refresh/disconnessione del controller). Sul controller, il pulsante Time Out apre una modal per scegliere squadra + un `.mp4` da `spot/` (cartella in root, non versionata) o nessun video; durante il time out mostra il countdown, ripremerlo chiude in anticipo. Sul display, un overlay fullscreen con countdown copre il tabellone: riproduce il video (riallineandosi al punto giusto su riconnessione a metà time out) o mostra "TIME OUT <squadra>" se manca il video o finisce prima dei 30s, con dissolvenza nell'ultimo secondo. Lista esposta da `/spot/list` (JSON), file serviti da `/spot/<file>.mp4` sia in produzione (`server.js`) che in dev (`vite-plugin-websocket.js`). I video partono con audio; se l'autoplay con suono è bloccato si ripiega su muto — per audio reale il browser del display va avviato in kiosk con autoplay consentito (es. Chrome `--autoplay-policy=no-user-gesture-required`).
|
||||
|
||||
`server.js` (Express) espone `createApp(distDir)` (solo routing, testabile) e `startServer(port)` (HTTP + WebSocket); serve entrambe le interfacce sulla porta 3000: `/display` → `dist/index.html`, `/controller` → `dist/controller.html`. Singolo endpoint WebSocket su `/ws`. L'avvio automatico parte solo se il file è eseguito come entrypoint.
|
||||
**`server.js`** — Express: espone `createApp(distDir)` (solo routing, testabile) e `startServer(port)` (HTTP + WebSocket). Serve `/display` → `dist/index.html`, `/controller` → `dist/controller.html` sulla porta 3000; endpoint WebSocket unico `/ws`. Si auto-avvia solo se eseguito come entrypoint.
|
||||
|
||||
In sviluppo, `vite-plugin-websocket.js` incorpora il server WebSocket dentro il dev server Vite con middleware di URL rewrite per `/display` e `/controller`.
|
||||
**`vite-plugin-websocket.js`** — in dev, incorpora il server WebSocket nel dev server Vite con rewrite URL per `/display` e `/controller`.
|
||||
|
||||
## Vincoli di design
|
||||
|
||||
- **Tutta la logica sul server** — i client sono pura UI; il server è l'unica source of truth.
|
||||
- **WebSocket role-based** — i client si registrano come `display` o `controller`; solo i controller possono inviare azioni.
|
||||
- **Logica solo server-side** — i client sono pura UI; il server è l'unica source of truth.
|
||||
- **WebSocket role-based** — i client si registrano come `display` o `controller`; solo i controller inviano azioni.
|
||||
- **Stato immutabile** — `applyAction` non muta mai lo stato, restituisce sempre un nuovo oggetto.
|
||||
- **Un solo controller** — il design prevede un controller e un display attivi; non esiste conflict resolution per controller simultanei.
|
||||
- **Stato persistente** — `src/persist.js` salva lo stato in `.segnapunti/state.json` dopo ogni azione e lo carica all'avvio del server.
|
||||
- **Un solo controller/display attivi** — nessuna conflict resolution per controller simultanei.
|
||||
- **Stato persistente** — `src/persist.js` salva in `.segnapunti/state.json` dopo ogni azione e ricarica all'avvio.
|
||||
|
||||
## Layout dei test
|
||||
|
||||
@@ -76,25 +78,34 @@ In sviluppo, `vite-plugin-websocket.js` incorpora il server WebSocket dentro il
|
||||
| Stress | `tests/stress/` | Vitest + Node |
|
||||
| E2E | `tests/e2e/` | Playwright (Chromium, Firefox, Mobile Chrome) |
|
||||
|
||||
I test E2E girano in serie (`workers: 1`) per evitare race condition sullo stato WebSocket. Eseguire `npm run serve` prima di `npm run test:e2e`.
|
||||
E2E gira in serie (`workers: 1`) per evitare race condition sullo stato WebSocket; richiede `npm run serve` attivo prima di `npm run test:e2e`.
|
||||
|
||||
La copertura statement/linee della suite Vitest è oltre il 99%. Per testare `startServer()` (`server.js`) o i rami platform-dependent di `getNetworkIPs()` (`server-utils.js`) senza effetti collaterali reali (scrittura su `.segnapunti/state.json`, dipendenza dalla piattaforma), si mocka il modulo con `vi.mock`/`vi.doMock` (vedi `tests/integration/startServer.test.js` e `tests/unit/server-utils-platform.test.js`) invece di eseguire il codice reale.
|
||||
Copertura statement/linee Vitest oltre il 99%. Per testare `startServer()` (`server.js`) o i rami platform-dependent di `getNetworkIPs()` (`server-utils.js`) senza effetti collaterali reali (scrittura su `.segnapunti/state.json`, dipendenza dalla piattaforma), mockare il modulo con `vi.mock`/`vi.doMock` (vedi `tests/integration/startServer.test.js`, `tests/unit/server-utils-platform.test.js`) invece di eseguire il codice reale.
|
||||
|
||||
## Deploy
|
||||
|
||||
Il progetto si distribuisce tramite Docker. L'immagine è pubblicata sul registry Gitea self-hosted (`santantonio.sytes.net`):
|
||||
`Dockerfile` è multi-stage con 4 target: `deps` (dipendenze condivise), `dev` (hot reload Vite), `builder` (build frontend), `runtime` (immagine di produzione, target di default).
|
||||
|
||||
**Produzione** — `docker-compose.yml` usa l'immagine già pubblicata sul registry Gitea self-hosted (`santantonio.sytes.net`), niente build locale:
|
||||
|
||||
```bash
|
||||
docker compose up -d # Avvia con immagine dal registry privato (porta 3000)
|
||||
```
|
||||
|
||||
Il volume `./.segnapunti` persiste lo stato tra i riavvii del container.
|
||||
**Sviluppo** — `docker-compose.dev.yml` builda il target `dev` in locale e monta il codice sorgente per l'hot reload (Vite su porta 5173); `node_modules` resta un volume anonimo del container per non farsi sovrascrivere dal bind-mount (evita anche problemi di binari nativi arch-specific tra host e container):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
Per buildare in locale l'immagine di produzione (es. per testarla prima del push sul registry): `docker build -t segnapunti:local .` (usa il target `runtime` di default).
|
||||
|
||||
Volume `./.segnapunti` persiste lo stato tra i riavvii del container.
|
||||
|
||||
## Istruzioni operative
|
||||
|
||||
- Per ogni nuova funzionalità: analizza come si inserisce nel flusso `action → applyAction → broadcast`, poi decidi se aggiungere un nuovo tipo di action o estendere uno esistente.
|
||||
- Qualsiasi modifica alle regole di gioco va fatta esclusivamente in `src/gameState.js`.
|
||||
- Qualsiasi modifica al protocollo WebSocket va fatta in `src/websocket-handler.js`.
|
||||
- Aggiungere test unit in `tests/unit/gameState.test.js` per ogni nuovo action type o regola di gioco.
|
||||
- Il codice deve essere scritto in italiano per commenti e nomi di variabili di dominio (es. `servHome`, `striscia`, `nomi`), ma in inglese per nomi tecnici standard (`state`, `action`, `handler`).
|
||||
- Non inserire emoji in codice, UI (label, testi dei bottoni, banner) o commenti, a meno che l'utente non le richieda esplicitamente.
|
||||
- Nuova funzionalità: valutare come si inserisce nel flusso `action → applyAction → broadcast`, poi decidere se serve un nuovo action type o estendere uno esistente.
|
||||
- Regole di gioco → solo `src/gameState.js`. Protocollo WebSocket → solo `src/websocket-handler.js`.
|
||||
- Nuovo action type o regola di gioco → aggiungere test in `tests/unit/gameState.test.js`.
|
||||
- Commenti e nomi di dominio in italiano (es. `servHome`, `striscia`, `nomi`); nomi tecnici standard in inglese (`state`, `action`, `handler`).
|
||||
- Niente emoji in codice, UI (label, testi bottoni, banner) o commenti, salvo richiesta esplicita dell'utente.
|
||||
|
||||
+13
-4
@@ -1,13 +1,22 @@
|
||||
# Stage 1: build frontend
|
||||
FROM node:20-alpine AS builder
|
||||
# Stage 1: dipendenze condivise (build + dev)
|
||||
FROM node:24-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: dev — hot reload via Vite, sorgente montato da bind-mount
|
||||
FROM deps AS dev
|
||||
ENV NODE_ENV=development
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
# Stage 3: build frontend
|
||||
FROM deps AS builder
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: runtime
|
||||
FROM node:20-alpine
|
||||
# Stage 4: runtime di produzione
|
||||
FROM node:24-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production PORT=3000
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Segnapunti
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -9,14 +9,29 @@ Segnapunti digitale in tempo reale per partite di pallavolo. Un server centrale
|
||||
|
||||
---
|
||||
|
||||
## Requisiti
|
||||
|
||||
| Strumento | Versione minima | Uso |
|
||||
|---|---|---|
|
||||
| Node.js | >= 24 | Sviluppo locale (`npm run dev`) |
|
||||
| npm | >= 11 | Sviluppo locale |
|
||||
| Docker + Docker Compose | — | Deploy in produzione; alternativa containerizzata allo sviluppo locale |
|
||||
|
||||
Versione Node pinnata in `.nvmrc` e in `engines` di `package.json`. Con [nvm](https://github.com/nvm-sh/nvm): `nvm use`.
|
||||
|
||||
---
|
||||
|
||||
## Indice
|
||||
|
||||
- [Requisiti](#requisiti)
|
||||
- [Architettura](#architettura)
|
||||
- [Guida utente](#guida-utente)
|
||||
- [Funzionalità](#funzionalità)
|
||||
- [Deploy con Docker](#deploy-con-docker)
|
||||
- [Sviluppo](#sviluppo)
|
||||
- [Sviluppo con Docker](#sviluppo-con-docker)
|
||||
- [Test](#test)
|
||||
- [Changelog](#changelog)
|
||||
|
||||
---
|
||||
|
||||
@@ -165,7 +180,6 @@ La **partita** è vinta da chi raggiunge 3 set su 5 (modalità `3/5`) o 2 set su
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Deploy con Docker
|
||||
|
||||
### Prima installazione
|
||||
@@ -186,26 +200,28 @@ docker compose pull && docker compose up -d
|
||||
|
||||
### Build e pubblicazione immagine
|
||||
|
||||
Il `Dockerfile` è multi-stage (`deps` → `dev` / `builder` → `runtime`); senza specificare `--target`, il build produce l'ultimo stage (`runtime`), l'immagine di produzione:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
-t santantonio.sytes.net/attilio/segnapunti:2.0.0 \
|
||||
-t santantonio.sytes.net/attilio/segnapunti:2.1.0 \
|
||||
-t santantonio.sytes.net/attilio/segnapunti:latest .
|
||||
|
||||
docker push santantonio.sytes.net/attilio/segnapunti:2.0.0
|
||||
docker push santantonio.sytes.net/attilio/segnapunti:2.1.0
|
||||
docker push santantonio.sytes.net/attilio/segnapunti:latest
|
||||
```
|
||||
|
||||
Per testare in locale l'immagine di produzione senza pubblicarla:
|
||||
|
||||
```bash
|
||||
docker build -t segnapunti:local .
|
||||
docker run --rm -p 3000:3000 -v ./.segnapunti:/app/.segnapunti segnapunti:local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sviluppo
|
||||
|
||||
### Requisiti
|
||||
|
||||
| Strumento | Versione minima |
|
||||
|---|---|
|
||||
| Node.js | >= 18 |
|
||||
| npm | >= 9 |
|
||||
|
||||
### Avvio
|
||||
|
||||
```bash
|
||||
@@ -230,6 +246,29 @@ Lo stato viene salvato in `.segnapunti/state.json` anche in modalità dev.
|
||||
|
||||
---
|
||||
|
||||
## Sviluppo con Docker
|
||||
|
||||
In alternativa a Node.js installato in locale, `docker-compose.dev.yml` builda il target `dev` del `Dockerfile` e monta il codice sorgente nel container, con hot reload identico a `npm run dev`:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
| URL | Interfaccia |
|
||||
|---|---|
|
||||
| `http://localhost:5173/display` | Display |
|
||||
| `http://localhost:5173/controller` | Controller |
|
||||
|
||||
Le modifiche ai file sorgente sull'host si riflettono subito nel container (bind-mount); `node_modules` resta invece un volume anonimo del container, per non farsi sovrascrivere dal bind-mount e per evitare incompatibilità tra i binari nativi installati sull'host e quelli richiesti dal container (es. host ARM / container `node:24-alpine` su x86).
|
||||
|
||||
Per fermare e rimuovere il container:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test
|
||||
|
||||
| Comando | Descrizione |
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
segnapunti:
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
container_name: segnapunti-dev
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
segnapunti:
|
||||
image: santantonio.sytes.net/attilio/segnapunti:2.0.0
|
||||
image: santantonio.sytes.net/attilio/segnapunti:2.1.0
|
||||
container_name: segnapunti
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
Generated
+428
-384
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "segnapuntianto",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
@@ -30,12 +33,12 @@
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^25.2.3",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"@vitest/ui": "^4.1.10",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"happy-dom": "^20.6.1",
|
||||
"jsdom": "^28.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
+23
-20
@@ -34,11 +34,11 @@ export function buildRefertoHtml(state, now = new Date()) {
|
||||
return `
|
||||
<div class="form-row">
|
||||
<div class="form-team">
|
||||
<div class="form-team-name">${nomi.home}</div>
|
||||
<div class="form-team-name form-team-name-h">${nomi.home}</div>
|
||||
<div class="giocatori">${formazioneSet.home.map(giocatoreHtml).join('')}</div>
|
||||
</div>
|
||||
<div class="form-team">
|
||||
<div class="form-team-name">${nomi.guest}</div>
|
||||
<div class="form-team-name form-team-name-g">${nomi.guest}</div>
|
||||
<div class="giocatori">${formazioneSet.guest.map(giocatoreHtml).join('')}</div>
|
||||
</div>
|
||||
</div>`
|
||||
@@ -79,7 +79,11 @@ export function buildRefertoHtml(state, now = new Date()) {
|
||||
|
||||
const vinc = vincitoreSet(s)
|
||||
const nomeVinc = vinc === 'h' ? nomi.home : vinc === 'g' ? nomi.guest : ''
|
||||
const etichettaVinc = nomeVinc ? ` · vinto da <strong>${nomeVinc}</strong>` : ''
|
||||
const etichettaVinc = nomeVinc ? `<span class="set-vinc">vinto da <strong>${nomeVinc}</strong></span>` : ''
|
||||
|
||||
const servIcon = '<img src="/serv-nero.png" class="serv-icon" alt="Servizio">'
|
||||
const servHomeIcon = s.serv === 'h' ? servIcon : ''
|
||||
const servGuestIcon = s.serv === 'g' ? servIcon : ''
|
||||
|
||||
const puntiHtml = punti.map(p =>
|
||||
`<span class="punto punto-${p.chi}">${p.h}-${p.g}</span>`
|
||||
@@ -90,7 +94,8 @@ export function buildRefertoHtml(state, now = new Date()) {
|
||||
<div class="set-barra"><span>SET</span><span class="set-numero">${i + 1}</span></div>
|
||||
<div class="set-corpo">
|
||||
<div class="set-header">
|
||||
${nomi.home} <strong>${h}</strong> · <strong>${g}</strong> ${nomi.guest}${etichettaVinc}
|
||||
<span>${servHomeIcon}${nomi.home} <strong>${h}</strong> · <strong>${g}</strong> ${nomi.guest}${servGuestIcon}</span>
|
||||
${etichettaVinc}
|
||||
</div>
|
||||
<div class="form-inizio">
|
||||
<div class="form-inizio-label">Formazione di partenza</div>
|
||||
@@ -130,25 +135,23 @@ export function buildRefertoHtml(state, now = new Date()) {
|
||||
.meta { font-size: 11px; color: #333; margin-top: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
.squadre-row { display: flex; align-items: center; gap: 14px; }
|
||||
.badge-ab { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; border: 2px solid #111; font-weight: bold; font-size: 12px; flex-shrink: 0; }
|
||||
.badge-ab { display: inline-flex; align-items: center; justify-content: center; height: 22px; padding: 0 8px; border-radius: 11px; border: 2px solid #111; font-weight: bold; font-size: 12px; flex-shrink: 0; }
|
||||
.nome-sq { font-size: 18px; font-weight: bold; }
|
||||
.vs { font-size: 12px; color: #666; font-weight: bold; }
|
||||
|
||||
.risultato-riepilogo { display: flex; align-items: center; gap: 10px; }
|
||||
.risultato { font-size: 30px; font-weight: bold; letter-spacing: 3px; }
|
||||
.modalita-label { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
.set-section { display: flex; margin-bottom: 10px; border: 2px solid #111; }
|
||||
.set-barra { flex: 0 0 26px; background: var(--teal); color: #fff; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border-right: 2px solid #111; }
|
||||
.set-barra span:first-child { writing-mode: vertical-rl; transform: rotate(180deg); font-weight: bold; font-size: 11px; letter-spacing: 2px; }
|
||||
.set-numero { font-weight: bold; font-size: 15px; }
|
||||
.set-corpo { flex: 1; min-width: 0; }
|
||||
.set-header { background: #eaf7fb; padding: 6px 12px; font-size: 13px; border-bottom: 1px solid #111; }
|
||||
.set-header { background: #eaf7fb; padding: 6px 12px; font-size: 13px; border-bottom: 1px solid #111; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
|
||||
.set-vinc { flex-shrink: 0; }
|
||||
.serv-icon { width: 12px; height: 12px; vertical-align: middle; margin: 0 3px; }
|
||||
|
||||
.punti-grid { display: flex; flex-wrap: wrap; gap: 0; padding: 0; border-top: 1px solid #111; }
|
||||
.punto { display: inline-flex; align-items: center; justify-content: center; min-width: 34px; padding: 3px 4px; font-size: 11px; font-family: 'Courier New', monospace; white-space: nowrap; border-right: 1px solid #ccc; border-bottom: 1px solid #ccc; }
|
||||
.punto-h { background: #d0e8ff; color: #003a6e; }
|
||||
.punto-g { background: #ffddc0; color: #6e2700; }
|
||||
.punto-h { background: #f5c518; color: #111; }
|
||||
.punto-g { background: #2196f3; color: #fff; }
|
||||
|
||||
.eventi-set { padding: 6px 12px; border-bottom: 1px solid #111; }
|
||||
.eventi-label { font-size: 10px; font-weight: bold; letter-spacing: 0.5px; text-transform: uppercase; color: #0f8faf; margin-bottom: 4px; }
|
||||
@@ -160,6 +163,8 @@ export function buildRefertoHtml(state, now = new Date()) {
|
||||
.form-row { display: flex; gap: 40px; }
|
||||
.form-team { flex: 1; }
|
||||
.form-team-name { font-weight: bold; font-size: 12px; margin-bottom: 5px; }
|
||||
.form-team-name-h { color: #f5c518; }
|
||||
.form-team-name-g { color: #2196f3; }
|
||||
.giocatori { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
.giocatore { background: #fff; border-radius: 50%; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 11px; border: 2px solid #111; }
|
||||
|
||||
@@ -181,11 +186,11 @@ export function buildRefertoHtml(state, now = new Date()) {
|
||||
<div class="meta">${dataOra} · Modalità: ${modalitaPartita}</div>
|
||||
</div>
|
||||
<div class="squadre-row">
|
||||
<span class="badge-ab">A</span>
|
||||
<span class="badge-ab">Casa</span>
|
||||
<span class="nome-sq">${nomi.home}</span>
|
||||
<span class="vs">vs</span>
|
||||
<span class="nome-sq">${nomi.guest}</span>
|
||||
<span class="badge-ab">B</span>
|
||||
<span class="badge-ab">Ospiti</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,15 +200,11 @@ export function buildRefertoHtml(state, now = new Date()) {
|
||||
<div class="finale-box">
|
||||
<div class="finale-box-header">Risultato finale</div>
|
||||
<table class="finale-tabella">
|
||||
<thead><tr><th>Set</th><th>A</th><th>B</th></tr></thead>
|
||||
<thead><tr><th>Set</th><th>${nomi.home}</th><th>${nomi.guest}</th></tr></thead>
|
||||
<tbody>${risultatoSetHtml}</tbody>
|
||||
</table>
|
||||
<div class="finale-vince">Vince ${setVinti.home >= setVinti.guest ? nomi.home : nomi.guest} ${setVinti.home} – ${setVinti.guest}</div>
|
||||
</div>
|
||||
<div class="risultato-riepilogo">
|
||||
<div class="risultato">${setVinti.home} – ${setVinti.guest}</div>
|
||||
<div class="modalita-label">set vinti</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -219,5 +220,7 @@ export function generaReferto(state) {
|
||||
const w = window.open('', '_blank')
|
||||
w.document.write(html)
|
||||
w.document.close()
|
||||
w.print()
|
||||
// Aspetta il caricamento delle immagini (icona servizio) prima di stampare,
|
||||
// altrimenti la stampa parte con risorse ancora in caricamento e appaiono vuote.
|
||||
w.addEventListener('load', () => w.print())
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ describe('generaReferto (referto.js)', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('apre una nuova scheda, scrive il referto e avvia la stampa', () => {
|
||||
it('apre una nuova scheda, scrive il referto e avvia la stampa dopo il caricamento', () => {
|
||||
const listeners = {}
|
||||
const popup = {
|
||||
document: { write: vi.fn(), close: vi.fn() },
|
||||
print: vi.fn(),
|
||||
addEventListener: vi.fn((evt, cb) => { listeners[evt] = cb }),
|
||||
}
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(popup)
|
||||
|
||||
@@ -26,6 +28,9 @@ describe('generaReferto (referto.js)', () => {
|
||||
expect(popup.document.write).toHaveBeenCalledTimes(1)
|
||||
expect(popup.document.write.mock.calls[0][0]).toContain('Antoniana')
|
||||
expect(popup.document.close).toHaveBeenCalledTimes(1)
|
||||
expect(popup.print).not.toHaveBeenCalled()
|
||||
|
||||
listeners.load()
|
||||
expect(popup.print).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user