51 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5 fcff950006 Bump versione a 0.9.2 e aggiorna il changelog
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:41:46 +02:00
davideandClaude Sonnet 5 99ca3b6495 Rende ad accordion l'elenco Profili in admin: una sola scheda aperta
Lo stato "quale giocatore è aperto" sale a Dashboard invece di essere
locale a ogni SchedaGiocatore, così aprirne una chiude quella precedente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:38:33 +02:00
davideandClaude Sonnet 5 457a0fe23d Aggiunge copertura test e documentazione per Notifiche attive in admin
Estrae la deduplica degli id giocatore in idsConNotificheAttive
(src/lib/notifiche-attive.server.ts), testata a livello unit. Aggiunge
un test di integrazione sui permessi della route (401/403/200), sullo
stesso schema di permessi-route.test.ts. Documenta il nuovo endpoint
in notifiche.md e la dashboard admin a tab in profilo-giocatore.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:32:11 +02:00
davideandClaude Sonnet 5 d8fc6ef04f Aggiunge la sezione Notifiche in admin e converte la dashboard a tab
Nuovo endpoint admin-only /api/public/notifiche-attive per contare e
elencare i giocatori con almeno un dispositivo iscritto alle notifiche
push. La dashboard admin passa da sezioni impilate a un menu a pillole
scorrevole (BarraSottosezioni), come già fatto in Classifica e Squadra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:23:30 +02:00
davideandClaude Sonnet 5 e99d853987 Rimuove la nota su chi vede i dati amministrativi dal profilo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:10:00 +02:00
davideandClaude Sonnet 5 efca4243b6 Semplifica la label del campo email in admin: solo "Email"
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:07:22 +02:00
davideandClaude Sonnet 5 abd6fafc0c Rimuove le dipendenze da Lovable: l'app non gira più nel suo editor
Tolti il login social e il reporting errori verso l'editor Lovable (codice
morto, mai importato o no-op in produzione), la cartella .lovable/ con i
piani dell'editor, e riscritto vite.config.ts senza il preset
@lovable.dev/vite-tanstack-config, esplicitando gli stessi plugin
(TanStack Start, Tailwind, tsconfig-paths, React, Nitro/cloudflare) che
forniva.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 13:59:46 +02:00
davideandClaude Sonnet 5 d52ceeb0f9 Bump versione a 0.9.1 e aggiorna il changelog
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 07:51:03 +02:00
Ivan CacciariandCursor a9f146d77e Migliora le schede dello storico partite: loghi, casa-ospite e chevron.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 21:01:49 +02:00
davideandClaude Sonnet 5 04e80a3531 Allinea la documentazione al codice: migration, roadmap e moduli mancanti
Emerso da un audit doc↔codice: PROJECT_STATE.md era fermo a M12 (23 migration)
mentre supabase/migrations/ ne ha 27, fino a M16; ROADMAP.md non citava MVP,
Turno palloni, Infortuni e Profilo Giocatore come voci a sé pur essendo tutte
implementate; profilo-giocatore.md era l'unico modulo senza l'intestazione
Stato/File principali degli altri.

Aggiunge anche le due spec mancanti in docs/modules/: Squadra (anagrafica,
useRosa/useAnagraficaRosa, gestione admin, classifica interna) e Calendario ed
Eventi (vista mensile vs gestione admin, pulizia a cascata alla cancellazione).

Corregge inoltre la nota sul versionamento in CHANGELOG.md: sempre a tre cifre
(x.y.z), mai x.y.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 13:31:39 +02:00
davideandClaude Sonnet 5 5569d08b59 Aggiunge il dettaglio esteso di partita: formazioni e scontri diretti dal CSI
In Campionato > Storico partite ogni gara diventa cliccabile: verso /partita/$id
se collegata a un evento CrAPP, verso la nuova /partita-csi/$id altrimenti (nessuna
creazione automatica di eventi dal calendario CSI, quindi molte gare ne sono prive).

Il dettaglio aggiunge formazioni (titolari/panchina/staff), storico scontri diretti
e probabilità di vittoria calcolata dal CSI, letti on-demand da tre nuovi endpoint
per-partita (match-main/players/stats.php) dietro /api/public/csi-partita/$id, con
cache server per-partita separata da quella di /api/public/csi — è un dato aperto a
richiesta, non precaricato per tutti come classifica e partite.

Include anche logo squadra e metadati leggeri (girone, n° gara, arbitro, link al
referto ufficiale) già presenti nel JSON esistente, a costo zero.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:07:03 +02:00
davideandClaude Sonnet 5 c628997934 Mostra anche la classifica di Coppa in Campionato > Classifica
parseClassifica() legge project-sheets.php?project_id=848 con lo stesso
parsing del girone di campionato (stessa struttura di tabella), quindi
nessun parser dedicato. La Coppa resta limitata alla fase a gironi: la
finale secca vive in un project_id figlio non tracciato dal codice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:29:34 +02:00
davideandClaude Sonnet 5 8353761b6e Corregge il dettaglio MVP in classifica: solo partite, non tutti gli eventi
Il sottotitolo "N partite giocate" per il criterio MVP usava totaliEventiGiocatore(),
che conta anche gli allenamenti: un numero fuorviante perché l'MVP si vota solo alle
partite del calendario interno (eventi_app), non a quelle del sito CSI. Aggiunge
contaPartiteGiocate() (presenze.ts) e Giocatore.partiteGiocate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:02:38 +02:00
davideandClaude Sonnet 5 15f058f760 Corregge la classifica interna di Squadra a parità di punteggio
I giocatori a pari valore condividono ora la stessa posizione (dense rank) invece di
essere numerati in sequenza, e il sottotitolo di ogni riga segue il criterio scelto
invece di mostrare sempre le presenze consecutive. Per Palloni mostra le volte
consecutive in cui il giocatore li ha portati (nuovo Giocatore.seriePalloni).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:55:06 +02:00
davideandClaude Sonnet 5 8dfd81ebe4 Estende bonifica-evento.test.ts a tutte e 9 le tabelle collegate
Il test copriva solo 3 delle 9 istruzioni DELETE dentro
bonifica_dati_evento_orfani() (risposte_presenze, mvp_voti,
pagelle_voti), lasciando cacche_partita, turni_palloni,
scout_sessioni, scout_live, scout_partite e badge_social_voti senza
verifica automatica. Ora inserisce una riga orfana in tutte e sei le
tabelle evento_id e una riga orfana più due storiche (Scout, CSI) in
tutte e tre le tabelle match_id, verificando la stessa distinzione
delicata su ciascuna, non solo su un sottoinsieme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:37:01 +02:00
davideandClaude Sonnet 5 bef5542ce3 Rende testabile la bonifica di M15 come funzione RPC (M16)
M15 ha ripulito una tantum le righe orfane con un blocco di DELETE
verificato solo a mano, senza lasciare nessuna rete di sicurezza
automatica per il futuro. Questa migration rende lo stesso corpo la
funzione bonifica_dati_evento_orfani(), riservata al service role, così
resta richiamabile se il trigger di M14 smettesse mai di funzionare.

Aggiunge test/integration/bonifica-evento.test.ts: inserisce una riga
orfana e una storica su id Scout/CSI, richiama la funzione via RPC e
verifica che tocchi solo la prima. Documenta l'aggiornamento in
DESIGN_DECISIONS.md (DD-029), DATABASE.md, test/README.md e CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:32:16 +02:00
davideandClaude Sonnet 5 57463f993b Bonifica una tantum le righe orfane da eventi cancellati prima di M14
M14 (DD-029) pulisce a cascata i dati collegati solo per le cancellazioni
future; questa migration ripulisce lo storico. Per risposte_presenze,
cacche_partita, turni_palloni, scout_sessioni, scout_live e scout_partite
elimina ogni riga il cui evento_id non esiste più in eventi_app.

Per mvp_voti, pagelle_voti e badge_social_voti serve più cautela: prima
del passaggio all'id evento CrAPP, match_id conteneva id Scout
(prefisso "s") o CSI (numerico o data-based), voti storici legittimi
mai collegati a un evento CrAPP. Il filtro si applica solo ai match_id
nel formato di nuovoIdEvento() ("e" + timestamp base36), così i vecchi
voti Scout/CSI restano intatti. Verificato manualmente sul database
locale prima dell'applicazione.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:24:43 +02:00
davideandClaude Sonnet 5 c7cd527045 Pulisce a cascata i dati collegati quando un evento viene cancellato (DD-029)
Un audit del modulo Obiettivi ha verificato che gli obiettivi non hanno
bisogno di pulizia: sono ricalcolati a runtime sull'elenco eventi
corrente. Il problema reale era un livello sotto: cancellare un evento
lasciava orfane le righe collegate in 8 tabelle (risposte_presenze,
cacche_partita, mvp_voti, pagelle_voti, badge_social_voti,
turni_palloni, scout_sessioni, scout_live, scout_partite), nessuna con
una foreign key verso eventi_app.

Aggiunge un trigger AFTER DELETE su eventi_app (migration
m14_pulizia_dati_evento_cancellato) invece di una FK con CASCADE, non
applicabile per dati storici disallineati (match_id che a volte punta
a id Scout/CSI). Verificato con un nuovo test di integrazione contro
il database locale; documentato in DESIGN_DECISIONS.md, DATABASE.md,
obiettivi-squadra.md, test/README.md e CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:16:29 +02:00
davideandClaude Sonnet 5 00235d1594 Applica una soglia minima di campione a Media voto e MVP in home (DD-028)
Rimuove l'hint statico "+2 questo mese" dalla StatTile Presenze. La StatTile
Media voto usa ora la stessa soglia minima di voti del badge Pagellone
(VOTI_MINIMI_PAGELLA), tramite la nuova funzione pura testabile
mediaVotoColpoDOcchio(). L'MVP di partita richiede un quorum minimo di 2
voti totali (VOTI_MINIMI_MVP) oltre al margine netto già richiesto: un
solo voto non assegna più la vittoria, con effetto anche sui conteggi
già mostrati. Aggiorna test unitari e di integrazione, e documenta la
decisione in DESIGN_DECISIONS.md, pagelle.md, mvp.md e CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 09:56:56 +02:00
davideandClaude Sonnet 5 6d177d5d8a Documenta le StatTile home e rimuove i riferimenti obsoleti a v1.0/v1.1/v2.0
Aggiunge alle doc di presenze/pagelle il collegamento con le StatTile di
home «Colpo d'occhio» e registra nel changelog la rimozione dell'hint
statico. Uniforma i moduli allo stato "implementato" senza tag di
versione e riscrive in DESIGN_DECISIONS.md i riferimenti a v1.0/v1.1/v2.0
con descrizioni neutre, coerenti con il fatto che il progetto non usa
più quella numerazione.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 09:32:19 +02:00
Ivan CacciariandCursor 89b08349c4 Rinomina la tab Stagione in Season sul Profilo.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 09:19:45 +02:00
Ivan CacciariandCursor f1d61ebd6e Allinea le tab del Profilo a una sola riga a larghezza uguale.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 09:17:49 +02:00
Ivan CacciariandCursor dbf60d3a7e Affina Profilo e Squadra: logo home, tab Docs uguali, separatore classifica.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 09:10:22 +02:00
Ivan CacciariandCursor b0c1e2f826 Rinomina la tab Statistiche in Stats su Squadra.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 08:47:56 +02:00
Ivan CacciariandCursor 0eb2d60569 Sposta la classifica interna in Statistiche e uniforma le tab Squadra.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 08:47:56 +02:00
davideandClaude Sonnet 5 3b305b0f8e Completa la copertura test di tutti i badge e corregge due bug trovati in audit
Audit dedicato su tutti i 16 badge: s-tiebreak non applicava la soglia minima di
voti pagella di Pagellone sullo stesso campo mediaVoto (corretto), s-cacche
prometteva "partite di campionato" senza che il codice lo verificasse mai
(corretta la descrizione, comportamento invariato). Aggiunti test unit e
integration end-to-end mancanti su badge segreti e social, con dati scritti a
database anche per i cinque segreti che prima ne erano privi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 15:36:33 +02:00
davideandClaude Sonnet 5 a451d4187f Completa la copertura test del badge Risposta lampo e ne documenta il comportamento
Analisi della logica di serieConferme() senza bug trovati; aggiunte le soglie
esplicite 3/8/15 e i rami mancanti (convocati ristretti, tipo misto, bordo
esatto delle 24h, evento futuro), più un integration test end-to-end che
verifica la mappatura creato_il/risposto_il con dati reali.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 15:07:59 +02:00
davideandClaude Sonnet 5 8a692bfe2f Documenta il salto automatico dei test CSI quando il portale non risponde
Aggiunto un punto ai Limiti noti di collegamento-csi.md: il portale può essere
del tutto irraggiungibile (non solo cambiare formato, già coperto dal punto 5),
con l'episodio dell'8 settembre 2026 come esempio verificato. Spiega perché
api.test.ts salta quei 5 test invece di farli fallire, e che tornano a girare da
soli quando il CSI risponde di nuovo. Aggiunta la stessa nota, più breve, in
test/README.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:48:33 +02:00
davideandClaude Sonnet 5 cde01ff02b Salta (invece di fallire) i test CSI in api.test.ts quando il portale non risponde
Il portale CSI (livescore.csibologna.it) è momentaneamente in manutenzione lato
loro (risponde con un errore SQL in chiaro al posto del JSON su
getEventsByTeamId.php). I 5 test che dipendono dal CSI reale ora sondano una
volta sola /api/public/csi: se non è raggiungibile vengono saltati con salta()
invece di fallire, senza toccare la loro logica né cancellarli — tornano a girare
non appena il portale è di nuovo su.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:46:54 +02:00
davideandClaude Sonnet 5 d250f41749 Il badge Sherpa dei palloni conta solo i turni confermati, non le proposte
rosa.ts calcolava Giocatore.palloni su completaTurni() (turni salvati + proposte
automatiche di rotazione non ancora confermate da nessuno): un giocatore poteva
avanzare nel badge senza aver mai confermato un turno, solo perché l'algoritmo lo
proponeva per un evento passato. Ora passa a conteggioTurni() solo turniSalvati
(i turni davvero confermati in turni_palloni); completaTurni() resta in uso solo
per la UI di rotazione (TurnoPalloni.tsx, PromemoriaPalloni.tsx). Corregge anche
la StatTile "quante volte hai portato i palloni" nel profilo, che leggeva lo
stesso valore.

Aggiornato palloni-badge.test.ts per verificare il nuovo comportamento (un evento
non confermato non conta più per nessuno) invece del vecchio. docs/modules/
badge.md e palloni.md aggiornati di conseguenza, spostando la voce da "Limiti
noti" a "risolto".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:43:58 +02:00
davideandClaude Sonnet 5 4fb05748c3 Completa la copertura test del badge Sempre in palestra e ne documenta il comportamento
Nessun bug trovato: serieConsecutiva() gestisce correttamente buco (azzera) vs
infortunio (congela senza azzerare), lo stesso filtro sui convocati della funzione
pura protegge anche questo badge senza bisogno dell'estensione RLS di M13.
Confermato che il limite noto "risposto_il non ricostruibile prima di m9" riguarda
solo serie-conferme e il segreto s-mai-forfait, non questo badge: serieConsecutiva()
guarda solo lo stato della risposta, non il suo istante.

Aggiunti test unit sulle soglie (badges.test.ts) e un nuovo end-to-end
(serie-allenamenti-badge.test.ts) che scrive allenamenti e risposte reali sul
database locale e verifica l'intera pipeline fetchPresenze()/daRiga() ->
serieConsecutiva() -> statoBadge() attraverso le tre soglie, incluso il caso "un
buco azzera" e, separatamente, "un infortunio congela invece di azzerare".
docs/modules/badge.md aggiornato con la pipeline, la copertura test e la
descrizione corretta (l'infortunio non spezza la serie).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:39:26 +02:00
davideandClaude Sonnet 5 10d1455eff Completa la copertura test del badge Presenza fissa e ne documenta il comportamento
Nessun bug trovato: contaPresenzeGiocatore() filtra correttamente per convocati,
tipo evento e stato (presente/ritardo contano, assente/forse no), gli eventi
futuri restano esclusi. Confermato che, a differenza di MVP/pagelle/badge social,
questo badge non ha bisogno dell'estensione RLS di M13: il filtro sui convocati è
già nella funzione pura, non solo in UI.

Aggiunti test unit sulle soglie (badges.test.ts) e un nuovo end-to-end
(presenze-badge.test.ts) che scrive eventi e risposte reali sul database locale e
verifica l'intera pipeline fetchPresenze()/daRiga() -> contaPresenzeGiocatore() ->
statoBadge() attraverso le tre soglie, incluso il caso "non convocato, la riga
esiste ma non conta". docs/modules/badge.md aggiornato con la pipeline, la
copertura test e la correzione della dicitura "in stagione" (il conteggio è
sull'intero storico, coerente con gli altri badge).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:33:51 +02:00
davideandClaude Sonnet 5 e6a222f196 Completa la copertura test del badge Sherpa dei palloni e ne documenta il comportamento
Nessun bug nella logica di calcolo: soglie 3/6/10 corrette, gradoRaggiunto()/
statoBadge() si comportano come per gli altri badge da contatore. Trovato però un
comportamento reale ma non ovvio (già accennato in palloni.md, mai dimostrato con
dati veri): il conteggio del badge include anche le proposte automatiche di
completaTurni() non ancora confermate da nessuno, non solo i turni salvati in
turni_palloni.

Aggiunti test unit sulle soglie (badges.test.ts) e un nuovo end-to-end
(palloni-badge.test.ts) che scrive eventi/turni parzialmente confermati sul
database locale e verifica l'intera pipeline fetchTurni() -> completaTurni() ->
conteggioTurni() -> statoBadge(), incluso il caso "evento futuro non conta".
docs/modules/badge.md aggiornato con la pipeline, la copertura test e il limite
noto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:26:41 +02:00
davideandClaude Sonnet 5 1fdc6720a0 Sistema tutti gli errori di lint (prettier) rimasti su main
npx eslint --fix su test/unit/obiettivi.test.ts, test/integration/obiettivi.test.ts,
BarraSottosezioni.tsx, EventoCard.tsx e calendario.tsx — solo formattazione,
nessuna modifica di comportamento. Verificato con la suite unit (32/32 verde).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:12:49 +02:00
davideandClaude Sonnet 5 080379cc64 Sistema i buchi del badge Pagellone: soglia minima voti e voto limitato ai convocati
Il badge Pagellone si sblocca ora solo con almeno 5 pagelle ricevute
(VOTI_MINIMI_PAGELLA): un voto solo poteva sbloccarlo o farlo sparire senza
significatività statistica. Aggiunto Giocatore.votiPagella per farlo funzionare.

La migration m13 (DD-027) estende le policy RLS di M11 su pagelle_voti, mvp_voti e
badge_social_voti: votante e votato devono essere convocati all'evento (prima solo
un filtro applicativo), e per le pagelle anche pagelle_chiuse=false. Le policy
admin restano permissive di proposito. Applicata anche al progetto cloud.

Copertura test completa: unit sulla soglia minima, integration sulle nuove policy
RLS (permessi.test.ts) e un end-to-end reale (pagella-badge.test.ts) sul modello
di mvp-badge.test.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:08:52 +02:00
davideandClaude Sonnet 5 8c9cf370e8 Toglie duplicazioni dalla sezione Implementazione di badge.md
Il bullet MVP ripeteva per intero il meccanismo di voto già documentato in mvp.md
(e con un'imprecisione: l'RLS di m11 non è una barriera contro l'autovoto, impedisce
solo di votare a nome di un altro). Ora rimanda a mvp.md e tiene solo ciò che serve
a capire il badge. Tolto anche il bullet iniziale sui segreti, che ripeteva parola
per parola l'intro della sezione "Elenco badge".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:44:00 +02:00
davideandClaude Sonnet 5 c7588c5aa2 Completa la copertura test del badge MVP e riscrive la doc del modulo badge
Aggiunge il caso positivo RLS mancante per mvp_voti in permessi.test.ts e un nuovo
test end-to-end (mvp-badge.test.ts) che verifica l'intera pipeline voti reali ->
mvpVintiPerGiocatore() -> statoBadge(). docs/modules/badge.md ora elenca tutti e 16
i badge con come funzionano, la copertura test badge per badge e i due problemi
minori trovati in audit (badgeSbloccati() morta, categoria senza vincolo DB).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:38:30 +02:00
davideandClaude Sonnet 5 9b81fcf2fc Riscrive la doc del modulo obiettivi di squadra riflettendo lo stato attuale
Rende la doc autosufficiente: tabella completa dei 10 obiettivi con id, calcolo
e target; sezione dedicata alla dipendenza dal JSON CSI per le vittorie (o3/o4/o5)
con il limite del parsing silenzioso e i passi per fixarlo se il portale cambia
formato; tabella di copertura test per ogni obiettivo con i file esatti.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:06:37 +02:00
davideandClaude Sonnet 5 242941740b Cappa le vittorie di "10 vittorie in campionato" e completa la copertura test
o5 non aveva il Math.min(vittorie, 10) dei fratelli o3/o4: il progresso
mostrato non cambiava (già cappato al 100%), ma il valore grezzo sì, mostrato
senza cap in squadra.tsx/index.tsx (es. "15/10 vittorie" invece di "10/10").

Nessuno dei tre obiettivi aveva test. Le vittorie non toccano Supabase -
arrivano dal portale CSI via /api/public/csi - quindi l'integration test
estende test/integration/api.test.ts (che già chiama quella route dal vivo)
invece di test/integration/obiettivi.test.ts, verificando o3/o4/o5 sui dati
CSI reali del giorno.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:01:43 +02:00
davideandClaude Sonnet 5 7e51fe6345 Segnala in modo visibile se il portale CSI cambia formato
Le vittorie in campionato dipendono dal JSON delle partite (non dalla
classifica HTML, come erroneamente documentato prima). Se quel formato cambia,
il parsing fallisce in silenzio: nessun errore, nessuna notifica, le vittorie
restano ferme a 0% finché qualcuno non se ne accorge per caso.

Aggiunta partiteFormatoSospetto() in csi-core.ts: confronta gli eventi grezzi
con il risultato del parsing per distinguere un vero "formato cambiato" da un
legittimo "nessuna gara ancora in programma". La route logga l'errore server e
il flag arriva fino a /classifica, dove sostituisce la riga "Dati CSI
aggiornati alle..." con un badge discreto color warning - visibile a chi apre
la pagina, senza notifiche invasive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:01:32 +02:00
davideandClaude Sonnet 5 8c6fceb682 Documenta e copre con test l'obiettivo "Continuità di squadra"
Il target 12 non è un valore arbitrario da scalare con la rosa come gli altri
target fissi: è il minimo di giocatori per schierare due sestetti (6vs6) in
allenamento, confermato intenzionale. Documentato in
docs/modules/obiettivi-squadra.md.

Non aveva alcun test. Aggiunti unit test (rosa vuota, confine >= 3, target
fisso) e un integration test end-to-end che scrive tre allenamenti e presenze
reali sul database locale, calcola serieConsecutiva() (la stessa funzione pura
usata da useRosa() in produzione) sui dati riletti, e verifica il conteggio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:34:50 +02:00
davideandClaude Sonnet 5 0dadabfea6 Completa la copertura test dell'obiettivo "200 pagelle compilate"
C'era una sola asserzione (2 pagelle -> 2), nessun caso vuoto esplicito e
nessun integration test. Aggiunti il caso vuoto, il conteggio aggregato su
più match_id, e l'assert su o13 nell'integration test già scritto per la
media pagelle (stessi voti reali scritti su pagelle_voti, zero setup extra).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:29:13 +02:00
davideandClaude Sonnet 5 261578357a Completa la copertura test di o1, o2 e aggiunge quella di "Media pagelle da 7.5"
o1 e o2 avevano solo test con un evento singolo per volta: mancavano rosa vuota
(divisione per zero), verifica che le partite contino come gli allenamenti con
eventi sociali/compleanni esclusi, e l'aggregazione su più eventi dello stesso
mese. Aggiunti unit e integration test per entrambi.

"Media pagelle da 7.5" aveva solo il caso vuoto e un caso che faceva già media
esatta: aggiunti test sull'arrotondamento a una cifra decimale e
sull'aggregazione di voti da più partite, più un integration test che scrive
voti veri su pagelle_voti rispettando i vincoli reali della tabella.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:25:49 +02:00
davideandClaude Sonnet 5 447016f03b Completa la copertura test dell'obiettivo "250 presenze complessive"
C'era solo un confronto circolare (la stessa formula ricalcolata sugli stessi
dati reali) più il caso rosa vuota. Aggiunto un unit test con roster piccolo e
valori noti (5 + 10 + 15 = 30), e un integration test end-to-end che scrive
eventi/risposte veri sul database locale, calcola contaPresenzeGiocatore() (la
stessa funzione pura usata da useRosa() in produzione) sui dati riletti, e
verifica che obiettiviSquadra() sommi correttamente il risultato.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:19:27 +02:00
davideandClaude Sonnet 5 e13722eae9 Rende dinamica anche la scadenza di "Tutti rispondono alle convocazioni"
La scadenza era una data fissa (2026-09-30), stesso problema già risolto per
"presenze del mese" e "evento di squadra al mese": ora segue l'ultimo giorno
del mese corrente. Aggiunti unit test e integration test end-to-end contro il
database locale (isolando gli eventi di test, perché questo obiettivo aggrega
su tutti gli eventi indipendentemente dal mese).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:14:57 +02:00
davideandClaude Sonnet 5 2b30dd4708 Rende dinamico il mese degli obiettivi di squadra invece di una costante fissa
"Presenze del mese" e "evento di squadra al mese" erano ancorati a una costante
MESE = "2026-08": passato agosto restavano congelati sul mese scorso invece di
azzerarsi a ogni cambio mese. Ora il mese di riferimento (e per il primo anche
titolo e scadenza) segue la data corrente, con un parametro `oggi` iniettabile
per test deterministici. Aggiunti unit test e un integration test end-to-end
contro il database locale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:00:31 +02:00
davideandClaude Sonnet 5 440bf0cd56 Rimuove todo.md: appunti di lavoro della sessione, non documentazione di progetto
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:14:39 +02:00
davideandClaude Sonnet 5 8add483a5b Precarica il chunk delle pagine al touchstart sui tab della BottomNav
Nessuna rotta aveva preload configurato: il chunk JS di una pagina iniziava a
scaricarsi solo a tap completato, mai prima. Con 4 tab piccole (8-12KB l'una) e
sempre visibili, non c'e' motivo di aspettare il tap per iniziare.

preload="intent" sui 4 Link: parte al touchstart, prima che il tap sia completo.
Precarica solo il codice, non i dati (nessuna rotta usa loader), quindi nessun
costo aggiuntivo di letture verso Supabase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:10:05 +02:00
davideandClaude Sonnet 5 3258a24f20 Estende il fix di useIo/useRosa a EventoCard e altri 13 usi non statistici
EventoCard monta un hook per ogni card mostrata: usava useGiocatoreCorrente
(= useIo = useRosa, le 6 statistiche pesanti) solo per leggere il proprio id.
Il Calendario ne rende diverse insieme (prossimi eventi, compleanni, drawer del
giorno), quindi ogni apertura moltiplicava il ricalcolo dell'intera rosa.

Audit di tutti gli altri usi di useGiocatoreCorrente: 12 su 13 leggevano solo
id/nome/verita', mai una statistica. Corretti allo stesso modo (-> useGiocatoreBase):
benvenuto.tsx, VotazioneMvp, SondaggioCacche, VotoSocial, eventi.tsx,
PromemoriaPalloni (Home), TurnoPalloni, ScoutEntry, Pagelle.

partita.$id.tsx: `io` era dichiarato e mai piu' usato, rimosso.

Stesso pattern trovato anche su useRosa (non solo useGiocatoreCorrente) in
scout.tsx e RosaPresenze.tsx (montata su partita e allenamento): entrambi
passati al nuovo useAnagraficaRosa, esteso con ruolo e numero.

Unica eccezione: CelebrazioneBadge.tsx usa davvero le statistiche complete
(badge, MVP, serie) ed e' montato globalmente in __root.tsx - non downgradabile,
resta il costo di base piu' alto rimasto in giro.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:00:52 +02:00
davideandClaude Sonnet 5 82b6c6b333 Evita di calcolare le statistiche di tutta la rosa dove serve solo l'anagrafica
LinkProfilo (header di ogni pagina) e il Calendario (compleanni del mese) usavano
useIo/useRosa, che calcola MVP, pagelle, cacche, palloni e infortuni per l'intera
squadra tramite 6 hook e un useMemo pesante - anche se a entrambi serviva solo
id/nome/nascita/iniziali. Il Calendario in particolare pagava questo costo ad ogni
apertura solo per i compleanni del mese.

- crapp-data.ts: esporta inizialiDa, già usata internamente per il fallback avatar.
- ui-bits.tsx: LinkProfilo usa useGiocatoreBase (sola anagrafica) invece di useIo.
- rosa.ts: nuovo hook useAnagraficaRosa (id, nome, nascita) senza gli altri 5 hook
  statistici ne' il useMemo su tutta la rosa.
- eventi.ts: compleanniEventi accetta solo i tre campi che usa, non l'intero
  Giocatore.
- calendario.tsx: usa useAnagraficaRosa al posto di useRosa.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:47:31 +02:00
davideandClaude Sonnet 5 08b00627e5 Riduce il lag su Android: tab memoizzate, vetro/blur/drag disattivati sui device deboli
- squadra.tsx e classifica.tsx: il contenuto di ogni tab di BarraSottosezioni è ora
  memoizzato con useMemo, cosi' uno stato locale o un refetch in background non
  ricalcolano piu' anche le sezioni nascoste.
- BottomNav, BarraSottosezioni, calendario: useMotoRidotto (RAM bassa o
  prefers-reduced-motion) disattiva sui device deboli la catena di filtri SVG piu'
  pesante di .vetro, il backdrop-blur della barra tab e il drag orizzontale;
  su iOS e device performanti resta tutto invariato.
- styles.css: rimossa l'utility .materiale, orfana da quando BottomNav e' passata
  a .vetro.
- ui-bits.tsx: decoding="async" su TeamLogo.
- todo.md: traccia le cause individuate e lo stato di applicazione di ciascuna.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:39:40 +02:00
113 changed files with 8137 additions and 1499 deletions
@@ -1,38 +0,0 @@
# Obiettivi di squadra: sezione in Squadra + widget in home
## Stato attuale
Al momento c'è un solo "obiettivo di squadra" ed è hardcoded nella home (`src/routes/index.tsx`, riga 136): **"90% di presenze ad agosto"** con una barra finta all'82%. Non esiste uno schema dati né una sezione dedicata, e non è collegato a calendario, presenze o statistiche. Le voci "obiettivi" nelle descrizioni di Squadra e Profilo si riferiscono ai badge individuali, non a obiettivi di squadra.
## Cosa faccio
1. **Modello dati locale** in `src/lib/crapp-data.ts`
- Nuovo tipo `ObiettivoSquadra`: id, titolo, descrizione, target, valore attuale, unità, scadenza (opzionale), icona.
- Array `obiettiviSquadra` con gli obiettivi demo della stagione.
2. **Sezione "Obiettivi di squadra" dentro la scheda Squadra** (`src/routes/squadra.tsx`)
- Nuova sezione con la lista completa degli obiettivi: barra di progresso, percentuale, stato (in corso / completato).
- Ordinati con gli obiettivi in corso in cima e i completati in fondo.
- Nessuna nuova rotta e nessuna modifica al bottom nav.
3. **Widget home dinamico** (`src/routes/index.tsx`)
- Sostituisce l'obiettivo hardcoded: mostra sempre il primo obiettivo in corso preso dalla lista.
- Progresso calcolato dai dati reali dove possibile (es. presenze derivate dagli eventi).
4. **Obiettivi demo iniziali**
- 90% di presenze ad agosto (collegato agli eventi di agosto).
- 70% di risposte entro 24h nel prossimo mese.
- Prima vittoria del campionato (collegato allo storico match).
- 5 vittorie in campionato (collegato allo storico match).
- 10 vittorie in campionato (collegato allo storico match).
- 1 evento di squadra al mese (pizzata, ecc.).
## Cosa non cambia
- Resta un prototipo offline: i dati restano in `src/lib/crapp-data.ts`.
- I badge individuali restano come sono in `src/lib/badges.ts` e nella rosa di `src/routes/squadra.tsx`.
- Bottom nav e rotte invariate.
@@ -1,58 +0,0 @@
# Rendere CrAPP operativa: migrazione dal prototipo al Cloud
## Stato attuale
L'app è un prototipo UI con alcune funzioni già collegate a Lovable Cloud:
- Già sul Cloud: voti MVP (`mvp_voti`), iscrizioni push (`push_subscriptions`), sessioni scout (`scout_sessioni`), turni palloni (`turni_palloni`).
- Ancora in locale come dati demo: rosa giocatori, calendario eventi, storico partite, classifica CSI, statistiche individuali, presenze/assenze, badge.
## Cosa serve per renderla operativa
1. **Rosa e profili giocatori sul database**
- Creare tabella `profiles` (o `giocatori`) con nome, numero maglia, ruolo, data di nascita, foto profilo.
- Collegare ogni riga all'utente autenticato corrispondente.
- Rimuovere la rosa statica da `src/lib/crapp-data.ts` e caricarla dal backend.
2. **Autenticazione reale**
- Sostituire la semplice selezione "Chi sei?" in `localStorage` con login email/password o OAuth (Google).
- Ogni giocatore accede con le proprie credenziali e vede solo i propri dati modificabili.
- Necessaria per garantire che uno scout o un voto MVP provenga davvero da quel giocatore.
3. **Calendario eventi persistente**
- Tabella `eventi` con tipo, titolo, data, ora, luogo, avversario, casa/fuori.
- Tabella `presenze` (evento_id, giocatore_id, stato, aggiornato_il).
- I compleanni possono restare derivati dalla data di nascita dei giocatori.
4. **Statistiche e badge dinamici**
- Tabella `statistiche` o `azioni_scout` (evento_id, giocatore_id, tipo azione, valore, creato_il).
- I badge vengono calcolati in tempo reale dalle statistiche accumulate, senza valori fissi in `crapp-data.ts`.
5. **Scout live collegato ai dati reali**
- Le azioni registrate in `scout.tsx` devono scrivere sulle tabelle eventi/statistiche.
- Mantenere il lock di modifica singolo e l'attivazione solo il giorno della partita.
6. **Classifica CSI**
- Tabella `classifica` aggiornata manualmente da un admin o importata dal sito CSI quando disponibile.
- Per ora nessuna API CSI ufficiale: si inserisce a mano o si copia/incolla.
7. **Notifiche push definitive**
- Verificare che i cron job inviino correttamente i promemoria palloni.
- Aggiungere notifiche per conferma eventi, promemoria presenze, MVP votabile.
8. **Ruoli e permessi**
- Definire chi può creare/modificare eventi (capitano/admin).
- Chi può fare scout (designato per partita).
- Chi può modificare i turni palloni (tutti, come da tua richiesta).
## Cosa resta salvato nel Cloud
Sì: tutto ciò che viene scritto sulle tabelle Lovable Cloud/Supabase resta salvato online e condiviso tra tutti i dispositivi.
I dati demo in `src/lib/crapp-data.ts` invece no: sono file statici, quindi ogni aggiornamento dell'app li sovrascrive e ogni telefono li vede identici.
## Decisioni da prendere insieme
- Vuoi abilitare login email/password per ogni giocatore, o preferisci mantenere la selezione "Chi sei?" senza password per semplicità?
- Chi gestirà inserimento eventi e aggiornamento classifica: solo alcuni o tutta la squadra?
- Vuoi procedere per fasi (prima rosa + calendario + presenze, poi statistiche) o tutto insieme?
@@ -1,25 +0,0 @@
# Ridimensionamento badge nella lista squadra
## Obiettivo
Rendere i badge accanto al nome del giocatore nella lista squadra più compatti e meno invasivi, mantenendo lo stile stilizzato (icone Lucide colorate per grado) e lasciando la scheda espansa con una dimensione leggibile.
## Modifiche previste
1. **Lista squadra (`src/routes/squadra.tsx`)**
- Ridurre le icone badge sbloccati mostrate accanto al nome da `h-4 w-4` (16px) a `h-3 w-3` (12px).
- Mantenere il massimo di 3 badge visibili e il contatore `+N` con testo ridotto a `text-[9px]` per armonizzare.
- Lasciare invariati avatar, nome, ruolo ed età.
2. **Scheda giocatore espansa (`src/routes/squadra.tsx`)**
- Portare le icone badge dalla dimensione attuale a `h-4 w-4` (16px), leggibili ma non troppo grandi.
- Mantenere card, colori per grado (bronzo/argento/oro) e testi descrittivi.
3. **Verifica**
- Controllare la preview su `/squadra` per confermare che i badge in lista siano discreti e la scheda espansa rimanga leggibile.
- Eseguire build per assicurarsi che non ci siano errori di tipo o stile.
## Cosa non cambia
- Colori dei gradi, soglie badge, logica di sblocco e votazione MVP.
- Layout generale della pagina e bottom navigation.
@@ -1,14 +0,0 @@
# Rimuovere placeholder "Livello 7" dal Profilo
## Obiettivo
Eliminare il testo statico "Livello 7" dalla scheda profilo, dato che non è collegato a nessun calcolo reale e l'utente preferisce toglierlo per ora.
## Modifica
- `src/routes/profilo.tsx`: rimuovere il paragrafo `<p className="font-display text-2xl leading-none">Livello 7</p>` (riga 116) e, se necessario, riallineare il layout circostante per evitare spazi vuoti strani.
## Verifica
- Build senza errori.
- Preview della pagina Profilo: nessun riferimento a "Livello" visibile.
@@ -1,34 +0,0 @@
# Selezione giocatore al primo avvio
Aggiungere un flusso di onboarding che chiede "Chi sei?" la prima volta che l'app viene aperta, memorizzando la scelta in `localStorage`. Il profilo e la home si aggiorneranno automaticamente in base al giocatore selezionato.
## Cosa cambia
1. **Nuovo store `src/lib/user-store.ts`**
- Persiste in `localStorage` l'`id` del giocatore scelto.
- Espone `useGiocatoreCorrente()` che restituisce il giocatore selezionato o `null`.
- Espone `impostaGiocatore(id)` e `resetGiocatore()`.
2. **Nuova route `/benvenuto`**
- Schermata full-screen con logo, titolo "Benvenuto in CrAPP" e lista scrollabile della rosa.
- Ogni riga mostra iniziali, nome, ruolo e numero maglia.
- Al tap su un giocatore, lo store viene aggiornato e l'utente viene portato a `/`.
- Non mostra la bottom navigation.
3. **Reindirizzamento condizionato in `__root.tsx`**
- Se non è ancora stato selezionato un giocatore, qualunque route apre `/benvenuto`.
- Dopo la scelta, l'app funziona normalmente.
4. **Sostituzione di `giocatoreCorrente` con `useGiocatoreCorrente()`**
- Aggiornare `src/routes/index.tsx` per salutare il giocatore selezionato e mostrarne le statistiche rapide.
- Aggiornare `src/routes/profilo.tsx` per renderlo il profilo personale del giocatore scelto.
5. **Cambio utente dalle impostazioni**
- In `src/routes/profilo.tsx`, aggiungere una voce "Cambia giocatore" che resetta la selezione e porta a `/benvenuto`.
## Note tecniche
- `localStorage` viene letto solo lato client, usando `useSyncExternalStore` per evitare mismatch di hydration.
- La rosa reale è già presente in `src/lib/crapp-data.ts` (`giocatori`).
- La costante esportata `giocatoreCorrente` verrà rimossa; i componenti consumeranno il nuovo hook.
- Nessun backend richiesto: resta un prototipo locale.
@@ -1,45 +0,0 @@
# Turno palloni nel calendario + promemoria
Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra tutti, con rotazione automatica proposta dall'app e promemoria (in-app e push).
## Cosa vedrà la squadra
- **Riga "Palloni" su ogni evento** (allenamento e partita) nella card del calendario e in Home: avatar + nome dell'incaricato, oppure "Da assegnare".
- **Chiunque può cambiarlo**: tocco sulla riga, si apre la lista della rosa, si sceglie il nome. La modifica è immediata e visibile a tutti.
- **Proposta automatica a rotazione**: l'app suggerisce chi non ha ancora fatto il turno di recente (o è stato meno volte incaricato). Il suggerimento è solo una proposta: resta sempre modificabile.
- **Storico turni** nella scheda squadra/giocatore: quante volte ciascuno ha portato i palloni.
- **Due promemoria per l'incaricato**:
1. il giorno stesso dell'evento in cui li deve **prendere** a fine allenamento/partita;
2. il giorno dell'evento successivo, per ricordargli di **riportarli**.
- I promemoria arrivano come banner ben visibile in Home e, per chi attiva le notifiche, come notifica push sul telefono.
## Impostazione tecnica
**Backend (Lovable Cloud)**
- Attivazione di Lovable Cloud.
- Tabella `eventi` (spostando i dati demo attuali su database) o, in alternativa minima, tabella `turni_palloni` con `evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`. Scelgo la seconda per limitare il refactor: gli eventi restano in `crapp-data.ts` finché non si passa a calendario dinamico.
- Tabella `push_subscriptions` (giocatore_id, endpoint, chiavi) per le notifiche.
- Grant espliciti + RLS: lettura e scrittura aperte a tutti gli utenti dell'app (nessun login previsto oggi → policy per `anon` limitate a queste tabelle, nessun dato personale sensibile).
- Server functions in `src/lib/palloni.functions.ts`: `getTurni`, `setTurno`, `suggerisciTurno`.
**Frontend**
- Nuovo componente `TurnoPalloni` usato in `EventoCard` e in Home.
- Lettura via TanStack Query (`ensureQueryData` nel loader, `useSuspenseQuery` nel componente), invalidazione dopo la modifica.
- Banner promemoria in Home basato su data odierna + evento successivo, mostrato solo al giocatore selezionato in `user-store`.
**Notifiche push**
- Service worker dedicato al messaging (separato dalla PWA esistente), chiavi VAPID salvate come secret.
- Schermata in Profilo: "Attiva notifiche palloni" con richiesta di permesso.
- Invio schedulato tramite un endpoint `src/routes/api/public/promemoria-palloni.ts` protetto da secret, richiamato una volta al giorno da un job pianificato (pg_cron).
- Nota: su iPhone le notifiche push funzionano solo se l'app è installata dalla schermata Home.
## Ordine di lavoro
1. Attivare Lovable Cloud e creare tabelle + policy.
2. Server functions + UI del turno palloni (assegnazione manuale condivisa).
3. Rotazione automatica suggerita + storico turni.
4. Banner promemoria in-app.
5. Notifiche push + job giornaliero.
-5
View File
@@ -1,5 +0,0 @@
{
"schemaVersion": 1,
"template": "tanstack_start_ts_current",
"revision": "tanstack_start_ts_current-9e5645c506e5"
}
+28 -6
View File
@@ -1,6 +1,6 @@
# Project State
Ultimo aggiornamento: 06/09/2026
Ultimo aggiornamento: 09/09/2026
## Stato generale
@@ -10,7 +10,9 @@ Backend migrato al nuovo Supabase proprietario. Autenticazione Google, dashboard
amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzione su `main`.
Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora
sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati
reali (M9).
reali (M9). Prima versione pre-release rilasciata (0.9.0, vedi `docs/CHANGELOG.md`). Cancellare
un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane da
cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
---
@@ -21,7 +23,8 @@ reali (M9).
- Cursor e Claude Code come ambienti di sviluppo
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
- 23 migration in `supabase/migrations/`, fino a `m12_niente_autovoto`
- 27 migration in `supabase/migrations/`, fino a `m16_funzione_bonifica_dati_evento_orfani`
(09/09/2026)
- Sviluppo locale verificato con il nuovo Supabase
---
@@ -36,8 +39,9 @@ reali (M9).
## Database
- Schema v1.0 e migration da M1 a M12 applicate al nuovo Supabase (`m12_niente_autovoto`
in produzione dal 06/09/2026, verificata con `npx supabase migration list`)
- Schema v1.0 e migration da M1 a M16 applicate al nuovo Supabase
(`m16_funzione_bonifica_dati_evento_orfani` in produzione dal 09/09/2026, verificata con
`npx supabase migration list`)
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`)
più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori
attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
@@ -61,6 +65,19 @@ reali (M9).
scoutate concluse, e collegamento della tabella `scout_sessioni` (già presente nello
schema ma mai usata) al blocco condiviso dello Scout Live — prima entrambi vivevano solo
in `localStorage`, quindi visibili a un solo dispositivo
- Migration `m13_convocati_e_pagelle_chiuse`: le RLS di `mvp_voti`/`pagelle_voti`/
`badge_social_voti` richiedono che votante e votato siano convocati all'evento (e per le
pagelle anche `pagelle_chiuse = false`)
- Migration `m14_pulizia_dati_evento_cancellato` (DD-029): cancellare un evento pulisce a
cascata, tramite trigger, tutte le tabelle collegate (`risposte_presenze`,
`cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`,
`scout_sessioni`, `scout_live`, `scout_partite`)
- Migration `m15_bonifica_dati_evento_orfani`: bonifica una tantum delle righe orfane da
cancellazioni di eventi precedenti a M14, senza toccare i vecchi voti MVP/pagelle/badge
social legati a id Scout o CSI
- Migration `m16_funzione_bonifica_dati_evento_orfani`: la stessa logica di bonifica di M15
resta richiamabile come funzione `bonifica_dati_evento_orfani()` (riservata al service
role), se mai servisse di nuovo
---
@@ -68,13 +85,18 @@ reali (M9).
- Squadra
- Presenze
- Badge
- Badge (incluso badge social)
- Scout Live (blocco e archivio partite sincronizzati tra dispositivi, migration `m7_scout_partite`)
- Pagelle
- MVP
- Obiettivi di squadra
- Turno palloni (specifica in `docs/modules/palloni.md`)
- Infortuni, in forma minima (specifica in `docs/modules/infortuni.md`)
- Notifiche
- Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`)
- Serie di presenze (specifica in `docs/modules/serie-presenze.md`)
- Collegamento CSI: classifica campionato e Coppa, storico e dettaglio partita — formazioni,
scontri diretti (specifica in `docs/modules/collegamento-csi.md`)
---
+4 -15
View File
@@ -5,7 +5,6 @@
"": {
"name": "crapp",
"dependencies": {
"@lovable.dev/cloud-auth-js": "^1.1.2",
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.101.1",
@@ -27,7 +26,7 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@lovable.dev/vite-tanstack-config": "2.8.5",
"@tanstack/devtools-vite": "^0.8.3",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
@@ -130,14 +129,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lovable.dev/cloud-auth-js": ["@lovable.dev/cloud-auth-js@1.1.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", {}, "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg=="],
"@lovable.dev/vite-plugin-dev-server-bridge": ["@lovable.dev/vite-plugin-dev-server-bridge@1.2.1", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-JdADRwpEJA5t0ggXbd96dND23Wsp69Af9lVSV5m7MnMGxJZlPgqAqz7HUKcfJESfI5M5yFZOl9e0x76JMMDOAg=="],
"@lovable.dev/vite-plugin-hmr-gate": ["@lovable.dev/vite-plugin-hmr-gate@1.3.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.3.5.tgz", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-LxCj6JIbYRQ6peMm2aVn/bhHLkwZ5eZ4Cn6gmh7LfjaplqZHlA24nhCowgt6ZLfbckc4d8tSy0He7H8EiFEXag=="],
"@lovable.dev/vite-tanstack-config": ["@lovable.dev/vite-tanstack-config@2.8.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-2.8.5.tgz", { "dependencies": { "@lovable.dev/vite-plugin-dev-server-bridge": "^1.2.1", "@lovable.dev/vite-plugin-hmr-gate": "^1.3.4", "@tanstack/devtools-vite": "^0.8.1", "lightningcss": "^1.30.0" }, "peerDependencies": { "@tailwindcss/vite": ">=4.0.0", "@tanstack/react-start": ">=1.100.0", "@vitejs/plugin-react": ">=4.0.0", "nitro": ">=3.0.260603-beta", "vite": ">=5.0.0 <9.0.0", "vite-tsconfig-paths": ">=6.0.0" }, "optionalPeers": ["nitro"] }, "sha512-qPNxEXjvRsrbJHKgHxuLWpRW/gu2nIM+62qjTUCbCno5nRPMKyJQNsgjh48qjAkS6T0MRIkY/3BENNXeGsi5hw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" } }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="],
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
@@ -424,7 +415,7 @@
"canvas-confetti": ["canvas-confetti@1.9.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@@ -800,10 +791,6 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/devtools-bundler-core/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@tanstack/devtools-vite/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@tanstack/router-plugin/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
@@ -820,6 +807,8 @@
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"h3/srvx": ["srvx@0.12.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g=="],
"h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
+1 -1
View File
@@ -4,4 +4,4 @@ saveTextLockfile = true
minimumReleaseAge = 86400
# Each entry bypasses the 24h guard for one package — confirm with the user
# before adding any.
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config", "@lovable.dev/mcp-js", "@lovable.dev/vite-plugin-dev-server-bridge", "@lovable.dev/vite-plugin-hmr-gate", "@lovable.dev/email-js", "@lovable.dev/webhooks-js"]
minimumReleaseAgeExcludes = []
+75 -73
View File
@@ -1,84 +1,86 @@
# Changelog
Tutte le modifiche significative del progetto, dalla più recente. Il formato segue
[Keep a Changelog](https://keepachangelog.com/it/1.1.0/) e la numerazione
[Semantic Versioning](https://semver.org/lang/it/): il numero di versione è quello di
`package.json`.
Le modifiche rilevanti di CrAPP sono documentate qui, in ordine di rilascio. Il formato
segue [Keep a Changelog](https://keepachangelog.com/it/1.1.0/): ogni versione ha una data
e le voci sono divise per categoria (Aggiunto, Modificato, Sicurezza...). L'elenco
completo delle funzionalità, fatte e previste, sta in [ROADMAP.md](ROADMAP.md); qui si
registra solo _quando_ una voce è stata rilasciata e con quale versione.
L'elenco delle funzionalità disponibili e previste non si ripete qui: sta in
[ROADMAP.md](ROADMAP.md), che elenca il _cosa_ senza numeri di versione — quelli stanno solo
qui.
Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Il progetto è pre-1.0 (`0.y.z`):
finché resta sotto `1.0.0` un aumento di `y` può includere anche cambi non compatibili
all'indietro.
## [Non rilasciato] — 0.9.0
## [Non rilasciato]
Prima versione, pre-release.
## [0.9.2] - 2026-09-10
### Aggiunto
- Login con Google tramite Supabase Auth e collegamento automatico dell'account al proprio
giocatore confrontando l'email (DD-011, DD-018; migration `m5_email_giocatori_squadra`):
senza sessione non si entra in nessuna schermata.
- Dashboard amministratore `/admin`: stato dei profili, download di documento, certificato e
foto tessera, export CSV per il tesseramento, aggiunta e disattivazione dei giocatori
(la riga non viene mai eliminata, così presenze, voti e badge restano agganciati al suo id).
- Profilo giocatore: dati anagrafici, documento, certificato medico e foto tessera con le
relative scadenze (migration `m2_profili_giocatore`, bucket privato), divisi nelle tab
Stagione, Documenti e Opzioni — [modules/profilo-giocatore.md](modules/profilo-giocatore.md).
- Tracciamento del tesseramento CSI: numero e data di tessera in `giocatori_squadra`, badge e
contatore in dashboard (migration `m8_tesseramento_csi`).
- Foto profilo condivise tra dispositivi tramite il bucket pubblico `avatar-giocatori`
(migration `m6_avatar_giocatori`).
- Serie di presenze calcolate sui dati reali (`serieConsecutiva()`), con la colonna
`risposto_il` che congela l'istante della **prima** risposta tramite trigger (migration
`m9_risposte_presenze_risposto_il`): sblocca la serie "Conferme 24h" e i badge "Risposta
lampo" e "Mai un forfait" — [modules/serie-presenze.md](modules/serie-presenze.md).
- Scout Live sincronizzato tra dispositivi: il blocco "chi sta scoutando" passa dalla tabella
`scout_sessioni` e le partite concluse vengono archiviate in `scout_partite` (migration
`m7_scout_partite`). Si apre dalla sezione «Scout live» di `/partita/$id`, solo il giorno
della partita, e può usarlo chiunque sia autenticato: uno per volta, grazie al lock.
- Votazione MVP legata all'evento CrAPP e non al referto CSI o allo Scout: si apre due ore
dopo `data`+`ora` della partita, anche senza risultato caricato — [modules/mvp.md](modules/mvp.md).
- Sondaggio pre-partita aperto dalle 8:00 del giorno della partita fino al fischio d'inizio
(poi resta chiuso, anche nei giorni successivi) e pulsante «Avvisa tutti del sondaggio» per
gli amministratori (`POST /api/public/apri-sondaggio`); nessun cron, l'invio è manuale.
- Turni palloni con rotazione automatica sulle partite e assegnazione manuale per gli
allenamenti, che restano «da assegnare» finché non si sceglie (migration M10).
- Notifiche push con il testo cifrato **dentro** la push (`aes128gcm`, RFC 8291), così
arrivano anche ad app chiusa e a schermo bloccato (DD-026).
- Collegamento CSI: classifica e risultati ufficiali letti dal portale Livescore CSI Bologna
(stagione 2025/26, Open Misto Eccellenza, Girone B) —
[modules/collegamento-csi.md](modules/collegamento-csi.md).
- Note dell'evento visibili in `/allenamento/$id` e `/partita/$id`, con gli a capo mantenuti.
- «Segnala un bug» e «Suggerisci una nuova funzionalità» in `/profilo`: due link che aprono
una issue GitHub sul template giusto, senza tabelle né schermate di gestione.
- Interfaccia accessibile: contrasto dei token colore sopra 4.5:1, `viewport-fit=cover` e
`theme-color` coerenti con un'app chiara, `lang="it"`, `:focus-visible` globale, tocchi da
44px, `aria-current`/`aria-pressed`/`aria-controls`/`aria-busy`, niente testo sotto i 12px.
- Movimento con molle interrompibili di `motion` al posto delle `@keyframes` a durata fissa,
swipe fra i mesi del calendario e barre di progresso che misurano l'avanzamento tra un
traguardo e il successivo (DD-021).
- Suite di test in `test/` (unit, integration, end-to-end) eseguita con bun e senza nuove
dipendenze: `npm run test` e `npm run test:all`.
- Convenzioni interne: primitive condivise (`Card`, `Campo`, `classiInput` in `ui-bits`),
cache aggiornata con `setQueryData` invece di rileggere il database dopo ogni scrittura, e
logica pura estratta in `src/lib/` con i suoi test.
- Infrastruttura di sviluppo: migrazione da Lovable a sviluppo locale, repository GitHub
indipendente, deploy automatico su Vercel.
- **Dashboard amministratore** — nuova tab "Notifiche" che mostra quanti giocatori hanno
le notifiche push attive e chi sono.
### Modificato
- **Dashboard amministratore** — le sezioni impilate diventano un menu di tab scorrevole a
pillole (come Squadra e Campionato); nell'elenco Profili resta aperta una sola scheda
alla volta.
- **Profilo** — testi dei campi amministrativi semplificati (label email, rimossa la nota
su chi vede quei dati).
### Rimosso
- Le dipendenze e il codice legati all'editor Lovable (login social e reporting errori
verso l'editor): l'app non ci gira più.
## [0.9.1] - 2026-09-10
### Modificato
- **Storico partite** — ogni scheda mostra il logo accanto al nome di entrambe le squadre
(CRAP e avversario), risultato e parziali in ordine casaospite (verde/rosso restano
vittoria/sconfitta CRAP) e un chevron a destra per chiarire che la riga apre il dettaglio.
## [0.9.0] - 2026-09-09
Prima versione pre-release: lo sviluppo precedente non era versionato a parte, quindi
questa release riunisce tutto ciò che l'app fa oggi in produzione.
### Aggiunto
- **Gestione squadra** — rosa dei giocatori con ruoli e dati anagrafici di base.
- **Profilo Giocatore** — dati personali e amministrativi, documento d'identità,
certificato medico (caricamento, scadenza, stato, download) e foto tessera in
un'unica schermata, sia lato giocatore sia lato amministratore; lo storico dei
certificati resta un'estensione futura.
- **Gestione tesseramenti CSI** — raccolta dei dati richiesti dal CSI, tracciamento di chi
è già tesserato (numero e data tessera) ed export CSV per il tesseramento.
- **Calendario** — eventi di allenamento e partita, con schermata di dettaglio dedicata.
- **Presenze** — conferma o rifiuto della partecipazione a un evento, visibile a tutta la
squadra al posto di chat e fogli condivisi.
- **Serie di presenze** — tre serie (presenze, conferme, allenamenti) calcolate sui dati
reali della rosa.
- **Scout Live** — un solo referente alla volta registra in tempo reale le azioni di gioco
durante la partita.
- **Badge** — gamification con gradi bronzo/argento/oro, badge segreti e badge social
votati tra compagni.
- **Pagelle** — voto tra compagni (1-10) a fine partita, con media personale e di squadra.
- **Votazione MVP** — elezione del migliore in campo della partita tramite voto tra
compagni, un voto a testa.
- **Obiettivi di squadra** — traguardi collettivi che avanzano con presenze, risposte alle
convocazioni, pagelle e risultati di campionato.
- **Turno palloni** — rotazione condivisa e promemoria di chi porta e riporta i palloni ad
allenamenti e partite.
- **Notifiche push** — promemoria intelligenti su un unico opt-in per dispositivo.
- **Dashboard amministratore** — vista aggregata su tesseramenti, certificati, presenze e
dati della rosa, con download CSV.
- **Collegamento CSI** — classifica di campionato e Coppa, storico partite e dettaglio di
ogni gara (formazioni, storico scontri diretti, probabilità di vittoria calcolata dal
CSI) letti in tempo reale dal portale ufficiale Livescore CSI Bologna, senza inserimento
manuale da parte degli amministratori.
- **Infortuni** — conteggio degli eventi saltati per infortunio, riusando lo stato di
presenza già registrato per le convocazioni.
### Sicurezza
- Migration `m4_solo_autenticati`: tolto al ruolo `anon` l'accesso alle tabelle dell'app
(applicata in produzione il 03/09/2026).
- I permessi di amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`): senza,
basterebbe scegliere il nome giusto per amministrare.
- Migration `m11_scritture_per_ruolo`: ogni voto è firmato con lo slot collegato all'account
(DD-023); il collegamento account → giocatore non è modificabile dal giocatore stesso
(DD-016).
- Migration `m12_niente_autovoto`: i vincoli `mvp_no_autovoto` e `badge_social_no_autovoto`
rifiutano l'auto-voto anche a chi scrive direttamente su PostgREST, come già faceva
`pagelle_voti`.
- Al voto MVP partecipano solo i presenti (o in ritardo) di quell'evento; il filtro è
applicativo, non RLS ([modules/mvp.md](modules/mvp.md)).
- La suite copre i rifiuti `401` di `richiediAdmin` (DD-024), i permessi di
`badge_social_voti` e le deroghe admin di M11, e verifica che un ripensamento non riscriva
`risposto_il` (trigger di `m9`).
- Autenticazione tramite Google via Supabase Auth, unico metodo di accesso; permessi
differenziati per ruolo (giocatore/amministratore) su tabelle e route.
+5 -5
View File
@@ -15,7 +15,7 @@ nessuna di esse da M4 (DD-011).
| ---------------------------------------------------------------- | ------------------------------------------------------------------- |
| `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) |
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) |
@@ -46,7 +46,7 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. |
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
@@ -63,9 +63,9 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
| Tabella | Scopo | Note |
| ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`). |
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli della v1.0. |
| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`). |
| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli originari della tabella; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. |
| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
## Turni e notifiche
+214 -29
View File
@@ -25,13 +25,13 @@ Serve a rispondere a domande del tipo:
| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile |
| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB |
| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli |
| [DD-009](#dd-009--tesseramento-csi-manuale-in-v11-integrazione-api-in-v20) | CSI manuale v1.1, API v2.0 |
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati-in-v1) | Niente storico certificati v1 |
| [DD-009](#dd-009--tesseramento-csi-manuale-integrazione-api-in-una-fase-successiva) | CSI manuale, poi integrazione API |
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati) | Niente storico certificati |
| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo |
| [DD-012](#dd-012--non-migrare-gli-id-giocatore-in-v11) | Non migrare ID in v1.1 |
| [DD-012](#dd-012--rimandare-la-migrazione-degli-id-giocatore) | Rimandare la migrazione ID |
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB |
| [DD-016](#dd-016--schema-dati-profilo-giocatore-v11-f0) | Schema dati Profilo Giocatore v1.1 |
| [DD-016](#dd-016--schema-dati-profilo-giocatore-f0) | Schema dati Profilo Giocatore |
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email |
| [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) | Il branch lo decide l'utente |
@@ -42,6 +42,9 @@ Serve a rispondere a domande del tipo:
| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate |
| [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale |
| [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato |
| [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati |
| [DD-028](#dd-028--soglia-minima-di-campione-per-media-voto-e-mvp-in-home) | Soglia minima Media voto e MVP |
| [DD-029](#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati) | Pulizia a cascata evento cancellato |
**In valutazione**
@@ -141,7 +144,7 @@ Ogni nuova funzionalità significativa viene prima **progettata e documentata**
**Conseguenze**
- Rallenta leggermente lavvio di nuove feature, ma riduce rework e discussioni infinite.
- I moduli v1.0 vanno retro-documentati quando possibile.
- I moduli preesistenti vanno retro-documentati quando possibile.
- Nessuna feature non documentata entra in produzione.
**Riesame**
@@ -184,7 +187,7 @@ indietro. Vedi DD-019.
**Stato:** Accettata
**Contesto**
CrAPP v1.0 è già usata dalla squadra per presenze, calendario, scout, badge e notifiche. Rischiare regressioni su moduli funzionanti vanifica la fiducia degli utenti.
CrAPP è già usata dalla squadra per presenze, calendario, scout, badge e notifiche. Rischiare regressioni su moduli funzionanti vanifica la fiducia degli utenti.
**Decisione**
Le nuove versioni **introducono** funzionalità. Non si riscrive un modulo già operativo salvo richiesta esplicita e pianificata.
@@ -305,34 +308,34 @@ Se la squadra chiede esplicitamente classifiche tecniche per ruolo.
---
### DD-009 — Tesseramento CSI manuale in v1.1, integrazione API in v2.0
### DD-009 — Tesseramento CSI manuale, integrazione API in una fase successiva
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
La v1.1 deve aiutare gli admin a raccogliere documenti e dati per il tesseramento CSI. Un collegamento automatico al sistema CSI è complesso e non urgente.
Il modulo Profilo Giocatore deve aiutare gli admin a raccogliere documenti e dati per il tesseramento CSI. Un collegamento automatico al sistema CSI è complesso e non urgente.
**Decisione**
- **v1.1:** profilo completo, dashboard admin, download documenti, export CSV con i campi richiesti dal CSI.
- **v2.0:** eventuale collegamento automatico a CSI (calendario, risultati, classifica ufficiale).
- **Prima fase:** profilo completo, dashboard admin, download documenti, export CSV con i campi richiesti dal CSI.
- **Fase successiva:** eventuale collegamento automatico a CSI (calendario, risultati, classifica ufficiale).
**Alternative scartate**
- Integrazione CSI già in v1.1 → scope troppo ampio, dipendenza da API esterne non controllate.
- Integrazione CSI già nella prima fase → scope troppo ampio, dipendenza da API esterne non controllate.
**Conseguenze**
- Gli admin guadagnano subito tempo (niente più Excel e chat per i documenti).
- Lexport CSV deve essere affidabile e completo: è il deliverable chiave della v1.1.
- Lexport CSV deve essere affidabile e completo: è il deliverable chiave di questa prima fase.
**Riesame**
Quando il CSI mette a disposizione API stabili o quando il volume di tesseramenti giustifica lautomazione.
---
### DD-010 — Profilo giocatore: niente storico certificati in v1
### DD-010 — Profilo giocatore: niente storico certificati
**Data:** agosto 2026
**Stato:** Accettata
@@ -341,7 +344,7 @@ Quando il CSI mette a disposizione API stabili o quando il volume di tesserament
Il certificato medico va aggiornato ogni stagione. Tenere lo storico di tutte le versioni complica upload, storage e privacy.
**Decisione**
In v1 il giocatore può **sovrascrivere** certificato e data di scadenza. Lo storico delle versioni precedenti non viene conservato.
Il giocatore può **sovrascrivere** certificato e data di scadenza. Lo storico delle versioni precedenti non viene conservato.
**Alternative scartate**
@@ -367,7 +370,7 @@ Se il CSI o il regolamento interno richiedono conservazione storica.
Oggi lapp identifica lutente con la selezione del giocatore da una lista, senza login. Documenti, certificati e dati personali richiedono sapere _chi_ sta operando e impedire accessi non autorizzati.
**Decisione**
Prima di completare il modulo Profilo Giocatore (v1.1), introdurre **login con Google o email** tramite Supabase Auth — non tramite Lovable Auth. Dopo il login, il giocatore associa il proprio profilo squadra.
Prima di completare il modulo Profilo Giocatore, introdurre **login con Google o email** tramite Supabase Auth — non tramite Lovable Auth. Dopo il login, il giocatore associa il proprio profilo squadra.
**Alternative scartate**
@@ -385,7 +388,7 @@ Dopo il rollout auth, se emergono problemi di adozione (giocatori poco digitali)
---
### DD-012 — Non migrare gli ID giocatore in v1.1
### DD-012 — Rimandare la migrazione degli ID giocatore
**Data:** agosto 2026
**Stato:** Accettata
@@ -394,11 +397,11 @@ Dopo il rollout auth, se emergono problemi di adozione (giocatori poco digitali)
Lapp usa identificativi semplici (`g1`, `g2`, …) collegati a presenze, voti, palloni e altre funzioni già in uso. Nel database esiste anche una tabella `giocatori` con UUID, non collegata al codice attuale.
**Decisione**
Per la v1.1 **non** unificare gli ID. I nuovi dati del profilo si agganciano agli identificativi già in uso. La migrazione verso UUID resta un lavoro separato, pianificato e testato.
Per ora **non** unificare gli ID. I nuovi dati del profilo si agganciano agli identificativi già in uso. La migrazione verso UUID resta un lavoro separato, pianificato e testato.
**Alternative scartate**
- Migrare tutto a UUID in v1.1 → rischio alto di rompere presenze, voti, scout e notifiche.
- Migrare subito tutto a UUID → rischio alto di rompere presenze, voti, scout e notifiche.
**Conseguenze**
@@ -406,7 +409,7 @@ Per la v1.1 **non** unificare gli ID. I nuovi dati del profilo si agganciano agl
- `DATABASE.md` va tenuto aggiornato su cosa è “attivo” e cosa è “futuro”.
**Riesame**
Quando la v1.1 è stabile e c’è tempo per una migration con checklist regressioni completa.
Quando il modulo Profilo Giocatore è stabile e c’è tempo per una migration con checklist regressioni completa.
---
@@ -435,16 +438,16 @@ Se si adotta un servizio che viola questa regola.
---
### DD-016 — Schema dati Profilo Giocatore v1.1 (F0)
### DD-016 — Schema dati Profilo Giocatore (F0)
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
La progettazione F0 del modulo Profilo Giocatore ha definito come persistere dati personali, documenti e certificati, in coesistenza con lanagrafica attuale (`g1``g17` nel codice) e con la tabella `giocatori` UUID già presente ma non usata. Serviva una scelta chiara su dove salvare i dati, come collegare lautenticazione e come proteggere documenti sensibili — senza toccare le tabelle v1.0 già operative.
La progettazione F0 del modulo Profilo Giocatore ha definito come persistere dati personali, documenti e certificati, in coesistenza con lanagrafica attuale (`g1``g17` nel codice) e con la tabella `giocatori` UUID già presente ma non usata. Serviva una scelta chiara su dove salvare i dati, come collegare lautenticazione e come proteggere documenti sensibili — senza toccare le tabelle preesistenti già operative.
**Decisione**
Per la v1.1 si introducono **due nuove tabelle additive**:
Si introducono **due nuove tabelle additive**:
- **`giocatori_squadra`** — anagrafica squadra con ID testuali (`g1``g17`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento account (`auth_user_id`).
- **`profili_giocatore`** — dati personali, metadati documento identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`.
@@ -455,8 +458,8 @@ Regole vincolanti:
2. Lassociazione **`auth_user_id` ↔ giocatore** è unoperazione **controllata e atomica** (es. al primo accesso da `/benvenuto`, con `UPDATE … WHERE auth_user_id IS NULL`). Il giocatore **non può modificare liberamente** `auth_user_id`; solo un admin può resettarlo in casi eccezionali.
3. I file (documento identità, certificato, foto tessera) vivono nel bucket Storage **`profili-giocatore`**, configurato come **privato**.
4. Documenti personali e sanitari **non devono mai essere esposti tramite URL pubblici**. Accesso solo tramite client autenticato con policy RLS, o signed URL a scadenza breve per download admin.
5. Le **tabelle v1.0 esistenti non vengono modificate** (`eventi_app`, `risposte_presenze`, voti, palloni, scout, push, ecc.). Il profilo si aggancia agli ID `g1``g17` già in uso, senza migrare verso UUID in v1.1 (coerente con DD-012).
6. La tabella `giocatori` (UUID) resta **invariata e non usata** dal modulo profilo in v1.1.
5. Le **tabelle preesistenti non vengono modificate** (`eventi_app`, `risposte_presenze`, voti, palloni, scout, push, ecc.). Il profilo si aggancia agli ID `g1``g17` già in uso, senza migrare verso UUID per ora (coerente con DD-012).
6. La tabella `giocatori` (UUID) resta **invariata e non usata** dal modulo profilo.
**Alternative scartate**
@@ -464,21 +467,21 @@ Regole vincolanti:
- Salvare file come base64 nel database → ingestibile, difficile da gestire e da scaricare.
- Bucket pubblico con URL permanenti → inaccettabile per dati sanitari e documenti didentità.
- Permettere al giocatore di cambiare `auth_user_id` liberamente → rischio di impersonazione e race condition.
- Modificare tabelle v1.0 per aggiungere FK verso il profilo → viola DD-004 e DD-012.
- Modificare le tabelle preesistenti per aggiungere FK verso il profilo → viola DD-004 e DD-012.
**Conseguenze**
- Coesistono temporaneamente tre rappresentazioni dellanagrafica: `crapp-data.ts` (fallback), `giocatori_squadra` (target), `giocatori` UUID (dormiente).
- `src/lib/rosa.ts` legge dal database e ricade su `crapp-data.ts` in caso di errore o assenza dati (DD-015).
- Il completamento profilo (30/30/30/10) si calcola in app, non si persiste nel database.
- Lo storico certificati non viene conservato in v1 (coerente con DD-010).
- Lo storico certificati non viene conservato (coerente con DD-010).
- Le migration M1M2 (tabelle, RLS, bucket) restano **additive**: solo `CREATE`, nessun `ALTER`/`DROP` su schema esistente.
- Raffina e attua quanto proposto in DD-015 per la rosa anagrafica, senza sostituire formalmente quella voce.
**Riesame**
- Quando `giocatori_squadra` è stabile in produzione e il fallback `crapp-data.ts` non serve più.
- Quando si pianifica la convergenza verso UUID (DD-012, post v1.1).
- Quando si pianifica la convergenza verso UUID (DD-012, dopo che il profilo sarà stabile).
- Se il CSI o il regolamento richiedono conservazione storica documenti o consensi privacy dedicati.
---
@@ -570,7 +573,7 @@ Unificare gradualmente sul modello autenticato, dopo auth e profilo stabili.
Rischio regressioni su calendario e presenze, moduli più usati della squadra.
**Riesame previsto**
Post v1.1, con migration e test dedicati.
Dopo che il modulo Profilo Giocatore sarà stabile, con migration e test dedicati.
---
@@ -759,7 +762,7 @@ spesso da rendere il tema scuro una funzione e non un vezzo.
**Stato:** Accettata
**Contesto**
Le tabelle della v1.0 sono nate con policy `USING (true)` per `anon, authenticated`. M4
Le tabelle preesistenti sono nate con policy `USING (true)` per `anon, authenticated`. M4
(DD-011) ha tolto il GRANT ad `anon`, e la cosa è stata letta come «ora è chiuso». Non lo
era: per gli autenticati non c'era rimasto nessun limite. Verificato sul database locale con
un utente appena creato, senza ruolo e senza slot nella rosa: `POST /rest/v1/eventi_app`
@@ -970,3 +973,185 @@ prima di inviare.
**Riesame**
Se servisse mandare payload più grandi del limite del protocollo, o se un servizio push
smettesse di accettare corpi cifrati (nessuno lo fa: è lo standard).
### DD-027 — Chi vota deve essere convocato, non solo autenticato come sé stesso
**Stato:** accettata · **Data:** 8 settembre 2026
**Contesto**
Un audit del modulo Badge (`docs/modules/badge.md`) ha trovato due filtri rimasti solo
applicativi dopo DD-023: la policy di M11 garantisce che `votante_id` sia lo slot collegato
all'account di chi scrive, ma non controlla che **votante e votato fossero convocati**
all'evento — un utente che scrive direttamente su PostgREST (bypassando l'interfaccia) poteva
votare o essere votato in una partita a cui non aveva partecipato, gonfiando `mediaVoto`,
`mvp` o un badge social a piacere. Allo stesso modo, `eventi_app.pagelle_chiuse` nascondeva
solo i bottoni in UI: un voto pagella "fuori tempo" restava tecnicamente possibile.
**Decisione**
La policy "Ognuno gestisce i propri voti ..." di `pagelle_voti`, `mvp_voti` e
`badge_social_voti` (M11) guadagna un controllo aggiuntivo tramite la funzione
`evento_permette_voto()` (migration `m13_convocati_e_pagelle_chiuse`): verifica che sia
`votante_id` sia `votato_id` compaiano in `eventi_app.convocati` per quel `match_id`
(`convocati` vuoto = tutta la rosa, la stessa convenzione di `convocatiEvento()` in
`eventi.ts`), e — solo per le pagelle — che `pagelle_chiuse` sia falso. Le policy admin
restano invariate e permissive: un amministratore deve poter correggere un voto anche fuori
convocazione o dopo la chiusura.
Il controllo si ferma alla **convocazione**, non alla **presenza reale**: per l'MVP, ad
esempio, l'interfaccia limita già il voto ai soli presenti/in ritardo
(`usePresenzeEvento`), un filtro più stretto che resta solo applicativo — un convocato ma
assente passa ancora a livello database. Stringere fino a quel punto avrebbe richiesto
leggere `risposte_presenze` dentro la policy, un salto di complessità non giustificato
dall'audit che ha originato questa decisione.
**Alternative scartate**
- Un trigger `BEFORE INSERT/UPDATE` invece di RLS → si applicherebbe anche alla service key
e agli admin, bloccando correzioni legittime fuori convocazione; la RLS, applicata solo
alla policy non-admin, li esclude naturalmente.
- Controllare anche la presenza reale (`risposte_presenze`), non solo la convocazione →
scope maggiore del gap trovato in audit, e specifico dell'MVP (pagelle e badge social non
hanno un concetto di "presente" distinto da "convocato" nell'interfaccia attuale).
**Conseguenze**
- Un evento senza `convocati` esplicito (lista vuota, il caso più comune oggi) non cambia
comportamento: tutta la rosa resta votabile, come prima.
- I test di `test/integration/permessi.test.ts` sono la definizione eseguibile anche di
questa parte della tabella dei permessi (voti non convocati rifiutati, `pagelle_chiuse`
rifiutata a database, controlli positivi che provano che un voto legittimo passa ancora).
- Resta un gap conosciuto e documentato (non quello risolto qui): che il votante fosse
davvero presente, non solo convocato, per MVP/pagelle/badge social.
**Riesame**
Se un giorno servisse bloccare anche il voto di un convocato-ma-assente a livello database,
non solo in UI.
### DD-028 — Soglia minima di campione per Media voto e MVP in home
**Data:** 9 settembre 2026
**Stato:** Accettata
**Contesto**
Un audit della sezione «Colpo d'occhio» in home (`index.tsx`, StatTile Presenze/Media
voto/MVP) ha trovato che due delle tre statistiche non avevano nessun minimo campionario:
`mediePagelle()` calcola una media aritmetica pura, così un giocatore con un solo voto da 10
mostrava "10" in home, più alto di un titolare con 40 voti e media 7.2 — lo stesso problema
che il badge Pagellone già risolve con `VOTI_MINIMI_PAGELLA` (badge.md), ma applicato solo al
badge, non alla StatTile home. Allo stesso modo `mvpVintiPerGiocatore()`/`vincitoriMvp()`
assegnavano un MVP di partita anche con un solo voto totale: bastava che un solo giocatore
votasse perché il votato "vincesse" nettamente, senza nessun quorum di partecipazione.
**Decisione**
- **Media voto** in home usa la stessa soglia del badge Pagellone: sotto `VOTI_MINIMI_PAGELLA`
(5) voti ricevuti, la StatTile mostra `—` invece della media, tramite la funzione pura
`mediaVotoColpoDOcchio()` (`pagelle.ts`), estratta dalla route per restare testabile (DD-020).
La funzione `mediePagelle()` non cambia: il filtro resta lato chiamante, come già faceva il
badge.
- **MVP**: `conteggioPartita`'s aggregazione, tramite `vincitoriMvp()` e
`mvpVintiPerGiocatore()`, richiede ora un quorum minimo di voti totali sulla partita
(`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`) prima di assegnare un vincitore, oltre alla regola già
esistente del margine netto tra primo e secondo. Un solo voto non basta più a incoronare
nessuno, nemmeno in assenza di concorrenza.
**Alternative scartate**
- Alzare la soglia MVP oltre 2 (es. metà dei convocati) → serve conoscere i convocati
dell'evento dentro una funzione che oggi lavora solo sui voti; complessità non giustificata
per il gap trovato in audit.
- Lasciare l'MVP senza quorum e limitarsi al fix della Media voto → il problema di fondo
(un numero esiguo di voti che decide una statistica mostrata come solida) resterebbe aperto
per l'MVP.
**Conseguenze**
- Alcuni MVP di partita già assegnati con un solo voto totale non contano più nel conteggio
`mvp` del giocatore: è una modifica retroattiva al dato mostrato, non solo al calcolo futuro,
perché `mvpVintiPerGiocatore()` deriva sempre il conteggio dai voti grezzi, senza storico
persistito a parte.
- `mediePagelle()` resta invariata: chi la chiama altrove (profilo, squadra) senza applicare la
soglia continua a mostrare la media grezza — non tocca questa decisione, resta il limite già
noto in [pagelle.md](modules/pagelle.md).
**Riesame**
Se la squadra segnala che il quorum di 2 voti per l'MVP è troppo permissivo o troppo severo, o
se si vuole applicare la stessa soglia di Media voto anche alle StatTile di profilo e squadra.
### DD-029 — Cancellare un evento pulisce a cascata i dati collegati
**Data:** 9 settembre 2026
**Stato:** Accettata
**Contesto**
Un audit del modulo Obiettivi ha verificato che gli obiettivi in sé non hanno bisogno di
nessuna pulizia quando un evento viene cancellato: sono ricalcolati a runtime sull'elenco
eventi corrente (`obiettivi.ts`), quindi un evento sparito da `eventi_app` semplicemente
smette di contare. Il problema è un livello sotto: `useEliminaEvento()`
(`src/lib/eventi.ts`) cancella solo la riga in `eventi_app`. Nessuna delle otto tabelle
collegate (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`,
`badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) ha mai
avuto una foreign key verso `eventi_app(id)`: le loro righe restavano orfane a database.
Oggi è innocuo per le statistiche, perché nessun calcolo legge quelle tabelle se non partendo
dall'elenco eventi corrente. Ma è un rischio latente: un id evento futuro identico a uno
passato (generato come `"e" + timestamp`, collisione improbabile ma non impossibile)
erediterebbe dati vecchi non suoi; e un calcolo futuro che iterasse direttamente una di quelle
tabelle, invece di partire da `eventi_app`, conterebbe anche le righe orfane.
**Decisione**
Migration `m14_pulizia_dati_evento_cancellato`: un trigger `AFTER DELETE ON eventi_app`
cancella a cascata le righe corrispondenti (`evento_id`/`match_id = id evento cancellato`)
nelle otto tabelle collegate, tramite una funzione `SECURITY DEFINER`. Agisce a database, non
in `useEliminaEvento()`: protegge anche chi cancella un evento scrivendo direttamente su
PostgREST, non solo chi passa dal bottone dell'app.
**Alternative scartate**
- Foreign key con `ON DELETE CASCADE` verso `eventi_app(id)` → non applicabile subito:
`mvp.md` documenta che storicamente `match_id` in `mvp_voti`/`pagelle_voti`/
`badge_social_voti` a volte conteneva l'id di una sessione Scout o di una partita CSI, non
l'id evento CrAPP. Un vincolo FK avrebbe rifiutato la migration alla prima riga storica
disallineata; il trigger non valida i dati esistenti, solo le cancellazioni da qui in avanti.
- Cancellazione manuale nelle otto tabelle dentro `useEliminaEvento()` → fragile: va tenuta
aggiornata a mano ogni volta che un nuovo modulo aggiunge una tabella con `evento_id`, e non
protegge chi scrive/cancella direttamente su PostgREST.
- Bonificare anche le righe orfane già esistenti nella stessa migration → rimandato: tocca dati
reali già scritti, è un intervento più delicato che merita una migration a sé, non urgente
perché quelle righe sono già invisibili a ogni calcolo attuale.
**Conseguenze**
- Da questa migration in poi, cancellare un evento (da qualunque punto, app o REST diretto)
ripulisce automaticamente tutte le tabelle collegate.
- Le righe orfane generate da cancellazioni **precedenti** a questa migration restano nel
database: il trigger previene il problema da qui in avanti, non ripulisce lo storico.
- `test/integration/pulizia-evento.test.ts` è la definizione eseguibile del comportamento:
scrive una riga in ciascuna delle otto tabelle, cancella l'evento e verifica che spariscano
tutte.
**Aggiornamento (9 settembre 2026, stesso giorno)** — la bonifica dello storico prevista sopra
come "riesame" è stata fatta subito dopo, migration `m15_bonifica_dati_evento_orfani`: righe
orfane in `risposte_presenze`, `cacche_partita`, `turni_palloni`, `scout_sessioni`,
`scout_live`, `scout_partite` identificate confrontando `evento_id` con `eventi_app`. Per
`mvp_voti`/`pagelle_voti`/`badge_social_voti` il confronto si applica **solo** ai `match_id`
nel formato id evento CrAPP (`^e[0-9a-z]+$`, quello di `nuovoIdEvento()`): i vecchi voti su id
Scout (prefisso `s` + timestamp decimale) o CSI (numerico o `data-squadra-squadra`) non sono
orfani, sono dati storici legittimi mai collegati a un evento CrAPP (vedi contesto sopra), e la
migration non li tocca. Verificato manualmente sul database locale prima di applicarla: un voto
di test su id Scout è sopravvissuto alla bonifica, un voto di test su id evento CrAPP orfano è
stato rimosso.
**Aggiornamento (9 settembre 2026)** — la verifica manuale di M15 non lasciava nessuna rete di
sicurezza automatica per il futuro, a differenza del resto del progetto (DD-020). Migration
`m16_funzione_bonifica_dati_evento_orfani` rende lo stesso corpo una funzione
`bonifica_dati_evento_orfani()` (riservata al `service_role`, non richiamabile dall'app),
coperta da `test/integration/bonifica-evento.test.ts`: inserisce una riga orfana e una storica
su id Scout/CSI, richiama la funzione via RPC e verifica che tocchi solo la prima. Non serve
richiamarla di nuovo ora (M15 ha già ripulito lo storico): resta pronta come intervento di
manutenzione se in futuro il trigger di M14 smettesse di funzionare o emergesse un altro batch
di orfani.
**Riesame**
Se una nuova tabella collegata a un evento non viene aggiunta al trigger quando creata (va
aggiornata a mano, non c'è un meccanismo che lo forzi).
-1
View File
@@ -13,7 +13,6 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
| `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. |
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
## Regole da rispettare nelle prossime modifiche
+10 -4
View File
@@ -6,7 +6,7 @@ il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata e con q
## Fatto
Tutto quello che è in `main` e finirà nella prima release.
Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md`).
- [x] Gestione squadra
- [x] Calendario
@@ -16,17 +16,23 @@ Tutto quello che è in `main` e finirà nella prima release.
- [x] Badge
- [x] Badge social
- [x] Pagelle
- [x] Votazione MVP
- [x] Obiettivi di squadra
- [x] Turno palloni
- [x] Infortuni — conteggio eventi saltati, in forma minima
- [x] Notifiche Push (promemoria intelligenti)
- [x] Dashboard amministratore
- [x] Download CSV dati
- [x] Certificati medici — caricamento, scadenza, stato e download; lo storico dei
certificati resta un'estensione futura
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
(caricamento, scadenza, stato, download) e foto tessera; lo storico dei certificati
resta un'estensione futura
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
tesserato (numero e data di tessera)
- [x] Collegamento CSI (stagione 2025/26)
- [x] Classifica automatica
- [x] Classifica automatica (campionato e Coppa)
- [x] Risultati campionato
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
calcolata dal CSI
## Prossimo
@@ -1,5 +1,5 @@
-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016)
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle v1.0 esistenti.
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle preesistenti.
-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato
-- `profili-giocatore` creato dalla migration M3.
+400 -8
View File
@@ -1,6 +1,6 @@
# Modulo — Badge
**Stato:** implementato (v1.0), coerente con DD-007 e DD-008
**Stato:** implementato, coerente con DD-007 e DD-008
**File principali:** `src/lib/badges.ts`, `src/lib/badge-social.ts`,
`src/components/crapp/CollezioneBadge.tsx`, `src/components/crapp/BadgeDrawer.tsx`,
`src/components/crapp/CelebrazioneBadge.tsx`, `src/components/crapp/VotoSocial.tsx`
@@ -26,10 +26,30 @@ badge assegnati per voto dai compagni.
## Implementazione
- `badgeDefs`/`badgeSegreti` (`badges.ts`) definiscono ogni badge con una funzione
`valore(g)` e soglie bronzo/argento/oro. Le fonti dato sono solo statistiche indipendenti
dal ruolo in campo: MVP, media pagelle, turni palloni, presenze, serie, infortuni,
ritardi, cacche — **mai** punti/ace/muri dello Scout Live, coerentemente con DD-008.
- I badge segreti restano nascosti (icona lucchetto) finché non sbloccati.
`valore(g)` e tre soglie bronzo/argento/oro (elenco completo con fonte e soglie di ognuno in
"Elenco badge" sotto). Il grado è calcolato da `gradoRaggiunto()`/`statoBadge()`: soglie
inclusive, vince l'ultima raggiunta o superata. Per la maggior parte dei badge il valore è
già pronto: `Giocatore` arriva da `useRosa()` con presenze, palloni, serie, infortuni,
ritardi, cacche e media pagelle già calcolati da altri moduli — `badges.ts` si limita a
confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, il
Pagellone, lo Sherpa dei palloni, l'MVP e i badge social.
- **Badge Sherpa dei palloni** (`palloni`, in `badgeDefs`): `g.palloni` non è un contatore
incrementato a ogni evento, ma ricalcolato da `conteggioTurni()` (`palloni-core.ts`) su
`Giocatore.palloni` (`rosa.ts`) — meccanismo di turni/rotazione descritto per intero in
[palloni.md](palloni.md), non ripetuto qui. `rosa.ts` passa a `conteggioTurni()` **solo i
turni confermati** (`turniSalvati` da `useTurniPalloni()`), non l'output di
`completaTurni()`: le proposte automatiche di rotazione (usate altrove, per la UI di
`TurnoPalloni.tsx`) non contano per il badge, che premia solo chi ha davvero confermato di
aver portato i palloni. Conta solo per eventi già trascorsi (`e.data < oggi`, stesso
criterio delle presenze).
- **Badge Pagellone** (`pagella`, in `badgeDefs`): a differenza degli altri badge da
contatore, richiede un numero minimo di voti (`VOTI_MINIMI_PAGELLA = 5`, `badges.ts`) prima
che `g.mediaVoto` conti — sotto soglia `valore(g)` è forzato a `0` (badge bloccato), anche
con una media altissima. Aggiunto perché senza minimo un singolo voto poteva
sbloccare/far sparire il badge senza nessuna significatività statistica (vedi
[pagelle.md](pagelle.md) per la pipeline voto → media, qui non ripetuta). Il numero di voti
ricevuti arriva in `Giocatore.votiPagella` (`rosa.ts`), popolato insieme a `mediaVoto` dalla
stessa `mediePagelle()`.
- **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per
partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della
partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita
@@ -37,6 +57,14 @@ badge assegnati per voto dai compagni.
database (vincolo `badge_social_no_autovoto`, migration `m12_niente_autovoto`). A
differenza delle [Pagelle](pagelle.md), qui non c'è alcun tentativo di anonimato:
`votante_id`/`votato_id` sono entrambi visibili.
- **Badge MVP** (`mvp`, in `badgeDefs`): l'unico badge normale la cui fonte non è un contatore
già pronto ma il risultato della votazione MVP tra compagni — meccanismo di voto (chi vota
chi, apertura, autovoto, RLS) descritto per intero in [mvp.md](mvp.md), non ripetuto qui.
Quello che serve per capire il badge: `mvpVintiPerGiocatore()` (`mvp-voti.ts`) conta, per
ogni giocatore, quante partite ha vinto con un **vantaggio netto** sul secondo (non il
totale dei voti ricevuti; in caso di parità la partita non conta per nessuno). `rosa.ts`
(`useRosa()`) scrive quel numero in `Giocatore.mvp`, che `badgeDefs` legge con
`valore: (g) => g.mvp` e confronta con le soglie 1/3/5 (bronzo/argento/oro).
- `CollezioneBadge.tsx` mostra sbloccati, in progresso, badge social vinti e un contatore di
badge segreti ancora da scoprire; `BadgeDrawer.tsx` il dettaglio di un singolo badge;
`CelebrazioneBadge.tsx` l'overlay celebrativo alla prima visualizzazione di un badge nuovo.
@@ -46,6 +74,61 @@ badge assegnati per voto dai compagni.
---
## Elenco badge
Riferimento completo per chi lavora sul codice. **In app i 5 badge segreti restano nascosti
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
informazioni verso l'interfaccia.
### Badge normali (gradi bronzo/argento/oro)
Tutti calcolati come `valore(g)` confrontato con tre soglie crescenti; il grado è l'ultima
soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
| id | nome | come si guadagna | soglie B/A/O |
| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
| `mvp` | MVP | partite vinte nettamente al voto MVP dei compagni (`g.mvp`, vedi pipeline sopra) | 1 / 3 / 5 |
| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`), solo se ne ha ricevuti almeno `VOTI_MINIMI_PAGELLA` (5) | 6.5 / 7.5 / 8.5 |
| `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 |
| `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 |
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 |
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
### Badge segreti (booleani, nascosti finché non sbloccati)
Stesso motore dei normali ma con soglie `{bronzo:1, argento:1, oro:1}`: `valore(g)` è 0 o 1,
quindi il badge è "trovato o no", mai graduato. In UI compaiono con icona lucchetto finché non
sbloccati. Attenzione se si tocca `gradoRaggiunto()`: con le tre soglie tutte uguali a 1, il
grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo che il ciclo
`for` sovrascrive), mai `"bronzo"` — l'unica cosa che conta davvero per questi badge è
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
| id | nome | condizione esatta |
| --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
| `s-mai-forfait` | Mai un forfait | almeno 10 conferme rapide consecutive **e** almeno 15 presenze (`g.serieConferme >= 10 && g.presenze >= 15`) |
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
### Badge social (votati dai compagni, 5 categorie per partita)
Non hanno gradi: si "vince" o non si vince una categoria in una partita. `vincitoreCategoria()`
richiede un vantaggio netto sul secondo classificato, in parità nessun vincitore.
`badgeSocialVinti()` conta quante partite ha vinto ciascun giocatore in ogni categoria (non i
voti ricevuti).
| id | nome | cosa premia |
| ------------ | -------------------------- | ------------------------------------------------ |
| `affidabile` | Compagno affidabile | sempre presente, sempre sul pezzo |
| `spirito` | Miglior spirito di squadra | carica il gruppo dal primo all'ultimo punto |
| `fairplay` | Fair play | rispetto per compagni, avversari e arbitro |
| `meme` | Meme della partita | la scena più memorabile della partita |
| `cuore` | Cuore del gruppo | chi tiene unita la squadra anche fuori dal campo |
---
## Regole rispettate
- **DD-007**: nessuna tabella `badge_sbloccati`, tutto calcolato a runtime dai dati
@@ -55,6 +138,284 @@ badge assegnati per voto dai compagni.
---
## Copertura test
Verifica badge per badge (fatta rileggendo codice e test riga per riga, non solo per
categoria). Due bug trovati in una sessione di audit dedicata su tutti i 16 badge (dettagli
nelle sezioni sotto e in "Problemi noti"): `s-tiebreak` non applicava la soglia minima di voti
di Pagellone (**corretto**), `s-cacche` prometteva "partite di campionato" senza che il codice
lo verificasse mai (**la descrizione è stata corretta**, il comportamento — qualunque partita
conta — era già quello voluto). Tutti e 16 i badge hanno ora copertura unit **e** integration
end-to-end completa.
**Badge normali** — `badges.ts` testa la propria funzione pura (soglia → grado,
`badges.test.ts`) sull'output di altri moduli:
- `mvp`: soglie inclusive verificate (1→bronzo, 3→argento, 99→resta oro,
`badges.test.ts:44-48`), progresso a metà (`:52-56`).
- `pagella`: caso critico delle soglie decimali senza arrotondamento per eccesso — 6.4 →
nessun grado, 6.5 → bronzo (`badges.test.ts:65-67`); un vero 6.49 non diventa "quasi
bronzo". Più la soglia minima di voti (vedi sotto).
- `palloni`: soglie 3/6/10 testate esplicitamente (bronzo/argento/oro, confine incluso e
oltre l'oro resta oro, `badges.test.ts:94-104`), oltre a un caso di progresso non tondo
(5/6 → 83%). Pipeline end-to-end sotto, come `mvp`/`pagella`.
- `presenze`: soglie 5/15/30 testate esplicitamente (confine incluso, oltre l'oro resta oro,
`badges.test.ts:108-116`). Pipeline end-to-end sotto, come `mvp`/`pagella`/`palloni`.
- `serie-allenamenti`: soglie 3/6/10 testate esplicitamente (confine incluso, oltre l'oro
resta oro, `badges.test.ts:118-126`). Pipeline end-to-end sotto, come gli altri badge da
tabella.
- `serie-conferme`: soglie 3/8/15 testate esplicitamente (confine incluso, oltre l'oro resta
oro, `badges.test.ts:128-136`), oltre agli invarianti generali e a
`collezioneBadge`/`prossimoTraguardo` con valori al massimo. Pipeline end-to-end sotto, come
gli altri badge da tabella.
`mvp`, `pagella`, `palloni`, `presenze`, `serie-allenamenti` e `serie-conferme` sono le
eccezioni con integration dedicato (sotto) perché la loro fonte passa da una tabella di
voto/turni/presenze
letta e ricalcolata dal vivo, non da un contatore già pronto altrove.
**Badge segreti** — ognuno testato con la propria condizione esatta e il confine appena sotto:
`s-tiebreak` (mvp:1 non basta, mediaVoto 7.9 non basta, sotto `VOTI_MINIMI_PAGELLA` voti non
basta nemmeno con media alta — vedi il bug fix sotto), `s-mai-forfait` (ogni soglia isolata al
confine, non solo "entrambe servono"), `s-infermeria` (2 infortuni non bastano), `s-ritardi` (4
ritardi non bastano — gap colmato in questa sessione), `s-cacche` (2 cacche non bastano).
Copertura unit completa **e** integration dedicato per tutti e 5 (aggiunto in questa sessione,
vedi sotto): i dati sorgente hanno già i propri test di integrazione nei rispettivi moduli, ma
nessuno prima arrivava fino a `statoBadge()` sul segreto stesso con dati scritti a database.
**Badge MVP — pipeline end-to-end** (aggiunta in una sessione dedicata a completare la
copertura di questo badge):
- Unit: `badges.test.ts` (soglie/gradi) + `mvp-voti.test.ts` (conteggio partita, vincitore con
vantaggio netto, parità che non assegna, apertura voto 2h dopo il fischio d'inizio).
- Integration (`npx supabase start` richiesto):
- `scritture.test.ts` — semantica dell'`upsert` di `mvp_voti` (un voto per
partita/votante, l'ultimo sostituisce) e rifiuto dell'autovoto a database
(`mvp_no_autovoto`).
- `permessi.test.ts` — RLS di `m11`: il proprio voto MVP si registra (caso positivo), non
si può votare a nome di un altro (caso negativo); RLS di `m13` (sotto): un votante o un
votato non convocati vengono rifiutati.
- `mvp-badge.test.ts` — end-to-end reale: scrive voti su `mvp_voti`, rilegge via REST come
fa `useVotiMvp()`, calcola `mvpVintiPerGiocatore()` e verifica che `statoBadge()` assegni
il grado corretto (bronzo a 1-2 vittorie nette, argento a 3), incluso un pareggio che non
deve contare come vittoria.
**Badge Pagellone — pipeline end-to-end e soglia minima di voti** (stessa sessione di sopra,
dopo l'analisi che ha trovato il gap "un voto solo sblocca il badge"):
- Unit: `badges.test.ts:69-88` — sotto `VOTI_MINIMI_PAGELLA` (5) il badge resta bloccato anche
con `mediaVoto: 10`; esattamente a 5 la media torna a contare; sopra soglia valgono le
normali soglie di grado (`mediaVoto: 6.5` con 5 voti → bronzo, non oro).
- Integration (`npx supabase start` richiesto):
- `scritture.test.ts` — semantica dell'`upsert` di `pagelle_voti` e rifiuto dell'autovoto
(`pagelle_no_autovoto`), già presente prima di questa sessione.
- `permessi.test.ts` — RLS di `m13`: un votante o un votato non convocati vengono rifiutati
(per tutte e tre le tabelle di voto, non solo le pagelle), e un voto pagella dopo
`pagelle_chiuse` viene rifiutato anche a database, non solo nascosto in UI.
- `pagella-badge.test.ts` (nuovo) — end-to-end reale: scrive voti su `pagelle_voti`, rilegge
via REST come fa `usePagelle()`, calcola `mediePagelle()` e verifica che `statoBadge()`
tenga il badge bloccato sotto soglia, lo sblocchi al voto minimo con il grado giusto, e
applichi le soglie normali sopra soglia.
**Badge Sherpa dei palloni — pipeline end-to-end, ora senza contare le proposte non
confermate** (analisi dedicata: trovato e sistemato il gap "le proposte contano", che
gonfiava il badge di turni mai confermati da nessuno — vedi "Problemi noti da sistemare"):
- Unit: `badges.test.ts:93-104` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
`palloni-core.test.ts`, già completo prima di questa sessione (`completaTurni()`,
`conteggioTurni()`, rotazione bilanciata su un giro completo di partite, allenamenti mai
proposti in automatico, turno di un giocatore non più in rosa che non rompe il conteggio).
- Integration (`npx supabase start` richiesto):
- `scritture.test.ts` — un turno resta uno per evento (l'upsert sostituisce, non aggiunge).
- `palloni-badge.test.ts` — end-to-end reale: scrive eventi e turni **solo parzialmente
confermati** su `eventi_app`/`turni_palloni`, rilegge via REST come fa `fetchTurni()`/
`daRiga()` e passa `turniSalvati` (solo confermati, mai l'output di `completaTurni()`) a
`conteggioTurni()` fino a `statoBadge()`: dimostra che un evento passato senza turno
confermato **non conta per nessuno**, anche se un algoritmo di rotazione (usato altrove
per la UI) lo proporrebbe automaticamente; verifica anche che un evento futuro non conti,
pur avendo già una conferma.
**Badge Presenza fissa — pipeline end-to-end** (analisi dedicata: nessun bug trovato; a
differenza di MVP/pagelle/badge social, per questo badge **non serve** l'estensione RLS di
M13 — vedi sotto):
- Unit: `badges.test.ts:108-116` — soglie 5/15/30 (confine incluso, oltre l'oro resta oro) +
`presenze.test.ts`, già molto completo prima di questa sessione (`contaPresenzeGiocatore()`
con ritardo che conta come presenza, denominatore uguale per tutti, eventi futuri esclusi,
filtro sui convocati, solo partite/allenamenti).
- Integration (`npx supabase start` richiesto):
- `obiettivi.test.ts` — copre già `contaPresenzeGiocatore()` end-to-end per l'obiettivo
"250 presenze complessive" (o3), la stessa funzione usata dal badge.
- `presenze-badge.test.ts` (nuovo) — end-to-end reale sul badge: scrive eventi e risposte
su `eventi_app`/`risposte_presenze`, rilegge via REST come fa `fetchPresenze()`/`daRiga()`
e verifica che `statoBadge()` attraversi le tre soglie con dati veri (incluso un ritardo
che conta come presenza e un'assenza che non conta). Dimostra anche che una risposta
scritta per un evento senza convocazione **non conta comunque**, perché
`contaPresenzeGiocatore()` filtra già per `convocati` lato applicazione — a differenza di
MVP/pagelle/badge social, qui non serve una policy RLS aggiuntiva: il filtro è nella
funzione pura che il badge consuma, non solo in UI.
**Badge Sempre in palestra — pipeline end-to-end** (analisi dedicata: nessun bug trovato).
Stessa fonte dati di `presenze` (`risposte_presenze`) ma logica diversa: non un totale, una
**serie consecutiva** che un buco azzera e un infortunio congela. Anche qui, come per
`presenze`, non serve nessuna estensione RLS: il filtro sui convocati è già nella funzione
pura.
- Unit: `badges.test.ts:118-126` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
`presenze.test.ts`, già completo prima di questa sessione su `serieConsecutiva()` (buco che
azzera, infortunio che congela invece di azzerare, nessuna risposta vale come buco,
convocati che non spezzano la serie di chi non era coinvolto).
- Integration (`npx supabase start` richiesto):
- `serie-allenamenti-badge.test.ts` (nuovo) — end-to-end reale: scrive allenamenti e
risposte su `eventi_app`/`risposte_presenze`, rilegge via REST e verifica che
`statoBadge()` attraversi bronzo/argento/oro con presenze consecutive vere, che
un'assenza dopo 10 presenze di fila azzeri tutto (torna a nessun grado), e — separatamente
— che un infortunio **non** azzeri la serie ma la lasci congelata (3 presenze vere,
un infortunio nel mezzo saltato dal conteggio, poi ancora presente: la serie resta a 3,
non riparte da 1).
**Badge Risposta lampo — pipeline end-to-end** (analisi dedicata: nessun bug trovato nella
logica di calcolo; l'unico limite è quello già noto e documentato sui dati pre-`m9`, vedi
"Limiti noti"). Il badge dipende da `serieConferme()`, che passa da due colonne facili da
confondere fra loro (`creato_il`/`risposto_il`, vedi [serie-presenze.md](serie-presenze.md)):
un integration test aggiunto per verificare che la mappatura verso `creatoIl`/`tempi` regga con
dati reali, non solo con timestamp scelti a mano — cosa che i test unitari, che non toccano il
database, non possono garantire.
- Unit: `badges.test.ts:128-136` — soglie 3/8/15 (confine incluso, oltre l'oro resta oro) +
`presenze.test.ts`, esteso in questa sessione su `serieConferme()`: oltre al buco che azzera
e all'evento senza `creatoIl` che viene saltato (già presenti), ora anche un evento convocato
solo per un altro giocatore che non spezza la serie, partite e allenamenti sommati nella
stessa serie, il confronto inclusivo esattamente a 24h (dentro conta, un secondo oltre
azzera), e un evento futuro che non entra ancora nel calcolo.
- Integration (`npx supabase start` richiesto):
- `serie-conferme-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il`
esplicito e risposte con `risposto_il` esplicito su `eventi_app`/`risposte_presenze`,
rilegge via REST come fa `daRiga()`/`fetchPresenze()` e verifica che `statoBadge()`
attraversi bronzo/argento/oro con conferme rapide vere, che una risposta arrivata oltre le
24h azzeri tutto anche dopo 15 conferme di fila, e — separatamente — che partite e
allenamenti si sommino nella stessa serie senza bisogno di un filtro per tipo.
**Badge Cliente VIP dell'Infermeria e Aspettate, arrivo! — pipeline end-to-end** (analisi
dedicata: nessun bug trovato). Stessa fonte (`contaInfortuni()`/`contaRitardi()` in
`src/lib/infortuni.ts`, entrambe sopra la stessa `contaStato()` privata) e stessa struttura di
`serie-allenamenti`/`serie-conferme`, ma senza serie: un contatore semplice di eventi passati.
- Unit: `badges.test.ts` (soglie 3 e 5, confine appena sotto) + `infortuni.test.ts`, esteso in
questa sessione con un giocatore che ha **sia** un infortunio **sia** un ritardo (su eventi
diversi): i due conteggi restano indipendenti, nessuno "ruba" voci all'altro.
- Integration (`npx supabase start` richiesto):
- `s-infermeria-badge.test.ts` / `s-ritardi-badge.test.ts` (nuovi) — end-to-end reali: scrivono
eventi e risposte "infortunato"/"ritardo" su `eventi_app`/`risposte_presenze`, rileggono via
REST e verificano che il segreto resti bloccato appena sotto soglia e si sblocchi
esattamente al confine (3 infortuni, 5 ritardi).
**Badge Trono di ferro — pipeline end-to-end, descrizione corretta** (analisi dedicata: trovato
un disallineamento fra descrizione e codice, **risolto aggiornando il testo**, non la logica —
vedi "Problemi noti" più sotto per il perché). `statisticheCacche()` (`src/lib/cacche.ts`) non
ha mai distinto partite di campionato da amichevoli: contava (e conta ancora) qualunque partita
con 3+ cacche dichiarate. La vecchia descrizione del badge prometteva "partite di campionato",
cosa che il codice non ha mai verificato — corretta in "partite (campionato o amichevole)".
- Unit: `badges.test.ts` (soglia 3, confine appena sotto — gap colmato in questa sessione) +
`cacche.test.ts` (già completo su `giornateTop`).
- Integration (`npx supabase start` richiesto):
- `s-cacche-badge.test.ts` (nuovo) — end-to-end reale: scrive 2 giornate da record su partite
di campionato e una su un'amichevole, dimostrando con dati veri che l'amichevole conta
esattamente come le altre — pin del comportamento attuale, così chi in futuro reintroduce un
filtro sul campionato deve accorgersene qui, non scoprirlo in produzione.
**Badge Uomo tie-break — pipeline end-to-end, bug corretto** (analisi dedicata: trovato e
sistemato il gap "un voto pagella solo sblocca il segreto insieme a 2 MVP"). Il segreto usa
`g.mediaVoto`, lo stesso campo del badge normale `pagella` — che però lo azzera sotto
`VOTI_MINIMI_PAGELLA` (5) voti ricevuti, proprio per evitare che un singolo voto sblocchi/tolga
il badge senza significatività statistica. `s-tiebreak` non applicava lo stesso filtro: ora sì
(`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`).
- Unit: `badges.test.ts` — sotto la soglia minima di voti il segreto resta bloccato anche con
media 8 e 2 MVP; un solo MVP non basta (isolato dal resto).
- Integration (`npx supabase start` richiesto):
- `s-tiebreak-badge.test.ts` (nuovo) — end-to-end reale: scrive voti MVP e pagella veri,
dimostra che un solo voto pagella (media alta, 2 MVP) NON sblocca il segreto, e che il quinto
voto lo sblocca — il fix verificato con la stessa pipeline `mvp_voti`/`pagelle_voti` → REST →
`mvpVintiPerGiocatore()`/`mediePagelle()``statoBadge()` che userebbe l'app.
**Badge Mai un forfait — pipeline end-to-end** (analisi dedicata: nessun bug trovato). Unico
segreto a combinare due statistiche indipendenti (`serieConferme()` e
`contaPresenzeGiocatore()`), entrambe già testate a fondo nei rispettivi moduli.
- Unit: `badges.test.ts`, esteso in questa sessione con ogni soglia isolata al confine
(`serieConferme` appena sotto con `presenze` abbondanti, e viceversa), non solo "insieme non
bastano".
- Integration (`npx supabase start` richiesto):
- `s-mai-forfait-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il` e
risposte con `risposto_il` veri, verifica che il segreto resti bloccato a 9/9 e si sblocchi a
15/15, e che una risposta lenta azzeri la serie di conferme **senza** azzerare le presenze
già accumulate (le due statistiche restano indipendenti anche a database).
**Badge social** — nessuna delle 5 categorie ha logica _propria_ nel codice: l'id è solo una
chiave di raggruppamento, `conteggioCategoria`/`vincitoreCategoria`/`badgeSocialVinti` sono
identici per tutte (`badge-social.ts:107-158`). Testare a fondo 2-3 categorie copre l'intero
meccanismo:
- Unit (`badge-social.test.ts`): conteggio isolato per match+categoria (`:29-32`), vantaggio
netto/parità → nessun vincitore (`:38-41`), vittorie multi-partita (`badgeSocialVinti`, g2
vince in `m1` e `m2``{affidabile: 2}`, `:48`), zero voti → zero badge (`:51`). Estesi in
questa sessione: un voto totale solo basta a vincere, una parità a 3 candidati (i primi due
pari, il terzo staccato) resta senza vincitore, categorie diverse nella stessa partita non si
mischiano in `badgeSocialVinti()`.
- Integration: upsert/sostituzione voto per categoria (`scritture.test.ts:170-202`), autovoto
rifiutato — doppia barriera UI + database (`scritture.test.ts:124-148`), RLS `m11` — un
giocatore firma solo il proprio voto (`permessi.test.ts:344-369`).
- `badge-social.test.ts` (nuovo, in `test/integration/`) — end-to-end reale sulle **5
categorie effettive** di `categorieSocial` (non più solo 2-3, e non più le categorie
inventate di `scritture.test.ts`): scrive voti veri su `badge_social_voti`, dimostra che
tutte e 5 si contano e si vincono allo stesso modo, e che una parità su una categoria non
tocca il conteggio delle altre 4 nella stessa partita.
### Riepilogo per badge
| # | id | tipo | test unit | test integration |
| --- | ------------------- | ------- | ------------------------------------- | --------------------------------------------- |
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
| 6 | `serie-conferme` | normale | ✅ (limite noto sotto) | ✅ (`serie-conferme-badge`) |
| 7 | `s-tiebreak` | segreto | ✅ (bug corretto, vedi sotto) | ✅ (`s-tiebreak-badge`) |
| 8 | `s-mai-forfait` | segreto | ✅ | ✅ (`s-mai-forfait-badge`) |
| 9 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
| 10 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
| 11 | `s-cacche` | segreto | ✅ (descrizione corretta, vedi sotto) | ✅ (`s-cacche-badge`) |
| 12 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 14 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 15 | `meme` | social | ✅ | ✅ (`badge-social`) |
| 16 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
---
## Problemi noti da sistemare
- **`badgeSbloccati()` morta** (`badges.ts:283-285`): duplica esattamente
`collezioneBadge(g).sbloccati`. Zero riferimenti fuori dalla propria definizione, né in
`src/` né nei test. Da rimuovere o documentare perché esiste (es. uso futuro/esterno).
- **`categoria` senza vincolo DB** in `badge_social_voti`: la colonna è `text NOT NULL` senza
CHECK o FK verso i 5 id di `categorieSocial`
(`supabase/migrations/20260803140647_affa1c11-fa92-450f-9f00-02d87195a6d9.sql:4`). I test
stessi lo dimostrano scrivendo categorie inesistenti (`"sorriso"`/`"urlo"`,
`scritture.test.ts`). Non sfruttabile da un utente normale (l'app manda solo le 5 categorie
valide), stesso tipo di gap "solo applicativo, non a DB" del punto sotto sul votato/convocato.
- **`conteggioTurni()` non filtra per tipo evento** (`palloni-core.ts:70-82`), a differenza di
`eventiPalloni()` che scarta i compleanni. Un turno registrato per errore su un evento fuori
dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei palloni. Rischio
teorico basso (l'UI non offre questa combinazione), comportamento pinnato da un test dedicato
in `palloni-core.test.ts` così che un domani, se serve stringere, non lo si scopra rompendo un
test esistente ma leggendo perché quel test lo dimostrava apposta.
---
## Limiti noti
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
@@ -64,12 +425,39 @@ badge assegnati per voto dai compagni.
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
"ottenuto" può sparire o apparire retroattivamente.
- La policy di M11 garantisce che il voto sia firmato con il proprio `votante_id`, ma non
che il votato sia un giocatore convocato per quella partita: quello resta un filtro solo
applicativo.
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
browser o dispositivo.
**Risolto (analisi del badge Sherpa dei palloni)**: prima `g.palloni` (`rosa.ts`) includeva
anche i turni che `completaTurni()` propone in automatico per un evento passato senza
assegnazione esplicita, non solo quelli confermati in `turni_palloni` — un giocatore poteva
vedere avanzare il badge senza aver mai confermato nulla, semplicemente perché l'algoritmo di
rotazione l'aveva proposto. Ora `rosa.ts` passa a `conteggioTurni()` solo `turniSalvati` (i
turni confermati), non l'output di `completaTurni()`: quest'ultimo resta in uso solo per la
UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteggio del badge.
Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di
conseguenza.
**Risolto (audit completo dei 16 badge)**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo
stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il
segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
`g.votiPagella >= VOTI_MINIMI_PAGELLA`, dimostrato con dati reali in `s-tiebreak-badge.test.ts`.
Il badge `s-cacche` prometteva invece "partite di **campionato**" nella descrizione senza che
nessuna funzione della pipeline lo verificasse mai (`statisticheCacche()` conta qualunque
partita) — qui si è scelto di correggere la descrizione, non il codice: il comportamento
"qualunque partita conta" resta quello voluto, pinnato in `s-cacche-badge.test.ts`.
**Risolto (M13, `20260908120000_m13_convocati_e_pagelle_chiuse.sql`)**: prima la policy di M11
garantiva solo che il voto fosse firmato con il proprio `votante_id`, non che il votato (né il
votante) fossero convocati per quella partita — filtro solo applicativo, aggirabile scrivendo
direttamente su PostgREST. Ora `evento_permette_voto()` lo verifica anche a database per
`pagelle_voti`, `mvp_voti` e `badge_social_voti` (convocati vuoto = tutta la rosa, stessa
convenzione di `convocatiEvento()`), e per le sole pagelle verifica anche che
`eventi_app.pagelle_chiuse` sia falso — prima un voto "fuori tempo" restava tecnicamente
possibile bypassando l'interfaccia. Le policy admin restano permissive: un amministratore può
ancora correggere un voto anche fuori convocazione o dopo la chiusura.
---
## Evoluzioni possibili
@@ -77,3 +465,7 @@ badge assegnati per voto dai compagni.
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
- Verificare sui dati di stagione che i tre badge legati alle serie si sblocchino davvero,
ora che le serie sono calcolate.
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
- Se un domani serve restringere `conteggioTurni()` per tipo evento (vedi "Problemi noti"),
aggiornare anche il test che oggi ne pinna il comportamento permissivo.
+60
View File
@@ -0,0 +1,60 @@
# Modulo — Calendario ed Eventi
**Stato:** implementato
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/routes/calendario.tsx`
(vista mensile, tutti), `src/routes/eventi.tsx` (creazione/modifica, solo admin),
`src/components/crapp/EventoCard.tsx` (card condivisa)
**Test:** `test/unit/eventi.test.ts`
---
## Obiettivo
Un unico calendario condiviso per allenamenti, partite, amichevoli ed eventi extra
(riunioni, cene di squadra...), al posto di messaggi sparsi in chat. Ogni evento in
`eventi_app` diventa il punto a cui si agganciano presenze, convocazioni, MVP, pagelle,
scout e turno palloni — la maggior parte degli altri moduli dipende da un `evento.id`.
## Due schermate, due pubblici
- **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti,
partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()`
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa()`, non righe
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
di evento, i giorni con più eventi si dividono lo spazio.
- **`/eventi`** — "Gestione eventi", riservata agli amministratori (`useIsAdmin()`): crea,
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database).
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
`linkPerEvento()` decide dove porta il click — `/partita/$id` per una partita (con
`/partita-csi/$id` come alternativa "solo CSI" quando non c'è un evento collegato, vedi
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, nessun link per eventi ed
eventi virtuali (compleanni).
## Lettura lato server
`src/lib/eventi.server.ts` (`leggiEventi()`) è la stessa conversione riga→modello di
`eventi.ts`, ma con `supabaseAdmin` per le route API che girano senza sessione utente (es.
`sollecita-presenze.ts`, `promemoria-palloni.ts` — vedi `presenze.md` e `palloni.md`) e per
`notifiche-smart.ts`, che decide i promemoria da mandare in base agli eventi del giorno.
---
## Limiti noti
1. **Cancellare un evento è distruttivo per tutto ciò che vi era agganciato.** Un trigger
(`m14_pulizia_dati_evento_cancellato`,
[DD-029](../DESIGN_DECISIONS.md#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati))
pulisce a cascata presenze, cacche, voti MVP/pagelle/badge social, turni palloni e scout
di quell'evento: non è recuperabile con un annulla, e prima di M14 quelle righe restavano
orfane nel database (bonificate una tantum da M15/M16, vedi `PROJECT_STATE.md`).
2. **Nessuna creazione automatica degli eventi partita dal calendario CSI.** Le gare
ufficiali arrivano già come dati (`getEventsByTeamId.php`, vedi `collegamento-csi.md`),
ma un amministratore deve comunque creare a mano l'evento corrispondente in `/eventi`
perché esistano convocazioni, presenze, MVP e pagelle per quella partita — altrimenti la
gara resta visibile solo nello storico CSI, con un dettaglio "solo CSI" più povero
(`/partita-csi/$id` invece di `/partita/$id`). In `docs/ROADMAP.md` sotto "Prossimo".
+273 -37
View File
@@ -1,7 +1,8 @@
# Modulo — Collegamento CSI
**Stato:** implementato (stagione 2025/26)
**Route interessata:** `/classifica`
**Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id`
(dettaglio di una gara: formazioni e scontri diretti)
---
@@ -21,26 +22,125 @@ Il portale **non espone un'API pubblica documentata**. Vengono usati gli stessi
che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza
API key, ma **non offrono alcuna garanzia di stabilità**.
| Endpoint | Formato | Uso |
| ------------------------------------------------ | ------- | ------------------------------------------------------------------------------ |
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi |
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra: data, ora, avversario, campo, risultato, parziali |
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
`project-last_results.php`, `team-roster.php`, `team-results.php`.
Le pagine "umane" (`league_details.php`, `team_details.php`) sono gusci lato server: non
contengono dati, li caricano dopo via JS dagli stessi endpoint `components/*.php`
verificato leggendo `assets/js/project.js` e `assets/js/team.js`, referenziati in fondo
alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare
un `project_id`), nessun codice le chiama direttamente.
### Identificativi (stagione 2025/26)
| Cosa | Valore |
| ------------------- | -------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` | `767` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
| `team_id` | `3359` |
| Girone | B |
| Cosa | Valore |
| ---------------------- | ---------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` (girone) | `767` |
| Coppa | PVM Coppa CSI Misto Silver |
| `project_id` (coppa) | `848` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
| `team_id` | `3359` |
| Girone | B |
Gli identificativi sono costanti in `src/lib/csi-core.ts`.
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
`src/lib/csi-core.ts`.
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
verificato chiamando l'endpoint direttamente, che oggi restituisce sia
`project_id=848` (Coppa) sia `project_id=767` (Campionato). Non serve aprire
`team_details.php` nel browser, questo componente basta.
### Endpoint usati dall'app
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
| -------------------------------------------------- | ------- | ------------------------------------ | ------------------------------------ |
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`,
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
Colonne per `<td>` (0-indicizzate): `0` Pos · `1` Squadra (nome + logo + link a
`team_details.php?team_id=…`) · `2` Punti · `3` Partite giocate · `4` Vinte · `5` Perse ·
`6`-`7` Tie-break vinti/persi (non lette) · `8` Set fatti · `9` Set subiti · poi punti
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per `project_id=767`
(campionato) e `project_id=848` (fase a gironi della Coppa): `parseClassifica()` è
condivisa, nessun parser dedicato per la Coppa.
**Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e**
Coppa insieme, vedi limite 4), con: `id`, `start` (`"2025-11-12T22:00:00"`, data+ora
locale), `team1`/`team2` (nomi squadre), `result` (`"3 - 1"`, stringa libera), `partials`
(`"25 - 23</br>23 - 25</br>..."`, HTML nei separatori), `field` (impianto), `project`
(nome campionato, es. `"PVM - Coppa CSI Misto Silver"` — usato per distinguere le
competizioni, vedi limite 4), `league`, `group` (es. `"Girone B"`), `match_number`. Letto
da `partiteDaEventi()` in `csi-core.ts`, che estrae punteggio/parziali con le regex
`punteggio()`/`parziali()` — vedi limite 5 sui rischi di questo parsing.
**Campi leggeri aggiuntivi letti dallo stesso JSON** (nessuna fetch in più, solo campi in
più letti dallo stesso `evento`): `team1_logo`/`team2_logo` (URL del logo, quello
dell'avversario finisce in `PartitaCsi.logoAvversario`), `group` (girone, es. `"Girone
B"`), `match_number` (n° gara, es. `"5/XEB"`), `referees` (arbitro, spesso vuoto), `link`
(URL del referto ufficiale, `match_details.php?id=…`).
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
`project-last_results.php`, `project-sheets-scorers.php`/`-results.php`/`-measures.php`
(sotto-tab di `project-sheets`: marcatori, risultati per giornata, provvedimenti
disciplinari), `team-roster.php` (rosa), `team-staff.php`, `team-results.php`,
`team-scorers.php`.
### Dettaglio di una singola gara (formazioni e scontri diretti)
Il referto di ogni gara sul portale (`match_details.php?id=<matchId>`, dove `matchId` è lo
stesso `id` restituito da `getEventsByTeamId.php`) carica a sua volta tre componenti via
`assets/js/match.js`:
| Endpoint | Formato | Uso |
| ----------------------------------------------- | ------- | --------------------------------------------------------- |
| `components/match-main.php?match_id=<id>` | HTML | Giornata e una nota libera sotto l'impianto |
| `components/match-players.php?match_id=<id>` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre |
| `components/match-stats.php?match_id=<id>` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI |
Altri due componenti della stessa pagina non sono usati: `match-live.php` (diretta testuale
punto-per-punto, utile solo a gara in corso) e la lista dettagliata dei precedenti dentro
`historyModal` in `match-stats.php` (un elenco partita-per-partita meno affidabile del
riepilogo aggregato — vedi sotto).
**`match-main.php`** — `parseInfoPartita()` (`csi-core.ts`) legge: la giornata (es. `"2ª
Giornata"`, assente per gare fuori dal girone come la finale di Coppa) e una nota libera
sotto l'impianto (`nota`). Quella nota **non ha un formato fisso**: a volte è `"Pubblico
non ammesso"`, a volte il nome della palestra, a volte altro — va mostrata così com'è, non
interpretata come un flag booleano.
**`match-players.php`** — `parseFormazioni()` legge due blocchi `<div class="col-12
col-md-6 mt-4">`, separati nel markup dal commento `<!-- SQUADRA OSPITE -->`, ciascuno con
una `<ul class="list-group">` di giocatori in ordine titolari → divisore "A DISPOSIZIONE"
→ panchina → divisore "STAFF" → staff (numero maglia, nome, ruolo; lo staff ha la stessa
struttura ma senza numero). Quale dei due blocchi sia "noi" si riconosce con
`isNostraSquadra()`, non assumendo un ordine fisso casa/ospite — verificato che l'ordine
nel markup è sempre "squadra casa" prima e "squadra ospite" dopo, ma il codice non si fida
di questo per evitare sorprese. `null` se nessuno dei due nomi è la nostra squadra
(referto non ancora compilato o formato cambiato).
**`match-stats.php`** — `parsePrecedenti()` legge il blocco di riepilogo in fondo alla
pagina (non la lista `historyModal` partita-per-partita, che in un caso osservato conteneva
gare di **altre squadre** senza relazione con la gara corrente — dato non affidabile da
interpretare): numero di precedenti, vittorie totali/in casa/fuori di entrambe, probabilità
di vittoria calcolata dal CSI. **Attenzione a un'insidia verificata sui dati reali**: le
due barre di probabilità sono colorate per chi è favorito (verde = più alta, rosso = più
bassa), **non** per casa/ospite — un parser ingenuo che associasse il verde alla squadra
casa sbaglierebbe metà delle volte. Il parser usa invece l'ordine di apparizione nel
markup (prima barra = squadra casa, seconda = ospite), coerente con l'ordine dei blocchi
"Squadra casa"/"Squadra ospite" più sopra nella stessa pagina. Con "0 precedenti" il CSI
omette del tutto le righe vittorie/in-casa/fuori (restano a `0`) ma la probabilità resta
comunque presente: `parsePrecedenti()` distingue quindi "0 precedenti" (oggetto valido con
`totale: 0`) da "formato non riconosciuto" (`null`, solo se nessuno dei due nomi squadra è
identificabile).
**Formato di `DettaglioPartitaCsi`** (il JSON che compongono insieme):
`{ giornata, nota, formazioni: { noi, avversario } | null, precedenti: PrecedentiCsi | null
}`, dove ogni `FormazioneSquadra` è `{ squadra, titolari: GiocatoreFormazione[], panchina,
staff: StaffFormazione[] }` e `GiocatoreFormazione` è `{ numero, nome, ruolo }`.
---
@@ -49,29 +149,83 @@ Gli identificativi sono costanti in `src/lib/csi-core.ts`.
```
CSI (portale)
↓ fetch server-side, cache 6 ore
/api/public/csi → src/routes/api/public/csi.ts
↓ JSON { classifica, partite, girone, aggiornato }
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
/api/public/csi → src/routes/api/public/csi.ts
↓ JSON { classifica, classificaCoppa, partite, girone, aggiornato }
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
/classifica → src/routes/classifica.tsx
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone
sotto; tab "Storico partite": ogni squadra col proprio logo,
chevron di dettaglio sulle gare cliccabili)
CSI (portale, 3 endpoint)
↓ fetch server-side on-demand, cache per-partita 6 ore
/api/public/csi-partita/$id → src/routes/api/public/csi-partita.$id.ts
↓ JSON DettaglioPartitaCsi { giornata, nota, formazioni, precedenti }
useCsiPartita() → src/lib/csi-partita.ts (React Query, staleTime 6h)
DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazioni + scontri diretti)
/partita/$id (evento CrAPP collegato) o /partita-csi/$id (nessun evento collegato)
```
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe),
`partiteDaEventi()` (JSON → partite), `isNostraSquadra()`, `partiteGiocate()`.
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI. Cache in memoria di
6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne esiste uno).
- **`src/lib/csi.ts`** — hook client, una lettura per sessione.
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe,
usata sia per `classifica` sia per `classificaCoppa`), `partiteDaEventi()` (JSON → partite),
`isNostraSquadra()`, `partiteGiocate()`, `matchDaPartitaCsi()` (porta i campi leggeri —
logo, girone, n° gara, arbitro, link — nella forma usata dalle liste), `parseInfoPartita()`,
`parseFormazioni()`, `parsePrecedenti()` (vedi sezione precedente).
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI per classifica e
partite: tre fetch in parallelo (classifica girone, classifica Coppa, partite). Cache in
memoria di 6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne
esiste uno). Se solo la Coppa fallisce (`scarica(...).catch(() => "")`) la risposta resta
comunque `200` con `classificaCoppa: []`: è un dato supplementare, non blocca la classifica
del girone.
- **`src/routes/api/public/csi-partita.$id.ts`** — route separata per il dettaglio di una
singola gara: tre fetch in parallelo (`match-main`/`match-players`/`match-stats.php`). Cache
in memoria **per `matchId`** (una `Map`, non un singolo valore come `csi.ts`), stessa
finestra di 6 ore. A differenza di `/api/public/csi`, qui un fallimento del fetch è fatale
(`503`, nessun fallback "meglio un dato vecchio"): non c'è ancora una cache da riusare la
prima volta che qualcuno apre una gara, e un errore upstream reale (verificato: CSI risponde
`500` su `match-stats.php` per un `match_id` inventato) va distinto da "gara senza
formazioni ancora pubblicate" (quell'endpoint risponde `200` con markup vuoto, gestito da
`parseFormazioni()`/`parsePrecedenti()` restituendo `null`, non da un errore HTTP).
- **`src/lib/csi.ts`** / **`src/lib/csi-partita.ts`** — hook client React Query, stessa
`staleTime` di 6h. `useCsiPartita(matchId)` è `enabled` solo quando `matchId` è definito:
va montato solo nel dettaglio di una gara, mai in una lista (altrimenti sarebbe una fetch
per riga, vedi "Regole rispettate" sotto).
- **`src/components/crapp/DettaglioCsi.tsx`** — UI condivisa tra `/partita/$id` e
`/partita-csi/$id`: `LogoSquadra` (logo con hotlink diretto al portale CSI, si nasconde da
sola se l'immagine non carica invece di mostrare un'icona rotta), `MetaPartitaCsi` (girone,
n° gara, arbitro, link al referto — campi leggeri, zero fetch aggiuntive), `DettaglioCsiEsteso`
(formazioni + scontri diretti, monta `useCsiPartita()`).
- **`src/routes/partita-csi.$id.tsx`** — dettaglio "solo CSI" per le gare **senza** un evento
CrAPP collegato (l'app non crea ancora eventi automaticamente dal calendario CSI, vedi
"Evoluzioni possibili"): nessuna convocazione/presenza/MVP/scout, solo risultato, parziali
e i dati CSI di questa sezione. `id` è l'`id` della gara sul portale CSI
(`PartitaCsi.id`), non un evento CrAPP.
- **`src/routes/partita.$id.tsx`** — per le gare **con** un evento CrAPP collegato, mostra le
stesse informazioni CSI (logo, metadati, formazioni, scontri diretti) in più rispetto a
prima, quando `csiMatch` esiste per quella data.
- **`test/unit/csi-core.test.ts`** — check del parsing: `bun test/unit/csi-core.test.ts`.
Con `CSI_LIVE=1` verifica anche gli endpoint reali.
Con `CSI_LIVE=1` verifica anche gli endpoint reali, incluse formazioni e precedenti di una
gara giocata.
### Regole rispettate
- **Nessuna chiamata dal browser**: il portale viene contattato solo lato server, al massimo
4 volte al giorno, indipendentemente da quanti giocatori aprono l'app (regola anti-consumo).
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della tabella.
- **Nessuna chiamata dal browser per classifica/partite**: il portale viene contattato solo
lato server, al massimo 4 volte al giorno per `/api/public/csi`, indipendentemente da
quanti giocatori aprono l'app (regola anti-consumo). **`/api/public/csi-partita/$id` è
diverso di proposito**: è on-demand, chiamato solo quando un giocatore apre il dettaglio
di una gara specifica (mai precaricato in una lista, vedi `useCsiPartita()` sopra) — non
rientra nel limite delle 4 chiamate/giorno perché non è un dato mostrato a tutti a ogni
apertura dell'app, ma cache comunque 6 ore per evitare rifetch ripetuti sulla stessa gara.
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della
tabella/lista, sia per classifica/partite sia per formazioni/precedenti.
- **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo
dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati
ricadono sulle partite dello Scout Live locale (`useScoutMatches()`).
`/api/public/csi-partita/$id` non ha questo fallback sulla prima chiamata per una gara mai
vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non
mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina.
- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo.
---
@@ -83,18 +237,100 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`.
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id, ricavabile da
`team_details.php?team_id=3359`, che elenca i campionati della squadra. Oggi va aggiornato
a mano in `csi-core.ts`.
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
istanze. Sufficiente per una squadra; se serve di più, spostare i dati in una tabella
Supabase riempita da un job cron (stesso pattern di `promemoria-palloni`).
4. **I risultati includono anche la Coppa**, non solo il girone di campionato.
istanze — vale sia per `/api/public/csi` sia per la `Map` per-partita di
`/api/public/csi-partita/$id`. Sufficiente per una squadra; se serve di più, spostare i
dati in una tabella Supabase riempita da un job cron (stesso pattern di
`promemoria-palloni`).
4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.**
`getEventsByTeamId.php?team_id=3359` è per squadra, non per competizione (vedi tabella
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa
struttura a tabella-per-girone di `project_id=767`, quindi `parseClassifica()` funziona
invariata — nessun parser dedicato. Resta un limite: quella pagina copre **solo la fase a
gironi**. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola
andata seguiti da una finale secca tra le due vincenti, disputata in un `project_id`
figlio separato generato a fine fase a gironi (verificato con `curl` diretto:
`project-main.php?project_id=848` descrive il regolamento — "due gironi sola andata, le
due vincenti in finale, gare 3 set su 5" — e `project-sheets.php?project_id=848` mostra
sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il
tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in
finale, `/classifica` continuerebbe a mostrare la classifica (ormai chiusa) del proprio
girone di Coppa, non l'esito della finale: non c'è codice che segua quel `project_id`
figlio, che oltretutto cambia a ogni edizione della Coppa e non è noto in anticipo.
5. **Le partite si leggono da JSON, con parsing fragile su campi testuali.** `result` e
`partials` in `getEventsByTeamId.php` sono stringhe libere tipo `"3-1"`, lette con
un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il portale CSI
cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la regex
non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata"
(`setNostri`/`setLoro` a `null`) — silenziosamente, senza errori. Se invece la risposta
cambiasse forma radicalmente (non più un array), `partiteDaEventi()` torna `[]`.
**Conseguenza sugli obiettivi di squadra**: le "vittorie in campionato" (`obiettivi.ts`,
obiettivi o3/o4/o5) dipendono da `partiteGiocate(csi.partite)` — se il parsing delle partite
si rompe così, questi tre obiettivi restano bloccati a 0% anche a fronte di vittorie reali.
**Il fallback della route non se ne accorgerebbe da solo**: `/api/public/csi` lancia un
errore solo se *sia* la classifica *sia* le partite sono vuote insieme
(`classifica.length === 0 && partite.length === 0`); se si rompe solo il parsing delle
partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200`
con `partite: []`. Per questo `leggiCsi()` confronta il JSON grezzo con il risultato di
`partiteDaEventi()` tramite `partiteFormatoSospetto()` (`csi-core.ts`): se ci sono eventi
grezzi ma nessuno è stato riconosciuto come nostra partita, logga un `console.error`
distingue così un vero "formato cambiato" da un legittimo "nessuna gara ancora in
programma" (dove gli eventi grezzi stessi sono vuoti). Il flag `formatoSospetto` viaggia
anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
(`src/routes/classifica.tsx`), dove mostra un badge discreto ("Il portale CSI potrebbe aver
cambiato formato: dati da verificare.") al posto della normale riga "Dati CSI aggiornati
alle...": un log server passa inosservato per settimane, un badge visibile a chi apre la
pagina campionato molto meno. Il fix, quando succede, è isolato a
`partiteDaEventi()`/`punteggio()`/`parziali()` in `csi-core.ts` (gli endpoint stessi
cambiano solo se cambia il dominio o serve autenticazione, nel qual caso va toccata anche
`src/routes/api/public/csi.ts`); va poi aggiornato anche `test/unit/csi-core.test.ts` con
fixture nel nuovo formato.
6. **Il portale può essere del tutto irraggiungibile, non solo cambiare formato.** Scenario
diverso dal punto 5 (lì il JSON è valido ma non riconosciuto, qui la risposta non è
nemmeno JSON): l'8 settembre 2026 `getEventsByTeamId.php` ha risposto con `200` ma un
errore SQL del loro backend in chiaro al posto del JSON
(`Query non valida (getProjectTeams): Table 'uqc2os2x_livescore.seasons' doesn't exist`,
verificato con `curl` diretto sul loro dominio). `leggiCsi()` (`src/routes/api/public/
csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde
`503 "CSI non raggiungibile"` (o serve la cache se ce n'è una) — nessun crash, ma nessun
dato nuovo finché il portale non torna. **Effetto sulla suite test**: i test di
`test/integration/api.test.ts` che leggono il CSI reale sondano `/api/public/csi` una
volta prima di partire; se risponde con errore li salta (`salta()`, non `prova()`) invece
di farli fallire, loggando il motivo — la suite resta verde durante un'indisponibilità
temporanea del portale, senza che quei 5 test vengano cancellati o disattivati in modo
permanente: tornano a girare da soli non appena il CSI risponde di nuovo con `200`.
7. **I loghi delle squadre sono "hotlinked" direttamente dal browser al portale CSI**
(`LogoSquadra` in `DettaglioCsi.tsx` punta a `logoAvversario`, un URL
`livescore.csibologna.it/images/...`). È un'eccezione consapevole alla regola "nessuna
chiamata dal browser al CSI": un'immagine, a differenza dei dati, non ha bisogno di
passare dalla cache server per restare aggiornata, e proxarla/cacherla lato server per
ogni squadra avversaria (potenzialmente decine a stagione) sarebbe uno sforzo sproporzionato
al beneficio. Se un logo non carica (URL cambiato, squadra senza foto),
`LogoSquadra` si nasconde da sola (`onError``null`) invece di mostrare un'icona rotta.
8. **L'ordine "titolari"/"A disposizione" in `parseFormazioni()` è quello del referto CSI,
non necessariamente il sestetto che è sceso davvero in campo al fischio d'inizio.** Il
CSI non separa esplicitamente "chi ha giocato titolare" da "chi era comunque convocato e
in lista gara": il divisore "A DISPOSIZIONE" nella pagina sembra riflettere l'ordine di
inserimento nel referto più che le sostituzioni reali. Va quindi presentato come "referto
del CSI", non come cronaca esatta di chi ha giocato quanto.
---
## Evoluzioni possibili
- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili).
- Creazione automatica degli eventi partita da calendario CSI.
- Creazione automatica degli eventi partita da calendario CSI — risolverebbe anche il
limite 4 di sopra: ogni gara avrebbe un evento CrAPP e andrebbe sempre su `/partita/$id`,
senza più bisogno di `/partita-csi/$id` per le gare "orfane".
- Confronto tra i parziali ufficiali e quelli dello Scout Live.
- Tabellone a eliminazione della fase finale di Coppa (limite 4): oggi non tracciato, il
`project_id` figlio (es. `905`) andrebbe scoperto a runtime leggendo il link dentro
`project-sheets.php?project_id=848` invece di essere una costante.
+15 -5
View File
@@ -1,6 +1,6 @@
# Modulo — Votazione MVP
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
---
@@ -37,6 +37,9 @@ restano nel database ma non vengono più letti da nessuna schermata).
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
incoronare nessuno, nemmeno senza concorrenza.
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
nel profilo e in home.
@@ -48,10 +51,17 @@ restano nel database ma non vengono più letti da nessuna schermata).
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
vincolo è che non sia il votante stesso (`mvp_no_autovoto`): che votante e votato fossero
presenti a quella partita, e che siano passate due ore dall'inizio, restano filtri solo
applicativi — chi scrive su PostgREST li aggira.
- In caso di parità, nessun MVP viene assegnato per quella partita.
vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`).
Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia
il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
= tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti**
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque.
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella
partita.
---
+8 -4
View File
@@ -81,10 +81,14 @@ route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi
è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna
variabile d'ambiente.
| Route | Controllo | Chi la chiama |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante riservato agli admin |
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
| Route | Controllo | Chi la chiama |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
---
+142 -29
View File
@@ -1,6 +1,7 @@
# Modulo — Obiettivi di squadra
**Stato:** implementato (v1.0), con costanti stagionali da aggiornare a mano
**Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano,
copertura test completa (unit + integration) su tutti e 10 gli obiettivi.
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
---
@@ -15,47 +16,159 @@ comportamenti di squadra oltre alla singola prestazione.
## Dati
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts` che legge dati
già aggregati altrove (`risposte_presenze`, `pagelle_voti`, i risultati ufficiali CSI, le
serie).
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts`
(`obiettiviSquadra()`) che legge dati già aggregati altrove (`risposte_presenze`,
`pagelle_voti`, i risultati ufficiali CSI, le serie di presenza). `obiettiviOrdinati()` li
ordina mettendo i completati in coda e gli altri per progresso decrescente.
Non c'è nessuno stato da tenere sincronizzato quando un evento viene cancellato: gli obiettivi
sono ricalcolati da zero a ogni render partendo dall'elenco eventi corrente, quindi un evento
sparito da `eventi_app` smette semplicemente di contare, senza bisogno di nessuna pulizia
esplicita. Il problema che *sembrava* riguardare gli obiettivi era in realtà nelle tabelle
collegate a un evento (presenze, pagelle, MVP, ecc.), che restavano orfane a database dopo la
cancellazione: risolto a livello database con un trigger (migration
`m14_pulizia_dati_evento_cancellato`, DD-029), non nel modulo Obiettivi.
`obiettiviSquadra(rosa, ctx, oggi)` accetta un terzo parametro opzionale `oggi: Date` (default
`new Date()`) per iniettare una data deterministica nei test — usato dai due obiettivi con mese
corrente dinamico (vedi sotto).
---
## Obiettivi definiti
| Obiettivo | Calcolo | Target | Fonte |
| ---------------------------------- | ----------------------------------------------- | ------ | ---------------------------------------------- |
| 90% presenze ad agosto | risposte presente/ritardo sugli eventi del mese | 90% | `risposte_presenze` |
| Tutti rispondono alle convocazioni | risposte totali / eventi possibili | 90% | `risposte_presenze` |
| 250 presenze complessive | somma presenze di tutta la rosa | 250 | aggregato da `useRosa()` |
| Media pagelle da 7.5 | media di squadra | 7.5 | `pagelle_voti` |
| 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
| Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 | `serieAllenamenti` |
| 1 / 5 / 10 vittorie in campionato | partite vinte da dati CSI ufficiali | 1/5/10 | modulo [Collegamento CSI](collegamento-csi.md) |
| 1 evento di squadra al mese | eventi di tipo "evento" nel mese | 1 | `eventi_app` |
| id | Obiettivo | Calcolo | Target | Fonte |
| ----- | ----------------------------------- | ----------------------------------------------------- | ----------------------- | ------------------------------------------ |
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
smart (`notifiche-smart.ts`).
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
---
## Limiti noti
## Obiettivi mensili — mese dinamico
- "Continuità di squadra" dipende da `serieAllenamenti` (vedi
[Serie di presenze](serie-presenze.md)), calcolato sui dati reali: un evento passato senza
risposta vale come assenza e azzera la serie, quindi l'obiettivo misura anche quanto la
squadra risponde alle convocazioni, non solo la presenza.
- **Il mese di riferimento è una costante fissa nel codice** (agosto 2026): gli obiettivi
legati al mese corrente vanno aggiornati manualmente a ogni cambio di mese o stagione, oggi
sono "congelati" su un mese già passato.
- Le vittorie di campionato dipendono dal parsing HTML del portale CSI: se quel parsing si
rompe, questi tre obiettivi restano a 0% anche a fronte di vittorie reali.
- I target (250 presenze, 200 pagelle, ecc.) sono costanti fisse, da rivedere manualmente a
ogni stagione.
`o1` ("90% presenze del mese") e `o6` ("1 evento di squadra al mese") si azzerano
automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente
(fuso Europe/Rome, `meseCorrente(oggi)`), non più una costante fissa. Per `o1`, titolo
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
seguono di conseguenza.
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
anch'essa l'ultimo giorno del mese corrente (`fineMese(oggi)`), non più una data fissa.
---
## Evoluzioni possibili
## Continuità di squadra — il target 12 è il minimo per un 6vs6
- Calcolare il mese di riferimento dinamicamente invece di una costante hardcoded.
Il target di 12 giocatori con almeno 3 allenamenti consecutivi (`o11`) **non è arbitrario**: è
il numero minimo di giocatori per schierare due sestetti (6 contro 6) in allenamento. A
differenza degli altri target fissi, non va scalato in proporzione alla rosa se questa cambia
dimensione — resta 12 finché l'obiettivo è "riuscire ad allenarsi in modo completo".
Dipende da `serieAllenamenti` (vedi [Serie di presenze](serie-presenze.md)), calcolato sui dati
reali: un evento passato senza risposta vale come assenza e azzera la serie, quindi l'obiettivo
misura anche quanto la squadra risponde alle convocazioni, non solo la presenza fisica.
---
## Vittorie in campionato (o3/o4/o5) — dipendenza dal portale CSI
Le vittorie (`ctx.vittorie`) arrivano dal **JSON** delle partite del portale CSI Bologna
(`getEventsByTeamId.php`, non la pagina HTML della classifica), tramite
`partiteGiocate(csi.partite).filter(p => p.setNostri > p.setLoro)` calcolato in
`src/lib/rosa.ts` (`useObiettivi()`). `o3`/`o4`/`o5` sono lo stesso numero di vittorie letto a
tre soglie diverse (1/5/10), ciascuna cappata con `Math.min` — nessuna delle tre supera mai il
proprio target, nemmeno con più vittorie di quante ne servano.
### Il limite: il parsing del JSON può rompersi in silenzio
`result` e `partials` nella risposta di `getEventsByTeamId.php` sono stringhe libere tipo
`"3-1"`, lette con un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il
portale CSI cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la
regex non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata" — **senza
errori**. Se la risposta cambiasse forma radicalmente (non più un array), `partiteDaEventi()`
torna `[]`. In entrambi i casi `o3`/`o4`/`o5` restano bloccati a 0% anche a fronte di vittorie
reali, e il fallback della route (`/api/public/csi`) non se ne accorgerebbe da solo: lancia un
errore solo se *sia* la classifica *sia* le partite sono vuote insieme, quindi se si rompe solo
il JSON delle partite mentre la classifica HTML continua a funzionare, la route risponde
comunque `200` con `partite: []`.
### Come è mitigato oggi
- **`partiteFormatoSospetto()`** (`csi-core.ts`) confronta gli eventi grezzi ricevuti con il
risultato di `partiteDaEventi()`: se ci sono eventi ma nessuno è stato riconosciuto come
nostra partita, il formato è quasi certamente cambiato (distingue così un vero "formato
rotto" da un legittimo "nessuna gara ancora in programma", dove gli eventi grezzi sono vuoti
anche loro).
- La route (`src/routes/api/public/csi.ts`) logga un `console.error` quando succede.
- Il flag viaggia anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
(`src/routes/classifica.tsx`), dove sostituisce la riga "Dati CSI aggiornati alle..." con un
badge discreto color warning ("Il portale CSI potrebbe aver cambiato formato: dati da
verificare.") — visibile a chi apre la pagina campionato, non solo nei log del server.
### Come fixarlo, se succede
1. **Vedere il nuovo formato**: guardare la risposta reale dell'endpoint, o lanciare
`CSI_LIVE=1 bun test/unit/csi-core.test.ts` (interroga il portale vero).
2. **Aggiornare il parsing** in `src/lib/csi-core.ts`: quasi sempre basta toccare
`punteggio()`/`parziali()` (le regex sul formato del punteggio) o i nomi dei campi letti in
`partiteDaEventi()`. Il resto dell'app consuma solo i tipi già puliti che questo file
produce (`DatiCsi`, `PartitaCsi[]`), quindi il fix resta isolato.
3. Serve toccare anche `src/routes/api/public/csi.ts` solo se cambiano gli **URL/endpoint**
stessi o serve autenticazione — non per un semplice cambio di formato dei dati.
4. **Aggiornare i test**: `test/unit/csi-core.test.ts` con fixture nel nuovo formato, altrimenti
restano verdi contro un formato che non esiste più.
Dettagli completi (endpoint, identificativi di stagione, altri limiti del collegamento CSI) in
[Collegamento CSI](collegamento-csi.md).
---
## Copertura test
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
da un backend reale, non solo funzione pura con contesto costruito a mano).
| Obiettivi | Unit test | Integration test |
| ------------ | -------------------------------- | ------------------------------------------------------------ |
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
(evento dentro/fuori mese), scadenza dinamica.
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
della tabella (`pagelle_no_autovoto`, `pagelle_voto_range`), li rilegge, verifica media
arrotondata e conteggio.
- **o3/o4/o5** (CSI reale, non Supabase — le vittorie non toccano il database): estende
`test/integration/api.test.ts`, che già chiama `/api/public/csi` dal vivo. Legge le vittorie
vere del giorno con la stessa logica di `useObiettivi()`, le passa a `obiettiviSquadra()` e
verifica cap e target su dati reali.
Per rilanciare tutto: `npm run test` (unit, nessuna rete) e `npm run test:integration`
(richiede `npx supabase start` per o1/o2/o6/o7/o11/o12/o13, e rete verso CSI Bologna per
o3/o4/o5 — quest'ultimo gira comunque anche senza stack Supabase locale).
+25 -12
View File
@@ -1,6 +1,6 @@
# Modulo — Pagelle
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/pagelle.ts`, `src/components/crapp/Pagelle.tsx`
---
@@ -29,9 +29,19 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
- `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
volte, l'ultimo voto sovrascrive il precedente.
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
tutti i voti della stagione; `pagellePartita()` la calcola per singola partita;
`mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in `squadra.tsx`.
- `useRosa()` inietta la media stagionale nel campo `mediaVoto` di ogni giocatore.
**tutti i voti mai ricevuti** — l'app non ha un concetto di stagione/reset, quindi non è
"la media di questa stagione" ma lo storico completo; `pagellePartita()` la calcola per
singola partita; `mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in
`squadra.tsx`.
- `useRosa()` inietta questa media storica nel campo `mediaVoto` di ogni giocatore, insieme al
numero di voti ricevuti (`votiPagella`) — usato dal badge Pagellone (vedi
[badge.md](badge.md)) per richiedere un minimo di voti prima che la media conti, e mostrato
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
- La StatTile **home** applica la stessa soglia del badge Pagellone (DD-028) tramite la
funzione pura `mediaVotoColpoDOcchio()`: sotto `VOTI_MINIMI_PAGELLA` voti ricevuti mostra
`—` invece della media, non solo quando i voti sono zero. È stata estratta come funzione
testabile (coerente con DD-020) invece di restare una condizione inline nella route. Le
StatTile di **profilo** e **squadra** non applicano questa soglia (vedi "Limiti noti").
---
@@ -39,25 +49,28 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
- Anti auto-voto imposto anche a livello database (constraint, non solo filtro UI).
- L'admin può marcare un evento come `pagelleChiuse` (`eventi.ts`), che nasconde i bottoni di
voto in UI.
voto in UI **e**, da M13, rifiuta anche a database un voto scritto dopo la chiusura (RLS
`evento_permette_voto()`, `pagelle_voti`).
- Da M13 anche il votante e il votato devono essere convocati all'evento: verificato a
database, non solo in UI (stessa RLS di sopra).
---
## Limiti noti
- **`pagelleChiuse` è solo un flag UI**: nessuna policy RLS lo controlla, quindi un voto
"fuori tempo" resta tecnicamente possibile bypassando l'interfaccia.
- **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id`
sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non
mostra mai il votante, ma il dato non è né aggregato né mascherato lato server.
- Nessun controllo a livello database che il votante sia realmente un convocato della
partita: solo filtro applicativo.
- La media non richiede un numero minimo di voti: con un solo voto ricevuto, la media
coincide con quel voto.
- La media mostrata nel **profilo** e in **squadra** non richiede un numero minimo di voti:
con un solo voto ricevuto, la media coincide con quel voto. Il badge Pagellone (`badge.md`)
e la StatTile **home** (DD-028) applicano invece la stessa soglia minima prima di
considerarla — profilo e squadra no.
- Le due regole di M13 (convocazione, `pagelle_chiuse`) valgono solo per la policy "Ognuno
gestisce i propri voti pagella": un amministratore può ancora correggere un voto fuori
convocazione o dopo la chiusura, di proposito (deve poter sistemare un errore).
---
## Evoluzioni possibili
- Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati.
- Far rispettare `pagelleChiuse` anche via RLS.
+17 -8
View File
@@ -1,6 +1,6 @@
# Modulo — Palloni
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/palloni.ts`, `src/lib/palloni-core.ts`,
`src/components/crapp/TurnoPalloni.tsx`, `src/components/crapp/PromemoriaPalloni.tsx`,
`src/routes/api/public/promemoria-palloni.ts`
@@ -33,11 +33,17 @@ compaiono.
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
upsert su `evento_id`.
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
ricalcolato a runtime da `conteggioTurni()` su turni salvati **più proposte non ancora
confermate** (partite/eventi) — non è uno storico in tabella dedicata. Conta solo gli
eventi già passati (`e.data < oggi`, stesso criterio delle presenze): un turno assegnato
in anticipo per un allenamento futuro non è ancora "portato", quindi non sale finché quel
giorno non arriva.
ricalcolato a runtime da `conteggioTurni()` sui **soli turni confermati** (`turniSalvati`
in `rosa.ts`) — non è uno storico in tabella dedicata, ma non include le proposte
automatiche di `completaTurni()` (quelle restano solo per la UI di rotazione,
`TurnoPalloni.tsx`/`PromemoriaPalloni.tsx`). Conta solo gli eventi già passati (`e.data <
oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un allenamento
futuro non è ancora "portato", quindi non sale finché quel giorno non arriva.
- `serieConsecutivaPalloni()` (`palloni-core.ts`) calcola le volte **consecutive** in cui il
giocatore ha portato i palloni (`Giocatore.seriePalloni` in `rosa.ts`), mostrate nel
sottotitolo della classifica interna di Squadra quando si ordina per Palloni. Stesso
criterio "solo eventi già passati" di `conteggioTurni()`; un evento passato senza turno
confermato non spezza la serie di nessuno (viene saltato, non conta come "non portati").
- `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx`
è il banner in Home per il giocatore di turno.
@@ -62,10 +68,13 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
- Il conteggio dei turni include anche le proposte non confermate: badge e statistiche
possono contare turni mai effettivamente convalidati da nessuno.
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
"assente" o "infortunato" per quell'evento.
- **`conteggioTurni()` non filtra per tipo evento** (a differenza di `eventiPalloni()`, che
scarta i compleanni): guarda solo `e.data < oggi`. Un turno registrato per errore su un
evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa combinazione — ma
il comportamento attuale è pinnato da un test dedicato in `palloni-core.test.ts`.
---
+9 -1
View File
@@ -1,6 +1,6 @@
# Modulo — Presenze
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/presenze.ts`, `src/lib/presenze-mese.ts`, `src/components/crapp/RosaPresenze.tsx`,
`src/components/crapp/EventoCard.tsx`, `src/routes/api/public/sollecita-presenze.ts`
@@ -52,6 +52,14 @@ contaPresenzeGiocatore() / totaliEventiGiocatore() → src/lib/presenze.ts
usePresenzeUltimoMese() → src/lib/presenze-mese.ts
(percentuale ultimi 30gg, da cache già in memoria)
contaPresenzeGiocatore() alimenta il campo `presenze` del `Giocatore` in `useRosa()`, mostrato
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
contaPartiteGiocate() è contaPresenzeGiocatore() ristretto alle sole partite (non
allenamenti): alimenta `Giocatore.partiteGiocate`, usato nel sottotitolo della classifica
interna di Squadra quando si ordina per MVP — un conteggio di eventi generico (allenamenti
compresi) sarebbe fuorviante lì, perché l'MVP si vota solo alle partite.
--- sollecito (solo admin) ---
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
+16 -2
View File
@@ -1,5 +1,11 @@
# Modulo — Profilo Giocatore
**Stato:** implementato
**File principali:** `src/lib/profili.ts`, `src/lib/profili-core.ts`, `src/routes/profilo.tsx`,
`src/routes/admin.tsx`
---
## Obiettivo
Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amministrative e documentali di ciascun membro della squadra.
@@ -159,9 +165,17 @@ Contiene.
## Dashboard amministratore
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
Profilo → Opzioni).
Profilo → Opzioni), organizzata in tab scorrevoli a pillole (`BarraSottosezioni`, stesso
componente di [Squadra](squadra.md) e Campionato): Squadra, Profili, Disattivati (solo se
c'è almeno un giocatore disattivato) e Notifiche.
Per ogni giocatore vengono mostrati.
La tab **Notifiche** mostra quanti giocatori attivi hanno almeno un dispositivo iscritto
alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (vedi
[Notifiche](notifiche.md)). È solo consultiva: l'attivazione resta un gesto che ogni
giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
di altri.
Per ogni giocatore, nella tab Profili, vengono mostrati.
- Stato del profilo
- Certificato medico
+1 -1
View File
@@ -1,6 +1,6 @@
# Modulo — Scout Live
**Stato:** implementato (v1.0, fix M7 per la persistenza condivisa)
**Stato:** implementato (fix M7 per la persistenza condivisa)
**File principali:** `src/lib/scout-live.ts`, `src/lib/scout-stato.ts`, `src/lib/scout-store.ts`,
`src/lib/scout-export.ts`, `src/lib/cacche.ts`, `src/components/crapp/ScoutEntry.tsx`,
`src/components/crapp/SondaggioCacche.tsx`, `src/routes/scout.tsx`, `src/routes/partita.$id.tsx`
+2 -1
View File
@@ -5,7 +5,8 @@
`src/components/crapp/SerieCard.tsx`
**Migration collegata:** `m9_risposte_presenze_risposto_il`
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`,
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`)
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`),
`test/integration/serie-allenamenti-badge.test.ts`, `test/integration/serie-conferme-badge.test.ts`
---
+89
View File
@@ -0,0 +1,89 @@
# Modulo — Squadra
**Stato:** implementato
**File principali:** `src/lib/giocatori-squadra.ts`, `src/lib/giocatori-squadra.server.ts`,
`src/lib/rosa.ts`, `src/routes/squadra.tsx`, `src/routes/admin.tsx` (sezione rosa)
**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/rosa.test.ts`
---
## Obiettivo
Tenere l'anagrafica della rosa (nome, numero di maglia, ruolo, chi è collegato a quale
account) in un unico posto — `giocatori_squadra` — e farla usare a tutte le schermate che
hanno bisogno di sapere "chi c'è in squadra", invece di ciascuna avere la propria copia.
Prima di [DD-015](../DESIGN_DECISIONS.md#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database)
la lista viveva hardcoded in `src/lib/crapp-data.ts`: aggiungere o disattivare un
giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
---
## Due letture diverse, per non pagare due volte lo stesso costo
- **`useAnagraficaRosa()`** (`rosa.ts`) — solo id, nome, ruolo, numero, data di nascita dei
giocatori `attivo`. Serve dove basta sapere chi c'è, es. i compleanni nel Calendario o le
liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni.
- **`useRosa()`** (`rosa.ts`) — la stessa anagrafica arricchita con tutte le statistiche
personali calcolate a runtime: presenze, partite giocate, serie (presenze, allenamenti,
partite, conferme, palloni), MVP vinti, media voto pagelle, palloni, cacche, infortuni,
ritardi. Non fa query aggiuntive: combina in un `useMemo` le cache già in memoria di
`mvp-voti.ts`, `pagelle.ts`, `cacche.ts`, `palloni.ts`, `infortuni.ts`, `presenze.ts`,
`eventi.ts` — la spec di ciascuna di queste statistiche sta nel modulo relativo
(`mvp.md`, `pagelle.md`, `palloni.md`, `infortuni.md`, `presenze.md`). `useRosa()` è anche
la base di `useIo()` (il giocatore sul dispositivo corrente) e `useObiettivi()`
(`obiettivi-squadra.md`).
Entrambe filtrano solo i giocatori `attivo`: chi ha lasciato la squadra resta nel database
(presenze, voti, pagelle e badge della stagione restano agganciati al suo id) ma sparisce
dagli elenchi correnti.
## Gestione dati squadra (solo amministratore)
Da `/admin` un amministratore può ([DD-017](../DESIGN_DECISIONS.md#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore)):
| Azione | Hook | Effetto |
| ---------------------- | ------------------------ | ------------------------------------------------------------- |
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database |
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
accesso con Google, per corrispondenza email
([DD-018](../DESIGN_DECISIONS.md#dd-018--collegamento-automatico-giocatoreaccount-per-email)).
`useCollegaGiocatore()` esiste per completare quel flusso, non per una scelta libera
dell'admin.
Le regole di validazione (`validaDatiSquadra()`, `numeroGiaUsato()`) rispecchiano i vincoli
della tabella (numero maglia univoco tra gli attivi, campi obbligatori): l'obiettivo è
mostrare un messaggio leggibile invece di far arrivare un errore Postgres grezzo
all'amministratore.
## Classifica interna di Squadra
La tab "Stats" di `/squadra` mostra una classifica interna ordinabile per 5 criteri
(`CriterioClassifica` in `rosa.ts`): presenze, media voto, MVP, palloni, cacche/partita.
`classificaRank()` calcola un "dense rank" (a parità di valore stessa posizione, il
successivo non salta — 1, 1, 2, non 1, 1, 3); `dettaglioClassifica()` sceglie quale
sottostatistica mostrare sotto il nome, coerente col criterio selezionato (es. "voti
pagella" per il criterio media voto, non sempre "presenze consecutive").
Le altre tab di `/squadra` (Rosa, Obiettivi, Badge) sono viste diverse sugli stessi dati di
`useRosa()`/`useObiettivi()`/`badges.ts`: non introducono altra logica di dominio, solo
presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squadra.md`.
---
## Limiti noti
1. **`giocatori_squadra` non ha ancora una colonna per la data di nascita.** Per i
giocatori storici (seed iniziale) la nascita viene letta da `crapp-data.ts`
(`nascitaPerId`, lookup per id); un giocatore aggiunto dopo la migrazione non ha nascita
nota finché la colonna non esiste (DD-015). Effetto visibile: niente compleanno nel
Calendario per quei giocatori.
2. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database
non risponde o non è ancora popolato, `rosaFallback()` genera una rosa di riserva dai
dati statici storici. Un ambiente nuovo senza dati in `giocatori_squadra` mostra quindi
comunque una squadra, non una schermata vuota — ma è la rosa 2025/26 hardcoded, non
quella reale.
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "crapp",
"version": "0.9.0",
"version": "0.9.2",
"private": true,
"sideEffects": false,
"type": "module",
@@ -17,7 +17,6 @@
"test:all": "bun test/run.ts all"
},
"dependencies": {
"@lovable.dev/cloud-auth-js": "^1.1.2",
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.101.1",
@@ -39,7 +38,7 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@lovable.dev/vite-tanstack-config": "2.8.5",
"@tanstack/devtools-vite": "^0.8.3",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
+29 -13
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
import { proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
export type VoceSottosezione = {
id: string;
@@ -20,8 +21,9 @@ const transizioneTab = { type: "tween" as const, duration: 0.16, ease: [0.25, 0.
*
* `variante="sottolineatura"`: pillola piena sulla tab attiva (Squadra e Classifica CSI);
* su mobile la sola barra tab scorre in orizzontale senza allargare la pagina.
* Default `pillole`: tab a larghezza naturale (Profilo). Il titolo ripetuto sotto la
* barra non viene mai mostrato: letichetta è già nella tab.
* Default `pillole`: tab a larghezza uguale (basata sulla etichetta più lunga); se non
* entrano nella viewport la barra scorre in orizzontale (Profilo). Il titolo ripetuto sotto
* la barra non viene mai mostrato: letichetta è già nella tab.
*/
export function BarraSottosezioni({
voci,
@@ -32,13 +34,17 @@ export function BarraSottosezioni({
voci: VoceSottosezione[];
defaultId?: string;
variante?: "pillole" | "sottolineatura";
/** Tab a larghezza uguale che riempiono la barra (es. Campionato). */
/** Tab a larghezza uguale che riempiono la barra (es. Campionato, Squadra). */
riempiLarghezza?: boolean;
}) {
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
const direzione = useRef(0);
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const ridotto = useReducedMotion();
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: qui disattiva sia lo swipe orizzontale sia il
// blur della barra tab, i due costi maggiori all'ingresso in una pagina con
// sottosezioni (Squadra, Campionato, Profilo).
const ridotto = useMotoRidotto();
const indice = Math.max(
0,
voci.findIndex((v) => v.id === attiva),
@@ -69,7 +75,8 @@ export function BarraSottosezioni({
<div
className={cn(
"min-w-0 max-w-full border-b border-border bg-background",
!sottolineatura && "bg-background/80 pt-3 backdrop-blur-md",
!sottolineatura &&
(ridotto ? "bg-background pt-3" : "bg-background/80 pt-3 backdrop-blur-md"),
)}
>
{/* Solo la barra tab può scrollare in orizzontale: non allarga il layout pagina. */}
@@ -83,11 +90,13 @@ export function BarraSottosezioni({
role="tablist"
aria-label="Sottosezioni"
className={cn(
"flex flex-nowrap",
riempiLarghezza
? "w-full"
: "w-max min-w-full",
sottolineatura ? "gap-1 px-2 py-1.5" : "snap-x snap-mandatory gap-1.5 px-5",
? "flex w-full flex-nowrap"
: sottolineatura
? "flex w-max min-w-full flex-nowrap"
: // Pillole: colonne uguali alla voce più lunga; overflow → scroll sul wrapper.
"grid w-max min-w-full grid-flow-col auto-cols-[1fr]",
sottolineatura ? "gap-1 px-2 py-1.5" : "gap-1.5 px-5",
)}
>
{voci.map((v) => {
@@ -107,7 +116,11 @@ export function BarraSottosezioni({
}}
className={cn(
"min-h-11 touch-manipulation whitespace-nowrap text-sm font-bold uppercase tracking-wide transition-colors",
riempiLarghezza ? "min-w-0 flex-1" : "shrink-0",
riempiLarghezza
? "min-w-0 flex-1"
: sottolineatura
? "shrink-0"
: "w-full",
sottolineatura
? cn(
"rounded-xl px-1 py-2.5 text-center",
@@ -116,7 +129,8 @@ export function BarraSottosezioni({
: "text-muted-foreground",
)
: cn(
"snap-center rounded-full px-3.5 py-2",
"rounded-full py-2 text-center",
riempiLarghezza ? "px-2" : "px-3.5",
selezionata
? "bg-accent text-accent-foreground shadow-pop"
: "bg-secondary text-muted-foreground",
@@ -136,7 +150,9 @@ export function BarraSottosezioni({
key={voce.id}
role="tabpanel"
aria-label={voce.label}
drag="x"
// Sui device deboli lo swipe orizzontale (pointer listener + hit-testing
// ad ogni frame) resta disattivato: si cambia tab solo toccando la barra.
drag={ridotto ? false : "x"}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.12}
dragMomentum={false}
+17 -3
View File
@@ -1,7 +1,8 @@
import { Link } from "@tanstack/react-router";
import { CalendarDays, Home, Trophy, Users } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { motion } from "motion/react";
import { molla } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
// Quattro voci e non cinque: il profilo sta in alto a destra
// nell'intestazione di ogni pagina, dove lo cerca chi arriva da iOS.
@@ -13,7 +14,10 @@ const items = [
] as const;
export function BottomNav() {
const ridotto = useReducedMotion();
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: qui disattiva sia la molla della capsula sia il
// vetro sfocato, i due costi maggiori ad ogni cambio di rotta.
const ridotto = useMotoRidotto();
return (
<nav
@@ -62,11 +66,21 @@ export function BottomNav() {
*/}
{/* `vetro` porta con sé le proprie ombre, quindi niente `shadow-chrome`:
sarebbero due `box-shadow` sullo stesso elemento e una vincerebbe. */}
<div className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5">
<div
className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5"
// Su device deboli il backdrop-filter con feTurbulence/feDisplacementMap
// (solo Blink, cioè Android) e la molla della capsula sotto sono i due
// costi maggiori ad ogni cambio di rotta: `data-leggero` li disattiva.
{...(ridotto ? { "data-leggero": "" } : {})}
>
{items.map(({ to, label, icon: Icon }) => (
<Link
key={to}
to={to}
// Il chunk della pagina parte già al tocco (touchstart), non al tap completo:
// le 4 rotte sono piccole (8-12KB), nessun costo aggiuntivo di query (nessuna
// rotta usa `loader`, quindi non prefetcha dati, solo codice).
preload="intent"
activeOptions={{ exact: to === "/" }}
activeProps={{ "aria-current": "page" }}
// Lo stato attivo non è solo colore: è la capsula piena sotto la
+283
View File
@@ -0,0 +1,283 @@
import { useState } from "react";
import { ExternalLink, ShieldAlert, Users2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { useCsiPartita } from "@/lib/csi-partita";
import type { FormazioneSquadra, PrecedentiCsi } from "@/lib/csi-core";
/** Logo squadra dal portale CSI: nascosto invece che rotto se l'immagine non carica. */
export function LogoSquadra({
src,
alt,
className,
}: {
src: string;
alt: string;
className?: string;
}) {
const [errore, setErrore] = useState(false);
if (!src || errore) return null;
return (
<img
src={src}
alt={alt}
loading="lazy"
onError={() => setErrore(true)}
className={cn("shrink-0 rounded-lg object-contain", className)}
/>
);
}
/** Metadati leggeri sempre disponibili (nessuna fetch aggiuntiva): girone, n° gara, arbitro, referto. */
export function MetaPartitaCsi({
girone,
numeroGara,
arbitro,
link,
}: {
girone: string;
numeroGara: string;
arbitro: string;
link: string;
}) {
const voci = [
girone,
numeroGara ? `N° gara ${numeroGara}` : "",
arbitro ? `Arbitro: ${arbitro}` : "",
]
.filter(Boolean)
.join(" · ");
if (!voci && !link) return null;
return (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{voci ? <span>{voci}</span> : null}
{link ? (
<a
href={link}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 font-semibold text-accent"
>
Referto ufficiale CSI <ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
);
}
function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
return (
<div>
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
{squadra.squadra}
</p>
<div className="mt-2 space-y-1">
{squadra.titolari.map((g, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="w-6 shrink-0 text-center font-display text-xs text-accent">
{g.numero}
</span>
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
{g.ruolo ? <span className="text-xs text-muted-foreground">{g.ruolo}</span> : null}
</div>
))}
</div>
{squadra.panchina.length > 0 ? (
<>
<p className="mt-3 text-[11px] font-bold uppercase text-muted-foreground/70">
A disposizione
</p>
<div className="mt-1 space-y-1">
{squadra.panchina.map((g, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-6 shrink-0 text-center">{g.numero}</span>
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
</div>
))}
</div>
</>
) : null}
{squadra.staff.length > 0 ? (
<div className="mt-3 space-y-0.5 text-xs text-muted-foreground">
{squadra.staff.map((s, i) => (
<div key={i}>
{s.nome} · {s.ruolo}
</div>
))}
</div>
) : null}
</div>
);
}
function BarraPrecedenti({
label,
noi,
avversario,
}: {
label: string;
noi: number;
avversario: number;
}) {
const totale = noi + avversario || 1;
return (
<div className="text-xs">
<div className="flex items-center justify-between text-muted-foreground">
<span className="font-bold text-foreground">{noi}</span>
<span>{label}</span>
<span className="font-bold text-foreground">{avversario}</span>
</div>
<div className="mt-1 flex h-1.5 overflow-hidden rounded-full bg-secondary">
<div className="bg-accent" style={{ width: `${(noi / totale) * 100}%` }} />
</div>
</div>
);
}
const NOME_NOI = "CRAP Volley";
/** Intestazione con i nomi delle due squadre, per non dover ripeterli su ogni barra sotto. */
function TestataSquadre({ avversario }: { avversario: string }) {
return (
<div className="flex items-center justify-between text-xs font-bold">
<span className="truncate text-accent">{NOME_NOI}</span>
<span className="truncate text-right text-muted-foreground">{avversario}</span>
</div>
);
}
function BarraProbabilita({
avversario,
noi,
avversarioPct,
}: {
avversario: string;
noi: number;
avversarioPct: number;
}) {
const favoritaNoi = noi >= avversarioPct;
return (
<div>
<p className="mb-2 text-xs text-muted-foreground">
Probabilità di vittoria (calcolo CSI):{" "}
<span className="font-bold text-foreground">{favoritaNoi ? NOME_NOI : avversario}</span>{" "}
favorita al {Math.max(noi, avversarioPct).toFixed(0)}%.
</p>
<div className="mb-1 flex items-center justify-between text-[11px] font-bold">
<span className={favoritaNoi ? "text-success" : "text-muted-foreground"}>{NOME_NOI}</span>
<span className={!favoritaNoi ? "text-success" : "text-muted-foreground"}>
{avversario}
</span>
</div>
<div className="flex h-7 overflow-hidden rounded-full">
<div
className="flex items-center justify-center bg-success text-xs font-bold text-success-foreground"
style={{ width: `${noi}%` }}
>
{noi.toFixed(0)}%
</div>
<div
className="flex items-center justify-center bg-destructive text-xs font-bold text-destructive-foreground"
style={{ width: `${avversarioPct}%` }}
>
{avversarioPct.toFixed(0)}%
</div>
</div>
</div>
);
}
function BloccoPrecedenti({
precedenti,
avversario,
}: {
precedenti: PrecedentiCsi;
avversario: string;
}) {
return (
<div className="space-y-3">
<TestataSquadre avversario={avversario} />
<p className="text-xs text-muted-foreground">
{precedenti.totale > 0
? `${precedenti.totale} precedenti in archivio sul portale CSI.`
: "Nessun precedente in archivio sul portale CSI: primo confronto tra le due squadre."}
</p>
{precedenti.totale > 0 ? (
<>
<BarraPrecedenti
label="vittorie"
noi={precedenti.vinteNoi}
avversario={precedenti.vinteAvversario}
/>
<BarraPrecedenti
label="in casa"
noi={precedenti.casaNoi}
avversario={precedenti.casaAvversario}
/>
<BarraPrecedenti
label="fuori"
noi={precedenti.fuoriNoi}
avversario={precedenti.fuoriAvversario}
/>
</>
) : null}
{precedenti.probabilitaNoi !== null && precedenti.probabilitaAvversario !== null ? (
<BarraProbabilita
avversario={avversario}
noi={precedenti.probabilitaNoi}
avversarioPct={precedenti.probabilitaAvversario}
/>
) : null}
</div>
);
}
/**
* Formazioni e storico scontri diretti per una gara CSI: una fetch in più (cache 6h lato
* server), quindi va montato solo quando l'utente apre il dettaglio della gara, mai in una
* lista. `matchId` è l'id della gara sul portale CSI (`PartitaCsi.id`). `avversario` serve
* solo a etichettare le barre di "Scontri diretti" (nome squadra, non un dato dal CSI).
*/
export function DettaglioCsiEsteso({
matchId,
avversario,
}: {
matchId: string;
avversario: string;
}) {
const { data, isLoading } = useCsiPartita(matchId);
if (isLoading) {
return <p className="px-1 text-center text-xs text-muted-foreground">Carico i dati dal CSI</p>;
}
if (!data || (!data.formazioni && !data.precedenti)) return null;
return (
<div className="space-y-4">
{data.giornata || data.nota ? (
<p className="text-xs text-muted-foreground">
{[data.giornata, data.nota].filter(Boolean).join(" · ")}
</p>
) : null}
{data.formazioni ? (
<Card>
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
<Users2 className="h-4 w-4 text-accent" /> Formazioni
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ListaFormazione squadra={data.formazioni.noi} />
<ListaFormazione squadra={data.formazioni.avversario} />
</div>
</Card>
) : null}
{data.precedenti ? (
<Card>
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
</div>
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
</Card>
) : null}
</div>
);
}
+7 -8
View File
@@ -16,7 +16,7 @@ import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
import { dataOggi } from "@/lib/scout-live";
const tipoMeta = {
@@ -69,7 +69,10 @@ export function EventoCard({
}) {
const { risposte } = usePresenzeEvento(evento.id);
const salva = useSalvaPresenza();
const io = useGiocatoreCorrente();
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta): `useGiocatoreBase`
// legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`.
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
const io = useGiocatoreBase();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const stato = io ? risposte[io.id] : undefined;
@@ -85,8 +88,7 @@ export function EventoCard({
const passato = evento.data < dataOggi();
const cliccabile = Boolean(linkTo);
/** Solo gli eventi extra-campo non hanno scheda dedicata: le note restano sulla card. */
const noteCard =
evento.tipo === "evento" ? evento.note.trim() : "";
const noteCard = evento.tipo === "evento" ? evento.note.trim() : "";
const stati =
evento.tipo === "evento"
? // Se resta un vecchio "infortunato", mostra il bottone solo per poterlo togliere.
@@ -190,10 +192,7 @@ export function EventoCard({
{io ? (
<div
className={cn(
"pointer-events-auto flex w-full gap-1",
metaStato ? "mt-2.5" : "mt-3",
)}
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
role="group"
aria-label="La tua presenza"
>
+3 -2
View File
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar";
import type { Giocatore } from "@/lib/crapp-data";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
@@ -20,7 +20,8 @@ export function Pagelle({
convocati: Giocatore[];
chiuse?: boolean;
}) {
const io = useGiocatoreCorrente();
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const { voti: tutti, isPending } = usePagelle();
const vota = useVotaPagella();
const [apertoPer, setApertoPer] = useState<string | null>(null);
@@ -304,10 +304,6 @@ export function ProfiloAmministrativo({
</span>
</div>
<p className="text-xs text-muted-foreground">
Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro.
</p>
<CampiProfilo
corrente={corrente}
aggiorna={aggiorna}
+3 -2
View File
@@ -3,11 +3,12 @@ import { formatData } from "@/lib/crapp-data";
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
import { useTurniPalloni } from "@/lib/palloni";
import { useEventi } from "@/lib/eventi";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
/** Avvisi per chi è di turno: prendere i palloni oggi, o riportarli oggi. */
export function PromemoriaPalloni() {
const io = useGiocatoreCorrente();
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. Montato in Home.
const io = useGiocatoreBase();
const { turni } = useTurniPalloni();
const { eventi } = useEventi();
if (!io) return null;
+7 -5
View File
@@ -7,8 +7,8 @@ import { Avatar } from "@/components/crapp/Avatar";
import { Barra } from "@/components/motion/Barra";
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useRosa } from "@/lib/rosa";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import { dataOggi } from "@/lib/scout-live";
@@ -18,9 +18,11 @@ const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente
export function RosaPresenze({ eventoId, data }: { eventoId: string; data: string }) {
const { risposte, isPending } = usePresenzeEvento(eventoId);
const salva = useSalvaPresenza();
const io = useGiocatoreCorrente();
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
// niente statistiche di squadra.
const io = useGiocatoreBase();
const admin = useIsAdmin();
const rosa = useRosa();
const rosa = useAnagraficaRosa();
const [sollecito, setSollecito] = useState(false);
const passato = data < dataOggi();
@@ -169,7 +171,7 @@ function Gruppo({
}: {
titolo: string;
n: number;
lista: Giocatore[];
lista: Array<Pick<Giocatore, "id" | "nome" | "ruolo" | "numero">>;
attenzione?: boolean;
}) {
return (
+3 -2
View File
@@ -2,7 +2,7 @@ import { Link } from "@tanstack/react-router";
import { ChevronRight, Lock, Radio } from "lucide-react";
import { cn } from "@/lib/utils";
import { sessioneScaduta, usePartitaDiOggi, useSessioneScout } from "@/lib/scout-live";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
/**
* Accesso allo scout live: attivo solo il giorno della partita e se nessun altro lo sta usando.
@@ -17,7 +17,8 @@ export function ScoutEntry({
}) {
const { pronto, partita: diOggi } = usePartitaDiOggi();
const partita = eventoId && diOggi?.id !== eventoId ? null : diOggi;
const io = useGiocatoreCorrente();
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const { data: sessione } = useSessioneScout(partita?.id ?? null);
const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null;
+3 -2
View File
@@ -4,7 +4,7 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import {
@@ -27,7 +27,8 @@ export function SondaggioCacche({
dataEvento: string;
oraEvento: string;
}) {
const io = useGiocatoreCorrente();
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const { righe } = useCacche();
const salva = useSalvaCacche();
const { righe: squadra } = useGiocatoriSquadra();
+3 -2
View File
@@ -7,14 +7,15 @@ import { intestazioniAutenticate } from "@/lib/auth";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
import { useIsAdmin } from "@/lib/ruoli";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
export function TurnoPalloni({ eventoId }: { eventoId: string }) {
const [aperto, setAperto] = useState(false);
const [avviso, setAvviso] = useState(false);
const { salvati, turni, isPending } = useTurniPalloni();
const assegna = useAssegnaTurno();
const io = useGiocatoreCorrente();
// Solo `.nome` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const admin = useIsAdmin();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
+3 -2
View File
@@ -3,7 +3,7 @@ import { Crown, Vote } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
import { usePresenzeEvento } from "@/lib/presenze";
import type { Evento } from "@/lib/eventi";
import {
@@ -25,7 +25,8 @@ import {
*/
export function VotazioneMvp({ evento }: { evento: Evento }) {
const matchId = evento.id;
const io = useGiocatoreCorrente();
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const voti = useVotiMvp();
const vota = useVotaMvp();
const { righe: squadra } = useGiocatoriSquadra();
+3 -2
View File
@@ -3,7 +3,7 @@ import { Check, Crown, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
import {
categorieSocial,
conteggioCategoria,
@@ -15,7 +15,8 @@ import {
/** Voto social post-partita: un compagno per categoria, veloce da mobile. */
export function VotoSocial({ matchId }: { matchId: string }) {
const io = useGiocatoreCorrente();
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const voti = useVotiSocial();
const vota = useVotaSocial();
const { righe: squadra } = useGiocatoriSquadra();
+12 -5
View File
@@ -2,8 +2,9 @@ import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from "
import { Link } from "@tanstack/react-router";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { statoMeta, type Stato } from "@/lib/crapp-data";
import { useIo } from "@/lib/rosa";
import { inizialiDa, statoMeta, type Stato } from "@/lib/crapp-data";
import { nomeCompleto } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { Avatar } from "@/components/crapp/Avatar";
import { Reveal } from "@/components/motion/Reveal";
import { Numero } from "@/components/motion/Numero";
@@ -25,6 +26,7 @@ export function TeamLogo({
alt="CRAP Volley"
width={192}
height={192}
decoding="async"
className={cn("shrink-0 rounded-2xl object-cover shadow-pop", className)}
/>
);
@@ -49,10 +51,15 @@ export function Card({
/**
* Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è
* l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a
* `PageHeader` per rimetterci il logo sarebbe un link a stessa.
* `PageHeader` con il logo che porta alla home.
*
* Usa `useGiocatoreBase` (sola anagrafica) e non `useIo`: qui serve solo id e
* iniziali, mentre `useIo` calcola l'intera rosa con statistiche (MVP, pagelle,
* cacche, palloni, infortuni). Essendo in un componente montato su quasi ogni
* pagina, quei moduli finirebbero nel bundle condiviso di tutte le rotte.
*/
export function LinkProfilo() {
const g = useIo();
const g = useGiocatoreBase();
if (!g) return <TeamLogo className="h-12 w-12" />;
return (
<Link
@@ -60,7 +67,7 @@ export function LinkProfilo() {
aria-label="Il tuo profilo"
className="premi shrink-0 rounded-2xl ring-2 ring-primary-foreground/30"
>
<Avatar id={g.id} fallback={g.iniziali} className="h-12 w-12 text-lg" />
<Avatar id={g.id} fallback={inizialiDa(nomeCompleto(g))} className="h-12 w-12 text-lg" />
</Link>
);
}
-41
View File
@@ -1,41 +0,0 @@
// This file is auto-generated by Lovable. Do not modify it.
import { createLovableAuth } from "@lovable.dev/cloud-auth-js";
import { supabase } from "../supabase/client";
const lovableAuth = createLovableAuth();
type SignInOptions = {
redirect_uri?: string;
extraParams?: Record<string, string>;
};
export const lovable = {
auth: {
signInWithOAuth: async (
provider: "google" | "apple" | "microsoft" | "lovable",
opts?: SignInOptions,
) => {
const result = await lovableAuth.signInWithOAuth(provider, {
redirect_uri: opts?.redirect_uri ?? window.location.origin,
extraParams: {
...opts?.extraParams,
},
});
if (result.redirected) {
return result;
}
if (result.error) {
return result;
}
try {
await supabase.auth.setSession(result.tokens);
} catch (e) {
return { error: e instanceof Error ? e : new Error(String(e)) };
}
return result;
},
},
};
+11 -3
View File
@@ -58,6 +58,12 @@ export type BadgeDef = {
notificaPush?: string;
};
/**
* Voti minimi perché la media pagelle conti per il badge Pagellone: sotto soglia una singola
* pagella potrebbe sbloccarlo (o farlo sparire) senza significatività statistica.
*/
export const VOTI_MINIMI_PAGELLA = 5;
export const badgeDefs: BadgeDef[] = [
{
id: "mvp",
@@ -77,7 +83,7 @@ export const badgeDefs: BadgeDef[] = [
unita: "di media voto",
icon: ClipboardCheck,
soglie: { bronzo: 6.5, argento: 7.5, oro: 8.5 },
valore: (g) => g.mediaVoto,
valore: (g) => (g.votiPagella >= VOTI_MINIMI_PAGELLA ? g.mediaVoto : 0),
},
{
id: "palloni",
@@ -130,7 +136,9 @@ export const badgeSegreti: BadgeDef[] = [
icon: Ghost,
segreto: true,
soglie: { bronzo: 1, argento: 1, oro: 1 },
valore: (g) => (g.mvp >= 2 && g.mediaVoto >= 8 ? 1 : 0),
// Stessa soglia minima di voti del badge Pagellone: sotto VOTI_MINIMI_PAGELLA la media
// non è statisticamente significativa, non deve poter sbloccare nemmeno questo segreto.
valore: (g) => (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8 ? 1 : 0),
celebrazione: "Nei momenti caldi ci sei sempre.",
},
{
@@ -178,7 +186,7 @@ export const badgeSegreti: BadgeDef[] = [
id: "s-cacche",
nome: "Trono di ferro",
descrizione:
"Almeno 3 partite di campionato affrontate con 3 o più cacche pre-gara. Il bagno del PalaCRAP porta il tuo nome 🚽😂",
"Almeno 3 partite affrontate con 3 o più cacche pre-gara (campionato o amichevole, qui non si fanno sconti). Il bagno del PalaCRAP porta il tuo nome 🚽😂",
unita: "partite da record",
icon: Toilet,
segreto: true,
+11 -1
View File
@@ -28,6 +28,8 @@ export type Giocatore = {
nascita: string;
presenze: number;
totaliEventi: number;
/** Solo partite (non allenamenti) a cui era presente o in ritardo. */
partiteGiocate: number;
streak: number;
/** Serie consecutive per tipo: si azzerano in modo indipendente. */
serieAllenamenti: number;
@@ -37,8 +39,12 @@ export type Giocatore = {
mvp: number;
/** Media delle pagelle ricevute dai compagni (1-10). */
mediaVoto: number;
/** Quante pagelle ha ricevuto: sotto la soglia minima il badge Pagellone resta bloccato. */
votiPagella: number;
/** Quante volte ha portato i palloni. */
palloni: number;
/** Volte consecutive (fino a oggi) in cui ha portato i palloni. */
seriePalloni: number;
/** Partite di campionato con almeno 3 cacche dichiarate. */
cacche: number;
/** Media di cacche dichiarate per partita. */
@@ -73,7 +79,8 @@ const rosaCSI: Rosa[] = [
{ nome: "Giada Valbonesi", nascita: "1994-05-20", ruolo: "Opposto", numero: 10 },
];
function inizialiDa(nome: string) {
/** Iniziali da un nome completo (max 2 lettere), es. per il fallback di `Avatar`. */
export function inizialiDa(nome: string) {
return nome
.split(" ")
.map((p) => p[0] ?? "")
@@ -98,15 +105,18 @@ export const giocatori: Giocatore[] = rosaCSI
iniziali: inizialiDa(r.nome),
presenze: 0,
totaliEventi: 0,
partiteGiocate: 0,
streak: 0,
serieAllenamenti: 0,
seriePartite: 0,
serieConferme: 0,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
infortuni: 0,
ritardi: 0,
palloni: 0,
seriePalloni: 0,
cacche: 0,
cacchePartita: 0,
}))
+225
View File
@@ -10,6 +10,12 @@ import type { RigaClassifica } from "./crapp-data";
export const CSI_BASE = "https://livescore.csibologna.it";
/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */
export const CSI_PROJECT_ID = 767;
/**
* Coppa CSI Misto Silver 2025/26. Stesso formato di tabella del campionato (girone
* all'italiana), solo con meno squadre: `parseClassifica()` funziona invariata. Cambia a
* ogni stagione come CSI_PROJECT_ID, vedi docs/modules/collegamento-csi.md.
*/
export const CSI_COPPA_PROJECT_ID = 848;
/** C.R.A.P. Volley sul portale CSI. */
export const CSI_TEAM_ID = 3359;
export const CSI_GIRONE = "Girone B";
@@ -19,12 +25,23 @@ export const urlClassifica = (projectId = CSI_PROJECT_ID) =>
`${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`;
export const urlPartite = (teamId = CSI_TEAM_ID) =>
`${CSI_BASE}/assets/json/getEventsByTeamId.php?team_id=${teamId}`;
/** Info generali della gara (giornata, pubblico ammesso). */
export const urlPartitaInfo = (matchId: string) =>
`${CSI_BASE}/components/match-main.php?match_id=${matchId}`;
/** Formazioni titolari/panchina/staff di entrambe le squadre. */
export const urlPartitaFormazioni = (matchId: string) =>
`${CSI_BASE}/components/match-players.php?match_id=${matchId}`;
/** Storico scontri diretti e probabilità di vittoria calcolata dal CSI. */
export const urlPartitaPrecedenti = (matchId: string) =>
`${CSI_BASE}/components/match-stats.php?match_id=${matchId}`;
export type PartitaCsi = {
id: string;
data: string;
ora: string;
avversario: string;
/** URL del logo avversario sul portale CSI, vuoto se non presente. */
logoAvversario: string;
casa: boolean;
/** null finché la gara non è stata giocata. */
setNostri: number | null;
@@ -32,13 +49,60 @@ export type PartitaCsi = {
parziali: Array<[number, number]>;
campo: string;
competizione: string;
/** Es. "Girone B". */
girone: string;
/** Es. "5/XEB". */
numeroGara: string;
arbitro: string;
/** Referto ufficiale su livescore.csibologna.it. */
link: string;
};
export type GiocatoreFormazione = { numero: string; nome: string; ruolo: string };
export type StaffFormazione = { nome: string; ruolo: string };
export type FormazioneSquadra = {
squadra: string;
titolari: GiocatoreFormazione[];
panchina: GiocatoreFormazione[];
staff: StaffFormazione[];
};
export type PrecedentiCsi = {
totale: number;
vinteNoi: number;
vinteAvversario: number;
casaNoi: number;
casaAvversario: number;
fuoriNoi: number;
fuoriAvversario: number;
/** Percentuale 0-100, null se il CSI non la pubblica (es. sport senza storico). */
probabilitaNoi: number | null;
probabilitaAvversario: number | null;
};
export type DettaglioPartitaCsi = {
/** Es. "2ª Giornata", vuoto se non trovata (fase a eliminazione, coppa...). */
giornata: string;
/**
* Testo libero del CSI sotto l'impianto: a volte "Pubblico non ammesso", a volte il nome
* della palestra o altro non ha un formato fisso, va mostrato così com'è. Vuoto se assente.
*/
nota: string;
/** null se il referto non ha ancora le formazioni (partita non ancora giocata/schierata). */
formazioni: { noi: FormazioneSquadra; avversario: FormazioneSquadra } | null;
/** null solo se il formato non è riconosciuto; con 0 precedenti i campi restano a 0. */
precedenti: PrecedentiCsi | null;
};
export type DatiCsi = {
classifica: RigaClassifica[];
/** Classifica del girone di Coppa (project_id 848). Vuota se il parsing fallisce. */
classificaCoppa: RigaClassifica[];
partite: PartitaCsi[];
girone: string;
aggiornato: string;
/** true se il formato delle partite sembra cambiato (vedi `partiteFormatoSospetto`). */
formatoSospetto: boolean;
};
/** "C.R.A.P. Volley" e "CRAP Volley" devono confrontarsi uguali. */
@@ -107,11 +171,17 @@ type EventoCsi = {
id?: number | string;
start?: string;
team1?: string;
team1_logo?: string;
team2?: string;
team2_logo?: string;
result?: string;
partials?: string;
field?: string;
project?: string;
group?: string;
match_number?: string;
referees?: string;
link?: string;
};
function punteggio(result: string | undefined): [number, number] | null {
@@ -146,12 +216,17 @@ export function partiteDaEventi(eventi: unknown): PartitaCsi[] {
data: dataIso,
ora: (oraIso ?? "").slice(0, 5),
avversario: casa ? team2 : team1,
logoAvversario: (casa ? evento.team2_logo : evento.team1_logo)?.trim() ?? "",
casa,
setNostri: set ? (casa ? set[0] : set[1]) : null,
setLoro: set ? (casa ? set[1] : set[0]) : null,
parziali: casa ? tutti : tutti.map(([a, b]) => [b, a] as [number, number]),
campo: testo(evento.field ?? ""),
competizione: testo(evento.project ?? ""),
girone: testo(evento.group ?? ""),
numeroGara: testo(evento.match_number ?? ""),
arbitro: testo(evento.referees ?? ""),
link: evento.link?.trim() ?? "",
});
}
return partite.sort((a, b) => b.data.localeCompare(a.data));
@@ -162,15 +237,165 @@ export function partiteGiocate(partite: PartitaCsi[]): PartitaCsi[] {
return partite.filter((p) => p.setNostri !== null && p.setLoro !== null);
}
/**
* True se il formato di `getEventsByTeamId.php` sembra cambiato: `partiteDaEventi()` fallisce
* in modo silenzioso (nessun array o campi non riconosciuti), quindi un array vuoto da solo non
* distingue "il portale CSI ha cambiato formato" da "la squadra non ha ancora gare in
* programma". Qui invece si confronta con la risposta grezza: se contiene eventi ma nessuno è
* stato riconosciuto come nostra partita, è quasi certamente un problema di parsing, non una
* stagione senza gare. Usata da `/api/public/csi` per loggare il caso invece di lasciarlo
* silenzioso vedi "Limiti noti" in docs/modules/collegamento-csi.md.
*/
export function partiteFormatoSospetto(eventiGrezzi: unknown, partite: PartitaCsi[]): boolean {
if (!Array.isArray(eventiGrezzi)) return true;
return eventiGrezzi.length > 0 && partite.length === 0;
}
/** Converte una gara CSI già giocata nella forma comune usata nelle liste risultati. */
export function matchDaPartitaCsi(p: PartitaCsi) {
return {
id: p.id,
data: p.data,
avversario: p.avversario,
logoAvversario: p.logoAvversario,
casa: p.casa,
setNostri: p.setNostri ?? 0,
setLoro: p.setLoro ?? 0,
parziali: p.parziali,
campo: p.campo,
girone: p.girone,
numeroGara: p.numeroGara,
arbitro: p.arbitro,
link: p.link,
};
}
/** Estrae giornata e nota libera (pubblico ammesso, dettagli impianto...) da `match-main.php`. */
export function parseInfoPartita(html: string): { giornata: string; nota: string } {
const giornataM = /(\d+)\s*<sup>a<\/sup>\s*Giornata/.exec(html);
const notaM = /<div class="text-start"><span>([^<]*)<\/span><\/div>/.exec(html);
return {
giornata: giornataM ? `${giornataM[1]}ª Giornata` : "",
nota: notaM ? testo(notaM[1] ?? "") : "",
};
}
/**
* Una squadra dentro `match-players.php`: una `<ul class="list-group">` di `<li>`, in
* ordine titolari divisore "A DISPOSIZIONE" panchina divisore "STAFF" staff. I
* membri dello staff hanno la stessa struttura dei giocatori ma senza numero di maglia.
*/
function parseBloccoSquadraFormazione(blocco: string): FormazioneSquadra | null {
const nomeM = /team_details\.php\?team_id=\d*"[^>]*>([^<]+)<\/a>/.exec(blocco);
const squadra = nomeM ? testo(nomeM[1] ?? "") : "";
if (!squadra) return null;
const titolari: GiocatoreFormazione[] = [];
const panchina: GiocatoreFormazione[] = [];
const staff: StaffFormazione[] = [];
let sezione: "titolari" | "panchina" | "staff" = "titolari";
for (const voce of blocco.match(/<li class="list-group-item[\s\S]*?<\/li>/gi) ?? []) {
if (/A DISPOSIZIONE/.test(voce)) {
sezione = "panchina";
continue;
}
if (/STAFF/.test(voce)) {
sezione = "staff";
continue;
}
const nomePersonaM = /person_details\.php\?id=\d+">([^<]+)<\/a>/.exec(voce);
if (!nomePersonaM) continue;
const nome = testo(nomePersonaM[1] ?? "");
const ruoloM = /<div class="small">([^<]*)<\/div>/.exec(voce);
const ruolo = ruoloM ? testo(ruoloM[1] ?? "") : "";
if (sezione === "staff") {
staff.push({ nome, ruolo });
continue;
}
const numeroM = /shrink-8"[^>]*>(\d+)</.exec(voce);
const giocatore = { numero: numeroM?.[1] ?? "", nome, ruolo };
(sezione === "titolari" ? titolari : panchina).push(giocatore);
}
return { squadra, titolari, panchina, staff };
}
/**
* Formazioni di entrambe le squadre da `match-players.php`. Il markup non distingue
* esplicitamente casa/ospite, ma le racchiude in due blocchi separati dal commento
* `<!-- SQUADRA OSPITE -->`; qui si riconosce la nostra tramite `isNostraSquadra()`
* invece di assumere un ordine fisso.
*/
export function parseFormazioni(
html: string,
): { noi: FormazioneSquadra; avversario: FormazioneSquadra } | null {
const idx = html.indexOf("SQUADRA OSPITE");
if (idx === -1) return null;
const primo = parseBloccoSquadraFormazione(html.slice(0, idx));
const secondo = parseBloccoSquadraFormazione(html.slice(idx));
if (!primo || !secondo) return null;
const noi = isNostraSquadra(primo.squadra)
? primo
: isNostraSquadra(secondo.squadra)
? secondo
: null;
if (!noi) return null;
return { noi, avversario: noi === primo ? secondo : primo };
}
/**
* Storico scontri diretti e probabilità di vittoria da `match-stats.php`. Il blocco di
* riepilogo (non la lista partita-per-partita nel modal, meno affidabile da interpretare)
* riporta i numeri nell'ordine squadra-casa/squadra-ospite di *questa* gara: qui si
* riconosce quale delle due siamo noi tramite `isNostraSquadra()`, come in
* `parseFormazioni()`. Con "0 precedenti" il CSI omette del tutto le righe
* vittorie/in-casa/fuori (restano a 0) ma la probabilità resta comunque presente.
*/
export function parsePrecedenti(html: string): PrecedentiCsi | null {
const idxCasa = html.indexOf("Squadra casa");
const idxOspite = html.indexOf("Squadra ospite");
if (idxCasa === -1 || idxOspite === -1) return null;
const nomeCasaM = /team_details\.php\?team_id=\d+"[^>]*>([^<]+)<\/a>/.exec(
html.slice(idxCasa, idxOspite),
);
const nomeOspiteM = /team_details\.php\?team_id=\d+"[^>]*>([^<]+)<\/a>/.exec(
html.slice(idxOspite),
);
const nomeCasa = nomeCasaM ? testo(nomeCasaM[1] ?? "") : "";
const nomeOspite = nomeOspiteM ? testo(nomeOspiteM[1] ?? "") : "";
const noiECasa = isNostraSquadra(nomeCasa);
if (!noiECasa && !isNostraSquadra(nomeOspite)) return null;
const totaleM = /Precedenti:<\/span>\s*<b><a[^>]*>(\d+)<\/a><\/b>/.exec(html);
const vittorieM =
/<div><b>(\d+)<\/b><\/div>\s*<div[^>]*>vittorie<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(html);
const inCasaM =
/<div><b>(\d+)<\/b><\/div>\s*<div>in casa<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(html);
const fuoriM = /<div><b>(\d+)<\/b><\/div>\s*<div>fuori<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(
html,
);
// Le due barre "progress-bar" appaiono nell'ordine casa/ospite: il colore (verde/rosso)
// segue chi è favorito, non la posizione, quindi qui si usa l'ordine nel markup e non la
// classe CSS per capire quale percentuale è "nostra".
const percentuali = [...html.matchAll(/progress-bar[\s\S]*?width:\s*([\d.]+)%/g)].map((m) =>
Number(m[1]),
);
const [probCasa, probOspite] = percentuali;
// I due gruppi di ogni regex sono nell'ordine squadra-casa/squadra-ospite di questa gara:
// `casaIndice`/`ospiteIndice` scelgono quale dei due è "noi" in base a `noiECasa`.
const casaIndice = noiECasa ? 1 : 2;
const ospiteIndice = noiECasa ? 2 : 1;
const numero = (m: RegExpExecArray | null, indice: 1 | 2) => (m ? Number(m[indice]) : 0);
return {
totale: totaleM ? Number(totaleM[1]) : 0,
vinteNoi: numero(vittorieM, casaIndice),
vinteAvversario: numero(vittorieM, ospiteIndice),
casaNoi: numero(inCasaM, casaIndice),
casaAvversario: numero(inCasaM, ospiteIndice),
fuoriNoi: numero(fuoriM, casaIndice),
fuoriAvversario: numero(fuoriM, ospiteIndice),
probabilitaNoi: (noiECasa ? probCasa : probOspite) ?? null,
probabilitaAvversario: (noiECasa ? probOspite : probCasa) ?? null,
};
}
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import type { DettaglioPartitaCsi } from "./csi-core";
/**
* Dettaglio di una singola gara CSI (formazioni, storico scontri diretti): una fetch in
* più per partita, cache server 6h come `useCsi()`. Va usato solo quando l'utente apre il
* dettaglio di una gara, mai in una lista: vedi docs/modules/collegamento-csi.md.
*/
export function useCsiPartita(matchId: string | undefined) {
return useQuery<DettaglioPartitaCsi>({
queryKey: ["csi-partita", matchId],
queryFn: async () => {
const res = await fetch(`/api/public/csi-partita/${matchId}`);
if (!res.ok) throw new Error("csi-partita non disponibile");
return res.json();
},
enabled: !!matchId,
staleTime: 6 * 60 * 60_000,
retry: 1,
});
}
+12 -2
View File
@@ -162,8 +162,18 @@ export function useEliminaEvento() {
});
}
/** Compleanni della rosa, come eventi di calendario dell'anno indicato. */
export function compleanniEventi(rosa: Giocatore[], anno = new Date().getFullYear()): Evento[] {
/**
* Compleanni della rosa, come eventi di calendario dell'anno indicato.
*
* Prende solo i tre campi che usa (non l'intero `Giocatore`): il Calendario li
* legge da un'anagrafica leggera per non tirarsi dietro tutte le statistiche
* (MVP, pagelle, cacche, palloni, infortuni) di `useRosa` solo per le date di
* nascita.
*/
export function compleanniEventi(
rosa: Array<Pick<Giocatore, "id" | "nome" | "nascita">>,
anno = new Date().getFullYear(),
): Evento[] {
return rosa
.filter((g) => g.nascita)
.map((g) => {
-58
View File
@@ -1,58 +0,0 @@
type LovableErrorOptions = {
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
handled?: boolean;
severity?: "error" | "warning" | "info";
};
type LovableEvents = {
captureException?: (
error: unknown,
context?: Record<string, unknown>,
options?: LovableErrorOptions,
) => void;
};
declare global {
interface Window {
__lovableEvents?: LovableEvents;
__lovableReportRuntimeError?: (payload: {
message: string;
stack?: string;
filename?: string;
}) => void;
}
}
export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
if (typeof window === "undefined") return;
window.__lovableEvents?.captureException?.(
error,
{
source: "react_error_boundary",
route: window.location.pathname,
...context,
},
{
mechanism: "react_error_boundary",
handled: false,
severity: "error",
},
);
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
// editor's telemetry never sees them. Forward to lovable.js's reporting hook,
// which is present only inside the editor preview.
// Loaders and server fns commonly throw a raw Response; String(it) is the
// opaque "[object Response]", so pull out the status and URL instead.
const message =
error instanceof Response
? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
: error instanceof Error
? error.message
: String(error);
const stack = error instanceof Error ? error.stack : undefined;
window.__lovableReportRuntimeError?.({
message,
...(stack !== undefined && { stack }),
filename: window.location.pathname,
});
}
+21 -4
View File
@@ -60,6 +60,9 @@ export function useVotaMvp() {
export type ConteggioMvp = { id: string; nome: string; voti: number };
/** Voti minimi in una partita perché l'MVP possa essere assegnato (un solo voto non decide). */
export const VOTI_MINIMI_MVP = 2;
/** Conteggio voti di una partita, dal più votato. */
export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] {
const map = new Map<string, ConteggioMvp>();
@@ -83,8 +86,13 @@ export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> {
const out: Record<string, string> = {};
for (const [matchId] of perMatch) {
const top = conteggioPartita(voti, matchId);
// In caso di parità nessun MVP assegnato finché il voto non si sblocca.
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
// In caso di parità, o sotto il quorum minimo, nessun MVP assegnato.
if (
totaleVoti >= VOTI_MINIMI_MVP &&
top.length > 0 &&
(top.length === 1 || top[0]!.voti > top[1]!.voti)
) {
out[matchId] = top[0]!.nome;
}
}
@@ -95,13 +103,22 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) {
return voti.find((v) => v.match_id === matchId && v.votante_id === votanteId) ?? null;
}
/** MVP vinti per giocatore, contando una vittoria per partita votata. */
/**
* MVP vinti per giocatore, contando una vittoria per partita votata (una partita in pareggio
* al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti
* restituisce una mappa vuota.
*/
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> {
const out: Record<string, number> = {};
const matchIds = new Set(voti.map((v) => v.match_id));
for (const matchId of matchIds) {
const top = conteggioPartita(voti, matchId);
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
if (
totaleVoti >= VOTI_MINIMI_MVP &&
top.length > 0 &&
(top.length === 1 || top[0]!.voti > top[1]!.voti)
) {
const id = top[0]!.id;
out[id] = (out[id] ?? 0) + 1;
}
+22
View File
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { intestazioniAutenticate } from "./auth";
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
async function fetchNotificheAttive(): Promise<Set<string>> {
const res = await fetch("/api/public/notifiche-attive", {
headers: await intestazioniAutenticate(),
});
if (!res.ok) throw new Error("Impossibile leggere le notifiche attive");
const { giocatoreIds } = (await res.json()) as { giocatoreIds: string[] };
return new Set(giocatoreIds);
}
/** Insieme degli id giocatore con almeno un dispositivo iscritto alle notifiche push. */
export function useNotificheAttive() {
return useQuery({
queryKey: NOTIFICHE_ATTIVE_KEY,
queryFn: fetchNotificheAttive,
staleTime: 5 * 60_000,
});
}
+4
View File
@@ -0,0 +1,4 @@
/** Id giocatore distinti tra le righe di `push_subscriptions` (una riga per dispositivo). */
export function idsConNotificheAttive(righe: Array<{ giocatore_id: string }>): string[] {
return [...new Set(righe.map((r) => r.giocatore_id))];
}
+34 -11
View File
@@ -26,11 +26,32 @@ export type ContestoObiettivi = {
export const contestoVuoto: ContestoObiettivi = { eventi: [], presenze: {}, pagelle: [] };
const MESE = "2026-08";
/** Mese corrente in formato "YYYY-MM" (fuso Europe/Rome), per gli obiettivi che si azzerano ogni mese. */
function meseCorrente(oggi: Date): string {
return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(oggi).slice(0, 7);
}
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number) {
/** "a settembre" / "ad agosto": preposizione con elisione davanti a vocale. */
function aMese(oggi: Date): string {
const nome = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", month: "long" }).format(
oggi,
);
const preposizione = /^[aeiou]/i.test(nome) ? "ad" : "a";
return `${preposizione} ${nome}`;
}
/** Ultimo giorno del mese corrente, come "YYYY-MM-DD". */
function fineMese(oggi: Date): string {
const mese = meseCorrente(oggi);
const anno = Number(mese.slice(0, 4));
const numeroMese = Number(mese.slice(5, 7));
const ultimoGiorno = new Date(Date.UTC(anno, numeroMese, 0)).getUTCDate();
return `${mese}-${String(ultimoGiorno).padStart(2, "0")}`;
}
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese: string) {
const delMese = ctx.eventi.filter(
(e) => e.data.startsWith(MESE) && (e.tipo === "partita" || e.tipo === "allenamento"),
(e) => e.data.startsWith(mese) && (e.tipo === "partita" || e.tipo === "allenamento"),
);
if (delMese.length === 0 || rosaSize === 0) return 0;
const posti = delMese.length * rosaSize;
@@ -56,19 +77,21 @@ function percentualeRisposte(ctx: ContestoObiettivi, rosaSize: number) {
export function obiettiviSquadra(
rosa: Giocatore[] = giocatori,
ctx: ContestoObiettivi = contestoVuoto,
oggi: Date = new Date(),
): ObiettivoSquadra[] {
const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0);
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
const vittorie = ctx.vittorie ?? 0;
const mese = meseCorrente(oggi);
return [
{
id: "o1",
titolo: "90% di presenze ad agosto",
titolo: `90% di presenze ${aMese(oggi)}`,
descrizione: "Media presenze su partite e allenamenti del mese",
valore: percentualePresenzeMese(ctx, rosa.length),
valore: percentualePresenzeMese(ctx, rosa.length, mese),
target: 90,
unita: "%",
scadenza: "2026-08-31",
scadenza: fineMese(oggi),
emoji: "📣",
impatto: "Ogni sì in più alza la media di tutta la squadra.",
},
@@ -79,7 +102,7 @@ export function obiettiviSquadra(
valore: percentualeRisposte(ctx, rosa.length),
target: 90,
unita: "%",
scadenza: "2026-09-30",
scadenza: fineMese(oggi),
emoji: "⚡",
impatto: "Bastano pochi tap per far quadrare i conti a chi organizza.",
},
@@ -147,7 +170,7 @@ export function obiettiviSquadra(
id: "o5",
titolo: "10 vittorie in campionato",
descrizione: "Obiettivo stagionale per il podio",
valore: vittorie,
valore: Math.min(vittorie, 10),
target: 10,
unita: "vittorie",
emoji: "🏆",
@@ -157,7 +180,7 @@ export function obiettiviSquadra(
id: "o6",
titolo: "1 evento di squadra al mese",
descrizione: "Pizzate, cene e uscite fuori dal campo",
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(MESE)).length,
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(mese)).length,
target: 1,
unita: "eventi",
emoji: "🍕",
@@ -170,8 +193,8 @@ export function progressoObiettivo(o: ObiettivoSquadra) {
return Math.min(100, Math.round((o.valore / o.target) * 100));
}
export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi) {
return obiettiviSquadra(rosa, ctx).sort((a, b) => {
export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi, oggi?: Date) {
return obiettiviSquadra(rosa, ctx, oggi).sort((a, b) => {
const pa = progressoObiettivo(a);
const pb = progressoObiettivo(b);
const ca = pa >= 100 ? 1 : 0;
+15 -1
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { VOTI_MINIMI_PAGELLA } from "./badges";
/** Voto anonimo da 1 a 10 dato a un compagno per una partita. */
export type VotoPagella = {
@@ -60,7 +61,11 @@ function arrotonda(n: number) {
return Math.round(n * 10) / 10;
}
/** Media stagionale di ciascun giocatore: giocatoreId -> media e numero di voti. */
/**
* Media storica di ciascun giocatore su tutti i voti mai ricevuti (l'app non ha un concetto
* di stagione/reset): giocatoreId -> media e numero di voti. Chi non ha ancora ricevuto voti
* non compare nella mappa (nessuna divisione per zero): sta al chiamante gestire il fallback.
*/
export function mediePagelle(voti: VotoPagella[]): Record<string, MediaPagella> {
const somma: Record<string, { tot: number; n: number }> = {};
for (const v of voti) {
@@ -90,6 +95,15 @@ export function mieiVoti(voti: VotoPagella[], matchId: string, votanteId: string
return out;
}
/**
* Media voto da mostrare nella StatTile home (sezione «Colpo d'occhio», DD-028): sotto
* `VOTI_MINIMI_PAGELLA` voti ricevuti, `` invece della media grezza stessa soglia del
* badge Pagellone, non applicata invece dalle StatTile di profilo e squadra.
*/
export function mediaVotoColpoDOcchio(g: { mediaVoto: number; votiPagella: number }): number | "—" {
return g.votiPagella >= VOTI_MINIMI_PAGELLA ? g.mediaVoto : "—";
}
/** Media pagelle di tutta la squadra su tutte le partite. */
export function mediaSquadra(voti: VotoPagella[]) {
if (voti.length === 0) return 0;
+18
View File
@@ -1,6 +1,7 @@
import { formatData } from "./crapp-data";
import type { Evento } from "./eventi";
import { dataOggi } from "./scout-live";
import { aggiornaSerie } from "./serie";
export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null };
@@ -81,6 +82,23 @@ export function conteggioTurni(
return out;
}
/**
* Volte consecutive in cui il giocatore ha portato i palloni, contando solo eventi già
* passati (stesso criterio `e.data < oggi` di `conteggioTurni`): un evento passato senza
* turno confermato non spezza la serie di nessuno, perché non dice ancora chi ha portato
* i palloni quella volta.
*/
export function serieConsecutivaPalloni(
giocatoreId: string,
turni: Record<string, string>,
eventi: Evento[],
oggi: string = dataOggi(),
): number {
return eventiPalloni(eventi)
.filter((e) => e.data < oggi && turni[e.id])
.reduce((serie, e) => aggiornaSerie(serie, turni[e.id] === giocatoreId), 0);
}
export function eventiDelGiorno(eventi: Evento[], isoData: string): Evento[] {
return eventiPalloni(eventi).filter((e) => e.data === isoData);
}
+28 -3
View File
@@ -14,28 +14,53 @@ export type MappaPresenze = Record<string, Record<string, Stato>>;
export type MappaTempiRisposta = Record<string, Record<string, string>>;
/** Allenamenti e partite CrAPP già passati, che contano per le statistiche di presenza. */
function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string, oggi = dataOggi()) {
function eventiContanoPresenze(
eventi: Evento[],
giocatoreId?: string,
oggi = dataOggi(),
tipo?: "partita" | "allenamento",
) {
return eventi.filter(
(e) =>
(e.tipo === "partita" || e.tipo === "allenamento") &&
(tipo === undefined || e.tipo === tipo) &&
e.data < oggi &&
(giocatoreId === undefined || e.convocati.length === 0 || e.convocati.includes(giocatoreId)),
);
}
/** Presenze effettive (presente o in ritardo) su eventi CrAPP. */
/**
* Presenze effettive (presente o in ritardo) su eventi CrAPP. Senza eventi rilevanti
* restituisce 0. `tipo` filtra a un solo tipo di evento (es. solo partite); di default
* conta partite e allenamenti insieme, come il resto delle statistiche di presenza.
*/
export function contaPresenzeGiocatore(
giocatoreId: string,
eventi: Evento[],
presenze: MappaPresenze,
oggi: string = dataOggi(),
tipo?: "partita" | "allenamento",
): number {
return eventiContanoPresenze(eventi, giocatoreId, oggi).filter((e) => {
return eventiContanoPresenze(eventi, giocatoreId, oggi, tipo).filter((e) => {
const stato = presenze[e.id]?.[giocatoreId];
return stato === "presente" || stato === "ritardo";
}).length;
}
/**
* Partite (non allenamenti) a cui il giocatore era presente o in ritardo: il dato giusto
* per contesti legati alle prestazioni in campo (es. MVP), a differenza di
* `totaliEventiGiocatore()` che è il denominatore delle presenze e include gli allenamenti.
*/
export function contaPartiteGiocate(
giocatoreId: string,
eventi: Evento[],
presenze: MappaPresenze,
oggi: string = dataOggi(),
): number {
return contaPresenzeGiocatore(giocatoreId, eventi, presenze, oggi, "partita");
}
/** Eventi CrAPP rilevanti per il denominatore presenze di un giocatore. */
export function totaliEventiGiocatore(
giocatoreId: string,
+96 -4
View File
@@ -4,12 +4,13 @@ import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
import { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti";
import { mediePagelle, usePagelle } from "./pagelle";
import { statisticheCacche, useCacche } from "./cacche";
import { conteggioTurni } from "./palloni-core";
import { conteggioTurni, serieConsecutivaPalloni } from "./palloni-core";
import { useTurniPalloni } from "./palloni";
import { useInfortuniERitardi } from "./infortuni";
import { useGiocatoreId } from "./user-store";
import { useEventi } from "./eventi";
import {
contaPartiteGiocate,
contaPresenzeGiocatore,
serieConferme,
serieConsecutiva,
@@ -24,6 +25,31 @@ function iniziali(nome: string, cognome: string): string {
return `${nome[0] ?? ""}${cognome[0] ?? ""}`.toUpperCase();
}
/**
* Solo anagrafica (id, nome, ruolo, numero, data di nascita) dei giocatori attivi es.
* per i compleanni nel Calendario o le liste presenze. A differenza di `useRosa` non
* legge MVP, pagelle, cacche, palloni infortuni: evita di montare quei cinque hook e
* il relativo `useMemo` solo per l'anagrafica.
*/
export function useAnagraficaRosa(): Array<
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita">
> {
const { righe: squadra } = useGiocatoriSquadra();
return useMemo(
() =>
squadra
.filter((g) => g.attivo)
.map((g) => ({
id: g.id,
nome: nomeCompleto(g),
ruolo: g.ruolo,
numero: g.numero,
nascita: nascitaPerId[g.id] ?? "",
})),
[squadra],
);
}
/**
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
* palloni, infortuni, ritardi, cacche). Legge l'anagrafica da `giocatori_squadra`
@@ -36,7 +62,7 @@ export function useRosa(): Giocatore[] {
const voti = useVotiMvp();
const { voti: pagelle } = usePagelle();
const { righe: cacche } = useCacche();
const { turni } = useTurniPalloni();
const { salvati: turniSalvati } = useTurniPalloni();
const { infortuni, ritardi } = useInfortuniERitardi();
const { eventi } = useEventi();
const { presenze: mappaPresenze, tempi } = useRispostePresenze();
@@ -46,7 +72,10 @@ export function useRosa(): Giocatore[] {
return useMemo(() => {
const medie = mediePagelle(pagelle);
const statCacche = statisticheCacche(cacche);
const palloni = conteggioTurni(turni, eventi);
// Solo i turni confermati, non le proposte automatiche di completaTurni(): il badge deve
// premiare chi ha davvero portato i palloni, non chi l'algoritmo di rotazione ha
// scelto per un evento passato senza che nessuno confermasse nulla.
const palloni = conteggioTurni(turniSalvati, eventi);
const mvpVinti = mvpVintiPerGiocatore(votiMvp);
return squadra
@@ -60,19 +89,82 @@ export function useRosa(): Giocatore[] {
iniziali: iniziali(g.nome, g.cognome),
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
totaliEventi: totaliEventiGiocatore(g.id, eventi),
partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze),
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
serieConferme: serieConferme(g.id, eventi, tempi),
mvp: mvpVinti[g.id] ?? 0,
mediaVoto: medie[g.id]?.media ?? 0,
votiPagella: medie[g.id]?.voti ?? 0,
palloni: palloni[g.id] ?? 0,
seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi),
cacche: statCacche[g.id]?.giornateTop ?? 0,
cacchePartita: statCacche[g.id]?.media ?? 0,
infortuni: infortuni[g.id] ?? 0,
ritardi: ritardi[g.id] ?? 0,
}));
}, [squadra, votiMvp, pagelle, cacche, turni, infortuni, ritardi, eventi, mappaPresenze, tempi]);
}, [
squadra,
votiMvp,
pagelle,
cacche,
turniSalvati,
infortuni,
ritardi,
eventi,
mappaPresenze,
tempi,
]);
}
/** Criteri di ordinamento della classifica interna di Squadra. */
export type CriterioClassifica = "presenze" | "mediaVoto" | "mvp" | "palloni" | "cacchePartita";
/**
* Dettaglio mostrato sotto il nome nella classifica interna, coerente col criterio
* selezionato: mostrare sempre le "presenze consecutive" aveva senso solo per Presenze,
* per gli altri criteri era un dato fuorviante perché scollegato dal valore in classifica.
*/
export function dettaglioClassifica(
g: {
streak: number;
votiPagella: number;
partiteGiocate: number;
cacche: number;
seriePalloni: number;
},
criterio: CriterioClassifica,
): string {
switch (criterio) {
case "mediaVoto":
return `${g.votiPagella} voti pagella`;
case "mvp":
return `${g.partiteGiocate} partite giocate`;
case "cacchePartita":
return `${g.cacche} giornate top`;
case "palloni":
return `${g.seriePalloni} volte consecutive`;
default:
return `${g.streak} presenze consecutive`;
}
}
/**
* Posizione in classifica ("dense rank"): a parità di valore i giocatori condividono la
* stessa posizione e il numero successivo non salta (1, 1, 2 non 1, 1, 3). `valori` deve
* essere già ordinato in modo decrescente, coerente con l'ordine visualizzato.
*/
export function classificaRank(valori: number[]): number[] {
const rank: number[] = [];
for (let i = 0; i < valori.length; i++) {
if (i > 0 && valori[i] === valori[i - 1]) {
rank.push(rank[i - 1] ?? 1);
} else {
rank.push((rank[i - 1] ?? 0) + 1);
}
}
return rank;
}
/** Il giocatore selezionato sul dispositivo, con le statistiche complete. */
+64
View File
@@ -19,13 +19,16 @@ import { Route as ProfiloRouteImport } from './routes/profilo'
import { Route as ScoutRouteImport } from './routes/scout'
import { Route as SquadraRouteImport } from './routes/squadra'
import { Route as AllenamentoIdRouteImport } from './routes/allenamento.$id'
import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
import { Route as ApiPublicSollecitaPresenzeRouteImport } from './routes/api/public/sollecita-presenze'
import { Route as ApiPublicCsiPartitaIdRouteImport } from './routes/api/public/csi-partita.$id'
const IndexRoute = IndexRouteImport.update({
id: '/',
@@ -77,6 +80,11 @@ const AllenamentoIdRoute = AllenamentoIdRouteImport.update({
path: '/allenamento/$id',
getParentRoute: () => rootRouteImport,
} as any)
const PartitaCsiIdRoute = PartitaCsiIdRouteImport.update({
id: '/partita-csi/$id',
path: '/partita-csi/$id',
getParentRoute: () => rootRouteImport,
} as any)
const PartitaIdRoute = PartitaIdRouteImport.update({
id: '/partita/$id',
path: '/partita/$id',
@@ -92,6 +100,12 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
path: '/api/public/csi',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicNotificheAttiveRoute =
ApiPublicNotificheAttiveRouteImport.update({
id: '/api/public/notifiche-attive',
path: '/api/public/notifiche-attive',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicPromemoriaPalloniRoute =
ApiPublicPromemoriaPalloniRouteImport.update({
id: '/api/public/promemoria-palloni',
@@ -114,6 +128,11 @@ const ApiPublicSollecitaPresenzeRoute =
path: '/api/public/sollecita-presenze',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicCsiPartitaIdRoute = ApiPublicCsiPartitaIdRouteImport.update({
id: '/api/public/csi-partita/$id',
path: '/api/public/csi-partita/$id',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -126,13 +145,16 @@ export interface FileRoutesByFullPath {
'/scout': typeof ScoutRoute
'/squadra': typeof SquadraRoute
'/allenamento/$id': typeof AllenamentoIdRoute
'/partita-csi/$id': typeof PartitaCsiIdRoute
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
'/api/public/csi-partita/$id': typeof ApiPublicCsiPartitaIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -145,13 +167,16 @@ export interface FileRoutesByTo {
'/scout': typeof ScoutRoute
'/squadra': typeof SquadraRoute
'/allenamento/$id': typeof AllenamentoIdRoute
'/partita-csi/$id': typeof PartitaCsiIdRoute
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
'/api/public/csi-partita/$id': typeof ApiPublicCsiPartitaIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -165,13 +190,16 @@ export interface FileRoutesById {
'/scout': typeof ScoutRoute
'/squadra': typeof SquadraRoute
'/allenamento/$id': typeof AllenamentoIdRoute
'/partita-csi/$id': typeof PartitaCsiIdRoute
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
'/api/public/csi-partita/$id': typeof ApiPublicCsiPartitaIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -186,13 +214,16 @@ export interface FileRouteTypes {
| '/scout'
| '/squadra'
| '/allenamento/$id'
| '/partita-csi/$id'
| '/partita/$id'
| '/api/public/apri-sondaggio'
| '/api/public/csi'
| '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
| '/api/public/push-subscribe'
| '/api/public/sollecita-presenze'
| '/api/public/csi-partita/$id'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -205,13 +236,16 @@ export interface FileRouteTypes {
| '/scout'
| '/squadra'
| '/allenamento/$id'
| '/partita-csi/$id'
| '/partita/$id'
| '/api/public/apri-sondaggio'
| '/api/public/csi'
| '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
| '/api/public/push-subscribe'
| '/api/public/sollecita-presenze'
| '/api/public/csi-partita/$id'
id:
| '__root__'
| '/'
@@ -224,13 +258,16 @@ export interface FileRouteTypes {
| '/scout'
| '/squadra'
| '/allenamento/$id'
| '/partita-csi/$id'
| '/partita/$id'
| '/api/public/apri-sondaggio'
| '/api/public/csi'
| '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
| '/api/public/push-subscribe'
| '/api/public/sollecita-presenze'
| '/api/public/csi-partita/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -244,13 +281,16 @@ export interface RootRouteChildren {
ScoutRoute: typeof ScoutRoute
SquadraRoute: typeof SquadraRoute
AllenamentoIdRoute: typeof AllenamentoIdRoute
PartitaCsiIdRoute: typeof PartitaCsiIdRoute
PartitaIdRoute: typeof PartitaIdRoute
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
ApiPublicSollecitaPresenzeRoute: typeof ApiPublicSollecitaPresenzeRoute
ApiPublicCsiPartitaIdRoute: typeof ApiPublicCsiPartitaIdRoute
}
declare module '@tanstack/react-router' {
@@ -325,6 +365,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AllenamentoIdRouteImport
parentRoute: typeof rootRouteImport
}
'/partita-csi/$id': {
id: '/partita-csi/$id'
path: '/partita-csi/$id'
fullPath: '/partita-csi/$id'
preLoaderRoute: typeof PartitaCsiIdRouteImport
parentRoute: typeof rootRouteImport
}
'/partita/$id': {
id: '/partita/$id'
path: '/partita/$id'
@@ -346,6 +393,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiPublicCsiRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/notifiche-attive': {
id: '/api/public/notifiche-attive'
path: '/api/public/notifiche-attive'
fullPath: '/api/public/notifiche-attive'
preLoaderRoute: typeof ApiPublicNotificheAttiveRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/promemoria-palloni': {
id: '/api/public/promemoria-palloni'
path: '/api/public/promemoria-palloni'
@@ -374,6 +428,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiPublicSollecitaPresenzeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/csi-partita/$id': {
id: '/api/public/csi-partita/$id'
path: '/api/public/csi-partita/$id'
fullPath: '/api/public/csi-partita/$id'
preLoaderRoute: typeof ApiPublicCsiPartitaIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
@@ -388,13 +449,16 @@ const rootRouteChildren: RootRouteChildren = {
ScoutRoute: ScoutRoute,
SquadraRoute: SquadraRoute,
AllenamentoIdRoute: AllenamentoIdRoute,
PartitaCsiIdRoute: PartitaCsiIdRoute,
PartitaIdRoute: PartitaIdRoute,
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
ApiPublicCsiRoute: ApiPublicCsiRoute,
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
ApiPublicSollecitaPresenzeRoute: ApiPublicSollecitaPresenzeRoute,
ApiPublicCsiPartitaIdRoute: ApiPublicCsiPartitaIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
-4
View File
@@ -12,7 +12,6 @@ import {
import { useEffect, useState, type ReactNode } from "react";
import appCss from "../styles.css?url";
import { reportLovableError } from "../lib/lovable-error-reporting";
import { BottomNav } from "../components/crapp/BottomNav";
import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge";
import { Toaster } from "../components/ui/sonner";
@@ -47,9 +46,6 @@ function NotFoundComponent() {
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
console.error(error);
const router = useRouter();
useEffect(() => {
reportLovableError(error, { boundary: "tanstack_root_error_component" });
}, [error]);
return (
<div className="flex min-h-dvh items-center justify-center bg-background px-4">
+100 -62
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import {
BadgeCheck,
Bell,
ChevronDown,
Download,
FileText,
@@ -16,14 +17,8 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
Campo,
classiInput,
PageHeader,
Section,
Select,
StatTile,
} from "@/components/crapp/ui-bits";
import { Campo, classiInput, PageHeader, Select, StatTile } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
import {
nomeCompleto,
@@ -54,6 +49,7 @@ import {
import { oggiISO } from "@/lib/palloni-core";
import { scaricaCsv } from "@/lib/scout-export";
import { useIsAdmin } from "@/lib/ruoli";
import { useNotificheAttive } from "@/lib/notifiche-admin";
import { Reveal } from "@/components/motion/Reveal";
export const Route = createFileRoute("/admin")({
@@ -232,7 +228,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
</Select>
</Campo>
</div>
<Campo label="Email (collegamento automatico al login, DD-018)">
<Campo label="Email">
<input
type="email"
value={squadraCorrente.email ?? ""}
@@ -384,13 +380,16 @@ function SchedaGiocatore({
profilo,
oggi,
indice,
aperta,
onToggle,
}: {
g: GiocatoreSquadra;
profilo: Profilo | undefined;
oggi: string;
indice: number;
aperta: boolean;
onToggle: () => void;
}) {
const [aperta, setAperta] = useState(false);
const nome = nomeCompleto(g);
const { ruolo, numero } = g;
const perc = completamento(profilo);
@@ -401,11 +400,7 @@ function SchedaGiocatore({
return (
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
<button
type="button"
onClick={() => setAperta((v) => !v)}
className="flex w-full items-center gap-3 text-left"
>
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 text-left">
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm tabular-nums">
{numero}
</div>
@@ -552,7 +547,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
</Select>
</Campo>
</div>
<Campo label="Email (collegamento automatico al login, opzionale)">
<Campo label="Email">
<input
type="email"
value={dati.email ?? ""}
@@ -623,6 +618,8 @@ function Dashboard() {
const admin = useIsAdmin();
const { righe: squadra } = useGiocatoriSquadra();
const { profili, isPending } = useProfili();
const { data: notificheAttive } = useNotificheAttive();
const [schedaAperta, setSchedaAperta] = useState<string | null>(null);
const oggi = oggiISO();
if (!admin) {
@@ -649,56 +646,97 @@ function Dashboard() {
).length;
const tesserati = attivi.filter((g) => g.numeroTessera).length;
const contenutoSquadra = (
<>
<div className="grid grid-cols-2 gap-2">
<StatTile valore={attivi.length} label="Giocatori" />
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
<StatTile
valore={`${certificatiOk}/${attivi.length}`}
label="Certificati validi"
hint="non scaduti"
/>
<StatTile valore={`${tesserati}/${attivi.length}`} label="Tesserati" hint="CSI" />
</div>
<button
type="button"
onClick={() => scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<Download className="h-4 w-4" /> Esporta CSV tesseramento
</button>
<AggiungiGiocatore righe={squadra} />
</>
);
const contenutoProfili = isPending ? (
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
Caricamento
</p>
) : (
<div className="space-y-3">
{attivi.map((g, i) => (
<SchedaGiocatore
key={g.id}
g={g}
profilo={profili[g.id]}
oggi={oggi}
indice={i}
aperta={schedaAperta === g.id}
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))}
/>
))}
</div>
);
const contenutoDisattivati = (
<div className="space-y-2">
{disattivi.map((g) => (
<GiocatoreDisattivato key={g.id} g={g} />
))}
</div>
);
const contenutoNotifiche = notificheAttive ? (
<>
<StatTile
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
label="Notifiche attive"
/>
<div className="mt-3 space-y-2">
{attivi
.filter((g) => notificheAttive.has(g.id))
.map((g) => (
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card">
<Bell className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<p className="truncate text-sm font-semibold leading-tight">{nomeCompleto(g)}</p>
</div>
))}
</div>
</>
) : (
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
Caricamento
</p>
);
return (
<>
<PageHeader titolo="Dashboard" sottotitolo="Profili e tesseramento" />
<Section titolo="Squadra">
<div className="grid grid-cols-2 gap-2">
<StatTile valore={attivi.length} label="Giocatori" />
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
<StatTile
valore={`${certificatiOk}/${attivi.length}`}
label="Certificati validi"
hint="non scaduti"
/>
<StatTile valore={`${tesserati}/${attivi.length}`} label="Tesserati" hint="CSI" />
</div>
<button
type="button"
onClick={() =>
scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))
}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<Download className="h-4 w-4" /> Esporta CSV tesseramento
</button>
<AggiungiGiocatore righe={squadra} />
</Section>
<Section titolo="Profili" indice={1}>
{isPending ? (
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
Caricamento
</p>
) : (
<div className="space-y-3">
{attivi.map((g, i) => (
<SchedaGiocatore key={g.id} g={g} profilo={profili[g.id]} oggi={oggi} indice={i} />
))}
</div>
)}
</Section>
{disattivi.length > 0 ? (
<Section titolo="Giocatori disattivati" indice={2}>
<div className="space-y-2">
{disattivi.map((g) => (
<GiocatoreDisattivato key={g.id} g={g} />
))}
</div>
</Section>
) : null}
<BarraSottosezioni
defaultId="squadra"
variante="sottolineatura"
riempiLarghezza
voci={[
{ id: "squadra", label: "Squadra", contenuto: contenutoSquadra },
{ id: "profili", label: "Profili", contenuto: contenutoProfili },
...(disattivi.length > 0
? [{ id: "disattivati", label: "Disattivati", contenuto: contenutoDisattivati }]
: []),
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
]}
/>
</>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { createFileRoute } from "@tanstack/react-router";
import {
parseFormazioni,
parseInfoPartita,
parsePrecedenti,
urlPartitaFormazioni,
urlPartitaInfo,
urlPartitaPrecedenti,
type DettaglioPartitaCsi,
} from "@/lib/csi-core";
const SCADENZA_MS = 6 * 60 * 60 * 1000;
// Una gara giocata non cambia più: la cache per-partita non ha bisogno di scadere mai
// per i dati storici, ma teniamo la stessa finestra di /api/public/csi per semplicità e
// per non tenere in memoria per sempre partite che nessuno riguarda più. Stessa cache in
// memoria del processo, stesso limite (si perde ai cold start): vedi limite 3 in
// docs/modules/collegamento-csi.md.
const cache = new Map<string, { dati: DettaglioPartitaCsi; scadenza: number }>();
async function scarica(url: string): Promise<string> {
const res = await fetch(url, {
headers: { "User-Agent": "CrAPP/1.0 (+https://crapvolley.it)" },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`CSI ${res.status} su ${url}`);
return res.text();
}
async function leggiDettaglioPartita(matchId: string): Promise<DettaglioPartitaCsi> {
const [main, players, stats] = await Promise.all([
scarica(urlPartitaInfo(matchId)),
scarica(urlPartitaFormazioni(matchId)),
scarica(urlPartitaPrecedenti(matchId)),
]);
return {
...parseInfoPartita(main),
formazioni: parseFormazioni(players),
precedenti: parsePrecedenti(stats),
};
}
export const Route = createFileRoute("/api/public/csi-partita/$id")({
server: {
handlers: {
GET: async ({ params }) => {
const matchId = params.id;
const voce = cache.get(matchId);
if (voce && Date.now() < voce.scadenza) return Response.json(voce.dati);
try {
const dati = await leggiDettaglioPartita(matchId);
cache.set(matchId, { dati, scadenza: Date.now() + SCADENZA_MS });
return Response.json(dati);
} catch (error) {
console.error("csi-partita", matchId, error);
// Meglio un dato vecchio che nessun dato, come /api/public/csi.
if (voce) return Response.json(voce.dati);
return new Response("CSI non raggiungibile", { status: 503 });
}
},
},
},
});
+31 -3
View File
@@ -1,8 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import {
CSI_COPPA_PROJECT_ID,
CSI_GIRONE,
parseClassifica,
partiteDaEventi,
partiteFormatoSospetto,
urlClassifica,
urlPartite,
type DatiCsi,
@@ -27,13 +29,39 @@ async function scarica(url: string): Promise<string> {
}
async function leggiCsi(): Promise<DatiCsi> {
const [html, json] = await Promise.all([scarica(urlClassifica()), scarica(urlPartite())]);
const [html, htmlCoppa, json] = await Promise.all([
scarica(urlClassifica()),
// Non blocca la risposta se fallisce: la Coppa è un dato supplementare, non critico
// come classifica/partite del girone (vedi il controllo sotto).
scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""),
scarica(urlPartite()),
]);
const classifica = parseClassifica(html);
const partite = partiteDaEventi(JSON.parse(json));
const classificaCoppa = parseClassifica(htmlCoppa);
const eventiGrezzi = JSON.parse(json);
const partite = partiteDaEventi(eventiGrezzi);
if (classifica.length === 0 && partite.length === 0) {
throw new Error("CSI: risposta senza classifica né partite");
}
return { classifica, partite, girone: CSI_GIRONE, aggiornato: new Date().toISOString() };
// La classifica basta a evitare l'errore sopra, ma se solo le partite si rompono (formato di
// getEventsByTeamId.php cambiato) la route tornerebbe comunque 200 senza che nessuno se ne
// accorga: le vittorie degli obiettivi di squadra resterebbero ferme a 0% in silenzio. Oltre
// al log server, il flag arriva fino a `/classifica` (badge discreto) perché qualcuno se ne
// accorga anche senza guardare i log.
const formatoSospetto = partiteFormatoSospetto(eventiGrezzi, partite);
if (formatoSospetto) {
console.error(
"csi: il formato di getEventsByTeamId.php sembra cambiato, nessuna partita riconosciuta",
);
}
return {
classifica,
classificaCoppa,
partite,
girone: CSI_GIRONE,
aggiornato: new Date().toISOString(),
formatoSospetto,
};
}
export const Route = createFileRoute("/api/public/csi")({
+25
View File
@@ -0,0 +1,25 @@
import { createFileRoute } from "@tanstack/react-router";
import { richiediAdmin } from "@/lib/auth-route.server";
import { idsConNotificheAttive } from "@/lib/notifiche-attive.server";
export const Route = createFileRoute("/api/public/notifiche-attive")({
server: {
handlers: {
GET: async ({ request }) => {
const negato = await richiediAdmin(request);
if (negato) return negato;
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data, error } = await supabaseAdmin
.from("push_subscriptions")
.select("giocatore_id");
if (error) {
console.error("notifiche-attive", error);
return new Response("Errore lettura", { status: 500 });
}
return Response.json({ giocatoreIds: idsConNotificheAttive(data ?? []) });
},
},
},
});
+3 -2
View File
@@ -10,7 +10,7 @@ import {
useCollegaGiocatore,
useGiocatoriSquadra,
} from "@/lib/giocatori-squadra";
import { impostaGiocatore, resetGiocatore, useGiocatoreCorrente } from "@/lib/user-store";
import { impostaGiocatore, resetGiocatore, useGiocatoreBase } from "@/lib/user-store";
export const Route = createFileRoute("/benvenuto")({
validateSearch: (search: Record<string, unknown>): { next?: string } => {
@@ -38,7 +38,8 @@ export const Route = createFileRoute("/benvenuto")({
function Benvenuto() {
const navigate = useNavigate();
const { next } = Route.useSearch();
const giocatore = useGiocatoreCorrente();
// Solo verità (`!!giocatore`): `useGiocatoreBase` (anagrafica) basta, niente statistiche.
const giocatore = useGiocatoreBase();
const { pronta, utenteId, emailUtente } = useSessione();
const { righe, daDatabase } = useGiocatoriSquadra();
const collega = useCollegaGiocatore();
+109 -106
View File
@@ -2,13 +2,14 @@ import { useEffect, useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { CalendarPlus, ChevronLeft, ChevronRight, X } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
import { molla, proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
import { useRosa } from "@/lib/rosa";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useIsAdmin } from "@/lib/ruoli";
import {
Drawer,
@@ -114,8 +115,10 @@ function Calendario() {
const [drawerAperto, setDrawerAperto] = useState(false);
const admin = useIsAdmin();
const { eventi } = useEventi();
const rosa = useRosa();
const ridotto = useReducedMotion();
const rosa = useAnagraficaRosa();
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
const ridotto = useMotoRidotto();
const { anno, mese, direzione, precedente, successivo } = useMeseNav();
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
@@ -179,7 +182,7 @@ function Calendario() {
<span key={i}>{g}</span>
))}
</div>
{/*
{/*
Il mese si cambia anche con lo swipe: il punto d'arrivo si
decide proiettando la velocità di rilascio (come la
decelerazione dello scroll iOS), non dalla posizione del dito.
@@ -192,112 +195,112 @@ function Calendario() {
riga e sulle colonne di bordo. Il padding sta dentro il riquadro
di ritaglio, i margini negativi rimettono la griglia dov'era.
*/}
<div className="relative -mx-1 mt-1 overflow-hidden p-1">
<AnimatePresence initial={false} mode="popLayout" custom={direzione}>
<motion.div
key={mesePrefix}
custom={direzione}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.18}
dragMomentum={false}
onDragEnd={(_, info) => {
const arrivo = info.offset.x + proietta(info.velocity.x);
if (arrivo < -60) successivo();
else if (arrivo > 60) precedente();
}}
initial={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione * 48 }}
animate={{ opacity: 1, x: 0 }}
exit={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione * -48 }}
transition={ridotto ? { duration: 0.2 } : molla.foglio}
className="grid touch-pan-y grid-cols-7 gap-1"
>
{Array.from({ length: offsetLunedi }).map((_, i) => (
<span key={`v${i}`} />
))}
{Array.from({ length: giorni }).map((_, i) => {
const giorno = i + 1;
const eventiGiorno = eventiPerGiorno.get(giorno) ?? [];
const haEventi = eventiGiorno.length > 0;
// Attenzione: si conta per **tipo**, non per numero di
// eventi. Due partite nello stesso giorno restano una cella
// rossa piena; si divide solo se i tipi sono diversi.
const tipiGiorno = Array.from(new Set(eventiGiorno.map((e) => e.tipo)));
// Più tipi: la cella si divide in bande a taglio netto (gli
// stop sono duplicati apposta, non è una sfumatura), una per
// tipo, in diagonale.
const sfondo =
tipiGiorno.length > 1
? `linear-gradient(135deg, ${tipiGiorno
.map((t, idx) => {
const da = (idx / tipiGiorno.length) * 100;
const a = ((idx + 1) / tipiGiorno.length) * 100;
return `${coloreTipo[t]} ${da}%, ${coloreTipo[t]} ${a}%`;
})
.join(", ")})`
: undefined;
const tipo = tipiGiorno.length === 1 ? tipiGiorno[0] : undefined;
const isOggi =
!!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
const Cella = haEventi ? "button" : "div";
return (
<Cella
key={giorno}
type={haEventi ? "button" : undefined}
onClick={haEventi ? () => apriGiorno(giorno) : undefined}
style={sfondo ? { backgroundImage: sfondo } : undefined}
className={cn(
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold",
tipo === "partita" && "bg-accent text-accent-foreground",
tipo === "allenamento" && "bg-training text-training-foreground",
tipo === "evento" && "bg-warning text-warning-foreground",
tipo === "compleanno" && "bg-success text-success-foreground",
!tipo && !haEventi && "text-muted-foreground",
!tipo && haEventi && "text-foreground",
haEventi && "cursor-pointer transition-transform active:scale-90",
isOggi && "ring-2 ring-foreground ring-offset-1 ring-offset-card",
)}
aria-label={
haEventi
? `${giorno} ${mesiIT[mese]}: ${eventiGiorno.length} ${eventiGiorno.length === 1 ? "evento" : "eventi"}`
: undefined
}
aria-current={isOggi ? "date" : undefined}
>
{/*
<div className="relative -mx-1 mt-1 overflow-hidden p-1">
<AnimatePresence initial={false} mode="popLayout" custom={direzione}>
<motion.div
key={mesePrefix}
custom={direzione}
drag={ridotto ? false : "x"}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.18}
dragMomentum={false}
onDragEnd={(_, info) => {
const arrivo = info.offset.x + proietta(info.velocity.x);
if (arrivo < -60) successivo();
else if (arrivo > 60) precedente();
}}
initial={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione * 48 }}
animate={{ opacity: 1, x: 0 }}
exit={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione * -48 }}
transition={ridotto ? { duration: 0.2 } : molla.foglio}
className="grid touch-pan-y grid-cols-7 gap-1"
>
{Array.from({ length: offsetLunedi }).map((_, i) => (
<span key={`v${i}`} />
))}
{Array.from({ length: giorni }).map((_, i) => {
const giorno = i + 1;
const eventiGiorno = eventiPerGiorno.get(giorno) ?? [];
const haEventi = eventiGiorno.length > 0;
// Attenzione: si conta per **tipo**, non per numero di
// eventi. Due partite nello stesso giorno restano una cella
// rossa piena; si divide solo se i tipi sono diversi.
const tipiGiorno = Array.from(new Set(eventiGiorno.map((e) => e.tipo)));
// Più tipi: la cella si divide in bande a taglio netto (gli
// stop sono duplicati apposta, non è una sfumatura), una per
// tipo, in diagonale.
const sfondo =
tipiGiorno.length > 1
? `linear-gradient(135deg, ${tipiGiorno
.map((t, idx) => {
const da = (idx / tipiGiorno.length) * 100;
const a = ((idx + 1) / tipiGiorno.length) * 100;
return `${coloreTipo[t]} ${da}%, ${coloreTipo[t]} ${a}%`;
})
.join(", ")})`
: undefined;
const tipo = tipiGiorno.length === 1 ? tipiGiorno[0] : undefined;
const isOggi =
!!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
const Cella = haEventi ? "button" : "div";
return (
<Cella
key={giorno}
type={haEventi ? "button" : undefined}
onClick={haEventi ? () => apriGiorno(giorno) : undefined}
style={sfondo ? { backgroundImage: sfondo } : undefined}
className={cn(
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold",
tipo === "partita" && "bg-accent text-accent-foreground",
tipo === "allenamento" && "bg-training text-training-foreground",
tipo === "evento" && "bg-warning text-warning-foreground",
tipo === "compleanno" && "bg-success text-success-foreground",
!tipo && !haEventi && "text-muted-foreground",
!tipo && haEventi && "text-foreground",
haEventi && "cursor-pointer transition-transform active:scale-90",
isOggi && "ring-2 ring-foreground ring-offset-1 ring-offset-card",
)}
aria-label={
haEventi
? `${giorno} ${mesiIT[mese]}: ${eventiGiorno.length} ${eventiGiorno.length === 1 ? "evento" : "eventi"}`
: undefined
}
aria-current={isOggi ? "date" : undefined}
>
{/*
Sulle celle divise il numero sta direttamente sulle
bande, staccato dal fondo da un alone bianco: è la
resa scelta, il colore deve restare pieno e visibile
fino al bordo.
*/}
<span className="relative drop-shadow-[0_1px_1px_rgba(255,255,255,0.5)]">
{giorno}
</span>
</Cella>
);
})}
</motion.div>
</AnimatePresence>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Scorri a destra o sinistra per cambiare mese.
</p>
<div className="mt-3 flex flex-wrap gap-3 text-xs font-semibold text-muted-foreground">
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-accent" /> Partita
</span>
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-training" /> Allenamento
</span>
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-warning" /> Eventi
</span>
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-success" /> Compleanni
</span>
</div>
</Card>
</Section>
<span className="relative drop-shadow-[0_1px_1px_rgba(255,255,255,0.5)]">
{giorno}
</span>
</Cella>
);
})}
</motion.div>
</AnimatePresence>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Scorri a destra o sinistra per cambiare mese.
</p>
<div className="mt-3 flex flex-wrap gap-3 text-xs font-semibold text-muted-foreground">
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-accent" /> Partita
</span>
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-training" /> Allenamento
</span>
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-warning" /> Eventi
</span>
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-success" /> Compleanni
</span>
</div>
</Card>
</Section>
{admin ? (
<div className="px-5 pt-4">
+277 -155
View File
@@ -1,14 +1,53 @@
import { useMemo } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { ChevronRight, RefreshCw } from "lucide-react";
import { AlertTriangle, ChevronRight, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatData } from "@/lib/crapp-data";
import { PageHeader } from "@/components/crapp/ui-bits";
import { formatData, type RigaClassifica } from "@/lib/crapp-data";
import { PageHeader, TeamLogo } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { useScoutMatches } from "@/lib/scout-store";
import { useCsi } from "@/lib/csi";
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
import { useEventi } from "@/lib/eventi";
import { LogoSquadra } from "@/components/crapp/DettaglioCsi";
const LOGO_NOI = "/logo-nerorosso.svg";
const NOME_NOI = "CRAP Volley";
/** Logo CRAP o avversario; cerchio con iniziali se il CSI non ha l'immagine. */
function LogoPartita({
nostro,
logoAvversario,
avversario,
}: {
nostro: boolean;
logoAvversario: string;
avversario: string;
}) {
if (nostro) {
return (
<TeamLogo src={LOGO_NOI} className="h-8 w-8 rounded-lg shadow-none" />
);
}
if (logoAvversario) {
return <LogoSquadra src={logoAvversario} alt={avversario} className="h-8 w-8" />;
}
const iniziali = avversario
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("");
return (
<span
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-secondary text-[10px] font-bold text-muted-foreground"
aria-hidden
>
{iniziali || "?"}
</span>
);
}
const TAB_CLASSIFICA = ["classifica", "storico"] as const;
type TabClassifica = (typeof TAB_CLASSIFICA)[number];
@@ -36,6 +75,44 @@ export const Route = createFileRoute("/classifica")({
component: Classifica,
});
function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: string }) {
return (
<div className="overflow-hidden rounded-3xl bg-card shadow-card">
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-xs font-bold uppercase text-muted-foreground">
<span>#</span>
<span>Squadra</span>
<span className="text-center">G</span>
<span className="text-center">Set</span>
<span className="text-center">Pt</span>
</div>
{righe.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">{vuoto}</p>
) : (
righe.map((r) => {
const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley";
return (
<div
key={r.pos}
className={cn(
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-sm last:border-0",
noi && "bg-accent/10",
)}
>
<span className={cn("font-display text-base", noi && "text-accent")}>{r.pos}</span>
<span className={cn("truncate", noi ? "font-bold" : "font-medium")}>{r.squadra}</span>
<span className="text-center text-xs text-muted-foreground">{r.giocate}</span>
<span className="text-center text-xs tabular-nums text-muted-foreground">
{r.setFatti}:{r.setSubiti}
</span>
<span className="text-center font-bold tabular-nums">{r.punti}</span>
</div>
);
})
)}
</div>
);
}
function formatAggiornamento(iso: string) {
const d = new Date(iso);
const oggi = new Date().toDateString() === d.toDateString();
@@ -48,32 +125,205 @@ function Classifica() {
const scoutMatches = useScoutMatches();
const { data: csi } = useCsi();
const votiMvp = useVotiMvp();
const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []);
const { eventi } = useEventi();
const classifica = csi?.classifica ?? [];
const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
const eventoIdPerData = new Map(
eventi.filter((e) => e.tipo === "partita").map((e) => [e.data, e.id]),
const classifica = useMemo(() => csi?.classifica ?? [], [csi]);
const classificaCoppa = useMemo(() => csi?.classificaCoppa ?? [], [csi]);
const mvpPerMatch = useMemo(() => vincitoriMvp(votiMvp.data ?? []), [votiMvp.data]);
const eventoIdPerData = useMemo(
() => new Map(eventi.filter((e) => e.tipo === "partita").map((e) => [e.data, e.id])),
[eventi],
);
// I voti MVP sono legati all'evento CrAPP, non al referto CSI né allo scout.
const mvpPerData = (data: string) => mvpPerMatch[eventoIdPerData.get(data) ?? ""] ?? "";
const tuttiMatch = csiGiocate.length
? csiGiocate.map((p) => {
const m = matchDaPartitaCsi(p);
return { ...m, mvp: mvpPerData(m.data), scout: false };
})
: scoutMatches.map((m) => ({
id: m.id,
data: m.data,
avversario: m.avversario,
casa: m.casa,
setNostri: m.setNostri,
setLoro: m.setLoro,
parziali: m.parziali,
mvp: mvpPerData(m.data),
scout: true,
}));
const tuttiMatch = useMemo(() => {
const mvpPerData = (data: string) => mvpPerMatch[eventoIdPerData.get(data) ?? ""] ?? "";
const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
return csiGiocate.length
? csiGiocate.map((p) => {
const m = matchDaPartitaCsi(p);
return { ...m, mvp: mvpPerData(m.data), scout: false };
})
: scoutMatches.map((m) => ({
id: m.id,
data: m.data,
avversario: m.avversario,
logoAvversario: "",
casa: m.casa,
setNostri: m.setNostri,
setLoro: m.setLoro,
parziali: m.parziali,
mvp: mvpPerData(m.data),
scout: true,
}));
}, [csi, scoutMatches, mvpPerMatch, eventoIdPerData]);
// Ogni tab è memoizzata: un refetch in background di uno solo dei dati (CSI,
// scout, eventi, voti MVP) non deve ricostruire il JSX dell'altra tab.
const contenutoClassifica = useMemo(
() => (
<>
<div
className={cn(
"mb-3 flex items-center gap-2 rounded-2xl px-3 py-2 text-xs",
csi?.formatoSospetto
? "bg-warning/15 text-warning"
: "bg-secondary text-muted-foreground",
)}
>
{csi?.formatoSospetto ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-warning" aria-hidden />
) : (
<RefreshCw className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden />
)}
{csi?.formatoSospetto
? "Il portale CSI potrebbe aver cambiato formato: dati da verificare."
: csi
? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}`
: "Dati CSI in arrivo"}
</div>
{classificaCoppa.length > 0 ? (
<>
<h3 className="mb-2 px-1 text-sm font-bold text-foreground">Coppa</h3>
<TabellaClassifica righe={classificaCoppa} vuoto="Classifica non disponibile." />
<h3 className="mb-2 mt-4 px-1 text-sm font-bold text-foreground">Girone</h3>
</>
) : null}
<TabellaClassifica righe={classifica} vuoto="Classifica non ancora disponibile." />
</>
),
[csi, classifica, classificaCoppa],
);
const contenutoStorico = useMemo(
() =>
tuttiMatch.length === 0 ? (
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
Nessuna partita disponibile.
</p>
) : (
<div className="space-y-3">
{tuttiMatch.map((m) => {
const vinta = m.setNostri > m.setLoro;
const eventoId = eventoIdPerData.get(m.data);
const cliccabile = Boolean(eventoId) || !m.scout;
const casa = {
nome: m.casa ? NOME_NOI : m.avversario,
nostro: m.casa,
};
const trasferta = {
nome: m.casa ? m.avversario : NOME_NOI,
nostro: !m.casa,
};
// Badge e parziali in ordine casaospite; il colore resta sulla vittoria CRAP.
const setCasa = m.casa ? m.setNostri : m.setLoro;
const setOspite = m.casa ? m.setLoro : m.setNostri;
const contenuto = (
<>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex min-w-0 items-center gap-2">
<LogoPartita
nostro={casa.nostro}
logoAvversario={m.logoAvversario}
avversario={m.avversario}
/>
<p className="truncate text-sm font-bold">{casa.nome}</p>
</div>
<div className="flex min-w-0 items-center gap-2">
<LogoPartita
nostro={trasferta.nostro}
logoAvversario={m.logoAvversario}
avversario={m.avversario}
/>
<p className="truncate text-sm font-bold">{trasferta.nome}</p>
</div>
<p className="text-xs text-muted-foreground">
{formatData(m.data)} · MVP {m.mvp || "da votare"}
{m.scout ? " · scoutata" : ""}
</p>
</div>
<span
className={cn(
"shrink-0 rounded-xl px-2.5 py-1 font-display text-xl",
vinta
? "bg-success text-success-foreground"
: "bg-destructive text-destructive-foreground",
)}
>
{setCasa}-{setOspite}
</span>
{cliccabile ? (
<ChevronRight
className="h-5 w-5 shrink-0 text-muted-foreground"
aria-hidden
/>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{m.parziali.map((p, i) => {
const puntiCasa = m.casa ? p[0] : p[1];
const puntiOspite = m.casa ? p[1] : p[0];
const setVintoDaNoi = p[0] > p[1];
return (
<span
key={i}
className={cn(
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
setVintoDaNoi
? "bg-secondary"
: "bg-muted text-muted-foreground",
)}
>
{puntiCasa}-{puntiOspite}
</span>
);
})}
{eventoId && !m.mvp ? (
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
</span>
) : null}
</div>
</>
);
const aria = `${casa.nome} vs ${trasferta.nome}: dettaglio partita`;
if (eventoId) {
return (
<Link
key={m.id}
to="/partita/$id"
params={{ id: eventoId }}
aria-label={aria}
className="premi block rounded-3xl bg-card p-4 shadow-card ring-1 ring-transparent transition-[box-shadow,transform] active:scale-[0.99] hover:ring-accent/30"
>
{contenuto}
</Link>
);
}
// Senza evento CrAPP collegato (nessuna creazione automatica dal calendario
// CSI, vedi "Evoluzioni possibili"): le gare scoutate localmente non hanno un
// corrispettivo sul portale da mostrare, quelle CSI sì.
return m.scout ? (
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
{contenuto}
</article>
) : (
<Link
key={m.id}
to="/partita-csi/$id"
params={{ id: m.id }}
aria-label={aria}
className="premi block rounded-3xl bg-card p-4 shadow-card ring-1 ring-transparent transition-[box-shadow,transform] active:scale-[0.99] hover:ring-accent/30"
>
{contenuto}
</Link>
);
})}
</div>
),
[tuttiMatch, eventoIdPerData],
);
return (
<>
@@ -87,136 +337,8 @@ function Classifica() {
variante="sottolineatura"
riempiLarghezza
voci={[
{
id: "classifica",
label: "Classifica",
contenuto: (
<>
<div className="mb-3 flex items-center gap-2 rounded-2xl bg-secondary px-3 py-2 text-xs text-muted-foreground">
<RefreshCw className="h-3.5 w-3.5 text-accent" />
{csi
? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}`
: "Dati CSI in arrivo"}
</div>
<div className="overflow-hidden rounded-3xl bg-card shadow-card">
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-xs font-bold uppercase text-muted-foreground">
<span>#</span>
<span>Squadra</span>
<span className="text-center">G</span>
<span className="text-center">Set</span>
<span className="text-center">Pt</span>
</div>
{classifica.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
Classifica non ancora disponibile.
</p>
) : (
classifica.map((r) => {
const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley";
return (
<div
key={r.pos}
className={cn(
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-sm last:border-0",
noi && "bg-accent/10",
)}
>
<span className={cn("font-display text-base", noi && "text-accent")}>
{r.pos}
</span>
<span className={cn("truncate", noi ? "font-bold" : "font-medium")}>
{r.squadra}
</span>
<span className="text-center text-xs text-muted-foreground">
{r.giocate}
</span>
<span className="text-center text-xs tabular-nums text-muted-foreground">
{r.setFatti}:{r.setSubiti}
</span>
<span className="text-center font-bold tabular-nums">{r.punti}</span>
</div>
);
})
)}
</div>
</>
),
},
{
id: "storico",
label: "Storico partite",
contenuto:
tuttiMatch.length === 0 ? (
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
Nessuna partita disponibile.
</p>
) : (
<div className="space-y-3">
{tuttiMatch.map((m) => {
const vinta = m.setNostri > m.setLoro;
const eventoId = eventoIdPerData.get(m.data);
const contenuto = (
<>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-bold">
{m.casa ? "CRAP Volley" : m.avversario} vs{" "}
{m.casa ? m.avversario : "CRAP Volley"}
</p>
<p className="text-xs text-muted-foreground">
{formatData(m.data)} · MVP {m.mvp || "da votare"}
{m.scout ? " · scoutata" : ""}
</p>
</div>
<span
className={cn(
"rounded-xl px-2.5 py-1 font-display text-lg",
vinta
? "bg-success text-success-foreground"
: "bg-destructive text-destructive-foreground",
)}
>
{m.setNostri}-{m.setLoro}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{m.parziali.map((p, i) => (
<span
key={i}
className={cn(
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
p[0] > p[1] ? "bg-secondary" : "bg-muted text-muted-foreground",
)}
>
{p[0]}-{p[1]}
</span>
))}
{eventoId && !m.mvp ? (
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
Vota MVP <ChevronRight className="h-3.5 w-3.5" />
</span>
) : null}
</div>
</>
);
return eventoId ? (
<Link
key={m.id}
to="/partita/$id"
params={{ id: eventoId }}
className="premi block rounded-3xl bg-card p-4 shadow-card active:scale-[0.99]"
>
{contenuto}
</Link>
) : (
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
{contenuto}
</article>
);
})}
</div>
),
},
{ id: "classifica", label: "Classifica", contenuto: contenutoClassifica },
{ id: "storico", label: "Storico partite", contenuto: contenutoStorico },
]}
/>
</>
+3 -2
View File
@@ -25,7 +25,7 @@ import {
type CategoriaEvento,
type Evento,
} from "@/lib/eventi";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
import { useIsAdmin } from "@/lib/ruoli";
export const Route = createFileRoute("/eventi")({
@@ -57,7 +57,8 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [
];
function GestioneEventi() {
const io = useGiocatoreCorrente();
// Solo verità (`!io`, gate admin): `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const admin = useIsAdmin();
const { eventi, isPending, isError, error, refetch } = useEventi();
const { righe: squadra } = useGiocatoriSquadra();
+3 -2
View File
@@ -15,6 +15,7 @@ import { useCsi } from "@/lib/csi";
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
import { useScoutMatches } from "@/lib/scout-store";
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
import { mediaVotoColpoDOcchio } from "@/lib/pagelle";
export const Route = createFileRoute("/")({
head: () => ({
@@ -238,8 +239,8 @@ function Index() {
<Section titolo="Colpo d'occhio" indice={6}>
<div className="grid grid-cols-3 gap-2">
<StatTile valore={giocatore.presenze} label="Presenze" hint="+2 questo mese" />
<StatTile valore={giocatore.mediaVoto || "—"} label="Media voto" />
<StatTile valore={giocatore.presenze} label="Presenze" />
<StatTile valore={mediaVotoColpoDOcchio(giocatore)} label="Media voto" />
<StatTile valore={giocatore.mvp} label="MVP" />
</div>
</Section>
+118
View File
@@ -0,0 +1,118 @@
import { createFileRoute } from "@tanstack/react-router";
import { Trophy, Swords } from "lucide-react";
import { cn } from "@/lib/utils";
import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { formatData } from "@/lib/crapp-data";
import { useCsi } from "@/lib/csi";
import { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/crapp/DettaglioCsi";
export const Route = createFileRoute("/partita-csi/$id")({
head: () => {
const titolo = "Dettaglio partita";
return {
meta: [
{ title: `${titolo} — CrAPP` },
{
name: "description",
content: "Dettaglio partita ufficiale CSI del CRAP Volley.",
},
{ property: "og:title", content: `${titolo} — CrAPP` },
{ property: "og:description", content: "Dettaglio partita ufficiale CSI del CRAP Volley." },
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary" },
],
};
},
component: PartitaCsiDetail,
});
/**
* Dettaglio "solo CSI" per le gare senza un evento CrAPP collegato (l'app non crea ancora
* eventi automaticamente dal calendario CSI): niente convocazioni/presenze/MVP/scout, solo
* i dati ufficiali del portale. Per le gare già collegate a un evento vedi `/partita/$id`,
* che mostra le stesse informazioni CSI in più.
*/
function PartitaCsiDetail() {
const { id } = Route.useParams();
const { data: csi } = useCsi();
const partita = csi?.partite.find((p) => p.id === id);
if (!partita) {
return (
<div className="px-5 pt-8">
<p className="mt-8 text-center text-sm text-muted-foreground">Partita non trovata</p>
</div>
);
}
const giocata = partita.setNostri !== null && partita.setLoro !== null;
const vinta = giocata && (partita.setNostri as number) > (partita.setLoro as number);
return (
<>
<PageHeader titolo="Partita" sottotitolo={formatData(partita.data)} />
<Section titolo={partita.competizione || "Campionato"}>
<div className="rounded-3xl bg-card p-5 shadow-card">
<div className="flex items-center gap-3">
<div
className={cn(
"grid h-12 w-12 shrink-0 place-items-center rounded-2xl",
partita.casa ? "bg-primary/15 text-primary" : "bg-accent/15 text-accent",
)}
>
{partita.casa ? <Swords className="h-6 w-6" /> : <Trophy className="h-6 w-6" />}
</div>
<LogoSquadra
src={partita.logoAvversario}
alt={partita.avversario}
className="h-10 w-10"
/>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
{partita.casa ? "In casa" : "Fuori casa"}
</p>
<p className="truncate text-lg font-bold leading-tight">{partita.avversario}</p>
</div>
{giocata ? (
<span
className={cn(
"shrink-0 rounded-full px-3 py-1 font-display text-lg",
vinta
? "bg-success text-success-foreground"
: "bg-destructive text-destructive-foreground",
)}
>
{partita.setNostri} - {partita.setLoro}
</span>
) : null}
</div>
{giocata && partita.parziali.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-1.5">
{partita.parziali.map(([noi, loro], i) => (
<span
key={i}
className="rounded-lg bg-secondary px-2 py-1 text-xs font-semibold tabular-nums"
>
{noi}-{loro}
</span>
))}
</div>
) : null}
<MetaPartitaCsi
girone={partita.girone}
numeroGara={partita.numeroGara}
arbitro={partita.arbitro}
link={partita.link}
/>
</div>
</Section>
<Section titolo="Formazioni e scontri diretti">
<DettaglioCsiEsteso matchId={partita.id} avversario={partita.avversario} />
</Section>
</>
);
}
+25 -2
View File
@@ -7,12 +7,12 @@ import { convocatiEvento, useEvento } from "@/lib/eventi";
import { useRosa } from "@/lib/rosa";
import { useCsi } from "@/lib/csi";
import { matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
import { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/crapp/DettaglioCsi";
import { Pagelle } from "@/components/crapp/Pagelle";
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useIsAdmin } from "@/lib/ruoli";
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
import { VotoSocial } from "@/components/crapp/VotoSocial";
@@ -46,7 +46,6 @@ export const Route = createFileRoute("/partita/$id")({
function PartitaDetail() {
const { id } = Route.useParams();
const { evento } = useEvento(id);
const io = useGiocatoreCorrente();
const admin = useIsAdmin();
const scoutMatches = useScoutMatches();
const { data: csi } = useCsi();
@@ -74,10 +73,16 @@ function PartitaDetail() {
id: scout.id,
data: scout.data,
avversario: scout.avversario,
logoAvversario: "",
casa: scout.casa,
setNostri: scout.setNostri,
setLoro: scout.setLoro,
parziali: scout.parziali,
campo: "",
girone: "",
numeroGara: "",
arbitro: "",
link: "",
}
: csiMatch
? matchDaPartitaCsi(csiMatch)
@@ -107,6 +112,9 @@ function PartitaDetail() {
>
{casa ? <Swords className="h-6 w-6" /> : <Trophy className="h-6 w-6" />}
</div>
{match?.logoAvversario ? (
<LogoSquadra src={match.logoAvversario} alt={avversario} className="h-10 w-10" />
) : null}
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
{casa ? "In casa" : "Fuori casa"}
@@ -136,6 +144,15 @@ function PartitaDetail() {
Conferme: {presentiVeri}/{convocati.length}
</div>
{csiMatch ? (
<MetaPartitaCsi
girone={csiMatch.girone}
numeroGara={csiMatch.numeroGara}
arbitro={csiMatch.arbitro}
link={csiMatch.link}
/>
) : null}
<TurnoPalloni eventoId={evento.id} />
</div>
</Section>
@@ -173,6 +190,12 @@ function PartitaDetail() {
</Section>
) : null}
{csiMatch ? (
<Section titolo="Formazioni e scontri diretti">
<DettaglioCsiEsteso matchId={csiMatch.id} avversario={avversario} />
</Section>
) : null}
<Section titolo="MVP eletto dalla squadra">
<div className="rounded-3xl bg-card p-5 shadow-card">
<VotazioneMvp evento={evento} />
+9 -4
View File
@@ -130,11 +130,15 @@ function Profilo() {
return (
<>
{/* Qui il link al profilo sarebbe un link a sé stessa: torna il logo. */}
{/* Sul profilo il logo torna in home al posto del link al profilo. */}
<PageHeader
titolo={g.nome}
sottotitolo={`#${g.numero} · ${g.ruolo}`}
azione={<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />}
azione={
<Link to="/" aria-label="Vai alla home" className="premi shrink-0 rounded-2xl">
<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />
</Link>
}
/>
<Reveal className="-mt-6 px-5">
@@ -184,10 +188,11 @@ function Profilo() {
<BarraSottosezioni
key={tabIniziale}
defaultId={tabIniziale}
riempiLarghezza
voci={[
{
id: "stagione",
label: "Stagione",
label: "Season",
contenuto: (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-2">
@@ -225,7 +230,7 @@ function Profilo() {
},
{
id: "documenti",
label: "Documenti",
label: "Docs",
contenuto: <ProfiloAmministrativo giocatoreId={g.id} conTendina={false} />,
},
{
+6 -4
View File
@@ -6,8 +6,8 @@ import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { formatData } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { useRosa } from "@/lib/rosa";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase } from "@/lib/user-store";
import { usePresenzeEvento } from "@/lib/presenze";
import {
statoIniziale,
@@ -81,7 +81,8 @@ function Blocco({
function Scout() {
const { pronto, partita } = usePartitaDiOggi();
const io = useGiocatoreCorrente();
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const sessione = useSessioneScout(partita?.id ?? null);
const statoSalvato = useStatoScout(partita?.id ?? null);
const apri = useApriSessioneScout();
@@ -204,7 +205,8 @@ function ScoutBoard({
const cancella = useCancellaStatoScout();
const salvaMatch = useSalvaScoutMatch();
const { risposte } = usePresenzeEvento(partita.id);
const rosa = useRosa();
// Solo id/nome/numero servono (selezione e display): niente statistiche di squadra.
const rosa = useAnagraficaRosa();
const finito = useRef(false);
/** In campo solo chi ha confermato la presenza (anche in ritardo). */
+342 -369
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Cake, ChevronDown, Crown, Trophy } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -7,7 +7,13 @@ import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { Avatar } from "@/components/crapp/Avatar";
import { formatData } from "@/lib/crapp-data";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useRosa, useObiettivi } from "@/lib/rosa";
import {
useRosa,
useObiettivi,
classificaRank,
dettaglioClassifica,
type CriterioClassifica,
} from "@/lib/rosa";
import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese";
import { totaliSquadra, useScoutMatches } from "@/lib/scout-store";
import { useCsi } from "@/lib/csi";
@@ -70,7 +76,7 @@ const criteri = [
{ id: "cacchePartita", label: "Cacche" },
] as const;
type Criterio = (typeof criteri)[number]["id"];
type Criterio = CriterioClassifica;
function valore(
g: { presenze: number; mediaVoto: number; mvp: number; palloni: number; cacchePartita: number },
@@ -95,6 +101,7 @@ function Squadra() {
)
: 0;
const ordinati = [...rosa].sort((a, b) => valore(b, criterio) - valore(a, criterio));
const rank = classificaRank(ordinati.map((g) => valore(g, criterio)));
const max = ordinati[0] ? valore(ordinati[0], criterio) || 1 : 1;
const { data: csi } = useCsi();
const matchGiocati = csi ? partiteGiocate(csi.partite).length : scoutMatches.length;
@@ -103,6 +110,333 @@ function Squadra() {
? Math.round(obiettivi.reduce((s, o) => s + progressoObiettivo(o), 0) / obiettivi.length)
: 0;
// Ogni tab è memoizzata: aprire/chiudere una card giocatore (stato `aperto`)
// non deve ricalcolare badge e obiettivi delle altre sezioni.
const contenutoRosa = useMemo(
() => (
<div className="space-y-2">
{rosa.map((g) => {
const stati = badgeGiocatore(g);
const sbloccati = [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
const isOpen = aperto === g.id;
return (
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
<button
type="button"
onClick={() => setAperto(isOpen ? null : g.id)}
className="flex min-h-11 w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
aria-expanded={isOpen}
>
<Avatar
id={g.id}
fallback={g.numero ? String(g.numero) : g.iniziali}
className="h-11 w-11 text-lg"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
<span className="mt-1 flex items-center gap-2">
<RuoloBadge ruolo={g.ruolo} />
<span className="text-xs text-muted-foreground">
{g.numero ? `#${g.numero}` : "n° da definire"}
</span>
</span>
</span>
{sbloccati.length > 0 ? (
<span className="flex max-w-[74px] shrink-0 flex-wrap items-center justify-end gap-0.5">
{sbloccati.map((b) => {
const Icon = b.def.icon;
return (
<span key={b.def.id} title={`${b.def.nome}${gradoMeta[b.grado!].label}`}>
<Icon
className={cn("h-3.5 w-3.5", gradoMeta[b.grado!].text)}
aria-hidden="true"
/>
<span className="sr-only">
{b.def.nome}: {gradoMeta[b.grado!].label}
</span>
</span>
);
})}
</span>
) : null}
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-180",
)}
/>
</button>
{isOpen ? (
<div className="border-t border-border px-4 pb-4 pt-3">
<div className="flex flex-wrap items-center gap-2">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
{g.nascita.slice(0, 4)}
</p>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{[
{ l: "Presenze", v: `${g.presenze}/${g.totaliEventi}` },
{ l: "Presenze 30gg", v: `${mese[g.id]?.percentuale ?? 0}%` },
{ l: "Presenze di fila", v: g.streak },
{ l: "Media voto", v: g.mediaVoto || "—" },
{ l: "MVP", v: g.mvp },
{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" },
].map((s) => (
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
<p className="font-display text-xl leading-none">{s.v}</p>
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
{s.l}
</p>
</div>
))}
</div>
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
</p>
{sbloccati.length === 0 ? (
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
Nessun badge sbloccato per ora.
</p>
) : (
<div className="mt-2 grid grid-cols-2 gap-2">
{sbloccati.map((b) => {
const Icon = b.def.icon;
const meta = gradoMeta[b.grado!];
return (
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
<Icon className={cn("h-4 w-4", meta.text)} />
<p className="mt-1 text-xs font-bold leading-tight">{b.def.nome}</p>
<p className="text-xs text-muted-foreground">
{b.valore} {b.def.unita}
{b.prossimaSoglia
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
: ""}
</p>
<p className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}>
{meta.label}
</p>
</div>
</BadgeDrawer>
);
})}
</div>
)}
</div>
) : null}
</article>
);
})}
</div>
),
[rosa, aperto, mese],
);
const contenutoStats = useMemo(
() => (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-2">
<StatTile valore={`${mediaPresenze}%`} label="Media presenze" />
<StatTile valore={matchGiocati} label="Match giocati" />
<StatTile valore={mediaSquadra(pagelle) || "—"} label="Media pagelle" />
<StatTile valore={team.punti} label="Punti squadra" />
<StatTile valore={team.ace} label="Ace squadra" />
<StatTile valore={team.muri} label="Muri squadra" />
</div>
<div className="border-t border-border pt-4">
<button
type="button"
onClick={() => setFiltroAperto(true)}
aria-haspopup="dialog"
className="flex w-full min-w-0 items-center gap-2 rounded-2xl bg-card px-3 py-2.5 text-left shadow-card"
>
<Trophy className="h-5 w-5 shrink-0 text-warning" aria-hidden />
<span className="shrink-0 text-[14px] text-foreground/80">Classifica per</span>
<span className="min-w-0 flex-1 truncate text-right text-[14px] font-bold">
{criteri.find((c) => c.id === criterio)?.label}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
<Drawer open={filtroAperto} onOpenChange={setFiltroAperto}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Classifica per</DrawerTitle>
</DrawerHeader>
<div className="flex flex-col gap-1 px-4 pb-6">
{criteri.map((c) => (
<DrawerClose key={c.id} asChild>
<button
type="button"
onClick={() => setCriterio(c.id)}
className={cn(
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-sm font-bold",
criterio === c.id
? "bg-accent text-accent-foreground"
: "bg-secondary text-foreground",
)}
>
{c.label}
</button>
</DrawerClose>
))}
</div>
</DrawerContent>
</Drawer>
<div className="mt-3 space-y-2">
{ordinati.map((g, i) => (
<div key={g.id} className="rounded-2xl bg-card p-3 shadow-card">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-secondary font-display text-lg">
{rank[i]}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-bold">
{g.nome}
{rank[i] === 1 ? (
<Crown className="ml-1 inline h-3.5 w-3.5 text-warning" />
) : null}
</p>
<p className="truncate text-xs text-muted-foreground">
#{g.numero} · {g.ruolo} · {dettaglioClassifica(g, criterio)}
</p>
</div>
</div>
<span className="font-display text-xl tabular-nums">
{valore(g, criterio) || "—"}
</span>
</div>
<Barra
percentuale={Math.max(6, (valore(g, criterio) / max) * 100)}
altezza="h-1.5"
trackClassName="mt-2"
/>
</div>
))}
</div>
</div>
</div>
),
[mediaPresenze, matchGiocati, pagelle, team, filtroAperto, criterio, ordinati, rank, max],
);
const contenutoObiettivi = useMemo(
() => (
<>
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
<div className="flex items-end justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
Progresso collettivo
</p>
<p className="font-display text-4xl leading-none">
<Numero valore={mediaObiettivi} suffisso="%" />
</p>
</div>
<p className="text-xs text-primary-foreground/70">
{completati}/{obiettivi.length} completati
</p>
</div>
<Barra percentuale={mediaObiettivi} trackClassName="mt-3 bg-primary-foreground/15" />
</div>
<div className="space-y-2">
{obiettivi.map((o, i) => {
const pct = progressoObiettivo(o);
const fatto = pct >= 100;
return (
<Reveal
key={o.id}
indice={i}
className={cn(
"premi rounded-3xl bg-card p-4 shadow-card ring-1",
fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent",
)}
>
<div className="flex items-start gap-2">
<span className="text-lg leading-none">{o.emoji}</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold leading-tight">{o.titolo}</p>
<p className="text-xs text-muted-foreground">{o.descrizione}</p>
</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-bold uppercase tracking-wide",
fatto
? "bg-success text-success-foreground"
: "bg-secondary text-muted-foreground",
)}
>
{fatto ? "Completato" : `${pct}%`}
</span>
</div>
<Barra percentuale={pct} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
{o.valore}/{o.target} {o.unita} · {pct}%
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
</p>
<p className="mt-1 text-xs font-semibold text-accent">{microcopyObiettivo(o)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
</Reveal>
);
})}
</div>
</>
),
[obiettivi, mediaObiettivi, completati],
);
const contenutoBadge = useMemo(
() => (
<div className="space-y-2">
{badgeDefs.map((b) => {
const Icon = b.icon;
return (
<BadgeDrawer key={b.id} def={b}>
<div className="rounded-3xl bg-card p-3 shadow-card">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-accent" />
<p className="text-sm font-bold leading-tight">{b.nome}</p>
<p className="ml-auto text-xs text-muted-foreground">{descrizioneSoglie(b)}</p>
</div>
<div className="mt-2 grid grid-cols-3 gap-1.5">
{gradiOrdine.map((grado) => {
const meta = gradoMeta[grado];
const quanti = rosa.filter((g) => {
const raggiunto = gradoRaggiunto(b, b.valore(g));
return raggiunto
? gradiOrdine.indexOf(raggiunto) >= gradiOrdine.indexOf(grado)
: false;
}).length;
return (
<div
key={grado}
className={cn("rounded-2xl p-2 text-center ring-1", meta.bg, meta.ring)}
>
<p className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</p>
<p className="font-display text-lg leading-none">{b.soglie[grado]}</p>
<p className="text-xs text-muted-foreground">
{quanti}/{rosa.length}
</p>
</div>
);
})}
</div>
</div>
</BadgeDrawer>
);
})}
</div>
),
[rosa],
);
return (
<>
<PageHeader titolo="Squadra" sottotitolo={`${rosa.length} giocatori · Stagione 2026/27`} />
@@ -110,373 +444,12 @@ function Squadra() {
<BarraSottosezioni
defaultId="rosa"
variante="sottolineatura"
riempiLarghezza
voci={[
{
id: "rosa",
label: "Rosa",
contenuto: (
<div className="space-y-2">
{rosa.map((g) => {
const stati = badgeGiocatore(g);
const sbloccati = [
...stati.filter((b) => b.grado !== null),
...badgeSegretiSbloccati(g),
];
const isOpen = aperto === g.id;
return (
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
<button
type="button"
onClick={() => setAperto(isOpen ? null : g.id)}
className="flex min-h-11 w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
aria-expanded={isOpen}
>
<Avatar
id={g.id}
fallback={g.numero ? String(g.numero) : g.iniziali}
className="h-11 w-11 text-lg"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">
{g.nome}
</span>
<span className="mt-1 flex items-center gap-2">
<RuoloBadge ruolo={g.ruolo} />
<span className="text-xs text-muted-foreground">
{g.numero ? `#${g.numero}` : "n° da definire"}
</span>
</span>
</span>
{sbloccati.length > 0 ? (
<span className="flex max-w-[74px] shrink-0 flex-wrap items-center justify-end gap-0.5">
{sbloccati.map((b) => {
const Icon = b.def.icon;
return (
<span
key={b.def.id}
title={`${b.def.nome}${gradoMeta[b.grado!].label}`}
>
<Icon
className={cn("h-3.5 w-3.5", gradoMeta[b.grado!].text)}
aria-hidden="true"
/>
<span className="sr-only">
{b.def.nome}: {gradoMeta[b.grado!].label}
</span>
</span>
);
})}
</span>
) : null}
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
isOpen && "rotate-180",
)}
/>
</button>
{isOpen ? (
<div className="border-t border-border px-4 pb-4 pt-3">
<div className="flex flex-wrap items-center gap-2">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
{g.nascita.slice(0, 4)}
</p>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{[
{ l: "Presenze", v: `${g.presenze}/${g.totaliEventi}` },
{ l: "Presenze 30gg", v: `${mese[g.id]?.percentuale ?? 0}%` },
{ l: "Presenze di fila", v: g.streak },
{ l: "Media voto", v: g.mediaVoto || "—" },
{ l: "MVP", v: g.mvp },
{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" },
].map((s) => (
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
<p className="font-display text-xl leading-none">{s.v}</p>
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
{s.l}
</p>
</div>
))}
</div>
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Badge sbloccati · {collezioneBadge(g).ottenuti}/
{collezioneBadge(g).totali}
</p>
{sbloccati.length === 0 ? (
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
Nessun badge sbloccato per ora.
</p>
) : (
<div className="mt-2 grid grid-cols-2 gap-2">
{sbloccati.map((b) => {
const Icon = b.def.icon;
const meta = gradoMeta[b.grado!];
return (
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<div
className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}
>
<Icon className={cn("h-4 w-4", meta.text)} />
<p className="mt-1 text-xs font-bold leading-tight">
{b.def.nome}
</p>
<p className="text-xs text-muted-foreground">
{b.valore} {b.def.unita}
{b.prossimaSoglia
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
: ""}
</p>
<p
className={cn(
"mt-1.5 text-xs font-bold uppercase",
meta.text,
)}
>
{meta.label}
</p>
</div>
</BadgeDrawer>
);
})}
</div>
)}
</div>
) : null}
</article>
);
})}
</div>
),
},
{
id: "stats",
label: "Statistiche",
contenuto: (
<div className="grid grid-cols-3 gap-2">
<StatTile valore={`${mediaPresenze}%`} label="Media presenze" />
<StatTile valore={matchGiocati} label="Match giocati" />
<StatTile valore={mediaSquadra(pagelle) || "—"} label="Media pagelle" />
<StatTile valore={team.punti} label="Punti squadra" />
<StatTile valore={team.ace} label="Ace squadra" />
<StatTile valore={team.muri} label="Muri squadra" />
</div>
),
},
{
id: "classifica-giocatori",
label: "Classifica",
aTuttoLarghezza: true,
contenuto: (
<>
<button
type="button"
onClick={() => setFiltroAperto(true)}
aria-haspopup="dialog"
className="flex w-full min-w-0 items-center gap-2 border-b border-border bg-card px-5 py-2.5 text-left"
>
<Trophy className="h-4 w-4 shrink-0 text-warning" aria-hidden />
<span className="shrink-0 text-sm text-foreground/80">Classifica per</span>
<span className="min-w-0 flex-1 truncate text-right text-sm font-bold">
{criteri.find((c) => c.id === criterio)?.label}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
<Drawer open={filtroAperto} onOpenChange={setFiltroAperto}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Classifica per</DrawerTitle>
</DrawerHeader>
<div className="flex flex-col gap-1 px-4 pb-6">
{criteri.map((c) => (
<DrawerClose key={c.id} asChild>
<button
type="button"
onClick={() => setCriterio(c.id)}
className={cn(
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-sm font-bold",
criterio === c.id
? "bg-accent text-accent-foreground"
: "bg-secondary text-foreground",
)}
>
{c.label}
</button>
</DrawerClose>
))}
</div>
</DrawerContent>
</Drawer>
<div className="space-y-2 px-5 pt-3">
{ordinati.map((g, i) => (
<div key={g.id} className="rounded-2xl bg-card p-3 shadow-card">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<div className="flex min-w-0 items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-secondary font-display text-base">
{i + 1}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-bold">
{g.nome}
{i === 0 ? (
<Crown className="ml-1 inline h-3.5 w-3.5 text-warning" />
) : null}
</p>
<p className="truncate text-xs text-muted-foreground">
#{g.numero} · {g.ruolo} · {g.streak} presenze consecutive
</p>
</div>
</div>
<span className="font-display text-xl tabular-nums">
{valore(g, criterio) || "—"}
</span>
</div>
<Barra
percentuale={Math.max(6, (valore(g, criterio) / max) * 100)}
altezza="h-1.5"
trackClassName="mt-2"
/>
</div>
))}
</div>
</>
),
},
{
id: "obiettivi",
label: "Obiettivi",
contenuto: (
<>
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
<div className="flex items-end justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
Progresso collettivo
</p>
<p className="font-display text-4xl leading-none">
<Numero valore={mediaObiettivi} suffisso="%" />
</p>
</div>
<p className="text-xs text-primary-foreground/70">
{completati}/{obiettivi.length} completati
</p>
</div>
<Barra
percentuale={mediaObiettivi}
trackClassName="mt-3 bg-primary-foreground/15"
/>
</div>
<div className="space-y-2">
{obiettivi.map((o, i) => {
const pct = progressoObiettivo(o);
const fatto = pct >= 100;
return (
<Reveal
key={o.id}
indice={i}
className={cn(
"premi rounded-3xl bg-card p-4 shadow-card ring-1",
fatto
? "ring-success/40"
: pct >= 90
? "ring-accent/40"
: "ring-transparent",
)}
>
<div className="flex items-start gap-2">
<span className="text-lg leading-none">{o.emoji}</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold leading-tight">{o.titolo}</p>
<p className="text-xs text-muted-foreground">{o.descrizione}</p>
</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-bold uppercase tracking-wide",
fatto
? "bg-success text-success-foreground"
: "bg-secondary text-muted-foreground",
)}
>
{fatto ? "Completato" : `${pct}%`}
</span>
</div>
<Barra percentuale={pct} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
{o.valore}/{o.target} {o.unita} · {pct}%
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
</p>
<p className="mt-1 text-xs font-semibold text-accent">
{microcopyObiettivo(o)}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
</Reveal>
);
})}
</div>
</>
),
},
{
id: "badge",
label: "Badge",
contenuto: (
<div className="space-y-2">
{badgeDefs.map((b) => {
const Icon = b.icon;
return (
<BadgeDrawer key={b.id} def={b}>
<div className="rounded-3xl bg-card p-3 shadow-card">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-accent" />
<p className="text-sm font-bold leading-tight">{b.nome}</p>
<p className="ml-auto text-xs text-muted-foreground">
{descrizioneSoglie(b)}
</p>
</div>
<div className="mt-2 grid grid-cols-3 gap-1.5">
{gradiOrdine.map((grado) => {
const meta = gradoMeta[grado];
const quanti = rosa.filter((g) => {
const raggiunto = gradoRaggiunto(b, b.valore(g));
return raggiunto
? gradiOrdine.indexOf(raggiunto) >= gradiOrdine.indexOf(grado)
: false;
}).length;
return (
<div
key={grado}
className={cn(
"rounded-2xl p-2 text-center ring-1",
meta.bg,
meta.ring,
)}
>
<p className={cn("text-xs font-bold uppercase", meta.text)}>
{meta.label}
</p>
<p className="font-display text-lg leading-none">
{b.soglie[grado]}
</p>
<p className="text-xs text-muted-foreground">
{quanti}/{rosa.length}
</p>
</div>
);
})}
</div>
</div>
</BadgeDrawer>
);
})}
</div>
),
},
{ id: "rosa", label: "Rosa", contenuto: contenutoRosa },
{ id: "stats", label: "Stats", contenuto: contenutoStats },
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi },
{ id: "badge", label: "Badge", contenuto: contenutoBadge },
]}
/>
</>
+20 -58
View File
@@ -392,55 +392,14 @@
}
}
/* Superfici traslucide: se l'utente chiede meno trasparenza diventano piene. */
@media (prefers-reduced-transparency: reduce) {
.materiale {
background-color: var(--color-card);
backdrop-filter: none;
}
}
@media (prefers-contrast: more) {
.materiale {
background-color: var(--color-card);
backdrop-filter: none;
border-color: var(--color-foreground);
}
}
/*
* Chrome flottante in stile Apple: la barra è un materiale traslucido sotto
* cui il contenuto scorre, con una sfumatura al bordo al posto della linea
* netta (scroll edge effect).
*/
/*
* Il materiale deve reggersi sul colore, non sul blur.
* Vetro della barra flottante.
*
* Con un fondo poco denso (era 72%) l'aspetto dipendeva quasi tutto da
* `backdrop-filter`, ed è che i due motori divergono: WebKit sfoca in modo
* molto più denso e "latteo" di Blink, e `saturate` amplifica lo scarto. Il
* risultato era una barra che su Safari sembrava piena e su Chrome Android
* trasparente, a parità di CSS. A 85% il colore fa il lavoro e il blur è la
* rifinitura: resta la profondità, sparisce quasi tutta la differenza.
*
* `in srgb` e non `in oklab`: mescolare con `transparent` che è nero con
* alpha 0 dipende da come il motore premoltiplica, e in srgb è implementato
* in modo molto più uniforme.
*/
@utility materiale {
background-color: color-mix(in srgb, var(--color-card) 85%, transparent);
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
}
/*
* Vetro della barra flottante. È `materiale` spinto: si vede attraverso.
*
* `materiale` sta all'85% di card perché è una superficie larga quanto lo
* schermo, dove il divario di blur fra WebKit e Blink si nota (608f2ed). Qui
* la superficie è una pillola piccola: si può scendere al 62% e lasciare che
* sia il blur a fare il lavoro, che è la condizione perché si legga come vetro
* invece che come plastica bianca.
* Su una superficie larga quanto lo schermo un fondo all'85% di card serve a
* tenere allineati WebKit e Blink, che sfocano in modo diverso (608f2ed); su
* una pillola piccola come questa lo scarto non si legge, quindi si scende al
* 62% e si lascia che sia il blur a fare il lavoro, condizione perché si
* legga come vetro invece che come plastica bianca.
*
* Le tre ombre interne sono la luce sul bordo, non decorazione: filo chiaro in
* alto (la luce che entra), filo scuro in basso (lo spessore del vetro), alone
@@ -475,6 +434,20 @@
}
}
/*
* `data-leggero` lo mette `BottomNav` quando `useMotoRidotto` rileva un device
* debole (RAM bassa) o `prefers-reduced-motion`: la catena di filtri SVG sopra
* (feTurbulence + feDisplacementMap, solo Blink/Android) è il costo maggiore
* della barra fissa ad ogni cambio di rotta, quindi qui si torna al semplice
* sfondo pieno. Specificità più alta di `.vetro` da sola: vince a prescindere
* dall'ordine nel file.
*/
.vetro[data-leggero] {
background-color: var(--color-card);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* Il vetro traslucido è un vezzo: se l'utente chiede meno trasparenza sparisce. */
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
.vetro {
@@ -490,17 +463,6 @@
}
}
/*
* Chrome su Android disattiva `backdrop-filter` quando l'accelerazione
* hardware non è disponibile: senza questo, su quei device la barra resta
* solo semitrasparente e il contenuto si legge attraverso.
*/
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.materiale {
background-color: var(--color-card);
}
}
/** Padding inferiore della chrome flottante. */
@utility pad-sicura-fondo {
padding-bottom: var(--pad-sicura-fondo);
@@ -0,0 +1,88 @@
-- M13 — Le tabelle di voto controllano anche a database chi può votare chi.
--
-- Due gap segnalati in `docs/modules/badge.md` (audit del modulo Badge), entrambi finora
-- solo filtri applicativi, aggirabili da chi scrive direttamente su PostgREST:
--
-- 1. Niente impediva di votare (o essere votati) da/per un giocatore non convocato
-- all'evento — vale per pagelle_voti, mvp_voti, badge_social_voti.
-- 2. `eventi_app.pagelle_chiuse` nascondeva solo i bottoni in UI: un voto "fuori tempo"
-- restava tecnicamente possibile.
--
-- Le policy "Ognuno gestisce i propri voti ..." di M11 vengono estese con un controllo sui
-- convocati dell'evento: `convocati` vuoto significa "tutta la rosa" (stessa convenzione di
-- `convocatiEvento()` in eventi.ts), quindi il controllo si applica solo se la lista non è
-- vuota. Le policy admin restano invariate: un amministratore può correggere un voto anche
-- per un giocatore che non risultava convocato o dopo la chiusura delle pagelle.
CREATE OR REPLACE FUNCTION public.evento_permette_voto(
p_match_id text, p_votante_id text, p_votato_id text, p_richiede_pagelle_aperte boolean
) RETURNS boolean
LANGUAGE sql STABLE AS $$
SELECT EXISTS (
SELECT 1 FROM public.eventi_app e
WHERE e.id = p_match_id
AND (cardinality(e.convocati) = 0 OR p_votante_id = ANY(e.convocati))
AND (cardinality(e.convocati) = 0 OR p_votato_id = ANY(e.convocati))
AND (NOT p_richiede_pagelle_aperte OR NOT e.pagelle_chiuse)
);
$$;
-- --- pagelle: anche convocazione e chiusura --------------------------------------------
DROP POLICY "Ognuno gestisce i propri voti pagella" ON public.pagelle_voti;
CREATE POLICY "Ognuno gestisce i propri voti pagella" ON public.pagelle_voti
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, true)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, true)
);
-- --- MVP: solo convocazione, la votazione non ha un flag di chiusura -------------------
DROP POLICY "Ognuno gestisce il proprio voto MVP" ON public.mvp_voti;
CREATE POLICY "Ognuno gestisce il proprio voto MVP" ON public.mvp_voti
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
);
-- --- badge social: solo convocazione, stesso motivo -------------------------------------
DROP POLICY "Ognuno gestisce i propri voti social" ON public.badge_social_voti;
CREATE POLICY "Ognuno gestisce i propri voti social" ON public.badge_social_voti
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
);
@@ -0,0 +1,44 @@
-- M14 — Pulizia a cascata dei dati collegati quando un evento viene cancellato
--
-- Un audit del modulo Obiettivi ha verificato che gli obiettivi in sé non hanno bisogno di
-- nessuna pulizia: sono ricalcolati a runtime sull'elenco eventi corrente (`obiettivi.ts`), e
-- un evento cancellato semplicemente sparisce da quell'elenco. Il problema è un livello sotto:
-- `useEliminaEvento()` (`src/lib/eventi.ts`) cancella solo la riga in `eventi_app`, lasciando
-- orfane le righe collegate in `risposte_presenze`, `cacche_partita`, `mvp_voti`,
-- `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live` e
-- `scout_partite` — nessuna di queste ha mai avuto una foreign key verso `eventi_app(id)`.
--
-- Una FK con ON DELETE CASCADE non è applicabile oggi: `mvp.md` documenta che storicamente
-- `match_id` in `mvp_voti`/`pagelle_voti`/`badge_social_voti` a volte conteneva l'id di una
-- sessione Scout o di una partita CSI, non l'id evento CrAPP — un vincolo FK rifiuterebbe la
-- migration alla prima riga storica disallineata. Un trigger non valida i dati esistenti,
-- solo le cancellazioni da qui in avanti, quindi è applicabile senza bonificare prima lo
-- storico (bonifica che resta un lavoro separato, se mai servirà).
--
-- SECURITY DEFINER: le policy DELETE di alcune tabelle collegate potrebbero in futuro
-- restringersi (oggi sono tutte aperte, DD-023); il trigger deve continuare a pulire a
-- prescindere da chi ha eseguito la DELETE su eventi_app.
CREATE OR REPLACE FUNCTION public.pulisci_dati_evento_cancellato()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
DELETE FROM public.risposte_presenze WHERE evento_id = OLD.id;
DELETE FROM public.cacche_partita WHERE evento_id = OLD.id;
DELETE FROM public.mvp_voti WHERE match_id = OLD.id;
DELETE FROM public.pagelle_voti WHERE match_id = OLD.id;
DELETE FROM public.badge_social_voti WHERE match_id = OLD.id;
DELETE FROM public.turni_palloni WHERE evento_id = OLD.id;
DELETE FROM public.scout_sessioni WHERE evento_id = OLD.id;
DELETE FROM public.scout_live WHERE evento_id = OLD.id;
DELETE FROM public.scout_partite WHERE evento_id = OLD.id;
RETURN OLD;
END;
$$;
CREATE TRIGGER eventi_app_pulisci_dati_collegati
AFTER DELETE ON public.eventi_app
FOR EACH ROW EXECUTE FUNCTION public.pulisci_dati_evento_cancellato();
@@ -0,0 +1,55 @@
-- M15 — Bonifica delle righe orfane lasciate da eventi cancellati prima di M14
--
-- M14 ha aggiunto un trigger che pulisce a cascata i dati collegati quando un evento viene
-- cancellato (DD-029), ma agisce solo sulle cancellazioni da quel momento in avanti. Questa
-- migration ripulisce una tantum le righe orfane lasciate da cancellazioni PRECEDENTI a M14.
--
-- Per `risposte_presenze`, `cacche_partita`, `turni_palloni`, `scout_sessioni`, `scout_live` e
-- `scout_partite`, `evento_id` ha sempre e solo indicato un id evento CrAPP (mai un altro
-- schema): qualsiasi riga il cui `evento_id` non esiste più in `eventi_app` è, senza ambiguità,
-- un orfano da una cancellazione passata (per `scout_partite`, `evento_id` può anche essere
-- legittimamente NULL — una partita scoutata mai collegata a un evento — e quelle righe non
-- vengono toccate).
--
-- `mvp_voti`, `pagelle_voti` e `badge_social_voti` sono diverse: PRIMA che `match_id`
-- diventasse l'id evento CrAPP, contenevano l'id di una sessione Scout (formato `s` + timestamp
-- in base 10, es. "s1717426810123") o la chiave di un referto CSI (id numerico del portale, o
-- fallback "data-squadra-squadra"). Quei voti sono dati storici legittimi, mai stati collegati
-- a un evento CrAPP: docs/modules/mvp.md li descrive come "non più letti da nessuna schermata",
-- non come dati da eliminare. Cancellarli qui sarebbe un bug, non una bonifica.
--
-- Il filtro `match_id ~ '^e[0-9a-z]+$'` isola solo i match_id nel formato di
-- `nuovoIdEvento()` (`"e" + Date.now().toString(36)`, src/lib/eventi.ts): nessun id Scout (che
-- inizia per "s") o CSI (numerico o con trattini) può rientrarci, quindi solo i veri orfani da
-- evento CrAPP cancellato vengono rimossi, mai un voto storico su id scout/CSI.
DELETE FROM public.risposte_presenze
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.cacche_partita
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.turni_palloni
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.scout_sessioni
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.scout_live
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.scout_partite
WHERE evento_id IS NOT NULL
AND evento_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.mvp_voti
WHERE match_id ~ '^e[0-9a-z]+$'
AND match_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.pagelle_voti
WHERE match_id ~ '^e[0-9a-z]+$'
AND match_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.badge_social_voti
WHERE match_id ~ '^e[0-9a-z]+$'
AND match_id NOT IN (SELECT id FROM public.eventi_app);
@@ -0,0 +1,61 @@
-- M16 — La bonifica di M15 diventa una funzione richiamabile e testabile
--
-- M15 ha ripulito una tantum le righe orfane lasciate da eventi cancellati prima di M14
-- (DD-029), con un blocco di DELETE eseguito una sola volta: non restava nulla da richiamare
-- né da testare in automatico, solo una verifica manuale fatta prima di applicarla.
--
-- Qui lo stesso corpo diventa la funzione `bonifica_dati_evento_orfani()`: se in futuro il
-- trigger di M14 venisse per errore rimosso o disattivato, o emergesse un altro batch di
-- orfani per un motivo imprevisto, si può rilanciare `select bonifica_dati_evento_orfani();`
-- invece di riscrivere da capo la stessa query delicata — e la sua logica resta coperta da
-- un test di integrazione (`test/integration/bonifica-evento.test.ts`) invece che verificata
-- a mano una tantum.
--
-- Riservata al service role: non è un'azione che un giocatore o un admin devono poter
-- richiamare dall'app, solo un intervento di manutenzione database.
CREATE OR REPLACE FUNCTION public.bonifica_dati_evento_orfani()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
DELETE FROM public.risposte_presenze
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.cacche_partita
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.turni_palloni
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.scout_sessioni
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.scout_live
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.scout_partite
WHERE evento_id IS NOT NULL
AND evento_id NOT IN (SELECT id FROM public.eventi_app);
-- Solo i match_id nel formato di nuovoIdEvento() ("e" + timestamp base36): i vecchi voti
-- storici su id Scout ("s" + timestamp) o CSI (numerico o "data-squadra-squadra") non
-- rientrano nel filtro e restano intatti (vedi M15 per il dettaglio).
DELETE FROM public.mvp_voti
WHERE match_id ~ '^e[0-9a-z]+$'
AND match_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.pagelle_voti
WHERE match_id ~ '^e[0-9a-z]+$'
AND match_id NOT IN (SELECT id FROM public.eventi_app);
DELETE FROM public.badge_social_voti
WHERE match_id ~ '^e[0-9a-z]+$'
AND match_id NOT IN (SELECT id FROM public.eventi_app);
END;
$$;
REVOKE ALL ON FUNCTION public.bonifica_dati_evento_orfani() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.bonifica_dati_evento_orfani() TO service_role;
+9 -1
View File
@@ -26,7 +26,7 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
| Cartella | Cosa verifica | Serve rete? |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route) | No |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`) e le letture lato server delle route push (`lettori-server`) sul database locale | Sì |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) e la funzione di bonifica delle righe orfane (`bonifica-evento`, M16) sul database locale | Sì |
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
@@ -46,6 +46,8 @@ bun test/integration/permessi.test.ts # permessi per ruolo sulle tabelle
bun test/integration/permessi-route.test.ts # chi può far partire le notifiche
bun test/integration/scritture.test.ts # semantica degli upsert e vincoli
bun test/integration/lettori-server.test.ts # le letture server delle route push
bun test/integration/pulizia-evento.test.ts # cascata alla cancellazione di un evento (M14)
bun test/integration/bonifica-evento.test.ts # funzione di bonifica righe orfane (M16)
```
`permessi-route` avvia il server di sviluppo **puntato al database locale** invece che al
@@ -102,6 +104,12 @@ Due cose scoperte scrivendo questi test, utili a chi ne aggiunge:
I test che richiedono `SUPABASE_SERVICE_ROLE_KEY` si saltano da soli se manca.
- Il check del parsing CSI può girare contro il portale reale:
`CSI_LIVE=1 bun test/unit/csi-core.test.ts`.
- I 5 test di `integration/api.test.ts` che leggono il CSI reale (classifica, partite,
cache, obiettivi o3/o4/o5) sondano `/api/public/csi` prima di partire: se il portale non
risponde (in manutenzione lato loro, vedi
[docs/modules/collegamento-csi.md](../docs/modules/collegamento-csi.md) § Limiti noti) si
saltano da soli invece di far fallire la suite, e tornano a girare da soli quando il
portale risponde di nuovo.
## Limite noto
+15 -1
View File
@@ -70,11 +70,25 @@ try {
const html = await pagina("/classifica");
assert.match(titolo(html), /Classifica campionato/);
assert.doesNotMatch(html, /CSI Milano/, "nessun residuo dei dati demo nel guscio");
// Una gara giocata deve avere un dettaglio "solo CSI" raggiungibile, con il suo
// endpoint per-partita servito (formazioni/precedenti): vedi partita-csi.$id.tsx.
const giocata = csi.partite.find((p) => p.setNostri !== null);
if (giocata) {
const htmlPartita = await pagina(`/partita-csi/${giocata.id}`);
assert.match(titolo(htmlPartita), /Dettaglio partita/);
const dettaglio = await fetch(url(`/api/public/csi-partita/${giocata.id}`));
assert.equal(dettaglio.status, 200, "l'endpoint per-partita risponde");
}
});
// --- 4. Dettaglio di un evento --------------------------------------------
await prova("le pagine di dettaglio reggono un id inesistente", async () => {
for (const percorso of ["/partita/non-esiste", "/allenamento/non-esiste"]) {
for (const percorso of [
"/partita/non-esiste",
"/allenamento/non-esiste",
"/partita-csi/non-esiste",
]) {
const res = await fetch(url(percorso));
assert.ok(res.status < 500, `${percorso}: nessun errore server (era ${res.status})`);
}
+103 -5
View File
@@ -4,7 +4,14 @@
* Nessun test scrive sul database: solo letture e validazioni.
*/
import assert from "node:assert/strict";
import { isNostraSquadra, type DatiCsi } from "@/lib/csi-core";
import { giocatori } from "@/lib/crapp-data";
import {
isNostraSquadra,
partiteGiocate,
type DatiCsi,
type DettaglioPartitaCsi,
} from "@/lib/csi-core";
import { obiettiviSquadra } from "@/lib/obiettivi";
import { avviaServer, haSupabase, json } from "../helpers/server";
import { prova, riepilogo, salta } from "../helpers/prova";
@@ -23,18 +30,38 @@ try {
// --- GET /api/public/csi ---------------------------------------------------
let csi: DatiCsi | undefined;
await prova("GET /api/public/csi restituisce classifica e partite", async () => {
// Il portale CSI (livescore.csibologna.it) è a volte in manutenzione lato loro (visto un
// errore SQL in chiaro al posto del JSON, non un problema nostro): sonda una volta sola
// prima di questi 5 test, così un "non raggiungibile" temporaneo si vede come saltato,
// non come fallito — la suite resta verde senza nascondere il motivo, e senza cancellare
// la copertura per quando il portale torna su.
const sondaCsi = await fetch(url("/api/public/csi"));
const csiRaggiungibile = sondaCsi.ok;
if (!csiRaggiungibile) {
console.log(
` · CSI non raggiungibile (${sondaCsi.status}): portale probabilmente in manutenzione`,
);
}
const provaCsi = (nome: string, fn: () => Promise<void> | void) =>
csiRaggiungibile
? prova(nome, fn)
: Promise.resolve(
salta(nome, "portale CSI non raggiungibile (probabile manutenzione lato loro)"),
);
await provaCsi("GET /api/public/csi restituisce classifica e partite", async () => {
const res = await fetch(url("/api/public/csi"));
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /application\/json/);
csi = (await json(res)) as DatiCsi;
assert.ok(Array.isArray(csi.classifica) && csi.classifica.length > 0);
assert.ok(Array.isArray(csi.classificaCoppa));
assert.ok(Array.isArray(csi.partite) && csi.partite.length > 0);
assert.equal(csi.girone, "Girone B");
assert.ok(!Number.isNaN(Date.parse(csi.aggiornato)), "timestamp valido");
});
await prova("la classifica contiene la nostra squadra con dati coerenti", () => {
await provaCsi("la classifica contiene la nostra squadra con dati coerenti", () => {
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
assert.ok(noi, "C.R.A.P. Volley presente in classifica");
assert.equal(noi.vinte + noi.perse, noi.giocate, "vinte + perse = giocate");
@@ -47,7 +74,13 @@ try {
);
});
await prova("ogni partita è coerente con il formato dell'app", () => {
await provaCsi("la classifica di Coppa, se presente, contiene la nostra squadra", () => {
if ((csi?.classificaCoppa.length ?? 0) === 0) return; // fase a gironi finita, pagina cambiata
const noi = csi?.classificaCoppa.find((r) => isNostraSquadra(r.squadra));
assert.ok(noi, "C.R.A.P. Volley presente nel girone di Coppa");
});
await provaCsi("ogni partita è coerente con il formato dell'app", () => {
for (const p of csi?.partite ?? []) {
assert.match(p.data, /^\d{4}-\d{2}-\d{2}$/, `${p.id}: data ISO`);
assert.ok(p.avversario.length > 0, `${p.id}: avversario valorizzato`);
@@ -58,13 +91,78 @@ try {
}
});
await prova("la seconda chiamata arriva dalla cache del server", async () => {
await provaCsi(
"le vittorie in campionato lette dal CSI reale alimentano o3/o4/o5 in modo coerente",
() => {
// Stessa logica di useObiettivi() in src/lib/rosa.ts: partite giocate e vinte.
const vittorieReali = partiteGiocate(csi?.partite ?? []).filter(
(p) => (p.setNostri ?? 0) > (p.setLoro ?? 0),
).length;
const obiettivi = obiettiviSquadra(giocatori, {
eventi: [],
presenze: {},
pagelle: [],
vittorie: vittorieReali,
});
const o3 = obiettivi.find((o) => o.id === "o3")!;
const o4 = obiettivi.find((o) => o.id === "o4")!;
const o5 = obiettivi.find((o) => o.id === "o5")!;
assert.deepEqual([o3.target, o4.target, o5.target], [1, 5, 10], "target fissi 1/5/10");
assert.equal(o3.valore, Math.min(vittorieReali, 1), "prima vittoria, cappata a 1");
assert.equal(o4.valore, Math.min(vittorieReali, 5), "5 vittorie, cappata a 5");
assert.equal(o5.valore, Math.min(vittorieReali, 10), "10 vittorie, cappata a 10");
assert.ok(o3.valore <= o4.valore && o4.valore <= o5.valore, "progressione o3 ≤ o4 ≤ o5");
},
);
await provaCsi("la seconda chiamata arriva dalla cache del server", async () => {
const t0 = Date.now();
const secondo = (await json(await fetch(url("/api/public/csi")))) as DatiCsi;
assert.equal(secondo.aggiornato, csi?.aggiornato, "stesso timestamp: nessuna nuova fetch");
assert.ok(Date.now() - t0 < 2_000, "risposta immediata");
});
await provaCsi(
"GET /api/public/csi-partita/$id restituisce formazioni e precedenti",
async () => {
const primaGiocata = partiteGiocate(csi?.partite ?? [])[0];
assert.ok(primaGiocata, "serve almeno una gara giocata per testare il dettaglio");
const res = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
assert.equal(res.status, 200);
const dettaglio = (await json(res)) as DettaglioPartitaCsi;
// formazioni/precedenti possono essere null se il portale non li pubblica per questa
// gara (es. referto non compilato): qui si verifica solo che, quando presenti, siano
// coerenti — non che ci siano sempre, vedi parseFormazioni()/parsePrecedenti().
if (dettaglio.formazioni) {
assert.ok(isNostraSquadra(dettaglio.formazioni.noi.squadra));
assert.ok(dettaglio.formazioni.noi.titolari.length > 0, "almeno un titolare");
}
if (dettaglio.precedenti) {
assert.ok(dettaglio.precedenti.totale >= 0);
}
const t0 = Date.now();
const secondo = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
assert.equal(secondo.status, 200);
assert.ok(Date.now() - t0 < 2_000, "seconda chiamata dalla cache: risposta immediata");
},
);
await provaCsi(
"GET /api/public/csi-partita/$id con un id davvero inesistente risponde 503, non rompe",
async () => {
// Un match_id inventato fa rispondere 500 a match-stats.php lato CSI (verificato a
// mano con curl): non è "gara senza formazioni ancora pubblicate" (quello risponde
// 200 con markup vuoto, gestito da parseFormazioni()/parsePrecedenti() → null), è un
// vero errore a monte. La route lo tratta come /api/public/csi tratta un CSI giù:
// 503 pulito, non un crash o un 200 con dati inventati.
const res = await fetch(url("/api/public/csi-partita/999999999"));
assert.equal(res.status, 503);
},
);
// --- GET /api/public/push-config -------------------------------------------
await prova("GET /api/public/push-config espone solo la chiave pubblica", async () => {
const res = await fetch(url("/api/public/push-config"));
+161
View File
@@ -0,0 +1,161 @@
/**
* Badge social (5 categorie) end-to-end contro il database locale:
* `bun test/integration/badge-social.test.ts`.
*
* I test unitari (`test/unit/badge-social.test.ts`) verificano `conteggioCategoria()`,
* `vincitoreCategoria()` e `badgeSocialVinti()` come funzioni pure, con categorie inventate
* ("sorriso", "urlo" in `scritture.test.ts`) o con solo 2-3 delle 5 reali. Qui invece si
* scrivono voti veri su `badge_social_voti` con le 5 categorie effettive di `categorieSocial`
* (`affidabile`, `spirito`, `fairplay`, `meme`, `cuore`), si rileggono via REST con la stessa
* selezione di `useVotiSocial()`, e si passa il risultato attraverso `badgeSocialVinti()`: se
* una colonna cambia nome o un id di categoria diverge da quello scritto dall'app, qui si vede,
* perché non c'è nessun vincolo CHECK a database sulla colonna `categoria` (vedi
* `docs/modules/badge.md` § Problemi noti) l'unica difesa è l'app che manda sempre uno dei 5
* id validi, e questo test lo dimostra con dati reali su tutte e 5, non solo su un paio.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-badge-social`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import {
badgeSocialVinti,
categorieSocial,
vincitoreCategoria,
type VotoSocial,
} from "@/lib/badge-social";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge social sul database", "stack locale non attivo (npx supabase start)");
riepilogo("badge-social");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge social su ${URL_BASE}`);
const PREFISSO = "test-badge-social";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function vota(voto: VotoSocial) {
const res = await rest("badge_social_voti?on_conflict=match_id,categoria,votante_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify(voto),
});
if (!res.ok) throw new Error(`upsert su badge_social_voti: ${res.status} ${await res.text()}`);
}
async function leggiVoti(): Promise<VotoSocial[]> {
const res = await rest(
`badge_social_voti?match_id=like.${PREFISSO}-*&select=match_id,categoria,votante_id,votato_id,votato_nome`,
);
return (await res.json()) as VotoSocial[];
}
try {
await prova(
"tutte e 5 le categorie reali si contano e si vincono indipendentemente",
async () => {
// "bs1" vince nettamente tutte e 5 le categorie reali nella partita m1 (2 voti contro
// 1 ciascuna): dimostra che l'id di categoria non è solo una stringa di comodo nei
// test unitari, ma funziona identico per tutte e 5 quelle vere dell'app.
for (const cat of categorieSocial) {
await vota({
match_id: `${PREFISSO}-m1`,
categoria: cat.id,
votante_id: "va",
votato_id: "bs1",
votato_nome: "Uno",
});
await vota({
match_id: `${PREFISSO}-m1`,
categoria: cat.id,
votante_id: "vb",
votato_id: "bs1",
votato_nome: "Uno",
});
await vota({
match_id: `${PREFISSO}-m1`,
categoria: cat.id,
votante_id: "vc",
votato_id: "bs2",
votato_nome: "Due",
});
}
const voti = await leggiVoti();
assert.equal(voti.length, categorieSocial.length * 3, "tutti i voti scritti si rileggono");
for (const cat of categorieSocial) {
const vincitore = vincitoreCategoria(voti, `${PREFISSO}-m1`, cat.id);
assert.equal(vincitore?.id, "bs1", `bs1 vince "${cat.id}" con vantaggio netto`);
}
const vinti = badgeSocialVinti(voti, "bs1");
assert.deepEqual(
vinti,
Object.fromEntries(categorieSocial.map((c) => [c.id, 1])),
"una vittoria per ciascuna delle 5 categorie reali, nessuna persa per strada",
);
assert.deepEqual(badgeSocialVinti(voti, "bs2"), {}, "bs2 non vince mai nettamente");
},
);
await prova("una parità su una categoria reale non assegna il badge, le altre sì", async () => {
// Nella partita m2, "affidabile" finisce in parità (nessun vincitore), le altre 4 le
// vince ancora "bs1": la parità deve bloccare solo la categoria coinvolta.
await vota({
match_id: `${PREFISSO}-m2`,
categoria: "affidabile",
votante_id: "va",
votato_id: "bs1",
votato_nome: "Uno",
});
await vota({
match_id: `${PREFISSO}-m2`,
categoria: "affidabile",
votante_id: "vb",
votato_id: "bs2",
votato_nome: "Due",
});
for (const cat of categorieSocial.filter((c) => c.id !== "affidabile")) {
await vota({
match_id: `${PREFISSO}-m2`,
categoria: cat.id,
votante_id: "va",
votato_id: "bs1",
votato_nome: "Uno",
});
}
const voti = await leggiVoti();
assert.equal(
vincitoreCategoria(voti, `${PREFISSO}-m2`, "affidabile"),
null,
"1 voto contro 1: parità, nessun vincitore",
);
const vintiTotali = badgeSocialVinti(voti, "bs1");
// m1 (tutte e 5) + m2 (le 4 non in parità): "affidabile" resta a 1 (solo m1), le altre 4 a 2.
assert.equal(vintiTotali["affidabile"], 1, "la parità in m2 non aggiunge una vittoria");
for (const cat of categorieSocial.filter((c) => c.id !== "affidabile")) {
assert.equal(vintiTotali[cat.id], 2, `"${cat.id}" vinta sia in m1 sia in m2`);
}
});
} finally {
await rest(`badge_social_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" });
}
riepilogo("badge-social");
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Funzione `bonifica_dati_evento_orfani()` (M16): `bun test/integration/bonifica-evento.test.ts`.
*
* M15 ha ripulito una tantum le righe orfane lasciate da eventi cancellati prima del trigger
* di M14 (DD-029); M16 ha reso permanente la stessa logica come funzione RPC, così resta
* richiamabile e testabile invece che verificata a mano una volta sola.
*
* Copre tutte e nove le istruzioni dentro la funzione, non solo un sottoinsieme: le sei
* tabelle che usano `evento_id` (`risposte_presenze`, `cacche_partita`, `turni_palloni`,
* `scout_sessioni`, `scout_live`, `scout_partite`) e le tre che usano `match_id`
* (`mvp_voti`, `pagelle_voti`, `badge_social_voti`).
*
* Il punto delicato è sulle tre tabelle `match_id`: la funzione deve cancellare solo i
* `match_id` nel formato id evento CrAPP ("e" + timestamp base36) senza corrispondenza in
* `eventi_app` mai i vecchi voti storici su id Scout ("s" + timestamp) o CSI (numerico),
* che sono dati legittimi mai collegati a un evento CrAPP (`docs/modules/mvp.md`). Questo
* test verifica la distinzione su tutte e tre, non solo su due delle tre.
*
* Gira solo sullo stack locale (`npx supabase start`): usa id con il prefisso
* `test-bonifica-evento`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("bonifica dati evento orfani", "stack locale non attivo (npx supabase start)");
riepilogo("bonifica-evento");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`bonifica dati evento orfani su ${URL_BASE}`);
const PREFISSO = "test-bonifica-evento";
// Deve rispettare ^e[0-9a-z]+$ (formato di nuovoIdEvento()): un id con trattini non
// verrebbe mai filtrato dalla funzione, quindi non testerebbe la regola che conta.
const ORFANO = `etestbonificaevento${Date.now().toString(36)}`;
const STORICO_SCOUT = `s${Date.now()}`; // formato id Scout storico: va preservato
const STORICO_CSI = `${Date.now()}`; // formato id CSI storico (numerico): va preservato
const TABELLE_EVENTO_ID = [
"risposte_presenze",
"cacche_partita",
"turni_palloni",
"scout_sessioni",
"scout_live",
"scout_partite",
];
const TABELLE_MATCH_ID = ["mvp_voti", "pagelle_voti", "badge_social_voti"];
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
Prefer: "return=minimal",
...(init?.headers ?? {}),
},
});
async function inserisci(tabella: string, riga: Record<string, unknown>) {
const res = await rest(tabella, { method: "POST", body: JSON.stringify(riga) });
if (!res.ok) throw new Error(`insert su ${tabella}: ${res.status} ${await res.text()}`);
}
async function esiste(tabella: string, filtro: string): Promise<boolean> {
const res = await rest(`${tabella}?${filtro}&select=*`, {
headers: { Prefer: "count=exact" },
});
return Number(res.headers.get("content-range")?.split("/")[1] ?? 0) > 0;
}
async function pulisci() {
for (const t of TABELLE_EVENTO_ID) {
await rest(`${t}?evento_id=eq.${ORFANO}`, { method: "DELETE" });
}
for (const t of TABELLE_MATCH_ID) {
await rest(`${t}?match_id=eq.${ORFANO}`, { method: "DELETE" });
await rest(`${t}?match_id=eq.${STORICO_SCOUT}`, { method: "DELETE" });
await rest(`${t}?match_id=eq.${STORICO_CSI}`, { method: "DELETE" });
}
}
try {
await prova(
"bonifica_dati_evento_orfani() rimuove solo gli orfani veri, non lo storico Scout/CSI",
async () => {
// Una riga orfana per ciascuna delle sei tabelle evento_id.
await inserisci("risposte_presenze", {
evento_id: ORFANO,
giocatore_id: `${PREFISSO}-g1`,
stato: "presente",
});
await inserisci("cacche_partita", {
evento_id: ORFANO,
giocatore_id: `${PREFISSO}-g1`,
quantita: 1,
});
await inserisci("turni_palloni", { evento_id: ORFANO, giocatore_id: `${PREFISSO}-g1` });
await inserisci("scout_sessioni", {
evento_id: ORFANO,
giocatore_id: `${PREFISSO}-g1`,
giocatore_nome: "Uno",
});
await inserisci("scout_live", { evento_id: ORFANO, stato: {} });
await inserisci("scout_partite", {
id: `${ORFANO}-scout`,
evento_id: ORFANO,
data: "2026-09-01",
avversario: "Test",
set_nostri: 3,
set_loro: 0,
});
// Una riga orfana + due storiche (Scout, CSI) per ciascuna delle tre tabelle match_id.
for (const t of TABELLE_MATCH_ID) {
const base = { votante_id: `${PREFISSO}-va`, votato_id: `${PREFISSO}-vb` };
const extra =
t === "pagelle_voti"
? { voto: 7 }
: t === "badge_social_voti"
? { categoria: "top", votato_nome: "Test" }
: { votato_nome: "Test" };
await inserisci(t, { match_id: ORFANO, ...base, ...extra });
await inserisci(t, { match_id: STORICO_SCOUT, ...base, ...extra });
await inserisci(t, { match_id: STORICO_CSI, ...base, ...extra });
}
const res = await fetch(`${URL_BASE}/rest/v1/rpc/bonifica_dati_evento_orfani`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: "{}",
});
if (!res.ok)
throw new Error(`rpc bonifica_dati_evento_orfani: ${res.status} ${await res.text()}`);
for (const t of TABELLE_EVENTO_ID) {
assert.equal(await esiste(t, `evento_id=eq.${ORFANO}`), false, `${t}: orfano rimosso`);
}
for (const t of TABELLE_MATCH_ID) {
assert.equal(await esiste(t, `match_id=eq.${ORFANO}`), false, `${t}: orfano rimosso`);
assert.equal(
await esiste(t, `match_id=eq.${STORICO_SCOUT}`),
true,
`${t}: storico Scout preservato`,
);
assert.equal(
await esiste(t, `match_id=eq.${STORICO_CSI}`),
true,
`${t}: storico CSI preservato`,
);
}
},
);
} finally {
await pulisci();
}
riepilogo("bonifica-evento");
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Badge MVP end-to-end contro il database locale: `bun test/integration/mvp-badge.test.ts`.
*
* I test unitari (`test/unit/mvp-voti.test.ts`, `test/unit/badges.test.ts`) verificano
* `mvpVintiPerGiocatore()` e `statoBadge()` come funzioni pure, con voti costruiti a mano. Qui
* invece si scrivono voti veri su `mvp_voti`, si rileggono via REST con la stessa selezione di
* `useVotiMvp()`, e si passa il risultato attraverso `mvpVintiPerGiocatore()` fino a
* `statoBadge()` sul badge `mvp`: se una colonna cambia nome o la mappatura si rompe, qui il
* grado del badge torna sbagliato anche se i test unitari restano verdi, perché quelli non
* toccano mai il database.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-mvp-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges";
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge MVP sul database", "stack locale non attivo (npx supabase start)");
riepilogo("mvp-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge MVP su ${URL_BASE}`);
const PREFISSO = "test-mvp-badge";
const mvpDef = badgeDefs.find((b) => b.id === "mvp")!;
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function upsert(riga: VotoMvp) {
const res = await rest("mvp_voti?on_conflict=match_id,votante_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify(riga),
});
if (!res.ok) throw new Error(`upsert su mvp_voti: ${res.status} ${await res.text()}`);
}
/** Rilegge esattamente come `useVotiMvp()`. */
async function leggiVoti(): Promise<VotoMvp[]> {
const res = await rest(
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`,
);
return (await res.json()) as VotoMvp[];
}
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
function giocatoreAzzerato(mvp: number): Giocatore {
return { ...giocatori[0]!, mvp, mediaVoto: 0, palloni: 0, presenze: 0 };
}
try {
await prova(
"il badge MVP passa da bronzo ad argento con vittorie vere lette dal database",
async () => {
// Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), pareggia in
// m3 (nessun vincitore) e perde in m4 (0 voti): dopo m1+m2 ha 2 vittorie -> bronzo,
// non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge.
await upsert({
match_id: `${PREFISSO}-m1`,
votante_id: "va",
votato_id: "vg1",
votato_nome: "Uno",
});
await upsert({
match_id: `${PREFISSO}-m1`,
votante_id: "vb",
votato_id: "vg1",
votato_nome: "Uno",
});
await upsert({
match_id: `${PREFISSO}-m1`,
votante_id: "vc",
votato_id: "vg2",
votato_nome: "Due",
});
await upsert({
match_id: `${PREFISSO}-m2`,
votante_id: "va",
votato_id: "vg1",
votato_nome: "Uno",
});
await upsert({
match_id: `${PREFISSO}-m2`,
votante_id: "vb",
votato_id: "vg1",
votato_nome: "Uno",
});
await upsert({
match_id: `${PREFISSO}-m3`,
votante_id: "va",
votato_id: "vg1",
votato_nome: "Uno",
});
await upsert({
match_id: `${PREFISSO}-m3`,
votante_id: "vb",
votato_id: "vg2",
votato_nome: "Due",
});
const voti = await leggiVoti();
const vinti = mvpVintiPerGiocatore(voti);
assert.equal(vinti["vg1"], 2, "vg1 vince m1 e m2, pareggia in m3 (non conta)");
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai nettamente");
const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0));
assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta");
assert.equal(badgeVg1.prossimo, "argento");
assert.equal(badgeVg1.prossimaSoglia, 3, "manca 1 vittoria all'argento");
const badgeVg2 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg2"] ?? 0));
assert.equal(badgeVg2.grado, null, "zero vittorie nette: nessun badge sbloccato");
// Una terza vittoria netta porta vg1 da bronzo ad argento (soglia 3). Due voti,
// non uno solo: sotto il quorum minimo (VOTI_MINIMI_MVP) nessuna vittoria conterebbe.
await upsert({
match_id: `${PREFISSO}-m4`,
votante_id: "va",
votato_id: "vg1",
votato_nome: "Uno",
});
await upsert({
match_id: `${PREFISSO}-m4`,
votante_id: "vb",
votato_id: "vg1",
votato_nome: "Uno",
});
const vintiDopo = mvpVintiPerGiocatore(await leggiVoti());
assert.equal(vintiDopo["vg1"], 3);
assert.equal(
statoBadge(mvpDef, giocatoreAzzerato(vintiDopo["vg1"]!)).grado,
"argento",
"3 vittorie: soglia argento raggiunta",
);
},
);
} finally {
await rest(`mvp_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" });
}
riepilogo("mvp-badge");
}
@@ -0,0 +1,106 @@
/**
* Chi può leggere l'elenco dei giocatori con notifiche push attive:
* `bun test/integration/notifiche-attive-route.test.ts`.
*
* `/api/public/notifiche-attive` legge `push_subscriptions` con la service role e
* salta la RLS (come le route che mandano notifiche, DD-024): il permesso deve stare
* nella route. Serve un database vero per provare token di un giocatore normale e di
* un amministratore, quindi solo stack locale.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { avviaServer, json } from "../helpers/server";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("notifiche attive: permessi route", "stack locale non attivo (npx supabase start)");
riepilogo("notifiche-attive-route");
} else {
const { url: SUPABASE, anon: ANON, servizio: SERVIZIO } = locale;
// Il server di sviluppo eredita queste: le route leggono i nomi senza prefisso.
process.env["SUPABASE_URL"] = SUPABASE;
process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
const PASSWORD = "prova-notifiche-123";
const idUtenti: string[] = [];
const authAdmin = { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` };
async function creaUtente(email: string): Promise<string> {
const res = await fetch(`${SUPABASE}/auth/v1/admin/users`, {
method: "POST",
headers: { ...authAdmin, "content-type": "application/json" },
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
idUtenti.push(corpo.id);
return corpo.id;
}
async function accedi(email: string): Promise<string> {
const res = await fetch(`${SUPABASE}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON, "content-type": "application/json" },
body: JSON.stringify({ email, password: PASSWORD }),
});
const corpo = (await res.json()) as { access_token?: string };
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
return corpo.access_token;
}
const emailGiocatore = `test-notifiche-giocatore-${Date.now()}@example.test`;
const emailAdmin = `test-notifiche-admin-${Date.now()}@example.test`;
await creaUtente(emailGiocatore);
const idAdmin = await creaUtente(emailAdmin);
await fetch(`${SUPABASE}/rest/v1/user_roles`, {
method: "POST",
headers: { ...authAdmin, "content-type": "application/json" },
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
});
const tokenGiocatore = await accedi(emailGiocatore);
const tokenAdmin = await accedi(emailAdmin);
const server = await avviaServer();
console.log(`notifiche-attive-route su ${server.baseUrl} (database ${SUPABASE})`);
const PERCORSO = "/api/public/notifiche-attive";
const chiama = (intestazioni: Record<string, string> = {}) =>
fetch(`${server.baseUrl}${PERCORSO}`, { headers: intestazioni });
try {
await prova("senza token la route non risponde", async () => {
assert.equal((await chiama()).status, 401);
});
await prova("un giocatore autenticato non è admin", async () => {
const res = await chiama({ authorization: `Bearer ${tokenGiocatore}` });
assert.equal(res.status, 403);
});
await prova("un amministratore riceve l'elenco degli id", async () => {
const res = await chiama({ authorization: `Bearer ${tokenAdmin}` });
assert.equal(res.status, 200);
const corpo = (await json(res)) as { giocatoreIds: unknown };
assert.ok(Array.isArray(corpo.giocatoreIds), "giocatoreIds è un array");
});
} finally {
server.stop();
for (const id of idUtenti) {
await fetch(`${SUPABASE}/rest/v1/user_roles?user_id=eq.${id}`, {
method: "DELETE",
headers: authAdmin,
});
await fetch(`${SUPABASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: authAdmin,
});
}
riepilogo("notifiche-attive-route");
}
}
+568
View File
@@ -0,0 +1,568 @@
/**
* Obiettivi di squadra end-to-end contro il database locale: `bun test/integration/obiettivi.test.ts`.
* Copre gli obiettivi con scadenza/mese dinamici ("presenze del mese", "evento di squadra al
* mese", "tutti rispondono alle convocazioni") e quelli la cui logica dipende da dati scritti
* su altre tabelle ("250 presenze complessive" via `contaPresenzeGiocatore()`, "media pagelle
* da 7.5" e "200 pagelle compilate" via `pagelle_voti`).
*
* I test unitari (`test/unit/obiettivi.test.ts`) verificano `obiettiviSquadra()` come funzione
* pura, con un `ContestoObiettivi` costruito a mano. Qui invece si scrivono righe vere su
* `eventi_app` e `risposte_presenze`, si rileggono con `leggiEventi()` (la stessa funzione che
* usa l'app lato server) e una query REST equivalente a `fetchPresenze()`, e si verifica che il
* risultato che arriva a `obiettiviSquadra()` sia quello atteso: se una colonna cambia nome o
* la mappatura si rompe, qui il valore torna sbagliato (o NaN) anche se i test unitari restano
* verdi, perché quelli non toccano mai il database.
*
* Il mese di test è marzo 2099: non collide con gli eventi del seed (tutti in agosto 2026), così
* questi due obiettivi che ora aggregano su TUTTI gli eventi del mese corrente, non solo
* quelli del test restano prevedibili senza dover filtrare gli eventi letti dal database.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-obiettivi`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { giocatori } from "@/lib/crapp-data";
import { obiettiviSquadra } from "@/lib/obiettivi";
import { contaPresenzeGiocatore, serieConsecutiva, type MappaPresenze } from "@/lib/presenze";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("obiettivi (o1) sul database", "stack locale non attivo (npx supabase start)");
riepilogo("obiettivi");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`obiettivi su ${URL_BASE}`);
// Prima di qualsiasi import dei moduli server: è da qui che nasce `supabaseAdmin`.
process.env["SUPABASE_URL"] = URL_BASE;
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
const { leggiEventi } = await import("@/lib/eventi.server");
const PREFISSO = "test-obiettivi";
const MESE_TEST = "2099-03";
const OGGI = new Date("2099-03-15T10:00:00Z");
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
/** Stessa query di `fetchPresenze()` (`src/lib/presenze.ts`), raggruppata a mano. */
async function leggiPresenze(eventoId: string): Promise<MappaPresenze> {
const res = await rest(
`risposte_presenze?evento_id=eq.${eventoId}&select=evento_id,giocatore_id,stato`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: string;
}>;
const mappa: MappaPresenze = {};
for (const r of righe) {
(mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato as never;
}
return mappa;
}
try {
const eventoId = `${PREFISSO}-e1`;
await prova("prepara evento e presenze reali su Supabase locale", async () => {
const inserito = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({
id: eventoId,
tipo: "allenamento",
titolo: "Test obiettivi o1",
data: `${MESE_TEST}-10`,
}),
});
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
// 9 presenti su 17 giocatori del roster reale (`crapp-data.ts`, id g1..g17).
const presenti = giocatori.slice(0, 9).map((g) => g.id);
const righe = giocatori.map((g) => ({
evento_id: eventoId,
giocatore_id: g.id,
stato: presenti.includes(g.id) ? "presente" : "assente",
}));
const inseritePresenze = await rest("risposte_presenze", {
method: "POST",
body: JSON.stringify(righe),
});
if (!inseritePresenze.ok) {
throw new Error(`inserimento presenze fallito: ${await inseritePresenze.text()}`);
}
});
await prova(
"o1 legge l'evento e le presenze vere dal database e calcola la percentuale del mese",
async () => {
const eventiReali = await leggiEventi();
assert.ok(
eventiReali.some((e) => e.id === eventoId),
"leggiEventi() include l'evento appena inserito",
);
const presenzeReali = await leggiPresenze(eventoId);
const obiettivi = obiettiviSquadra(
giocatori,
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
OGGI,
);
const o1 = obiettivi.find((o) => o.id === "o1")!;
assert.equal(
o1.valore,
Math.round((9 / giocatori.length) * 100),
"9 presenti su tutta la rosa, letti dal database",
);
assert.equal(o1.titolo, "90% di presenze a marzo", "titolo segue il mese iniettato");
assert.equal(o1.scadenza, "2099-03-31", "scadenza = ultimo giorno di marzo");
},
);
await prova("un evento fuori dal mese di test non sposta o1", async () => {
const eventoFuoriMese = `${PREFISSO}-e2`;
const fuori = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({
id: eventoFuoriMese,
tipo: "allenamento",
titolo: "Test obiettivi o1 - fuori mese",
data: "2099-04-01",
}),
});
if (!fuori.ok) throw new Error(`inserimento evento fallito: ${await fuori.text()}`);
try {
const presenzeFuoriMese = giocatori.map((g) => ({
evento_id: eventoFuoriMese,
giocatore_id: g.id,
stato: "presente",
}));
const scritte = await rest("risposte_presenze", {
method: "POST",
body: JSON.stringify(presenzeFuoriMese),
});
if (!scritte.ok) throw new Error(`inserimento presenze fallito: ${await scritte.text()}`);
const eventiReali = await leggiEventi();
const presenzeReali = {
...(await leggiPresenze(eventoId)),
...(await leggiPresenze(eventoFuoriMese)),
};
const o1 = obiettiviSquadra(
giocatori,
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
OGGI,
).find((o) => o.id === "o1")!;
assert.equal(
o1.valore,
Math.round((9 / giocatori.length) * 100),
"l'evento di aprile non entra nel calcolo di marzo",
);
} finally {
await rest(`eventi_app?id=eq.${eventoFuoriMese}`, { method: "DELETE" });
await rest(`risposte_presenze?evento_id=eq.${eventoFuoriMese}`, { method: "DELETE" });
}
});
await prova(
"o1 aggrega su più eventi reali dello stesso mese, incluse le partite",
async () => {
// Isolati dal resto del database (come per o2/o7/o12): l'evento "e1" di un test
// precedente resta nello stesso mese e andrebbe a sporcare l'aggregazione se non
// filtrassimo sui soli eventi di questo blocco.
const allenamentoId = `${PREFISSO}-o1b-allenamento`;
const partitaId = `${PREFISSO}-o1b-partita`;
for (const [id, tipo, data] of [
[allenamentoId, "allenamento", `${MESE_TEST}-12`],
[partitaId, "partita", `${MESE_TEST}-19`],
] as const) {
const inserito = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({ id, tipo, titolo: `Test obiettivi o1 (${tipo})`, data }),
});
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
}
// Tutta la rosa presente all'allenamento, nessuno alla partita: 50% aggregato sui due.
const risposte = [
...giocatori.map((g) => ({
evento_id: allenamentoId,
giocatore_id: g.id,
stato: "presente",
})),
...giocatori.map((g) => ({ evento_id: partitaId, giocatore_id: g.id, stato: "assente" })),
];
const scritte = await rest("risposte_presenze", {
method: "POST",
body: JSON.stringify(risposte),
});
if (!scritte.ok) throw new Error(`inserimento presenze fallito: ${await scritte.text()}`);
const eventiReali = (await leggiEventi()).filter(
(e) => e.id === allenamentoId || e.id === partitaId,
);
const presenzeReali = {
...(await leggiPresenze(allenamentoId)),
...(await leggiPresenze(partitaId)),
};
const o1 = obiettiviSquadra(
giocatori,
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
OGGI,
).find((o) => o.id === "o1")!;
assert.equal(
o1.valore,
50,
"le partite contano quanto gli allenamenti: 100% + 0% su due eventi reali = 50%",
);
},
);
await prova("o2 aggrega le risposte su più eventi reali, non solo su uno", async () => {
const eventoA = `${PREFISSO}-o2b-a`;
const eventoB = `${PREFISSO}-o2b-b`;
for (const [id, data] of [
[eventoA, `${MESE_TEST}-02`],
[eventoB, `${MESE_TEST}-22`],
] as const) {
const inserito = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({
id,
tipo: "allenamento",
titolo: "Test obiettivi o2 aggregato",
data,
}),
});
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
}
// Tutti rispondono al primo evento, nessuno al secondo: 50% aggregato sui due.
const risposteA = giocatori.map((g) => ({
evento_id: eventoA,
giocatore_id: g.id,
stato: "presente",
}));
const scritte = await rest("risposte_presenze", {
method: "POST",
body: JSON.stringify(risposteA),
});
if (!scritte.ok) throw new Error(`inserimento presenze fallito: ${await scritte.text()}`);
const eventiReali = (await leggiEventi()).filter((e) => e.id === eventoA || e.id === eventoB);
const presenzeReali = await leggiPresenze(eventoA);
const o2 = obiettiviSquadra(
giocatori,
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
OGGI,
).find((o) => o.id === "o2")!;
assert.equal(
o2.valore,
50,
"risposte piene su un evento, zero sull'altro = 50% aggregato sui due",
);
});
await prova(
"o6 legge dal database l'evento sociale del mese, azzerandosi come o1",
async () => {
const pizzataId = `${PREFISSO}-pizzata`;
const inserita = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({
id: pizzataId,
tipo: "evento",
titolo: "Test obiettivi o6",
data: `${MESE_TEST}-20`,
}),
});
if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`);
const eventiReali = await leggiEventi();
const o6InMese = obiettiviSquadra(
giocatori,
{ eventi: eventiReali, presenze: {}, pagelle: [] },
OGGI,
).find((o) => o.id === "o6")!;
assert.equal(o6InMese.valore, 1, "l'evento sociale di marzo conta letto dal database");
const OGGI_MESE_DOPO = new Date("2099-04-15T10:00:00Z");
const o6MeseDopo = obiettiviSquadra(
giocatori,
{ eventi: eventiReali, presenze: {}, pagelle: [] },
OGGI_MESE_DOPO,
).find((o) => o.id === "o6")!;
assert.equal(
o6MeseDopo.valore,
0,
"lo stesso evento non conta più il mese successivo (si azzera)",
);
},
);
await prova(
"o2 legge dal database risposte reali ed esclude i compleanni, con scadenza dinamica",
async () => {
// o2 (percentualeRisposte) aggrega su TUTTI gli eventi non-compleanno, senza filtro di
// mese: per un'asserzione deterministica isoliamo dal risultato reale solo i due eventi
// di questo test, invece di dipendere dal numero di eventi già presenti nel database
// (seed incluso).
const partitaId = `${PREFISSO}-o2-partita`;
const compleannoId = `${PREFISSO}-o2-compleanno`;
const inseritaPartita = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({
id: partitaId,
tipo: "partita",
titolo: "Test obiettivi o2",
data: `${MESE_TEST}-05`,
}),
});
if (!inseritaPartita.ok) {
throw new Error(`inserimento evento fallito: ${await inseritaPartita.text()}`);
}
const inseritoCompleanno = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({
id: compleannoId,
tipo: "compleanno",
titolo: "Test obiettivi o2 - compleanno",
data: `${MESE_TEST}-06`,
}),
});
if (!inseritoCompleanno.ok) {
throw new Error(`inserimento evento fallito: ${await inseritoCompleanno.text()}`);
}
// Solo 12 giocatori su tutta la rosa rispondono alla partita; nessuno "risponde" al
// compleanno, perché non richiede risposta.
const rispondenti = giocatori.slice(0, 12);
const righe = rispondenti.map((g, i) => ({
evento_id: partitaId,
giocatore_id: g.id,
stato: i % 2 === 0 ? "presente" : "forse",
}));
const inseriteRisposte = await rest("risposte_presenze", {
method: "POST",
body: JSON.stringify(righe),
});
if (!inseriteRisposte.ok) {
throw new Error(`inserimento presenze fallito: ${await inseriteRisposte.text()}`);
}
const eventiReali = (await leggiEventi()).filter(
(e) => e.id === partitaId || e.id === compleannoId,
);
const presenzeReali = await leggiPresenze(partitaId);
const o2 = obiettiviSquadra(
giocatori,
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
OGGI,
).find((o) => o.id === "o2")!;
assert.equal(
o2.valore,
Math.round((12 / giocatori.length) * 100),
"12 risposte reali su un solo evento che le richiede (il compleanno è escluso)",
);
assert.equal(o2.scadenza, "2099-03-31", "scadenza o2 = ultimo giorno del mese iniettato");
},
);
await prova("o7 somma presenze calcolate da eventi/risposte reali del database", async () => {
// o7 non calcola nulla da `ctx`: somma `g.presenze`, un campo già calcolato a monte da
// `contaPresenzeGiocatore()` (che in produzione alimenta `useRosa()`). Qui si esercita
// la stessa funzione pura sui dati appena scritti, per verificare l'intera catena
// DB -> contaPresenzeGiocatore -> o7, non solo la somma finale.
const allenamentoId = `${PREFISSO}-o7-allenamento`;
const partitaId = `${PREFISSO}-o7-partita`;
const OGGI_STR = "2099-01-20";
for (const [id, tipo, data] of [
[allenamentoId, "allenamento", "2099-01-05"],
[partitaId, "partita", "2099-01-08"],
] as const) {
const inserito = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({ id, tipo, titolo: `Test obiettivi o7 (${tipo})`, data }),
});
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
}
// g1: presente ai due eventi (2 presenze). g2: presente e in ritardo (2 presenze,
// il ritardo conta). g3: assente a entrambi (0 presenze).
const [g1, g2, g3] = giocatori;
const righe = [
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
{ evento_id: partitaId, giocatore_id: g1!.id, stato: "presente" },
{ evento_id: allenamentoId, giocatore_id: g2!.id, stato: "presente" },
{ evento_id: partitaId, giocatore_id: g2!.id, stato: "ritardo" },
{ evento_id: allenamentoId, giocatore_id: g3!.id, stato: "assente" },
{ evento_id: partitaId, giocatore_id: g3!.id, stato: "assente" },
];
const inserite = await rest("risposte_presenze", {
method: "POST",
body: JSON.stringify(righe),
});
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
const eventiReali = (await leggiEventi()).filter(
(e) => e.id === allenamentoId || e.id === partitaId,
);
const presenzeReali = {
...(await leggiPresenze(allenamentoId)),
...(await leggiPresenze(partitaId)),
};
const rosaConPresenzeReali = [g1!, g2!, g3!].map((g) => ({
...g,
presenze: contaPresenzeGiocatore(g.id, eventiReali, presenzeReali, OGGI_STR),
}));
const o7 = obiettiviSquadra(rosaConPresenzeReali, {
eventi: [],
presenze: {},
pagelle: [],
}).find((o) => o.id === "o7")!;
assert.equal(
o7.valore,
4,
"g1 (2) + g2 (2, il ritardo conta) + g3 (0) = 4, calcolate dal database",
);
});
await prova("o12/o13 media e conteggio pagelle vere lette da pagelle_voti", async () => {
const matchId = `${PREFISSO}-o12-m1`;
const [g1, g2, g3] = giocatori;
// 7 + 7 + 9 = 23 -> media 7.666... arrotondata a 7.7. Vincoli reali della tabella:
// niente autovoto (pagelle_no_autovoto), voto 1-10 (pagelle_voto_range).
const voti = [
{ match_id: matchId, votante_id: g1!.id, votato_id: g2!.id, voto: 7 },
{ match_id: matchId, votante_id: g2!.id, votato_id: g1!.id, voto: 7 },
{ match_id: matchId, votante_id: g3!.id, votato_id: g1!.id, voto: 9 },
];
const inseriti = await rest("pagelle_voti", { method: "POST", body: JSON.stringify(voti) });
if (!inseriti.ok) throw new Error(`inserimento pagelle fallito: ${await inseriti.text()}`);
const lette = await rest(
`pagelle_voti?match_id=eq.${matchId}&select=match_id,votante_id,votato_id,voto`,
);
const pagelleReali = (await lette.json()) as Array<{
match_id: string;
votante_id: string;
votato_id: string;
voto: number;
}>;
assert.equal(pagelleReali.length, 3, "i tre voti sono stati scritti e riletti dal database");
const obiettivi = obiettiviSquadra(giocatori, {
eventi: [],
presenze: {},
pagelle: pagelleReali,
});
assert.equal(
obiettivi.find((o) => o.id === "o12")!.valore,
7.7,
"media dei voti reali, arrotondata a una cifra decimale",
);
assert.equal(
obiettivi.find((o) => o.id === "o13")!.valore,
3,
"conteggio dei voti reali scritti sul database",
);
});
await prova(
"o11 conta chi ha almeno 3 allenamenti consecutivi, calcolati da eventi/risposte reali",
async () => {
// o11 non calcola nulla da `ctx`: legge `g.serieAllenamenti`, un campo già calcolato a
// monte da `serieConsecutiva()` (la stessa funzione pura usata da `useRosa()` in
// produzione). Qui si esercita l'intera catena DB -> serieConsecutiva -> o11.
const OGGI_STR = "2098-01-20";
const eventi = [
[`${PREFISSO}-o11-a1`, "2098-01-05"],
[`${PREFISSO}-o11-a2`, "2098-01-08"],
[`${PREFISSO}-o11-a3`, "2098-01-11"],
] as const;
for (const [id, data] of eventi) {
const inserito = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({ id, tipo: "allenamento", titolo: "Test obiettivi o11", data }),
});
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
}
const [g1, g2, g3] = giocatori;
// g1: presente ai tre allenamenti -> serie 3 (conta). g2: presente, presente, assente
// -> la serie si azzera all'ultimo (non conta). g3: assente, assente, presente -> serie
// 1 (non basta).
const stati: Record<string, [string, string, string]> = {
[g1!.id]: ["presente", "presente", "presente"],
[g2!.id]: ["presente", "presente", "assente"],
[g3!.id]: ["assente", "assente", "presente"],
};
const righe = Object.entries(stati).flatMap(([giocatoreId, statiPerEvento]) =>
eventi.map(([eventoId], i) => ({
evento_id: eventoId,
giocatore_id: giocatoreId,
stato: statiPerEvento[i],
})),
);
const inserite = await rest("risposte_presenze", {
method: "POST",
body: JSON.stringify(righe),
});
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
const eventiReali = (await leggiEventi()).filter((e) => eventi.some(([id]) => id === e.id));
const presenzeReali: MappaPresenze = {};
for (const [id] of eventi) Object.assign(presenzeReali, await leggiPresenze(id));
const rosaConSerieReali = [g1!, g2!, g3!].map((g) => ({
...g,
serieAllenamenti: serieConsecutiva(
g.id,
eventiReali,
presenzeReali,
"allenamento",
OGGI_STR,
),
}));
const o11 = obiettiviSquadra(rosaConSerieReali, {
eventi: [],
presenze: {},
pagelle: [],
}).find((o) => o.id === "o11")!;
assert.equal(
o11.valore,
1,
"solo g1 (presente ai tre allenamenti) resta in serie, calcolato dal database",
);
},
);
} finally {
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`pagelle_voti?match_id=like.${PREFISSO}*`, { method: "DELETE" });
riepilogo("obiettivi");
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Badge Pagellone end-to-end contro il database locale: `bun test/integration/pagella-badge.test.ts`.
*
* I test unitari (`test/unit/pagelle.test.ts`, `test/unit/badges.test.ts`) verificano
* `mediePagelle()` e `statoBadge()` come funzioni pure, con voti costruiti a mano. Qui invece
* si scrivono voti veri su `pagelle_voti`, si rileggono via REST con la stessa selezione di
* `usePagelle()`, e si passa il risultato attraverso `mediePagelle()` fino a `statoBadge()` sul
* badge `pagella`: se una colonna cambia nome o la mappatura si rompe, qui il grado del badge
* torna sbagliato anche se i test unitari restano verdi, perché quelli non toccano mai il
* database.
*
* Copre in particolare la soglia minima di voti (`VOTI_MINIMI_PAGELLA`, aggiunta per il gap
* "un solo voto sblocca/toglie il badge" segnalato in `docs/modules/badge.md`): con voti reali
* letti dal database, non solo con numeri scelti a mano.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-pagella-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeDefs, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges";
import { mediePagelle, type VotoPagella } from "@/lib/pagelle";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge Pagellone sul database", "stack locale non attivo (npx supabase start)");
riepilogo("pagella-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Pagellone su ${URL_BASE}`);
const PREFISSO = "test-pagella-badge";
const pagellaDef = badgeDefs.find((b) => b.id === "pagella")!;
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function upsert(riga: VotoPagella) {
const res = await rest("pagelle_voti?on_conflict=match_id,votante_id,votato_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify(riga),
});
if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`);
}
/** Rilegge esattamente come `usePagelle()`. */
async function leggiVoti(): Promise<VotoPagella[]> {
const res = await rest(
`pagelle_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,voto`,
);
return (await res.json()) as VotoPagella[];
}
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
function giocatoreAzzerato(mediaVoto: number, votiPagella: number): Giocatore {
return { ...giocatori[0]!, mediaVoto, votiPagella, mvp: 0, palloni: 0, presenze: 0 };
}
try {
await prova(
`sotto ${VOTI_MINIMI_PAGELLA} voti il badge resta bloccato anche con media alta`,
async () => {
// "pg1" riceve 4 voti da 9-10 (media altissima) ma sotto la soglia minima di voti:
// il badge non deve sbloccarsi nonostante la media sarebbe oro.
for (const [i, votante] of ["va", "vb", "vc", "vd"].entries()) {
await upsert({
match_id: `${PREFISSO}-m${i + 1}`,
votante_id: votante,
votato_id: "pg1",
voto: 9,
});
}
const voti = await leggiVoti();
const medie = mediePagelle(voti);
assert.equal(medie["pg1"]?.voti, 4, "4 voti scritti, 4 riletti");
assert.equal(medie["pg1"]?.media, 9, "media alta");
const badgePg1 = statoBadge(
pagellaDef,
giocatoreAzzerato(medie["pg1"]!.media, medie["pg1"]!.voti),
);
assert.equal(badgePg1.grado, null, "4 voti < 5: il badge resta bloccato");
},
);
await prova(
`al ${VOTI_MINIMI_PAGELLA}° voto il badge Pagellone si sblocca con il grado giusto`,
async () => {
// Un quinto voto a "pg1" (stesso giocatore del test precedente) fa scattare la
// soglia minima: la media (9) sblocca subito l'oro (soglia 8.5).
await upsert({
match_id: `${PREFISSO}-m5`,
votante_id: "ve",
votato_id: "pg1",
voto: 9,
});
const medie = mediePagelle(await leggiVoti());
assert.equal(medie["pg1"]?.voti, 5);
const badgePg1 = statoBadge(
pagellaDef,
giocatoreAzzerato(medie["pg1"]!.media, medie["pg1"]!.voti),
);
assert.equal(badgePg1.grado, "oro", "5 voti raggiunti: la media conta, è oro");
},
);
await prova("le soglie di grado normali si applicano sopra il minimo di voti", async () => {
// "pg2" riceve 5 voti che fanno una media di bronzo (6.5), non oro: verifica che sopra
// la soglia minima il grado dipenda ancora dalla media, non solo dal numero di voti.
for (const [i, votante] of ["va", "vb", "vc", "vd", "ve"].entries()) {
await upsert({
match_id: `${PREFISSO}-n${i + 1}`,
votante_id: votante,
votato_id: "pg2",
voto: i < 4 ? 6 : 9, // (6*4+9)/5 = 6.6 -> arrotondato 6.6, sopra 6.5
});
}
const medie = mediePagelle(await leggiVoti());
assert.equal(medie["pg2"]?.voti, 5);
assert.equal(medie["pg2"]?.media, 6.6);
const badgePg2 = statoBadge(
pagellaDef,
giocatoreAzzerato(medie["pg2"]!.media, medie["pg2"]!.voti),
);
assert.equal(badgePg2.grado, "bronzo", "media 6.6: bronzo, non oro");
});
} finally {
await rest(`pagelle_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" });
}
riepilogo("pagella-badge");
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Badge Sherpa dei palloni end-to-end contro il database locale:
* `bun test/integration/palloni-badge.test.ts`.
*
* I test unitari (`test/unit/palloni-core.test.ts`, `test/unit/badges.test.ts`) verificano
* `conteggioTurni()` e `statoBadge()` come funzioni pure, con eventi e turni costruiti a mano.
* Qui invece si scrivono eventi e turni veri su `eventi_app`/`turni_palloni`, si rileggono via
* REST con la stessa forma di `fetchTurni()`/`daRiga()`, e si passa il risultato attraverso
* `conteggioTurni()` fino a `statoBadge()` sul badge `palloni`: se una colonna cambia nome o la
* mappatura si rompe, qui il grado del badge torna sbagliato anche se i test unitari restano
* verdi, perché quelli non toccano mai il database.
*
* Verifica in particolare che il badge conti **solo i turni confermati**
* (`Giocatore.palloni` in `rosa.ts` usa `turniSalvati`, non l'output di `completaTurni()`):
* un evento passato senza turno confermato non deve avanzare il badge di nessuno, anche se
* `completaTurni()` (usato altrove, per la UI di rotazione) lo proporrebbe automaticamente a
* qualcuno. Prima di questo fix il badge includeva anche le proposte non confermate vedi
* `docs/modules/badge.md` § Limiti noti (risolto) per il dettaglio del bug e della correzione.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-palloni-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges";
import { conteggioTurni } from "@/lib/palloni-core";
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge palloni sul database", "stack locale non attivo (npx supabase start)");
riepilogo("palloni-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge palloni su ${URL_BASE}`);
const PREFISSO = "test-palloni-badge";
const palloniDef = badgeDefs.find((b) => b.id === "palloni")!;
// "Oggi" fissato nel futuro: gli eventi scritti sotto sono tutti datati nel passato remoto,
// così restano "passati" (e quindi contati) a prescindere da quando gira il test.
const OGGI = "2099-01-01";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function creaEvento(id: string, data: string) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ id, tipo: "partita", titolo: `Partita ${id}`, data }),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function confermaTurno(eventoId: string, giocatoreId: string) {
const res = await rest("turni_palloni?on_conflict=evento_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ evento_id: eventoId, giocatore_id: giocatoreId }),
});
if (!res.ok) throw new Error(`upsert su turni_palloni: ${res.status} ${await res.text()}`);
}
/** Rilegge esattamente come fa l'app: `daRiga()` per gli eventi, una mappa per i turni. */
async function leggiEventi(): Promise<Evento[]> {
const res = await rest(
"eventi_app?id=like." +
PREFISSO +
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
);
const righe = (await res.json()) as RigaEvento[];
return righe.map(daRiga);
}
/** Rilegge esattamente come `fetchTurni()`: solo i turni confermati, nessuna proposta. */
async function leggiTurniSalvati(): Promise<Record<string, string>> {
const res = await rest(
`turni_palloni?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id`,
);
const righe = (await res.json()) as Array<{ evento_id: string; giocatore_id: string }>;
const mappa: Record<string, string> = {};
for (const r of righe) mappa[r.evento_id] = r.giocatore_id;
return mappa;
}
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
function giocatoreAzzerato(palloni: number): Giocatore {
return { ...giocatori[0]!, palloni, mvp: 0, mediaVoto: 0, votiPagella: 0, presenze: 0 };
}
try {
await prova(
"il badge palloni passa da bronzo ad argento con turni reali, ignorando gli eventi mai confermati",
async () => {
// m1-m3: turno confermato esplicitamente a pv1 (3 turni -> soglia bronzo).
// m4: NESSUN turno confermato -> non deve contare per nessuno, anche se un algoritmo
// di rotazione (usato altrove per la UI) proporrebbe qualcuno in automatico.
// m5-m7: confermati di nuovo a pv1 (6 turni totali -> soglia argento).
const eventi = [
[`${PREFISSO}-m1`, "2020-01-01", "pv1"],
[`${PREFISSO}-m2`, "2020-01-02", "pv1"],
[`${PREFISSO}-m3`, "2020-01-03", "pv1"],
[`${PREFISSO}-m4`, "2020-01-04", null],
[`${PREFISSO}-m5`, "2020-01-05", "pv1"],
[`${PREFISSO}-m6`, "2020-01-06", "pv1"],
[`${PREFISSO}-m7`, "2020-01-07", "pv1"],
] as const;
for (const [id, data] of eventi.slice(0, 3)) await creaEvento(id, data);
for (const [id, , giocatore] of eventi.slice(0, 3))
if (giocatore) await confermaTurno(id, giocatore);
const eventiA3 = await leggiEventi();
const salvatiA3 = await leggiTurniSalvati();
const conteggioA3 = conteggioTurni(salvatiA3, eventiA3, OGGI);
assert.equal(conteggioA3["pv1"], 3, "3 turni confermati: soglia bronzo appena raggiunta");
assert.equal(
statoBadge(palloniDef, giocatoreAzzerato(conteggioA3["pv1"]!)).grado,
"bronzo",
"3 turni: bronzo, non oltre (soglia argento è 6)",
);
for (const [id, data] of eventi.slice(3)) await creaEvento(id, data);
for (const [id, , giocatore] of eventi.slice(3))
if (giocatore) await confermaTurno(id, giocatore);
const eventiLetti = await leggiEventi();
assert.equal(eventiLetti.length, 7, "tutti gli eventi scritti si rileggono");
const salvati = await leggiTurniSalvati();
assert.equal(Object.keys(salvati).length, 6, "6 turni confermati esplicitamente, m4 no");
const conteggio = conteggioTurni(salvati, eventiLetti, OGGI);
assert.equal(conteggio["pv1"], 6, "6 turni confermati per pv1");
assert.equal(
conteggio["pv2"] ?? 0,
0,
"pv2 non ha mai confermato nulla: m4 senza conferma non conta per nessuno",
);
const badgePv1 = statoBadge(palloniDef, giocatoreAzzerato(conteggio["pv1"]!));
assert.equal(badgePv1.grado, "argento", "6 turni: soglia argento raggiunta");
const badgePv2 = statoBadge(palloniDef, giocatoreAzzerato(conteggio["pv2"] ?? 0));
assert.equal(badgePv2.grado, null, "zero turni confermati: nessun badge");
},
);
await prova("un evento futuro non riceve conteggio, anche se già confermato", async () => {
// Stesso scenario di sopra ma con "oggi" prima di tutti gli eventi: nessun turno
// confermato deve ancora contare per il badge.
const eventiLetti = await leggiEventi();
const salvati = await leggiTurniSalvati();
const conteggio = conteggioTurni(salvati, eventiLetti, "2019-01-01");
assert.deepEqual(conteggio, {}, "tutti gli eventi sono nel futuro rispetto a 'oggi'");
});
} finally {
await rest(`turni_palloni?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("palloni-badge");
}
+148
View File
@@ -311,6 +311,18 @@ if (!locale) {
});
assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`);
const mioMvp = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO,
votante_id: "g1",
votato_id: "g5",
votato_nome: "Cinque",
}),
});
assert.equal(await righeToccate(mioMvp), 1, "il proprio voto MVP si registra");
const mvp = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
@@ -394,6 +406,142 @@ if (!locale) {
assert.equal(await righeToccate(pagella), 1, "e per cancellare il voto di un altro");
});
// M13: le tabelle di voto controllano anche a database chi può votare chi, non solo
// chi firma il voto. Prima di M13 un convocato poteva votare/essere votato in un
// evento a cui non aveva partecipato, e un voto pagella restava possibile anche a
// `pagelle_chiuse` — entrambi filtri solo applicativi (segnalati in `badge.md`).
// Nota: `tokenAdmin` non va usato per queste prove, la policy admin di M11 non ha il
// controllo sui convocati (l'admin corregge anche dati fuori convocazione di
// proposito) e farebbe passare tutto a prescindere, senza provare niente sulla nuova
// policy. Si usa solo `tokenGiocatore` (g1), un votante non-admin vero.
const EVENTO_SENZA_G1 = `${PREFISSO}-evento-senza-g1`;
const EVENTO_CON_G1 = `${PREFISSO}-evento-con-g1`;
const EVENTO_CHIUSO = `${PREFISSO}-evento-chiuso`;
await prova("un votante non convocato non può votare", async () => {
const creato = await rest("eventi_app", tokenAdmin, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
id: EVENTO_SENZA_G1,
tipo: "partita",
titolo: "Partita senza g1 tra i convocati",
data: "2026-01-02",
ora: "20:00",
luogo: "Palestra",
convocati: ["g2", "g5"],
}),
});
assert.equal(await righeToccate(creato), 1, "l'evento con convocati si crea");
const votanteEscluso = await rest("pagelle_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_SENZA_G1,
votante_id: "g1",
votato_id: "g2",
voto: 7,
}),
});
assert.ok(!votanteEscluso.ok, `g1 non era convocato, non vota (${votanteEscluso.status})`);
});
await prova("un votante convocato non può votare chi non lo era", async () => {
const creato = await rest("eventi_app", tokenAdmin, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
id: EVENTO_CON_G1,
tipo: "partita",
titolo: "Partita con g1 convocato, g2 no",
data: "2026-01-02",
ora: "20:00",
luogo: "Palestra",
convocati: ["g1", "g5"],
}),
});
assert.equal(await righeToccate(creato), 1, "l'evento con convocati si crea");
const votatoEscluso = await rest("badge_social_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_CON_G1,
categoria: "cuore",
votante_id: "g1",
votato_id: "g2",
votato_nome: "Due",
}),
});
assert.ok(
!votatoEscluso.ok,
`g2 non era convocato, non è votabile (${votatoEscluso.status})`,
);
// Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui
// convocato — senza questo, il test sopra potrebbe fallire per un altro motivo
// (es. un evento inesistente) e sembrare comunque corretto.
const votoValido = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_CON_G1,
votante_id: "g1",
votato_id: "g5",
votato_nome: "Cinque",
}),
});
assert.equal(await righeToccate(votoValido), 1, "votante e votato convocati: il voto passa");
});
await prova("pagelle_chiuse blocca anche a database, non solo in UI", async () => {
const creato = await rest("eventi_app", tokenAdmin, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
id: EVENTO_CHIUSO,
tipo: "partita",
titolo: "Partita con pagelle chiuse",
data: "2026-01-03",
ora: "20:00",
luogo: "Palestra",
pagelle_chiuse: true,
}),
});
assert.equal(await righeToccate(creato), 1, "l'evento con pagelle chiuse si crea");
const pagellaFuoriTempo = await rest("pagelle_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_CHIUSO,
votante_id: "g1",
votato_id: "g5",
voto: 7,
}),
});
assert.ok(
!pagellaFuoriTempo.ok,
`pagelle chiuse: voto rifiutato (${pagellaFuoriTempo.status})`,
);
// Il flag riguarda solo le pagelle: MVP e badge social non hanno un concetto di
// "chiusura" (mvp.md lo segnala esplicitamente come limite noto), quindi restano
// votabili sullo stesso evento.
const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_CHIUSO,
votante_id: "g1",
votato_id: "g5",
votato_nome: "Cinque",
}),
});
assert.equal(await righeToccate(mvpAncoraAperto), 1, "l'MVP non ha un flag di chiusura");
});
// Il terzo gruppo di DD-023: tabelle lasciate aperte **di proposito**, perché
// nell'interfaccia non hanno nessun gate — il turno palloni se lo passa chiunque, e lo
// Scout Live lo apre chiunque, con il solo lock di sessione a tenere l'ordine.
+197
View File
@@ -0,0 +1,197 @@
/**
* Badge Presenza fissa end-to-end contro il database locale:
* `bun test/integration/presenze-badge.test.ts`.
*
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
* `contaPresenzeGiocatore()` e `statoBadge()` come funzioni pure, con eventi e risposte
* costruiti a mano. Qui invece si scrivono eventi e risposte veri su `eventi_app`/
* `risposte_presenze`, si rileggono via REST con la stessa forma di `fetchPresenze()`/
* `daRiga()`, e si passa il risultato attraverso `contaPresenzeGiocatore()` fino a
* `statoBadge()` sul badge `presenze`: se una colonna cambia nome o la mappatura si rompe, qui
* il grado del badge torna sbagliato anche se i test unitari restano verdi, perché quelli non
* toccano mai il database.
*
* Copre anche un punto verificato nell'analisi ma non ovvio: **non serve nessun controllo a
* database sui convocati** per questo badge (a differenza di MVP/pagelle/badge social, vedi
* M13 in `docs/modules/badge.md`) perché `contaPresenzeGiocatore()` filtra già gli eventi per
* `convocati` lato applicazione una risposta scritta per un evento a cui non si era
* convocati non conta comunque, anche se la riga esiste nel database.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-presenze-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges";
import { contaPresenzeGiocatore } from "@/lib/presenze";
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge presenze sul database", "stack locale non attivo (npx supabase start)");
riepilogo("presenze-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge presenze su ${URL_BASE}`);
const PREFISSO = "test-presenze-badge";
const presenzeDef = badgeDefs.find((b) => b.id === "presenze")!;
// "Oggi" fissato nel futuro: gli eventi scritti sotto sono datati nel passato remoto, così
// restano "passati" (quindi contati) a prescindere da quando gira il test.
const OGGI = "2099-01-01";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
/** `2020-01-01` per i=1, avanzando di un giorno per ogni evento: niente collisioni di data. */
const dataEvento = (i: number) => {
const d = new Date(Date.UTC(2020, 0, 1));
d.setUTCDate(d.getUTCDate() + i - 1);
return d.toISOString().slice(0, 10);
};
async function creaEvento(id: string, i: number, convocati?: string[]) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
id,
tipo: "allenamento",
titolo: `Allenamento ${id}`,
data: dataEvento(i),
...(convocati ? { convocati } : {}),
}),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function rispondi(eventoId: string, giocatoreId: string, stato: string) {
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ evento_id: eventoId, giocatore_id: giocatoreId, stato }),
});
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
}
/** Rilegge esattamente come fa l'app: `daRiga()` per gli eventi. */
async function leggiEventi(): Promise<Evento[]> {
const res = await rest(
"eventi_app?id=like." +
PREFISSO +
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
);
const righe = (await res.json()) as RigaEvento[];
return righe.map(daRiga);
}
/** Rilegge esattamente come `fetchPresenze()`. */
async function leggiPresenze(): Promise<Record<string, Record<string, string>>> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: string;
}>;
const mappa: Record<string, Record<string, string>> = {};
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
return mappa;
}
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
function giocatoreAzzerato(presenze: number): Giocatore {
return { ...giocatori[0]!, presenze, mvp: 0, mediaVoto: 0, votiPagella: 0, palloni: 0 };
}
try {
await prova(
"il badge presenze passa da bronzo a oro con presenze vere lette dal database",
async () => {
// 30 allenamenti passati. "pp1" risponde a tutti e 30 (i primi 5 "presente", un
// "ritardo" nel mezzo per verificare che conti come presenza, il resto "presente"),
// così una sola scrittura basta a verificare le tre soglie 5/15/30.
for (let i = 1; i <= 30; i += 1) {
await creaEvento(`${PREFISSO}-a${String(i).padStart(2, "0")}`, i);
}
for (let i = 1; i <= 30; i += 1) {
const stato = i === 10 ? "ritardo" : "presente";
await rispondi(`${PREFISSO}-a${String(i).padStart(2, "0")}`, "pp1", stato);
}
// "pp2" risponde solo ai primi 5: resta a bronzo, non oltre.
for (let i = 1; i <= 5; i += 1) {
await rispondi(`${PREFISSO}-a${String(i).padStart(2, "0")}`, "pp2", "presente");
}
// "pp2" risponde anche "assente" a un evento: non deve contare.
await rispondi(`${PREFISSO}-a06`, "pp2", "assente");
const eventi = await leggiEventi();
assert.equal(eventi.length, 30, "tutti gli eventi scritti si rileggono");
const presenze = await leggiPresenze();
const contoPp1 = contaPresenzeGiocatore("pp1", eventi, presenze, OGGI);
assert.equal(contoPp1, 30, "30 risposte, incluso un ritardo contato come presenza");
assert.equal(
statoBadge(presenzeDef, giocatoreAzzerato(contoPp1)).grado,
"oro",
"30 presenze: soglia oro raggiunta",
);
const contoPp2 = contaPresenzeGiocatore("pp2", eventi, presenze, OGGI);
assert.equal(contoPp2, 5, "5 presenti, l'assente su a06 non conta");
const badgePp2 = statoBadge(presenzeDef, giocatoreAzzerato(contoPp2));
assert.equal(badgePp2.grado, "bronzo", "5 presenze: soglia bronzo raggiunta, non oltre");
assert.equal(badgePp2.prossimo, "argento");
assert.equal(badgePp2.prossimaSoglia, 15);
// Un pp2 a metà strada (15 presenze) sblocca l'argento.
for (let i = 7; i <= 16; i += 1) {
await rispondi(`${PREFISSO}-a${String(i).padStart(2, "0")}`, "pp2", "presente");
}
const eventiDopo = await leggiEventi();
const presenzeDopo = await leggiPresenze();
const contoPp2Dopo = contaPresenzeGiocatore("pp2", eventiDopo, presenzeDopo, OGGI);
assert.equal(contoPp2Dopo, 15, "5 + 10 nuove presenze");
assert.equal(
statoBadge(presenzeDef, giocatoreAzzerato(contoPp2Dopo)).grado,
"argento",
"15 presenze: soglia argento raggiunta",
);
},
);
await prova(
"una risposta a un evento senza convocazione non conta per il badge, anche se la riga esiste",
async () => {
// "pp3" non è tra i convocati di questo evento (lista esplicita che lo esclude), ma
// scrive comunque una risposta "presente" — bypassando l'interfaccia, come farebbe
// chi parla direttamente con PostgREST. Deve restare a zero: la difesa è nella
// funzione pura, non in una policy RLS (a differenza di MVP/pagelle/badge social).
await creaEvento(`${PREFISSO}-ristretto`, 40, ["pp1"]);
await rispondi(`${PREFISSO}-ristretto`, "pp3", "presente");
const eventi = await leggiEventi();
const presenze = await leggiPresenze();
const conto = contaPresenzeGiocatore("pp3", eventi, presenze, OGGI);
assert.equal(conto, 0, "pp3 non era convocato: la risposta non conta");
},
);
} finally {
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("presenze-badge");
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Pulizia a cascata dei dati collegati alla cancellazione di un evento (M14):
* `bun test/integration/pulizia-evento.test.ts`.
*
* Scrive una riga per ciascuna delle 9 tabelle collegate a un evento (`risposte_presenze`,
* `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`,
* `scout_sessioni`, `scout_live`, `scout_partite`), cancella l'evento e verifica che il
* trigger `eventi_app_pulisci_dati_collegati` le abbia rimosse tutte. Prima di M14 queste
* righe restavano orfane a database (`docs/DESIGN_DECISIONS.md`, DD-029).
*
* Gira solo sullo stack locale (`npx supabase start`): usa id con il prefisso
* `test-pulizia-evento`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("pulizia dati evento cancellato", "stack locale non attivo (npx supabase start)");
riepilogo("pulizia-evento");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`pulizia dati evento cancellato su ${URL_BASE}`);
const ID_EVENTO = "test-pulizia-evento-e1";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
Prefer: "return=minimal",
...(init?.headers ?? {}),
},
});
async function inserisci(tabella: string, riga: Record<string, unknown>) {
const res = await rest(tabella, { method: "POST", body: JSON.stringify(riga) });
if (!res.ok) throw new Error(`insert su ${tabella}: ${res.status} ${await res.text()}`);
}
async function conta(tabella: string, filtro: string): Promise<number> {
const res = await rest(`${tabella}?${filtro}&select=*`, {
headers: { Prefer: "count=exact" },
});
return Number(res.headers.get("content-range")?.split("/")[1] ?? 0);
}
const TABELLE_EVENTO_ID = [
"risposte_presenze",
"cacche_partita",
"turni_palloni",
"scout_sessioni",
"scout_live",
"scout_partite",
];
const TABELLE_MATCH_ID = ["mvp_voti", "pagelle_voti", "badge_social_voti"];
async function pulisciTutto() {
await rest(`eventi_app?id=eq.${ID_EVENTO}`, { method: "DELETE" });
for (const t of TABELLE_EVENTO_ID) {
await rest(`${t}?evento_id=eq.${ID_EVENTO}`, { method: "DELETE" });
}
for (const t of TABELLE_MATCH_ID) {
await rest(`${t}?match_id=eq.${ID_EVENTO}`, { method: "DELETE" });
}
}
try {
await prova("cancellare un evento pulisce a cascata tutte le tabelle collegate", async () => {
await inserisci("eventi_app", {
id: ID_EVENTO,
tipo: "allenamento",
titolo: "Test pulizia",
data: "2026-09-01",
});
await inserisci("risposte_presenze", {
evento_id: ID_EVENTO,
giocatore_id: "test-g1",
stato: "presente",
});
await inserisci("cacche_partita", {
evento_id: ID_EVENTO,
giocatore_id: "test-g1",
quantita: 2,
});
await inserisci("turni_palloni", { evento_id: ID_EVENTO, giocatore_id: "test-g1" });
await inserisci("scout_sessioni", {
evento_id: ID_EVENTO,
giocatore_id: "test-g1",
giocatore_nome: "Uno",
});
await inserisci("scout_live", { evento_id: ID_EVENTO, stato: {} });
await inserisci("scout_partite", {
id: `${ID_EVENTO}-scout`,
evento_id: ID_EVENTO,
data: "2026-09-01",
avversario: "Test",
set_nostri: 3,
set_loro: 0,
});
await inserisci("mvp_voti", {
match_id: ID_EVENTO,
votante_id: "test-g1",
votato_id: "test-g2",
votato_nome: "Due",
});
await inserisci("pagelle_voti", {
match_id: ID_EVENTO,
votante_id: "test-g1",
votato_id: "test-g2",
voto: 7,
});
await inserisci("badge_social_voti", {
match_id: ID_EVENTO,
categoria: "top",
votante_id: "test-g1",
votato_id: "test-g2",
votato_nome: "Due",
});
// Tutte le righe esistono prima della cancellazione.
for (const t of TABELLE_EVENTO_ID) {
assert.equal(await conta(t, `evento_id=eq.${ID_EVENTO}`), 1, `${t}: riga presente`);
}
for (const t of TABELLE_MATCH_ID) {
assert.equal(await conta(t, `match_id=eq.${ID_EVENTO}`), 1, `${t}: riga presente`);
}
const res = await rest(`eventi_app?id=eq.${ID_EVENTO}`, { method: "DELETE" });
if (!res.ok) throw new Error(`delete evento: ${res.status} ${await res.text()}`);
// Il trigger deve aver ripulito tutte le righe collegate.
for (const t of TABELLE_EVENTO_ID) {
assert.equal(await conta(t, `evento_id=eq.${ID_EVENTO}`), 0, `${t}: pulita a cascata`);
}
for (const t of TABELLE_MATCH_ID) {
assert.equal(await conta(t, `match_id=eq.${ID_EVENTO}`), 0, `${t}: pulita a cascata`);
}
});
} finally {
await pulisciTutto();
}
riepilogo("pulizia-evento");
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Badge segreto Trono di ferro end-to-end contro il database locale:
* `bun test/integration/s-cacche-badge.test.ts`.
*
* I test unitari (`test/unit/cacche.test.ts`, `test/unit/badges.test.ts`) verificano
* `statisticheCacche()` e `statoBadge()` come funzioni pure. Qui invece si scrivono righe vere
* su `cacche_partita`, si rileggono via REST con la stessa selezione di `useCacche()`, e si
* passa il risultato attraverso `statisticheCacche()` fino a `statoBadge()` sul badge segreto
* `s-cacche`.
*
* Copre in particolare un comportamento verificato in analisi e confermato intenzionale (non
* un bug): **la descrizione del badge non distingue più campionato da amichevoli** ("almeno 3
* partite affrontate con 3+ cacche pre-gara, campionato o amichevole") perché nessuna funzione
* della pipeline (`statisticheCacche()`, `rosa.ts`, `badges.ts`) filtra mai su
* `eventi_app.campionato` la vecchia descrizione prometteva "partite di campionato" senza
* che il codice lo verificasse mai. Qui si scrive apposta un evento con `campionato=false`
* (amichevole) e si dimostra che conta lo stesso, così un domani chi reintroduce un filtro sul
* campionato deve toccare anche questo test, non scoprirlo in produzione.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-s-cacche-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeSegreti, statoBadge } from "@/lib/badges";
import { statisticheCacche, type RigaCacche } from "@/lib/cacche";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge Trono di ferro sul database", "stack locale non attivo (npx supabase start)");
riepilogo("s-cacche-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Trono di ferro su ${URL_BASE}`);
const PREFISSO = "test-s-cacche-badge";
const def = badgeSegreti.find((b) => b.id === "s-cacche")!;
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function creaEvento(id: string, campionato: boolean) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
id,
tipo: "partita",
titolo: `Partita ${id}`,
data: "2020-01-01",
campionato,
}),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function dichiara(eventoId: string, giocatoreId: string, quantita: number) {
const res = await rest("cacche_partita?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ evento_id: eventoId, giocatore_id: giocatoreId, quantita }),
});
if (!res.ok) throw new Error(`upsert su cacche_partita: ${res.status} ${await res.text()}`);
}
async function leggiCacche(): Promise<RigaCacche[]> {
const res = await rest(
`cacche_partita?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,quantita`,
);
return (await res.json()) as RigaCacche[];
}
function giocatoreAzzerato(cacche: number): Giocatore {
return {
...giocatori[0]!,
cacche,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
palloni: 0,
presenze: 0,
};
}
try {
await prova(
"il segreto si sblocca con 3 giornate da record, comprese le amichevoli",
async () => {
// sc1: 2 giornate top su partite di campionato, 1 su un'amichevole. Il badge non fa
// distinzione: le 3 contano tutte allo stesso modo.
await creaEvento(`${PREFISSO}-c1`, true);
await creaEvento(`${PREFISSO}-c2`, true);
await creaEvento(`${PREFISSO}-a1`, false); // amichevole
await dichiara(`${PREFISSO}-c1`, "sc1", 3);
await dichiara(`${PREFISSO}-c2`, "sc1", 4);
const righeA2 = await leggiCacche();
const statsA2 = statisticheCacche(righeA2);
assert.equal(statsA2["sc1"]?.giornateTop, 2, "solo le due di campionato per ora");
assert.equal(
statoBadge(def, giocatoreAzzerato(statsA2["sc1"]!.giornateTop)).grado,
null,
"2 giornate top non bastano",
);
await dichiara(`${PREFISSO}-a1`, "sc1", 3);
const righe = await leggiCacche();
const stats = statisticheCacche(righe);
assert.equal(stats["sc1"]?.giornateTop, 3, "l'amichevole conta come le altre due");
assert.equal(
statoBadge(def, giocatoreAzzerato(stats["sc1"]!.giornateTop)).grado,
"oro",
"3 giornate top, campionato o amichevole: il segreto si sblocca",
);
},
);
} finally {
await rest(`cacche_partita?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("s-cacche-badge");
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Badge segreto Cliente VIP dell'Infermeria end-to-end contro il database locale:
* `bun test/integration/s-infermeria-badge.test.ts`.
*
* I test unitari (`test/unit/infortuni.test.ts`, `test/unit/badges.test.ts`) verificano
* `contaInfortuni()` e `statoBadge()` come funzioni pure, con eventi e risposte costruiti a
* mano. Qui invece si scrivono eventi e risposte "infortunato" veri su `eventi_app`/
* `risposte_presenze`, si rileggono via REST con la stessa forma di `daRiga()`/
* `fetchPresenze()`, e si passa il risultato attraverso `contaInfortuni()` fino a
* `statoBadge()` sul badge segreto `s-infermeria`: se una colonna cambia nome o la mappatura
* si rompe, qui il badge torna sbagliato anche se i test unitari restano verdi.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-s-infermeria-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeSegreti, statoBadge } from "@/lib/badges";
import { contaInfortuni } from "@/lib/infortuni";
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta(
"badge Cliente VIP dell'Infermeria sul database",
"stack locale non attivo (npx supabase start)",
);
riepilogo("s-infermeria-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Cliente VIP dell'Infermeria su ${URL_BASE}`);
const PREFISSO = "test-s-infermeria-badge";
const def = badgeSegreti.find((b) => b.id === "s-infermeria")!;
const OGGI = "2099-01-01";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
const dataEvento = (i: number) => {
const d = new Date(Date.UTC(2020, 0, 1));
d.setUTCDate(d.getUTCDate() + i - 1);
return d.toISOString().slice(0, 10);
};
async function creaEvento(id: string, i: number) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
id,
tipo: "allenamento",
titolo: `Evento ${id}`,
data: dataEvento(i),
}),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function rispondi(eventoId: string, giocatoreId: string, stato: string) {
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ evento_id: eventoId, giocatore_id: giocatoreId, stato }),
});
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
}
async function leggiEventi(): Promise<Evento[]> {
const res = await rest(
"eventi_app?id=like." +
PREFISSO +
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
);
const righe = (await res.json()) as RigaEvento[];
return righe.map(daRiga);
}
async function leggiPresenze(): Promise<Record<string, Record<string, string>>> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: string;
}>;
const mappa: Record<string, Record<string, string>> = {};
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
return mappa;
}
function giocatoreAzzerato(infortuni: number): Giocatore {
return {
...giocatori[0]!,
infortuni,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
palloni: 0,
presenze: 0,
};
}
try {
await prova("il segreto si sblocca al terzo infortunio vero, non prima", async () => {
for (let i = 1; i <= 3; i += 1) {
const id = `${PREFISSO}-e${i}`;
await creaEvento(id, i);
await rispondi(id, "si1", "infortunato");
}
const eventiA2 = (await leggiEventi()).filter((e) => e.id !== `${PREFISSO}-e3`);
const presenzeA2 = await leggiPresenze();
const contoA2 = contaInfortuni(presenzeA2, eventiA2, OGGI)["si1"] ?? 0;
assert.equal(contoA2, 2, "solo i primi due eventi contati");
assert.equal(
statoBadge(def, giocatoreAzzerato(contoA2)).grado,
null,
"2 infortuni non bastano",
);
const eventi = await leggiEventi();
const presenze = await leggiPresenze();
const conto = contaInfortuni(presenze, eventi, OGGI)["si1"] ?? 0;
assert.equal(conto, 3);
assert.equal(
statoBadge(def, giocatoreAzzerato(conto)).grado,
"oro",
"3 infortuni: il segreto si sblocca (soglia unica)",
);
});
} finally {
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("s-infermeria-badge");
}
@@ -0,0 +1,202 @@
/**
* Badge segreto Mai un forfait end-to-end contro il database locale:
* `bun test/integration/s-mai-forfait-badge.test.ts`.
*
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
* `serieConferme()`, `contaPresenzeGiocatore()` e `statoBadge()` come funzioni pure. Qui invece
* si scrivono eventi con `creato_il` esplicito e risposte con `risposto_il` esplicito su
* `eventi_app`/`risposte_presenze`, si rileggono via REST e si passa il risultato attraverso
* entrambe le funzioni fino a `statoBadge()` sul badge segreto `s-mai-forfait`, che è l'unico a
* combinare due statistiche indipendenti (serie di conferme rapide **e** presenze totali).
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-s-mai-forfait-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeSegreti, statoBadge } from "@/lib/badges";
import { serieConferme, contaPresenzeGiocatore } from "@/lib/presenze";
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge Mai un forfait sul database", "stack locale non attivo (npx supabase start)");
riepilogo("s-mai-forfait-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Mai un forfait su ${URL_BASE}`);
const PREFISSO = "test-s-mai-forfait-badge";
const def = badgeSegreti.find((b) => b.id === "s-mai-forfait")!;
const OGGI = "2099-01-01";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
const dataEvento = (i: number) => {
const d = new Date(Date.UTC(2020, 0, 1));
d.setUTCDate(d.getUTCDate() + i - 1);
return d.toISOString().slice(0, 10);
};
async function creaEvento(id: string, i: number) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
id,
tipo: "allenamento",
titolo: `Evento ${id}`,
data: dataEvento(i),
creato_il: `${dataEvento(i)}T08:00:00Z`,
}),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function rispondi(eventoId: string, giocatoreId: string, i: number) {
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
evento_id: eventoId,
giocatore_id: giocatoreId,
stato: "presente",
risposto_il: `${dataEvento(i)}T08:30:00Z`,
}),
});
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
}
async function leggiEventi(): Promise<Evento[]> {
const res = await rest(
"eventi_app?id=like." +
PREFISSO +
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
);
const righe = (await res.json()) as RigaEvento[];
return righe.map(daRiga);
}
async function leggiPresenzeETempi(): Promise<{
presenze: Record<string, Record<string, string>>;
tempi: Record<string, Record<string, string>>;
}> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato,risposto_il`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: string;
risposto_il: string;
}>;
const presenze: Record<string, Record<string, string>> = {};
const tempi: Record<string, Record<string, string>> = {};
for (const r of righe) {
(presenze[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
(tempi[r.evento_id] ??= {})[r.giocatore_id] = r.risposto_il;
}
return { presenze, tempi };
}
function giocatoreAzzerato(serieConferme: number, presenze: number): Giocatore {
return {
...giocatori[0]!,
serieConferme,
presenze,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
palloni: 0,
};
}
try {
await prova(
"il segreto si sblocca solo quando entrambe le condizioni sono vere insieme",
async () => {
// "mf1": 15 eventi passati, presente e confermato in fretta a tutti — soddisfa
// ampiamente sia la serie di conferme (>=10) sia le presenze (>=15).
for (let i = 1; i <= 15; i += 1) {
const id = `${PREFISSO}-a${String(i).padStart(2, "0")}`;
await creaEvento(id, i);
await rispondi(id, "mf1", i);
}
const eventi15 = await leggiEventi();
const { presenze: p15, tempi: t15 } = await leggiPresenzeETempi();
// Solo 9 eventi: serie di conferme appena sotto soglia, presenze sotto soglia.
const eventi9 = eventi15.slice(0, 9);
const serie9 = serieConferme("mf1", eventi9, t15, OGGI);
const pres9 = contaPresenzeGiocatore("mf1", eventi9, p15, OGGI);
assert.equal(serie9, 9);
assert.equal(pres9, 9);
assert.equal(
statoBadge(def, giocatoreAzzerato(serie9, pres9)).grado,
null,
"9 conferme e 9 presenze: nessuna delle due soglie raggiunta",
);
const serie15 = serieConferme("mf1", eventi15, t15, OGGI);
const pres15 = contaPresenzeGiocatore("mf1", eventi15, p15, OGGI);
assert.equal(serie15, 15);
assert.equal(pres15, 15);
assert.equal(
statoBadge(def, giocatoreAzzerato(serie15, pres15)).grado,
"oro",
"serie conferme >=10 e presenze >=15: il segreto si sblocca",
);
},
);
await prova("una risposta lenta rompe la serie ma non le presenze già accumulate", async () => {
// Un sedicesimo evento con risposta arrivata oltre le 24h: la serie conferme torna a 0,
// ma le presenze (indipendenti) restano a 16 — il segreto deve richiudersi, non serve
// ripartire da zero anche sulle presenze.
const idLento = `${PREFISSO}-a16`;
await creaEvento(idLento, 16);
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
evento_id: idLento,
giocatore_id: "mf1",
stato: "presente",
risposto_il: `${dataEvento(18)}T08:00:00Z`, // 2 giorni dopo la convocazione
}),
});
if (!res.ok)
throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
const eventi = await leggiEventi();
const { presenze, tempi } = await leggiPresenzeETempi();
const serie = serieConferme("mf1", eventi, tempi, OGGI);
const conto = contaPresenzeGiocatore("mf1", eventi, presenze, OGGI);
assert.equal(serie, 0, "la risposta lenta azzera la serie di conferme");
assert.equal(conto, 16, "le presenze restano quelle di sempre, indipendenti dalla serie");
assert.equal(
statoBadge(def, giocatoreAzzerato(serie, conto)).grado,
null,
"presenze abbondanti ma serie azzerata: il segreto si richiude",
);
});
} finally {
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("s-mai-forfait-badge");
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Badge segreto Aspettate, arrivo! end-to-end contro il database locale:
* `bun test/integration/s-ritardi-badge.test.ts`.
*
* Stessa struttura di `s-infermeria-badge.test.ts` ma sullo stato "ritardo" invece di
* "infortunato": scrive eventi e risposte veri su `eventi_app`/`risposte_presenze`, rilegge
* via REST e passa il risultato attraverso `contaRitardi()` fino a `statoBadge()` sul badge
* segreto `s-ritardi` (soglia 5, unica).
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-s-ritardi-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeSegreti, statoBadge } from "@/lib/badges";
import { contaRitardi } from "@/lib/infortuni";
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge Aspettate, arrivo! sul database", "stack locale non attivo (npx supabase start)");
riepilogo("s-ritardi-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Aspettate, arrivo! su ${URL_BASE}`);
const PREFISSO = "test-s-ritardi-badge";
const def = badgeSegreti.find((b) => b.id === "s-ritardi")!;
const OGGI = "2099-01-01";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
const dataEvento = (i: number) => {
const d = new Date(Date.UTC(2020, 0, 1));
d.setUTCDate(d.getUTCDate() + i - 1);
return d.toISOString().slice(0, 10);
};
async function creaEvento(id: string, i: number) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
id,
tipo: "allenamento",
titolo: `Evento ${id}`,
data: dataEvento(i),
}),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function rispondi(eventoId: string, giocatoreId: string, stato: string) {
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ evento_id: eventoId, giocatore_id: giocatoreId, stato }),
});
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
}
async function leggiEventi(): Promise<Evento[]> {
const res = await rest(
"eventi_app?id=like." +
PREFISSO +
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
);
const righe = (await res.json()) as RigaEvento[];
return righe.map(daRiga);
}
async function leggiPresenze(): Promise<Record<string, Record<string, string>>> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: string;
}>;
const mappa: Record<string, Record<string, string>> = {};
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
return mappa;
}
function giocatoreAzzerato(ritardi: number): Giocatore {
return {
...giocatori[0]!,
ritardi,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
palloni: 0,
presenze: 0,
};
}
try {
await prova("il segreto si sblocca al quinto ritardo vero, non prima", async () => {
for (let i = 1; i <= 5; i += 1) {
const id = `${PREFISSO}-e${i}`;
await creaEvento(id, i);
await rispondi(id, "sr1", "ritardo");
}
const eventiA4 = (await leggiEventi()).filter((e) => e.id !== `${PREFISSO}-e5`);
const presenzeA4 = await leggiPresenze();
const contoA4 = contaRitardi(presenzeA4, eventiA4, OGGI)["sr1"] ?? 0;
assert.equal(contoA4, 4, "solo i primi quattro eventi contati");
assert.equal(
statoBadge(def, giocatoreAzzerato(contoA4)).grado,
null,
"4 ritardi non bastano",
);
const eventi = await leggiEventi();
const presenze = await leggiPresenze();
const conto = contaRitardi(presenze, eventi, OGGI)["sr1"] ?? 0;
assert.equal(conto, 5);
assert.equal(
statoBadge(def, giocatoreAzzerato(conto)).grado,
"oro",
"5 ritardi: il segreto si sblocca (soglia unica)",
);
});
} finally {
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("s-ritardi-badge");
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Badge segreto Uomo tie-break end-to-end contro il database locale:
* `bun test/integration/s-tiebreak-badge.test.ts`.
*
* I test unitari (`test/unit/mvp-voti.test.ts`, `test/unit/pagelle.test.ts`,
* `test/unit/badges.test.ts`) verificano `mvpVintiPerGiocatore()`, `mediePagelle()` e
* `statoBadge()` come funzioni pure. Qui invece si scrivono voti veri su `mvp_voti` e
* `pagelle_voti`, si rileggono via REST e si passa il risultato attraverso
* `mvpVintiPerGiocatore()`/`mediePagelle()` fino a `statoBadge()` sul badge segreto
* `s-tiebreak`.
*
* Copre in particolare il fix di questa sessione: prima `s-tiebreak` usava `g.mediaVoto` senza
* applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale Pagellone che usa lo stesso
* campo un solo voto pagella altissimo poteva sbloccare il segreto insieme a 2 MVP, senza
* significatività statistica. Qui si dimostra con dati reali che ora serve lo stesso minimo di
* voti di Pagellone anche per questo segreto.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-s-tiebreak-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges";
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti";
import { mediePagelle, type VotoPagella } from "@/lib/pagelle";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge Uomo tie-break sul database", "stack locale non attivo (npx supabase start)");
riepilogo("s-tiebreak-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Uomo tie-break su ${URL_BASE}`);
const PREFISSO = "test-s-tiebreak-badge";
const def = badgeSegreti.find((b) => b.id === "s-tiebreak")!;
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function votaMvp(riga: VotoMvp) {
const res = await rest("mvp_voti?on_conflict=match_id,votante_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify(riga),
});
if (!res.ok) throw new Error(`upsert su mvp_voti: ${res.status} ${await res.text()}`);
}
async function votaPagella(riga: VotoPagella) {
const res = await rest("pagelle_voti?on_conflict=match_id,votante_id,votato_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify(riga),
});
if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`);
}
async function leggiMvp(): Promise<VotoMvp[]> {
const res = await rest(
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`,
);
return (await res.json()) as VotoMvp[];
}
async function leggiPagelle(): Promise<VotoPagella[]> {
const res = await rest(
`pagelle_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,voto`,
);
return (await res.json()) as VotoPagella[];
}
function giocatoreAzzerato(mvp: number, mediaVoto: number, votiPagella: number): Giocatore {
return { ...giocatori[0]!, mvp, mediaVoto, votiPagella, palloni: 0, presenze: 0 };
}
try {
await prova(
"sotto la soglia minima di voti pagella, 2 MVP e media alta non bastano",
async () => {
// "tb1" vince nettamente m1 e m2 (2 MVP, due voti a testa per rispettare il quorum
// minimo), e riceve un solo voto pagella da 9 (media alta ma su un campione troppo
// piccolo): il segreto deve restare bloccato.
await votaMvp({
match_id: `${PREFISSO}-m1`,
votante_id: "va",
votato_id: "tb1",
votato_nome: "Uno",
});
await votaMvp({
match_id: `${PREFISSO}-m1`,
votante_id: "vf",
votato_id: "tb1",
votato_nome: "Uno",
});
await votaMvp({
match_id: `${PREFISSO}-m2`,
votante_id: "va",
votato_id: "tb1",
votato_nome: "Uno",
});
await votaMvp({
match_id: `${PREFISSO}-m2`,
votante_id: "vf",
votato_id: "tb1",
votato_nome: "Uno",
});
await votaPagella({
match_id: `${PREFISSO}-m1`,
votante_id: "va",
votato_id: "tb1",
voto: 9,
});
const vinti = mvpVintiPerGiocatore(await leggiMvp());
const medie = mediePagelle(await leggiPagelle());
assert.equal(vinti["tb1"], 2, "2 MVP netti");
assert.equal(medie["tb1"]?.voti, 1, "un solo voto pagella");
assert.equal(medie["tb1"]?.media, 9);
const badge = statoBadge(
def,
giocatoreAzzerato(vinti["tb1"]!, medie["tb1"]!.media, medie["tb1"]!.voti),
);
assert.equal(
badge.grado,
null,
`sotto ${VOTI_MINIMI_PAGELLA} voti: il segreto resta bloccato`,
);
},
);
await prova(`al ${VOTI_MINIMI_PAGELLA}° voto pagella il segreto si sblocca`, async () => {
// Altri 4 voti pagella allo stesso "tb1", sempre alti: raggiunta la soglia minima, con
// 2 MVP e media alta il segreto si sblocca.
for (const [i, votante] of ["vb", "vc", "vd", "ve"].entries()) {
await votaPagella({
match_id: `${PREFISSO}-n${i + 1}`,
votante_id: votante,
votato_id: "tb1",
voto: 9,
});
}
const vinti = mvpVintiPerGiocatore(await leggiMvp());
const medie = mediePagelle(await leggiPagelle());
assert.equal(medie["tb1"]?.voti, 5);
assert.equal(medie["tb1"]?.media, 9);
const badge = statoBadge(
def,
giocatoreAzzerato(vinti["tb1"]!, medie["tb1"]!.media, medie["tb1"]!.voti),
);
assert.equal(badge.grado, "oro", "2 MVP, 5 voti, media 9: il segreto si sblocca");
});
} finally {
await rest(`mvp_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" });
await rest(`pagelle_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" });
}
riepilogo("s-tiebreak-badge");
}
@@ -0,0 +1,206 @@
/**
* Badge Sempre in palestra end-to-end contro il database locale:
* `bun test/integration/serie-allenamenti-badge.test.ts`.
*
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
* `serieConsecutiva()` e `statoBadge()` come funzioni pure, con eventi e risposte costruiti a
* mano. Qui invece si scrivono allenamenti e risposte veri su `eventi_app`/
* `risposte_presenze`, si rileggono via REST con la stessa forma di `fetchPresenze()`/
* `daRiga()`, e si passa il risultato attraverso `serieConsecutiva()` fino a `statoBadge()` sul
* badge `serie-allenamenti`: se una colonna cambia nome o la mappatura si rompe, qui il grado
* del badge torna sbagliato anche se i test unitari restano verdi, perché quelli non toccano
* mai il database.
*
* Copre due comportamenti non ovvi con dati veri, non solo con numeri scelti a mano:
* - un buco nella serie (assente o nessuna risposta) la azzera;
* - un infortunio invece la **congela** (non la azzera): l'allenamento saltato per infortunio
* non conta a favore contro, e la serie riparte da dove si era fermata.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-serie-allenamenti-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges";
import { serieConsecutiva } from "@/lib/presenze";
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge Sempre in palestra sul database", "stack locale non attivo (npx supabase start)");
riepilogo("serie-allenamenti-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Sempre in palestra su ${URL_BASE}`);
const PREFISSO = "test-serie-allenamenti-badge";
const serieDef = badgeDefs.find((b) => b.id === "serie-allenamenti")!;
// "Oggi" fissato nel futuro: gli allenamenti scritti sotto sono datati nel passato remoto,
// così restano "passati" (quindi contati) a prescindere da quando gira il test.
const OGGI = "2099-01-01";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
/** `2020-01-01` per i=1, avanzando di un giorno per ogni evento: niente collisioni di data. */
const dataEvento = (i: number) => {
const d = new Date(Date.UTC(2020, 0, 1));
d.setUTCDate(d.getUTCDate() + i - 1);
return d.toISOString().slice(0, 10);
};
async function creaAllenamento(id: string, i: number) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
id,
tipo: "allenamento",
titolo: `Allenamento ${id}`,
data: dataEvento(i),
}),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function rispondi(eventoId: string, giocatoreId: string, stato: string) {
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ evento_id: eventoId, giocatore_id: giocatoreId, stato }),
});
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
}
async function leggiEventi(): Promise<Evento[]> {
const res = await rest(
"eventi_app?id=like." +
PREFISSO +
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
);
const righe = (await res.json()) as RigaEvento[];
return righe.map(daRiga);
}
async function leggiPresenze(): Promise<Record<string, Record<string, string>>> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: string;
}>;
const mappa: Record<string, Record<string, string>> = {};
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
return mappa;
}
function giocatoreAzzerato(serieAllenamenti: number): Giocatore {
return {
...giocatori[0]!,
serieAllenamenti,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
palloni: 0,
presenze: 0,
};
}
try {
await prova(
"il badge passa da bronzo a oro con presenze consecutive vere, e un buco lo azzera",
async () => {
// 10 allenamenti passati, tutti presenti per "sa1": bronzo (3), poi argento (6), poi
// oro (10) man mano che si rileggono i dati.
for (let i = 1; i <= 10; i += 1) {
await creaAllenamento(`${PREFISSO}-a${String(i).padStart(2, "0")}`, i);
await rispondi(`${PREFISSO}-a${String(i).padStart(2, "0")}`, "sa1", "presente");
}
const eventi10 = await leggiEventi();
const presenze10 = await leggiPresenze();
const serieA3 = serieConsecutiva(
"sa1",
eventi10.slice(0, 3),
presenze10,
"allenamento",
OGGI,
);
assert.equal(serieA3, 3);
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieA3)).grado,
"bronzo",
"3 allenamenti di fila: soglia bronzo",
);
const serieCompleta = serieConsecutiva("sa1", eventi10, presenze10, "allenamento", OGGI);
assert.equal(serieCompleta, 10, "presente a tutti e 10, nessun buco");
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieCompleta)).grado,
"oro",
"10 allenamenti di fila: soglia oro",
);
// Un undicesimo allenamento con un buco (assente) azzera la serie.
await creaAllenamento(`${PREFISSO}-a11`, 11);
await rispondi(`${PREFISSO}-a11`, "sa1", "assente");
const eventiConBuco = await leggiEventi();
const presenzeConBuco = await leggiPresenze();
const serieDopoBuco = serieConsecutiva(
"sa1",
eventiConBuco,
presenzeConBuco,
"allenamento",
OGGI,
);
assert.equal(serieDopoBuco, 0, "un'assenza azzera la serie, anche dopo 10 di fila");
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieDopoBuco)).grado,
null,
"serie azzerata: nessun grado, si riparte da zero",
);
},
);
await prova("un infortunio congela la serie invece di azzerarla", async () => {
// "sa2": presente ai primi due allenamenti, poi infortunato al terzo (saltato, non
// conta né a favore né contro), poi di nuovo presente: la serie deve restare a 3, non
// ripartire da 1.
const eventi = [`${PREFISSO}-b01`, `${PREFISSO}-b02`, `${PREFISSO}-b03`, `${PREFISSO}-b04`];
for (let i = 0; i < eventi.length; i += 1) {
await creaAllenamento(eventi[i]!, 20 + i);
}
await rispondi(eventi[0]!, "sa2", "presente");
await rispondi(eventi[1]!, "sa2", "presente");
await rispondi(eventi[2]!, "sa2", "infortunato");
await rispondi(eventi[3]!, "sa2", "presente");
const eventiLetti = await leggiEventi();
const presenzeLette = await leggiPresenze();
const serie = serieConsecutiva("sa2", eventiLetti, presenzeLette, "allenamento", OGGI);
assert.equal(serie, 3, "l'infortunio è saltato: 3 presenze restano consecutive");
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serie)).grado,
"bronzo",
"3 di fila (infortunio escluso dal conteggio): soglia bronzo",
);
});
} finally {
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("serie-allenamenti-badge");
}
@@ -0,0 +1,229 @@
/**
* Badge Risposta lampo end-to-end contro il database locale:
* `bun test/integration/serie-conferme-badge.test.ts`.
*
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
* `serieConferme()` e `statoBadge()` come funzioni pure, con eventi e risposte costruiti a
* mano. Qui invece si scrivono allenamenti/partite ed eventi veri su `eventi_app`, e risposte
* vere su `risposte_presenze` con `creato_il`/`risposto_il` espliciti, si rileggono via REST
* con la stessa forma di `daRiga()`/`fetchPresenze()`, e si passa il risultato attraverso
* `serieConferme()` fino a `statoBadge()` sul badge `serie-conferme`: se una colonna cambia
* nome, o la mappatura `creato_il`/`risposto_il` -> `creatoIl`/`tempi` si rompe, qui il grado
* del badge torna sbagliato anche se i test unitari restano verdi, perché quelli non toccano
* mai il database.
*
* Copre due comportamenti non ovvi con dati veri, non solo con numeri scelti a mano:
* - una risposta arrivata oltre le 24h dalla convocazione azzera la serie, anche dopo una
* striscia lunga;
* - partite e allenamenti contano nella stessa serie, senza bisogno di un filtro per tipo.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-serie-conferme-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges";
import { serieConferme } from "@/lib/presenze";
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge Risposta lampo sul database", "stack locale non attivo (npx supabase start)");
riepilogo("serie-conferme-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Risposta lampo su ${URL_BASE}`);
const PREFISSO = "test-serie-conferme-badge";
const serieDef = badgeDefs.find((b) => b.id === "serie-conferme")!;
// "Oggi" fissato nel futuro: gli eventi scritti sotto sono datati nel passato remoto, così
// restano "passati" (quindi contati) a prescindere da quando gira il test.
const OGGI = "2099-01-01";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
/** `2020-01-01` per i=1, avanzando di un giorno per ogni evento: niente collisioni di data. */
const dataEvento = (i: number) => {
const d = new Date(Date.UTC(2020, 0, 1));
d.setUTCDate(d.getUTCDate() + i - 1);
return d.toISOString().slice(0, 10);
};
/** Istante di convocazione: le 8 del mattino del giorno dell'evento. */
const creazioneEvento = (i: number) => `${dataEvento(i)}T08:00:00Z`;
async function creaEvento(
id: string,
i: number,
tipo: "allenamento" | "partita" = "allenamento",
) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
id,
tipo,
titolo: `Evento ${id}`,
data: dataEvento(i),
creato_il: creazioneEvento(i),
}),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function rispondi(
eventoId: string,
giocatoreId: string,
stato: string,
rispostoIl: string,
) {
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
evento_id: eventoId,
giocatore_id: giocatoreId,
stato,
risposto_il: rispostoIl,
}),
});
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
}
async function leggiEventi(): Promise<Evento[]> {
const res = await rest(
"eventi_app?id=like." +
PREFISSO +
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
);
const righe = (await res.json()) as RigaEvento[];
return righe.map(daRiga);
}
/** Stessa forma di `fetchPresenze()`: eventoId -> giocatoreId -> istante della risposta. */
async function leggiTempi(): Promise<Record<string, Record<string, string>>> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,risposto_il`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
risposto_il: string;
}>;
const mappa: Record<string, Record<string, string>> = {};
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.risposto_il;
return mappa;
}
function giocatoreAzzerato(serieConferme: number): Giocatore {
return {
...giocatori[0]!,
serieConferme,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
palloni: 0,
presenze: 0,
};
}
try {
await prova(
"il badge passa da bronzo a oro con conferme rapide vere, e una risposta lenta lo azzera",
async () => {
// 15 eventi passati, tutti confermati mezz'ora dopo la convocazione per "sc1":
// bronzo (3), poi argento (8), poi oro (15) man mano che si rileggono i dati.
for (let i = 1; i <= 15; i += 1) {
const id = `${PREFISSO}-a${String(i).padStart(2, "0")}`;
await creaEvento(id, i);
await rispondi(id, "sc1", "presente", `${dataEvento(i)}T08:30:00Z`);
}
const eventi15 = await leggiEventi();
const tempi15 = await leggiTempi();
const serieA3 = serieConferme("sc1", eventi15.slice(0, 3), tempi15, OGGI);
assert.equal(serieA3, 3);
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieA3)).grado,
"bronzo",
"3 conferme rapide di fila: soglia bronzo",
);
const serieA8 = serieConferme("sc1", eventi15.slice(0, 8), tempi15, OGGI);
assert.equal(serieA8, 8);
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieA8)).grado,
"argento",
"8 conferme rapide di fila: soglia argento",
);
const serieCompleta = serieConferme("sc1", eventi15, tempi15, OGGI);
assert.equal(serieCompleta, 15, "confermato in fretta a tutti e 15, nessun buco");
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieCompleta)).grado,
"oro",
"15 conferme rapide di fila: soglia oro",
);
// Un sedicesimo evento con una risposta arrivata dopo 2 giorni azzera la serie.
const idLento = `${PREFISSO}-a16`;
await creaEvento(idLento, 16);
await rispondi(idLento, "sc1", "presente", `${dataEvento(18)}T08:00:00Z`);
const eventiConRitardo = await leggiEventi();
const tempiConRitardo = await leggiTempi();
const serieDopoRitardo = serieConferme("sc1", eventiConRitardo, tempiConRitardo, OGGI);
assert.equal(
serieDopoRitardo,
0,
"una risposta oltre le 24h azzera la serie, anche dopo 15 conferme di fila",
);
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieDopoRitardo)).grado,
null,
"serie azzerata: nessun grado, si riparte da zero",
);
},
);
await prova(
"partite e allenamenti contano nella stessa serie, senza filtro per tipo",
async () => {
// "sc2": conferma rapida su un allenamento, poi su una partita, poi su un altro
// allenamento: la serie sale a 3 senza bisogno che siano tutti dello stesso tipo.
await creaEvento(`${PREFISSO}-b01`, 30, "allenamento");
await creaEvento(`${PREFISSO}-b02`, 31, "partita");
await creaEvento(`${PREFISSO}-b03`, 32, "allenamento");
await rispondi(`${PREFISSO}-b01`, "sc2", "presente", `${dataEvento(30)}T08:30:00Z`);
await rispondi(`${PREFISSO}-b02`, "sc2", "presente", `${dataEvento(31)}T08:30:00Z`);
await rispondi(`${PREFISSO}-b03`, "sc2", "presente", `${dataEvento(32)}T08:30:00Z`);
const eventiLetti = await leggiEventi();
const tempiLetti = await leggiTempi();
const serie = serieConferme("sc2", eventiLetti, tempiLetti, OGGI);
assert.equal(serie, 3, "partita in mezzo a due allenamenti: la serie resta a 3");
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serie)).grado,
"bronzo",
"3 conferme rapide di fila, tipo misto: soglia bronzo",
);
},
);
} finally {
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("serie-conferme-badge");
}

Some files were not shown because too many files have changed in this diff Show More