71 Commits
Author SHA1 Message Date
davideandClaude Opus 5 822180bffc Riscrive AGENTS.md e riallinea la documentazione allo stato reale.
I riferimenti ai documenti in AGENTS.md erano rotti: una venticinquina di link
nella forma docs/[README.md](http://README.md), che spezzavano il nome del file
a meta e puntavano a domini inesistenti. Ora sono percorsi relativi verificati,
con CHANGELOG.md e DESIGN_DECISIONS.md sotto docs/ e PROJECT_STATE.md in root.

Tolte da AGENTS.md le sezioni Architettura, Documentazione e Struttura della
documentazione: duplicavano ARCHITECTURE.md e docs/README.md con uno stack ormai
parziale, contro la regola "ogni informazione ha una sola casa" che docs/README.md
stesso impone. Aggiunti invece i comandi, bun e la guardia minimumReleaseAge:
Codex e Cursor leggono solo AGENTS.md e non avevano modo di sapere come si
verifica una modifica. Scritta la checklist "Fine lavoro" che CLAUDE.md citava
senza che esistesse.

Nuova regola: chi aggiunge o modifica una funzione scrive o aggiorna il test nello
stesso lavoro, i test devono essere verdi e la doc del modulo va aggiornata se il
comportamento cambia (DD-020). Serve perche con main come branch di lavoro non
c'e piu un ambiente di prova tra il codice e i giocatori.

Il flusso git documentato non descriveva piu la realta: main e arrivato a 43
commit di vantaggio su develop, rimasto fermo. DD-003 e ora sostituita da DD-019:
il branch dei commit lo decide l'utente, l'assistente al massimo consiglia un
branch dedicato e non committa, non pusha e non apre PR di propria iniziativa.

Allineati di conseguenza ARCHITECTURE.md (sezione branch), README.md (flusso,
install con bun, comandi di test e lint), ROADMAP.md e TODO.md (le voci spuntate
sono in produzione, non su develop) e PROJECT_STATE.md (auth e profilo giocatore
in produzione, 20 migration fino a M9, passaggi 1-3 e 5 fatti).

Corretti poi sei disallineamenti tra documentazione e codice, ognuno verificato
sul sorgente:

- badge.md e obiettivi-squadra.md dicevano che le serie sono inerti e che
  serieAllenamenti e sempre 0, quindi badge e obiettivo "Continuita di squadra"
  non sbloccabili. Falso da 7237e8f: presenze.ts:48 le calcola e rosa.ts:64-67 le
  attacca al Giocatore. Il limite che resta e un altro, ora scritto: risposto_il
  non e ricostruibile prima di m9, quindi sulle risposte vecchie serieConferme e
  un'approssimazione.
- TODO.md e PROJECT_STATE.md davano il tracciamento tesseramento CSI come da
  fare, mentre ROADMAP, CHANGELOG e DATABASE lo davano per fatto. Lo e:
  admin.tsx:264-278 registra numero e data, :472 mostra Tesserato/Da tesserare,
  :676 il contatore.
- collegamento-csi.md indicava il check di parsing in src/lib/csi-core.test.ts;
  sta in test/unit/csi-core.test.ts, in src/lib non esiste nessun .test.ts.
- profilo-giocatore.md annunciava cinque aree del profilo e ne elencava sette.
- "Segnala un bug" e "Suggerisci una nuova funzionalita" (profilo.tsx:259-276,
  commit 72a9864) non erano documentati da nessuna parte, contro DD-002: ora
  stanno in profilo-giocatore.md e nel CHANGELOG.

npm run test: 28/28 file ok. npm run lint: 12 problemi, identici a prima di
questa modifica e tutti in src/, non toccato qui.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:24:22 +02:00
davideandClaude Opus 5 7237e8ff39 Calcola davvero le serie di presenze e sblocca badge e obiettivo.
I tre contatori (serieAllenamenti, seriePartite, serieConferme) e streak erano
zero fisso in useRosa(): la sezione Serie di presenze del profilo mostrava sempre
progresso nullo, i badge legati alla costanza erano impossibili da sbloccare e
l'obiettivo Continuita di squadra restava a 0/12.

serieConsecutiva() e serieConferme() (src/lib/presenze.ts) derivano le serie dagli
eventi passati e da risposte_presenze gia in cache, senza query aggiuntive.

Migration m9: nuova colonna risposto_il con l'istante della prima risposta, resa
immutabile da un trigger, confrontata con eventi_app.creato_il per le conferme
entro 24 ore. Serviva perche aggiornato_il registra l'ultima modifica, quindi chi
rispondeva subito e cambiava idea dopo risultava lento.

Corregge anche la barra di progresso, che misurava valore/prossimo e tornava
indietro a ogni traguardo raggiunto (2/3 = 67%, poi 3/6 = 50%).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:46:04 +02:00
Ivan CacciariandCursor 952f7b43d3 Sposta Completa il tuo profilo sopra Prossimo impegno in home.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 12:50:43 +02:00
Ivan CacciariandCursor 438076c572 Rende collassabili badge e tesseramento nel profilo e toglie Scout Live dalla home.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 12:43:53 +02:00
Ivan CacciariandCursor fe1ac5434c Alleggerisce Squadra e Classifica con sezioni a tendina e limita i prossimi eventi a 4.
Rimuove anche Scout Live dalle statistiche di squadra per snellire la schermata.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 12:01:13 +02:00
Ivan CacciariandCursor 9aef361bf0 Sposta lo storico match in Classifica e rimuove i risultati ufficiali duplicati.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 11:40:03 +02:00
davide f70430e1bf Retro-documenta i moduli v1.0 mancanti in docs/modules/
Presenze, Serie di presenze, Scout Live, Pagelle, MVP, Badge, Palloni,
Obiettivi di squadra, Infortuni, Notifiche: chiudono il debito di
documentazione tracciato in TODO.md (DD-002). Le sei route API
pubbliche vengono descritte dentro il modulo a cui appartengono.
2026-09-03 15:10:18 +02:00
davide 72a9864e94 Aggiunge segnalazione bug e richiesta funzionalità dal profilo
I template GitHub guidano chi apre una issue a fornire titolo,
descrizione strutturata ed eventuali screenshot invece di un campo
libero. Dal profilo, prima di "Esci", due link aprono direttamente
il template giusto su GitHub.
2026-09-03 14:46:38 +02:00
davide 2db2086c10 Aggiunge licenza 2026-09-03 14:23:56 +02:00
davide ac4a4a96e8 Blocca il salvataggio se il numero di maglia è già assegnato
Prima era solo un warning e il salvataggio procedeva comunque,
generando doppioni non voluti in rosa.
2026-09-03 14:22:11 +02:00
davide 6286c18a72 Sostituisce l'input libero del ruolo con un menu a tendina
Evita ruoli scritti in modo incoerente (maiuscole, refusi) sia in
modifica che in aggiunta giocatore.
2026-09-03 14:17:30 +02:00
davideandClaude Sonnet 5 70b4123452 Forza la revalidazione dell'avatar dopo la sostituzione della foto
Con cacheControl: "60" l'immagine sostituita poteva restare quella
vecchia per un minuto se la CDN davanti allo storage non varia la
cache in base alla query string di cache-busting (?v=).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:05:50 +02:00
davideandClaude Sonnet 5 0c2a6c5330 Chiede conferma prima di cambiare o rimuovere la foto profilo
Entrambe le azioni avvenivano subito, senza possibilità di annullare
per errore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:58:32 +02:00
davideandClaude Sonnet 5 5c9ffb84e8 Filtri classifica giocatori tutti su una riga senza scroll
Rinomina "Cacche/partita" in "Cacche" e passa i pill da flex-wrap a
flex-nowrap con flex-1/truncate, così entrano tutti su una riga sola
senza andare a capo né richiedere swipe orizzontale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:41:16 +02:00
davideandClaude Sonnet 5 988eb6d575 Evita lo scroll orizzontale nei filtri della classifica giocatori
I pill (Presenze, Media voto, MVP, Palloni, Cacche/partita) andavano su
una riga scrollabile orizzontalmente; ora vanno a capo con flex-wrap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:36:52 +02:00
davideandClaude Sonnet 5 0710d143a9 Documenta l'applicazione della migration M4 in produzione
Il gate "M4 non applicabile finché la squadra non è collegata" è superato:
il login era già l'unica via d'accesso lato app, quindi i giocatori non
ancora collegati non erano comunque impattati dal residuo di accesso anon
che M4 chiudeva. Aggiorna anche DD-018, ormai stale: l'email dei giocatori
si imposta da /admin, non solo via migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:28:23 +02:00
davideandClaude Sonnet 5 9c173df753 Corregge riferimenti alla migration M3, mai applicata
M3 esisteva solo come bozza archiviata in docs/archive/migrations/: il bucket
profili-giocatore è creato dalla migration M2 insieme alla tabella. Allinea
PROJECT_STATE.md (12 -> 18 migration), DATABASE.md, CHANGELOG.md, TODO.md,
DESIGN_DECISIONS.md, test/README.md e il test di integrazione dei profili.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:00:27 +02:00
davideandClaude Mythos a07c8104d5 Aggiorna docs/modules/collegamento-csi.md: fallback non è più sui dati demo
I dati dimostrativi in crapp-data.ts sono stati rimossi (DD-015); il fallback
reale oggi è classifica vuota (o ultimo dato in cache) e risultati dallo
Scout Live locale.

Co-Authored-By: Claude Mythos <noreply@anthropic.com>
2026-09-03 10:40:03 +02:00
davide 224bb93beb Chiude DD-015: la rosa non è più hardcoded
Aggiorna DESIGN_DECISIONS.md (DD-015 da "In valutazione" ad "Accettata",
con le conseguenze reali: fallback nascita e crapp-data.ts come solo
seed/fallback), DATABASE.md, ARCHITECTURE.md e PROJECT_STATE.md di
conseguenza.
2026-09-03 10:17:13 +02:00
davide 923d1fe762 Aggiorna i test per le nuove firme con rosa esplicita
completaTurni() e csvScoutMatch() prendono ora la rosa come parametro
invece di leggerla dalla lista statica di crapp-data.ts.
2026-09-03 10:17:07 +02:00
davide 4c0ea66126 Aggancia Squadra, Presenze, Pagelle, Scout e calendario alla rosa reale
convocatiEvento() e compleanniEventi() prendono ora la rosa come parametro
invece di leggere la lista statica; csvScoutMatch() idem. Le schermate di
gestione eventi, dettaglio allenamento/partita, calendario e scout live,
più i widget di presenze/pagelle/voto MVP/voto social/sondaggio
cacche/turno palloni, passano tutte la rosa letta da useRosa() o
useGiocatoriSquadra(): un giocatore aggiunto o disattivato dalla dashboard
admin ora si riflette ovunque.
2026-09-03 10:17:03 +02:00
davide b6c0f40f66 Aggancia funzioni di libreria e route push alla rosa reale
obiettivi.ts, palloni-core.ts, presenze-mese.ts e user-store.ts non
dipendono più dalla lista statica di crapp-data.ts ma ricevono/leggono la
rosa reale (percentuali obiettivo, rotazione turno palloni, riepilogo
presenze mensile, identità del dispositivo). Le route API per le notifiche
push (push-messaggio, promemoria-palloni, sollecita-presenze) leggono la
squadra lato server con leggiGiocatoriSquadra(), filtrando attivo.
2026-09-03 10:16:52 +02:00
davide 7e93229eee Sposta QueryClientProvider sopra AppShell per evitare crash SSR
useGiocatoreBase() ora interroga giocatori_squadra con useQuery, ma
RootComponent la chiamava prima di montare QueryClientProvider: ogni pagina
falliva in SSR con "No QueryClient set". Il provider avvolge ora tutto fin
dall'inizio; la logica di redirect/loading si sposta in un componente
AppShell interno, comportamento invariato.
2026-09-03 10:16:40 +02:00
davide 4470139504 Aggancia useRosa() a giocatori_squadra (DD-015)
useRosa()/useIo()/useObiettivi() leggono ora l'anagrafica da giocatori_squadra
tramite useGiocatoriSquadra(), filtrando solo i giocatori attivo. crapp-data.ts
resta il seed storico e il fallback (rosaFallback) e fornisce anche la data di
nascita per id, non ancora una colonna della tabella. Aggiunge
giocatori-squadra.server.ts per leggere la rosa lato route API (stesso pattern
di eventi.server.ts).
2026-09-03 10:16:35 +02:00
davideandClaude Sonnet 5 cce6525f09 Rimuove la dicitura DEVELOP dalla schermata di benvenuto
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:33:27 +02:00
davideandClaude Sonnet 5 c7445cfab1 Aggiunge il tracciamento del tesseramento CSI in giocatori_squadra
Numero e data della tessera arrivano dal comitato dopo l'iscrizione, quindi
li scrive solo un admin (come numero/ruolo): estende il trigger di M1/M5,
aggiunge il pannello dedicato in /admin con badge e conteggio in dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:27:12 +02:00
davideandClaude Sonnet 5 ff45b2379c Aggiunge unit test per i moduli lib rimasti scoperti
Copre la logica pura isolabile di avatar-store, error-capture, error-page,
lovable-error-reporting, profili (validazione upload), push-client (guardie
senza DOM), scout-stato e webpush.server (firma JWT VAPID con chiavi P-256
generate al volo, fetch intercettato). Alza i file di src/lib coperti da 19
a 28 su 37, senza introdurre nuove dipendenze.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 11:34:09 +02:00
davide d3b117e470 Ordina la rosa e la squadra per cognome
Sposta dividiNome in crapp-data.ts per riordinare la rosa di fallback per
cognome; la query a giocatori_squadra ora ordina per cognome, nome invece
che per id.
2026-09-02 10:59:28 +02:00
davide 64771fffc6 Chiude l'accesso anonimo a scout_partite in M7
Il REVOKE per scout_partite andava messo qui, dove la tabella viene creata,
non in M4. Allinea l'accesso anonimo alle altre tabelle post-M4.
2026-09-02 10:59:20 +02:00
davide 395cee3c9c Rimuove REVOKE su scout_partite da M4 (tabella non ancora esistente)
La tabella scout_partite viene creata solo in M7 (2026-09-01): il REVOKE in
questa migration del 2026-08-31 precedeva la sua creazione.
2026-09-02 10:59:16 +02:00
davideandClaude Sonnet 5 5e4ad307c9 Aggiorna CHANGELOG e PROJECT_STATE con M6 e M7
docs/CHANGELOG.md e PROJECT_STATE.md erano fermi al 30/08 e non riportavano
gli ultimi due commit: foto profilo su Supabase Storage (M6) e
sincronizzazione dello Scout Live tra dispositivi (M7).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 09:43:22 +02:00
Ivan CacciariandCursor c52e4c4e27 Separate player presence from scout stats
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 22:42:35 +02:00
Ivan CacciariandCursor d63beae04c Replace demo stats with real data
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 22:34:07 +02:00
davideandClaude Sonnet 5 c314a08f6d Sincronizza lo Scout Live tra dispositivi (blocco e archivio partite)
Due bug architetturali, entrambi con la stessa causa: dati che vivevano
solo in localStorage, quindi visibili a un solo dispositivo.

- Il blocco "chi sta scoutando" (scout-live.ts) non funzionava mai tra
  telefoni diversi: due persone potevano prendere il controllo insieme
  da dispositivi diversi, sovrascrivendosi a vicenda le azioni nel
  salvataggio condiviso. Ora usa scout_sessioni, tabella già presente
  nello schema ma mai collegata al codice.

- La partita scoutata finita (scout-store.ts) veniva salvata solo in
  localStorage: "Punti/Ace/Muri squadra" e le presenze derivate dallo
  scout esistevano solo sul telefono di chi aveva chiuso la partita.
  Nuova tabella scout_partite archivia la partita completa (azioni
  incluse) per tutta la squadra.

useScoutMatches() mantiene la stessa firma di prima (ScoutMatch[]), ora
alimentata da una query invece che da localStorage: nessuna modifica
necessaria nei punti che la leggono (squadra, classifica, home,
dettaglio partita, rosa). Rigenerati i tipi Supabase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 16:08:59 +02:00
davideandClaude Sonnet 5 d2d62b6799 Sincronizza le foto profilo dei giocatori su Supabase Storage
Le foto caricate in Profilo vivevano solo in localStorage: ogni giocatore
vedeva la propria foto solo sul proprio dispositivo, mai quella dei
compagni nella rosa (Squadra). Ora vengono caricate in un bucket
pubblico dedicato (avatar-giocatori, migration m6) e il componente
Avatar carica l'URL pubblico con fallback su numero/iniziali se assente
o non ancora caricata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 14:32:19 +02:00
davideandClaude Sonnet 5 f6036f21ec Rende raggiungibile il voto MVP dalle card dei risultati
"Ultima partita" in home e le card di "Storico match" in squadra ora
sono link a /partita/$id quando esiste un evento di calendario con la
stessa data del risultato (CSI o scout), con un'indicazione "Vota MVP"
quando non è ancora stato eletto. Prima erano card statiche: per votare
bisognava trovare la partita a mano dal calendario.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 13:56:56 +02:00
davideandClaude Sonnet 5 1a93c7657a Aggiorna i test dopo la rimozione della classifica e dello storico dummy
Tolti i check su `classifica`, `classificaConScout` e `storicoMatch`,
non più esportati da crapp-data.ts / scout-store.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 13:39:12 +02:00
davideandClaude Sonnet 5 85223baa9d Formatta la documentazione con prettier
Solo whitespace: tabelle allineate, enfasi normalizzata (* -> _), a capo
coerenti. Nessuna modifica di contenuto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 13:38:56 +02:00
davideandClaude Sonnet 5 0a04025fe9 Rimuove i dati dummy della classifica e usa i risultati CSI reali
La classifica e lo storico partite mostravano dati inventati (squadre e
risultati finti) come base, poi sovrascritti dai dati CSI quando
disponibili. Ora classifica, ultimi risultati, storico match e obiettivi
di squadra legati alle vittorie usano solo dati reali (CSI o scout live),
con stati vuoti quando i dati CSI non sono ancora disponibili.

Include anche la correzione di tutti gli errori di formattazione
prettier segnalati da `npm run lint` sul resto del codice sorgente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 13:38:33 +02:00
davideandClaude Sonnet 5 d813dee282 Dashboard admin: aggiungi, disattiva e riattiva giocatori
- Aggiungi giocatore: nuovo form in /admin (nome, cognome, numero,
  ruolo, email opzionale), id g<N> calcolato in automatico.
- Email modificabile anche per i giocatori già in rosa dal pannello
  Dati squadra, non solo alla creazione — completa quanto rimandato
  da DD-018.
- Disattiva/Riattiva: un giocatore che lascia la squadra sparisce
  dalla rosa attiva senza che la riga venga eliminata, così presenze,
  voti, pagelle e badge della stagione restano agganciati al suo id.
  Nuova sezione "Giocatori disattivati" per riattivarli.
- Messaggio d'errore leggibile per l'unico vincolo unique della
  tabella (email duplicata) invece del codice Postgres grezzo.

Nessuna migration: sia l'inserimento sia la modifica passano dalla
policy admin FOR ALL già esistente su giocatori_squadra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 11:29:41 +02:00
davideandClaude Mythos 13e5c3bd23 Fix: il calendario apre il mese corrente invece di agosto 2026
useMeseNav aveva un default hardcoded (anno 2026, mese 7) invece di
calcolare oggi. Chiunque apra /calendario finiva sempre su agosto
2026, indipendentemente dalla data reale.

Co-Authored-By: Claude Mythos <noreply@anthropic.com>
2026-09-01 10:58:23 +02:00
davideandClaude Mythos 215af4bbc4 DD-018: collegamento automatico giocatore-account per email
Al primo accesso l'app collega da sola l'account Google al giocatore
la cui email registrata coincide (case-insensitive), invece di far
scegliere il nome da un elenco. Senza corrispondenza compare solo un
messaggio d'errore con un pulsante per uscire e riprovare con un altro
account: nessuna scelta manuale di ripiego.

- Migration m5: colonna `email` su giocatori_squadra, seed per Ivan
  Cacciari e Davide Grilli, trigger esteso per richiedere anche il
  match email oltre allo slot libero.
- slotPerEmail() sostituisce slotLiberi() (rimossa, senza più
  chiamanti in produzione).

Co-Authored-By: Claude Mythos  <noreply@anthropic.com>
2026-09-01 10:46:54 +02:00
davideandClaude Sonnet 5 127e7c76e9 Copre giocatori-squadra e la scelta dei messaggi push palloni
Estrae in palloni-core.ts la logica pura di push-messaggio e
promemoria-palloni (finora mista alle chiamate Supabase), così da
poterla testare senza scrivere sul database. Aggiunge test per le
funzioni pure di giocatori-squadra.ts, finora senza copertura.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 09:39:09 +02:00
davide aa1c49542b Merge branch 'develop' into main 2026-09-01 09:28:19 +02:00
davideandClaude Opus 5 1036eb860d DD-011: make Google login the only way in
Remove the free player selection from /benvenuto: without a Supabase session
no screen renders, and the VITE_AUTH_OBBLIGATORIA bridge flag is gone.
Admin rights now come only from user_roles, so the hardcoded name list in
crapp-data.ts is deleted along with its tests.

Add migration m4_solo_autenticati, which revokes anon access to the v1.0
tables. Apply it only once the whole team has linked an account.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:21:34 +02:00
ivancacciari1995-a11y f325c0485c Merge pull request #2 from ivancacciari1995-a11y/fix/migration-history-cleanup
Clean up superseded profile migrations
2026-08-31 17:26:36 +02:00
Ivan Cacciari 5a6aaad885 Clean up superseded profile migrations 2026-08-31 17:15:58 +02:00
Ivan Cacciari e4f963d170 Update AI and collaboration workflow 2026-08-31 15:31:10 +02:00
Ivan CacciariandCursor f424b3a9f7 Add M2 migration for player profiles and private storage.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 15:25:29 +02:00
davideandClaude Opus 5 990c2bbd4c Add commit conventions for AI assistants
Commit only when asked, message written by the assistant in English.
The only exception to the Italian-everywhere rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 22:47:55 +02:00
davideandClaude Opus 5 764f75085e Sync roadmap and project state with the code
Mark medical certificates, admin dashboard and CSV export as done: the
player-side profile screens exist, so the notes calling them missing were
stale. Leave CSI membership and official calendar open, each with what is
already there.

Record the current dev state of Google auth: implemented but returning
"provider is not enabled" until the provider is turned on in Supabase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 22:47:55 +02:00
davideandClaude Opus 5 9e17c2fadd DD-017: l'admin scrive al posto del giocatore
Registra la decisione prima del codice, come chiede AGENTS.md.

Il modello dei permessi passa da "ognuno i suoi" a "ognuno i suoi, più l'admin
su tutti, tranne i file": senza, l'export per il tesseramento resta incompleto e
il lavoro amministrativo torna in chat, contro la missione del progetto. Gli
upload restano al giocatore perché su documenti d'identità e dati sanitari la
catena di responsabilità deve restare leggibile.

Aggiornati di conseguenza il documento di modulo (utenti, azioni della
dashboard, permessi) e il changelog. Annotato nel riesame che oggi non esiste
audit di chi modifica un dato.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:15:16 +02:00
davideandClaude Opus 5 53b2252997 Test sulla validazione dei dati squadra
Copre i controlli che evitano un errore Postgres di ritorno: nome, cognome e
ruolo non vuoti, numero di maglia intero e positivo (il CHECK della tabella), e
il rilevamento di un numero già assegnato a un altro giocatore attivo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:15:16 +02:00
davideandClaude Opus 5 718ef09dfa L'amministratore può modificare i dati dei giocatori
Attua DD-017. La scheda della dashboard era di sola lettura: ora l'admin apre il
giocatore e modifica.

- Dati squadra (nome, cognome, numero, ruolo): le docs li assegnavano già agli
  amministratori, ma non esisteva nessuna schermata per cambiarli.
- Dati personali e del documento: compilabili al posto del giocatore, perché un
  export CSI incompleto rimanda il lavoro in chat.
- Scollega account: libera uno slot assegnato per errore, come previsto da
  DD-016 regola 2.

I file restano fuori: l'admin li scarica ma non li carica al posto di altri.

Nessuna migration: le policy di M1 e M2 riconoscevano già l'admin. I campi del
profilo diventano un componente condiviso (CampiProfilo) tra la schermata del
giocatore e la dashboard, con gli upload passati come slot: da admin quelle
righe non compaiono.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 18:15:16 +02:00
davideandClaude Opus 5 3f52e9cc54 Documenta dashboard admin, profili e ambiente locale
Checklist "Fine lavoro" di AGENTS.md per il lavoro dei due commit precedenti.

- DATABASE.md: profili_giocatore creata, user_roles come fonte dei permessi, e
  la nuova sezione Storage per il bucket privato.
- ARCHITECTURE.md: autenticazione e ruoli tra i punti fermi, comandi dello stack
  Supabase locale; gli stessi comandi in CLAUDE.md.
- CHANGELOG.md: la voce è marcata come presente su develop e non ancora in
  produzione.
- PROJECT_STATE.md: i cinque passaggi per attivare login e dashboard in
  produzione, in ordine, con il redirect URI di Google e la query per il primo
  admin. Segnalato che dev e produzione condividono lo stesso database.
- TODO.md: la dashboard esce dal backlog; entra il profilo lato giocatore, senza
  il quale la dashboard resterebbe senza dati.
- modules/profilo-giocatore.md: registrate due scelte fatte in corso d'opera —
  solo Google al posto di "Google oppure Email", e "Visualizza profilo" come
  scheda in linea invece di una schermata separata.

ROADMAP.md resta con la casella non spuntata: la funzionalità è su develop, non
ancora rilasciata.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 17:58:24 +02:00
davideandClaude Opus 5 255afde48e Test per profili, ruoli e permessi sul database
Copre le parti nuove e una trappola che sarebbe passata inosservata.

- unit: completamento del profilo (30/30/30/10), stato di scadenza dei
  documenti, export CSV, conversione riga <-> modello, anagrafica di squadra.
- unit: risoluzione dei permessi admin, incluso il fatto che con una sessione
  attiva decide il database e la lista di nomi non conta più.
- integration (schema-profili): verifica su un database vero le colonne che il
  codice legge, il bucket privato e la chiusura verso l'utente anonimo.
- e2e: /admin entra nell'elenco delle schermate verificate.

schema-profili tenta scritture da anonimo per dimostrare che la RLS le respinge,
e poi rilegge la riga: su un UPDATE che tocca zero righe PostgREST risponde 2xx,
quindi fidarsi del codice di risposta darebbe un falso verde. Si salta da solo
dove M2/M3 non sono ancora applicate, indicandolo nel motivo.

Verificato: 8/8 contro lo stack locale (che ha M2/M3), 21/21 file con
npm run test:all contro il progetto cloud.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 17:58:12 +02:00
davideandClaude Opus 5 da51517ffc Dashboard amministratore e profilo per il tesseramento
Implementa la voce "Dashboard amministratore" della roadmap v1.1, come descritta
in docs/modules/profilo-giocatore.md, insieme alla parte di profilo che la
alimenta.

- /admin: stato dei profili della squadra, download di documento, certificato e
  foto tessera, export CSV con i 12 campi del tesseramento CSI. Un certificato
  scaduto non conta come valido.
- Profilo giocatore: dati personali, documento (fronte e retro), certificato e
  foto tessera, con widget di completamento in Home che sparisce al 100%.
- I permessi di amministrazione arrivano da user_roles (DD-011) e non più dalla
  lista di nomi in crapp-data.ts, che resta come ponte finché
  VITE_AUTH_OBBLIGATORIA non viene acceso in produzione.
- Login Google via Supabase Auth: al primo accesso l'account si collega a uno
  slot libero di giocatori_squadra, e il vincolo lo fa rispettare il trigger di
  M1 (DD-016 regola 2).

Migration additive: M2 crea profili_giocatore, M3 il bucket privato
profili-giocatore. Nessuna tabella v1.0 viene toccata, quindi si possono
applicare senza cambiare il comportamento attuale dell'app.

supabase/config.toml e seed.sql configurano lo stack locale: serve perché il
progetto Supabase è uno solo, condiviso tra sviluppo e produzione.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 17:57:59 +02:00
davideandClaude Opus 5 ea29f053e3 Enable the claude-md-management plugin for the repo
Adds .claude/settings.json so every clone gets the same Claude Code
plugins: the official vercel and supabase ones already in use locally,
plus claude-md-management for maintaining CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:34:38 +02:00
davideandClaude Opus 5 e1e8dd5415 Reorganize documentation and unify the AI assistant rules
Documentation:
- Add docs/README.md, the documentation index that AGENTS.md pointed to as the
  first file to read but which did not exist.
- One home per piece of information: the feature list stays in ROADMAP.md,
  CHANGELOG.md records only when something shipped, TODO.md only ongoing work.
  Reconcile the entries that had drifted (CSI was both done and pending;
  pagelle, badge social and serie were missing from the roadmap).
- Rewrite DATABASE.md as tables: add giocatori_squadra (already created by a
  migration) and profili_giocatore (planned in DD-016), fix the wrong heading
  levels, drop the duplicated roadmap.
- DESIGN_DECISIONS.md: move the index to the top and sort it, extract the
  template into _template-dd.md.
- ARCHITECTURE.md becomes the technical reference; CLAUDE.md no longer
  duplicates it.
- Add docs/EFFICIENZA_CLOUD.md with the rules previously kept in mem/,
  separating what the code enforces from the goals not yet implemented.
- Fix statements the code contradicted: mutations use setQueryData rather than
  invalidateQueries, and scout_sessioni and giocatori_squadra are not read by
  the code yet.
- Track the v1.0 modules with no spec in docs/modules/ from TODO.md.

AI assistants:
- AGENTS.md is the single source of the rules, now including the technical
  constraints only Claude Code knew about (generated files, Vite plugins, data
  access) and an end-of-work checklist that applies to every assistant.
- CLAUDE.md and .cursor/rules/crapp.mdc point to AGENTS.md instead of
  restating it.
- Remove the five .cursor/*.md files, which Cursor never loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 16:26:14 +02:00
davideandClaude Opus 5 c06b33e83b Document how to run the tests.
test/README.md covers the commands, what each folder verifies, whether it
needs the network, and the one real limit: the app renders after
hydration, so the end-to-end tests check HTTP responses and the data
behind the pages, not the rendered interface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:07:59 +02:00
davideandClaude Opus 5 9ca124a868 Expire a scout session whose timestamp cannot be read.
sessioneScaduta compared Date.now() against NaN when aggiornato_il was
not a valid date, and every comparison with NaN is false: the session
was reported as still active, so the Scout Live table stayed locked to a
player who could no longer release it.

An unreadable timestamp now frees the session, which is the safe
direction: at worst someone takes over a scouting session that was
already unattended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:07:59 +02:00
davideandClaude Opus 5 af563603f9 Add a test suite for the domain logic and the server routes.
The project had no automated verification at all, so the "Test" step in
the AGENTS.md workflow rested entirely on clicking through the app.

The suite runs on bun with node:assert and adds no dependency: every file
is a script that exits non-zero when a check fails, and test/run.ts runs
each one in its own process. Unit tests cover the rules that decide what
players see (badges, streaks, ball duty rotation, ratings, MVP ties,
scouting totals, goals, notifications, CSI parsing); integration and
end-to-end tests drive the real dev server. Nothing writes to the
database, so both can be pointed at a live environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 13:07:41 +02:00
davideandClaude Opus 5 fee3d0b55a Read the official standings and results from the CSI portal.
The Campionato page showed hardcoded demo data. It now reads the real
2025/26 season (Campionato Open Misto Eccellenza, project 767, team
3359, Girone B) from the Livescore CSI Bologna portal.

The portal has no documented API: we call the same endpoints its own
pages call over ajax, so parsing must degrade gracefully. A single
server route fetches them, caches for 6 hours and serves the last good
payload on failure; the page falls back to the previous data when
nothing is available. No browser ever contacts the portal, keeping the
request count independent of how many players open the app.

Also fixes the header, which claimed "Girone C - CSI Milano".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 12:23:12 +02:00
davide baaffc66db Add CLAUDE.md guide for Claude Code sessions. 2026-08-30 12:23:12 +02:00
Ivan Cacciari 7d16bdba75 Update project state and Supabase configuration 2026-08-30 12:16:20 +02:00
Ivan CacciariandCursor 7b4bfe8b27 Add M1 migration for giocatori_squadra roster table.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 17:27:58 +02:00
Ivan CacciariandCursor 32782227f4 Record approved F0 player profile data schema as DD-016.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 17:00:28 +02:00
Ivan CacciariandCursor 57b36b5a09 Reference DESIGN_DECISIONS.md in AGENTS.md for AI and contributors.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:33:32 +02:00
Ivan Cacciari 19e1bb0790 Add AI documentation and project governance 2026-08-07 19:42:21 +02:00
Ivan Cacciari 7a2b26ec89 Add project documentation and AI development guidelines 2026-08-07 15:36:02 +02:00
Ivan Cacciari 6dc63d9250 Test branch develop 2026-08-07 12:23:27 +02:00
172 changed files with 11226 additions and 1672 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"enabledPlugins": {
"vercel@claude-plugins-official": true,
"supabase@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true
}
}
+16
View File
@@ -0,0 +1,16 @@
---
description: Regole di progetto CrAPP
alwaysApply: true
---
Prima di qualsiasi modifica leggi @AGENTS.md e seguine le regole: sono vincolanti e valgono
per intero.
- Non implementare funzionalità non documentate in `docs/`.
- Lavora su `develop`, mai direttamente su `main`.
- Codice, commenti e documentazione in italiano.
Non aggiungere regole in questo file: una regola nuova va in `AGENTS.md`, che leggono anche
Claude Code e Codex. Vale per qualsiasi aggiunta o modifica — regola, funzionalità, decisione,
schema database: prima di considerare finito il lavoro esegui la checklist «Fine lavoro» di
`AGENTS.md`.
+70
View File
@@ -0,0 +1,70 @@
name: 🐛 Segnala un bug
description: Segnala un comportamento non corretto di CrAPP
title: "[Bug]: "
labels:
- bug
body:
- type: input
id: titolo-bug
attributes:
label: Titolo del bug
description: Riassumi il problema in una frase
placeholder: "Es. Il numero di maglia duplicato non blocca il salvataggio"
validations:
required: true
- type: textarea
id: comportamento-attuale
attributes:
label: Cosa succede
description: Descrivi il comportamento che hai osservato
validations:
required: true
- type: textarea
id: comportamento-atteso
attributes:
label: Cosa ti aspettavi succedesse
validations:
required: true
- type: textarea
id: passaggi
attributes:
label: Passaggi per riprodurre
placeholder: |
1. Vai su ...
2. Clicca su ...
3. Compare l'errore ...
validations:
required: true
- type: dropdown
id: ambiente
attributes:
label: Dispositivo/browser
description: Da dove hai riscontrato il problema
options:
- Smartphone (Chrome/Safari)
- Computer (Chrome)
- Computer (Safari)
- Computer (Firefox)
- Altro
validations:
required: false
- type: textarea
id: screenshot
attributes:
label: Screenshot
description: Trascina qui una o più immagini che mostrano il problema (opzionale)
validations:
required: false
- type: checkboxes
id: duplicati
attributes:
label: Verifica
options:
- label: Ho controllato che non esista già una issue aperta su questo stesso problema
required: true
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
@@ -0,0 +1,55 @@
name: ✨ Richiesta di funzionalità
description: Suggerisci una nuova funzionalità per CrAPP
title: "[Feature]: "
labels:
- enhancement
body:
- type: input
id: titolo-feature
attributes:
label: Titolo della funzionalità
description: Riassumi la proposta in una frase
placeholder: "Es. Notifica push quando viene aggiunto un nuovo evento"
validations:
required: true
- type: textarea
id: problema
attributes:
label: Problema che risolve
description: Cosa non riesci a fare oggi, o cosa ti costa troppa fatica
placeholder: "Es. Non mi accorgo quando viene aggiunta una partita finché non apro l'app"
validations:
required: true
- type: textarea
id: soluzione
attributes:
label: Soluzione proposta
description: Come immagini che dovrebbe funzionare
validations:
required: true
- type: textarea
id: alternative
attributes:
label: Alternative considerate
description: Altri modi in cui potresti risolvere lo stesso problema (opzionale)
validations:
required: false
- type: textarea
id: screenshot
attributes:
label: Screenshot o mockup
description: Trascina qui immagini di riferimento (opzionale)
validations:
required: false
- type: checkboxes
id: duplicati
attributes:
label: Verifica
options:
- label: Ho controllato che non esista già una richiesta simile
required: true
+2
View File
@@ -39,3 +39,5 @@ dist-ssr
# Optional
.vercel
# Supabase CLI local state
supabase/.temp/
@@ -7,25 +7,32 @@ Al momento c'è un solo "obiettivo di squadra" ed è hardcoded nella home (`src/
## 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.
- 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.
- 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).
- 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.).
- 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.
- Bottom nav e rotte invariate.
@@ -1,6 +1,7 @@
# 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
@@ -19,5 +20,6 @@ Rendere i badge accanto al nome del giocatore nella lista squadra più compatti
- 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,11 +1,14 @@
# 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.
@@ -16,6 +16,7 @@ Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra t
## 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.
@@ -23,11 +24,13 @@ Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra t
- 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).
+142 -10
View File
@@ -1,10 +1,142 @@
<!-- LOVABLE:BEGIN -->
> [!IMPORTANT]
> This project is connected to [Lovable](https://lovable.dev). Avoid rewriting
> published git history — force pushing, or rebasing/amending/squashing commits
> that are already pushed — as it rewrites history on Lovable's side and the
> user will likely lose their project history.
>
> Commits you push to the connected branch sync back to Lovable and show up in
> the editor, so keep the branch in a working state.
<!-- LOVABLE:END -->
# CrAPP — regole per gli assistenti AI
Regole vincolanti per qualsiasi assistente AI (Claude Code, Codex, Cursor, ChatGPT) che lavora
su questo repository. Valgono integralmente; `CLAUDE.md` le richiama e non le ripete.
CrAPP è una PWA per la gestione di una squadra di pallavolo. Deve ridurre il lavoro degli
amministratori, aumentare il coinvolgimento dei giocatori, centralizzare le informazioni della
squadra e usare l'AI solo quando porta un beneficio reale. Il perché sta in
[docs/VISION.md](docs/VISION.md).
## Prima di modificare il codice
1. Leggi l'indice [docs/README.md](docs/README.md) e segui l'ordine di lettura che indica; poi
il documento del modulo interessato in [docs/modules/](docs/modules/).
2. Verifica lo stato attuale del repository: commit recenti, modifiche non committate, lavoro
introdotto da altri collaboratori o da altri assistenti.
3. Non presumere che il progetto sia come l'hai lasciato nell'ultima sessione: la fonte di
verità è il repository, non la cronologia della conversazione.
Non implementare funzionalità non documentate: prima si documenta
([DD-002](docs/DESIGN_DECISIONS.md#dd-002--sviluppo-document-first)), poi si scrive il codice.
## Comandi
Le dipendenze si installano con **bun** (`bun.lock`). `bunfig.toml` impone
`minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a
`minimumReleaseAgeExcludes` richiede conferma esplicita dell'utente.
```bash
npm run dev # vite dev su http://localhost:8080
npm run build # build di produzione (nitro)
npm run lint # eslint (include prettier come regola)
npm run format # prettier --write .
npm run test # suite di test (test/); npm run test:all per quella completa
npx supabase start # database locale in Docker (migration applicate + seed)
npx supabase db reset # ricrea il database locale da zero
npx supabase db push # applica le migration al progetto cloud
```
## Test
**Chi aggiunge o modifica una funzione scrive anche il test.** Non è opzionale e non si
rimanda: una funzione nuova senza test non è finita, una funzione modificata il cui test non
copre più il comportamento nuovo va aggiornata nello stesso lavoro.
- I test devono **risultare verdi**: non si consegna con test rossi, non si commenta un test
che fallisce e non si indebolisce un'asserzione per farla passare. Se un test rosso segnala
un comportamento voluto che è cambiato, si aggiorna il test spiegando perché.
- La logica di dominio pura sta in `src/lib/` ed è quella da coprire in `test/unit/`: se una
funzione è difficile da testare perché mischia calcolo e hook, separala (`*-core.ts`) come
già fatto per palloni e pagelle.
- Convenzioni, struttura delle cartelle e comandi in [test/README.md](test/README.md).
- Se il comportamento cambia, cambia anche la documentazione: modulo in
[docs/modules/](docs/modules/), più i file elencati in Tracciabilità.
## Fine lavoro
Prima di dire che hai finito:
1. i test delle funzioni aggiunte o modificate esistono e sono verdi;
2. `npm run lint` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e);
3. la documentazione toccata dalla modifica è aggiornata (vedi Test e Tracciabilità);
4. hai detto all'utente cosa hai cambiato, cosa hai lasciato fuori e quali rischi vedi.
## Git
`main` è la versione in produzione: qualsiasi commit deve lasciare l'app funzionante.
`develop` pubblica una preview Vercel, ma oggi è fermo indietro rispetto a `main` e non
rappresenta lo stato attuale (DD-019). I branch `feature/…`, `fix/…`, `refactor/…` servono per
lavori paralleli o rischiosi.
**È l'utente a decidere su quale branch va un commit.** L'assistente può consigliare un branch
dedicato quando la modifica è rischiosa o parallela ad altro lavoro, ma non cambia branch né
apre PR di propria iniziativa. In assenza di indicazioni si lavora dove si trova il repository.
Non committare, non fare push e non aprire PR senza che l'utente lo abbia chiesto.
## Tracciabilità
Ogni modifica significativa deve lasciare una traccia leggibile senza la cronologia delle
conversazioni: commit con messaggio descrittivo, più il documento giusto tra
[docs/CHANGELOG.md](docs/CHANGELOG.md) (cosa è stato rilasciato e quando),
[PROJECT_STATE.md](PROJECT_STATE.md) (stato generale del progetto),
[docs/DESIGN_DECISIONS.md](docs/DESIGN_DECISIONS.md) (decisioni architetturali, voci `DD-XXX`),
[docs/ROADMAP.md](docs/ROADMAP.md), [docs/TODO.md](docs/TODO.md),
[docs/DATABASE.md](docs/DATABASE.md) (se cambia lo schema).
Quali contenuti vanno in quale file, e le convenzioni di scrittura, stanno nelle regole di
manutenzione di [docs/README.md](docs/README.md): ogni informazione ha una sola casa, non
duplicarla altrove.
## Database
Il database è Supabase; lo schema documentato sta in [docs/DATABASE.md](docs/DATABASE.md),
allineato alle migration in `supabase/migrations/`.
- Ogni modifica allo schema è una **nuova** migration: le migration già applicate sono storia
e non si riscrivono.
- Non eliminare tabelle esistenti, non modificare lo schema senza motivazione.
- Ordine: progetta → documenta → crea la migration → testala in locale (`npx supabase db reset`)
→ verifica l'assenza di regressioni → solo dopo applicala in produzione.
- Preferisci strutture scalabili, evita duplicazione dei dati.
## Codice e interfaccia
L'architettura tecnica (stack, struttura delle cartelle, punti fermi da non rompere) sta in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): leggila prima di toccare routing, `vite.config.ts`,
client Supabase o autenticazione.
Componenti piccoli, riutilizzabili, a responsabilità singola. Prima di crearne uno nuovo,
verifica se esiste già in `src/components/`. L'interfaccia resta semplice, moderna, veloce,
ottimizzata per smartphone: poche schermate, pochi click, stile coerente con l'esistente.
## Regola anti-regressione
Le nuove versioni aggiungono funzionalità. Non riscrivere moduli già funzionanti senza una
motivazione esplicita, e non fare refactoring trasversali mentre sviluppi altro. Prima di
modificare un modulo esistente verifica quali altre parti dell'app lo usano.
## L'AI non deve
- introdurre librerie senza necessità, né aggirare `minimumReleaseAge`;
- modificare il database o il comportamento dell'app senza richiesta esplicita;
- eliminare funzionalità esistenti;
- sovrascrivere modifiche di altri collaboratori senza averne compreso lo scopo;
- riscrivere migration già applicate;
- committare, pushare o cambiare branch di propria iniziativa.
## L'AI deve
- spiegare le modifiche importanti e segnalare rischi, conflitti e possibili regressioni
**prima** di toccare parti sensibili;
- mantenere la compatibilità con il codice esistente e riutilizzare i componenti;
- privilegiare la semplicità;
- tenere aggiornata la documentazione quando serve.
## Filosofia
Prima di scrivere codice: questa modifica rende CrAPP più semplice? Riduce il lavoro degli
amministratori? Migliora l'esperienza dei giocatori? È coerente con la documentazione? Riduce
o aumenta la complessità futura? Se almeno una risposta è negativa, rivaluta la soluzione.
+9
View File
@@ -0,0 +1,9 @@
# CLAUDE.md
Le regole di progetto stanno in @AGENTS.md: valgono integralmente e non sono ripetute qui —
compresi i comandi (`npm run dev/lint/test`, supabase) e la checklist «Fine lavoro» da eseguire
prima di dire che hai finito. La documentazione tecnica è indicizzata in
[docs/README.md](docs/README.md); l'architettura in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
**Non aggiungere regole in questo file.** Una regola nuova va in `AGENTS.md`, che leggono
anche Codex e Cursor; scritta qui la vedrebbe solo Claude Code.
+14
View File
@@ -0,0 +1,14 @@
Copyright (c) 2026 Ivan Cacciari e Davide Grilli. Tutti i diritti riservati.
Il presente software e la relativa documentazione (il "Software") sono di
proprietà esclusiva di Ivan Cacciari e Davide Grilli.
Non è concessa alcuna licenza d'uso, salvo autorizzazione scritta esplicita
dei titolari. È vietato copiare, modificare, distribuire, sublicenziare,
vendere, pubblicare o comunque sfruttare in tutto o in parte il Software,
in qualsiasi forma o con qualsiasi mezzo, senza il preventivo consenso
scritto dei titolari.
IL SOFTWARE È FORNITO "COSÌ COM'È", SENZA GARANZIE DI ALCUN TIPO, ESPLICITE
O IMPLICITE. I TITOLARI NON SONO RESPONSABILI PER QUALSIASI DANNO DERIVANTE
DALL'USO O DALL'IMPOSSIBILITÀ DI USO DEL SOFTWARE.
+138
View File
@@ -0,0 +1,138 @@
# Project State
Ultimo aggiornamento: 04/09/2026
## Stato generale
Fase corrente:
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).
---
## Infrastruttura
- Si lavora direttamente su `main` (DD-019): `develop` esiste ma è fermo indietro, quindi la
sua preview Vercel non rappresenta lo stato attuale
- 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`
- 20 migration in `supabase/migrations/`, fino a `m9_risposte_presenze_risposto_il`
- Sviluppo locale verificato con il nuovo Supabase
---
## Backend
- Backend operativo: Supabase proprietario (`kfkcldwncxqaixetsjes`)
- Lovable Cloud: non più backend operativo di CrAPP
- Vecchio Project Ref `hetycilxgkdmccelwerq`: deprecato, non utilizzare
---
## Database
- Schema v1.0 e migration da M1 a M9 applicate al nuovo Supabase
- `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
bisogno di una migration
- `public.giocatori_squadra` è ora la source of truth della rosa letta dall'app (DD-015,
03/09/2026): «Aggiungi giocatore» e «Disattiva giocatore» della dashboard admin si
riflettono su Squadra, Presenze, Pagelle, Badge e Scout. `src/lib/crapp-data.ts` resta
solo come seed storico, fallback offline e sorgente della data di nascita (colonna non
ancora presente su `giocatori_squadra`)
- Migration `m6_avatar_giocatori`: bucket pubblico `avatar-giocatori` per le foto profilo,
al posto di `localStorage` (una per giocatore, letto da `src/lib/avatar-store.ts`)
- Migration `m7_scout_partite`: nuova tabella `scout_partite` per l'archivio delle partite
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
---
## Moduli completati
- Squadra
- Presenze
- Badge
- Scout Live (blocco e archivio partite sincronizzati tra dispositivi, migration `m7_scout_partite`)
- Pagelle
- MVP
- Notifiche
- Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`)
- Serie di presenze (specifica in `docs/modules/serie-presenze.md`)
---
## Autenticazione e dashboard amministratore
In produzione su `main`. **Il login è l'unica via d'accesso** (31/08/2026): la selezione
libera del giocatore non esiste più, senza sessione Google si resta su `/benvenuto`, e i
permessi di amministrazione arrivano solo da `user_roles`.
**Attenzione all'ordine:** finché il provider Google è spento in Supabase, «Accedi con
Google» risponde
```
{"code":400,"error_code":"validation_failed","msg":"Unsupported provider: provider is not enabled"}
```
e **nessuno entra nell'app**. Vale ancora per chi allestisce un ambiente nuovo (per esempio
lo stack Supabase locale): il passo 1 qui sotto va fatto per primo.
Passaggi in ordine, nessuno dei quali è reversibile a metà. **Stato al 04/09/2026: fatti i
passaggi 1, 2, 3 e 5 (M4 applicata); il passaggio 4 è un processo continuo (7 dei 16 giocatori
attivi hanno già fatto il primo accesso).**
1. **Provider Google in Supabase** — Google Cloud Console: consent screen _External_ (scope
`email` e `profile`, non sensibili: nessuna verifica richiesta, e la modalità _Testing_
regge fino a 100 utenti, più che sufficiente per la squadra), credenziale
_Web application_ con redirect URI
`https://kfkcldwncxqaixetsjes.supabase.co/auth/v1/callback`. Client ID e
secret in _Authentication → Providers → Google_. In _URL Configuration_: Site URL di
produzione, più `localhost:8080` e il wildcard delle preview Vercel tra i Redirect URLs.
Per provare sullo stack locale invece che sul cloud servono anche `enabled = true` in
`[auth.external.google]` di `supabase/config.toml`, le due variabili
`SUPABASE_AUTH_GOOGLE_*` in `.env` e una credenziale con redirect URI
`http://127.0.0.1:54321/auth/v1/callback`.
2. **Migration M2** (`supabase db push`). È un `CREATE` puro: si può applicare in
produzione senza toccare il comportamento attuale.
3. **Primo admin**, dopo il primo login (l'ID esiste solo da quel momento):
`INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '<mail>';`
4. **Collegamento degli account**: ciascuno accede con Google e viene collegato in
automatico al proprio giocatore per email (DD-018) — nessuna scelta manuale. Finché
l'email di un giocatore non è impostata, il suo accesso mostra un errore; da `/admin` si
imposta l'email di un giocatore (nuovo o esistente) senza bisogno di una migration. Da
settembre 2026 tutti i giocatori attivi hanno l'email registrata, ma il collegamento vero
e proprio (`auth_user_id`) avviene solo al primo login di ciascuno, quindi resta un
processo continuo che si ripete a ogni nuovo giocatore aggiunto a stagione in corso. Uno
slot già collegato può essere liberato solo da un admin.
5. **Solo a squadra collegata**: migration `m4_solo_autenticati`, che toglie al ruolo `anon`
l'accesso alle tabelle v1.0. Da lì in poi i dati sono raggiungibili solo con una sessione;
le route in `src/routes/api/public/` usano la service role e continuano a funzionare.
**Applicata in produzione il 03/09/2026** — non è più necessario aspettare che l'intera
rosa abbia già fatto login: il login era già l'unica via d'accesso lato app, quindi i
giocatori non ancora collegati non erano comunque impattati; M4 chiudeva solo un residuo
di accesso diretto al database bypassando l'app.
Attenzione: dev e produzione condividono lo stesso progetto Supabase. Un account di prova che
collega uno slot lo occupa anche in produzione, e va liberato da un admin.
## Prossimo sviluppo
Niente di assegnato: la v1.1 è completa, tesseramento CSI incluso (numero e data di tessera
registrabili da `/admin`, migration `m8_tesseramento_csi`). Le voci ancora aperte stanno in
[docs/ROADMAP.md](docs/ROADMAP.md).
---
## Note
Il progetto segue una metodologia document-first.
Ogni nuova funzionalità viene progettata nella cartella `docs/modules/` prima di essere implementata.
+52 -109
View File
@@ -1,118 +1,61 @@
# CRAP Volley Hub
# CrAPP 🏐
CrAPP App per CRAP Volley
CrAPP è una Progressive Web App sviluppata per digitalizzare completamente la gestione di una squadra di pallavolo.
Vorrei sviluppare unapp mobile per la squadra di pallavolo CRAP Volley, con nome CrAPP, disponibile per Android e iOS. Lobiettivo è creare unapp semplice da usare, moderna, bella da vedere e più coinvolgente rispetto a SportEasy, includendo anche funzionalità normalmente a pagamento in altre app.
## Funzionalità principali
Funzionalità principali
- Gestione squadra
- Gestione presenze
- Calendario allenamenti e partite
- Scout Live
- Badge e gamification
- Statistiche
- Notifiche intelligenti
- Gestione amministrativa
- AI per la pianificazione degli allenamenti (in sviluppo)
Gestione presenze/assenze
## Stack tecnologico
Partite
React 19, TypeScript, TanStack Start (SSR), Vite 8, Tailwind CSS 4, Radix UI / shadcn,
Supabase (PostgreSQL, Auth, Storage), Vercel, GitHub. Dettagli in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
Allenamenti
## Avvio locale
Eventi extra
Le dipendenze si installano con **bun** (`bun.lock`):
Stati rapidi: presente, assente, forse, in ritardo, indisponibile, infortunato
Statistiche giocatori
Presenze totali
Presenze consecutive
Gol/punti o altre statistiche specifiche della pallavolo
MVP, migliori performance, medie stagione
Statistiche partite
Risultati
Formazioni
Andamento set
Storico match
Campionato in tempo reale
Visualizzazione classifica e risultati
Dati presi direttamente dal sito del CSI
Aggiornamento automatico o importazione periodica
Calendario squadra
Allenamenti
Partite
Promemoria
Vista mensile e lista eventi
Profilo giocatore
Foto
Ruolo
Statistiche personali
Badge e obiettivi
Idea di stile
Interfaccia sportiva, pulita e moderna
Molto mobile-first
Design divertente, energico e più “premium”
Inserire in seguito il logo della squadra
Possibile uso di badge, livelli, premi e mini-gamification per rendere lapp più piacevole da usare
Extra che sarebbe bello aggiungere
Notifiche push per convocazioni e cambi orario
Chat o bacheca squadra
Report automatici dopo le partite
Sondaggi rapidi per disponibilità
Sezione “Best of the match”
Obiettivi di gruppo per presenza e continuità
Obiettivo finale
Realizzare una app che non sia solo utile per la gestione della squadra, ma anche piacevole, coinvolgente e bella da usare ogni giorno.
This project was built with [Lovable](https://lovable.dev).
**Live app**: https://volley-cronos-app.lovable.app
## Build with Lovable
Continue developing this project in the [Lovable editor](https://lovable.dev/projects/8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c).
- **Ship faster**: describe what you want to build and Lovable handles the code.
- **Stay in sync**: every change made in Lovable is committed straight to this repository.
- **Full ownership**: this code is yours. Push to `main` on GitHub and your changes sync back into Lovable, ready for your next prompt.
## Development
Prefer working locally? You need Node.js and npm — [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating).
```sh
git clone <this-repository-url>
cd <repository-name>
npm i
npm run dev
```bash
bun install
npm run dev # http://localhost:8080
```
## Comandi
```bash
npm run build # build di produzione
npm run lint # eslint (include prettier)
npm run test # test unit; npm run test:all per la suite completa
```
Chi aggiunge o modifica una funzione scrive anche il test e lo lascia verde
([test/README.md](test/README.md)).
## Deploy
Deploy automatico su Vercel a ogni push su `main`, che è anche il branch di lavoro corrente.
`develop` pubblica un Preview Deployment, ma oggi è indietro rispetto a `main`. Su quale branch
committare lo decide chi sviluppa (DD-019).
## Variabili d'ambiente
Il progetto richiede le seguenti variabili:
- `SUPABASE_URL`
- `SUPABASE_PUBLISHABLE_KEY`
- `VITE_SUPABASE_URL`
- `VITE_SUPABASE_PUBLISHABLE_KEY`
## Documentazione
Indice in [docs/README.md](docs/README.md). Le regole per gli assistenti AI stanno in
[AGENTS.md](AGENTS.md), lo stato corrente del lavoro in [PROJECT_STATE.md](PROJECT_STATE.md).
+125
View File
@@ -0,0 +1,125 @@
# Architettura del progetto
Come è fatta CrAPP: stack, organizzazione del codice, flusso di sviluppo. È il documento di
riferimento tecnico — `CLAUDE.md` non ripete questi contenuti, li richiama.
## Stack
| Livello | Tecnologie |
| ------------- | ------------------------------------------------------------------------------------- |
| Frontend | React 19, TypeScript, TanStack Start (SSR), Vite 8, Tailwind CSS 4, Radix UI / shadcn |
| Backend | Supabase (PostgreSQL, Auth, Storage) |
| Hosting | Vercel |
| Versionamento | Git, GitHub |
Le dipendenze sono installate con **bun** (`bun.lock`, `bunfig.toml`). `bunfig.toml` impone
`minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a
`minimumReleaseAgeExcludes` richiede conferma esplicita.
## Struttura del progetto
```
src/
components/ componenti condivisi (crapp/, ui/, motion/)
routes/ routing file-based
lib/ logica di dominio, un file per modulo
integrations/ client Supabase e integrazioni esterne
hooks/
assets/
supabase/ migration SQL
test/ suite di test (unit, integration, end-to-end)
docs/ documentazione ufficiale
```
## Punti fermi
- **Routing**: file-based in `src/routes/`. `src/routeTree.gen.ts` è **generato**, non si
modifica a mano.
- **Configurazione Vite**: `vite.config.ts` usa `@lovable.dev/vite-tanstack-config`, che
include già devtools, tanstackStart, viteReact, tailwind, tsconfig-paths, nitro e l'alias
`@``src/`. **Non ri-aggiungere questi plugin**: l'app si rompe.
- **Entry point server**: `src/server.ts` avvolge l'entry di TanStack Start per intercettare
gli errori SSR che h3 trasformerebbe in un 500 JSON silenzioso, e renderizza
`renderErrorPage()`. `src/start.ts` registra i middleware globali (error handler, CSRF sui
server functions, `attachSupabaseAuth`).
- **Supabase**: `src/integrations/supabase/client.ts` (browser/SSR, chiave publishable — file
generato) e `client.server.ts` (`supabaseAdmin`, solo server). `types.ts` è generato dallo
schema; finché non viene rigenerato, le tabelle introdotte da M1/M2 si usano tramite
`client-nuove-tabelle.ts`, con i tipi di riga dichiarati nei moduli di `src/lib/`.
- **Autenticazione**: login Google via Supabase Auth (`src/lib/auth.ts`, DD-011). È l'unica
strada di accesso: `__root.tsx` rimanda a `/benvenuto` chi non ha sessione, e l'identità
del giocatore è lo slot di `giocatori_squadra` collegato all'account. I permessi di
amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`).
## Livello dati
Tutta la logica di dominio sta in `src/lib/`, un file per modulo (`presenze`, `eventi`,
`pagelle`, `mvp-voti`, `palloni`, `cacche`, `badges`, `scout-*`, `infortuni`, …). Il pattern
ricorrente:
- ogni modulo esporta hook TanStack Query (`useX`); i default globali stanno in
`src/router.tsx` (`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect`
disattivati, `retry: 1`);
- dopo una mutazione la cache si aggiorna con `setQueryData`, **non** con
`invalidateQueries`: invalidare provoca una rilettura e costa una query in più (unica
eccezione oggi: `scout-live.ts`);
- le funzioni pure di calcolo sono separate dagli hook (es. `palloni-core.ts` vs
`palloni.ts`, `mediePagelle()` vs `usePagelle()`);
- `src/lib/rosa.ts` è l'aggregatore: compone tutti gli hook e restituisce la rosa completa
con le statistiche derivate, **senza query aggiuntive** rispetto a quelle già in cache. Le
route consumano `useRosa()`, non i singoli moduli.
Nessun accesso al database dai componenti: solo attraverso i moduli in `src/lib/`, così il
backend resta sostituibile in un solo punto (DD-013, [PORTABILITA.md](PORTABILITA.md)).
Vincoli di efficienza cloud — niente polling, cache lunga, `setQueryData` invece di
`invalidateQueries` — in [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md).
Badge e statistiche sono calcolati a runtime dai dati, non persistiti (DD-007). La
gamification deve restare equa tra ruoli (DD-008).
La rosa vive nella tabella `giocatori_squadra`, letta tramite `useRosa()`/`useGiocatoriSquadra()`
(DD-015, DD-016). `src/lib/crapp-data.ts` (`rosaCSI`) resta solo come seed storico e fallback
quando il database non risponde.
## UI
Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `PageHeader`, `Section`,
`StatTile`), primitive shadcn in `src/components/ui/`, animazioni in
`src/components/motion/`. Mobile-first (DD-005): poche schermate, pochi click.
## Comandi
```bash
npm run dev # vite dev su http://localhost:8080
npm run build # build di produzione (nitro)
npm run lint # eslint (include prettier come regola)
npm run format # prettier --write .
npm run test # test unit (veloci, senza rete né database)
npm run test:integration # route server vere
npm run test:e2e # percorsi sull'app servita
npm run test:all # tutto
```
Database di sviluppo in locale (Docker), alternativo al progetto Supabase cloud:
```bash
npx supabase start # avvia lo stack locale e applica tutte le migration
npx supabase stop # spegne i container
npx supabase db reset # ricrea il database da zero: migration + supabase/seed.sql
npx supabase db push # applica le migration al progetto cloud
```
`supabase/seed.sql` popola qualche profilo di prova e gira **solo in locale**. Serve perché
il progetto cloud è uno solo, condiviso tra sviluppo e produzione: lo stack locale è il posto
dove provare le migration distruttive senza toccare i dati veri.
## Branch e flusso di sviluppo
- `main` → produzione, deploy automatico su Vercel. È anche il branch di lavoro corrente.
- `develop` → preview Vercel; oggi indietro rispetto a `main`, non rappresenta lo stato attuale.
- `feature/…`, `fix/…`, `refactor/…` → lavori rischiosi o paralleli.
Su quale branch va un commit lo decide l'utente (DD-019): un assistente AI può consigliare un
branch dedicato, non sceglierlo. Poiché si lavora su `main`, la rete di sicurezza sono i test,
che vanno scritti insieme al codice e devono essere verdi (DD-020, [test/README.md](../test/README.md)).
+108
View File
@@ -0,0 +1,108 @@
# Changelog
Tutte le modifiche significative del progetto vengono registrate in questo documento, in
ordine dalla più recente. L'elenco delle funzionalità disponibili e previste non si ripete
qui: sta in [ROADMAP.md](ROADMAP.md).
## Versione attuale — agosto 2026
### Serie di presenze calcolate sui dati reali
- `serieConsecutiva()` (`src/lib/presenze.ts`) deriva le serie da eventi passati e
`risposte_presenze`: prima erano `0` fisso in `useRosa()` e la sezione «Serie di presenze»
del profilo era di fatto inerte, insieme ai badge e all'obiettivo «Continuità di squadra»
che ne dipendono.
- Migration `m9_risposte_presenze_risposto_il`: nuova colonna `risposto_il` con l'istante
della **prima** risposta, resa immutabile da un trigger (`aggiornato_il` registrava solo
l'ultima modifica, quindi chi rispondeva subito e cambiava idea dopo risultava lento).
Confrontata con `eventi_app.creato_il` sblocca finalmente la serie "Conferme 24h" e i badge
"Risposta lampo" e "Mai un forfait". Il dato non è ricostruibile all'indietro: vale da qui
in avanti (vedi [modules/serie-presenze.md](modules/serie-presenze.md)).
- La barra di progresso di una serie ora misura l'avanzamento fra il traguardo raggiunto e il
successivo: prima usava `valore/prossimo` e tornava indietro a ogni traguardo (2/3 = 67%,
poi 3/6 = 50%).
### Segnalazioni dal profilo
- «Segnala un bug» e «Suggerisci una nuova funzionalità» in `/profilo` → Impostazioni: due
link che aprono una issue GitHub sul template giusto
(`.github/ISSUE_TEMPLATE/bug_report.yml`, `feature_request.yml`). Nessuna tabella e nessuna
schermata di gestione: la segnalazione vive su GitHub
(vedi [modules/profilo-giocatore.md](modules/profilo-giocatore.md)).
### Autenticazione e dashboard amministratore (in produzione)
- Login con Google tramite Supabase Auth (DD-011). Al primo accesso l'account si collega a
un giocatore di `giocatori_squadra`, e il collegamento non è più modificabile dal
giocatore stesso (DD-016 regola 2).
- La selezione libera del giocatore è stata rimossa: `/benvenuto` offre solo l'accesso con
Google e senza sessione non si entra in nessuna schermata. Sparita anche la variabile
`VITE_AUTH_OBBLIGATORIA` (non serve più) e il pulsante «Cambia giocatore» in `/profilo`.
- I permessi di amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`): la lista
di nomi in `crapp-data.ts` è stata eliminata, altrimenti bastava scegliere il nome giusto
per amministrare.
- Migration `m4_solo_autenticati`: toglie al ruolo `anon` l'accesso alle tabelle v1.0.
Applicata in produzione il 03/09/2026, dopo aver impostato l'email di tutta la rosa
attiva — il login era già l'unica via d'accesso lato app, quindi il collegamento dei
singoli account (che resta un processo continuo a ogni login) non era comunque
condizionato da questa migration.
- Collegamento automatico al proprio giocatore per email (DD-018, migration
`m5_email_giocatori_squadra`): niente più scelta manuale da un elenco, `/benvenuto`
confronta l'email dell'account Google con `giocatori_squadra.email` e collega da solo.
Senza corrispondenza compare solo un messaggio d'errore, con un pulsante per uscire e
riprovare con un altro account.
- Dashboard admin: nuove azioni "Aggiungi giocatore" (con email opzionale per il
collegamento automatico) e "Disattiva/Riattiva giocatore" per chi lascia la squadra — la
riga non viene mai eliminata, così presenze, voti, pagelle e badge della stagione restano
agganciati al suo id. L'email è anche modificabile dal pannello "Dati squadra" di ogni
giocatore già in rosa.
- Tracciamento tesseramento CSI (migration `m8_tesseramento_csi`): numero e data di tessera
in `giocatori_squadra`, come gli altri campi che gestisce solo l'admin (DD-016/DD-018). La
dashboard mostra chi è già tesserato (badge sulla scheda, contatore in "Squadra") e un
pannello per registrare numero e data una volta arrivata la tessera dal CSI.
- Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica
documento, certificato medico e foto tessera con le relative scadenze
([modules/profilo-giocatore.md](modules/profilo-giocatore.md)).
- Nuova schermata `/admin`: stato dei profili della squadra, download di documento,
certificato e foto tessera, export CSV per il tesseramento CSI.
- Migration `m2_profili_giocatore` (tabella dei profili e bucket privato), additiva.
- Dalla dashboard l'amministratore modifica i dati squadra (nome, cognome, numero, ruolo),
compila i dati personali al posto di un giocatore e scollega un account da un profilo
(DD-017). I file restano esclusi: li carica solo il giocatore. Nessuna migration: le
policy di M1 e M2 lo consentivano già.
- Foto profilo sincronizzate tra dispositivi: da `/profilo` la foto caricata finisce nel
bucket pubblico `avatar-giocatori` (migration `m6_avatar_giocatori`) invece che in
`localStorage`, così compare per tutta la squadra e non solo su chi l'ha caricata.
L'Avatar mostra numero/iniziali finché la foto non è presente.
- Scout Live sincronizzato tra dispositivi: il blocco "chi sta scoutando" ora passa dalla
tabella `scout_sessioni` invece che da `localStorage`, quindi due telefoni non possono più
prendere il controllo insieme sovrascrivendosi a vicenda. Le partite scoutate concluse
vengono archiviate nella nuova tabella `scout_partite` (migration `m7_scout_partite`):
prima restavano visibili solo sul telefono di chi aveva chiuso la partita.
### Test
- Suite di test in `test/` (unit, integration, end-to-end) eseguita con bun,
senza nuove dipendenze: `npm run test` e `npm run test:all`.
- Corretto un difetto emerso dai test: una sessione Scout Live con timestamp
illeggibile restava bloccata per sempre invece di scadere.
### Collegamento CSI
- Classifica e risultati ufficiali letti dal portale Livescore CSI Bologna
(stagione 2025/26, Campionato Open Misto Eccellenza, Girone B).
- La pagina Campionato non usa più dati dimostrativi.
- Dettagli e limiti in [modules/collegamento-csi.md](modules/collegamento-csi.md).
### Infrastruttura
- Migrazione completa da Lovable a sviluppo locale.
- Configurazione Git.
- Repository GitHub indipendente.
- Deploy automatico tramite Vercel.
- Branch main e develop.
## Versione 1.0 — luglio 2026
Prima versione usata dalla squadra. Funzionalità incluse: vedi
[ROADMAP.md § Versione 1.0](ROADMAP.md#versione-10--rilasciata).
+68
View File
@@ -0,0 +1,68 @@
# Database CrAPP
Struttura del database Supabase (PostgreSQL) e ruolo di ogni tabella. Lo schema autoritativo
sono le migration in `supabase/migrations/`: **una tabella nuova va documentata qui nella
stessa modifica che la crea**. Le funzionalità future stanno in [ROADMAP.md](ROADMAP.md),
non in questo file.
## Anagrafica e utenti
| Tabella | Scopo | Note |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1``gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde, e come sorgente della data di nascita (non ancora una colonna di questa tabella). Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. |
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
## Storage
| Bucket | Scopo | Note |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `profili-giocatore` | Documento d'identità, certificato medico e foto tessera, in cartelle per giocatore (`<giocatore_id>/<sezione>.<est>`). | **Privato** e destinato a restare tale: contiene documenti e dati sanitari, che non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella, l'admin può scaricare tutto tramite signed URL a scadenza breve. Creato dalla migration `m2_profili_giocatore`. |
| `avatar-giocatori` | Foto profilo mostrate nel cerchio avatar (Squadra, Profilo), un file per giocatore (`<giocatore_id>/avatar.jpg`). | **Pubblico**: foto informali, non documenti sensibili. Qualsiasi autenticato può caricare/sostituire/eliminare un file (nessun controllo per-proprietario, la maggior parte dei giocatori non ha ancora `auth_user_id` collegato, DD-018). Letto da `src/lib/avatar-store.ts`. Creato dalla migration `m6_avatar_giocatori`. |
## Eventi e presenze
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. |
| `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). |
## Scout
| Tabella | Scopo | Note |
| ---------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. Prima viveva solo in `localStorage`: "Scout occupato da X" non funzionava mai tra dispositivi diversi (fix M7). |
| `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione di Scout Live. | Serve esclusivamente per statistiche di squadra, mai per classifiche individuali (DD-008). Letta/scritta da `src/lib/scout-stato.ts`. |
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. Prima il risultato finale finiva solo in `localStorage`: invisibile a chiunque non fosse il dispositivo di chi aveva chiuso la partita (fix M7). |
## Votazioni
| Tabella | Scopo | Note |
| ------------------- | ------------------------------------ | ------------------------ |
| `mvp_voti` | Voti MVP assegnati a fine partita. | |
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. |
| `badge_social_voti` | Voti social per i badge. | |
## Turni e notifiche
| Tabella | Scopo | Note |
| -------------------- | --------------------------------------------- | ---- |
| `turni_palloni` | Gestione dei turni palloni. | |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Storico dei promemoria inviati. | |
## Funzioni speciali
| Tabella | Scopo | Note |
| ---------------- | --------------------- | -------------------------------------- |
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
## Badge
Non esiste una tabella dedicata: i badge vengono **calcolati a runtime** dall'applicazione a
partire dai dati esistenti (DD-007).
+670
View File
@@ -0,0 +1,670 @@
# Registro delle decisioni di progetto
Questo documento raccoglie le **decisioni importanti** prese nel corso della vita di CrAPP: scelte che hanno influito sulla direzione del prodotto, sullorganizzazione del lavoro o su come lapp si evolve nel tempo.
Non descrive _come_ è fatto il codice. Per quello esistono `ARCHITECTURE.md` e `DATABASE.md`.
Serve a rispondere a domande del tipo:
- _Perché abbiamo scelto così?_
- _Cosa avevamo escluso e perché?_
- _Quando conviene riaprire una decisione?_
---
## Indice
**Accettate**
| ID | Titolo |
| --------------------------------------------------------------------------------- | ------------------------------------- |
| [DD-001](#dd-001--crapp-deve-restare-indipendente-da-lovable) | Indipendenza da Lovable |
| [DD-002](#dd-002--sviluppo-document-first) | Sviluppo document-first |
| [DD-004](#dd-004--ogni-versione-aggiunge-non-riscrive) | Ogni versione aggiunge, non riscrive |
| [DD-005](#dd-005--mobile-first-pochi-click-pochi-schermi) | Mobile-first |
| [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-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-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-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 |
| [DD-020](#dd-020--una-funzione-modificata-senza-test-non-è-finita) | Test obbligatori e verdi |
**In valutazione**
| ID | Titolo |
| ----------------------------------------------------------------- | ---------------------- |
| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB |
**Sostituite**
| ID | Titolo |
| ---------------------------------------------------------------- | --------------------- |
| [DD-003](#dd-003--due-branch-main-stabile-develop-per-il-lavoro) | Branch main / develop |
---
## Come usare questo registro
Ogni decisione segue lo stesso schema:
| Campo | Significato |
| ------------------------ | ------------------------------------------------------ |
| **Data** | Quando la decisione è stata presa o confermata |
| **Stato** | Accettata · In valutazione · Sostituita · Obsoleta |
| **Contesto** | Quale problema o opportunità avevamo di fronte |
| **Decisione** | Cosa abbiamo scelto di fare |
| **Alternative scartate** | Cosa non abbiamo fatto e perché |
| **Conseguenze** | Cosa comporta nel quotidiano (utenti, admin, sviluppo) |
| **Riesame** | Quando ha senso riconsiderarla |
**Quando aggiungere una voce**
- una scelta influisce su più moduli o su più release;
- escludiamo unalternativa non ovvia;
- accettiamo un compromesso consapevole (debito, limitazione, ritardo);
- cambiamo una decisione precedente.
**Quando non serve**
- dettagli implementativi locali;
- scelte estetiche minori;
- bugfix o correzioni puntuali.
**Come registrare una nuova decisione**
Copiare [`_template-dd.md`](_template-dd.md) in fondo al documento, assegnare il primo ID
libero e aggiungerlo all'indice.
---
## Decisioni accettate
---
### DD-001 — CrAPP deve restare indipendente da Lovable
**Data:** luglio 2026
**Stato:** Accettata
**Contesto**
Il progetto nasce come prototipo su Lovable Cloud. Per crescere serve controllo su codice, deploy, database e costi.
**Decisione**
Spostare lo sviluppo su repository GitHub indipendente, con deploy su Vercel e database Supabase gestito dal team.
**Alternative scartate**
- Restare su Lovable come unica piattaforma → troppa dipendenza da un servizio esterno.
- Riscrivere tutto da zero → costo e rischio inutili; il prototipo funzionava già.
**Conseguenze**
- Maggiore libertà e responsabilità per il team.
- Restano tracce del passaggio (dipendenze, meta tag): vanno eliminate gradualmente, non in blocco.
- Lapp deve poter girare anche fuori dallecosistema Lovable (vedi `PORTABILITA.md`).
**Riesame**
Quando il progetto non userà più alcun componente Lovable.
---
### DD-002 — Sviluppo document-first
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
Con più persone (e assistenti AI) che lavorano sul codice, serviva un modo per evitare funzionalità “inventate” al volo e incoerenze tra moduli.
**Decisione**
Ogni nuova funzionalità significativa viene prima **progettata e documentata** in `docs/modules/`, poi implementata. Il flusso ufficiale è: idea → progettazione → documentazione → database → codice → test → release.
**Alternative scartate**
- Documentare solo a posteriori → troppo spesso incompleto o assente.
- Affidarsi solo al codice come documentazione → illeggibile per chi non programma.
**Conseguenze**
- Rallenta leggermente lavvio di nuove feature, ma riduce rework e discussioni infinite.
- I moduli v1.0 vanno retro-documentati quando possibile.
- Nessuna feature non documentata entra in produzione.
**Riesame**
Se il team diventa molto piccolo e la documentazione smette di essere consultata.
---
### DD-003 — Due branch: main stabile, develop per il lavoro
**Data:** agosto 2026
**Stato:** Sostituita da [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) (settembre 2026)
**Contesto**
Serve separare ciò che i giocatori usano ogni giorno da ciò che è ancora in prova.
**Decisione**
- `main` → produzione, sempre funzionante, deploy automatico.
- `develop` → sviluppo e preview, merge su `main` solo dopo test.
**Alternative scartate**
- Sviluppare direttamente su `main` → rischio di rotture in produzione.
- Branch per ogni feature → eccessivo per la dimensione attuale del team.
**Conseguenze**
- Gli utenti in produzione non vedono lavori incompleti.
- Ogni release su `main` deve includere verifica delle funzionalità esistenti.
**Riesame**
Sostituita: nella pratica il lavoro è finito direttamente su `main` e `develop` è rimasto
indietro. Vedi DD-019.
---
### DD-004 — Ogni versione aggiunge, non riscrive
**Data:** agosto 2026
**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.
**Decisione**
Le nuove versioni **introducono** funzionalità. Non si riscrive un modulo già operativo salvo richiesta esplicita e pianificata.
**Alternative scartate**
- Refactoring ampio “per pulire” insieme a ogni release → alto rischio, poco valore immediato per gli utenti.
**Conseguenze**
- Coesistono temporaneamente soluzioni vecchie e nuove (es. dati hardcoded accanto a tabelle database).
- Il debito tecnico va gestito con migration dedicate, non di nascosto.
**Riesame**
Quando un modulo diventa ingestibile o blocca una release importante.
---
### DD-005 — Mobile-first, pochi click, pochi schermi
**Data:** origine progetto
**Stato:** Accettata
**Contesto**
I giocatori usano lapp soprattutto da smartphone, spesso in spogliatoio o in palestra, con poco tempo e poca pazienza.
**Decisione**
Interfaccia semplice, veloce, ottimizzata per telefono. Navigazione ridotta (barra inferiore). Ogni schermata deve avere uno scopo chiaro.
**Alternative scartate**
- Layout da desktop con menu complessi → scomodo in mobilità.
- App nativa iOS/Android → costi e tempi di pubblicazione non giustificati per una squadra amatoriale.
**Conseguenze**
- Funzionalità amministrative complesse vanno semplificate o suddivise con cura.
- La PWA è la forma giusta per questo pubblico.
**Riesame**
Se emergono esigenze desktop forti (es. gestione documenti massiva solo da PC).
---
### DD-006 — Intelligenza artificiale solo se porta beneficio reale
**Data:** origine progetto
**Stato:** Accettata
**Contesto**
LAI è attraente ma può complicare lapp, aumentare i costi e creare aspettative irrealistiche.
**Decisione**
Usare lAI solo quando riduce lavoro agli admin o migliora concretamente lesperienza dei giocatori. Non introdurla “perché si può”.
**Alternative scartate**
- AI ovunque (chatbot, suggerimenti automatici, analisi predittive) → fuori focus per una squadra amatoriale.
**Conseguenze**
- “AI Allenamenti” è in roadmap v1.2, non v1.1.
- Ogni proposta AI va valutata con la domanda: _chi risparmia tempo e quanto?_
**Riesame**
Quando lAI diventa economica e affidabile per casi duso chiari (es. generazione allenamenti).
---
### DD-007 — Badge calcolati dallapp, non salvati nel database
**Data:** origine progetto
**Stato:** Accettata
**Contesto**
I badge dipendono da statistiche già disponibili (presenze, MVP, cacche, ecc.). Salvare ogni badge sbloccato nel database aggiungerebbe complessità senza beneficio immediato.
**Decisione**
I badge vengono **calcolati al volo** dallapplicazione in base ai dati esistenti. Non esiste una tabella badge dedicata.
**Alternative scartate**
- Tabella `badge_sbloccati` con storico → utile in futuro per notifiche retroattive o audit, ma non necessaria ora.
**Conseguenze**
- Meno migration e meno sincronizzazione.
- Lo “sblocco” celebrativo usa cache locale per non ripetere animazioni.
- Un eventuale storico badge richiederà una nuova decisione.
**Riesame**
Se servono badge manuali assegnati dagli admin o storico immutabile.
---
### DD-008 — Gamification equa tra ruoli
**Data:** origine progetto
**Stato:** Accettata
**Contesto**
In pallavolo i ruoli hanno statistiche diverse (un libero non segna punti dattacco). Confrontare tutti sugli stessi numeri sarebbe ingiusto e scoraggiante.
**Decisione**
Le statistiche **personali** in profilo e squadra devono essere **eque per tutti i ruoli**. Dati tecnici di reparto (punti, ace, muri) restano nello Scout Live come informazione di squadra, non come leva competitiva individuale.
**Alternative scartate**
- Classifiche individuali basate su punti → penalizza libero, palleggiatore, centrale.
**Conseguenze**
- Badge e obiettivi usano presenze, MVP, pagelle, serie, cacche — metriche accessibili a tutti.
- Lo scout resta strumento tecnico, non gioco.
**Riesame**
Se la squadra chiede esplicitamente classifiche tecniche per ruolo.
---
### DD-009 — Tesseramento CSI manuale in v1.1, integrazione API in v2.0
**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.
**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).
**Alternative scartate**
- Integrazione CSI già in v1.1 → 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.
**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
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
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.
**Alternative scartate**
- Archivio certificati → utile per audit, rinviato a versioni future.
**Conseguenze**
- Implementazione più semplice e veloce.
- Gli admin vedono solo il certificato attuale.
- Va comunicato chiaramente ai giocatori che sostituire il file elimina quello precedente.
**Riesame**
Se il CSI o il regolamento interno richiedono conservazione storica.
---
### DD-011 — Autenticazione reale prima del profilo amministrativo completo
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
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.
**Alternative scartate**
- Continuare solo con selezione da lista → inaccettabile per dati sensibili.
- Lovable Auth → crea dipendenza da piattaforma che stiamo abbandonando.
**Conseguenze**
- Tutti dovranno fare login almeno una volta.
- Gli admin useranno ruoli veri (`user_roles`), non una lista di nomi hardcoded.
- È prerequisito per dashboard admin e export CSI.
**Riesame**
Dopo il rollout auth, se emergono problemi di adozione (giocatori poco digitali).
---
### DD-012 — Non migrare gli ID giocatore in v1.1
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
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.
**Alternative scartate**
- Migrare tutto a UUID in v1.1 → rischio alto di rompere presenze, voti, scout e notifiche.
**Conseguenze**
- Coesistono due modelli anagrafici fino a migration dedicata.
- `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.
---
### DD-013 — Portabilità: lapp non deve dipendere da servizi esclusivi
**Data:** luglio 2026
**Stato:** Accettata
**Contesto**
La squadra potrebbe voler cambiare hosting, database o fornitore auth in futuro.
**Decisione**
CrAPP deve poter girare su **Node.js + PostgreSQL standard**. Niente funzionalità bloccate su servizi proprietari. I dati si accedono solo tramite moduli in `src/lib/`, non direttamente dai componenti.
**Alternative scartate**
- Accettare lock-in per velocità → contrario alla lunga vita del progetto.
**Conseguenze**
- Supabase va bene perché è PostgreSQL e self-hostable.
- Le API push e i job restano endpoint HTTP richiamabili da qualsiasi scheduler.
**Riesame**
Se si adotta un servizio che viola questa regola.
---
### DD-016 — Schema dati Profilo Giocatore v1.1 (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.
**Decisione**
Per la v1.1 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`.
Regole vincolanti:
1. **`giocatori_squadra` diventa progressivamente la source of truth** per lanagrafica squadra. Durante la transizione, `crapp-data.ts` resta come **fallback** se il database non è disponibile o i dati non sono ancora migrati.
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.
**Alternative scartate**
- Estendere la tabella `giocatori` UUID → conflitto con ID operativi del codice e rischio di regressioni.
- 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.
**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).
- 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).
- Se il CSI o il regolamento richiedono conservazione storica documenti o consensi privacy dedicati.
---
### DD-017 — L'amministratore può compilare i dati al posto del giocatore
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
Il modulo Profilo Giocatore era costruito su un confine netto: ognuno scrive solo la propria riga, l'amministratore legge e scarica. Nella pratica quel confine blocca il lavoro che il modulo doveva togliere: se metà squadra non compila i propri dati, l'export per il tesseramento CSI resta incompleto e l'admin torna a chiedere le informazioni in chat — esattamente ciò che CrAPP deve eliminare. Inoltre le docs assegnavano già agli admin la gestione dei dati squadra (nome, cognome, numero, ruolo) e il reset del collegamento all'account (DD-016 regola 2), senza che esistesse una schermata per farlo.
**Decisione**
Dalla dashboard amministratore, un admin può:
1. modificare i **dati squadra** di qualsiasi giocatore (nome, cognome, numero, ruolo);
2. compilare e correggere i **dati personali e del documento** di qualsiasi giocatore;
3. **scollegare** un account da un profilo, liberando lo slot.
Restano fuori, e non cambiano:
- i **file** (documento, certificato, foto): l'admin li scarica ma non li carica né li sostituisce. Un documento d'identità lo produce il suo titolare, e la catena di responsabilità deve restare leggibile;
- il **giocatore**, che continua a non poter toccare i propri dati squadra.
**Alternative scartate**
- Lasciare tutto al giocatore → l'export CSI resta incompleto e il lavoro amministrativo torna in chat, contro la missione del progetto.
- Dare all'admin anche l'upload dei file → confonde chi ha fornito un documento, su dati sanitari e d'identità dove serve il contrario.
- Un ruolo intermedio (segreteria) per i soli dati personali → un ruolo in più per una squadra sola, con gli stessi tre amministratori di adesso.
**Conseguenze**
- Il modello dei permessi non è più "ognuno i suoi": è "ognuno i suoi, più l'admin su tutti, tranne i file". Le policy RLS di M1 e M2 lo consentivano già, quindi non servono migration.
- Un admin può correggere un errore di battitura in un numero di documento senza inseguire il giocatore.
- Un admin vede e scrive dati personali altrui: è un potere reale, dato a tre persone su diciassette. Va assegnato con la stessa cura di prima (una riga in `user_roles`, nessuna auto-promozione).
- Il completamento del profilo smette di essere un indicatore di _chi ha risposto_ e diventa un indicatore di _quali dati mancano_, chiunque li abbia inseriti.
**Riesame**
- Se la squadra cresce al punto da rendere sensato un ruolo di sola segreteria.
- Se serve tracciare _chi_ ha modificato un dato: oggi non c'è audit, e con la scrittura condivisa la domanda prima o poi arriva.
---
### DD-018 — Collegamento automatico giocatore↔account per email
**Data:** settembre 2026
**Stato:** Accettata
**Contesto**
DD-016 regola 2 prevedeva che, al primo accesso, il giocatore scegliesse manualmente il proprio slot libero da un elenco (`/benvenuto`). In pratica ogni giocatore ha un'email nota (o presto nota), quindi far scegliere un nome da una lista è un passaggio superfluo e un rischio: un giocatore può selezionare per errore lo slot di un compagno, e nulla nel flusso lo impedisce a livello di prodotto.
**Decisione**
Al primo accesso, `giocatori_squadra` viene interrogata per email (case-insensitive, tramite la nuova colonna `email`) invece di mostrare un elenco di slot liberi. Se l'email dell'account Google corrisponde a una riga libera, il collegamento avviene automaticamente. Se non corrisponde a nessuna riga (email non ancora nota, o nessun profilo per quella persona), l'utente vede solo un messaggio d'errore che invita a contattare un amministratore, con un pulsante per uscire e riprovare con un altro account — nessuna selezione manuale di ripiego. Le email sono popolate via migration (`m5_email_giocatori_squadra`) per la rosa iniziale; un'interfaccia in `/admin` per impostarle su nuovi giocatori è arrivata poco dopo (vedi "Alternative scartate"). Il trigger `enforce_giocatori_squadra_update` (DD-016) viene esteso per richiedere anche la corrispondenza email, non solo lo slot libero: il vincolo resta nel database, non solo nella UI.
**Alternative scartate**
- Mantenere la selezione manuale come ripiego quando l'email non trova corrispondenza → scartata: vanificherebbe la garanzia "ognuno collega solo il proprio profilo" e reintrodurrebbe il rischio di scelta errata che questa decisione vuole eliminare.
- Un'interfaccia admin per scrivere l'email dei giocatori → rimandata al momento della decisione, poi implementata nel form "Aggiungi giocatore" di `/admin` (`src/routes/admin.tsx`): serviva per collegare i giocatori aggiunti a metà stagione senza passare da una nuova migration ogni volta.
**Conseguenze**
- Le righe senza email nota restano bloccate — nessuno può collegarle, nemmeno per errore — finché un admin non la imposta da `/admin` (o, per la rosa iniziale, una migration). Da settembre 2026 tutta la rosa attiva ha l'email registrata.
- `slotLiberi` (funzione ed elenco "slot liberi" in `/benvenuto`) è stato rimosso: non aveva più chiamanti in produzione dopo il cambio.
- Un utente che accede con l'account Google sbagliato resta bloccato su `/benvenuto` finché non esce e riprova con l'account giusto.
**Riesame**
- Se in futuro serve un'assistenza admin diretta dal flusso di login invece che da `/admin`.
---
## Decisioni in valutazione
---
### DD-014 — Convergenza schema database (eventi e presenze)
**Data:** —
**Stato:** In valutazione
**Contesto**
Esistono due modelli paralleli: tabelle “legacy” usate dallapp (`eventi_app`, `risposte_presenze`) e tabelle “nuove” con autenticazione e vincoli (`eventi`, `presenze`, `giocatori` UUID).
**Decisione proposta**
Unificare gradualmente sul modello autenticato, dopo auth e profilo stabili.
**Perché non ora**
Rischio regressioni su calendario e presenze, moduli più usati della squadra.
**Riesame previsto**
Post v1.1, con migration e test dedicati.
---
### DD-015 — Rosa anagrafica: da codice hardcoded a database
**Data:** 3 settembre 2026
**Stato:** Accettata
**Contesto**
La lista giocatori viveva nel codice sorgente (`src/lib/crapp-data.ts`). Il database aveva già
`giocatori_squadra` (migration M1) popolata ma non letta da nessuna schermata tranne
`/benvenuto` e `/admin`: «Aggiungi giocatore» e «Disattiva giocatore» della dashboard non
avevano effetto su Squadra, Presenze, Pagelle, Badge e Scout, mantenendo gli stessi ID finché
non si farà DD-012.
**Decisione**
`useRosa()` (e con lei `useIo`, `useObiettivi`) legge ora `giocatori_squadra` tramite
`useGiocatoriSquadra()`, filtrando solo i giocatori `attivo`. Tutti i punti che prima
importavano la lista statica (`convocatiEvento`, `compleanniEventi`, `completaTurni`,
`csvScoutMatch`, i widget di voto/scout/palloni, le due route API che mandano push) sono stati
agganciati allo stesso hook o, lato server, a `leggiGiocatoriSquadra()`
(`src/lib/giocatori-squadra.server.ts`, stesso pattern di `eventi.server.ts`).
**Conseguenze**
- Un giocatore aggiunto o disattivato dalla dashboard admin ora si riflette ovunque, non solo
in `/benvenuto` e `/admin`.
- `giocatori_squadra` non ha ancora una colonna per la data di nascita: per i 17 giocatori
storici resta quella di `crapp-data.ts` (`nascitaPerId`, lookup per id); un giocatore
aggiunto dopo la migrazione non ha nascita nota finché la colonna non esiste. Follow-up da
aprire quando serve davvero.
- `src/lib/crapp-data.ts` resta come seed storico e fallback (`rosaFallback()`), non più come
fonte viva.
---
### DD-019 — Il branch dei commit lo decide l'utente
**Data:** 4 settembre 2026
**Stato:** Accettata — sostituisce [DD-003](#dd-003--due-branch-main-stabile-develop-per-il-lavoro)
**Contesto**
DD-003 prevedeva di lavorare su `develop` e portare su `main` solo dopo i test. Nella pratica
è successo il contrario: `main` è arrivato a 43 commit di vantaggio su `develop`, che è rimasto
fermo. Una regola che nessuno segue è peggio di nessuna regola, perché rende inaffidabile tutto
il resto del documento — e con più assistenti AI in gioco il rischio vero non era il branch
sbagliato, ma un agente che committa o pusha per conto suo.
**Decisione**
È l'utente a dire su quale branch va un commit. L'assistente può **consigliare** un branch
dedicato quando la modifica è rischiosa o parallela ad altro lavoro, ma non cambia branch, non
committa, non fa push e non apre PR di propria iniziativa. In assenza di indicazioni si lavora
dove si trova il repository, di fatto `main`.
**Alternative scartate**
- Tenere DD-003 e riallineare `develop` → si sarebbe rotta di nuovo alla prima fretta.
- Dismettere `develop` → si perderebbero le preview Vercel, utili quando servono davvero.
**Conseguenze**
- `main` è insieme produzione e branch di lavoro: ogni commit deve lasciare l'app funzionante,
quindi la rete di sicurezza sono i test (vedi DD-020), non il branch.
- `develop` esiste ancora ma è indietro: la sua preview Vercel non rappresenta lo stato attuale
finché non viene riallineata.
**Riesame**
Se il team cresce oltre una persona che scrive codice, o se un lavoro lungo ha bisogno di stare
fuori produzione per più di qualche giorno.
---
### DD-020 — Una funzione modificata senza test non è finita
**Data:** 4 settembre 2026
**Stato:** Accettata
**Contesto**
Con `main` come branch di lavoro (DD-019) non c'è più un ambiente di prova tra il codice e i
giocatori. La suite in `test/` esisteva già ma scriverla era di fatto facoltativo, e i difetti
trovati dai test sono arrivati a posteriori (la sessione Scout Live che non scadeva mai, le
serie di presenze ferme a zero per settimane).
**Decisione**
Chi aggiunge o modifica una funzione scrive o aggiorna il test nello stesso lavoro, e i test
devono essere verdi prima di consegnare. Non si commenta un test che fallisce né si indebolisce
un'asserzione per farla passare: se il comportamento voluto è cambiato, si aggiorna il test
dicendo perché.
**Alternative scartate**
- Test solo sui moduli critici → il confine «critico» si sposta a ogni fretta.
- Introdurre un framework di test → la suite bun con `node:assert` funziona e non aggiunge
dipendenze (vedi [test/README.md](../test/README.md)).
**Conseguenze**
- La logica di dominio va tenuta separabile dagli hook (`*-core.ts`), altrimenti non è
testabile in `test/unit/` senza rete.
- Le modifiche costano un po' di più; le regressioni in produzione costano di più.
**Riesame**
Se comparisse un ambiente di staging stabile che rende superflua parte della copertura.
+36
View File
@@ -0,0 +1,36 @@
# Efficienza cloud
Regola di progetto: CrAPP gira su un piano cloud minimo (Supabase + Vercel) per ~17 utenti.
Query, traffico e invocazioni vanno tenuti al minimo **per costruzione**, non ottimizzati dopo.
## Regole da rispettare
1. **Niente polling**: mai `refetchInterval` verso il database. Per sincronizzare più schede
aperte si usano `BroadcastChannel` o gli eventi di `storage`.
2. **Cache lunga e passiva**: i default del `QueryClient` stanno in `src/router.tsx`
(`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` disattivati,
`retry: 1`). Non alzare la frequenza di refetch modulo per modulo.
3. **Dopo una mutazione si aggiorna la cache con `setQueryData`**, non con
`invalidateQueries`: invalidare costa una rilettura. Unica eccezione oggi:
`src/lib/scout-live.ts`.
4. **Scout Live**: scrive solo chi sta segnando; gli altri leggono dati già salvati.
5. **Write once, read many**: statistiche, badge e classifiche si calcolano una volta e non
si ricalcolano a ogni apertura di pagina. I badge restano calcolati a runtime dai dati già
in cache, senza query aggiuntive (DD-007): `src/lib/rosa.ts` aggrega ciò che è già stato
letto.
6. **Push solo per eventi importanti**: convocazioni, promemoria allenamento/partita, turno
palloni, esito finale.
7. **Niente funzionalità pesanti**: foto, video, chat.
8. **Indici** sui campi usati per filtri e relazioni in ogni nuova migration.
## Obiettivi non ancora attuati
Questi punti sono stati definiti come direzione, ma **non sono implementati**: non descrivono
il comportamento attuale.
- **Dati CSI**: sincronizzazione periodica server-side salvata su una tabella locale, con
l'app che legge solo dal database interno. Oggi la lettura è live dal portale a ogni
richiesta, tramite `/api/public/csi` (vedi [modules/collegamento-csi.md](modules/collegamento-csi.md)).
- **Aggregati persistiti**: uno schema con `statistiche_aggregate` e `classifica_csi` è stato
ipotizzato ma non esiste; nessuna di quelle tabelle è in `supabase/migrations/`. Va valutato
con una decisione dedicata, perché tocca DD-007 (badge e statistiche calcolati a runtime).
+10 -10
View File
@@ -5,16 +5,16 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
## Stato attuale
| Componente | Portabile? | Note |
|---|---|---|
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
| 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. |
| Componente | Portabile? | Note |
| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ |
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
| 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
+44
View File
@@ -0,0 +1,44 @@
# Documentazione CrAPP
Indice della documentazione ufficiale del progetto. Ogni file risponde a una domanda
precisa: se l'informazione che cerchi non è nel file indicato, probabilmente non esiste
ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--sviluppo-document-first)).
## Dove sta cosa
| Documento | Risponde a |
| ------------------------------------------ | ---------------------------------------------------------------------------- |
| [VISION.md](VISION.md) | Perché esiste CrAPP, quali principi deve rispettare una funzionalità |
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto e cosa è previsto, versione per versione |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio |
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
| [TODO.md](TODO.md) | A cosa si sta lavorando adesso |
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
Le regole vincolanti per gli assistenti AI stanno in [AGENTS.md](../AGENTS.md);
lo stato corrente del lavoro in [PROJECT_STATE.md](../PROJECT_STATE.md).
## Ordine di lettura
Prima di modificare il codice, nell'ordine: questo indice → `VISION.md``ROADMAP.md`
`ARCHITECTURE.md``DATABASE.md``DESIGN_DECISIONS.md``TODO.md` → il documento del
modulo interessato in `modules/`.
## Regole di manutenzione
Ogni informazione ha **una sola casa**, per evitare che le copie divergano:
- l'elenco delle funzionalità (fatte e previste) sta solo in `ROADMAP.md`;
- `CHANGELOG.md` registra _quando_ qualcosa è stato rilasciato, non ripete l'elenco;
- `TODO.md` contiene solo il lavoro in corso o imminente, e rimanda alla roadmap;
- lo schema del database sta solo in `DATABASE.md`, allineato alle migration in
`supabase/migrations/`: una tabella nuova si documenta nella stessa modifica che la crea;
- le motivazioni stanno solo in `DESIGN_DECISIONS.md`, in voci `DD-XXX`; per aggiungerne
una si copia [\_template-dd.md](_template-dd.md).
Convenzioni di scrittura: un solo titolo `#` per file (le sezioni interne partono da `##`),
niente `---` come riempitivo tra i paragrafi, tabelle al posto degli elenchi ripetitivi.
+53
View File
@@ -0,0 +1,53 @@
# Roadmap
Elenco unico delle funzionalità di CrAPP, fatte e previste. È la fonte di riferimento per
il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata, `TODO.md` cosa si
sta facendo adesso.
## Versione 1.0 — rilasciata
- [x] Gestione squadra
- [x] Calendario
- [x] Presenze
- [x] Serie di presenze
- [x] Scout Live
- [x] Badge
- [x] Badge social
- [x] Pagelle
- [x] Obiettivi di squadra
- [x] Notifiche Push (promemoria intelligenti)
## Versione 1.1
Le voci spuntate sono in produzione su `main`. I passaggi di attivazione ancora aperti (per
esempio il collegamento dei singoli account) stanno in [PROJECT_STATE.md](../PROJECT_STATE.md).
- [x] Certificati medici — caricamento, scadenza, stato e download; 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] Dashboard amministratore
- [x] Download CSV dati
## Versione 1.2
- [ ] Database esercizi
- [ ] AI Allenamenti
- [ ] Archivio allenamenti
## Versione 2.0
- [x] Collegamento CSI (stagione 2025/26)
- [x] Classifica automatica
- [x] Risultati campionato
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
Campionato usa solo quelle giocate
## Idee future
- [ ] Gestione quote
- [ ] Calendario Google
- [ ] Backup automatici
- [ ] Analisi statistiche avanzate
- [ ] Widget meteo
- [ ] Analisi Scout con AI
+37
View File
@@ -0,0 +1,37 @@
# TODO
Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previste sta in
[ROADMAP.md](ROADMAP.md); le idee non ancora valutate pure.
## In corso
- Documentazione tecnica del progetto.
- Autenticazione Google e dashboard amministratore: il codice è in produzione su `main` e il
login è l'unica via d'accesso. La migration M4, che chiude gli accessi `anon` alle
tabelle v1.0, è stata applicata in produzione (03/09/2026). Resta il collegamento dei
singoli account: ogni giocatore si aggancia al proprio profilo al primo login (DD-018), un
processo continuo — vale anche per chi viene aggiunto a stagione in corso da `/admin`.
Stato di dettaglio in [PROJECT_STATE.md](../PROJECT_STATE.md).
## Prossimo
- Niente di assegnato. Le voci ancora aperte in [ROADMAP.md](ROADMAP.md) sono «Calendario
ufficiale» (v2.0, i dati delle gare future arrivano già dal feed CSI) e la v1.2.
La gestione tesseramenti CSI della v1.1 è completa: raccolta dati, export CSV e tracciamento
di chi è già tesserato (numero e data di tessera, migration `m8_tesseramento_csi`, registrabili
da `/admin`).
Il profilo giocatore lato giocatore e i certificati medici sono fatti: `ProfiloAmministrativo`
in `src/routes/profilo.tsx` carica documento, certificato e foto con le date di scadenza, e
la dashboard amministratore legge quei dati.
## Manutenzione ricorrente
- Collegamento CSI: aggiornare `project_id` e `team_id` a inizio stagione 2026/27
(vedi [modules/collegamento-csi.md](modules/collegamento-csi.md)).
## Backlog
- AI Allenamenti (roadmap v1.2).
- Backup automatici (roadmap, idee future).
+32
View File
@@ -0,0 +1,32 @@
# Visione del progetto
## Missione
CrAPP nasce con un obiettivo semplice:
Digitalizzare completamente la gestione di una squadra di pallavolo, eliminando il maggior numero possibile di attività manuali e aumentando il coinvolgimento dei giocatori attraverso strumenti moderni e intuitivi.
## Principi del progetto
Ogni funzionalità sviluppata deve rispettare almeno uno di questi principi:
- Ridurre il lavoro amministrativo.
- Migliorare il coinvolgimento della squadra.
- Centralizzare tutte le informazioni in un'unica piattaforma.
- Automatizzare le attività ripetitive.
- Sfruttare l'intelligenza artificiale solo quando porta un reale beneficio.
## Filosofia
CrAPP deve essere:
- Semplice
- Veloce
- Divertente
- Intuitiva
- Accessibile da smartphone
- Utilizzabile anche da persone poco esperte
## Obiettivo finale
Diventare il punto di riferimento per la gestione quotidiana della squadra, sostituendo chat, fogli Excel e strumenti separati con un'unica applicazione.
+21
View File
@@ -0,0 +1,21 @@
### DD-XXX — [Titolo breve della decisione]
**Data:**
**Stato:** Accettata · In valutazione · Sostituita · Obsoleta
**Contesto**
[Quale problema stavamo risolvendo?]
**Decisione**
[Cosa abbiamo scelto?]
**Alternative scartate**
- [Alternativa 1] → [perché no]
- [Alternativa 2] → [perché no]
**Conseguenze**
[Cosa cambia per utenti, admin e team di sviluppo]
**Riesame**
[Quando o in quali condizioni rivedere la decisione]
@@ -0,0 +1,89 @@
-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016)
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle v1.0 esistenti.
-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato
-- `profili-giocatore` creato dalla migration M3.
CREATE TABLE public.profili_giocatore (
giocatore_id text PRIMARY KEY REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
-- Dati personali richiesti dal tesseramento CSI
data_nascita date,
luogo_nascita text,
indirizzo text,
telefono text,
email text,
-- Documento di identità
documento_tipo text,
documento_numero text,
documento_rilasciato_da text,
documento_emissione date,
documento_scadenza date,
-- Il documento si carica fronte e retro: il CSI li vuole entrambi.
documento_fronte_path text,
documento_retro_path text,
-- Certificato medico (storico non conservato in v1: DD-010)
certificato_scadenza date,
certificato_path text,
-- Foto tessera
foto_path text,
creato_il timestamptz NOT NULL DEFAULT now(),
aggiornato_il timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.profili_giocatore IS
'Dati personali, documento e certificato di ciascun giocatore, 1:1 con giocatori_squadra. Vedi DD-016.';
CREATE TRIGGER update_profili_giocatore_aggiornato_il
BEFORE UPDATE ON public.profili_giocatore
FOR EACH ROW
EXECUTE FUNCTION public.update_aggiornato_il();
ALTER TABLE public.profili_giocatore ENABLE ROW LEVEL SECURITY;
-- Il giocatore vede e modifica solo il proprio profilo: il collegamento passa
-- da giocatori_squadra.auth_user_id, che solo un admin può riassegnare (M1).
CREATE POLICY "Il giocatore legge il proprio profilo" ON public.profili_giocatore
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
)
);
CREATE POLICY "Il giocatore crea il proprio profilo" ON public.profili_giocatore
FOR INSERT TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
)
);
CREATE POLICY "Il giocatore aggiorna il proprio profilo" ON public.profili_giocatore
FOR UPDATE TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
)
);
-- Gli admin leggono tutti i profili ed esportano i dati per il tesseramento.
CREATE POLICY "Gli admin gestiscono tutti i profili" ON public.profili_giocatore
FOR ALL TO authenticated
USING (public.has_role(auth.uid(), 'admin'::public.app_role))
WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
GRANT SELECT, INSERT, UPDATE ON public.profili_giocatore TO authenticated;
GRANT ALL ON public.profili_giocatore TO service_role;
@@ -0,0 +1,36 @@
-- M3 — Bucket privato per documenti, certificati e foto tessera (DD-016 regole 3 e 4)
-- Migration additiva. Il bucket nasce privato e resta privato: documenti d'identità e
-- dati sanitari non devono mai essere raggiungibili da un URL pubblico. L'accesso avviene
-- solo con client autenticato o con signed URL a scadenza breve generata per gli admin.
INSERT INTO storage.buckets (id, name, public)
VALUES ('profili-giocatore', 'profili-giocatore', false)
ON CONFLICT (id) DO NOTHING;
-- Convenzione dei path: `<giocatore_id>/<sezione>.<estensione>` (es. `g4/certificato.pdf`).
-- La prima cartella è l'ID del giocatore: è così che si riconosce il proprietario del file.
CREATE POLICY "Il giocatore gestisce i propri file" ON storage.objects
FOR ALL TO authenticated
USING (
bucket_id = 'profili-giocatore'
AND EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid()
)
)
WITH CHECK (
bucket_id = 'profili-giocatore'
AND EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid()
)
);
-- Gli admin scaricano i file di tutti, ma non li modificano: i documenti restano
-- in mano al giocatore che li ha caricati.
CREATE POLICY "Gli admin scaricano tutti i file dei profili" ON storage.objects
FOR SELECT TO authenticated
USING (
bucket_id = 'profili-giocatore'
AND public.has_role(auth.uid(), 'admin'::public.app_role)
);
+77
View File
@@ -0,0 +1,77 @@
# Modulo — Badge
**Stato:** implementato (v1.0), 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`
---
## Obiettivo
Gamification: sbloccare badge (gradi bronzo/argento/oro, più badge "segreti") in base a
statistiche personali reali del giocatore, per motivare la partecipazione senza penalizzare i
ruoli con meno statistiche "spettacolari" (DD-008).
---
## Dati
Nessuna tabella dedicata ai badge sbloccati: **calcolati interamente a runtime**
dall'oggetto `Giocatore` (DD-007). L'unica tabella coinvolta è `badge_social_voti`, per i
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.
- **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
(modificabile), con **auto-voto escluso sia in UI sia in logica** (`VotoSocial.tsx`). A
differenza delle [Pagelle](pagelle.md), qui non c'è alcun tentativo di anonimato:
`votante_id`/`votato_id` sono entrambi visibili.
- `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.
- Il rilevamento "nuovo" (`notifiche-smart.ts`) confronta id deterministici con quelli già
visti, salvati in `localStorage` — quindi **locale al dispositivo**, non sincronizzato tra
dispositivi dello stesso giocatore.
---
## Regole rispettate
- **DD-007**: nessuna tabella `badge_sbloccati`, tutto calcolato a runtime dai dati
esistenti.
- **DD-008**: nessun `BadgeDef` usa dati di reparto (punti/ace/muri); solo statistiche
raggiungibili da qualunque ruolo.
---
## Limiti noti
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
"Risposta lampo" e il segreto "Mai un forfait" si muovono solo se cambiano le serie. Le
serie sono calcolate sui dati reali dalla migration `m9` in avanti, ma "Risposta lampo" e
"Mai un forfait" dipendono da `serieConferme`, e `risposto_il` non è ricostruibile per le
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.
- RLS permissiva su `badge_social_voti` (stesso schema di `mvp_voti`): nessun controllo
server-side che `votante_id` coincida con l'utente autenticato.
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
browser o dispositivo.
---
## Evoluzioni possibili
- 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.
+100
View File
@@ -0,0 +1,100 @@
# Modulo — Collegamento CSI
**Stato:** implementato (stagione 2025/26)
**Route interessata:** `/classifica`
---
## Obiettivo
Mostrare nell'app la classifica e i risultati **ufficiali** del campionato CSI, al posto
dei dati dimostrativi hardcoded in `crapp-data.ts`. Nessun inserimento manuale da parte
degli amministratori: è esattamente il tipo di lavoro amministrativo che CrAPP deve togliere.
---
## Sorgente dati
Portale **Livescore CSI Bologna** (`https://livescore.csibologna.it`).
Il portale **non espone un'API pubblica documentata**. Vengono usati gli stessi endpoint
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`.
### 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 |
Gli identificativi sono costanti in `src/lib/csi-core.ts`.
---
## Implementazione
```
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)
/classifica → src/routes/classifica.tsx
```
- **`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.
- **`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.
### 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.
- **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()`).
- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo.
---
## Limiti noti
1. **La classifica si legge da HTML.** Se il portale cambia la struttura della tabella il
parsing restituisce un array vuoto: `/classifica` non si rompe, ma mostra "Classifica non
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`.
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.
---
## Evoluzioni possibili
- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili).
- Creazione automatica degli eventi partita da calendario CSI.
- Confronto tra i parziali ufficiali e quelli dello Scout Live.
+52
View File
@@ -0,0 +1,52 @@
# Modulo — Infortuni
**Stato:** implementato in forma minima (solo conteggio)
**File principali:** `src/lib/infortuni.ts`
---
## Obiettivo
Tracciare quanti eventi (allenamenti o partite) un giocatore ha saltato per infortunio,
riusando lo stato di presenza `infortunato` già registrato per le convocazioni — nessun
modulo di gestione infortuni a sé stante.
---
## Dati
Nessuna tabella dedicata: il dato vive interamente dentro `risposte_presenze`, come uno dei
valori possibili dell'enum `Stato` (`presente`, `assente`, `forse`, `ritardo`, `infortunato`).
---
## Implementazione
`contaStato()`/`contaInfortuni()` (`infortuni.ts`) contano, per ciascun giocatore, quante
volte compare lo stato `infortunato` nella mappa presenze già in cache (nessuna query
aggiuntiva). Lo stesso meccanismo, con `contaRitardi()`, conta i ritardi. Il risultato
alimenta il campo `infortuni` del `Giocatore` in `useRosa()`.
Visibile in UI solo indirettamente, tramite il [badge](badge.md) segreto "Cliente VIP
dell'Infermeria" (sbloccato con almeno 3 infortuni): non esiste uno StatTile dedicato nel
profilo che mostri il numero di infortuni come statistica di superficie.
---
## Limiti noti
- Nessuna durata o periodo tracciato: è solo un conteggio di eventi con quello stato, non un
inizio/fine infortunio.
- Il conteggio dipende dal fatto che qualcuno imposti correttamente lo stato "infortunato"
invece di "assente": nessuna validazione o promemoria lo garantisce.
- Poco visibile per valori bassi (1-2), perché emerge solo tramite un badge a soglia 3.
- `conInfortuni()`, una funzione di merge alternativa nello stesso file, non risulta usata da
nessuna parte del codice attuale — probabile residuo non collegato.
---
## Evoluzioni possibili
- Uno StatTile dedicato nel profilo, oltre al badge segreto.
- Se servisse un vero tracciamento (durata, tipo di infortunio), servirebbe una tabella
dedicata: oggi il modulo copre solo il conteggio.
+52
View File
@@ -0,0 +1,52 @@
# Modulo — Votazione MVP
**Stato:** implementato (v1.0)
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
---
## Obiettivo
Eleggere il MVP di una partita tramite voto tra compagni, un voto a testa, con vincitore
calcolato a runtime.
---
## Dati
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
partita, sovrascrivibile.
---
## Implementazione
- Il pannello compare in `partita.$id.tsx` solo se esiste un risultato (Scout Live salvato)
per la partita, altrimenti mostra "la partita non è ancora stata disputata".
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza
limiti, senza storico.
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
- `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.
---
## Limiti noti
- **Nessun controllo che impedisca di votare se stessi** — a differenza dei
[Badge social](badge.md), che escludono esplicitamente l'auto-voto. È una lacuna, non un
limite di design dichiarato altrove.
- Nessuna scadenza o chiusura della votazione: resta aperta indefinitamente.
- RLS permissiva: `votante_id`/`votato_id` sono testo libero inviato dal client (l'id
giocatore proviene da `localStorage`, non da un claim di sessione verificato server-side);
nessun trigger lega il voto all'utente autenticato.
- In caso di parità, nessun MVP viene assegnato per quella partita.
---
## Evoluzioni possibili
- Impedire l'auto-voto come già avviene nei Badge social.
- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita).
+93
View File
@@ -0,0 +1,93 @@
# Modulo — Notifiche
**Stato:** implementato parzialmente — solo il canale "turno palloni" è realmente collegato
(vedi Limiti noti)
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/push-client.ts`,
`src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
`src/routes/api/public/push-messaggio.ts`, `src/routes/api/public/push-subscribe.ts`,
`public/push-sw.js`
---
## Obiettivo
Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
indipendenti:
- **Push VAPID** — arrivano anche ad app chiusa (turno palloni, sollecito presenze).
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
serie e obiettivi appena raggiunti; non è un canale push separato.
---
## Dati
`push_subscriptions` (un dispositivo per riga, chiave `endpoint`), `promemoria_push` (coda
"consuma e cancella" del testo da mostrare — nonostante il nome, **non** è uno storico
persistente: la riga viene eliminata non appena letta dal service worker).
---
## Iscrizione alle notifiche push
1. Il giocatore attiva "Notifiche turno palloni" in `/profilo` → richiesta permesso browser.
2. `GET /api/public/push-config` restituisce solo la chiave pubblica VAPID.
3. Registrazione del service worker `public/push-sw.js` e `pushManager.subscribe()`.
4. `POST /api/public/push-subscribe` registra endpoint e chiavi in `push_subscriptions`
(upsert).
---
## Ruolo delle tre route pubbliche
- **`push-config`** — espone la sola chiave pubblica VAPID.
- **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo.
- **`push-messaggio`** — non invia nulla: il service worker la interroga **al momento della
ricezione** di una push (che arriva sempre "vuota", senza testo, per compatibilità) per
sapere quale messaggio mostrare. Priorità: un messaggio in coda su `promemoria_push`
(scritto da `sollecita-presenze`, vedi [Presenze](presenze.md)), altrimenti il messaggio
calcolato al volo sul turno palloni (vedi [Palloni](palloni.md)).
L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un JWT VAPID
(ECDSA P-256) e fa una POST senza corpo all'endpoint push del browser; è riusato identico da
`sollecita-presenze.ts` e `promemoria-palloni.ts`.
---
## Notifiche smart
`calcolaNotifiche()` (`notifiche-smart.ts`) genera un evento solo quando "c'è qualcosa di
reale": badge appena sbloccato, "sei a un passo" da un traguardo, serie che raggiunge un
traguardo esatto, obiettivo di squadra tra il 90 e il 100%, badge social vinto. Ogni notifica
ha un id deterministico; quelli già mostrati sono salvati in `localStorage` per non
ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
---
## Limiti noti
- **Le 4 voci "Notifiche convocazioni", "Promemoria allenamenti", "Cambi orario", "Bacheca
squadra" in `/profilo` sono placeholder statici**: checkbox non controllati
(`defaultChecked`, nessun `onChange`), non collegati a nessuno stato, nessuna colonna DB
per queste preferenze. L'unica preferenza realmente funzionante è "Notifiche turno
palloni".
- `promemoria_push` è descritta altrove come "storico" ma nel codice è una coda che si
autocancella alla lettura: non conserva nulla.
- Nessuna verifica di autenticazione su `push-messaggio` (chiunque conosca un endpoint push
valido può leggerne il messaggio) né su `promemoria-palloni`.
- Compatibilità iOS/Safari non gestita esplicitamente nel codice (nessun branch dedicato):
serve l'installazione da schermata Home per funzionare, ma l'app non lo segnala
esplicitamente.
- Le notifiche smart dipendono da un service worker già registrato: se il giocatore non ha
mai attivato le push, `notificaSistema()` non ha un `reg` a cui appoggiarsi e la notifica
locale non viene mai mostrata, anche con permesso concesso.
- Payload push sempre vuoto: ogni notifica richiede una fetch aggiuntiva (`push-messaggio`)
per ottenere il testo, quindi serve rete disponibile anche solo per mostrare il messaggio.
---
## Evoluzioni possibili
- Collegare (o rimuovere) le 4 preferenze placeholder in `/profilo`.
- Aggiungere autenticazione alle route pubbliche coinvolte.
- Gestire esplicitamente il caso iOS (messaggio se l'app non è installata da Home).
+61
View File
@@ -0,0 +1,61 @@
# Modulo — Obiettivi di squadra
**Stato:** implementato (v1.0), con costanti stagionali da aggiornare a mano
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
---
## Obiettivo
Mostrare traguardi collettivi (non individuali) che avanzano con il contributo di tutta la
rosa — presenze, risposte alle convocazioni, pagelle, risultati di campionato — per motivare
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).
---
## 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` |
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`).
---
## Limiti noti
- "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.
---
## Evoluzioni possibili
- Calcolare il mese di riferimento dinamicamente invece di una costante hardcoded.
+63
View File
@@ -0,0 +1,63 @@
# Modulo — Pagelle
**Stato:** implementato (v1.0)
**File principali:** `src/lib/pagelle.ts`, `src/components/crapp/Pagelle.tsx`
---
## Obiettivo
Voto tra compagni (1-10) a fine partita per ciascun convocato, usato per calcolare una media
personale mostrata nel profilo e una media di squadra.
---
## Dati
Tabella `pagelle_voti`, con vincoli imposti a livello database: `CHECK voto BETWEEN 1 AND 10`,
`CHECK votante_id <> votato_id` (anti auto-voto imposto anche dal database, non solo dalla
UI), `UNIQUE (match_id, votante_id, votato_id)`.
---
## Implementazione
- Il pannello `Pagelle` compare in `partita.$id.tsx` solo se esiste un risultato per la
partita (scout salvato o dato CSI).
- Ogni convocato può votare tutti gli altri convocati, mai se stesso — escluso sia in UI sia
dal vincolo DB.
- `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.
---
## Regole rispettate
- 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.
---
## 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.
---
## Evoluzioni possibili
- Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati.
- Far rispettare `pagelleChiuse` anche via RLS.
+71
View File
@@ -0,0 +1,71 @@
# Modulo — Palloni
**Stato:** implementato (v1.0)
**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`
---
## Obiettivo
Gestire un turno a rotazione condiviso per chi porta e riporta i palloni ad allenamenti e
partite, con proposta automatica, possibilità di modifica manuale e promemoria push il
giorno stesso.
---
## Dati
Tabella `turni_palloni` (`evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`) —
contiene solo i turni **confermati manualmente**; le proposte automatiche non salvate non vi
compaiono.
---
## Implementazione
- `completaTurni()` (`palloni-core.ts`) propone, per ogni evento senza turno già salvato, il
candidato con meno turni fatti, poi quello che non lo fa da più tempo, poi per ordine
alfabetico — un algoritmo greedy, non un ordine fisso né solo per data.
- `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** — non è uno storico in tabella dedicata.
- `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx`
è il banner in Home per il giocatore di turno.
---
## Route API pubblica `/api/public/promemoria-palloni`
Pensata per essere chiamata quotidianamente da uno scheduler esterno (pg_cron o simile,
secondo `docs/PORTABILITA.md`), non da nessun componente client. Calcola i destinatari del
giorno — chi deve **prendere** i palloni oggi e chi deve **riportarli** (l'incaricato
dell'evento precedente) — e invia loro una push "vuota" (`src/lib/webpush.server.ts`); il
testo effettivo viene calcolato al volo dal service worker interrogando
`/api/public/push-messaggio` (vedi [Notifiche](notifiche.md)).
---
## Limiti noti
- **Nessuna verifica di autenticazione/secret** sulla route `promemoria-palloni`: chiunque
può invocarla via POST diretto, nonostante il piano originale prevedesse una protezione
con secret.
- **Nessun cron nel repository**: lo scheduling effettivo (se esiste) è configurato fuori dal
codice versionato — da verificare lato Supabase/hosting.
- 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.
---
## Evoluzioni possibili
- Aggiungere un secret/header di autorizzazione alla route pubblica.
- Versionare il cron (es. una migration con `cron.schedule`) invece di configurarlo solo
lato dashboard.
- Escludere dalla rotazione chi ha già dichiarato assenza per l'evento.
+93
View File
@@ -0,0 +1,93 @@
# Modulo — Presenze
**Stato:** implementato (v1.0)
**File principali:** `src/lib/presenze.ts`, `src/lib/presenze-mese.ts`, `src/components/crapp/RosaPresenze.tsx`,
`src/routes/api/public/sollecita-presenze.ts`
---
## Obiettivo
Permettere a ogni giocatore di confermare o rifiutare la propria partecipazione a un evento
(allenamento o partita) e mostrare a tutta la squadra chi ha risposto e come, sostituendo i
solleciti a voce o su chat esterne.
---
## Dati
Tabella `risposte_presenze` (PK composita `evento_id, giocatore_id`), letta e scritta da
`src/lib/presenze.ts`. È il modello "in uso" citato in `docs/DATABASE.md`; le tabelle
`eventi`/`presenze` previste da DD-014 non sono referenziate da nessun punto del codice
attuale.
Stati possibili (`Stato` in `src/lib/crapp-data.ts`): `presente`, `assente`, `forse`,
`ritardo`, `infortunato`. Solo `presente` e `ritardo` contano come presenza effettiva nelle
statistiche. L'assenza di una riga per `(evento, giocatore)` equivale a "non ha ancora
risposto".
---
## Implementazione
```
Giocatore tocca uno stato in RosaPresenze
useSalvaPresenza() → src/lib/presenze.ts (upsert o delete su risposte_presenze,
↓ onConflict evento_id+giocatore_id)
risposte_presenze (Supabase)
↓ letta da
useRispostePresenze() → src/lib/presenze.ts (1 query per sessione, staleTime 5 min,
↓ legge tutta la tabella)
RosaPresenze → src/components/crapp/RosaPresenze.tsx
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato)
allenamento.$id.tsx / partita.$id.tsx
--- statistiche ---
contaPresenzeGiocatore() / totaliEventiGiocatore() → src/lib/presenze.ts
usePresenzeUltimoMese() → src/lib/presenze-mese.ts
(percentuale ultimi 30gg, da cache già in memoria)
--- sollecito (solo admin) ---
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
src/routes/api/public/sollecita-presenze.ts
├─ legge l'evento (eventi_app) e le risposte già date
├─ calcola i destinatari: giocatori attivi senza risposta o con "forse"
├─ per ciascuno registra il messaggio in promemoria_push e invia una push
│ (src/lib/webpush.server.ts)
└─ elimina le iscrizioni push scadute (404/410)
```
Un evento conta ai fini delle statistiche di presenza solo se è di tipo `partita` o
`allenamento` e il giocatore è tra i convocati (o non ci sono convocati specificati, cioè
vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
---
## Regole rispettate
- Aggiornamento ottimistico della cache locale dopo ogni salvataggio: nessuna rilettura dal
server, la UI risponde subito.
- Il sollecito è **manuale**: nessun cron nel repository lo richiama automaticamente, parte
solo dal bottone admin.
---
## Limiti noti
- Nessuna finestra temporale per rispondere: si può cambiare risposta anche a evento passato.
- Il controllo "solo il giocatore risponde per sé" è solo lato UI: le policy RLS di
`risposte_presenze` permettono a qualunque utente autenticato di scrivere qualunque riga
(`USING(true) WITH CHECK(true)`).
- `useRispostePresenze()` legge sempre l'intera tabella, non filtrata per evento: adeguato per
una singola squadra, da rivedere se il volume cresce molto.
- La route `/api/public/sollecita-presenze` non verifica lato server che il chiamante sia
admin: la protezione è solo nell'interfaccia (bottone visibile solo se `useIsAdmin()`).
---
## Evoluzioni possibili
- Restringere anche lato RLS/route chi può scrivere una risposta o chiamare il sollecito.
- Filtrare la lettura delle presenze per evento invece di caricare tutta la tabella.
+245
View File
@@ -0,0 +1,245 @@
# Modulo — Profilo Giocatore
## Obiettivo
Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amministrative e documentali di ciascun membro della squadra.
L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al giocatore sia agli amministratori, eliminando la gestione tramite chat, documenti cartacei e fogli Excel.
## Utenti
### Giocatore
Può:
- visualizzare il proprio profilo
- modificare i propri dati personali
- aggiornare il certificato medico
- aggiornare i documenti
- caricare le immagini richieste
### Amministratore
Può:
- visualizzare il profilo di tutti i giocatori
- scaricare documenti e certificati
- esportare i dati necessari al tesseramento CSI
- verificare lo stato di completamento dei profili
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo, email)
- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017)
- scollegare un account da un profilo, liberando lo slot
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale)
- disattivare un giocatore che ha lasciato la squadra, e riattivarlo in caso di errore: la
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
agganciati al suo id
Non può caricare o sostituire i file altrui: documento, certificato e foto restano
responsabilità del giocatore che li fornisce.
## Flusso utente
### Primo accesso
1. Login tramite Google oppure Email. _Implementato con il solo Google: la squadra ha tutti
un account Google, e un secondo metodo è additivo (un bottone in più sulla stessa
schermata) il giorno che serve._
2. Collegamento automatico al proprio giocatore, confrontando l'email dell'account Google
con l'email registrata in `giocatori_squadra` (DD-018). Nessuna scelta manuale: se
l'email non corrisponde a nessun profilo, l'accesso si ferma con un messaggio che invita
a contattare un amministratore.
3. Accesso alla Home.
Se il profilo non è completo compare automaticamente un widget di completamento.
## Home
Il giocatore visualizza un widget dedicato.
### Completa il tuo profilo
Viene mostrata una barra di avanzamento (esempio: _Profilo completato — 85%_), composta dalle seguenti sezioni.
- Dati personali
- Documento di identità
- Certificato medico
- Foto tessera
Quando tutte le sezioni sono complete il widget scompare automaticamente.
## Profilo
Il profilo viene suddiviso in sette aree.
### Dati Giocatore
**Dati squadra** — solo lettura, gestiti esclusivamente dagli amministratori.
- Nome
- Cognome
- Numero di maglia
- Ruolo
**Dati personali** — modificabili dal giocatore.
- Data di nascita
- Luogo di nascita
- Indirizzo di residenza
- Telefono
- Email
### Documento di identità
Campi.
- Tipo documento
- Numero documento
- Rilasciato da
- Data emissione
- Data scadenza
Upload.
- Foto fronte
- Foto retro
### Certificato medico
Campi.
- Data di scadenza
Upload.
- Certificato medico
Il giocatore può aggiornare liberamente sia la data sia il file.
Lo storico non viene mantenuto nella prima versione.
### Foto tessera
Upload di una fotografia formato tessera.
Utilizzata dagli amministratori per il tesseramento CSI.
### Statistiche
Sezione già presente. Contiene.
- Presenze
- Voto medio
- MVP
- Serie
- Altre statistiche disponibili
### Badge
Sezione già presente.
Contiene tutti i badge ottenuti e quelli ancora da sbloccare.
### Impostazioni
Contiene.
- Logout
- Preferenze notifiche
- Impostazioni applicazione
- Segnala un bug e Suggerisci una nuova funzionalità: due link che aprono una issue GitHub
già impostata sul template giusto (`.github/ISSUE_TEMPLATE/bug_report.yml` e
`feature_request.yml`). Nessun dato passa dall'app — la segnalazione vive interamente su
GitHub, così non servono né una tabella né una schermata di gestione.
## Dashboard amministratore
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
Profilo → Impostazioni).
Per ogni giocatore vengono mostrati.
- Stato del profilo
- Certificato medico
- Documento di identità
- Foto tessera
- Stato tesseramento CSI (tesserato / da tesserare)
Azioni disponibili.
- Visualizza profilo (la scheda si apre in linea nell'elenco: nessuna schermata separata)
- Scarica certificato
- Scarica documento
- Scarica foto tessera
- Modifica dati squadra e dati personali del giocatore (DD-017)
- Registra numero e data della tessera CSI, una volta arrivata dal comitato
- Scollega account, per liberare uno slot assegnato per errore
- Aggiungi giocatore, per inserire un nuovo membro della squadra
- Disattiva/Riattiva giocatore, per chi lascia la squadra (o rientra)
## Esportazione CSI
Gli amministratori possono esportare un file CSV contenente esclusivamente i dati richiesti per il tesseramento.
Campi esportati.
- Nome
- Cognome
- Data di nascita
- Luogo di nascita
- Indirizzo
- Telefono
- Email
- Tipo documento
- Numero documento
- Rilasciato da
- Data emissione
- Data scadenza
## Tracciamento tesseramento
Numero e data della tessera CSI non sono dati che il giocatore conosce in anticipo: arrivano
dal comitato dopo l'iscrizione effettiva. Per questo, a differenza dei dati personali del
profilo, li scrive solo un amministratore — come nome, cognome, numero di maglia e ruolo
(DD-017), il trigger sulla tabella li rende non modificabili dal giocatore stesso. La
dashboard mostra un badge "Tesserato"/"Da tesserare" su ogni scheda e il conteggio
complessivo della squadra.
## Completamento profilo
Ogni sezione contribuisce alla percentuale di completamento.
| Sezione | Peso |
| --------------------- | ---- |
| Dati personali | 30% |
| Documento di identità | 30% |
| Certificato medico | 30% |
| Foto tessera | 10% |
Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
## Permessi
**Giocatore** — può modificare esclusivamente il proprio profilo.
**Amministratore** — può visualizzare tutti i profili, scaricare tutti i documenti, esportare i
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
Non carica file al posto di altri.
## Versione 1
- Profilo giocatore
- Completamento profilo
- Gestione dati personali
- Documento di identità
- Certificato medico
- Foto tessera
- Dashboard amministratore
- Esportazione CSV CSI
## Versioni future
- Storico certificati medici
- Gestione documenti aggiuntivi
- Consensi privacy
- Firma digitale
- Verifica automatica documenti
+114
View File
@@ -0,0 +1,114 @@
# Modulo — Scout Live
**Stato:** implementato (v1.0, 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`
---
## Obiettivo
Permettere a un solo referente per volta di registrare in tempo reale, durante la partita,
punti, ace, muri ed errori di ciascun giocatore in campo, con salvataggio condiviso su
Supabase (non più solo `localStorage`, fix M7) così che tutta la squadra veda lo stato
aggiornato da qualunque dispositivo.
---
## Dati
- `scout_sessioni` — chi ha il controllo dello Scout Live per una partita (una riga per
`evento_id`, quindi un solo detentore).
- `scout_live` — stato in corso (azioni non ancora concluse) di una sessione.
- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni),
mai più modificato una volta salvato (solo eliminabile per intero).
- `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento
(`UNIQUE evento_id, giocatore_id`).
---
## Chi può usarlo
Solo gli admin lato UI: `ScoutEntry.tsx` e `scout.tsx` bloccano i non-admin con il messaggio
"Scout riservato". **Il controllo non è imposto a livello database**: le policy RLS di
`scout_sessioni`/`scout_live`/`scout_partite` sono aperte a qualunque utente autenticato, non
solo agli admin — la migration M4 toglie l'accesso solo al ruolo `anon`.
---
## Meccanismo di lock condiviso
- `useApriSessioneScout()` (`scout-live.ts`) prende il controllo con un upsert su
`scout_sessioni` (chiave `evento_id`), rifiutando se un altro giocatore ha già una sessione
non scaduta.
- Una sessione scade dopo 5 minuti di inattività; `useHeartbeatScout()` la rinnova ogni 60
secondi finché lo scout resta aperto.
- Il rilascio (`useChiudiSessioneScout()`) avviene al bottone "Rilascia", a fine partita, e
sull'evento `pagehide` della finestra (per liberare il lock se il browser viene chiuso senza
uscire esplicitamente).
- Nessun realtime: la sessione si rilegge solo all'apertura/focus pagina o al bottone
"Aggiorna" (`staleTime` 30s).
---
## Cosa registra
Tipi di azione (`AzioneTipo`, `scout-store.ts`): `attacco`, `ace`, `muro`, `errore`,
`punto_avv`, `errore_avv` — attacco/ace/muro ed errore avversario valgono come punto nostro,
errore nostro e punto avversario come punto avversario. Le azioni con giocatore
(attacco/ace/muro/errore) richiedono di selezionarlo prima dalla griglia dei convocati
(filtrati sulle risposte "presente"/"ritardo", con fallback a tutta la rosa se nessuno ha
risposto). Salvataggio automatico su `scout_live` con debounce di 800ms a ogni cambiamento.
---
## Fine partita
`finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in
`scout_partite` (INSERT, non upsert), poi cancella la riga da `scout_live` (stato consumato)
e rilascia la sessione.
---
## Export CSV
`scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per
giocatore e log cronologico delle azioni. Scaricabile dagli admin dalla pagina partita,
sezione "Report tecnico".
---
## Sondaggio cacche
`SondaggioCacche.tsx` chiede "quante cacche hai fatto prima di questa partita" (0-5+), sempre
modificabile, senza gating temporale reale (visibile sia prima sia dopo la partita nonostante
il nome). `statisticheCacche()` (`cacche.ts`) calcola media, record e `giornateTop`
(giornate con ≥3), soglia usata per un [badge](badge.md) segreto — coerente con DD-007 (badge
calcolati a runtime).
---
## Regole rispettate
- **DD-008 (gamification equa)**: i dati tecnici (punti/ace/muri) restano confinati allo Scout
Live come statistica di squadra e non entrano nel tipo `Giocatore` usato per badge o
classifiche individuali.
---
## Limiti noti
- Controllo "solo admin" non imposto dal database (vedi sopra).
- Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock:
lettura e upsert non sono atomici.
- `scout_partite` si inserisce ma non si corregge dall'interfaccia: solo eliminazione totale.
- Abbinamento partita↔scout fatto anche per uguaglianza di data come fallback: ambiguo se due
partite cadono lo stesso giorno.
---
## Evoluzioni possibili
- Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale.
- Restringere le policy RLS al solo ruolo admin.
+312
View File
@@ -0,0 +1,312 @@
# Modulo — Serie di presenze
**Stato:** implementato — tutte e tre le serie calcolate sui dati reali
**File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`,
`src/components/crapp/SerieCard.tsx`
**Migration collegata:** `m9_risposte_presenze_risposto_il`
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`
---
## Obiettivo
Motivare la costanza dei giocatori mostrando "serie" (streak) di comportamenti positivi
consecutivi — presenza agli allenamenti, presenza alle partite, risposta entro 24 ore alla
convocazione — con traguardi progressivi, sullo stile delle app fitness. È anche uno dei
requisiti di sblocco di alcuni [badge](badge.md) e di un [obiettivo di squadra](obiettivi-squadra.md).
---
## Le tre serie in sintesi
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
| ------------- | ------------------ | ------------------------------------------------------------ | ------------ |
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
| `conferme` | `serieConferme` | Eventi consecutivi con risposta entro 24h dalla convocazione | 3, 8, 15, 20 |
Esiste un quarto contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle
presenze ma **su partite e allenamenti insieme**. Non ha card né traguardi, compare come
"presenze consecutive" in `src/routes/index.tsx`, `src/routes/squadra.tsx` e
`src/routes/profilo.tsx`.
Le serie sono **indipendenti**: un buco agli allenamenti non tocca partite e conferme. È la
regola scritta in `aggiornaSerie()` e va mantenuta se si aggiungono altre serie.
---
## Dati
Non esiste una tabella delle serie e non c'è nessun contatore salvato: **le serie sono
ricalcolate da zero a ogni render**, partendo dagli eventi e dalle risposte già in cache
React Query. Nessuna query aggiuntiva, nessuna migration da rifare quando si cambia una
regola, nessun rischio di contatori disallineati dalla realtà.
Conseguenza pratica: se domani si inseriscono le presenze di eventi passati (import,
backfill, correzione a mano), le serie si aggiornano da sole al caricamento successivo.
### Tabelle lette
| Tabella | Colonne usate | A cosa servono |
| ------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ |
| `eventi_app` | `id`, `tipo`, `data`, `convocati`, `creato_il` | Quali impegni contano, in che ordine, e quando è partita la convocazione |
| `risposte_presenze` | `evento_id`, `giocatore_id`, `stato`, `risposto_il` | Se l'impegno è stato onorato e quanto in fretta è arrivata la risposta |
`risposto_il` (migration `m9`) è l'istante della **prima** risposta del giocatore per quell'
evento. Un trigger (`risposte_presenze_risposto_il_immutabile`) lo blocca su qualsiasi
UPDATE: senza, un giocatore che risponde subito e cambia idea una settimana dopo risulterebbe
lento. `aggiornato_il` continua a registrare l'ultima modifica ed è un'altra cosa: non usarlo
per le conferme.
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta.
---
## Flusso completo
```
eventi_app ─┐
├─► useEventi() ─┐
risposte_ │ (src/lib/eventi.ts) │
presenze ─┘ ├─► useRosa() ─► Giocatore.serie* ─┐
useRispostePresenze() ─┘ (rosa.ts) │
(presenze.ts) │
serieGiocatore() / serieMigliore()
(serie.ts, applica serieDefs)
┌────────────────────────────────┼──────────────┐
▼ ▼ ▼
SerieGriglia SerieHome badges.ts
(profilo) (home) obiettivi.ts
```
Chi calcola cosa:
- **`src/lib/presenze.ts`** — i tre numeri, dai dati grezzi.
- **`src/lib/rosa.ts`** — li attacca a ogni `Giocatore` dentro l'unica `useMemo` di `useRosa()`.
- **`src/lib/serie.ts`** — definizioni, traguardi, progresso e microcopy: da un numero a uno stato mostrabile.
- **`src/components/crapp/SerieCard.tsx`** — la resa a schermo.
---
## Il calcolo (`src/lib/presenze.ts`)
Tutte le serie passano dalla stessa funzione privata `serieSu()`, che fa quattro cose in
ordine:
1. **Filtra gli eventi rilevanti** con `eventiContanoPresenze()` — la stessa funzione che
alimenta il conteggio presenze, così le due statistiche non possono divergere:
- solo `tipo` `partita` o `allenamento` (mai `evento` o `compleanno`);
- solo eventi a cui il giocatore era convocato. **`convocati` vuoto significa "tutta la
rosa"**, non "nessuno": chi non è nell'elenco di una convocazione ristretta non vede
quell'evento e la sua serie non si spezza.
2. **Scarta il futuro** (`e.data <= oggi`). Gli eventi di oggi contano già: se serve un
confronto diverso, il parametro `oggi` è iniettabile (i test lo fissano a una data).
3. **Ordina per data crescente** (`localeCompare` su `YYYY-MM-DD`).
4. **Riduce** applicando `aggiornaSerie(serie, onorato(e))` a ogni evento: `+1` se onorato,
`0` altrimenti. La serie finale è quella che risulta **dopo l'ultimo evento passato**.
Quel che cambia fra le serie è solo il predicato `onorato`.
### `serieConsecutiva()` — allenamenti, partite, `streak`
```ts
serieConsecutiva(giocatoreId, eventi, presenze, tipo?, oggi?)
```
Onorato = lo stato salvato è `presente` **o** `ritardo`. Gli stati possibili sono
`presente | assente | forse | ritardo | infortunato` (`src/lib/crapp-data.ts`).
Conseguenze da conoscere prima di cambiare qualcosa:
- **`infortunato` azzera la serie**, esattamente come `assente`. Coerente con il conteggio
presenze, ma è una scelta da rivedere se si vuole "congelare" la serie di chi è fermo per
infortunio.
- **Nessuna risposta azzera la serie.** Un evento passato per cui il giocatore non ha mai
toccato l'app equivale a un'assenza. È voluto (la serie premia anche il rispondere), ma
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti.
- Senza `tipo` conta partite e allenamenti insieme: è così che si ottiene `streak`.
### `serieConferme()` — conferme entro 24 ore
```ts
serieConferme(giocatoreId, eventi, tempi, oggi?)
```
Onorato = esiste una risposta **e** `risposto_il creato_il ≤ 24h` (confronto inclusivo,
costante `ORE_24`, entrambi gli istanti passati da `Date.parse`).
- Conta **partite e allenamenti insieme**, non c'è una versione per tipo.
- **Lo stato non conta**: anche un "assente" dato in fretta tiene viva la serie. È una serie
sulla reattività, non sulla presenza.
- **Gli eventi senza `creatoIl` vengono saltati e non spezzano la serie.** Sono gli eventi
costruiti dal client e mai salvati a database — i compleanni di `compleanniEventi()` e la
bozza di `eventoVuoto()`. Senza istante di convocazione la domanda "ha risposto in fretta?"
non ha risposta, e trattarli come un buco punirebbe il giocatore per un dettaglio tecnico.
- **Le 24 ore partono dalla creazione dell'evento**, non da un invio di notifica: oggi un
momento di "convocazione mandata" distinto non esiste. Se un domani ci sarà, è quello
l'istante giusto da confrontare.
### Lettura e cache
`fetchPresenze()` fa **una sola query** e costruisce due mappe:
```ts
presenze: { [eventoId]: { [giocatoreId]: Stato } }
tempi: { [eventoId]: { [giocatoreId]: string /* ISO */ } }
```
Entrambe vivono nella stessa entry di React Query (`PRESENZE_KEY`, `staleTime` 5 minuti) e
`useRispostePresenze()` le espone come `presenze` e `tempi`.
`useSalvaPresenza()` non rilegge dopo la scrittura: aggiorna la cache a mano e deve tenere
allineate **entrambe** le mappe. Sull'`upsert` la colonna `risposto_il` non viene inviata —
è quello che la lascia intatta lato database sugli aggiornamenti — e la cache locale imita
la stessa regola con `istanti[giocatoreId] ??= new Date().toISOString()`: si valorizza solo
se manca. Chi tocca quella mutation deve preservare questi due dettagli, altrimenti ogni
ripensamento farebbe ripartire il cronometro delle conferme.
---
## Da numero a card (`src/lib/serie.ts`)
`serieDefs` è l'unica fonte di verità della UI: label, descrizione, icona, traguardi e la
funzione `valore(g)` che pesca il campo giusto dal `Giocatore`.
`statoSerie(def, g)` produce quello che serve a disegnare una card:
| Campo | Come si ricava |
| ----------- | --------------------------------------------------------------------------- |
| `valore` | `def.valore(g)` |
| `prossimo` | primo traguardo **strettamente maggiore** del valore; `null` oltre l'ultimo |
| `manca` | `prossimo - valore` (`0` se fuori scala) |
| `progresso` | percentuale **dentro il livello corrente**, vedi sotto |
| `messaggio` | microcopy, vedi sotto |
### Progresso
```
progresso = round((valore - traguardoPrecedente) / (prossimo - traguardoPrecedente) * 100)
```
La base è il traguardo già raggiunto, non zero. Con la vecchia formula (`valore / prossimo`)
la barra **tornava indietro** ogni volta che se ne raggiungeva uno: a 2 allenamenti segnava
67%, al terzo scendeva al 50%. Ora ogni traguardo apre un livello nuovo che riparte da 0% e
sale fino a 100%, che si tocca solo restando fuori scala (`prossimo === null`).
Esempio con i traguardi degli allenamenti (3, 6, 10, 15):
| Valore | Prossimo | Base | Progresso |
| ------ | -------- | ---- | --------- |
| 0 | 3 | 0 | 0% |
| 2 | 3 | 0 | 67% |
| 3 | 6 | 3 | 0% |
| 5 | 6 | 3 | 67% |
| 15+ | — | — | 100% |
### Messaggi
`messaggioSerie()` valuta in quest'ordine, prima corrispondenza vince:
1. `valore === 0` → «Serie … azzerata: riparti dal prossimo.»
2. `prossimo === null` → «Serie leggendaria: sei fuori scala!»
3. `manca === 1` → «Manca solo una volta al prossimo traguardo!»
4. `valore >= 5` → «Che continuità: ancora N e sali di livello.»
5. altrimenti → «Bella partenza: N al prossimo traguardo.»
Nota: il caso 1 scatta anche per chi non ha **mai** iniziato, e dice "azzerata". Se dà
fastidio, va distinto lì — il calcolo non sa differenziare "mai partito" da "appena rotto".
### Aggregatori
- `serieGiocatore(g)` — tutte le serie nell'ordine di `serieDefs`.
- `serieMigliore(g)` — quella col valore più alto. `Array.sort` è stabile, quindi **a parità
vince la prima definita in `serieDefs`**: con tutto a zero esce sempre "Allenamenti".
---
## Interfaccia (`src/components/crapp/SerieCard.tsx`)
- **`SerieGriglia`** — montata in `src/routes/profilo.tsx`, sezione "Serie di presenze". Una
card per serie: icona (sfondo gradiente se `valore > 0`, grigio se a zero), label,
descrizione, fiamma col numero, barra `Barra` e riga di testo `"valore/prossimo · messaggio"`
(il prefisso `valore/prossimo` sparisce fuori scala).
- **`SerieHome`** — riepilogo compatto: la serie migliore in evidenza più i tre numeri in
griglia. Attualmente **non è montata in nessuna route**: è pronta ma non usata.
---
## Chi dipende dalle serie
Toccare la regola di calcolo muove anche questi, che non hanno logica propria:
| Dove | Cosa | Soglie |
| ------------------------------- | -------------------------------------------------------------- | --------------------------- |
| `badges.ts` `serie-allenamenti` | "Sempre in palestra", su `serieAllenamenti` | bronzo 3, argento 6, oro 10 |
| `badges.ts` `serie-conferme` | "Risposta lampo", su `serieConferme` | bronzo 3, argento 8, oro 15 |
| `badges.ts` `s-mai-forfait` | Badge segreto: `serieConferme >= 10` **e** `presenze >= 15` | — |
| `obiettivi.ts` `o11` | "Continuità di squadra": giocatori con `serieAllenamenti >= 3` | target 12 |
---
## Costo
`useRosa()` ricalcola quattro serie per ogni giocatore attivo a ogni invalidazione della
memo, e ogni serie scorre tutti gli eventi: **O(rosa × eventi)** per render memoizzato. Con
una rosa e un calendario di squadra sono numeri irrisori. Le dipendenze della memo includono
`eventi`, `mappaPresenze` e `tempi`: se in futuro qualcuna cambiasse identità a ogni render,
il costo diventerebbe per-render e andrebbe stabilizzata a monte.
---
## Come modificare
- **Cambiare i traguardi di una serie** → l'array `traguardi` in `serieDefs`. Devono restare
crescenti (un test lo verifica) e non serve altro: progresso e messaggi si adeguano.
- **Cambiare la regola di presenza** (per esempio non azzerare su `infortunato`) → il
predicato dentro `serieConsecutiva()`. Valutare se allineare anche
`contaPresenzeGiocatore()`, che oggi usa lo stesso criterio.
- **Non azzerare quando manca la risposta** → sempre in quel predicato: distinguere
`stato === undefined` e restituire la serie invariata invece di `false`. Richiede di
cambiare `serieSu()`, che oggi conosce solo "onorato sì/no".
- **Cambiare la finestra delle conferme** → la costante `ORE_24`.
- **Contare anche gli eventi extra-campo** (pizzate, `tipo: "evento"`) → il filtro in
`eventiContanoPresenze()`, che però è condiviso col conteggio presenze: meglio un filtro
dedicato passato a `serieSu()` che modificarlo lì.
- **Aggiungere una quarta serie** → una voce in `serieDefs` (label, descrizione, icona,
traguardi, `valore`), un campo nel tipo `Giocatore` (`crapp-data.ts`, più lo zero nel seed),
il calcolo in `presenze.ts` e il collegamento in `useRosa()`. La UI non va toccata: griglia
e home iterano su `serieDefs`.
- **Mostrare il riepilogo in home**`SerieHome` esiste già, basta montarla.
---
## Limiti noti
**Le conferme rapide valgono solo da `m9` in avanti.** `risposto_il` non è ricostruibile a
posteriori: le righe già esistenti al momento della migration hanno ereditato `aggiornato_il`,
che è l'ultima modifica e non la prima risposta. Sui dati precedenti la serie è quindi
un'approssimazione ottimistica.
**Un evento passato senza risposta azzera la serie**, come un'assenza dichiarata: chi non ha
mai risposto ha serie a 0.
**L'ordinamento usa solo `data`, non `ora`.** Due eventi lo stesso giorno vengono processati
nell'ordine in cui arrivano dalla query (`.order("data")`), quindi non deterministico fra
loro. Irrilevante finché un buco e una presenza nello stesso giorno danno lo stesso
risultato finale, ma va sistemato se un giorno serve l'ordine esatto.
**Il fuso è quello del client.** `oggi` nasce da `new Date().toISOString()`, cioè UTC: nelle
prime ore della giornata italiana un evento di oggi può risultare "non ancora passato".
---
## Evoluzioni possibili
- Istante di convocazione esplicito (invio notifica) da usare al posto di `creato_il` per le
conferme.
- Distinguere "serie mai iniziata" da "serie interrotta" nel microcopy.
- Verificare che i badge e l'obiettivo "Continuità di squadra" si sblocchino davvero sui dati
di stagione.
-14
View File
@@ -1,14 +0,0 @@
---
name: Efficienza Cloud
description: Regole per minimizzare query, traffico e invocazioni Cloud (piano 20 crediti/mese, 17 utenti)
type: feature
---
- Nessun polling (`refetchInterval`) verso il database; sincronizzazione locale via BroadcastChannel/storage dove possibile.
- QueryClient globale: staleTime 5 min, gcTime 30 min, refetchOnWindowFocus/Mount/Reconnect disattivati, retry 1.
- Dopo una mutazione aggiornare la cache con `setQueryData`, non `invalidateQueries` (evita riletture).
- Scout live: scrive solo l'utente che segna; gli altri leggono dati già salvati.
- Statistiche, badge e classifiche: "write once, read many" — calcolate e salvate una volta a fine partita, mai ricalcolate a ogni apertura pagina.
- Dati CSI: sincronizzazione periodica server-side salvata su tabella locale; l'app legge solo dal database interno.
- Push solo per eventi importanti: convocazioni, promemoria allenamento/partita, turno palloni, esito finale.
- Niente foto/video/chat o funzionalità pesanti.
- Schema target: team_id, eventi, presenze, azioni_scout, statistiche_aggregate, classifica_csi, notifiche, con indici sui campi di filtro/relazione.
-16
View File
@@ -1,16 +0,0 @@
---
name: Portabilità su Node.js + PostgreSQL
description: Vincolo di architettura — l'app deve girare su un normale server Node.js con PostgreSQL, senza servizi esclusivi Lovable Cloud
type: constraint
---
L'app deve restare completamente portabile: ogni funzionalità deve poter girare su un normale server Node.js con PostgreSQL.
Regole:
- Accesso ai dati solo tramite i moduli in `src/lib/*.ts`; i componenti non parlano mai direttamente col database.
- Vietato usare funzionalità proprietarie Lovable/Supabase non self-hostable (edge functions proprietarie, auth Lovable come unico login, storage proprietario). `src/integrations/lovable/*` resta opzionale e non importato.
- SQL standard PostgreSQL nelle migrazioni; niente estensioni esclusive del provider.
- Configurazione solo via variabili d'ambiente standard; niente valori hardcoded.
- Job pianificati sempre richiamabili con un semplice HTTP POST, così funzionano con qualsiasi scheduler.
- Web push implementato con Web Crypto (compatibile Node 18+), non con SDK proprietari.
Dettaglio e guida di migrazione: `docs/PORTABILITA.md`.
+4 -2
View File
@@ -1,2 +1,4 @@
- [Efficienza Cloud](mem://features/cloud-efficienza) — Regole anti-consumo: niente polling, cache lunga, aggregati precalcolati, sync CSI server-side
- [Portabilità](mem://features/portabilita) — L'app deve girare su Node.js + PostgreSQL standard, nessun servizio esclusivo Lovable Cloud
Le regole di progetto non vivono più qui: sono in `docs/`.
- Efficienza cloud → `docs/EFFICIENZA_CLOUD.md`
- Portabilità → `docs/PORTABILITA.md`
+5 -1
View File
@@ -9,7 +9,11 @@
"build:dev": "vite build --mode development",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write ."
"format": "prettier --write .",
"test": "bun test/run.ts",
"test:integration": "bun test/run.ts integration",
"test:e2e": "bun test/run.ts e2e",
"test:all": "bun test/run.ts all"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
+1 -1
View File
@@ -43,4 +43,4 @@ self.addEventListener("notificationclick", (event) => {
return self.clients.openWindow("/");
}),
);
});
});
+11 -3
View File
@@ -1,23 +1,31 @@
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { useAvatar } from "@/lib/avatar-store";
import { urlAvatar } from "@/lib/avatar-store";
export function Avatar({
id,
fallback,
className,
alt,
bust,
}: {
id: string;
fallback: string;
className?: string;
alt?: string;
/** Forza il ricaricamento ignorando la cache: usato dopo aver cambiato la propria foto. */
bust?: number;
}) {
const src = useAvatar(id);
if (src) {
const [errore, setErrore] = useState(false);
useEffect(() => setErrore(false), [id, bust]);
if (!errore) {
const src = bust ? `${urlAvatar(id)}?v=${bust}` : urlAvatar(id);
return (
<img
src={src}
alt={alt ?? `Foto di ${fallback}`}
onError={() => setErrore(true)}
className={cn("shrink-0 rounded-2xl object-cover", className)}
/>
);
+14 -10
View File
@@ -14,13 +14,7 @@ import {
DrawerTrigger,
} from "@/components/ui/drawer";
function DettaglioBadge({
def,
stato,
}: {
def: BadgeDef;
stato?: BadgeStato;
}) {
function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
const Icon = def.icon;
const grado = stato?.grado ?? null;
const meta = grado ? gradoMeta[grado] : null;
@@ -94,10 +88,20 @@ function DettaglioBadge({
raggiunto ? `${gm.bg} ${gm.ring}` : "bg-secondary ring-border",
)}
>
<p className={cn("text-[10px] font-bold uppercase", raggiunto ? gm.text : "text-muted-foreground")}>
<p
className={cn(
"text-[10px] font-bold uppercase",
raggiunto ? gm.text : "text-muted-foreground",
)}
>
{gm.label}
</p>
<p className={cn("font-display text-xl leading-none", raggiunto ? "text-foreground" : "text-muted-foreground")}>
<p
className={cn(
"font-display text-xl leading-none",
raggiunto ? "text-foreground" : "text-muted-foreground",
)}
>
{def.soglie[g]}
</p>
<p className="text-[10px] text-muted-foreground">{def.unita}</p>
@@ -130,7 +134,7 @@ export function BadgeDrawer({
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<DrawerContent>
<DrawerHeader className="sr-only">
<DrawerTitle>{def.nome}</DrawerTitle>
<DrawerDescription>{def.descrizione}</DrawerDescription>
+1 -1
View File
@@ -27,4 +27,4 @@ export function BottomNav() {
</div>
</nav>
);
}
}
+1 -1
View File
@@ -76,4 +76,4 @@ export function CelebrazioneBadge() {
</div>
</div>
);
}
}
+10 -3
View File
@@ -10,7 +10,12 @@ import {
prossimoTraguardo,
type BadgeStato,
} from "@/lib/badges";
import { badgeSocialVinti, categorieSocial, type VotoSocial, type CategoriaSocial } from "@/lib/badge-social";
import {
badgeSocialVinti,
categorieSocial,
type VotoSocial,
type CategoriaSocial,
} from "@/lib/badge-social";
import { Reveal } from "@/components/motion/Reveal";
import { Barra } from "@/components/motion/Barra";
import { Numero } from "@/components/motion/Numero";
@@ -91,7 +96,9 @@ function SocialDrawer({
</div>
</div>
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
<p className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">Vinto</p>
<p className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
Vinto
</p>
<p className="mt-1 font-display text-3xl leading-none">
{conteggio}{" "}
<span className="text-lg text-muted-foreground">
@@ -246,4 +253,4 @@ export function CollezioneBadge({
</div>
</div>
);
}
}
+36 -31
View File
@@ -1,9 +1,10 @@
import { MapPin, Clock, Users, Cake, ArrowRight } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { formatData, giocatori, statoMeta, type Stato } from "@/lib/crapp-data";
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { Barra } from "@/components/motion/Barra";
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreCorrente } from "@/lib/user-store";
@@ -36,16 +37,18 @@ export function EventoCard({
const { risposte } = usePresenzeEvento(evento.id);
const salva = useSalvaPresenza();
const io = useGiocatoreCorrente();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const stato = io ? risposte[io.id] : undefined;
const presentiVeri = giocatori.filter(
const presentiVeri = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
).length;
const tipo =
evento.tipo === "partita" && !evento.campionato
? { label: "Amichevole", className: "bg-accent/70 text-accent-foreground" }
: tipoMeta[evento.tipo];
const totale = giocatori.length;
const perc = Math.round((presentiVeri / totale) * 100);
const totale = rosa.length;
const perc = totale ? Math.round((presentiVeri / totale) * 100) : 0;
const isCompleanno = evento.tipo === "compleanno";
if (isCompleanno) {
@@ -118,33 +121,35 @@ export function EventoCard({
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-3" />
{io ? (
<div className="mt-4 flex flex-wrap gap-1.5">
{stati.map((s) => {
const meta = statoMeta[s];
const attivo = stato === s;
return (
<button
key={s}
type="button"
disabled={salva.isPending}
onClick={() =>
salva.mutate({
eventoId: evento.id,
giocatoreId: io.id,
stato: attivo ? null : s,
})
}
className={cn(
"rounded-full border border-border px-2.5 py-1.5 text-[11px] font-semibold transition-all active:scale-95",
attivo ? cn(meta.className, "border-transparent shadow-card") : "bg-background text-muted-foreground",
)}
>
{meta.label}
</button>
);
})}
</div>
<div className="mt-4 flex flex-wrap gap-1.5">
{stati.map((s) => {
const meta = statoMeta[s];
const attivo = stato === s;
return (
<button
key={s}
type="button"
disabled={salva.isPending}
onClick={() =>
salva.mutate({
eventoId: evento.id,
giocatoreId: io.id,
stato: attivo ? null : s,
})
}
className={cn(
"rounded-full border border-border px-2.5 py-1.5 text-[11px] font-semibold transition-all active:scale-95",
attivo
? cn(meta.className, "border-transparent shadow-card")
: "bg-background text-muted-foreground",
)}
>
{meta.label}
</button>
);
})}
</div>
) : null}
</article>
);
}
}
+6 -4
View File
@@ -3,7 +3,7 @@ import { ClipboardCheck, Lock } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/crapp/Avatar";
import { giocatori } from "@/lib/crapp-data";
import type { Giocatore } from "@/lib/crapp-data";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
@@ -12,11 +12,11 @@ const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
/** Pagelle di fine partita: voto anonimo 1-10 a ciascun compagno. */
export function Pagelle({
matchId,
convocati = giocatori,
convocati,
chiuse = false,
}: {
matchId: string;
convocati?: typeof giocatori;
convocati: Giocatore[];
chiuse?: boolean;
}) {
const io = useGiocatoreCorrente();
@@ -104,7 +104,9 @@ export function Pagelle({
onClick={() => invia(g.id, v)}
className={cn(
"rounded-lg py-1.5 text-[11px] font-bold tabular-nums transition-transform active:scale-90",
mio === v ? "bg-accent text-accent-foreground" : "bg-card text-foreground",
mio === v
? "bg-accent text-accent-foreground"
: "bg-card text-foreground",
)}
>
{v}
@@ -0,0 +1,417 @@
import { useRef, useState } from "react";
import { Link } from "@tanstack/react-router";
import { Check, Eye, Loader2, Upload } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { SezioneTendina } from "@/components/crapp/ui-bits";
import { Reveal } from "@/components/motion/Reveal";
import {
caricaFile,
scaricaFile,
useProfili,
useSalvaProfilo,
type SezioneFile,
} from "@/lib/profili";
import {
completamento,
profiloVuoto,
sezioniComplete,
type Profilo,
type Sezione,
} from "@/lib/profili-core";
const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"];
const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm";
function Campo({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="text-[10px] font-bold uppercase tracking-wide text-muted-foreground">
{label}
</span>
<span className="mt-1 block">{children}</span>
</label>
);
}
function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) {
return (
<div className="flex items-center gap-2 pt-2">
<h3 className="font-display text-sm uppercase tracking-wide">{titolo}</h3>
{completa ? <Check className="h-4 w-4 text-success" /> : null}
</div>
);
}
function CampoFile({
label,
path,
sezione,
giocatoreId,
onCaricato,
}: {
label: string;
path: string | null;
sezione: SezioneFile;
giocatoreId: string;
onCaricato: (path: string) => Promise<void>;
}) {
const input = useRef<HTMLInputElement>(null);
const [inCorso, setInCorso] = useState(false);
async function scegli(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setInCorso(true);
try {
const nuovo = await caricaFile(giocatoreId, sezione, file, path);
await onCaricato(nuovo);
toast.success(`${label} caricato`);
} catch (errore) {
toast.error(errore instanceof Error ? errore.message : "Caricamento non riuscito");
} finally {
setInCorso(false);
}
}
return (
<div className="flex items-center justify-between gap-3 py-2">
<span className="flex min-w-0 items-center gap-2 text-sm">
<span
className={cn(
"grid h-6 w-6 shrink-0 place-items-center rounded-lg",
path ? "bg-success text-success-foreground" : "bg-secondary text-muted-foreground",
)}
>
{path ? <Check className="h-3.5 w-3.5" /> : <Upload className="h-3.5 w-3.5" />}
</span>
<span className="truncate">{label}</span>
</span>
<span className="flex shrink-0 items-center gap-2">
{path ? (
<button
type="button"
onClick={() => void scaricaFile(path)}
className="premi rounded-xl bg-secondary p-2 text-muted-foreground"
aria-label={`Vedi ${label}`}
>
<Eye className="h-4 w-4" />
</button>
) : null}
<button
type="button"
onClick={() => input.current?.click()}
disabled={inCorso}
className="premi rounded-xl bg-primary px-3 py-2 text-xs font-bold text-primary-foreground disabled:opacity-60"
>
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : path ? "Sostituisci" : "Carica"}
</button>
<input
ref={input}
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
onChange={scegli}
className="hidden"
/>
</span>
</div>
);
}
/**
* I campi del profilo, condivisi tra il giocatore e la dashboard amministratore (DD-017).
* Gli upload arrivano come slot: l'admin non carica file al posto di altri, quindi da
* quelle righe semplicemente non compaiono.
*/
export function CampiProfilo({
corrente,
aggiorna,
sezioni,
fileDocumento,
fileCertificato,
fileFoto,
}: {
corrente: Profilo;
aggiorna: (patch: Partial<Profilo>) => void;
sezioni: Record<Sezione, boolean>;
fileDocumento?: React.ReactNode;
fileCertificato?: React.ReactNode;
fileFoto?: React.ReactNode;
}) {
return (
<>
<Intestazione titolo="Dati personali" completa={sezioni.dati} />
<div className="grid grid-cols-2 gap-3">
<Campo label="Data di nascita">
<input
type="date"
value={corrente.dataNascita ?? ""}
onChange={(e) => aggiorna({ dataNascita: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Luogo di nascita">
<input
value={corrente.luogoNascita ?? ""}
maxLength={80}
onChange={(e) => aggiorna({ luogoNascita: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<Campo label="Indirizzo di residenza">
<input
value={corrente.indirizzo ?? ""}
maxLength={120}
onChange={(e) => aggiorna({ indirizzo: e.target.value })}
className={classiInput}
/>
</Campo>
<div className="grid grid-cols-2 gap-3">
<Campo label="Telefono">
<input
type="tel"
value={corrente.telefono ?? ""}
maxLength={20}
onChange={(e) => aggiorna({ telefono: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Email">
<input
type="email"
value={corrente.email ?? ""}
maxLength={120}
onChange={(e) => aggiorna({ email: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<Intestazione titolo="Documento di identità" completa={sezioni.documento} />
<div className="grid grid-cols-2 gap-3">
<Campo label="Tipo">
<select
value={corrente.documentoTipo ?? ""}
onChange={(e) => aggiorna({ documentoTipo: e.target.value })}
className={classiInput}
>
<option value=""></option>
{TIPI_DOCUMENTO.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</Campo>
<Campo label="Numero">
<input
value={corrente.documentoNumero ?? ""}
maxLength={40}
onChange={(e) => aggiorna({ documentoNumero: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<Campo label="Rilasciato da">
<input
value={corrente.documentoRilasciatoDa ?? ""}
maxLength={80}
onChange={(e) => aggiorna({ documentoRilasciatoDa: e.target.value })}
className={classiInput}
/>
</Campo>
<div className="grid grid-cols-2 gap-3">
<Campo label="Data emissione">
<input
type="date"
value={corrente.documentoEmissione ?? ""}
onChange={(e) => aggiorna({ documentoEmissione: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Data scadenza">
<input
type="date"
value={corrente.documentoScadenza ?? ""}
onChange={(e) => aggiorna({ documentoScadenza: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
{fileDocumento}
<Intestazione titolo="Certificato medico" completa={sezioni.certificato} />
<Campo label="Data di scadenza">
<input
type="date"
value={corrente.certificatoScadenza ?? ""}
onChange={(e) => aggiorna({ certificatoScadenza: e.target.value })}
className={classiInput}
/>
</Campo>
{fileCertificato}
{fileFoto}
</>
);
}
/**
* Dati amministrativi del giocatore: quello che la dashboard amministratore poi legge.
* Ogni giocatore scrive solo la propria riga è la RLS a garantirlo, non questo componente.
*/
export function ProfiloAmministrativo({
giocatoreId,
indice = 0,
}: {
giocatoreId: string;
indice?: number;
}) {
const { profili } = useProfili();
const salva = useSalvaProfilo();
const [bozza, setBozza] = useState<Profilo | null>(null);
const salvato = profili[giocatoreId];
const corrente = bozza ?? salvato ?? profiloVuoto(giocatoreId);
const sporco = bozza !== null;
const perc = completamento(corrente);
const sezioni = sezioniComplete(corrente);
function aggiorna(patch: Partial<Profilo>) {
setBozza({ ...corrente, ...patch });
}
async function scrivi(profilo: Profilo) {
await salva.mutateAsync(profilo);
setBozza(null);
}
async function salvaBozza() {
try {
await scrivi(corrente);
toast.success("Profilo aggiornato");
} catch (errore) {
toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito");
}
}
// Un file caricato va persistito subito, insieme a quello che si stava scrivendo.
const caricato = (campo: keyof Profilo) => async (path: string) =>
scrivi({ ...corrente, [campo]: path });
return (
<SezioneTendina
titolo="Dati per il tesseramento"
indice={indice}
azione={<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>}
>
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
<div className="h-1.5 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-accent-grad transition-all"
style={{ width: `${perc}%` }}
/>
</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}
sezioni={sezioni}
fileDocumento={
<div className="divide-y divide-border">
<CampoFile
label="Foto fronte"
path={corrente.documentoFrontePath}
sezione="documento-fronte"
giocatoreId={giocatoreId}
onCaricato={caricato("documentoFrontePath")}
/>
<CampoFile
label="Foto retro"
path={corrente.documentoRetroPath}
sezione="documento-retro"
giocatoreId={giocatoreId}
onCaricato={caricato("documentoRetroPath")}
/>
</div>
}
fileCertificato={
<CampoFile
label="Certificato medico"
path={corrente.certificatoPath}
sezione="certificato"
giocatoreId={giocatoreId}
onCaricato={caricato("certificatoPath")}
/>
}
fileFoto={
<>
<Intestazione titolo="Foto tessera" completa={sezioni.foto} />
<CampoFile
label="Foto tessera"
path={corrente.fotoPath}
sezione="foto"
giocatoreId={giocatoreId}
onCaricato={caricato("fotoPath")}
/>
</>
}
/>
<button
type="button"
onClick={salvaBozza}
disabled={!sporco || salva.isPending}
className="premi 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 disabled:opacity-50"
>
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{sporco ? "Salva" : "Salvato"}
</button>
</div>
</SezioneTendina>
);
}
/**
* Widget di Home: sparisce da solo quando il profilo è completo
* (docs/modules/profilo-giocatore.md § Home).
*/
export function CompletaProfilo({
giocatoreId,
indice = 0,
}: {
giocatoreId: string;
indice?: number;
}) {
const { profili, isPending } = useProfili();
const perc = completamento(profili[giocatoreId]);
if (isPending || perc === 100) return null;
return (
<Reveal indice={indice} className="px-5 pt-4">
<Link to="/profilo" className="premi block rounded-3xl bg-card p-4 shadow-card">
<div className="flex items-center justify-between gap-3">
<span className="font-display text-sm uppercase tracking-wide">
Completa il tuo profilo
</span>
<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-accent-grad transition-all"
style={{ width: `${perc}%` }}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Documento, certificato medico e foto tessera servono per il tesseramento CSI.
</p>
</Link>
</Reveal>
);
}
+2 -7
View File
@@ -1,11 +1,6 @@
import { AlertCircle } from "lucide-react";
import { formatData } from "@/lib/crapp-data";
import {
eventiPalloni,
eventoPrecedente,
eventoSuccessivo,
oggiISO,
} from "@/lib/palloni-core";
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";
@@ -59,4 +54,4 @@ export function PromemoriaPalloni() {
))}
</div>
);
}
}
+14 -10
View File
@@ -4,9 +4,11 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/crapp/Avatar";
import { Barra } from "@/components/motion/Barra";
import { giocatori, isAdmin, statoMeta, type Stato } from "@/lib/crapp-data";
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 { useIsAdmin } from "@/lib/ruoli";
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
@@ -14,12 +16,14 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
const { risposte, isPending } = usePresenzeEvento(eventoId);
const salva = useSalvaPresenza();
const io = useGiocatoreCorrente();
const admin = useIsAdmin();
const rosa = useRosa();
const [sollecito, setSollecito] = useState(false);
const mancanti = giocatori.filter((g) => !risposte[g.id]);
const risposteN = giocatori.length - mancanti.length;
const perc = Math.round((risposteN / giocatori.length) * 100);
const daSollecitare = mancanti.length + giocatori.filter((g) => risposte[g.id] === "forse").length;
const mancanti = rosa.filter((g) => !risposte[g.id]);
const risposteN = rosa.length - mancanti.length;
const perc = rosa.length ? Math.round((risposteN / rosa.length) * 100) : 0;
const daSollecitare = mancanti.length + rosa.filter((g) => risposte[g.id] === "forse").length;
async function sollecita() {
setSollecito(true);
@@ -48,7 +52,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
<div className="rounded-3xl bg-card p-4 shadow-card">
<div className="flex items-baseline justify-between">
<p className="text-sm font-bold">
Hanno risposto {risposteN}/{giocatori.length}
Hanno risposto {risposteN}/{rosa.length}
</p>
<span className="font-display text-xl leading-none">{perc}%</span>
</div>
@@ -56,7 +60,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
<div className="mt-3 flex flex-wrap gap-1.5">
{ordine.map((s) => {
const n = giocatori.filter((g) => risposte[g.id] === s).length;
const n = rosa.filter((g) => risposte[g.id] === s).length;
return (
<span
key={s}
@@ -105,7 +109,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
</div>
) : null}
{io && isAdmin(io.id) ? (
{admin ? (
<button
type="button"
onClick={sollecita}
@@ -127,7 +131,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
) : null}
{ordine.map((s) => {
const lista = giocatori.filter((g) => risposte[g.id] === s);
const lista = rosa.filter((g) => risposte[g.id] === s);
if (lista.length === 0) return null;
return (
<Gruppo
@@ -159,7 +163,7 @@ function Gruppo({
}: {
titolo: string;
n: number;
lista: typeof giocatori;
lista: Giocatore[];
attenzione?: boolean;
}) {
return (
+17 -8
View File
@@ -3,27 +3,36 @@ 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 { isAdmin } from "@/lib/crapp-data";
import { useIsAdmin } from "@/lib/ruoli";
/** Accesso allo scout live: attivo solo il giorno della partita e se nessun altro lo sta usando. */
export function ScoutEntry({ variante = "grande" }: { variante?: "grande" | "compatto" }) {
const { pronto, partita } = usePartitaDiOggi();
const io = useGiocatoreCorrente();
const admin = useIsAdmin();
const { data: sessione } = useSessioneScout(partita?.id ?? null);
// Strumento tecnico: solo i referenti/allenatori scoutizzano la partita.
const abilitato = !!io && isAdmin(io.id);
const abilitato = admin;
const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null;
const occupato = !!attiva && attiva.giocatore_id !== io?.id;
const disponibile = abilitato && pronto && !!partita && !occupato;
const titolo = !abilitato ? "Scout live" : !partita ? "Scout live non attivo" : occupato ? "Scout occupato" : "Scout live";
const sottotitolo = !abilitato ? "Riservato ad allenatori e referenti" : !partita
? "Si attiva il giorno della partita"
: occupato
? `In uso da ${attiva!.giocatore_nome}`
: "Segna punti, ace e muri in tempo reale";
const titolo = !abilitato
? "Scout live"
: !partita
? "Scout live non attivo"
: occupato
? "Scout occupato"
: "Scout live";
const sottotitolo = !abilitato
? "Riservato ad allenatori e referenti"
: !partita
? "Si attiva il giorno della partita"
: occupato
? `In uso da ${attiva!.giocatore_nome}`
: "Segna punti, ace e muri in tempo reale";
const contenuto = (
<>
+7 -3
View File
@@ -19,7 +19,9 @@ export function SerieGriglia({ g }: { g: Giocatore }) {
<span
className={cn(
"grid h-10 w-10 shrink-0 place-items-center rounded-2xl",
attiva ? "bg-accent-grad text-accent-foreground" : "bg-secondary text-muted-foreground",
attiva
? "bg-accent-grad text-accent-foreground"
: "bg-secondary text-muted-foreground",
)}
>
<Icon className="h-5 w-5" />
@@ -29,7 +31,9 @@ export function SerieGriglia({ g }: { g: Giocatore }) {
<p className="text-[11px] text-muted-foreground">{s.def.descrizione}</p>
</div>
<span className="inline-flex items-center gap-1 font-display text-2xl leading-none">
<Flame className={cn("h-4 w-4", attiva ? "text-accent" : "text-muted-foreground/40")} />
<Flame
className={cn("h-4 w-4", attiva ? "text-accent" : "text-muted-foreground/40")}
/>
{s.valore}
</span>
</div>
@@ -74,4 +78,4 @@ export function SerieHome({ g }: { g: Giocatore }) {
</div>
</div>
);
}
}
+10 -3
View File
@@ -1,6 +1,6 @@
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { giocatori } from "@/lib/crapp-data";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { mediaPartita, useCacche, useSalvaCacche } from "@/lib/cacche";
@@ -11,6 +11,7 @@ export function SondaggioCacche({ eventoId }: { eventoId: string }) {
const io = useGiocatoreCorrente();
const { righe } = useCacche();
const salva = useSalvaCacche();
const { righe: squadra } = useGiocatoriSquadra();
const dellaPartita = righe.filter((r) => r.evento_id === eventoId);
const mia = dellaPartita.find((r) => r.giocatore_id === io?.id);
@@ -18,7 +19,10 @@ export function SondaggioCacche({ eventoId }: { eventoId: string }) {
const classifica = [...dellaPartita]
.sort((a, b) => b.quantita - a.quantita)
.slice(0, 3)
.map((r) => ({ ...r, nome: giocatori.find((g) => g.id === r.giocatore_id)?.nome ?? "—" }));
.map((r) => {
const g = squadra.find((g) => g.id === r.giocatore_id);
return { ...r, nome: g ? nomeCompleto(g) : "—" };
});
async function rispondi(quantita: number) {
if (!io) return;
@@ -68,7 +72,10 @@ export function SondaggioCacche({ eventoId }: { eventoId: string }) {
Media squadra {media}
</span>
{classifica.map((r, i) => (
<span key={r.giocatore_id} className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
<span
key={r.giocatore_id}
className="rounded-full bg-secondary px-2.5 py-1 font-semibold"
>
{i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉"} {r.nome} · {r.quantita}
</span>
))}
+8 -6
View File
@@ -3,7 +3,7 @@ import { Check, CircleDot, Loader2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/crapp/Avatar";
import { giocatori } from "@/lib/crapp-data";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
import { useGiocatoreCorrente } from "@/lib/user-store";
@@ -12,10 +12,12 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
const { salvati, turni, isPending } = useTurniPalloni();
const assegna = useAssegnaTurno();
const io = useGiocatoreCorrente();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const id = turni[eventoId];
const proposto = !salvati[eventoId];
const giocatore = giocatori.find((g) => g.id === id);
const giocatore = rosa.find((g) => g.id === id);
function scegli(giocatoreId: string) {
setAperto(false);
@@ -46,7 +48,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
{isPending || assegna.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : null}
<span className="truncate">{giocatore ? giocatore.nome : "Da assegnare"}</span>
<span className="truncate">{giocatore ? nomeCompleto(giocatore) : "Da assegnare"}</span>
{proposto && giocatore ? (
<span className="shrink-0 rounded-full bg-card px-1.5 py-0.5 text-[9px] font-bold uppercase text-muted-foreground">
proposto
@@ -59,7 +61,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
{aperto ? (
<div className="mt-2.5 max-h-56 space-y-1 overflow-y-auto rounded-xl bg-card p-1.5">
{giocatori.map((g) => (
{rosa.map((g) => (
<button
key={g.id}
type="button"
@@ -70,7 +72,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
)}
>
<Avatar id={g.id} fallback={String(g.numero)} className="h-7 w-7 text-xs" />
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
<span className="min-w-0 flex-1 truncate">{nomeCompleto(g)}</span>
{g.id === id ? <Check className="h-4 w-4 shrink-0 text-accent" /> : null}
</button>
))}
@@ -78,4 +80,4 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
) : null}
</div>
);
}
}
+7 -5
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { Crown, Vote } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { giocatori } from "@/lib/crapp-data";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { conteggioPartita, mioVoto, useVotaMvp, useVotiMvp, type VotoMvp } from "@/lib/mvp-voti";
@@ -11,6 +11,8 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
const io = useGiocatoreCorrente();
const voti = useVotiMvp();
const vota = useVotaMvp();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const [aperto, setAperto] = useState(false);
const tutti: VotoMvp[] = voti.data ?? [];
@@ -69,12 +71,12 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
{aperto ? (
<div className="mt-3 grid max-h-60 grid-cols-2 gap-1.5 overflow-y-auto">
{giocatori.map((g) => (
{rosa.map((g) => (
<button
key={g.id}
type="button"
disabled={vota.isPending}
onClick={() => invia(g.id, g.nome)}
onClick={() => invia(g.id, nomeCompleto(g))}
className={cn(
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
mio?.votato_id === g.id
@@ -82,7 +84,7 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
: "bg-card text-foreground",
)}
>
{g.nome}
{nomeCompleto(g)}
</button>
))}
</div>
@@ -97,4 +99,4 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
) : null}
</div>
);
}
}
+8 -7
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { Check, Crown, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { giocatori } from "@/lib/crapp-data";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import {
categorieSocial,
@@ -18,6 +18,8 @@ export function VotoSocial({ matchId }: { matchId: string }) {
const io = useGiocatoreCorrente();
const voti = useVotiSocial();
const vota = useVotaSocial();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const [aperta, setAperta] = useState<string | null>(null);
const tutti = voti.data ?? [];
@@ -93,14 +95,14 @@ export function VotoSocial({ matchId }: { matchId: string }) {
{isOpen ? (
<div className="grid max-h-56 grid-cols-2 gap-1.5 overflow-y-auto border-t border-border p-3">
{giocatori
{rosa
.filter((g) => g.id !== io?.id)
.map((g) => (
<button
key={g.id}
type="button"
disabled={vota.isPending}
onClick={() => invia(cat.id, g.id, g.nome)}
onClick={() => invia(cat.id, g.id, nomeCompleto(g))}
className={cn(
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
mio?.votato_id === g.id
@@ -108,7 +110,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
: "bg-secondary text-foreground",
)}
>
{g.nome}
{nomeCompleto(g)}
</button>
))}
</div>
@@ -122,8 +124,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
<p className="inline-flex items-center gap-1.5 text-xs font-bold">
<Crown className="h-3.5 w-3.5 text-oro" />
<Icon className="h-3.5 w-3.5 text-accent" />
{vincitore.nome} · {vincitore.voti}{" "}
{vincitore.voti === 1 ? "voto" : "voti"}
{vincitore.nome} · {vincitore.voti} {vincitore.voti === 1 ? "voto" : "voti"}
</p>
) : (
<p className="text-[11px] text-muted-foreground">
@@ -137,4 +138,4 @@ export function VotoSocial({ matchId }: { matchId: string }) {
})}
</div>
);
}
}
+46 -3
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { statoMeta, type Stato } from "@/lib/crapp-data";
import { Reveal } from "@/components/motion/Reveal";
@@ -52,6 +53,48 @@ export function Section({
);
}
/** Sezione con titolo cliccabile: il contenuto si apre e chiude. `anteprima` resta sempre visibile. */
export function SezioneTendina({
titolo,
children,
anteprima,
azione,
defaultAperta = false,
indice = 0,
}: {
titolo: string;
children: ReactNode;
anteprima?: ReactNode;
azione?: ReactNode;
defaultAperta?: boolean;
indice?: number;
}) {
const [aperta, setAperta] = useState(defaultAperta);
return (
<Reveal as="section" indice={indice} className="px-5 py-4">
<button
type="button"
onClick={() => setAperta((v) => !v)}
className="mb-3 flex w-full items-center justify-between gap-3 text-left active:scale-[0.99]"
aria-expanded={aperta}
>
<h2 className="font-display text-lg uppercase tracking-wide">{titolo}</h2>
<span className="flex shrink-0 items-center gap-2">
{azione}
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
aperta && "rotate-180",
)}
/>
</span>
</button>
{anteprima}
{aperta ? children : null}
</Reveal>
);
}
export function StatoBadge({ stato, className }: { stato: Stato; className?: string }) {
const meta = statoMeta[stato];
return (
@@ -62,7 +105,7 @@ export function StatoBadge({ stato, className }: { stato: Stato; className?: str
className,
)}
>
{meta.label}
{meta.label}
</span>
);
}
@@ -87,4 +130,4 @@ export function StatTile({
{hint ? <p className="mt-0.5 text-[11px] text-accent">{hint}</p> : null}
</div>
);
}
}
+1 -1
View File
@@ -22,4 +22,4 @@ export function Barra({
/>
</div>
);
}
}
+1 -1
View File
@@ -17,4 +17,4 @@ export function Numero({
{suffisso}
</span>
);
}
}
+1 -1
View File
@@ -26,4 +26,4 @@ export function Reveal({
{children}
</Tag>
);
}
}
+4 -1
View File
@@ -11,7 +11,10 @@ type SignInOptions = {
export const lovable = {
auth: {
signInWithOAuth: async (provider: "google" | "apple" | "microsoft" | "lovable", opts?: SignInOptions) => {
signInWithOAuth: async (
provider: "google" | "apple" | "microsoft" | "lovable",
opts?: SignInOptions,
) => {
const result = await lovableAuth.signInWithOAuth(provider, {
redirect_uri: opts?.redirect_uri ?? window.location.origin,
extraParams: {
+7 -7
View File
@@ -1,15 +1,15 @@
// This file is automatically generated. Do not edit it directly.
import { createMiddleware } from '@tanstack/react-start'
import { supabase } from './client'
import { createMiddleware } from "@tanstack/react-start";
import { supabase } from "./client";
// Must be registered as a global `functionMiddleware` in `src/start.ts`; otherwise
// the browser never attaches the bearer token to serverFn RPCs.
export const attachSupabaseAuth = createMiddleware({ type: 'function' }).client(
export const attachSupabaseAuth = createMiddleware({ type: "function" }).client(
async ({ next }) => {
const { data } = await supabase.auth.getSession()
const token = data.session?.access_token
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
return next({
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
});
},
)
);
+42 -46
View File
@@ -1,19 +1,17 @@
// This file is automatically generated. Do not edit it directly.
import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { createClient } from '@supabase/supabase-js'
import type { Database } from './types'
import { createMiddleware } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import { createClient } from "@supabase/supabase-js";
import type { Database } from "./types";
function isNewSupabaseApiKey(value: string): boolean {
return value.startsWith('sb_publishable_') || value.startsWith('sb_secret_');
return value.startsWith("sb_publishable_") || value.startsWith("sb_secret_");
}
function createSupabaseFetch(supabaseKey: string): typeof fetch {
return (input, init) => {
const headers = new Headers(
typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined,
typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined,
);
if (init?.headers) {
@@ -21,81 +19,79 @@ function createSupabaseFetch(supabaseKey: string): typeof fetch {
}
// New Supabase API keys are opaque strings, not bearer JWTs.
if (isNewSupabaseApiKey(supabaseKey) && headers.get('Authorization') === `Bearer ${supabaseKey}`) {
headers.delete('Authorization');
if (
isNewSupabaseApiKey(supabaseKey) &&
headers.get("Authorization") === `Bearer ${supabaseKey}`
) {
headers.delete("Authorization");
}
headers.set('apikey', supabaseKey);
headers.set("apikey", supabaseKey);
return fetch(input, { ...init, headers });
};
}
export const requireSupabaseAuth = createMiddleware({ type: 'function' }).server(
export const requireSupabaseAuth = createMiddleware({ type: "function" }).server(
async ({ next }) => {
const SUPABASE_URL = process.env['SUPABASE_URL'];
const SUPABASE_PUBLISHABLE_KEY = process.env['SUPABASE_PUBLISHABLE_KEY'];
const SUPABASE_URL = process.env["SUPABASE_URL"];
const SUPABASE_PUBLISHABLE_KEY = process.env["SUPABASE_PUBLISHABLE_KEY"];
if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
const missing = [
...(!SUPABASE_URL ? ['SUPABASE_URL'] : []),
...(!SUPABASE_PUBLISHABLE_KEY ? ['SUPABASE_PUBLISHABLE_KEY'] : []),
...(!SUPABASE_URL ? ["SUPABASE_URL"] : []),
...(!SUPABASE_PUBLISHABLE_KEY ? ["SUPABASE_PUBLISHABLE_KEY"] : []),
];
const message = `Missing Supabase environment variable(s): ${missing.join(', ')}. Connect Supabase in Lovable Cloud.`;
const message = `Missing Supabase environment variable(s): ${missing.join(", ")}. Connect Supabase in Lovable Cloud.`;
console.error(`[Supabase] ${message}`);
throw new Error(message);
}
const request = getRequest();
if (!request?.headers) {
throw new Error('Unauthorized: No request headers available');
throw new Error("Unauthorized: No request headers available");
}
const authHeader = request.headers.get('authorization');
const authHeader = request.headers.get("authorization");
if (!authHeader) {
throw new Error('Unauthorized: No authorization header provided');
throw new Error("Unauthorized: No authorization header provided");
}
if (!authHeader.startsWith('Bearer ')) {
throw new Error('Unauthorized: Only Bearer tokens are supported');
if (!authHeader.startsWith("Bearer ")) {
throw new Error("Unauthorized: Only Bearer tokens are supported");
}
const token = authHeader.replace('Bearer ', '');
const token = authHeader.replace("Bearer ", "");
if (!token) {
throw new Error('Unauthorized: No token provided');
throw new Error("Unauthorized: No token provided");
}
if (token.split('.').length !== 3) {
throw new Error('Unauthorized: Invalid token');
if (token.split(".").length !== 3) {
throw new Error("Unauthorized: Invalid token");
}
const supabase = createClient<Database>(
SUPABASE_URL!,
SUPABASE_PUBLISHABLE_KEY!,
{
global: {
fetch: createSupabaseFetch(SUPABASE_PUBLISHABLE_KEY!),
headers: {
Authorization: `Bearer ${token}`,
},
const supabase = createClient<Database>(SUPABASE_URL!, SUPABASE_PUBLISHABLE_KEY!, {
global: {
fetch: createSupabaseFetch(SUPABASE_PUBLISHABLE_KEY!),
headers: {
Authorization: `Bearer ${token}`,
},
auth: {
storage: undefined,
persistSession: false,
autoRefreshToken: false,
},
}
);
},
auth: {
storage: undefined,
persistSession: false,
autoRefreshToken: false,
},
});
const { data, error } = await supabase.auth.getClaims(token);
if (error || !data?.claims) {
throw new Error('Unauthorized: Invalid token');
throw new Error("Unauthorized: Invalid token");
}
if (!data.claims.sub) {
throw new Error('Unauthorized: No user ID found in token');
throw new Error("Unauthorized: No user ID found in token");
}
return next({
@@ -0,0 +1,12 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { supabase } from "./client";
/**
* `types.ts` è generato dallo schema e non include ancora le tabelle introdotte dalle
* migration M1 (`giocatori_squadra`) e M2 (`profili_giocatore`). Finché non viene
* rigenerato si passa da qui: i tipi delle righe sono dichiarati nei moduli di `src/lib/`,
* che restano l'unico punto di accesso al database (DD-013).
*
* Da eliminare quando `types.ts` sarà rigenerato: i moduli torneranno a usare `supabase`.
*/
export const supabaseNuoveTabelle = supabase as unknown as SupabaseClient;
+16 -13
View File
@@ -2,17 +2,17 @@
// Server-side Supabase client with service role key - bypasses RLS.
// Use this for admin operations in server functions and server routes only.
// For user-authenticated queries (with RLS), use the auth middleware instead.
import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';
import { createClient } from "@supabase/supabase-js";
import type { Database } from "./types";
function isNewSupabaseApiKey(value: string): boolean {
return value.startsWith('sb_publishable_') || value.startsWith('sb_secret_');
return value.startsWith("sb_publishable_") || value.startsWith("sb_secret_");
}
function createSupabaseFetch(supabaseKey: string): typeof fetch {
return (input, init) => {
const headers = new Headers(
typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined,
typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined,
);
if (init?.headers) {
@@ -20,25 +20,28 @@ function createSupabaseFetch(supabaseKey: string): typeof fetch {
}
// New Supabase API keys are opaque strings, not bearer JWTs.
if (isNewSupabaseApiKey(supabaseKey) && headers.get('Authorization') === `Bearer ${supabaseKey}`) {
headers.delete('Authorization');
if (
isNewSupabaseApiKey(supabaseKey) &&
headers.get("Authorization") === `Bearer ${supabaseKey}`
) {
headers.delete("Authorization");
}
headers.set('apikey', supabaseKey);
headers.set("apikey", supabaseKey);
return fetch(input, { ...init, headers });
};
}
function createSupabaseAdminClient() {
const SUPABASE_URL = process.env['SUPABASE_URL'];
const SUPABASE_SERVICE_ROLE_KEY = process.env['SUPABASE_SERVICE_ROLE_KEY'];
const SUPABASE_URL = process.env["SUPABASE_URL"];
const SUPABASE_SERVICE_ROLE_KEY = process.env["SUPABASE_SERVICE_ROLE_KEY"];
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
const missing = [
...(!SUPABASE_URL ? ['SUPABASE_URL'] : []),
...(!SUPABASE_SERVICE_ROLE_KEY ? ['SUPABASE_SERVICE_ROLE_KEY'] : []),
...(!SUPABASE_URL ? ["SUPABASE_URL"] : []),
...(!SUPABASE_SERVICE_ROLE_KEY ? ["SUPABASE_SERVICE_ROLE_KEY"] : []),
];
const message = `Missing Supabase environment variable(s): ${missing.join(', ')}. Connect Supabase in Lovable Cloud.`;
const message = `Missing Supabase environment variable(s): ${missing.join(", ")}. Connect Supabase in Lovable Cloud.`;
console.error(`[Supabase] ${message}`);
throw new Error(message);
}
@@ -51,7 +54,7 @@ function createSupabaseAdminClient() {
storage: undefined,
persistSession: false,
autoRefreshToken: false,
}
},
});
}
+18 -16
View File
@@ -1,15 +1,15 @@
// This file is automatically generated. Do not edit it directly.
import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';
import { createClient } from "@supabase/supabase-js";
import type { Database } from "./types";
function isNewSupabaseApiKey(value: string): boolean {
return value.startsWith('sb_publishable_') || value.startsWith('sb_secret_');
return value.startsWith("sb_publishable_") || value.startsWith("sb_secret_");
}
function createSupabaseFetch(supabaseKey: string): typeof fetch {
return (input, init) => {
const headers = new Headers(
typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined,
typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined,
);
if (init?.headers) {
@@ -17,28 +17,31 @@ function createSupabaseFetch(supabaseKey: string): typeof fetch {
}
// New Supabase API keys are opaque strings, not bearer JWTs.
if (isNewSupabaseApiKey(supabaseKey) && headers.get('Authorization') === `Bearer ${supabaseKey}`) {
headers.delete('Authorization');
if (
isNewSupabaseApiKey(supabaseKey) &&
headers.get("Authorization") === `Bearer ${supabaseKey}`
) {
headers.delete("Authorization");
}
headers.set('apikey', supabaseKey);
headers.set("apikey", supabaseKey);
return fetch(input, { ...init, headers });
};
}
function createSupabaseClient() {
// Use import.meta.env for client-side (Vite build-time replacement)
// Fall back to process.env for SSR (server-side rendering)
const SUPABASE_URL = import.meta.env['VITE_SUPABASE_URL'] || process.env['SUPABASE_URL'];
const SUPABASE_PUBLISHABLE_KEY = import.meta.env['VITE_SUPABASE_PUBLISHABLE_KEY'] || process.env['SUPABASE_PUBLISHABLE_KEY'];
const SUPABASE_URL = import.meta.env["VITE_SUPABASE_URL"] || process.env["SUPABASE_URL"];
const SUPABASE_PUBLISHABLE_KEY =
import.meta.env["VITE_SUPABASE_PUBLISHABLE_KEY"] || process.env["SUPABASE_PUBLISHABLE_KEY"];
if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
const missing = [
...(!SUPABASE_URL ? ['SUPABASE_URL'] : []),
...(!SUPABASE_PUBLISHABLE_KEY ? ['SUPABASE_PUBLISHABLE_KEY'] : []),
...(!SUPABASE_URL ? ["SUPABASE_URL"] : []),
...(!SUPABASE_PUBLISHABLE_KEY ? ["SUPABASE_PUBLISHABLE_KEY"] : []),
];
const message = `Missing Supabase environment variable(s): ${missing.join(', ')}. Connect Supabase in Lovable Cloud.`;
const message = `Missing Supabase environment variable(s): ${missing.join(", ")}. Connect Supabase in Lovable Cloud.`;
console.error(`[Supabase] ${message}`);
throw new Error(message);
}
@@ -48,10 +51,10 @@ function createSupabaseClient() {
fetch: createSupabaseFetch(SUPABASE_PUBLISHABLE_KEY),
},
auth: {
storage: typeof window !== 'undefined' ? localStorage : undefined,
storage: typeof window !== "undefined" ? localStorage : undefined,
persistSession: true,
autoRefreshToken: true,
}
},
});
}
@@ -65,4 +68,3 @@ export const supabase = new Proxy({} as ReturnType<typeof createSupabaseClient>,
return Reflect.get(_supabase, prop, receiver);
},
});
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import type { Session } from "@supabase/supabase-js";
import { supabase } from "@/integrations/supabase/client";
/**
* Autenticazione reale con Google (DD-011). Il login ha sostituito la selezione del
* giocatore: senza sessione non si entra, e i permessi di amministrazione arrivano solo
* da `user_roles` (vedi `ruoli.ts`).
*/
export function useSessione() {
const [sessione, setSessione] = useState<Session | null>(null);
const [pronta, setPronta] = useState(false);
useEffect(() => {
let attivo = true;
// Il client Supabase esplode alla costruzione se mancano le variabili d'ambiente:
// qui va assorbito, altrimenti la schermata di accesso non si disegna proprio e
// resta irraggiungibile anche la selezione del giocatore.
try {
supabase.auth
.getSession()
.then(({ data }) => {
if (!attivo) return;
setSessione(data.session);
setPronta(true);
})
.catch(() => attivo && setPronta(true));
const { data } = supabase.auth.onAuthStateChange((_evento, nuova) => setSessione(nuova));
return () => {
attivo = false;
data.subscription.unsubscribe();
};
} catch (errore) {
console.error("[auth] Supabase non disponibile", errore);
setPronta(true);
return () => {
attivo = false;
};
}
}, []);
return {
sessione,
pronta,
utenteId: sessione?.user.id ?? null,
emailUtente: sessione?.user.email ?? null,
};
}
export async function accediConGoogle(): Promise<void> {
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: { redirectTo: window.location.origin },
});
if (error) throw error;
}
export async function esci(): Promise<void> {
const { error } = await supabase.auth.signOut();
if (error) throw error;
}
+47 -46
View File
@@ -1,60 +1,41 @@
import { useSyncExternalStore } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
const KEY = "crapp-avatars-v1";
const listeners = new Set<() => void>();
let cache: Record<string, string> | undefined;
const BUCKET = "avatar-giocatori";
const NOME_FILE = "avatar.jpg";
function read(): Record<string, string> {
if (cache) return cache;
if (typeof window === "undefined") return {};
try {
cache = JSON.parse(window.localStorage.getItem(KEY) ?? "{}") as Record<string, string>;
} catch {
cache = {};
}
return cache;
function percorso(id: string) {
return `${id}/${NOME_FILE}`;
}
function write(next: Record<string, string>) {
cache = next;
try {
window.localStorage.setItem(KEY, JSON.stringify(next));
} catch {
/* quota o storage non disponibile */
}
listeners.forEach((l) => l());
/** URL pubblico e stabile: il bucket è pubblico, nessuna richiesta di rete. */
export function urlAvatar(id: string): string {
return supabase.storage.from(BUCKET).getPublicUrl(percorso(id)).data.publicUrl;
}
const EMPTY: Record<string, string> = {};
const chiaveEsiste = (id: string) => ["avatar-esiste", id] as const;
export function useAvatars(): Record<string, string> {
return useSyncExternalStore(
(cb) => {
listeners.add(cb);
return () => listeners.delete(cb);
/** Solo per il proprio profilo: sapere se mostrare "rimuovi immagine". */
export function useAvatarEsiste(id: string | undefined) {
return useQuery({
queryKey: chiaveEsiste(id ?? ""),
enabled: !!id,
staleTime: 60_000,
queryFn: async () => {
const { data, error } = await supabase.storage.from(BUCKET).list(id!, { search: NOME_FILE });
if (error) throw error;
return (data ?? []).some((f) => f.name === NOME_FILE);
},
() => read(),
() => EMPTY,
);
});
}
export function useAvatar(id: string | undefined): string | null {
const all = useAvatars();
return id ? (all[id] ?? null) : null;
export function useInvalidaAvatarEsiste() {
const qc = useQueryClient();
return (id: string) => qc.invalidateQueries({ queryKey: chiaveEsiste(id) });
}
export function rimuoviAvatar(id: string) {
const next = { ...read() };
delete next[id];
write(next);
}
export function salvaAvatar(id: string, dataUrl: string) {
write({ ...read(), [id]: dataUrl });
}
/** Ridimensiona e comprime l'immagine scelta per stare in localStorage. */
export function fileToAvatar(file: File, size = 256): Promise<string> {
/** Ridimensiona e comprime l'immagine scelta in un quadrato JPEG. */
function fileToBlob(file: File, size = 256): Promise<Blob> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("Lettura file fallita"));
@@ -79,10 +60,30 @@ export function fileToAvatar(file: File, size = 256): Promise<string> {
size,
size,
);
resolve(canvas.toDataURL("image/jpeg", 0.82));
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Conversione fallita"))),
"image/jpeg",
0.82,
);
};
img.src = reader.result as string;
};
reader.readAsDataURL(file);
});
}
/** Ridimensiona, comprime e carica la foto profilo: sovrascrive quella precedente. */
export async function caricaAvatar(id: string, file: File) {
const blob = await fileToBlob(file);
const { error } = await supabase.storage.from(BUCKET).upload(percorso(id), blob, {
contentType: "image/jpeg",
upsert: true,
cacheControl: "0",
});
if (error) throw error;
}
export async function rimuoviAvatar(id: string) {
const { error } = await supabase.storage.from(BUCKET).remove([percorso(id)]);
if (error) throw error;
}
+2 -3
View File
@@ -139,8 +139,7 @@ export function mioVotoSocial(
) {
return (
voti.find(
(v) =>
v.match_id === matchId && v.categoria === categoria && v.votante_id === votanteId,
(v) => v.match_id === matchId && v.categoria === categoria && v.votante_id === votanteId,
) ?? null
);
}
@@ -156,4 +155,4 @@ export function badgeSocialVinti(voti: VotoSocial[], giocatoreId: string) {
}
}
return out;
}
}
+14 -11
View File
@@ -19,10 +19,7 @@ export type Grado = "bronzo" | "argento" | "oro";
export const gradiOrdine: Grado[] = ["bronzo", "argento", "oro"];
export const gradoMeta: Record<
Grado,
{ label: string; text: string; bg: string; ring: string }
> = {
export const gradoMeta: Record<Grado, { label: string; text: string; bg: string; ring: string }> = {
bronzo: { label: "Bronzo", text: "text-bronzo", bg: "bg-bronzo/15", ring: "ring-bronzo/40" },
argento: { label: "Argento", text: "text-argento", bg: "bg-argento/20", ring: "ring-argento/50" },
oro: { label: "Oro", text: "text-oro", bg: "bg-oro/20", ring: "ring-oro/50" },
@@ -50,7 +47,8 @@ export const badgeDefs: BadgeDef[] = [
{
id: "mvp",
nome: "MVP",
descrizione: "Riconoscimento per il miglior giocatore della partita, scelto dai compagni a fine match.",
descrizione:
"Riconoscimento per il miglior giocatore della partita, scelto dai compagni a fine match.",
unita: "MVP",
icon: Trophy,
soglie: { bronzo: 1, argento: 3, oro: 5 },
@@ -59,7 +57,8 @@ export const badgeDefs: BadgeDef[] = [
{
id: "pagella",
nome: "Pagellone",
descrizione: "Media dei voti che i compagni ti danno a fine partita: conta come giochi, non quanti punti fai.",
descrizione:
"Media dei voti che i compagni ti danno a fine partita: conta come giochi, non quanti punti fai.",
unita: "di media voto",
icon: ClipboardCheck,
soglie: { bronzo: 6.5, argento: 7.5, oro: 8.5 },
@@ -68,7 +67,8 @@ export const badgeDefs: BadgeDef[] = [
{
id: "palloni",
nome: "Sherpa dei palloni",
descrizione: "Quante volte ti sei caricato la sacca dei palloni: lavoro oscuro, badge luminoso.",
descrizione:
"Quante volte ti sei caricato la sacca dei palloni: lavoro oscuro, badge luminoso.",
unita: "turni palloni",
icon: CircleDot,
soglie: { bronzo: 3, argento: 6, oro: 10 },
@@ -86,7 +86,8 @@ export const badgeDefs: BadgeDef[] = [
{
id: "serie-allenamenti",
nome: "Sempre in palestra",
descrizione: "Allenamenti consecutivi a cui sei stato presente: la costanza paga più del talento.",
descrizione:
"Allenamenti consecutivi a cui sei stato presente: la costanza paga più del talento.",
unita: "allenamenti di fila",
icon: Rocket,
soglie: { bronzo: 3, argento: 6, oro: 10 },
@@ -108,7 +109,8 @@ export const badgeSegreti: BadgeDef[] = [
{
id: "s-tiebreak",
nome: "Uomo tie-break",
descrizione: "Sbloccato da chi ha almeno 2 MVP e una media voto alta: nei momenti caldi ci sei sempre.",
descrizione:
"Sbloccato da chi ha almeno 2 MVP e una media voto alta: nei momenti caldi ci sei sempre.",
unita: "MVP con media alta",
icon: Ghost,
segreto: true,
@@ -119,7 +121,8 @@ export const badgeSegreti: BadgeDef[] = [
{
id: "s-mai-forfait",
nome: "Mai un forfait",
descrizione: "Sbloccato con 10 conferme rapide consecutive e 15 presenze: su di te la squadra può contare a occhi chiusi.",
descrizione:
"Sbloccato con 10 conferme rapide consecutive e 15 presenze: su di te la squadra può contare a occhi chiusi.",
unita: "requisito nascosto",
icon: Anchor,
segreto: true,
@@ -268,4 +271,4 @@ export function badgeSbloccati(g: Giocatore): BadgeStato[] {
export function descrizioneSoglie(def: BadgeDef) {
return `${def.soglie.bronzo}/${def.soglie.argento}/${def.soglie.oro} ${def.unita}`;
}
}
+7 -1
View File
@@ -63,7 +63,13 @@ function arrotonda(n: number) {
export function statisticheCacche(righe: RigaCacche[]): Record<string, StatCacche> {
const out: Record<string, StatCacche> = {};
for (const r of righe) {
const cur = out[r.giocatore_id] ?? { totale: 0, giornate: 0, media: 0, record: 0, giornateTop: 0 };
const cur = out[r.giocatore_id] ?? {
totale: 0,
giornate: 0,
media: 0,
record: 0,
giornateTop: 0,
};
cur.totale += r.quantita;
cur.giornate += 1;
cur.record = Math.max(cur.record, r.quantita);
+46 -84
View File
@@ -2,10 +2,18 @@ export type Stato = "presente" | "assente" | "forse" | "ritardo" | "infortunato"
export const statoMeta: Record<Stato, { label: string; emoji: string; className: string }> = {
presente: { label: "Presente", emoji: "✅", className: "bg-success text-success-foreground" },
assente: { label: "Assente", emoji: "❌", className: "bg-destructive text-destructive-foreground" },
assente: {
label: "Assente",
emoji: "❌",
className: "bg-destructive text-destructive-foreground",
},
forse: { label: "Forse", emoji: "🤔", className: "bg-warning text-warning-foreground" },
ritardo: { label: "In ritardo", emoji: "⏱️", className: "bg-info text-info-foreground" },
infortunato: { label: "Infortunato", emoji: "🩹", className: "bg-primary text-primary-foreground" },
infortunato: {
label: "Infortunato",
emoji: "🩹",
className: "bg-primary text-primary-foreground",
},
};
/**
@@ -74,74 +82,43 @@ function inizialiDa(nome: string) {
.toUpperCase();
}
type StatsDemo = Pick<Giocatore, "presenze" | "streak" | "mvp" | "mediaVoto">;
/** Statistiche demo: mix di badge bronzo / argento / oro già sbloccati. */
const statsDemo: Record<string, StatsDemo> = {
"Ivan Cacciari": { presenze: 31, streak: 8, mvp: 5, mediaVoto: 8.7 },
"Davide Grilli": { presenze: 27, streak: 5, mvp: 3, mediaVoto: 8.4 },
"Nicola Pezzoli": { presenze: 24, streak: 4, mvp: 2, mediaVoto: 8.2 },
"Laura Passabì": { presenze: 22, streak: 6, mvp: 3, mediaVoto: 8.1 },
"Francesca Tucci": { presenze: 19, streak: 3, mvp: 1, mediaVoto: 7.9 },
"Iacopo Ricci": { presenze: 21, streak: 2, mvp: 3, mediaVoto: 7.8 },
"Alessandra Brunacci": { presenze: 14, streak: 3, mvp: 1, mediaVoto: 7.5 },
"Mattias Bologna": { presenze: 12, streak: 2, mvp: 1, mediaVoto: 7.4 },
"Giada Valbonesi": { presenze: 13, streak: 4, mvp: 1, mediaVoto: 7.6 },
"Alessio Cocco": { presenze: 11, streak: 1, mvp: 0, mediaVoto: 7.2 },
"Mattia Catalano": { presenze: 9, streak: 2, mvp: 0, mediaVoto: 7.0 },
"Antonella Loverre": { presenze: 8, streak: 1, mvp: 0, mediaVoto: 6.9 },
"Carlo Di Castelnuovo": { presenze: 7, streak: 1, mvp: 0, mediaVoto: 6.8 },
"Camilla Esposito": { presenze: 6, streak: 2, mvp: 0, mediaVoto: 6.7 },
"Salvador Battistella": { presenze: 20, streak: 5, mvp: 1, mediaVoto: 7.7 },
"Silvia Chilese": { presenze: 16, streak: 3, mvp: 0, mediaVoto: 7.3 },
"Cristina Titone": { presenze: 15, streak: 2, mvp: 0, mediaVoto: 7.1 },
};
/** Serie demo derivate dalle statistiche: ogni tipo ha il suo contatore. */
function serieDa(s?: StatsDemo) {
const streak = s?.streak ?? 0;
const presenze = s?.presenze ?? 0;
return {
serieAllenamenti: streak,
seriePartite: Math.ceil(streak / 2),
serieConferme: presenze >= 20 ? 12 : presenze >= 14 ? 8 : presenze >= 8 ? 4 : 1,
};
export function dividiNome(completo: string): { nome: string; cognome: string } {
const spazio = completo.indexOf(" ");
if (spazio < 0) return { nome: completo, cognome: "" };
return { nome: completo.slice(0, spazio), cognome: completo.slice(spazio + 1) };
}
export const giocatori: Giocatore[] = rosaCSI.map((r, i) => ({
id: `g${i + 1}`,
nome: r.nome,
numero: r.numero ?? 0,
ruolo: r.ruolo,
nascita: r.nascita,
iniziali: inizialiDa(r.nome),
totaliEventi: 32,
infortuni: 0,
ritardi: 0,
palloni: 0,
cacche: 0,
cacchePartita: 0,
...(statsDemo[r.nome] ?? { presenze: 0, streak: 0, mvp: 0, mediaVoto: 0 }),
...serieDa(statsDemo[r.nome]),
}));
export const giocatori: Giocatore[] = rosaCSI
.map((r, i) => ({
id: `g${i + 1}`,
nome: r.nome,
numero: r.numero ?? 0,
ruolo: r.ruolo,
nascita: r.nascita,
iniziali: inizialiDa(r.nome),
presenze: 0,
totaliEventi: 0,
streak: 0,
serieAllenamenti: 0,
seriePartite: 0,
serieConferme: 0,
mvp: 0,
mediaVoto: 0,
infortuni: 0,
ritardi: 0,
palloni: 0,
cacche: 0,
cacchePartita: 0,
}))
.sort((a, b) => dividiNome(a.nome).cognome.localeCompare(dividiNome(b.nome).cognome, "it"));
export type Match = {
id: string;
data: string;
avversario: string;
casa: boolean;
setNostri: number;
setLoro: number;
parziali: Array<[number, number]>;
mvp: string;
};
export const storicoMatch: Match[] = [
{ id: "m1", data: "2026-07-25", avversario: "Pallavolo Sesto", casa: true, setNostri: 3, setLoro: 1, parziali: [[25, 19], [23, 25], [25, 21], [25, 18]], mvp: "Davide Grilli" },
{ id: "m2", data: "2026-07-18", avversario: "ASD Rondinella", casa: false, setNostri: 2, setLoro: 3, parziali: [[25, 22], [19, 25], [25, 23], [20, 25], [12, 15]], mvp: "Ivan Cacciari" },
{ id: "m3", data: "2026-07-11", avversario: "Virtus Cinisello", casa: true, setNostri: 3, setLoro: 0, parziali: [[25, 15], [25, 20], [25, 17]], mvp: "Laura Passabì" },
{ id: "m4", data: "2026-07-04", avversario: "Nuova Bovisa", casa: false, setNostri: 3, setLoro: 2, parziali: [[21, 25], [25, 23], [18, 25], [25, 20], [15, 11]], mvp: "Nicola Pezzoli" },
];
/**
* Fallback per la data di nascita: `giocatori_squadra` non ha ancora questa colonna
* (DD-015 follow-up). Un giocatore aggiunto dopo la migrazione non ha nascita nota.
*/
export const nascitaPerId: Record<string, string> = Object.fromEntries(
giocatori.map((g) => [g.id, g.nascita]),
);
export type RigaClassifica = {
pos: number;
@@ -154,25 +131,10 @@ export type RigaClassifica = {
punti: number;
};
export const classifica: RigaClassifica[] = [
{ pos: 1, squadra: "ASD Rondinella", giocate: 12, vinte: 10, perse: 2, setFatti: 33, setSubiti: 12, punti: 29 },
{ pos: 2, squadra: "CRAP Volley", giocate: 12, vinte: 9, perse: 3, setFatti: 31, setSubiti: 16, punti: 26 },
{ pos: 3, squadra: "Pallavolo Sesto", giocate: 12, vinte: 8, perse: 4, setFatti: 29, setSubiti: 19, punti: 24 },
{ pos: 4, squadra: "Volley Bruzzano", giocate: 12, vinte: 7, perse: 5, setFatti: 26, setSubiti: 21, punti: 21 },
{ pos: 5, squadra: "Aurora Nera", giocate: 12, vinte: 5, perse: 7, setFatti: 22, setSubiti: 25, punti: 16 },
{ pos: 6, squadra: "Virtus Cinisello", giocate: 12, vinte: 3, perse: 9, setFatti: 15, setSubiti: 30, punti: 10 },
{ pos: 7, squadra: "Nuova Bovisa", giocate: 12, vinte: 2, perse: 10, setFatti: 13, setSubiti: 32, punti: 7 },
];
export function formatData(iso: string) {
const d = new Date(iso + "T00:00:00");
return d.toLocaleDateString("it-IT", { weekday: "short", day: "2-digit", month: "long" });
}
/** Referenti che possono gestire eventi e sollecitare le risposte. */
export const adminNomi = ["Ivan Cacciari", "Iacopo Ricci", "Cristina Titone"];
export function isAdmin(giocatoreId: string) {
const g = giocatori.find((x) => x.id === giocatoreId);
return Boolean(g && adminNomi.includes(g.nome));
}
/* I permessi di amministrazione stanno in `user_roles` (DD-011), non in una lista di nomi:
vedi `src/lib/ruoli.ts`. */
+176
View File
@@ -0,0 +1,176 @@
import type { RigaClassifica } from "./crapp-data";
/**
* Lettura dei dati ufficiali dal portale Livescore CSI Bologna.
* Non esiste un'API documentata: usiamo gli stessi endpoint che il sito chiama
* via ajax. Nessuna autenticazione, ma nessuna garanzia di stabilità: ogni
* funzione qui deve fallire in modo pulito (array vuoto), mai lanciare.
*/
export const CSI_BASE = "https://livescore.csibologna.it";
/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */
export const CSI_PROJECT_ID = 767;
/** C.R.A.P. Volley sul portale CSI. */
export const CSI_TEAM_ID = 3359;
export const CSI_GIRONE = "Girone B";
export const CSI_NOME_SQUADRA = "C.R.A.P. Volley";
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}`;
export type PartitaCsi = {
id: string;
data: string;
ora: string;
avversario: string;
casa: boolean;
/** null finché la gara non è stata giocata. */
setNostri: number | null;
setLoro: number | null;
parziali: Array<[number, number]>;
campo: string;
competizione: string;
};
export type DatiCsi = {
classifica: RigaClassifica[];
partite: PartitaCsi[];
girone: string;
aggiornato: string;
};
/** "C.R.A.P. Volley" e "CRAP Volley" devono confrontarsi uguali. */
function normalizza(nome: string): string {
return nome.toLowerCase().replace(/[^a-z0-9]/g, "");
}
export function isNostraSquadra(nome: string): boolean {
return normalizza(nome) === normalizza(CSI_NOME_SQUADRA);
}
const entita: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
nbsp: " ",
deg: "°",
apos: "'",
};
function testo(html: string): string {
return html
.replace(/<[^>]*>/g, "")
.replace(/&(#\d+|[a-z]+);/gi, (intero, codice: string) =>
codice.startsWith("#")
? String.fromCharCode(Number(codice.slice(1)))
: (entita[codice.toLowerCase()] ?? intero),
)
.replace(/\s+/g, " ")
.trim();
}
/**
* Estrae la classifica dal frammento HTML di `project-sheets.php`.
* Il campionato ha due gironi: prendiamo la tabella che contiene la nostra
* squadra. Colonne (indice del `<td>`): 0 Pos · 1 Squadra · 2 Punti ·
* 3 Giocate · 4 Vinte · 5 Perse · 8 Set fatti · 9 Set subiti.
*/
export function parseClassifica(html: string): RigaClassifica[] {
const tabelle = html.match(/<table[\s\S]*?<\/table>/gi) ?? [];
const nostra = tabelle.find((t) => normalizza(testo(t)).includes(normalizza(CSI_NOME_SQUADRA)));
if (!nostra) return [];
const righe: RigaClassifica[] = [];
for (const riga of nostra.match(/<tr[\s\S]*?<\/tr>/gi) ?? []) {
const celle = (riga.match(/<td[\s\S]*?<\/td>/gi) ?? []).map(testo);
if (celle.length < 10) continue;
const pos = Number(celle[0]);
if (!Number.isFinite(pos) || pos === 0 || !celle[1]) continue;
righe.push({
pos,
squadra: celle[1],
punti: Number(celle[2]) || 0,
giocate: Number(celle[3]) || 0,
vinte: Number(celle[4]) || 0,
perse: Number(celle[5]) || 0,
setFatti: Number(celle[8]) || 0,
setSubiti: Number(celle[9]) || 0,
});
}
return righe;
}
type EventoCsi = {
id?: number | string;
start?: string;
team1?: string;
team2?: string;
result?: string;
partials?: string;
field?: string;
project?: string;
};
function punteggio(result: string | undefined): [number, number] | null {
const m = /(\d+)\s*-\s*(\d+)/.exec(result ?? "");
return m ? [Number(m[1]), Number(m[2])] : null;
}
function parziali(partials: string | undefined): Array<[number, number]> {
return [...(partials ?? "").matchAll(/(\d+)\s*-\s*(\d+)/g)].map((m) => [
Number(m[1]),
Number(m[2]),
]);
}
/** Converte gli eventi di `getEventsByTeamId.php` nel formato usato dall'app. */
export function partiteDaEventi(eventi: unknown): PartitaCsi[] {
if (!Array.isArray(eventi)) return [];
const partite: PartitaCsi[] = [];
for (const evento of eventi as EventoCsi[]) {
const team1 = testo(evento.team1 ?? "");
const team2 = testo(evento.team2 ?? "");
const casa = isNostraSquadra(team1);
if (!casa && !isNostraSquadra(team2)) continue;
const [dataIso, oraIso] = (evento.start ?? "").split("T");
if (!dataIso) continue;
const set = punteggio(evento.result);
const tutti = parziali(evento.partials);
partite.push({
id: String(evento.id ?? `${dataIso}-${team1}-${team2}`),
data: dataIso,
ora: (oraIso ?? "").slice(0, 5),
avversario: casa ? team2 : team1,
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 ?? ""),
});
}
return partite.sort((a, b) => b.data.localeCompare(a.data));
}
/** Solo le gare già giocate, dalla più recente. */
export function partiteGiocate(partite: PartitaCsi[]): PartitaCsi[] {
return partite.filter((p) => p.setNostri !== null && p.setLoro !== null);
}
/** 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,
casa: p.casa,
setNostri: p.setNostri ?? 0,
setLoro: p.setLoro ?? 0,
parziali: p.parziali,
};
}
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import type { DatiCsi } from "./csi-core";
export const CSI_KEY = ["csi"] as const;
/**
* Classifica e risultati ufficiali CSI. Il server tiene una cache di 6 ore:
* qui basta una lettura per sessione.
*/
export function useCsi() {
return useQuery<DatiCsi>({
queryKey: CSI_KEY,
queryFn: async () => {
const res = await fetch("/api/public/csi");
if (!res.ok) throw new Error("CSI non disponibile");
return res.json();
},
staleTime: 6 * 60 * 60_000,
retry: 1,
});
}
+13 -7
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { giocatori } from "./crapp-data";
import type { Giocatore } from "./crapp-data";
export type EventoTipo = "partita" | "allenamento" | "evento" | "compleanno";
@@ -20,6 +20,9 @@ export type Evento = {
casa: boolean;
/** Le pagelle di questa partita non accettano più voti. */
pagelleChiuse: boolean;
/** Quando l'evento è stato creato: è l'istante della convocazione.
* Assente sugli eventi generati dal client (compleanni, bozze non salvate). */
creatoIl?: string | undefined;
};
export type RigaEvento = {
@@ -34,6 +37,7 @@ export type RigaEvento = {
campionato: boolean;
casa: boolean | null;
pagelle_chiuse: boolean;
creato_il?: string;
};
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
@@ -50,11 +54,12 @@ export function daRiga(r: RigaEvento): Evento {
campionato: !!r.campionato,
casa: r.casa ?? true,
pagelleChiuse: !!r.pagelle_chiuse,
creatoIl: r.creato_il,
};
}
const COLONNE =
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
@@ -158,8 +163,9 @@ export function useEliminaEvento() {
}
/** Compleanni della rosa, come eventi di calendario dell'anno indicato. */
export function compleanniEventi(anno = new Date().getFullYear()): Evento[] {
return giocatori
export function compleanniEventi(rosa: Giocatore[], anno = new Date().getFullYear()): Evento[] {
return rosa
.filter((g) => g.nascita)
.map((g) => {
const md = g.nascita.slice(5);
const eta = anno - Number(g.nascita.slice(0, 4));
@@ -181,7 +187,7 @@ export function compleanniEventi(anno = new Date().getFullYear()): Evento[] {
}
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
export function convocatiEvento(evento: Evento | null) {
if (!evento || evento.convocati.length === 0) return giocatori;
return giocatori.filter((g) => evento.convocati.includes(g.id));
export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) {
if (!evento || evento.convocati.length === 0) return rosa;
return rosa.filter((g) => evento.convocati.includes(g.id));
}
+20
View File
@@ -0,0 +1,20 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import {
COLONNE_SQUADRA,
daRigaSquadra,
type GiocatoreSquadra,
type RigaGiocatoreSquadra,
} from "./giocatori-squadra";
/** Lettura squadra lato server (route API): stessa conversione del client. */
export async function leggiGiocatoriSquadra(): Promise<GiocatoreSquadra[]> {
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
// `types.ts` non include ancora `giocatori_squadra` con le colonne di M8 (vedi client-nuove-tabelle.ts).
const client = supabaseAdmin as unknown as SupabaseClient;
const { data } = await client
.from("giocatori_squadra")
.select(COLONNE_SQUADRA)
.order("cognome")
.order("nome");
return ((data ?? []) as RigaGiocatoreSquadra[]).map(daRigaSquadra);
}
+310
View File
@@ -0,0 +1,310 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
import { dividiNome, giocatori } from "./crapp-data";
/**
* Anagrafica operativa della squadra (`giocatori_squadra`, migration M1). È la source of
* truth per il collegamento account giocatore; `crapp-data.ts` resta il fallback finché
* la migrazione non è completa (DD-016 regola 1).
*/
export type GiocatoreSquadra = {
id: string;
nome: string;
cognome: string;
numero: number;
ruolo: string;
authUserId: string | null;
attivo: boolean;
email: string | null;
numeroTessera: string | null;
dataTessera: string | null;
};
export type RigaGiocatoreSquadra = {
id: string;
nome: string;
cognome: string;
numero: number;
ruolo: string;
auth_user_id: string | null;
attivo: boolean;
email: string | null;
numero_tessera: string | null;
data_tessera: string | null;
};
/** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */
export const RUOLI = ["Palleggiatore", "Banda", "Opposto", "Centrale", "Libero", "Jolly"] as const;
export const SQUADRA_KEY = ["giocatori-squadra"] as const;
/** Rosa di riserva quando il database non risponde o non è ancora popolato. */
export function rosaFallback(): GiocatoreSquadra[] {
return giocatori.map((g) => ({
...dividiNome(g.nome),
id: g.id,
numero: g.numero,
ruolo: g.ruolo,
authUserId: null,
attivo: true,
email: null,
numeroTessera: null,
dataTessera: null,
}));
}
export function nomeCompleto(g: GiocatoreSquadra): string {
return `${g.nome} ${g.cognome}`.trim();
}
/** Lo slot già collegato a questo account, se esiste. */
export function slotDi(
righe: GiocatoreSquadra[],
utenteId: string | null,
): GiocatoreSquadra | null {
if (!utenteId) return null;
return righe.find((g) => g.authUserId === utenteId) ?? null;
}
/** Lo slot libero la cui email coincide con quella dell'account Google (case-insensitive). */
export function slotPerEmail(
righe: GiocatoreSquadra[],
email: string | null,
): GiocatoreSquadra | null {
if (!email) return null;
const cercata = email.trim().toLowerCase();
return righe.find((g) => !g.authUserId && g.email?.trim().toLowerCase() === cercata) ?? null;
}
export const COLONNE_SQUADRA =
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera";
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
return {
id: r.id,
nome: r.nome,
cognome: r.cognome,
numero: r.numero,
ruolo: r.ruolo,
authUserId: r.auth_user_id,
attivo: r.attivo,
email: r.email,
numeroTessera: r.numero_tessera,
dataTessera: r.data_tessera,
};
}
async function fetchSquadra(): Promise<GiocatoreSquadra[]> {
const { data, error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.select(COLONNE_SQUADRA)
.order("cognome")
.order("nome");
if (error) throw error;
const righe = (data ?? []) as RigaGiocatoreSquadra[];
return righe.map(daRigaSquadra);
}
/** Anagrafica squadra: una lettura per sessione, cambia raramente. */
export function useGiocatoriSquadra() {
const query = useQuery({ queryKey: SQUADRA_KEY, queryFn: fetchSquadra, staleTime: 30 * 60_000 });
const righe = query.data?.length ? query.data : rosaFallback();
return { ...query, righe, daDatabase: !!query.data?.length };
}
/** Dati squadra: li gestisce solo un amministratore (DD-017). L'email è quella usata per
* il collegamento automatico al primo accesso (DD-018), non il dato personale del profilo. */
export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo" | "email">;
/**
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
* meglio dirlo qui che far tornare un errore Postgres all'utente.
* Restituisce il messaggio da mostrare, oppure `null` se va bene.
*/
export function validaDatiSquadra(dati: DatiSquadra): string | null {
if (!dati.nome.trim()) return "Il nome non può essere vuoto.";
if (!dati.cognome.trim()) return "Il cognome non può essere vuoto.";
if (!Number.isInteger(dati.numero) || dati.numero <= 0)
return "Il numero di maglia deve essere maggiore di zero.";
if (!dati.ruolo.trim()) return "Il ruolo non può essere vuoto.";
if (dati.email?.trim() && !dati.email.includes("@")) return "L'email non è valida.";
return null;
}
/** Il prossimo id libero nel formato `g<N>` richiesto dal vincolo della tabella. */
export function prossimoIdGiocatore(righe: GiocatoreSquadra[]): string {
const max = righe.reduce((acc, g) => {
const n = Number(g.id.slice(1));
return Number.isFinite(n) && n > acc ? n : acc;
}, 0);
return `g${max + 1}`;
}
/** Numeri di maglia doppi: il database li accetta, la squadra no. */
export function numeroGiaUsato(
righe: GiocatoreSquadra[],
giocatoreId: string,
numero: number,
): boolean {
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
}
/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
export function useSalvaDatiSquadra() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => {
const dati = {
nome: input.dati.nome.trim(),
cognome: input.dati.cognome.trim(),
numero: input.dati.numero,
ruolo: input.dati.ruolo.trim(),
email: input.dati.email?.trim() || null,
};
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update(dati)
.eq("id", input.giocatoreId);
if (error) throw error;
return { giocatoreId: input.giocatoreId, dati };
},
onSuccess: (input) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
);
},
});
}
/** Numero e data della tessera CSI, note solo dopo il tesseramento effettivo. */
export type DatiTesseramento = Pick<GiocatoreSquadra, "numeroTessera" | "dataTessera">;
/**
* Registra numero e data della tessera CSI (roadmap v1.1). Campo puramente amministrativo:
* il trigger di M8 lo rende scrivibile solo da un admin, il giocatore non può autodichiararsi
* tesserato.
*/
export function useSalvaTesseramento() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { giocatoreId: string; dati: DatiTesseramento }) => {
const dati = {
numero_tessera: input.dati.numeroTessera?.trim() || null,
data_tessera: input.dati.dataTessera || null,
};
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update(dati)
.eq("id", input.giocatoreId);
if (error) throw error;
return {
giocatoreId: input.giocatoreId,
dati: { numeroTessera: dati.numero_tessera, dataTessera: dati.data_tessera },
};
},
onSuccess: (input) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
);
},
});
}
/**
* Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le policy di M1.
* L'id (`g<N>`) non è generato dal database: va calcolato con `prossimoIdGiocatore`
* prima di chiamare questa mutazione.
*/
export function useAggiungiGiocatore() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { id: string; dati: DatiSquadra }) => {
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert({
id: input.id,
nome: input.dati.nome.trim(),
cognome: input.dati.cognome.trim(),
numero: input.dati.numero,
ruolo: input.dati.ruolo.trim(),
email: input.dati.email?.trim() || null,
});
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SQUADRA_KEY });
},
});
}
/**
* Attiva o disattiva un giocatore (es. ha lasciato la squadra): non elimina la riga, così
* presenze, voti, pagelle e badge della stagione restano agganciati al suo id.
*/
export function useImpostaAttivo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { giocatoreId: string; attivo: boolean }) => {
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update({ attivo: input.attivo })
.eq("id", input.giocatoreId);
if (error) throw error;
return input;
},
onSuccess: (input) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, attivo: input.attivo } : g)),
);
},
});
}
/**
* Libera uno slot occupato per errore (DD-016 regola 2, DD-017). Il giocatore
* potrà ricollegarsi al primo accesso; i dati del profilo restano dove sono.
*/
export function useScollegaAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (giocatoreId: string) => {
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update({ auth_user_id: null })
.eq("id", giocatoreId);
if (error) throw error;
return giocatoreId;
},
onSuccess: (giocatoreId) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
(prec ?? []).map((g) => (g.id === giocatoreId ? { ...g, authUserId: null } : g)),
);
},
});
}
/**
* Collega l'account al giocatore scelto. Il trigger di M1 accetta l'operazione solo se
* lo slot è libero e se nessun altro campo cambia (DD-016 regola 2): il vincolo vive nel
* database, non qui.
*/
export function useCollegaGiocatore() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { giocatoreId: string; utenteId: string }) => {
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update({ auth_user_id: input.utenteId })
.eq("id", input.giocatoreId)
.is("auth_user_id", null);
if (error) throw error;
return input;
},
onSuccess: (input) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
(prec ?? []).map((g) =>
g.id === input.giocatoreId ? { ...g, authUserId: input.utenteId } : g,
),
);
},
});
}
+1 -1
View File
@@ -57,4 +57,4 @@ export function useInfortuniERitardi(): { infortuni: ContoInfortuni; ritardi: Co
() => ({ infortuni: contaInfortuni(presenze), ritardi: contaRitardi(presenze) }),
[presenze],
);
}
}
+1 -1
View File
@@ -90,4 +90,4 @@ export async function coriandoli(ridotto = false) {
origin: { y: 0.7 },
disableForReducedMotion: true,
});
}
}
+15 -1
View File
@@ -83,4 +83,18 @@ export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> {
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. */
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 id = top[0]!.id;
out[id] = (out[id] ?? 0) + 1;
}
}
return out;
}
+3 -12
View File
@@ -1,16 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import type { Giocatore } from "./crapp-data";
import {
microcopyObiettivo,
progressoObiettivo,
type ObiettivoSquadra,
} from "./obiettivi";
import {
badgeGiocatore,
badgeSegretiSbloccati,
gradoMeta,
prossimoTraguardo,
} from "./badges";
import { microcopyObiettivo, progressoObiettivo, type ObiettivoSquadra } from "./obiettivi";
import { badgeGiocatore, badgeSegretiSbloccati, gradoMeta, prossimoTraguardo } from "./badges";
import { serieGiocatore } from "./serie";
import { badgeSocialVinti, categorieSocial, type VotoSocial } from "./badge-social";
@@ -166,4 +157,4 @@ export function useNotificheSmart(g: Giocatore | null, votiSocial: VotoSocial[]
const chiudi = useCallback(() => setCoda((c) => c.slice(1)), []);
return { notifica: coda[0] ?? null, restanti: Math.max(0, coda.length - 1), chiudi };
}
}
+12 -11
View File
@@ -1,4 +1,4 @@
import { giocatori, storicoMatch, type Giocatore } from "./crapp-data";
import { giocatori, type Giocatore } from "./crapp-data";
import type { Evento } from "./eventi";
import type { MappaPresenze } from "./presenze";
import { mediaSquadra, type VotoPagella } from "./pagelle";
@@ -20,18 +20,20 @@ export type ContestoObiettivi = {
eventi: Evento[];
presenze: MappaPresenze;
pagelle: VotoPagella[];
/** Vittorie ufficiali in campionato (dato CSI). */
vittorie?: number;
};
export const contestoVuoto: ContestoObiettivi = { eventi: [], presenze: {}, pagelle: [] };
const MESE = "2026-08";
function percentualePresenzeMese(ctx: ContestoObiettivi) {
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number) {
const delMese = ctx.eventi.filter(
(e) => e.data.startsWith(MESE) && (e.tipo === "partita" || e.tipo === "allenamento"),
);
if (delMese.length === 0) return 0;
const posti = delMese.length * giocatori.length;
if (delMese.length === 0 || rosaSize === 0) return 0;
const posti = delMese.length * rosaSize;
const presenti = delMese.reduce((s, e) => {
const risposte = ctx.presenze[e.id] ?? {};
return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length;
@@ -39,10 +41,10 @@ function percentualePresenzeMese(ctx: ContestoObiettivi) {
return Math.round((presenti / posti) * 100);
}
function percentualeRisposte(ctx: ContestoObiettivi) {
function percentualeRisposte(ctx: ContestoObiettivi, rosaSize: number) {
const daRispondere = ctx.eventi.filter((e) => e.tipo !== "compleanno");
if (daRispondere.length === 0) return 0;
const posti = daRispondere.length * giocatori.length;
if (daRispondere.length === 0 || rosaSize === 0) return 0;
const posti = daRispondere.length * rosaSize;
const risposte = daRispondere.reduce(
(s, e) => s + Object.keys(ctx.presenze[e.id] ?? {}).length,
0,
@@ -50,8 +52,6 @@ function percentualeRisposte(ctx: ContestoObiettivi) {
return Math.round((risposte / posti) * 100);
}
const vittorie = storicoMatch.filter((m) => m.setNostri > m.setLoro).length;
/** Obiettivi collaborativi: si muovono con il contributo di tutta la rosa. */
export function obiettiviSquadra(
rosa: Giocatore[] = giocatori,
@@ -59,12 +59,13 @@ export function obiettiviSquadra(
): 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;
return [
{
id: "o1",
titolo: "90% di presenze ad agosto",
descrizione: "Media presenze su partite e allenamenti del mese",
valore: percentualePresenzeMese(ctx),
valore: percentualePresenzeMese(ctx, rosa.length),
target: 90,
unita: "%",
scadenza: "2026-08-31",
@@ -75,7 +76,7 @@ export function obiettiviSquadra(
id: "o2",
titolo: "Tutti rispondono alle convocazioni",
descrizione: "Percentuale di risposte date sugli eventi in programma",
valore: percentualeRisposte(ctx),
valore: percentualeRisposte(ctx, rosa.length),
target: 90,
unita: "%",
scadenza: "2026-09-30",
+66 -14
View File
@@ -1,8 +1,11 @@
import { giocatori } from "./crapp-data";
import { formatData } from "./crapp-data";
import type { Evento } from "./eventi";
export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null };
/** Candidato al turno palloni: solo id e nome bastano per assegnare e ordinare. */
export type CandidatoTurno = { id: string; nome: string };
/** Eventi che richiedono i palloni (allenamenti, partite, extra), in ordine di data. */
export function eventiPalloni(eventi: Evento[]): Evento[] {
return eventi
@@ -18,10 +21,11 @@ export function eventiPalloni(eventi: Evento[]): Evento[] {
export function completaTurni(
turni: Record<string, string>,
eventi: Evento[],
rosa: CandidatoTurno[],
): Record<string, string> {
const risultato: Record<string, string> = { ...turni };
const conteggio = new Map<string, number>(giocatori.map((g) => [g.id, 0]));
const ultimo = new Map<string, number>(giocatori.map((g) => [g.id, -1]));
const conteggio = new Map<string, number>(rosa.map((g) => [g.id, 0]));
const ultimo = new Map<string, number>(rosa.map((g) => [g.id, -1]));
eventiPalloni(eventi).forEach((evento, indice) => {
const assegnato = risultato[evento.id];
@@ -32,17 +36,15 @@ export function completaTurni(
}
if (assegnato) return;
const scelto = giocatori
.slice()
.sort((a, b) => {
const ca = conteggio.get(a.id) ?? 0;
const cb = conteggio.get(b.id) ?? 0;
if (ca !== cb) return ca - cb;
const ua = ultimo.get(a.id) ?? -1;
const ub = ultimo.get(b.id) ?? -1;
if (ua !== ub) return ua - ub;
return a.nome.localeCompare(b.nome);
})[0];
const scelto = rosa.slice().sort((a, b) => {
const ca = conteggio.get(a.id) ?? 0;
const cb = conteggio.get(b.id) ?? 0;
if (ca !== cb) return ca - cb;
const ua = ultimo.get(a.id) ?? -1;
const ub = ultimo.get(b.id) ?? -1;
if (ua !== ub) return ua - ub;
return a.nome.localeCompare(b.nome);
})[0];
if (!scelto) return;
risultato[evento.id] = scelto.id;
@@ -83,3 +85,53 @@ export function oggiISO(): string {
const dd = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${mm}-${dd}`;
}
/** Chi deve ricevere l'avviso push, oggi: chi porta i palloni e chi li riprende. */
export function destinatariPromemoriaPalloni(
turni: Record<string, string>,
eventi: Evento[],
oggi: string,
): string[] {
const destinatari = new Set<string>();
for (const evento of eventiDelGiorno(eventi, oggi)) {
const incaricato = turni[evento.id];
if (incaricato) destinatari.add(incaricato);
const prima = eventoPrecedente(eventi, evento.id);
const precedente = prima ? turni[prima.id] : undefined;
if (precedente) destinatari.add(precedente);
}
return [...destinatari];
}
/** Testo del push per un giocatore: priorità a "riporta oggi", poi "tocca a te", poi generico. */
export function messaggioPalloniOggi(
turni: Record<string, string>,
eventi: Evento[],
oggi: string,
mioId: string,
nome: string,
): { title: string; body: string } {
for (const evento of eventiDelGiorno(eventi, oggi)) {
const prima = eventoPrecedente(eventi, evento.id);
if (prima && turni[prima.id] === mioId) {
return {
title: "Porta i palloni oggi",
body: `${evento.titolo} · ${evento.ora}. I palloni li hai tu dalla volta scorsa.`,
};
}
if (turni[evento.id] === mioId) {
const dopo = eventoSuccessivo(eventi, evento.id);
return {
title: "Tocca a te prendere i palloni",
body: dopo
? `A fine ${evento.titolo} porta a casa i palloni e riportali il ${formatData(dopo.data)}.`
: `A fine ${evento.titolo} porta a casa i palloni.`,
};
}
}
return {
title: "CrAPP · Turno palloni",
body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.",
};
}
+4 -1
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { completaTurni } from "./palloni-core";
import { useEventi } from "./eventi";
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
export const TURNI_KEY = ["turni-palloni"] as const;
@@ -26,8 +27,10 @@ export function useTurniPalloni() {
// Cambia raramente: una lettura per sessione è sufficiente.
const query = useQuery({ queryKey: TURNI_KEY, queryFn: fetchTurni, staleTime: 30 * 60_000 });
const { eventi } = useEventi();
const { righe: squadra } = useGiocatoriSquadra();
const salvati = query.data ?? {};
return { ...query, salvati, turni: completaTurni(salvati, eventi) };
const rosa = squadra.filter((g) => g.attivo).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
return { ...query, salvati, turni: completaTurni(salvati, eventi, rosa) };
}
export function useAssegnaTurno() {
+6 -5
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { useEventi } from "./eventi";
import { useRispostePresenze } from "./presenze";
import { giocatori } from "./crapp-data";
import { useGiocatoriSquadra } from "./giocatori-squadra";
/**
* Percentuale di presenze dell'ultimo mese (30 giorni), utile per le convocazioni.
@@ -46,6 +46,7 @@ export function usePresenzeUltimoMeseTutti(): Record<
> {
const { eventi } = useEventi();
const { presenze } = useRispostePresenze();
const { righe: squadra } = useGiocatoriSquadra();
return useMemo(() => {
const oggi = new Date();
@@ -53,6 +54,7 @@ export function usePresenzeUltimoMeseTutti(): Record<
inizio.setDate(inizio.getDate() - 30);
const da = inizio.toISOString().slice(0, 10);
const a = oggi.toISOString().slice(0, 10);
const idRosa = squadra.filter((g) => g.attivo).map((g) => g.id);
const rilevanti = eventi.filter(
(e) => (e.tipo === "partita" || e.tipo === "allenamento") && e.data >= da && e.data <= a,
@@ -60,8 +62,7 @@ export function usePresenzeUltimoMeseTutti(): Record<
const out: Record<string, { presenti: number; totali: number; percentuale: number }> = {};
for (const e of rilevanti) {
const ids =
e.convocati.length > 0 ? e.convocati : giocatori.map((g) => g.id);
const ids = e.convocati.length > 0 ? e.convocati : idRosa;
for (const id of ids) {
const rec = (out[id] ??= { presenti: 0, totali: 0, percentuale: 0 });
rec.totali += 1;
@@ -73,5 +74,5 @@ export function usePresenzeUltimoMeseTutti(): Record<
rec.percentuale = rec.totali ? Math.round((rec.presenti / rec.totali) * 100) : 0;
}
return out;
}, [eventi, presenze]);
}
}, [eventi, presenze, squadra]);
}
+120 -13
View File
@@ -1,28 +1,126 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import type { Stato } from "./crapp-data";
import type { Evento } from "./eventi";
import { aggiornaSerie } from "./serie";
export const PRESENZE_KEY = ["risposte-presenze"] as const;
/** eventoId -> giocatoreId -> stato */
export type MappaPresenze = Record<string, Record<string, Stato>>;
async function fetchPresenze(): Promise<MappaPresenze> {
/** eventoId -> giocatoreId -> istante della prima risposta (ISO). */
export type MappaTempiRisposta = Record<string, Record<string, string>>;
/** Allenamenti e partite CrAPP che contano per le statistiche di presenza. */
function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string) {
return eventi.filter(
(e) =>
(e.tipo === "partita" || e.tipo === "allenamento") &&
(giocatoreId === undefined ||
e.convocati.length === 0 ||
e.convocati.includes(giocatoreId)),
);
}
/** Presenze effettive (presente o in ritardo) su eventi CrAPP. */
export function contaPresenzeGiocatore(
giocatoreId: string,
eventi: Evento[],
presenze: MappaPresenze,
): number {
return eventiContanoPresenze(eventi, giocatoreId).filter((e) => {
const stato = presenze[e.id]?.[giocatoreId];
return stato === "presente" || stato === "ritardo";
}).length;
}
/** Eventi CrAPP rilevanti per il denominatore presenze di un giocatore. */
export function totaliEventiGiocatore(giocatoreId: string, eventi: Evento[]): number {
return eventiContanoPresenze(eventi, giocatoreId).length;
}
/**
* Serie di presenze consecutive su eventi già passati, in ordine di data:
* ogni presenza (o ritardo) vale +1, qualsiasi altra risposta o nessuna
* risposta azzera la serie. Senza `tipo` conta partite e allenamenti insieme.
*/
export function serieConsecutiva(
giocatoreId: string,
eventi: Evento[],
presenze: MappaPresenze,
tipo?: "partita" | "allenamento",
oggi: string = oggiIso(),
): number {
return serieSu(giocatoreId, eventi, oggi, tipo, (e) => {
const stato = presenze[e.id]?.[giocatoreId];
return stato === "presente" || stato === "ritardo";
});
}
const ORE_24 = 24 * 60 * 60 * 1000;
/**
* Serie di conferme rapide: risposte arrivate entro 24 ore dalla convocazione
* (`creatoIl` dell'evento). Gli eventi senza istante di creazione quelli generati
* dal client, non salvati a database non spezzano la serie: vengono saltati.
*/
export function serieConferme(
giocatoreId: string,
eventi: Evento[],
tempi: MappaTempiRisposta,
oggi: string = oggiIso(),
): number {
return serieSu(
giocatoreId,
eventi.filter((e) => e.creatoIl),
oggi,
undefined,
(e) => {
const risposto = tempi[e.id]?.[giocatoreId];
return risposto !== undefined && Date.parse(risposto) - Date.parse(e.creatoIl!) <= ORE_24;
},
);
}
function oggiIso() {
return new Date().toISOString().slice(0, 10);
}
/** Scorre gli eventi già passati in ordine di data applicando la regola delle serie. */
function serieSu(
giocatoreId: string,
eventi: Evento[],
oggi: string,
tipo: "partita" | "allenamento" | undefined,
onorato: (e: Evento) => boolean,
): number {
return eventiContanoPresenze(eventi, giocatoreId)
.filter((e) => (tipo === undefined || e.tipo === tipo) && e.data <= oggi)
.sort((a, b) => a.data.localeCompare(b.data))
.reduce((serie, e) => aggiornaSerie(serie, onorato(e)), 0);
}
type LetturaPresenze = { presenze: MappaPresenze; tempi: MappaTempiRisposta };
async function fetchPresenze(): Promise<LetturaPresenze> {
const { data, error } = await supabase
.from("risposte_presenze")
.select("evento_id, giocatore_id, stato");
.select("evento_id, giocatore_id, stato, risposto_il");
if (error) throw error;
const mappa: MappaPresenze = {};
const presenze: MappaPresenze = {};
const tempi: MappaTempiRisposta = {};
for (const riga of data ?? []) {
(mappa[riga.evento_id] ??= {})[riga.giocatore_id] = riga.stato as Stato;
(presenze[riga.evento_id] ??= {})[riga.giocatore_id] = riga.stato as Stato;
(tempi[riga.evento_id] ??= {})[riga.giocatore_id] = riga.risposto_il;
}
return mappa;
return { presenze, tempi };
}
/** Una lettura per sessione: le risposte cambiano poco durante la navigazione. */
export function useRispostePresenze() {
const query = useQuery({ queryKey: PRESENZE_KEY, queryFn: fetchPresenze, staleTime: 5 * 60_000 });
return { ...query, presenze: query.data ?? {} };
return { ...query, presenze: query.data?.presenze ?? {}, tempi: query.data?.tempi ?? {} };
}
export function usePresenzeEvento(eventoId: string) {
@@ -57,13 +155,22 @@ export function useSalvaPresenza() {
},
// Scrittura unica + aggiornamento cache locale, nessuna rilettura.
onSuccess: (input) => {
queryClient.setQueryData<MappaPresenze>(PRESENZE_KEY, (prec) => {
const mappa: MappaPresenze = { ...(prec ?? {}) };
const evento = { ...(mappa[input.eventoId] ?? {}) };
if (input.stato === null) delete evento[input.giocatoreId];
else evento[input.giocatoreId] = input.stato;
mappa[input.eventoId] = evento;
return mappa;
queryClient.setQueryData<LetturaPresenze>(PRESENZE_KEY, (prec) => {
const presenze: MappaPresenze = { ...(prec?.presenze ?? {}) };
const tempi: MappaTempiRisposta = { ...(prec?.tempi ?? {}) };
const stati = { ...(presenze[input.eventoId] ?? {}) };
const istanti = { ...(tempi[input.eventoId] ?? {}) };
if (input.stato === null) {
delete stati[input.giocatoreId];
delete istanti[input.giocatoreId];
} else {
stati[input.giocatoreId] = input.stato;
// Come a database: l'istante è quello della prima risposta, non dei ripensamenti.
istanti[input.giocatoreId] ??= new Date().toISOString();
}
presenze[input.eventoId] = stati;
tempi[input.eventoId] = istanti;
return { presenze, tempi };
});
},
});
+205
View File
@@ -0,0 +1,205 @@
import { rigaCsv } from "./scout-export";
import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
/**
* Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato
* `profili-giocatore`: qui viaggiano solo i path.
*/
export type Profilo = {
giocatoreId: string;
dataNascita: string | null;
luogoNascita: string | null;
indirizzo: string | null;
telefono: string | null;
email: string | null;
documentoTipo: string | null;
documentoNumero: string | null;
documentoRilasciatoDa: string | null;
documentoEmissione: string | null;
documentoScadenza: string | null;
documentoFrontePath: string | null;
documentoRetroPath: string | null;
certificatoScadenza: string | null;
certificatoPath: string | null;
fotoPath: string | null;
};
export type RigaProfilo = {
giocatore_id: string;
data_nascita: string | null;
luogo_nascita: string | null;
indirizzo: string | null;
telefono: string | null;
email: string | null;
documento_tipo: string | null;
documento_numero: string | null;
documento_rilasciato_da: string | null;
documento_emissione: string | null;
documento_scadenza: string | null;
documento_fronte_path: string | null;
documento_retro_path: string | null;
certificato_scadenza: string | null;
certificato_path: string | null;
foto_path: string | null;
};
export const COLONNE_PROFILO =
"giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email, documento_tipo, documento_numero, documento_rilasciato_da, documento_emissione, documento_scadenza, documento_fronte_path, documento_retro_path, certificato_scadenza, certificato_path, foto_path";
export function profiloVuoto(giocatoreId: string): Profilo {
return {
giocatoreId,
dataNascita: null,
luogoNascita: null,
indirizzo: null,
telefono: null,
email: null,
documentoTipo: null,
documentoNumero: null,
documentoRilasciatoDa: null,
documentoEmissione: null,
documentoScadenza: null,
documentoFrontePath: null,
documentoRetroPath: null,
certificatoScadenza: null,
certificatoPath: null,
fotoPath: null,
};
}
export function daRigaProfilo(r: RigaProfilo): Profilo {
return {
giocatoreId: r.giocatore_id,
dataNascita: r.data_nascita,
luogoNascita: r.luogo_nascita,
indirizzo: r.indirizzo,
telefono: r.telefono,
email: r.email,
documentoTipo: r.documento_tipo,
documentoNumero: r.documento_numero,
documentoRilasciatoDa: r.documento_rilasciato_da,
documentoEmissione: r.documento_emissione,
documentoScadenza: r.documento_scadenza,
documentoFrontePath: r.documento_fronte_path,
documentoRetroPath: r.documento_retro_path,
certificatoScadenza: r.certificato_scadenza,
certificatoPath: r.certificato_path,
fotoPath: r.foto_path,
};
}
/** I campi vuoti tornano al database come NULL, non come stringa vuota. */
function oNull(valore: string | null): string | null {
const pulito = valore?.trim();
return pulito ? pulito : null;
}
export function aRigaProfilo(p: Profilo): RigaProfilo {
return {
giocatore_id: p.giocatoreId,
data_nascita: oNull(p.dataNascita),
luogo_nascita: oNull(p.luogoNascita),
indirizzo: oNull(p.indirizzo),
telefono: oNull(p.telefono),
email: oNull(p.email),
documento_tipo: oNull(p.documentoTipo),
documento_numero: oNull(p.documentoNumero),
documento_rilasciato_da: oNull(p.documentoRilasciatoDa),
documento_emissione: oNull(p.documentoEmissione),
documento_scadenza: oNull(p.documentoScadenza),
documento_fronte_path: oNull(p.documentoFrontePath),
documento_retro_path: oNull(p.documentoRetroPath),
certificato_scadenza: oNull(p.certificatoScadenza),
certificato_path: oNull(p.certificatoPath),
foto_path: oNull(p.fotoPath),
};
}
/** Pesi delle sezioni del profilo (docs/modules/profilo-giocatore.md). */
export const PESI = { dati: 30, documento: 30, certificato: 30, foto: 10 } as const;
export type Sezione = keyof typeof PESI;
export function sezioniComplete(p: Profilo | null | undefined): Record<Sezione, boolean> {
return {
dati: !!(p?.dataNascita && p.luogoNascita && p.indirizzo && p.telefono && p.email),
documento: !!(
p?.documentoTipo &&
p.documentoNumero &&
p.documentoScadenza &&
p.documentoFrontePath &&
p.documentoRetroPath
),
certificato: !!(p?.certificatoScadenza && p.certificatoPath),
foto: !!p?.fotoPath,
};
}
/** Percentuale di completamento: calcolata a runtime, mai persistita (DD-007, DD-016). */
export function completamento(p: Profilo | null | undefined): number {
const complete = sezioniComplete(p);
return (Object.keys(PESI) as Sezione[]).reduce(
(somma, s) => somma + (complete[s] ? PESI[s] : 0),
0,
);
}
export type StatoScadenza = "mancante" | "scaduto" | "valido";
/** Un certificato scaduto blocca il tesseramento: per l'admin non vale come presente. */
export function statoScadenza(
scadenza: string | null | undefined,
path: string | null | undefined,
oggi: string,
): StatoScadenza {
if (!path || !scadenza) return "mancante";
return scadenza < oggi ? "scaduto" : "valido";
}
/** Colonne richieste dal tesseramento CSI, nell'ordine del documento di modulo. */
const INTESTAZIONI = [
"Nome",
"Cognome",
"Data di nascita",
"Luogo di nascita",
"Indirizzo",
"Telefono",
"Email",
"Tipo documento",
"Numero documento",
"Rilasciato da",
"Data emissione",
"Data scadenza",
];
export function csvTesseramento(
squadra: GiocatoreSquadra[],
profili: Record<string, Profilo>,
): string {
const righe = [rigaCsv(INTESTAZIONI)];
for (const g of squadra) {
const p = profili[g.id];
righe.push(
rigaCsv([
g.nome,
g.cognome,
p?.dataNascita ?? "",
p?.luogoNascita ?? "",
p?.indirizzo ?? "",
p?.telefono ?? "",
p?.email ?? "",
p?.documentoTipo ?? "",
p?.documentoNumero ?? "",
p?.documentoRilasciatoDa ?? "",
p?.documentoEmissione ?? "",
p?.documentoScadenza ?? "",
]),
);
}
return righe.join("\n");
}
/** Etichetta per l'elenco della dashboard. */
export function etichettaGiocatore(g: GiocatoreSquadra): string {
return `#${g.numero} ${nomeCompleto(g)}`;
}
+117
View File
@@ -0,0 +1,117 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
import {
aRigaProfilo,
COLONNE_PROFILO,
daRigaProfilo,
type Profilo,
type RigaProfilo,
} from "./profili-core";
export const PROFILI_KEY = ["profili-giocatore"] as const;
export const BUCKET = "profili-giocatore";
async function fetchProfili(): Promise<Record<string, Profilo>> {
const { data, error } = await supabaseNuoveTabelle
.from("profili_giocatore")
.select(COLONNE_PROFILO);
if (error) throw error;
const mappa: Record<string, Profilo> = {};
for (const r of (data ?? []) as RigaProfilo[]) mappa[r.giocatore_id] = daRigaProfilo(r);
return mappa;
}
/**
* Profili visibili all'utente corrente: le policy RLS decidono quanti sono il proprio
* per un giocatore, tutti per un admin. Una lettura per sessione.
*/
export function useProfili() {
const query = useQuery({ queryKey: PROFILI_KEY, queryFn: fetchProfili, staleTime: 30 * 60_000 });
return { ...query, profili: query.data ?? {} };
}
/**
* I documenti stanno in un bucket privato e non hanno URL permanenti (DD-016 regola 4):
* ogni download passa da una signed URL che scade in un minuto.
*/
export async function urlFirmato(path: string): Promise<string> {
const { data, error } = await supabase.storage.from(BUCKET).createSignedUrl(path, 60);
if (error) throw error;
return data.signedUrl;
}
export async function scaricaFile(path: string): Promise<void> {
const url = await urlFirmato(path);
window.open(url, "_blank", "noopener,noreferrer");
}
/**
* Salva il profilo del giocatore. Le policy RLS lasciano scrivere solo la propria riga:
* il vincolo vive nel database, qui non serve ricontrollarlo.
*/
export function useSalvaProfilo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (profilo: Profilo) => {
const { error } = await supabaseNuoveTabelle
.from("profili_giocatore")
.upsert(aRigaProfilo(profilo), { onConflict: "giocatore_id" });
if (error) throw error;
return profilo;
},
// Scrittura unica e cache aggiornata a mano, senza rilettura.
onSuccess: (profilo) => {
queryClient.setQueryData<Record<string, Profilo>>(PROFILI_KEY, (prec) => ({
...(prec ?? {}),
[profilo.giocatoreId]: profilo,
}));
},
});
}
export type SezioneFile = "documento-fronte" | "documento-retro" | "certificato" | "foto";
const MAX_BYTE = 8 * 1024 * 1024;
const TIPI_AMMESSI = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
function estensione(nome: string): string {
const punto = nome.lastIndexOf(".");
const est = punto > 0 ? nome.slice(punto + 1).toLowerCase() : "";
return /^[a-z0-9]{1,5}$/.test(est) ? est : "bin";
}
/**
* Carica un file nella cartella del giocatore e restituisce il path da salvare sul profilo.
* Il controllo su tipo e dimensione sta qui perché è il confine con un file scelto
* dall'utente; le policy dello Storage impediscono comunque di scrivere fuori dalla
* propria cartella.
*/
export async function caricaFile(
giocatoreId: string,
sezione: SezioneFile,
file: File,
pathPrecedente?: string | null,
): Promise<string> {
if (!TIPI_AMMESSI.includes(file.type)) {
throw new Error("Formato non ammesso: usa JPG, PNG, WEBP o PDF.");
}
if (file.size > MAX_BYTE) throw new Error("File troppo grande: massimo 8 MB.");
const path = `${giocatoreId}/${sezione}.${estensione(file.name)}`;
const { error } = await supabase.storage
.from(BUCKET)
.upload(path, file, { upsert: true, contentType: file.type });
if (error) throw error;
// Cambiando estensione il vecchio file resterebbe orfano nel bucket.
if (pathPrecedente && pathPrecedente !== path) {
await supabase.storage.from(BUCKET).remove([pathPrecedente]);
}
return path;
}
export async function rimuoviFile(path: string): Promise<void> {
const { error } = await supabase.storage.from(BUCKET).remove([path]);
if (error) throw error;
}
+1 -1
View File
@@ -69,4 +69,4 @@ export async function disattivaNotifiche(): Promise<void> {
await sub.unsubscribe();
}
await reg?.unregister();
}
}
+58 -24
View File
@@ -1,29 +1,45 @@
import { useMemo } from "react";
import { giocatori, type Giocatore } from "./crapp-data";
import { giocatoriConScout, useScoutMatches } from "./scout-store";
import { useVotiMvp, vincitoriMvp } from "./mvp-voti";
import { nascitaPerId, type Giocatore } from "./crapp-data";
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 { useTurniPalloni } from "./palloni";
import { useInfortuniERitardi } from "./infortuni";
import { useGiocatoreBase } from "./user-store";
import { useGiocatoreId } from "./user-store";
import { useEventi } from "./eventi";
import { useRispostePresenze } from "./presenze";
import {
contaPresenzeGiocatore,
serieConferme,
serieConsecutiva,
totaliEventiGiocatore,
useRispostePresenze,
} from "./presenze";
import { obiettiviOrdinati } from "./obiettivi";
import { useCsi } from "./csi";
import { partiteGiocate } from "./csi-core";
function iniziali(nome: string, cognome: string): string {
return `${nome[0] ?? ""}${cognome[0] ?? ""}`.toUpperCase();
}
/**
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
* palloni, infortuni, ritardi, cacche). Usa solo cache già in memoria:
* nessuna query aggiuntiva rispetto a quelle che l'app fa comunque.
* palloni, infortuni, ritardi, cacche). Legge l'anagrafica da `giocatori_squadra`
* (DD-015): solo i giocatori attivi, gli altri restano nel database ma spariscono
* dagli elenchi correnti. Usa solo cache già in memoria: nessuna query aggiuntiva
* rispetto a quelle che l'app fa comunque.
*/
export function useRosa(): Giocatore[] {
const scoutMatches = useScoutMatches();
const { righe: squadra } = useGiocatoriSquadra();
const voti = useVotiMvp();
const { voti: pagelle } = usePagelle();
const { righe: cacche } = useCacche();
const { turni } = useTurniPalloni();
const { infortuni, ritardi } = useInfortuniERitardi();
const { eventi } = useEventi();
const { presenze: mappaPresenze, tempi } = useRispostePresenze();
const votiMvp = voti.data ?? [];
@@ -31,24 +47,40 @@ export function useRosa(): Giocatore[] {
const medie = mediePagelle(pagelle);
const statCacche = statisticheCacche(cacche);
const palloni = conteggioTurni(turni);
return giocatoriConScout(scoutMatches, vincitoriMvp(votiMvp)).map((g) => ({
...g,
mediaVoto: medie[g.id]?.media ?? g.mediaVoto,
palloni: palloni[g.id] ?? 0,
cacche: statCacche[g.id]?.giornateTop ?? 0,
cacchePartita: statCacche[g.id]?.media ?? 0,
infortuni: infortuni[g.id] ?? 0,
ritardi: ritardi[g.id] ?? 0,
}));
}, [scoutMatches, votiMvp, pagelle, cacche, turni, infortuni, ritardi]);
const mvpVinti = mvpVintiPerGiocatore(votiMvp);
return squadra
.filter((g) => g.attivo)
.map((g) => ({
id: g.id,
nome: nomeCompleto(g),
numero: g.numero,
ruolo: g.ruolo,
nascita: nascitaPerId[g.id] ?? "",
iniziali: iniziali(g.nome, g.cognome),
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
totaliEventi: totaliEventiGiocatore(g.id, eventi),
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,
palloni: palloni[g.id] ?? 0,
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]);
}
/** Il giocatore selezionato sul dispositivo, con le statistiche complete. */
export function useIo(): Giocatore | null {
const base = useGiocatoreBase();
const id = useGiocatoreId();
const rosa = useRosa();
if (!base) return null;
return rosa.find((g) => g.id === base.id) ?? base;
if (!id) return null;
return rosa.find((g) => g.id === id) ?? null;
}
/** Obiettivi collaborativi calcolati sui dati reali già in cache. */
@@ -57,7 +89,9 @@ export function useObiettivi() {
const { eventi } = useEventi();
const { presenze } = useRispostePresenze();
const { voti: pagelle } = usePagelle();
return obiettiviOrdinati(rosa, { eventi, presenze, pagelle });
const { data: csi } = useCsi();
const vittorie = csi
? partiteGiocate(csi.partite).filter((p) => (p.setNostri ?? 0) > (p.setLoro ?? 0)).length
: 0;
return obiettiviOrdinati(rosa, { eventi, presenze, pagelle, vittorie });
}
export { giocatori };
+34
View File
@@ -0,0 +1,34 @@
import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useSessione } from "./auth";
export const RUOLI_KEY = ["ruolo-admin"] as const;
/**
* Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011).
* Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare.
*/
/** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */
async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null> {
if (!utenteId) return null;
const { data, error } = await supabase
.from("user_roles")
.select("role")
.eq("user_id", utenteId)
.eq("role", "admin")
.maybeSingle();
if (error) throw error;
return !!data;
}
export function useIsAdmin(): boolean {
const { utenteId } = useSessione();
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
const query = useQuery({
queryKey: [...RUOLI_KEY, utenteId],
queryFn: () => fetchRuoloAdmin(utenteId),
staleTime: 30 * 60_000,
});
return query.data === true;
}
+21 -13
View File
@@ -1,7 +1,8 @@
import { giocatori } from "./crapp-data";
import type { Giocatore } from "./crapp-data";
import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store";
function riga(campi: Array<string | number>) {
/** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */
export function rigaCsv(campi: Array<string | number>) {
return campi
.map((c) => {
const testo = String(c);
@@ -11,26 +12,33 @@ function riga(campi: Array<string | number>) {
}
/** Esporta la scoutizzazione di una partita in CSV (separatore ";" per Excel IT). */
export function csvScoutMatch(match: ScoutMatch): string {
export function csvScoutMatch(match: ScoutMatch, rosa: Giocatore[]): string {
const righe: string[] = [];
righe.push(riga(["Partita", match.casa ? "CRAP Volley" : match.avversario, "vs", match.casa ? match.avversario : "CRAP Volley"]));
righe.push(riga(["Data", match.data, "Set", `${match.setNostri}-${match.setLoro}`]));
righe.push(
rigaCsv([
"Partita",
match.casa ? "CRAP Volley" : match.avversario,
"vs",
match.casa ? match.avversario : "CRAP Volley",
]),
);
righe.push(rigaCsv(["Data", match.data, "Set", `${match.setNostri}-${match.setLoro}`]));
righe.push("");
righe.push(riga(["Set", "Parziale nostro", "Parziale loro"]));
match.parziali.forEach((p, i) => righe.push(riga([i + 1, p[0], p[1]])));
righe.push(rigaCsv(["Set", "Parziale nostro", "Parziale loro"]));
match.parziali.forEach((p, i) => righe.push(rigaCsv([i + 1, p[0], p[1]])));
righe.push("");
righe.push(riga(["Numero", "Giocatore", "Ruolo", "Punti", "Ace", "Muri", "Errori"]));
righe.push(rigaCsv(["Numero", "Giocatore", "Ruolo", "Punti", "Ace", "Muri", "Errori"]));
const totali = totaliPerGiocatore(match.azioni);
for (const g of giocatori) {
for (const g of rosa) {
const t = totali.get(g.id);
if (!t) continue;
righe.push(riga([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori]));
righe.push(rigaCsv([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori]));
}
righe.push("");
righe.push(riga(["Set", "Giocatore", "Azione"]));
righe.push(rigaCsv(["Set", "Giocatore", "Azione"]));
for (const a of match.azioni) {
const g = giocatori.find((x) => x.id === a.giocatoreId);
righe.push(riga([a.set, g?.nome ?? "—", azioniMeta[a.tipo].label]));
const g = rosa.find((x) => x.id === a.giocatoreId);
righe.push(rigaCsv([a.set, g?.nome ?? "—", azioniMeta[a.tipo].label]));
}
return righe.join("\n");
}
+51 -85
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useEventi, type Evento } from "./eventi";
/** Minuti dopo i quali una sessione scout inattiva viene considerata libera. */
@@ -24,79 +25,34 @@ export type SessioneScout = {
export function sessioneScaduta(s: SessioneScout | null): boolean {
if (!s) return true;
return Date.now() - new Date(s.aggiornato_il).getTime() > SCADENZA_MINUTI * 60_000;
}
const storageKey = (eventoId: string) => `crap-scout-session-${eventoId}`;
function readSession(eventoId: string): SessioneScout | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(storageKey(eventoId));
if (!raw) return null;
return JSON.parse(raw) as SessioneScout;
} catch {
return null;
}
}
function writeSession(eventoId: string, sessione: SessioneScout | null) {
if (typeof window === "undefined") return;
if (sessione) {
window.localStorage.setItem(storageKey(eventoId), JSON.stringify(sessione));
} else {
window.localStorage.removeItem(storageKey(eventoId));
}
try {
const bc = new BroadcastChannel(`crap-scout-${eventoId}`);
bc.postMessage(sessione);
bc.close();
} catch {
// fallback: storage event is already fired by localStorage
}
const aggiornato = new Date(s.aggiornato_il).getTime();
// Timestamp illeggibile: meglio liberare la sessione che lasciarla bloccata per sempre.
if (Number.isNaN(aggiornato)) return true;
return Date.now() - aggiornato > SCADENZA_MINUTI * 60_000;
}
export const SESSIONE_KEY = (eventoId: string) => ["scout-sessione", eventoId] as const;
/** Sessione condivisa: chi la tiene aperta lo vede chiunque, su qualsiasi dispositivo. */
async function leggiSessione(eventoId: string): Promise<SessioneScout | null> {
const { data, error } = await supabase
.from("scout_sessioni")
.select("evento_id, giocatore_id, giocatore_nome, aggiornato_il")
.eq("evento_id", eventoId)
.maybeSingle();
if (error) throw error;
return data;
}
export function useSessioneScout(eventoId: string | null) {
const queryClient = useQueryClient();
const query = useQuery({
return useQuery({
queryKey: SESSIONE_KEY(eventoId ?? "-"),
enabled: !!eventoId,
// Sincronizzazione via BroadcastChannel/storage: nessun polling.
staleTime: Infinity,
queryFn: async (): Promise<SessioneScout | null> => {
if (!eventoId || typeof window === "undefined") return null;
return readSession(eventoId);
},
// Nessun push in tempo reale: si ricontrolla all'apertura/focus della pagina
// o con il pulsante "Aggiorna" quando risulta occupato.
staleTime: 30_000,
queryFn: () => leggiSessione(eventoId!),
});
useEffect(() => {
if (!eventoId) return;
let bc: BroadcastChannel | null = null;
try {
bc = new BroadcastChannel(`crap-scout-${eventoId}`);
bc.onmessage = (e) => {
queryClient.setQueryData(SESSIONE_KEY(eventoId), e.data as SessioneScout | null);
};
} catch {
const onStorage = (e: StorageEvent) => {
if (e.key === storageKey(eventoId)) {
queryClient.setQueryData(SESSIONE_KEY(eventoId), e.newValue ? (JSON.parse(e.newValue) as SessioneScout) : null);
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}
return () => {
if (bc) {
bc.onmessage = null;
bc.close();
}
};
}, [eventoId, queryClient]);
return query;
}
/** Prende il controllo dello scout se libero o scaduto. Ritorna true se ottenuto. */
@@ -104,17 +60,20 @@ export function useApriSessioneScout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { eventoId: string; giocatoreId: string; nome: string }) => {
if (typeof window === "undefined") return false;
const current = readSession(input.eventoId);
if (current && !sessioneScaduta(current) && current.giocatore_id !== input.giocatoreId) {
const attuale = await leggiSessione(input.eventoId);
if (attuale && !sessioneScaduta(attuale) && attuale.giocatore_id !== input.giocatoreId) {
return false;
}
writeSession(input.eventoId, {
evento_id: input.eventoId,
giocatore_id: input.giocatoreId,
giocatore_nome: input.nome,
aggiornato_il: new Date().toISOString(),
});
const { error } = await supabase.from("scout_sessioni").upsert(
{
evento_id: input.eventoId,
giocatore_id: input.giocatoreId,
giocatore_nome: input.nome,
aggiornato_il: new Date().toISOString(),
},
{ onConflict: "evento_id" },
);
if (error) throw error;
return true;
},
onSuccess: (_ok, input) => {
@@ -127,10 +86,13 @@ export function useChiudiSessioneScout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { eventoId: string; giocatoreId: string }) => {
if (typeof window === "undefined") return;
const current = readSession(input.eventoId);
if (current && current.giocatore_id === input.giocatoreId) {
writeSession(input.eventoId, null);
const attuale = await leggiSessione(input.eventoId);
if (attuale && attuale.giocatore_id === input.giocatoreId) {
const { error } = await supabase
.from("scout_sessioni")
.delete()
.eq("evento_id", input.eventoId);
if (error) throw error;
}
},
onSuccess: (_d, input) => {
@@ -140,15 +102,19 @@ export function useChiudiSessioneScout() {
}
/** Mantiene viva la sessione mentre lo scout è aperto. */
export function useHeartbeatScout(eventoId: string | null, giocatoreId: string | null, attivo: boolean) {
export function useHeartbeatScout(
eventoId: string | null,
giocatoreId: string | null,
attivo: boolean,
) {
useEffect(() => {
if (!attivo || !eventoId || !giocatoreId || typeof window === "undefined") return;
if (!attivo || !eventoId || !giocatoreId) return;
const id = window.setInterval(() => {
const current = readSession(eventoId);
if (current && current.giocatore_id === giocatoreId) {
current.aggiornato_il = new Date().toISOString();
writeSession(eventoId, current);
}
void supabase
.from("scout_sessioni")
.update({ aggiornato_il: new Date().toISOString() })
.eq("evento_id", eventoId)
.eq("giocatore_id", giocatoreId);
}, 60_000);
return () => window.clearInterval(id);
}, [attivo, eventoId, giocatoreId]);
+121 -93
View File
@@ -1,24 +1,55 @@
import { useSyncExternalStore } from "react";
import { giocatori, classifica, type Giocatore, type RigaClassifica } from "./crapp-data";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { giocatori, type Giocatore } from "./crapp-data";
export type AzioneTipo =
| "attacco"
| "ace"
| "muro"
| "errore"
| "punto_avv"
| "errore_avv";
export type AzioneTipo = "attacco" | "ace" | "muro" | "errore" | "punto_avv" | "errore_avv";
export const azioniMeta: Record<
AzioneTipo,
{ label: string; short: string; nostro: boolean; richiedeGiocatore: boolean; className: string }
> = {
attacco: { label: "Punto attacco", short: "Punto", nostro: true, richiedeGiocatore: true, className: "bg-accent text-accent-foreground" },
ace: { label: "Ace", short: "Ace", nostro: true, richiedeGiocatore: true, className: "bg-success text-success-foreground" },
muro: { label: "Muro", short: "Muro", nostro: true, richiedeGiocatore: true, className: "bg-info text-info-foreground" },
errore: { label: "Errore nostro", short: "Errore", nostro: false, richiedeGiocatore: true, className: "bg-destructive text-destructive-foreground" },
punto_avv: { label: "Punto avversario", short: "Punto avv.", nostro: false, richiedeGiocatore: false, className: "bg-muted text-muted-foreground" },
errore_avv: { label: "Errore avversario", short: "Err. avv.", nostro: true, richiedeGiocatore: false, className: "bg-secondary text-foreground" },
attacco: {
label: "Punto attacco",
short: "Punto",
nostro: true,
richiedeGiocatore: true,
className: "bg-accent text-accent-foreground",
},
ace: {
label: "Ace",
short: "Ace",
nostro: true,
richiedeGiocatore: true,
className: "bg-success text-success-foreground",
},
muro: {
label: "Muro",
short: "Muro",
nostro: true,
richiedeGiocatore: true,
className: "bg-info text-info-foreground",
},
errore: {
label: "Errore nostro",
short: "Errore",
nostro: false,
richiedeGiocatore: true,
className: "bg-destructive text-destructive-foreground",
},
punto_avv: {
label: "Punto avversario",
short: "Punto avv.",
nostro: false,
richiedeGiocatore: false,
className: "bg-muted text-muted-foreground",
},
errore_avv: {
label: "Errore avversario",
short: "Err. avv.",
nostro: true,
richiedeGiocatore: false,
className: "bg-secondary text-foreground",
},
};
export type Azione = {
@@ -37,54 +68,87 @@ export type ScoutMatch = {
setNostri: number;
setLoro: number;
parziali: Array<[number, number]>;
mvp: string;
azioni: Azione[];
};
const KEY = "crapp-scout-v1";
type RigaScoutPartita = {
id: string;
data: string;
avversario: string;
casa: boolean;
set_nostri: number;
set_loro: number;
parziali: unknown;
azioni: unknown;
};
let cache: ScoutMatch[] | null = null;
const listeners = new Set<() => void>();
function read(): ScoutMatch[] {
if (cache) return cache;
if (typeof window === "undefined") return (cache = []);
try {
const raw = window.localStorage.getItem(KEY);
cache = raw ? (JSON.parse(raw) as ScoutMatch[]) : [];
} catch {
cache = [];
}
return cache;
function daRiga(r: RigaScoutPartita): ScoutMatch {
return {
id: r.id,
data: r.data,
avversario: r.avversario,
casa: r.casa,
setNostri: r.set_nostri,
setLoro: r.set_loro,
parziali: (r.parziali as Array<[number, number]> | null) ?? [],
azioni: (r.azioni as Azione[] | null) ?? [],
};
}
function write(next: ScoutMatch[]) {
cache = next;
try {
window.localStorage.setItem(KEY, JSON.stringify(next));
} catch {
/* storage non disponibile */
}
listeners.forEach((l) => l());
}
export const SCOUT_MATCHES_KEY = ["scout-partite"] as const;
export function salvaScoutMatch(m: ScoutMatch) {
write([m, ...read()]);
}
export function eliminaScoutMatch(id: string) {
write(read().filter((m) => m.id !== id));
async function fetchScoutMatches(): Promise<ScoutMatch[]> {
const { data, error } = await supabase
.from("scout_partite")
.select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni")
.order("creato_il", { ascending: false });
if (error) throw error;
return (data ?? []).map(daRiga);
}
/** Partite scoutate condivise con tutta la squadra: chi scoutizza le vede da qualsiasi
* dispositivo, non solo da quello di chi ha chiuso la partita. */
export function useScoutMatches(): ScoutMatch[] {
return useSyncExternalStore(
(cb) => {
listeners.add(cb);
return () => listeners.delete(cb);
const { data } = useQuery({
queryKey: SCOUT_MATCHES_KEY,
staleTime: 60_000,
queryFn: fetchScoutMatches,
});
return data ?? [];
}
export function useSalvaScoutMatch() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { eventoId: string | null; match: ScoutMatch }) => {
const { error } = await supabase.from("scout_partite").insert({
id: input.match.id,
evento_id: input.eventoId,
data: input.match.data,
avversario: input.match.avversario,
casa: input.match.casa,
set_nostri: input.match.setNostri,
set_loro: input.match.setLoro,
parziali: JSON.parse(JSON.stringify(input.match.parziali)),
azioni: JSON.parse(JSON.stringify(input.match.azioni)),
});
if (error) throw error;
return input.match;
},
() => read(),
() => [],
);
onSuccess: () => queryClient.invalidateQueries({ queryKey: SCOUT_MATCHES_KEY }),
});
}
export function useEliminaScoutMatch() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { error } = await supabase.from("scout_partite").delete().eq("id", id);
if (error) throw error;
return id;
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: SCOUT_MATCHES_KEY }),
});
}
/** Somma delle azioni di un match per giocatore. */
@@ -122,58 +186,22 @@ export function totaliSquadra(matches: ScoutMatch[]) {
return out;
}
/** Presenze e MVP ricavati dalle partite scoutate: nessuna statistica individuale offensiva.
* `mvpPerMatch` mappa idPartita -> nome dell'MVP eletto dalla squadra. */
/**
* MVP ricavati dalle partite scoutate (mappa matchId nome vincitore).
* Le presenze personali restano su `risposte_presenze`, non sullo scout.
*/
export function giocatoriConScout(
matches: ScoutMatch[],
mvpPerMatch: Record<string, string> = {},
): Giocatore[] {
return giocatori.map((g) => {
let presenze = 0;
let mvp = 0;
for (const m of matches) {
if (totaliPerGiocatore(m.azioni).has(g.id)) presenze += 1;
if (mvpPerMatch[m.id] === g.nome) mvp += 1;
}
return {
...g,
mvp: g.mvp + mvp,
presenze: g.presenze + presenze,
totaliEventi: g.totaliEventi + matches.length,
mvp,
};
});
}
/** Classifica demo aggiornata con i match scoutati (solo la nostra riga). */
export function classificaConScout(matches: ScoutMatch[]): RigaClassifica[] {
if (matches.length === 0) return classifica;
const agg = matches.reduce(
(s, m) => {
const vinta = m.setNostri > m.setLoro;
s.giocate += 1;
s.vinte += vinta ? 1 : 0;
s.perse += vinta ? 0 : 1;
s.setFatti += m.setNostri;
s.setSubiti += m.setLoro;
s.punti += vinta ? (m.setLoro <= 1 ? 3 : 2) : m.setLoro === 3 && m.setNostri === 2 ? 1 : 0;
return s;
},
{ giocate: 0, vinte: 0, perse: 0, setFatti: 0, setSubiti: 0, punti: 0 },
);
return classifica
.map((r) =>
r.squadra === "CRAP Volley"
? {
...r,
giocate: r.giocate + agg.giocate,
vinte: r.vinte + agg.vinte,
perse: r.perse + agg.perse,
setFatti: r.setFatti + agg.setFatti,
setSubiti: r.setSubiti + agg.setSubiti,
punti: r.punti + agg.punti,
}
: r,
)
.sort((a, b) => b.punti - a.punti || b.setFatti - b.setSubiti - (a.setFatti - a.setSubiti))
.map((r, i) => ({ ...r, pos: i + 1 }));
}

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