56 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5 c1744d4087 Sposta avatar e documenti profilo su PocketBase (file field)
Sostituisce i due bucket Supabase Storage con campi file PocketBase:

- avatar-giocatori: nuova collection avatar_giocatori separata da
  giocatori_squadra, perché la policy originale ("chiunque autenticato
  carica/sostituisce/elimina qualsiasi avatar, nessun controllo
  proprietario") è più permissiva delle regole di giocatori_squadra —
  mescolarle avrebbe indebolito le une o bloccato l'altra. File pubblico,
  come il bucket originale;
- profili-giocatore: campi file su profili_giocatore stesso, con
  protected: true (vedi sotto).

Trovato e corretto un problema di sicurezza reale: PocketBase rende i
file pubblici di default (si affida solo alla casualità del nome file),
a meno di impostare esplicitamente protected: true sul campo — i
documenti d'identità e i certificati medici sarebbero stati raggiungibili
senza autenticazione, in violazione di DD-016 regola 4. Verificato che
ora servono un token valido (404 senza, 200 con).

La scrittura dei campi testuali del profilo e il caricamento dei file
sono due operazioni separate (PocketBase rifiuta una stringa dove si
aspetta un file): verificato che caricare un documento non cancella i
dati testuali già salvati.

Rimossi da profili-core.ts RigaProfilo/daRigaProfilo/aRigaProfilo
(shape Postgres non più usata da nessun modulo di produzione) e
rimuoviFile (mai chiamato da nessun componente). Avatar.tsx non usa più
un URL deterministico per giocatore: PocketBase genera nomi file
casuali, quindi legge la mappa avatar tramite una query React Query
condivisa tra tutte le istanze del componente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:58:54 +02:00
davideandClaude Sonnet 5 d125bdfb6a Sposta le route di notifiche push su PocketBase
Le route server in src/routes/api/public/ (promemoria-palloni,
sollecita-presenze, apri-sondaggio, push-subscribe, notifiche-attive)
usano pbAdmin al posto di supabaseAdmin per leggere/scrivere
push_subscriptions e i dati necessari a comporre gli avvisi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:58:40 +02:00
davideandClaude Sonnet 5 689468141d Sposta roster, eventi, presenze, voti, scout e palloni su PocketBase
Riscrive i moduli dati che parlavano con Supabase per usare l'SDK
PocketBase, mantenendo invariate le firme degli hook React Query così i
componenti a valle non cambiano: giocatori-squadra, eventi, presenze,
cacche, pagelle, mvp-voti, badge-social, scout (store/live/stato),
palloni.

Aggiunge due helper condivisi in src/integrations/pocketbase/:
- upsert.ts: PocketBase non ha upsert nativo, questi replicano il
  pattern onConflict di Supabase (per id significativo o per filtro su
  combinazione di campi), verificati contro un'istanza reale così da non
  duplicare record sulla stessa scrittura ripetuta;
- formato.ts: normalizza le date PocketBase ("AAAA-MM-GG HH:MM:SS.sssZ",
  spazio non "T") a ISO stretto, altrimenti Date.parse e i confronti
  testuali con altre date si comportano in modo incoerente.

Corregge anche due problemi trovati testando contro un'istanza reale:
- alcune API rule confrontavano un campo relazione con @request.auth.id
  senza richiedere l'autenticazione, lasciando passare richieste anonime
  quando entrambi i lati erano vuoti;
- gli hook non riconoscevano il superuser PocketBase (solo il ruolo
  admin applicativo), bloccando le operazioni fatte con pbAdmin.

Aggiunta la colonna "casa" mancante su eventi_app e i campi di sistema
created/updated (non generati automaticamente da PocketBase per le
collection create via migration, a differenza di quanto assunto
inizialmente).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:58:27 +02:00
davideandClaude Sonnet 5 0f5a27ed5f Sposta l'autenticazione da Supabase a PocketBase
Login Google via PocketBase invece che Supabase Auth: nuovi client
in src/integrations/pocketbase/ (browser, server con superuser, attacher
del bearer token per le server function). Il ruolo admin/user vive ora
come campo diretto sul record utente PocketBase invece che nella tabella
separata user_roles, quindi ruoli.ts e la verifica in auth-route.server.ts
non hanno più bisogno di una query aggiuntiva.

requireSupabaseAuth/auth-middleware.ts non viene riportato: non era
importato da nessun file, era codice morto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 14:58:09 +02:00
davideandClaude Sonnet 5 15885f32c7 Aggiunge infrastruttura PocketBase locale (Docker, schema, hook)
Introduce PocketBase come sostituto di Supabase solo per l'ambiente di
sviluppo locale (docs/PORTABILITA.md): docker-compose.pocketbase.yml,
schema iniziale in pocketbase/pb_migrations/ (collection equivalenti alle
tabelle Supabase, con le stesse API rule dove esprimibili) e la logica
procedurale che in Postgres viveva in trigger/funzioni riscritta come
hook JS in pocketbase/pb_hooks/ (claim slot giocatore, blocco autovoto,
convocati/pagelle chiuse, immutabilità di risposto_il).

Il superuser e il provider Google OAuth2 si ricreano da soli a ogni
avvio da variabili d'ambiente (script scripts/pocketbase-reset.sh),
così un reset non richiede passaggi manuali da dashboard.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:39:40 +02:00
154 changed files with 9992 additions and 2121 deletions
+5 -1
View File
@@ -40,4 +40,8 @@ dist-ssr
# Optional
.vercel
# Supabase CLI local state
supabase/.temp/
supabase/.temp/
# PocketBase locale (dati e log runtime, non lo schema in pb_migrations/pb_hooks)
pocketbase/pb_data/*
!pocketbase/pb_data/.gitkeep
@@ -1,38 +0,0 @@
# Obiettivi di squadra: sezione in Squadra + widget in home
## Stato attuale
Al momento c'è un solo "obiettivo di squadra" ed è hardcoded nella home (`src/routes/index.tsx`, riga 136): **"90% di presenze ad agosto"** con una barra finta all'82%. Non esiste uno schema dati né una sezione dedicata, e non è collegato a calendario, presenze o statistiche. Le voci "obiettivi" nelle descrizioni di Squadra e Profilo si riferiscono ai badge individuali, non a obiettivi di squadra.
## Cosa faccio
1. **Modello dati locale** in `src/lib/crapp-data.ts`
- Nuovo tipo `ObiettivoSquadra`: id, titolo, descrizione, target, valore attuale, unità, scadenza (opzionale), icona.
- Array `obiettiviSquadra` con gli obiettivi demo della stagione.
2. **Sezione "Obiettivi di squadra" dentro la scheda Squadra** (`src/routes/squadra.tsx`)
- Nuova sezione con la lista completa degli obiettivi: barra di progresso, percentuale, stato (in corso / completato).
- Ordinati con gli obiettivi in corso in cima e i completati in fondo.
- Nessuna nuova rotta e nessuna modifica al bottom nav.
3. **Widget home dinamico** (`src/routes/index.tsx`)
- Sostituisce l'obiettivo hardcoded: mostra sempre il primo obiettivo in corso preso dalla lista.
- Progresso calcolato dai dati reali dove possibile (es. presenze derivate dagli eventi).
4. **Obiettivi demo iniziali**
- 90% di presenze ad agosto (collegato agli eventi di agosto).
- 70% di risposte entro 24h nel prossimo mese.
- Prima vittoria del campionato (collegato allo storico match).
- 5 vittorie in campionato (collegato allo storico match).
- 10 vittorie in campionato (collegato allo storico match).
- 1 evento di squadra al mese (pizzata, ecc.).
## Cosa non cambia
- Resta un prototipo offline: i dati restano in `src/lib/crapp-data.ts`.
- I badge individuali restano come sono in `src/lib/badges.ts` e nella rosa di `src/routes/squadra.tsx`.
- Bottom nav e rotte invariate.
@@ -1,58 +0,0 @@
# Rendere CrAPP operativa: migrazione dal prototipo al Cloud
## Stato attuale
L'app è un prototipo UI con alcune funzioni già collegate a Lovable Cloud:
- Già sul Cloud: voti MVP (`mvp_voti`), iscrizioni push (`push_subscriptions`), sessioni scout (`scout_sessioni`), turni palloni (`turni_palloni`).
- Ancora in locale come dati demo: rosa giocatori, calendario eventi, storico partite, classifica CSI, statistiche individuali, presenze/assenze, badge.
## Cosa serve per renderla operativa
1. **Rosa e profili giocatori sul database**
- Creare tabella `profiles` (o `giocatori`) con nome, numero maglia, ruolo, data di nascita, foto profilo.
- Collegare ogni riga all'utente autenticato corrispondente.
- Rimuovere la rosa statica da `src/lib/crapp-data.ts` e caricarla dal backend.
2. **Autenticazione reale**
- Sostituire la semplice selezione "Chi sei?" in `localStorage` con login email/password o OAuth (Google).
- Ogni giocatore accede con le proprie credenziali e vede solo i propri dati modificabili.
- Necessaria per garantire che uno scout o un voto MVP provenga davvero da quel giocatore.
3. **Calendario eventi persistente**
- Tabella `eventi` con tipo, titolo, data, ora, luogo, avversario, casa/fuori.
- Tabella `presenze` (evento_id, giocatore_id, stato, aggiornato_il).
- I compleanni possono restare derivati dalla data di nascita dei giocatori.
4. **Statistiche e badge dinamici**
- Tabella `statistiche` o `azioni_scout` (evento_id, giocatore_id, tipo azione, valore, creato_il).
- I badge vengono calcolati in tempo reale dalle statistiche accumulate, senza valori fissi in `crapp-data.ts`.
5. **Scout live collegato ai dati reali**
- Le azioni registrate in `scout.tsx` devono scrivere sulle tabelle eventi/statistiche.
- Mantenere il lock di modifica singolo e l'attivazione solo il giorno della partita.
6. **Classifica CSI**
- Tabella `classifica` aggiornata manualmente da un admin o importata dal sito CSI quando disponibile.
- Per ora nessuna API CSI ufficiale: si inserisce a mano o si copia/incolla.
7. **Notifiche push definitive**
- Verificare che i cron job inviino correttamente i promemoria palloni.
- Aggiungere notifiche per conferma eventi, promemoria presenze, MVP votabile.
8. **Ruoli e permessi**
- Definire chi può creare/modificare eventi (capitano/admin).
- Chi può fare scout (designato per partita).
- Chi può modificare i turni palloni (tutti, come da tua richiesta).
## Cosa resta salvato nel Cloud
Sì: tutto ciò che viene scritto sulle tabelle Lovable Cloud/Supabase resta salvato online e condiviso tra tutti i dispositivi.
I dati demo in `src/lib/crapp-data.ts` invece no: sono file statici, quindi ogni aggiornamento dell'app li sovrascrive e ogni telefono li vede identici.
## Decisioni da prendere insieme
- Vuoi abilitare login email/password per ogni giocatore, o preferisci mantenere la selezione "Chi sei?" senza password per semplicità?
- Chi gestirà inserimento eventi e aggiornamento classifica: solo alcuni o tutta la squadra?
- Vuoi procedere per fasi (prima rosa + calendario + presenze, poi statistiche) o tutto insieme?
@@ -1,25 +0,0 @@
# Ridimensionamento badge nella lista squadra
## Obiettivo
Rendere i badge accanto al nome del giocatore nella lista squadra più compatti e meno invasivi, mantenendo lo stile stilizzato (icone Lucide colorate per grado) e lasciando la scheda espansa con una dimensione leggibile.
## Modifiche previste
1. **Lista squadra (`src/routes/squadra.tsx`)**
- Ridurre le icone badge sbloccati mostrate accanto al nome da `h-4 w-4` (16px) a `h-3 w-3` (12px).
- Mantenere il massimo di 3 badge visibili e il contatore `+N` con testo ridotto a `text-[9px]` per armonizzare.
- Lasciare invariati avatar, nome, ruolo ed età.
2. **Scheda giocatore espansa (`src/routes/squadra.tsx`)**
- Portare le icone badge dalla dimensione attuale a `h-4 w-4` (16px), leggibili ma non troppo grandi.
- Mantenere card, colori per grado (bronzo/argento/oro) e testi descrittivi.
3. **Verifica**
- Controllare la preview su `/squadra` per confermare che i badge in lista siano discreti e la scheda espansa rimanga leggibile.
- Eseguire build per assicurarsi che non ci siano errori di tipo o stile.
## Cosa non cambia
- Colori dei gradi, soglie badge, logica di sblocco e votazione MVP.
- Layout generale della pagina e bottom navigation.
@@ -1,14 +0,0 @@
# Rimuovere placeholder "Livello 7" dal Profilo
## Obiettivo
Eliminare il testo statico "Livello 7" dalla scheda profilo, dato che non è collegato a nessun calcolo reale e l'utente preferisce toglierlo per ora.
## Modifica
- `src/routes/profilo.tsx`: rimuovere il paragrafo `<p className="font-display text-2xl leading-none">Livello 7</p>` (riga 116) e, se necessario, riallineare il layout circostante per evitare spazi vuoti strani.
## Verifica
- Build senza errori.
- Preview della pagina Profilo: nessun riferimento a "Livello" visibile.
@@ -1,34 +0,0 @@
# Selezione giocatore al primo avvio
Aggiungere un flusso di onboarding che chiede "Chi sei?" la prima volta che l'app viene aperta, memorizzando la scelta in `localStorage`. Il profilo e la home si aggiorneranno automaticamente in base al giocatore selezionato.
## Cosa cambia
1. **Nuovo store `src/lib/user-store.ts`**
- Persiste in `localStorage` l'`id` del giocatore scelto.
- Espone `useGiocatoreCorrente()` che restituisce il giocatore selezionato o `null`.
- Espone `impostaGiocatore(id)` e `resetGiocatore()`.
2. **Nuova route `/benvenuto`**
- Schermata full-screen con logo, titolo "Benvenuto in CrAPP" e lista scrollabile della rosa.
- Ogni riga mostra iniziali, nome, ruolo e numero maglia.
- Al tap su un giocatore, lo store viene aggiornato e l'utente viene portato a `/`.
- Non mostra la bottom navigation.
3. **Reindirizzamento condizionato in `__root.tsx`**
- Se non è ancora stato selezionato un giocatore, qualunque route apre `/benvenuto`.
- Dopo la scelta, l'app funziona normalmente.
4. **Sostituzione di `giocatoreCorrente` con `useGiocatoreCorrente()`**
- Aggiornare `src/routes/index.tsx` per salutare il giocatore selezionato e mostrarne le statistiche rapide.
- Aggiornare `src/routes/profilo.tsx` per renderlo il profilo personale del giocatore scelto.
5. **Cambio utente dalle impostazioni**
- In `src/routes/profilo.tsx`, aggiungere una voce "Cambia giocatore" che resetta la selezione e porta a `/benvenuto`.
## Note tecniche
- `localStorage` viene letto solo lato client, usando `useSyncExternalStore` per evitare mismatch di hydration.
- La rosa reale è già presente in `src/lib/crapp-data.ts` (`giocatori`).
- La costante esportata `giocatoreCorrente` verrà rimossa; i componenti consumeranno il nuovo hook.
- Nessun backend richiesto: resta un prototipo locale.
@@ -1,45 +0,0 @@
# Turno palloni nel calendario + promemoria
Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra tutti, con rotazione automatica proposta dall'app e promemoria (in-app e push).
## Cosa vedrà la squadra
- **Riga "Palloni" su ogni evento** (allenamento e partita) nella card del calendario e in Home: avatar + nome dell'incaricato, oppure "Da assegnare".
- **Chiunque può cambiarlo**: tocco sulla riga, si apre la lista della rosa, si sceglie il nome. La modifica è immediata e visibile a tutti.
- **Proposta automatica a rotazione**: l'app suggerisce chi non ha ancora fatto il turno di recente (o è stato meno volte incaricato). Il suggerimento è solo una proposta: resta sempre modificabile.
- **Storico turni** nella scheda squadra/giocatore: quante volte ciascuno ha portato i palloni.
- **Due promemoria per l'incaricato**:
1. il giorno stesso dell'evento in cui li deve **prendere** a fine allenamento/partita;
2. il giorno dell'evento successivo, per ricordargli di **riportarli**.
- I promemoria arrivano come banner ben visibile in Home e, per chi attiva le notifiche, come notifica push sul telefono.
## Impostazione tecnica
**Backend (Lovable Cloud)**
- Attivazione di Lovable Cloud.
- Tabella `eventi` (spostando i dati demo attuali su database) o, in alternativa minima, tabella `turni_palloni` con `evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`. Scelgo la seconda per limitare il refactor: gli eventi restano in `crapp-data.ts` finché non si passa a calendario dinamico.
- Tabella `push_subscriptions` (giocatore_id, endpoint, chiavi) per le notifiche.
- Grant espliciti + RLS: lettura e scrittura aperte a tutti gli utenti dell'app (nessun login previsto oggi → policy per `anon` limitate a queste tabelle, nessun dato personale sensibile).
- Server functions in `src/lib/palloni.functions.ts`: `getTurni`, `setTurno`, `suggerisciTurno`.
**Frontend**
- Nuovo componente `TurnoPalloni` usato in `EventoCard` e in Home.
- Lettura via TanStack Query (`ensureQueryData` nel loader, `useSuspenseQuery` nel componente), invalidazione dopo la modifica.
- Banner promemoria in Home basato su data odierna + evento successivo, mostrato solo al giocatore selezionato in `user-store`.
**Notifiche push**
- Service worker dedicato al messaging (separato dalla PWA esistente), chiavi VAPID salvate come secret.
- Schermata in Profilo: "Attiva notifiche palloni" con richiesta di permesso.
- Invio schedulato tramite un endpoint `src/routes/api/public/promemoria-palloni.ts` protetto da secret, richiamato una volta al giorno da un job pianificato (pg_cron).
- Nota: su iPhone le notifiche push funzionano solo se l'app è installata dalla schermata Home.
## Ordine di lavoro
1. Attivare Lovable Cloud e creare tabelle + policy.
2. Server functions + UI del turno palloni (assegnazione manuale condivisa).
3. Rotazione automatica suggerita + storico turni.
4. Banner promemoria in-app.
5. Notifiche push + job giornaliero.
-5
View File
@@ -1,5 +0,0 @@
{
"schemaVersion": 1,
"template": "tanstack_start_ts_current",
"revision": "tanstack_start_ts_current-9e5645c506e5"
}
+28 -6
View File
@@ -1,6 +1,6 @@
# Project State
Ultimo aggiornamento: 06/09/2026
Ultimo aggiornamento: 09/09/2026
## Stato generale
@@ -10,7 +10,9 @@ Backend migrato al nuovo Supabase proprietario. Autenticazione Google, dashboard
amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzione su `main`.
Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora
sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati
reali (M9).
reali (M9). Prima versione pre-release rilasciata (0.9.0, vedi `docs/CHANGELOG.md`). Cancellare
un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane da
cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
---
@@ -21,7 +23,8 @@ reali (M9).
- Cursor e Claude Code come ambienti di sviluppo
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
- 23 migration in `supabase/migrations/`, fino a `m12_niente_autovoto`
- 27 migration in `supabase/migrations/`, fino a `m16_funzione_bonifica_dati_evento_orfani`
(09/09/2026)
- Sviluppo locale verificato con il nuovo Supabase
---
@@ -36,8 +39,9 @@ reali (M9).
## Database
- Schema v1.0 e migration da M1 a M12 applicate al nuovo Supabase (`m12_niente_autovoto`
in produzione dal 06/09/2026, verificata con `npx supabase migration list`)
- Schema v1.0 e migration da M1 a M16 applicate al nuovo Supabase
(`m16_funzione_bonifica_dati_evento_orfani` in produzione dal 09/09/2026, verificata con
`npx supabase migration list`)
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`)
più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori
attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
@@ -61,6 +65,19 @@ reali (M9).
scoutate concluse, e collegamento della tabella `scout_sessioni` (già presente nello
schema ma mai usata) al blocco condiviso dello Scout Live — prima entrambi vivevano solo
in `localStorage`, quindi visibili a un solo dispositivo
- Migration `m13_convocati_e_pagelle_chiuse`: le RLS di `mvp_voti`/`pagelle_voti`/
`badge_social_voti` richiedono che votante e votato siano convocati all'evento (e per le
pagelle anche `pagelle_chiuse = false`)
- Migration `m14_pulizia_dati_evento_cancellato` (DD-029): cancellare un evento pulisce a
cascata, tramite trigger, tutte le tabelle collegate (`risposte_presenze`,
`cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`,
`scout_sessioni`, `scout_live`, `scout_partite`)
- Migration `m15_bonifica_dati_evento_orfani`: bonifica una tantum delle righe orfane da
cancellazioni di eventi precedenti a M14, senza toccare i vecchi voti MVP/pagelle/badge
social legati a id Scout o CSI
- Migration `m16_funzione_bonifica_dati_evento_orfani`: la stessa logica di bonifica di M15
resta richiamabile come funzione `bonifica_dati_evento_orfani()` (riservata al service
role), se mai servisse di nuovo
---
@@ -68,13 +85,18 @@ reali (M9).
- Squadra
- Presenze
- Badge
- Badge (incluso badge social)
- Scout Live (blocco e archivio partite sincronizzati tra dispositivi, migration `m7_scout_partite`)
- Pagelle
- MVP
- Obiettivi di squadra
- Turno palloni (specifica in `docs/modules/palloni.md`)
- Infortuni, in forma minima (specifica in `docs/modules/infortuni.md`)
- Notifiche
- Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`)
- Serie di presenze (specifica in `docs/modules/serie-presenze.md`)
- Collegamento CSI: classifica campionato e Coppa, storico e dettaglio partita — formazioni,
scontri diretti (specifica in `docs/modules/collegamento-csi.md`)
---
+9
View File
@@ -55,6 +55,15 @@ Il progetto richiede le seguenti variabili:
- `VITE_SUPABASE_URL`
- `VITE_SUPABASE_PUBLISHABLE_KEY`
Solo per lo sviluppo locale con PocketBase al posto di Supabase (vedi
[docs/PORTABILITA.md](docs/PORTABILITA.md)):
- `VITE_POCKETBASE_URL` / `POCKETBASE_URL``http://127.0.0.1:8090` con
`docker-compose.pocketbase.yml`
- `POCKETBASE_SUPERUSER_EMAIL` / `POCKETBASE_SUPERUSER_PASSWORD` — credenziali del
superuser creato al primo avvio, usate solo dalle route server che oggi usano
`supabaseAdmin`
## Documentazione
Indice in [docs/README.md](docs/README.md). Le regole per gli assistenti AI stanno in
+7 -15
View File
@@ -5,7 +5,6 @@
"": {
"name": "crapp",
"dependencies": {
"@lovable.dev/cloud-auth-js": "^1.1.2",
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.101.1",
@@ -16,6 +15,7 @@
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"motion": "^13.2.0",
"pocketbase": "^0.28.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"sonner": "^2.0.7",
@@ -27,7 +27,7 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@lovable.dev/vite-tanstack-config": "2.8.5",
"@tanstack/devtools-vite": "^0.8.3",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
@@ -130,14 +130,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lovable.dev/cloud-auth-js": ["@lovable.dev/cloud-auth-js@1.1.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", {}, "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg=="],
"@lovable.dev/vite-plugin-dev-server-bridge": ["@lovable.dev/vite-plugin-dev-server-bridge@1.2.1", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-JdADRwpEJA5t0ggXbd96dND23Wsp69Af9lVSV5m7MnMGxJZlPgqAqz7HUKcfJESfI5M5yFZOl9e0x76JMMDOAg=="],
"@lovable.dev/vite-plugin-hmr-gate": ["@lovable.dev/vite-plugin-hmr-gate@1.3.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.3.5.tgz", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-LxCj6JIbYRQ6peMm2aVn/bhHLkwZ5eZ4Cn6gmh7LfjaplqZHlA24nhCowgt6ZLfbckc4d8tSy0He7H8EiFEXag=="],
"@lovable.dev/vite-tanstack-config": ["@lovable.dev/vite-tanstack-config@2.8.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-2.8.5.tgz", { "dependencies": { "@lovable.dev/vite-plugin-dev-server-bridge": "^1.2.1", "@lovable.dev/vite-plugin-hmr-gate": "^1.3.4", "@tanstack/devtools-vite": "^0.8.1", "lightningcss": "^1.30.0" }, "peerDependencies": { "@tailwindcss/vite": ">=4.0.0", "@tanstack/react-start": ">=1.100.0", "@vitejs/plugin-react": ">=4.0.0", "nitro": ">=3.0.260603-beta", "vite": ">=5.0.0 <9.0.0", "vite-tsconfig-paths": ">=6.0.0" }, "optionalPeers": ["nitro"] }, "sha512-qPNxEXjvRsrbJHKgHxuLWpRW/gu2nIM+62qjTUCbCno5nRPMKyJQNsgjh48qjAkS6T0MRIkY/3BENNXeGsi5hw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" } }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="],
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
@@ -424,7 +416,7 @@
"canvas-confetti": ["canvas-confetti@1.9.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@@ -660,6 +652,8 @@
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"pocketbase": ["pocketbase@0.28.1", "", {}, "sha512-/3ihkq+rvfcs0MQgrK4sEElOg6gfenHW9S/O+3BSO+V1yT+4B10+9aT22uTQUPqze9ItwNtwWyY5ZmdgVCTdVA=="],
"postcss": ["postcss@8.5.24", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -800,10 +794,6 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/devtools-bundler-core/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@tanstack/devtools-vite/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@tanstack/router-plugin/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
@@ -820,6 +810,8 @@
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"h3/srvx": ["srvx@0.12.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g=="],
"h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
+1 -1
View File
@@ -4,4 +4,4 @@ saveTextLockfile = true
minimumReleaseAge = 86400
# Each entry bypasses the 24h guard for one package — confirm with the user
# before adding any.
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config", "@lovable.dev/mcp-js", "@lovable.dev/vite-plugin-dev-server-bridge", "@lovable.dev/vite-plugin-hmr-gate", "@lovable.dev/email-js", "@lovable.dev/webhooks-js"]
minimumReleaseAgeExcludes = []
+38
View File
@@ -0,0 +1,38 @@
# PocketBase locale per lo sviluppo (sostituisce "npx supabase start" solo in locale,
# vedi docs/PORTABILITA.md e la voce DD relativa). La produzione resta su Supabase.
#
# Uso:
# docker compose -f docker-compose.pocketbase.yml up -d # avvia
# docker compose -f docker-compose.pocketbase.yml down # ferma
# docker compose -f docker-compose.pocketbase.yml down -v # ferma e azzera i dati locali
#
# Dashboard admin: http://127.0.0.1:8090/_/
# API: http://127.0.0.1:8090/api/
services:
pocketbase:
image: ghcr.io/muchobien/pocketbase:latest
container_name: crapp-pocketbase
restart: unless-stopped
# --automigrate=0: le modifiche fatte da dashboard (es. abilitare un provider OAuth2,
# con relativo client secret) NON devono finire come file di migration versionati in
# git. Le migration restano solo quelle scritte a mano in pocketbase/pb_migrations/.
command: ["--automigrate=0"]
env_file:
- .env
environment:
# L'immagine crea il superuser da queste variabili a ogni avvio (idempotente):
# sopravvive a "npm run pocketbase:reset" senza passaggi manuali.
PB_ADMIN_EMAIL: ${POCKETBASE_SUPERUSER_EMAIL:-}
PB_ADMIN_PASSWORD: ${POCKETBASE_SUPERUSER_PASSWORD:-}
ports:
- "8090:8090"
volumes:
- ./pocketbase/pb_data:/pb_data
- ./pocketbase/pb_migrations:/pb_migrations
- ./pocketbase/pb_hooks:/pb_hooks
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8090/api/health"]
interval: 5s
timeout: 3s
retries: 5
+75 -73
View File
@@ -1,84 +1,86 @@
# Changelog
Tutte le modifiche significative del progetto, dalla più recente. Il formato segue
[Keep a Changelog](https://keepachangelog.com/it/1.1.0/) e la numerazione
[Semantic Versioning](https://semver.org/lang/it/): il numero di versione è quello di
`package.json`.
Le modifiche rilevanti di CrAPP sono documentate qui, in ordine di rilascio. Il formato
segue [Keep a Changelog](https://keepachangelog.com/it/1.1.0/): ogni versione ha una data
e le voci sono divise per categoria (Aggiunto, Modificato, Sicurezza...). L'elenco
completo delle funzionalità, fatte e previste, sta in [ROADMAP.md](ROADMAP.md); qui si
registra solo _quando_ una voce è stata rilasciata e con quale versione.
L'elenco delle funzionalità disponibili e previste non si ripete qui: sta in
[ROADMAP.md](ROADMAP.md), che elenca il _cosa_ senza numeri di versione — quelli stanno solo
qui.
Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Il progetto è pre-1.0 (`0.y.z`):
finché resta sotto `1.0.0` un aumento di `y` può includere anche cambi non compatibili
all'indietro.
## [Non rilasciato] — 0.9.0
## [Non rilasciato]
Prima versione, pre-release.
## [0.9.2] - 2026-09-10
### Aggiunto
- Login con Google tramite Supabase Auth e collegamento automatico dell'account al proprio
giocatore confrontando l'email (DD-011, DD-018; migration `m5_email_giocatori_squadra`):
senza sessione non si entra in nessuna schermata.
- Dashboard amministratore `/admin`: stato dei profili, download di documento, certificato e
foto tessera, export CSV per il tesseramento, aggiunta e disattivazione dei giocatori
(la riga non viene mai eliminata, così presenze, voti e badge restano agganciati al suo id).
- Profilo giocatore: dati anagrafici, documento, certificato medico e foto tessera con le
relative scadenze (migration `m2_profili_giocatore`, bucket privato), divisi nelle tab
Stagione, Documenti e Opzioni — [modules/profilo-giocatore.md](modules/profilo-giocatore.md).
- Tracciamento del tesseramento CSI: numero e data di tessera in `giocatori_squadra`, badge e
contatore in dashboard (migration `m8_tesseramento_csi`).
- Foto profilo condivise tra dispositivi tramite il bucket pubblico `avatar-giocatori`
(migration `m6_avatar_giocatori`).
- Serie di presenze calcolate sui dati reali (`serieConsecutiva()`), con la colonna
`risposto_il` che congela l'istante della **prima** risposta tramite trigger (migration
`m9_risposte_presenze_risposto_il`): sblocca la serie "Conferme 24h" e i badge "Risposta
lampo" e "Mai un forfait" — [modules/serie-presenze.md](modules/serie-presenze.md).
- Scout Live sincronizzato tra dispositivi: il blocco "chi sta scoutando" passa dalla tabella
`scout_sessioni` e le partite concluse vengono archiviate in `scout_partite` (migration
`m7_scout_partite`). Si apre dalla sezione «Scout live» di `/partita/$id`, solo il giorno
della partita, e può usarlo chiunque sia autenticato: uno per volta, grazie al lock.
- Votazione MVP legata all'evento CrAPP e non al referto CSI o allo Scout: si apre due ore
dopo `data`+`ora` della partita, anche senza risultato caricato — [modules/mvp.md](modules/mvp.md).
- Sondaggio pre-partita aperto dalle 8:00 del giorno della partita fino al fischio d'inizio
(poi resta chiuso, anche nei giorni successivi) e pulsante «Avvisa tutti del sondaggio» per
gli amministratori (`POST /api/public/apri-sondaggio`); nessun cron, l'invio è manuale.
- Turni palloni con rotazione automatica sulle partite e assegnazione manuale per gli
allenamenti, che restano «da assegnare» finché non si sceglie (migration M10).
- Notifiche push con il testo cifrato **dentro** la push (`aes128gcm`, RFC 8291), così
arrivano anche ad app chiusa e a schermo bloccato (DD-026).
- Collegamento CSI: classifica e risultati ufficiali letti dal portale Livescore CSI Bologna
(stagione 2025/26, Open Misto Eccellenza, Girone B) —
[modules/collegamento-csi.md](modules/collegamento-csi.md).
- Note dell'evento visibili in `/allenamento/$id` e `/partita/$id`, con gli a capo mantenuti.
- «Segnala un bug» e «Suggerisci una nuova funzionalità» in `/profilo`: due link che aprono
una issue GitHub sul template giusto, senza tabelle né schermate di gestione.
- Interfaccia accessibile: contrasto dei token colore sopra 4.5:1, `viewport-fit=cover` e
`theme-color` coerenti con un'app chiara, `lang="it"`, `:focus-visible` globale, tocchi da
44px, `aria-current`/`aria-pressed`/`aria-controls`/`aria-busy`, niente testo sotto i 12px.
- Movimento con molle interrompibili di `motion` al posto delle `@keyframes` a durata fissa,
swipe fra i mesi del calendario e barre di progresso che misurano l'avanzamento tra un
traguardo e il successivo (DD-021).
- Suite di test in `test/` (unit, integration, end-to-end) eseguita con bun e senza nuove
dipendenze: `npm run test` e `npm run test:all`.
- Convenzioni interne: primitive condivise (`Card`, `Campo`, `classiInput` in `ui-bits`),
cache aggiornata con `setQueryData` invece di rileggere il database dopo ogni scrittura, e
logica pura estratta in `src/lib/` con i suoi test.
- Infrastruttura di sviluppo: migrazione da Lovable a sviluppo locale, repository GitHub
indipendente, deploy automatico su Vercel.
- **Dashboard amministratore** — nuova tab "Notifiche" che mostra quanti giocatori hanno
le notifiche push attive e chi sono.
### Modificato
- **Dashboard amministratore** — le sezioni impilate diventano un menu di tab scorrevole a
pillole (come Squadra e Campionato); nell'elenco Profili resta aperta una sola scheda
alla volta.
- **Profilo** — testi dei campi amministrativi semplificati (label email, rimossa la nota
su chi vede quei dati).
### Rimosso
- Le dipendenze e il codice legati all'editor Lovable (login social e reporting errori
verso l'editor): l'app non ci gira più.
## [0.9.1] - 2026-09-10
### Modificato
- **Storico partite** — ogni scheda mostra il logo accanto al nome di entrambe le squadre
(CRAP e avversario), risultato e parziali in ordine casaospite (verde/rosso restano
vittoria/sconfitta CRAP) e un chevron a destra per chiarire che la riga apre il dettaglio.
## [0.9.0] - 2026-09-09
Prima versione pre-release: lo sviluppo precedente non era versionato a parte, quindi
questa release riunisce tutto ciò che l'app fa oggi in produzione.
### Aggiunto
- **Gestione squadra** — rosa dei giocatori con ruoli e dati anagrafici di base.
- **Profilo Giocatore** — dati personali e amministrativi, documento d'identità,
certificato medico (caricamento, scadenza, stato, download) e foto tessera in
un'unica schermata, sia lato giocatore sia lato amministratore; lo storico dei
certificati resta un'estensione futura.
- **Gestione tesseramenti CSI** — raccolta dei dati richiesti dal CSI, tracciamento di chi
è già tesserato (numero e data tessera) ed export CSV per il tesseramento.
- **Calendario** — eventi di allenamento e partita, con schermata di dettaglio dedicata.
- **Presenze** — conferma o rifiuto della partecipazione a un evento, visibile a tutta la
squadra al posto di chat e fogli condivisi.
- **Serie di presenze** — tre serie (presenze, conferme, allenamenti) calcolate sui dati
reali della rosa.
- **Scout Live** — un solo referente alla volta registra in tempo reale le azioni di gioco
durante la partita.
- **Badge** — gamification con gradi bronzo/argento/oro, badge segreti e badge social
votati tra compagni.
- **Pagelle** — voto tra compagni (1-10) a fine partita, con media personale e di squadra.
- **Votazione MVP** — elezione del migliore in campo della partita tramite voto tra
compagni, un voto a testa.
- **Obiettivi di squadra** — traguardi collettivi che avanzano con presenze, risposte alle
convocazioni, pagelle e risultati di campionato.
- **Turno palloni** — rotazione condivisa e promemoria di chi porta e riporta i palloni ad
allenamenti e partite.
- **Notifiche push** — promemoria intelligenti su un unico opt-in per dispositivo.
- **Dashboard amministratore** — vista aggregata su tesseramenti, certificati, presenze e
dati della rosa, con download CSV.
- **Collegamento CSI** — classifica di campionato e Coppa, storico partite e dettaglio di
ogni gara (formazioni, storico scontri diretti, probabilità di vittoria calcolata dal
CSI) letti in tempo reale dal portale ufficiale Livescore CSI Bologna, senza inserimento
manuale da parte degli amministratori.
- **Infortuni** — conteggio degli eventi saltati per infortunio, riusando lo stato di
presenza già registrato per le convocazioni.
### Sicurezza
- Migration `m4_solo_autenticati`: tolto al ruolo `anon` l'accesso alle tabelle dell'app
(applicata in produzione il 03/09/2026).
- I permessi di amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`): senza,
basterebbe scegliere il nome giusto per amministrare.
- Migration `m11_scritture_per_ruolo`: ogni voto è firmato con lo slot collegato all'account
(DD-023); il collegamento account → giocatore non è modificabile dal giocatore stesso
(DD-016).
- Migration `m12_niente_autovoto`: i vincoli `mvp_no_autovoto` e `badge_social_no_autovoto`
rifiutano l'auto-voto anche a chi scrive direttamente su PostgREST, come già faceva
`pagelle_voti`.
- Al voto MVP partecipano solo i presenti (o in ritardo) di quell'evento; il filtro è
applicativo, non RLS ([modules/mvp.md](modules/mvp.md)).
- La suite copre i rifiuti `401` di `richiediAdmin` (DD-024), i permessi di
`badge_social_voti` e le deroghe admin di M11, e verifica che un ripensamento non riscriva
`risposto_il` (trigger di `m9`).
- Autenticazione tramite Google via Supabase Auth, unico metodo di accesso; permessi
differenziati per ruolo (giocatore/amministratore) su tabelle e route.
+5 -5
View File
@@ -15,7 +15,7 @@ nessuna di esse da M4 (DD-011).
| ---------------------------------------------------------------- | ------------------------------------------------------------------- |
| `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) |
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) |
@@ -46,7 +46,7 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. |
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
@@ -63,9 +63,9 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
| Tabella | Scopo | Note |
| ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`). |
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli della v1.0. |
| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`). |
| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli originari della tabella; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. |
| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
## Turni e notifiche
+214 -29
View File
@@ -25,13 +25,13 @@ Serve a rispondere a domande del tipo:
| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile |
| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB |
| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli |
| [DD-009](#dd-009--tesseramento-csi-manuale-in-v11-integrazione-api-in-v20) | CSI manuale v1.1, API v2.0 |
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati-in-v1) | Niente storico certificati v1 |
| [DD-009](#dd-009--tesseramento-csi-manuale-integrazione-api-in-una-fase-successiva) | CSI manuale, poi integrazione API |
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati) | Niente storico certificati |
| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo |
| [DD-012](#dd-012--non-migrare-gli-id-giocatore-in-v11) | Non migrare ID in v1.1 |
| [DD-012](#dd-012--rimandare-la-migrazione-degli-id-giocatore) | Rimandare la migrazione ID |
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB |
| [DD-016](#dd-016--schema-dati-profilo-giocatore-v11-f0) | Schema dati Profilo Giocatore v1.1 |
| [DD-016](#dd-016--schema-dati-profilo-giocatore-f0) | Schema dati Profilo Giocatore |
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email |
| [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) | Il branch lo decide l'utente |
@@ -42,6 +42,9 @@ Serve a rispondere a domande del tipo:
| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate |
| [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale |
| [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato |
| [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati |
| [DD-028](#dd-028--soglia-minima-di-campione-per-media-voto-e-mvp-in-home) | Soglia minima Media voto e MVP |
| [DD-029](#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati) | Pulizia a cascata evento cancellato |
**In valutazione**
@@ -141,7 +144,7 @@ Ogni nuova funzionalità significativa viene prima **progettata e documentata**
**Conseguenze**
- Rallenta leggermente lavvio di nuove feature, ma riduce rework e discussioni infinite.
- I moduli v1.0 vanno retro-documentati quando possibile.
- I moduli preesistenti vanno retro-documentati quando possibile.
- Nessuna feature non documentata entra in produzione.
**Riesame**
@@ -184,7 +187,7 @@ indietro. Vedi DD-019.
**Stato:** Accettata
**Contesto**
CrAPP v1.0 è già usata dalla squadra per presenze, calendario, scout, badge e notifiche. Rischiare regressioni su moduli funzionanti vanifica la fiducia degli utenti.
CrAPP è già usata dalla squadra per presenze, calendario, scout, badge e notifiche. Rischiare regressioni su moduli funzionanti vanifica la fiducia degli utenti.
**Decisione**
Le nuove versioni **introducono** funzionalità. Non si riscrive un modulo già operativo salvo richiesta esplicita e pianificata.
@@ -305,34 +308,34 @@ Se la squadra chiede esplicitamente classifiche tecniche per ruolo.
---
### DD-009 — Tesseramento CSI manuale in v1.1, integrazione API in v2.0
### DD-009 — Tesseramento CSI manuale, integrazione API in una fase successiva
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
La v1.1 deve aiutare gli admin a raccogliere documenti e dati per il tesseramento CSI. Un collegamento automatico al sistema CSI è complesso e non urgente.
Il modulo Profilo Giocatore deve aiutare gli admin a raccogliere documenti e dati per il tesseramento CSI. Un collegamento automatico al sistema CSI è complesso e non urgente.
**Decisione**
- **v1.1:** profilo completo, dashboard admin, download documenti, export CSV con i campi richiesti dal CSI.
- **v2.0:** eventuale collegamento automatico a CSI (calendario, risultati, classifica ufficiale).
- **Prima fase:** profilo completo, dashboard admin, download documenti, export CSV con i campi richiesti dal CSI.
- **Fase successiva:** eventuale collegamento automatico a CSI (calendario, risultati, classifica ufficiale).
**Alternative scartate**
- Integrazione CSI già in v1.1 → scope troppo ampio, dipendenza da API esterne non controllate.
- Integrazione CSI già nella prima fase → scope troppo ampio, dipendenza da API esterne non controllate.
**Conseguenze**
- Gli admin guadagnano subito tempo (niente più Excel e chat per i documenti).
- Lexport CSV deve essere affidabile e completo: è il deliverable chiave della v1.1.
- Lexport CSV deve essere affidabile e completo: è il deliverable chiave di questa prima fase.
**Riesame**
Quando il CSI mette a disposizione API stabili o quando il volume di tesseramenti giustifica lautomazione.
---
### DD-010 — Profilo giocatore: niente storico certificati in v1
### DD-010 — Profilo giocatore: niente storico certificati
**Data:** agosto 2026
**Stato:** Accettata
@@ -341,7 +344,7 @@ Quando il CSI mette a disposizione API stabili o quando il volume di tesserament
Il certificato medico va aggiornato ogni stagione. Tenere lo storico di tutte le versioni complica upload, storage e privacy.
**Decisione**
In v1 il giocatore può **sovrascrivere** certificato e data di scadenza. Lo storico delle versioni precedenti non viene conservato.
Il giocatore può **sovrascrivere** certificato e data di scadenza. Lo storico delle versioni precedenti non viene conservato.
**Alternative scartate**
@@ -367,7 +370,7 @@ Se il CSI o il regolamento interno richiedono conservazione storica.
Oggi lapp identifica lutente con la selezione del giocatore da una lista, senza login. Documenti, certificati e dati personali richiedono sapere _chi_ sta operando e impedire accessi non autorizzati.
**Decisione**
Prima di completare il modulo Profilo Giocatore (v1.1), introdurre **login con Google o email** tramite Supabase Auth — non tramite Lovable Auth. Dopo il login, il giocatore associa il proprio profilo squadra.
Prima di completare il modulo Profilo Giocatore, introdurre **login con Google o email** tramite Supabase Auth — non tramite Lovable Auth. Dopo il login, il giocatore associa il proprio profilo squadra.
**Alternative scartate**
@@ -385,7 +388,7 @@ Dopo il rollout auth, se emergono problemi di adozione (giocatori poco digitali)
---
### DD-012 — Non migrare gli ID giocatore in v1.1
### DD-012 — Rimandare la migrazione degli ID giocatore
**Data:** agosto 2026
**Stato:** Accettata
@@ -394,11 +397,11 @@ Dopo il rollout auth, se emergono problemi di adozione (giocatori poco digitali)
Lapp usa identificativi semplici (`g1`, `g2`, …) collegati a presenze, voti, palloni e altre funzioni già in uso. Nel database esiste anche una tabella `giocatori` con UUID, non collegata al codice attuale.
**Decisione**
Per la v1.1 **non** unificare gli ID. I nuovi dati del profilo si agganciano agli identificativi già in uso. La migrazione verso UUID resta un lavoro separato, pianificato e testato.
Per ora **non** unificare gli ID. I nuovi dati del profilo si agganciano agli identificativi già in uso. La migrazione verso UUID resta un lavoro separato, pianificato e testato.
**Alternative scartate**
- Migrare tutto a UUID in v1.1 → rischio alto di rompere presenze, voti, scout e notifiche.
- Migrare subito tutto a UUID → rischio alto di rompere presenze, voti, scout e notifiche.
**Conseguenze**
@@ -406,7 +409,7 @@ Per la v1.1 **non** unificare gli ID. I nuovi dati del profilo si agganciano agl
- `DATABASE.md` va tenuto aggiornato su cosa è “attivo” e cosa è “futuro”.
**Riesame**
Quando la v1.1 è stabile e c’è tempo per una migration con checklist regressioni completa.
Quando il modulo Profilo Giocatore è stabile e c’è tempo per una migration con checklist regressioni completa.
---
@@ -435,16 +438,16 @@ Se si adotta un servizio che viola questa regola.
---
### DD-016 — Schema dati Profilo Giocatore v1.1 (F0)
### DD-016 — Schema dati Profilo Giocatore (F0)
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
La progettazione F0 del modulo Profilo Giocatore ha definito come persistere dati personali, documenti e certificati, in coesistenza con lanagrafica attuale (`g1``g17` nel codice) e con la tabella `giocatori` UUID già presente ma non usata. Serviva una scelta chiara su dove salvare i dati, come collegare lautenticazione e come proteggere documenti sensibili — senza toccare le tabelle v1.0 già operative.
La progettazione F0 del modulo Profilo Giocatore ha definito come persistere dati personali, documenti e certificati, in coesistenza con lanagrafica attuale (`g1``g17` nel codice) e con la tabella `giocatori` UUID già presente ma non usata. Serviva una scelta chiara su dove salvare i dati, come collegare lautenticazione e come proteggere documenti sensibili — senza toccare le tabelle preesistenti già operative.
**Decisione**
Per la v1.1 si introducono **due nuove tabelle additive**:
Si introducono **due nuove tabelle additive**:
- **`giocatori_squadra`** — anagrafica squadra con ID testuali (`g1``g17`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento account (`auth_user_id`).
- **`profili_giocatore`** — dati personali, metadati documento identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`.
@@ -455,8 +458,8 @@ Regole vincolanti:
2. Lassociazione **`auth_user_id` ↔ giocatore** è unoperazione **controllata e atomica** (es. al primo accesso da `/benvenuto`, con `UPDATE … WHERE auth_user_id IS NULL`). Il giocatore **non può modificare liberamente** `auth_user_id`; solo un admin può resettarlo in casi eccezionali.
3. I file (documento identità, certificato, foto tessera) vivono nel bucket Storage **`profili-giocatore`**, configurato come **privato**.
4. Documenti personali e sanitari **non devono mai essere esposti tramite URL pubblici**. Accesso solo tramite client autenticato con policy RLS, o signed URL a scadenza breve per download admin.
5. Le **tabelle v1.0 esistenti non vengono modificate** (`eventi_app`, `risposte_presenze`, voti, palloni, scout, push, ecc.). Il profilo si aggancia agli ID `g1``g17` già in uso, senza migrare verso UUID in v1.1 (coerente con DD-012).
6. La tabella `giocatori` (UUID) resta **invariata e non usata** dal modulo profilo in v1.1.
5. Le **tabelle preesistenti non vengono modificate** (`eventi_app`, `risposte_presenze`, voti, palloni, scout, push, ecc.). Il profilo si aggancia agli ID `g1``g17` già in uso, senza migrare verso UUID per ora (coerente con DD-012).
6. La tabella `giocatori` (UUID) resta **invariata e non usata** dal modulo profilo.
**Alternative scartate**
@@ -464,21 +467,21 @@ Regole vincolanti:
- Salvare file come base64 nel database → ingestibile, difficile da gestire e da scaricare.
- Bucket pubblico con URL permanenti → inaccettabile per dati sanitari e documenti didentità.
- Permettere al giocatore di cambiare `auth_user_id` liberamente → rischio di impersonazione e race condition.
- Modificare tabelle v1.0 per aggiungere FK verso il profilo → viola DD-004 e DD-012.
- Modificare le tabelle preesistenti per aggiungere FK verso il profilo → viola DD-004 e DD-012.
**Conseguenze**
- Coesistono temporaneamente tre rappresentazioni dellanagrafica: `crapp-data.ts` (fallback), `giocatori_squadra` (target), `giocatori` UUID (dormiente).
- `src/lib/rosa.ts` legge dal database e ricade su `crapp-data.ts` in caso di errore o assenza dati (DD-015).
- Il completamento profilo (30/30/30/10) si calcola in app, non si persiste nel database.
- Lo storico certificati non viene conservato in v1 (coerente con DD-010).
- Lo storico certificati non viene conservato (coerente con DD-010).
- Le migration M1M2 (tabelle, RLS, bucket) restano **additive**: solo `CREATE`, nessun `ALTER`/`DROP` su schema esistente.
- Raffina e attua quanto proposto in DD-015 per la rosa anagrafica, senza sostituire formalmente quella voce.
**Riesame**
- Quando `giocatori_squadra` è stabile in produzione e il fallback `crapp-data.ts` non serve più.
- Quando si pianifica la convergenza verso UUID (DD-012, post v1.1).
- Quando si pianifica la convergenza verso UUID (DD-012, dopo che il profilo sarà stabile).
- Se il CSI o il regolamento richiedono conservazione storica documenti o consensi privacy dedicati.
---
@@ -570,7 +573,7 @@ Unificare gradualmente sul modello autenticato, dopo auth e profilo stabili.
Rischio regressioni su calendario e presenze, moduli più usati della squadra.
**Riesame previsto**
Post v1.1, con migration e test dedicati.
Dopo che il modulo Profilo Giocatore sarà stabile, con migration e test dedicati.
---
@@ -759,7 +762,7 @@ spesso da rendere il tema scuro una funzione e non un vezzo.
**Stato:** Accettata
**Contesto**
Le tabelle della v1.0 sono nate con policy `USING (true)` per `anon, authenticated`. M4
Le tabelle preesistenti sono nate con policy `USING (true)` per `anon, authenticated`. M4
(DD-011) ha tolto il GRANT ad `anon`, e la cosa è stata letta come «ora è chiuso». Non lo
era: per gli autenticati non c'era rimasto nessun limite. Verificato sul database locale con
un utente appena creato, senza ruolo e senza slot nella rosa: `POST /rest/v1/eventi_app`
@@ -970,3 +973,185 @@ prima di inviare.
**Riesame**
Se servisse mandare payload più grandi del limite del protocollo, o se un servizio push
smettesse di accettare corpi cifrati (nessuno lo fa: è lo standard).
### DD-027 — Chi vota deve essere convocato, non solo autenticato come sé stesso
**Stato:** accettata · **Data:** 8 settembre 2026
**Contesto**
Un audit del modulo Badge (`docs/modules/badge.md`) ha trovato due filtri rimasti solo
applicativi dopo DD-023: la policy di M11 garantisce che `votante_id` sia lo slot collegato
all'account di chi scrive, ma non controlla che **votante e votato fossero convocati**
all'evento — un utente che scrive direttamente su PostgREST (bypassando l'interfaccia) poteva
votare o essere votato in una partita a cui non aveva partecipato, gonfiando `mediaVoto`,
`mvp` o un badge social a piacere. Allo stesso modo, `eventi_app.pagelle_chiuse` nascondeva
solo i bottoni in UI: un voto pagella "fuori tempo" restava tecnicamente possibile.
**Decisione**
La policy "Ognuno gestisce i propri voti ..." di `pagelle_voti`, `mvp_voti` e
`badge_social_voti` (M11) guadagna un controllo aggiuntivo tramite la funzione
`evento_permette_voto()` (migration `m13_convocati_e_pagelle_chiuse`): verifica che sia
`votante_id` sia `votato_id` compaiano in `eventi_app.convocati` per quel `match_id`
(`convocati` vuoto = tutta la rosa, la stessa convenzione di `convocatiEvento()` in
`eventi.ts`), e — solo per le pagelle — che `pagelle_chiuse` sia falso. Le policy admin
restano invariate e permissive: un amministratore deve poter correggere un voto anche fuori
convocazione o dopo la chiusura.
Il controllo si ferma alla **convocazione**, non alla **presenza reale**: per l'MVP, ad
esempio, l'interfaccia limita già il voto ai soli presenti/in ritardo
(`usePresenzeEvento`), un filtro più stretto che resta solo applicativo — un convocato ma
assente passa ancora a livello database. Stringere fino a quel punto avrebbe richiesto
leggere `risposte_presenze` dentro la policy, un salto di complessità non giustificato
dall'audit che ha originato questa decisione.
**Alternative scartate**
- Un trigger `BEFORE INSERT/UPDATE` invece di RLS → si applicherebbe anche alla service key
e agli admin, bloccando correzioni legittime fuori convocazione; la RLS, applicata solo
alla policy non-admin, li esclude naturalmente.
- Controllare anche la presenza reale (`risposte_presenze`), non solo la convocazione →
scope maggiore del gap trovato in audit, e specifico dell'MVP (pagelle e badge social non
hanno un concetto di "presente" distinto da "convocato" nell'interfaccia attuale).
**Conseguenze**
- Un evento senza `convocati` esplicito (lista vuota, il caso più comune oggi) non cambia
comportamento: tutta la rosa resta votabile, come prima.
- I test di `test/integration/permessi.test.ts` sono la definizione eseguibile anche di
questa parte della tabella dei permessi (voti non convocati rifiutati, `pagelle_chiuse`
rifiutata a database, controlli positivi che provano che un voto legittimo passa ancora).
- Resta un gap conosciuto e documentato (non quello risolto qui): che il votante fosse
davvero presente, non solo convocato, per MVP/pagelle/badge social.
**Riesame**
Se un giorno servisse bloccare anche il voto di un convocato-ma-assente a livello database,
non solo in UI.
### DD-028 — Soglia minima di campione per Media voto e MVP in home
**Data:** 9 settembre 2026
**Stato:** Accettata
**Contesto**
Un audit della sezione «Colpo d'occhio» in home (`index.tsx`, StatTile Presenze/Media
voto/MVP) ha trovato che due delle tre statistiche non avevano nessun minimo campionario:
`mediePagelle()` calcola una media aritmetica pura, così un giocatore con un solo voto da 10
mostrava "10" in home, più alto di un titolare con 40 voti e media 7.2 — lo stesso problema
che il badge Pagellone già risolve con `VOTI_MINIMI_PAGELLA` (badge.md), ma applicato solo al
badge, non alla StatTile home. Allo stesso modo `mvpVintiPerGiocatore()`/`vincitoriMvp()`
assegnavano un MVP di partita anche con un solo voto totale: bastava che un solo giocatore
votasse perché il votato "vincesse" nettamente, senza nessun quorum di partecipazione.
**Decisione**
- **Media voto** in home usa la stessa soglia del badge Pagellone: sotto `VOTI_MINIMI_PAGELLA`
(5) voti ricevuti, la StatTile mostra `—` invece della media, tramite la funzione pura
`mediaVotoColpoDOcchio()` (`pagelle.ts`), estratta dalla route per restare testabile (DD-020).
La funzione `mediePagelle()` non cambia: il filtro resta lato chiamante, come già faceva il
badge.
- **MVP**: `conteggioPartita`'s aggregazione, tramite `vincitoriMvp()` e
`mvpVintiPerGiocatore()`, richiede ora un quorum minimo di voti totali sulla partita
(`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`) prima di assegnare un vincitore, oltre alla regola già
esistente del margine netto tra primo e secondo. Un solo voto non basta più a incoronare
nessuno, nemmeno in assenza di concorrenza.
**Alternative scartate**
- Alzare la soglia MVP oltre 2 (es. metà dei convocati) → serve conoscere i convocati
dell'evento dentro una funzione che oggi lavora solo sui voti; complessità non giustificata
per il gap trovato in audit.
- Lasciare l'MVP senza quorum e limitarsi al fix della Media voto → il problema di fondo
(un numero esiguo di voti che decide una statistica mostrata come solida) resterebbe aperto
per l'MVP.
**Conseguenze**
- Alcuni MVP di partita già assegnati con un solo voto totale non contano più nel conteggio
`mvp` del giocatore: è una modifica retroattiva al dato mostrato, non solo al calcolo futuro,
perché `mvpVintiPerGiocatore()` deriva sempre il conteggio dai voti grezzi, senza storico
persistito a parte.
- `mediePagelle()` resta invariata: chi la chiama altrove (profilo, squadra) senza applicare la
soglia continua a mostrare la media grezza — non tocca questa decisione, resta il limite già
noto in [pagelle.md](modules/pagelle.md).
**Riesame**
Se la squadra segnala che il quorum di 2 voti per l'MVP è troppo permissivo o troppo severo, o
se si vuole applicare la stessa soglia di Media voto anche alle StatTile di profilo e squadra.
### DD-029 — Cancellare un evento pulisce a cascata i dati collegati
**Data:** 9 settembre 2026
**Stato:** Accettata
**Contesto**
Un audit del modulo Obiettivi ha verificato che gli obiettivi in sé non hanno bisogno di
nessuna pulizia quando un evento viene cancellato: sono ricalcolati a runtime sull'elenco
eventi corrente (`obiettivi.ts`), quindi un evento sparito da `eventi_app` semplicemente
smette di contare. Il problema è un livello sotto: `useEliminaEvento()`
(`src/lib/eventi.ts`) cancella solo la riga in `eventi_app`. Nessuna delle otto tabelle
collegate (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`,
`badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) ha mai
avuto una foreign key verso `eventi_app(id)`: le loro righe restavano orfane a database.
Oggi è innocuo per le statistiche, perché nessun calcolo legge quelle tabelle se non partendo
dall'elenco eventi corrente. Ma è un rischio latente: un id evento futuro identico a uno
passato (generato come `"e" + timestamp`, collisione improbabile ma non impossibile)
erediterebbe dati vecchi non suoi; e un calcolo futuro che iterasse direttamente una di quelle
tabelle, invece di partire da `eventi_app`, conterebbe anche le righe orfane.
**Decisione**
Migration `m14_pulizia_dati_evento_cancellato`: un trigger `AFTER DELETE ON eventi_app`
cancella a cascata le righe corrispondenti (`evento_id`/`match_id = id evento cancellato`)
nelle otto tabelle collegate, tramite una funzione `SECURITY DEFINER`. Agisce a database, non
in `useEliminaEvento()`: protegge anche chi cancella un evento scrivendo direttamente su
PostgREST, non solo chi passa dal bottone dell'app.
**Alternative scartate**
- Foreign key con `ON DELETE CASCADE` verso `eventi_app(id)` → non applicabile subito:
`mvp.md` documenta che storicamente `match_id` in `mvp_voti`/`pagelle_voti`/
`badge_social_voti` a volte conteneva l'id di una sessione Scout o di una partita CSI, non
l'id evento CrAPP. Un vincolo FK avrebbe rifiutato la migration alla prima riga storica
disallineata; il trigger non valida i dati esistenti, solo le cancellazioni da qui in avanti.
- Cancellazione manuale nelle otto tabelle dentro `useEliminaEvento()` → fragile: va tenuta
aggiornata a mano ogni volta che un nuovo modulo aggiunge una tabella con `evento_id`, e non
protegge chi scrive/cancella direttamente su PostgREST.
- Bonificare anche le righe orfane già esistenti nella stessa migration → rimandato: tocca dati
reali già scritti, è un intervento più delicato che merita una migration a sé, non urgente
perché quelle righe sono già invisibili a ogni calcolo attuale.
**Conseguenze**
- Da questa migration in poi, cancellare un evento (da qualunque punto, app o REST diretto)
ripulisce automaticamente tutte le tabelle collegate.
- Le righe orfane generate da cancellazioni **precedenti** a questa migration restano nel
database: il trigger previene il problema da qui in avanti, non ripulisce lo storico.
- `test/integration/pulizia-evento.test.ts` è la definizione eseguibile del comportamento:
scrive una riga in ciascuna delle otto tabelle, cancella l'evento e verifica che spariscano
tutte.
**Aggiornamento (9 settembre 2026, stesso giorno)** — la bonifica dello storico prevista sopra
come "riesame" è stata fatta subito dopo, migration `m15_bonifica_dati_evento_orfani`: righe
orfane in `risposte_presenze`, `cacche_partita`, `turni_palloni`, `scout_sessioni`,
`scout_live`, `scout_partite` identificate confrontando `evento_id` con `eventi_app`. Per
`mvp_voti`/`pagelle_voti`/`badge_social_voti` il confronto si applica **solo** ai `match_id`
nel formato id evento CrAPP (`^e[0-9a-z]+$`, quello di `nuovoIdEvento()`): i vecchi voti su id
Scout (prefisso `s` + timestamp decimale) o CSI (numerico o `data-squadra-squadra`) non sono
orfani, sono dati storici legittimi mai collegati a un evento CrAPP (vedi contesto sopra), e la
migration non li tocca. Verificato manualmente sul database locale prima di applicarla: un voto
di test su id Scout è sopravvissuto alla bonifica, un voto di test su id evento CrAPP orfano è
stato rimosso.
**Aggiornamento (9 settembre 2026)** — la verifica manuale di M15 non lasciava nessuna rete di
sicurezza automatica per il futuro, a differenza del resto del progetto (DD-020). Migration
`m16_funzione_bonifica_dati_evento_orfani` rende lo stesso corpo una funzione
`bonifica_dati_evento_orfani()` (riservata al `service_role`, non richiamabile dall'app),
coperta da `test/integration/bonifica-evento.test.ts`: inserisce una riga orfana e una storica
su id Scout/CSI, richiama la funzione via RPC e verifica che tocchi solo la prima. Non serve
richiamarla di nuovo ora (M15 ha già ripulito lo storico): resta pronta come intervento di
manutenzione se in futuro il trigger di M14 smettesse di funzionare o emergesse un altro batch
di orfani.
**Riesame**
Se una nuova tabella collegata a un evento non viene aggiunta al trigger quando creata (va
aggiornata a mano, non c'è un meccanismo che lo forzi).
-1
View File
@@ -13,7 +13,6 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
| `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. |
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
## Regole da rispettare nelle prossime modifiche
+10 -4
View File
@@ -6,7 +6,7 @@ il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata e con q
## Fatto
Tutto quello che è in `main` e finirà nella prima release.
Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md`).
- [x] Gestione squadra
- [x] Calendario
@@ -16,17 +16,23 @@ Tutto quello che è in `main` e finirà nella prima release.
- [x] Badge
- [x] Badge social
- [x] Pagelle
- [x] Votazione MVP
- [x] Obiettivi di squadra
- [x] Turno palloni
- [x] Infortuni — conteggio eventi saltati, in forma minima
- [x] Notifiche Push (promemoria intelligenti)
- [x] Dashboard amministratore
- [x] Download CSV dati
- [x] Certificati medici — caricamento, scadenza, stato e download; lo storico dei
certificati resta un'estensione futura
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
(caricamento, scadenza, stato, download) e foto tessera; lo storico dei certificati
resta un'estensione futura
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
tesserato (numero e data di tessera)
- [x] Collegamento CSI (stagione 2025/26)
- [x] Classifica automatica
- [x] Classifica automatica (campionato e Coppa)
- [x] Risultati campionato
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
calcolata dal CSI
## Prossimo
@@ -1,5 +1,5 @@
-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016)
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle v1.0 esistenti.
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle preesistenti.
-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato
-- `profili-giocatore` creato dalla migration M3.
+400 -8
View File
@@ -1,6 +1,6 @@
# Modulo — Badge
**Stato:** implementato (v1.0), coerente con DD-007 e DD-008
**Stato:** implementato, coerente con DD-007 e DD-008
**File principali:** `src/lib/badges.ts`, `src/lib/badge-social.ts`,
`src/components/crapp/CollezioneBadge.tsx`, `src/components/crapp/BadgeDrawer.tsx`,
`src/components/crapp/CelebrazioneBadge.tsx`, `src/components/crapp/VotoSocial.tsx`
@@ -26,10 +26,30 @@ badge assegnati per voto dai compagni.
## Implementazione
- `badgeDefs`/`badgeSegreti` (`badges.ts`) definiscono ogni badge con una funzione
`valore(g)` e soglie bronzo/argento/oro. Le fonti dato sono solo statistiche indipendenti
dal ruolo in campo: MVP, media pagelle, turni palloni, presenze, serie, infortuni,
ritardi, cacche — **mai** punti/ace/muri dello Scout Live, coerentemente con DD-008.
- I badge segreti restano nascosti (icona lucchetto) finché non sbloccati.
`valore(g)` e tre soglie bronzo/argento/oro (elenco completo con fonte e soglie di ognuno in
"Elenco badge" sotto). Il grado è calcolato da `gradoRaggiunto()`/`statoBadge()`: soglie
inclusive, vince l'ultima raggiunta o superata. Per la maggior parte dei badge il valore è
già pronto: `Giocatore` arriva da `useRosa()` con presenze, palloni, serie, infortuni,
ritardi, cacche e media pagelle già calcolati da altri moduli — `badges.ts` si limita a
confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, il
Pagellone, lo Sherpa dei palloni, l'MVP e i badge social.
- **Badge Sherpa dei palloni** (`palloni`, in `badgeDefs`): `g.palloni` non è un contatore
incrementato a ogni evento, ma ricalcolato da `conteggioTurni()` (`palloni-core.ts`) su
`Giocatore.palloni` (`rosa.ts`) — meccanismo di turni/rotazione descritto per intero in
[palloni.md](palloni.md), non ripetuto qui. `rosa.ts` passa a `conteggioTurni()` **solo i
turni confermati** (`turniSalvati` da `useTurniPalloni()`), non l'output di
`completaTurni()`: le proposte automatiche di rotazione (usate altrove, per la UI di
`TurnoPalloni.tsx`) non contano per il badge, che premia solo chi ha davvero confermato di
aver portato i palloni. Conta solo per eventi già trascorsi (`e.data < oggi`, stesso
criterio delle presenze).
- **Badge Pagellone** (`pagella`, in `badgeDefs`): a differenza degli altri badge da
contatore, richiede un numero minimo di voti (`VOTI_MINIMI_PAGELLA = 5`, `badges.ts`) prima
che `g.mediaVoto` conti — sotto soglia `valore(g)` è forzato a `0` (badge bloccato), anche
con una media altissima. Aggiunto perché senza minimo un singolo voto poteva
sbloccare/far sparire il badge senza nessuna significatività statistica (vedi
[pagelle.md](pagelle.md) per la pipeline voto → media, qui non ripetuta). Il numero di voti
ricevuti arriva in `Giocatore.votiPagella` (`rosa.ts`), popolato insieme a `mediaVoto` dalla
stessa `mediePagelle()`.
- **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per
partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della
partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita
@@ -37,6 +57,14 @@ badge assegnati per voto dai compagni.
database (vincolo `badge_social_no_autovoto`, migration `m12_niente_autovoto`). A
differenza delle [Pagelle](pagelle.md), qui non c'è alcun tentativo di anonimato:
`votante_id`/`votato_id` sono entrambi visibili.
- **Badge MVP** (`mvp`, in `badgeDefs`): l'unico badge normale la cui fonte non è un contatore
già pronto ma il risultato della votazione MVP tra compagni — meccanismo di voto (chi vota
chi, apertura, autovoto, RLS) descritto per intero in [mvp.md](mvp.md), non ripetuto qui.
Quello che serve per capire il badge: `mvpVintiPerGiocatore()` (`mvp-voti.ts`) conta, per
ogni giocatore, quante partite ha vinto con un **vantaggio netto** sul secondo (non il
totale dei voti ricevuti; in caso di parità la partita non conta per nessuno). `rosa.ts`
(`useRosa()`) scrive quel numero in `Giocatore.mvp`, che `badgeDefs` legge con
`valore: (g) => g.mvp` e confronta con le soglie 1/3/5 (bronzo/argento/oro).
- `CollezioneBadge.tsx` mostra sbloccati, in progresso, badge social vinti e un contatore di
badge segreti ancora da scoprire; `BadgeDrawer.tsx` il dettaglio di un singolo badge;
`CelebrazioneBadge.tsx` l'overlay celebrativo alla prima visualizzazione di un badge nuovo.
@@ -46,6 +74,61 @@ badge assegnati per voto dai compagni.
---
## Elenco badge
Riferimento completo per chi lavora sul codice. **In app i 5 badge segreti restano nascosti
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
informazioni verso l'interfaccia.
### Badge normali (gradi bronzo/argento/oro)
Tutti calcolati come `valore(g)` confrontato con tre soglie crescenti; il grado è l'ultima
soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
| id | nome | come si guadagna | soglie B/A/O |
| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
| `mvp` | MVP | partite vinte nettamente al voto MVP dei compagni (`g.mvp`, vedi pipeline sopra) | 1 / 3 / 5 |
| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`), solo se ne ha ricevuti almeno `VOTI_MINIMI_PAGELLA` (5) | 6.5 / 7.5 / 8.5 |
| `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 |
| `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 |
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 |
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
### Badge segreti (booleani, nascosti finché non sbloccati)
Stesso motore dei normali ma con soglie `{bronzo:1, argento:1, oro:1}`: `valore(g)` è 0 o 1,
quindi il badge è "trovato o no", mai graduato. In UI compaiono con icona lucchetto finché non
sbloccati. Attenzione se si tocca `gradoRaggiunto()`: con le tre soglie tutte uguali a 1, il
grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo che il ciclo
`for` sovrascrive), mai `"bronzo"` — l'unica cosa che conta davvero per questi badge è
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
| id | nome | condizione esatta |
| --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
| `s-mai-forfait` | Mai un forfait | almeno 10 conferme rapide consecutive **e** almeno 15 presenze (`g.serieConferme >= 10 && g.presenze >= 15`) |
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
### Badge social (votati dai compagni, 5 categorie per partita)
Non hanno gradi: si "vince" o non si vince una categoria in una partita. `vincitoreCategoria()`
richiede un vantaggio netto sul secondo classificato, in parità nessun vincitore.
`badgeSocialVinti()` conta quante partite ha vinto ciascun giocatore in ogni categoria (non i
voti ricevuti).
| id | nome | cosa premia |
| ------------ | -------------------------- | ------------------------------------------------ |
| `affidabile` | Compagno affidabile | sempre presente, sempre sul pezzo |
| `spirito` | Miglior spirito di squadra | carica il gruppo dal primo all'ultimo punto |
| `fairplay` | Fair play | rispetto per compagni, avversari e arbitro |
| `meme` | Meme della partita | la scena più memorabile della partita |
| `cuore` | Cuore del gruppo | chi tiene unita la squadra anche fuori dal campo |
---
## Regole rispettate
- **DD-007**: nessuna tabella `badge_sbloccati`, tutto calcolato a runtime dai dati
@@ -55,6 +138,284 @@ badge assegnati per voto dai compagni.
---
## Copertura test
Verifica badge per badge (fatta rileggendo codice e test riga per riga, non solo per
categoria). Due bug trovati in una sessione di audit dedicata su tutti i 16 badge (dettagli
nelle sezioni sotto e in "Problemi noti"): `s-tiebreak` non applicava la soglia minima di voti
di Pagellone (**corretto**), `s-cacche` prometteva "partite di campionato" senza che il codice
lo verificasse mai (**la descrizione è stata corretta**, il comportamento — qualunque partita
conta — era già quello voluto). Tutti e 16 i badge hanno ora copertura unit **e** integration
end-to-end completa.
**Badge normali** — `badges.ts` testa la propria funzione pura (soglia → grado,
`badges.test.ts`) sull'output di altri moduli:
- `mvp`: soglie inclusive verificate (1→bronzo, 3→argento, 99→resta oro,
`badges.test.ts:44-48`), progresso a metà (`:52-56`).
- `pagella`: caso critico delle soglie decimali senza arrotondamento per eccesso — 6.4 →
nessun grado, 6.5 → bronzo (`badges.test.ts:65-67`); un vero 6.49 non diventa "quasi
bronzo". Più la soglia minima di voti (vedi sotto).
- `palloni`: soglie 3/6/10 testate esplicitamente (bronzo/argento/oro, confine incluso e
oltre l'oro resta oro, `badges.test.ts:94-104`), oltre a un caso di progresso non tondo
(5/6 → 83%). Pipeline end-to-end sotto, come `mvp`/`pagella`.
- `presenze`: soglie 5/15/30 testate esplicitamente (confine incluso, oltre l'oro resta oro,
`badges.test.ts:108-116`). Pipeline end-to-end sotto, come `mvp`/`pagella`/`palloni`.
- `serie-allenamenti`: soglie 3/6/10 testate esplicitamente (confine incluso, oltre l'oro
resta oro, `badges.test.ts:118-126`). Pipeline end-to-end sotto, come gli altri badge da
tabella.
- `serie-conferme`: soglie 3/8/15 testate esplicitamente (confine incluso, oltre l'oro resta
oro, `badges.test.ts:128-136`), oltre agli invarianti generali e a
`collezioneBadge`/`prossimoTraguardo` con valori al massimo. Pipeline end-to-end sotto, come
gli altri badge da tabella.
`mvp`, `pagella`, `palloni`, `presenze`, `serie-allenamenti` e `serie-conferme` sono le
eccezioni con integration dedicato (sotto) perché la loro fonte passa da una tabella di
voto/turni/presenze
letta e ricalcolata dal vivo, non da un contatore già pronto altrove.
**Badge segreti** — ognuno testato con la propria condizione esatta e il confine appena sotto:
`s-tiebreak` (mvp:1 non basta, mediaVoto 7.9 non basta, sotto `VOTI_MINIMI_PAGELLA` voti non
basta nemmeno con media alta — vedi il bug fix sotto), `s-mai-forfait` (ogni soglia isolata al
confine, non solo "entrambe servono"), `s-infermeria` (2 infortuni non bastano), `s-ritardi` (4
ritardi non bastano — gap colmato in questa sessione), `s-cacche` (2 cacche non bastano).
Copertura unit completa **e** integration dedicato per tutti e 5 (aggiunto in questa sessione,
vedi sotto): i dati sorgente hanno già i propri test di integrazione nei rispettivi moduli, ma
nessuno prima arrivava fino a `statoBadge()` sul segreto stesso con dati scritti a database.
**Badge MVP — pipeline end-to-end** (aggiunta in una sessione dedicata a completare la
copertura di questo badge):
- Unit: `badges.test.ts` (soglie/gradi) + `mvp-voti.test.ts` (conteggio partita, vincitore con
vantaggio netto, parità che non assegna, apertura voto 2h dopo il fischio d'inizio).
- Integration (`npx supabase start` richiesto):
- `scritture.test.ts` — semantica dell'`upsert` di `mvp_voti` (un voto per
partita/votante, l'ultimo sostituisce) e rifiuto dell'autovoto a database
(`mvp_no_autovoto`).
- `permessi.test.ts` — RLS di `m11`: il proprio voto MVP si registra (caso positivo), non
si può votare a nome di un altro (caso negativo); RLS di `m13` (sotto): un votante o un
votato non convocati vengono rifiutati.
- `mvp-badge.test.ts` — end-to-end reale: scrive voti su `mvp_voti`, rilegge via REST come
fa `useVotiMvp()`, calcola `mvpVintiPerGiocatore()` e verifica che `statoBadge()` assegni
il grado corretto (bronzo a 1-2 vittorie nette, argento a 3), incluso un pareggio che non
deve contare come vittoria.
**Badge Pagellone — pipeline end-to-end e soglia minima di voti** (stessa sessione di sopra,
dopo l'analisi che ha trovato il gap "un voto solo sblocca il badge"):
- Unit: `badges.test.ts:69-88` — sotto `VOTI_MINIMI_PAGELLA` (5) il badge resta bloccato anche
con `mediaVoto: 10`; esattamente a 5 la media torna a contare; sopra soglia valgono le
normali soglie di grado (`mediaVoto: 6.5` con 5 voti → bronzo, non oro).
- Integration (`npx supabase start` richiesto):
- `scritture.test.ts` — semantica dell'`upsert` di `pagelle_voti` e rifiuto dell'autovoto
(`pagelle_no_autovoto`), già presente prima di questa sessione.
- `permessi.test.ts` — RLS di `m13`: un votante o un votato non convocati vengono rifiutati
(per tutte e tre le tabelle di voto, non solo le pagelle), e un voto pagella dopo
`pagelle_chiuse` viene rifiutato anche a database, non solo nascosto in UI.
- `pagella-badge.test.ts` (nuovo) — end-to-end reale: scrive voti su `pagelle_voti`, rilegge
via REST come fa `usePagelle()`, calcola `mediePagelle()` e verifica che `statoBadge()`
tenga il badge bloccato sotto soglia, lo sblocchi al voto minimo con il grado giusto, e
applichi le soglie normali sopra soglia.
**Badge Sherpa dei palloni — pipeline end-to-end, ora senza contare le proposte non
confermate** (analisi dedicata: trovato e sistemato il gap "le proposte contano", che
gonfiava il badge di turni mai confermati da nessuno — vedi "Problemi noti da sistemare"):
- Unit: `badges.test.ts:93-104` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
`palloni-core.test.ts`, già completo prima di questa sessione (`completaTurni()`,
`conteggioTurni()`, rotazione bilanciata su un giro completo di partite, allenamenti mai
proposti in automatico, turno di un giocatore non più in rosa che non rompe il conteggio).
- Integration (`npx supabase start` richiesto):
- `scritture.test.ts` — un turno resta uno per evento (l'upsert sostituisce, non aggiunge).
- `palloni-badge.test.ts` — end-to-end reale: scrive eventi e turni **solo parzialmente
confermati** su `eventi_app`/`turni_palloni`, rilegge via REST come fa `fetchTurni()`/
`daRiga()` e passa `turniSalvati` (solo confermati, mai l'output di `completaTurni()`) a
`conteggioTurni()` fino a `statoBadge()`: dimostra che un evento passato senza turno
confermato **non conta per nessuno**, anche se un algoritmo di rotazione (usato altrove
per la UI) lo proporrebbe automaticamente; verifica anche che un evento futuro non conti,
pur avendo già una conferma.
**Badge Presenza fissa — pipeline end-to-end** (analisi dedicata: nessun bug trovato; a
differenza di MVP/pagelle/badge social, per questo badge **non serve** l'estensione RLS di
M13 — vedi sotto):
- Unit: `badges.test.ts:108-116` — soglie 5/15/30 (confine incluso, oltre l'oro resta oro) +
`presenze.test.ts`, già molto completo prima di questa sessione (`contaPresenzeGiocatore()`
con ritardo che conta come presenza, denominatore uguale per tutti, eventi futuri esclusi,
filtro sui convocati, solo partite/allenamenti).
- Integration (`npx supabase start` richiesto):
- `obiettivi.test.ts` — copre già `contaPresenzeGiocatore()` end-to-end per l'obiettivo
"250 presenze complessive" (o3), la stessa funzione usata dal badge.
- `presenze-badge.test.ts` (nuovo) — end-to-end reale sul badge: scrive eventi e risposte
su `eventi_app`/`risposte_presenze`, rilegge via REST come fa `fetchPresenze()`/`daRiga()`
e verifica che `statoBadge()` attraversi le tre soglie con dati veri (incluso un ritardo
che conta come presenza e un'assenza che non conta). Dimostra anche che una risposta
scritta per un evento senza convocazione **non conta comunque**, perché
`contaPresenzeGiocatore()` filtra già per `convocati` lato applicazione — a differenza di
MVP/pagelle/badge social, qui non serve una policy RLS aggiuntiva: il filtro è nella
funzione pura che il badge consuma, non solo in UI.
**Badge Sempre in palestra — pipeline end-to-end** (analisi dedicata: nessun bug trovato).
Stessa fonte dati di `presenze` (`risposte_presenze`) ma logica diversa: non un totale, una
**serie consecutiva** che un buco azzera e un infortunio congela. Anche qui, come per
`presenze`, non serve nessuna estensione RLS: il filtro sui convocati è già nella funzione
pura.
- Unit: `badges.test.ts:118-126` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
`presenze.test.ts`, già completo prima di questa sessione su `serieConsecutiva()` (buco che
azzera, infortunio che congela invece di azzerare, nessuna risposta vale come buco,
convocati che non spezzano la serie di chi non era coinvolto).
- Integration (`npx supabase start` richiesto):
- `serie-allenamenti-badge.test.ts` (nuovo) — end-to-end reale: scrive allenamenti e
risposte su `eventi_app`/`risposte_presenze`, rilegge via REST e verifica che
`statoBadge()` attraversi bronzo/argento/oro con presenze consecutive vere, che
un'assenza dopo 10 presenze di fila azzeri tutto (torna a nessun grado), e — separatamente
— che un infortunio **non** azzeri la serie ma la lasci congelata (3 presenze vere,
un infortunio nel mezzo saltato dal conteggio, poi ancora presente: la serie resta a 3,
non riparte da 1).
**Badge Risposta lampo — pipeline end-to-end** (analisi dedicata: nessun bug trovato nella
logica di calcolo; l'unico limite è quello già noto e documentato sui dati pre-`m9`, vedi
"Limiti noti"). Il badge dipende da `serieConferme()`, che passa da due colonne facili da
confondere fra loro (`creato_il`/`risposto_il`, vedi [serie-presenze.md](serie-presenze.md)):
un integration test aggiunto per verificare che la mappatura verso `creatoIl`/`tempi` regga con
dati reali, non solo con timestamp scelti a mano — cosa che i test unitari, che non toccano il
database, non possono garantire.
- Unit: `badges.test.ts:128-136` — soglie 3/8/15 (confine incluso, oltre l'oro resta oro) +
`presenze.test.ts`, esteso in questa sessione su `serieConferme()`: oltre al buco che azzera
e all'evento senza `creatoIl` che viene saltato (già presenti), ora anche un evento convocato
solo per un altro giocatore che non spezza la serie, partite e allenamenti sommati nella
stessa serie, il confronto inclusivo esattamente a 24h (dentro conta, un secondo oltre
azzera), e un evento futuro che non entra ancora nel calcolo.
- Integration (`npx supabase start` richiesto):
- `serie-conferme-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il`
esplicito e risposte con `risposto_il` esplicito su `eventi_app`/`risposte_presenze`,
rilegge via REST come fa `daRiga()`/`fetchPresenze()` e verifica che `statoBadge()`
attraversi bronzo/argento/oro con conferme rapide vere, che una risposta arrivata oltre le
24h azzeri tutto anche dopo 15 conferme di fila, e — separatamente — che partite e
allenamenti si sommino nella stessa serie senza bisogno di un filtro per tipo.
**Badge Cliente VIP dell'Infermeria e Aspettate, arrivo! — pipeline end-to-end** (analisi
dedicata: nessun bug trovato). Stessa fonte (`contaInfortuni()`/`contaRitardi()` in
`src/lib/infortuni.ts`, entrambe sopra la stessa `contaStato()` privata) e stessa struttura di
`serie-allenamenti`/`serie-conferme`, ma senza serie: un contatore semplice di eventi passati.
- Unit: `badges.test.ts` (soglie 3 e 5, confine appena sotto) + `infortuni.test.ts`, esteso in
questa sessione con un giocatore che ha **sia** un infortunio **sia** un ritardo (su eventi
diversi): i due conteggi restano indipendenti, nessuno "ruba" voci all'altro.
- Integration (`npx supabase start` richiesto):
- `s-infermeria-badge.test.ts` / `s-ritardi-badge.test.ts` (nuovi) — end-to-end reali: scrivono
eventi e risposte "infortunato"/"ritardo" su `eventi_app`/`risposte_presenze`, rileggono via
REST e verificano che il segreto resti bloccato appena sotto soglia e si sblocchi
esattamente al confine (3 infortuni, 5 ritardi).
**Badge Trono di ferro — pipeline end-to-end, descrizione corretta** (analisi dedicata: trovato
un disallineamento fra descrizione e codice, **risolto aggiornando il testo**, non la logica —
vedi "Problemi noti" più sotto per il perché). `statisticheCacche()` (`src/lib/cacche.ts`) non
ha mai distinto partite di campionato da amichevoli: contava (e conta ancora) qualunque partita
con 3+ cacche dichiarate. La vecchia descrizione del badge prometteva "partite di campionato",
cosa che il codice non ha mai verificato — corretta in "partite (campionato o amichevole)".
- Unit: `badges.test.ts` (soglia 3, confine appena sotto — gap colmato in questa sessione) +
`cacche.test.ts` (già completo su `giornateTop`).
- Integration (`npx supabase start` richiesto):
- `s-cacche-badge.test.ts` (nuovo) — end-to-end reale: scrive 2 giornate da record su partite
di campionato e una su un'amichevole, dimostrando con dati veri che l'amichevole conta
esattamente come le altre — pin del comportamento attuale, così chi in futuro reintroduce un
filtro sul campionato deve accorgersene qui, non scoprirlo in produzione.
**Badge Uomo tie-break — pipeline end-to-end, bug corretto** (analisi dedicata: trovato e
sistemato il gap "un voto pagella solo sblocca il segreto insieme a 2 MVP"). Il segreto usa
`g.mediaVoto`, lo stesso campo del badge normale `pagella` — che però lo azzera sotto
`VOTI_MINIMI_PAGELLA` (5) voti ricevuti, proprio per evitare che un singolo voto sblocchi/tolga
il badge senza significatività statistica. `s-tiebreak` non applicava lo stesso filtro: ora sì
(`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`).
- Unit: `badges.test.ts` — sotto la soglia minima di voti il segreto resta bloccato anche con
media 8 e 2 MVP; un solo MVP non basta (isolato dal resto).
- Integration (`npx supabase start` richiesto):
- `s-tiebreak-badge.test.ts` (nuovo) — end-to-end reale: scrive voti MVP e pagella veri,
dimostra che un solo voto pagella (media alta, 2 MVP) NON sblocca il segreto, e che il quinto
voto lo sblocca — il fix verificato con la stessa pipeline `mvp_voti`/`pagelle_voti` → REST →
`mvpVintiPerGiocatore()`/`mediePagelle()``statoBadge()` che userebbe l'app.
**Badge Mai un forfait — pipeline end-to-end** (analisi dedicata: nessun bug trovato). Unico
segreto a combinare due statistiche indipendenti (`serieConferme()` e
`contaPresenzeGiocatore()`), entrambe già testate a fondo nei rispettivi moduli.
- Unit: `badges.test.ts`, esteso in questa sessione con ogni soglia isolata al confine
(`serieConferme` appena sotto con `presenze` abbondanti, e viceversa), non solo "insieme non
bastano".
- Integration (`npx supabase start` richiesto):
- `s-mai-forfait-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il` e
risposte con `risposto_il` veri, verifica che il segreto resti bloccato a 9/9 e si sblocchi a
15/15, e che una risposta lenta azzeri la serie di conferme **senza** azzerare le presenze
già accumulate (le due statistiche restano indipendenti anche a database).
**Badge social** — nessuna delle 5 categorie ha logica _propria_ nel codice: l'id è solo una
chiave di raggruppamento, `conteggioCategoria`/`vincitoreCategoria`/`badgeSocialVinti` sono
identici per tutte (`badge-social.ts:107-158`). Testare a fondo 2-3 categorie copre l'intero
meccanismo:
- Unit (`badge-social.test.ts`): conteggio isolato per match+categoria (`:29-32`), vantaggio
netto/parità → nessun vincitore (`:38-41`), vittorie multi-partita (`badgeSocialVinti`, g2
vince in `m1` e `m2``{affidabile: 2}`, `:48`), zero voti → zero badge (`:51`). Estesi in
questa sessione: un voto totale solo basta a vincere, una parità a 3 candidati (i primi due
pari, il terzo staccato) resta senza vincitore, categorie diverse nella stessa partita non si
mischiano in `badgeSocialVinti()`.
- Integration: upsert/sostituzione voto per categoria (`scritture.test.ts:170-202`), autovoto
rifiutato — doppia barriera UI + database (`scritture.test.ts:124-148`), RLS `m11` — un
giocatore firma solo il proprio voto (`permessi.test.ts:344-369`).
- `badge-social.test.ts` (nuovo, in `test/integration/`) — end-to-end reale sulle **5
categorie effettive** di `categorieSocial` (non più solo 2-3, e non più le categorie
inventate di `scritture.test.ts`): scrive voti veri su `badge_social_voti`, dimostra che
tutte e 5 si contano e si vincono allo stesso modo, e che una parità su una categoria non
tocca il conteggio delle altre 4 nella stessa partita.
### Riepilogo per badge
| # | id | tipo | test unit | test integration |
| --- | ------------------- | ------- | ------------------------------------- | --------------------------------------------- |
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
| 6 | `serie-conferme` | normale | ✅ (limite noto sotto) | ✅ (`serie-conferme-badge`) |
| 7 | `s-tiebreak` | segreto | ✅ (bug corretto, vedi sotto) | ✅ (`s-tiebreak-badge`) |
| 8 | `s-mai-forfait` | segreto | ✅ | ✅ (`s-mai-forfait-badge`) |
| 9 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
| 10 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
| 11 | `s-cacche` | segreto | ✅ (descrizione corretta, vedi sotto) | ✅ (`s-cacche-badge`) |
| 12 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 14 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 15 | `meme` | social | ✅ | ✅ (`badge-social`) |
| 16 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
---
## Problemi noti da sistemare
- **`badgeSbloccati()` morta** (`badges.ts:283-285`): duplica esattamente
`collezioneBadge(g).sbloccati`. Zero riferimenti fuori dalla propria definizione, né in
`src/` né nei test. Da rimuovere o documentare perché esiste (es. uso futuro/esterno).
- **`categoria` senza vincolo DB** in `badge_social_voti`: la colonna è `text NOT NULL` senza
CHECK o FK verso i 5 id di `categorieSocial`
(`supabase/migrations/20260803140647_affa1c11-fa92-450f-9f00-02d87195a6d9.sql:4`). I test
stessi lo dimostrano scrivendo categorie inesistenti (`"sorriso"`/`"urlo"`,
`scritture.test.ts`). Non sfruttabile da un utente normale (l'app manda solo le 5 categorie
valide), stesso tipo di gap "solo applicativo, non a DB" del punto sotto sul votato/convocato.
- **`conteggioTurni()` non filtra per tipo evento** (`palloni-core.ts:70-82`), a differenza di
`eventiPalloni()` che scarta i compleanni. Un turno registrato per errore su un evento fuori
dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei palloni. Rischio
teorico basso (l'UI non offre questa combinazione), comportamento pinnato da un test dedicato
in `palloni-core.test.ts` così che un domani, se serve stringere, non lo si scopra rompendo un
test esistente ma leggendo perché quel test lo dimostrava apposta.
---
## Limiti noti
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
@@ -64,12 +425,39 @@ badge assegnati per voto dai compagni.
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
"ottenuto" può sparire o apparire retroattivamente.
- La policy di M11 garantisce che il voto sia firmato con il proprio `votante_id`, ma non
che il votato sia un giocatore convocato per quella partita: quello resta un filtro solo
applicativo.
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
browser o dispositivo.
**Risolto (analisi del badge Sherpa dei palloni)**: prima `g.palloni` (`rosa.ts`) includeva
anche i turni che `completaTurni()` propone in automatico per un evento passato senza
assegnazione esplicita, non solo quelli confermati in `turni_palloni` — un giocatore poteva
vedere avanzare il badge senza aver mai confermato nulla, semplicemente perché l'algoritmo di
rotazione l'aveva proposto. Ora `rosa.ts` passa a `conteggioTurni()` solo `turniSalvati` (i
turni confermati), non l'output di `completaTurni()`: quest'ultimo resta in uso solo per la
UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteggio del badge.
Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di
conseguenza.
**Risolto (audit completo dei 16 badge)**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo
stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il
segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
`g.votiPagella >= VOTI_MINIMI_PAGELLA`, dimostrato con dati reali in `s-tiebreak-badge.test.ts`.
Il badge `s-cacche` prometteva invece "partite di **campionato**" nella descrizione senza che
nessuna funzione della pipeline lo verificasse mai (`statisticheCacche()` conta qualunque
partita) — qui si è scelto di correggere la descrizione, non il codice: il comportamento
"qualunque partita conta" resta quello voluto, pinnato in `s-cacche-badge.test.ts`.
**Risolto (M13, `20260908120000_m13_convocati_e_pagelle_chiuse.sql`)**: prima la policy di M11
garantiva solo che il voto fosse firmato con il proprio `votante_id`, non che il votato (né il
votante) fossero convocati per quella partita — filtro solo applicativo, aggirabile scrivendo
direttamente su PostgREST. Ora `evento_permette_voto()` lo verifica anche a database per
`pagelle_voti`, `mvp_voti` e `badge_social_voti` (convocati vuoto = tutta la rosa, stessa
convenzione di `convocatiEvento()`), e per le sole pagelle verifica anche che
`eventi_app.pagelle_chiuse` sia falso — prima un voto "fuori tempo" restava tecnicamente
possibile bypassando l'interfaccia. Le policy admin restano permissive: un amministratore può
ancora correggere un voto anche fuori convocazione o dopo la chiusura.
---
## Evoluzioni possibili
@@ -77,3 +465,7 @@ badge assegnati per voto dai compagni.
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
- Verificare sui dati di stagione che i tre badge legati alle serie si sblocchino davvero,
ora che le serie sono calcolate.
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
- Se un domani serve restringere `conteggioTurni()` per tipo evento (vedi "Problemi noti"),
aggiornare anche il test che oggi ne pinna il comportamento permissivo.
+60
View File
@@ -0,0 +1,60 @@
# Modulo — Calendario ed Eventi
**Stato:** implementato
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/routes/calendario.tsx`
(vista mensile, tutti), `src/routes/eventi.tsx` (creazione/modifica, solo admin),
`src/components/crapp/EventoCard.tsx` (card condivisa)
**Test:** `test/unit/eventi.test.ts`
---
## Obiettivo
Un unico calendario condiviso per allenamenti, partite, amichevoli ed eventi extra
(riunioni, cene di squadra...), al posto di messaggi sparsi in chat. Ogni evento in
`eventi_app` diventa il punto a cui si agganciano presenze, convocazioni, MVP, pagelle,
scout e turno palloni — la maggior parte degli altri moduli dipende da un `evento.id`.
## Due schermate, due pubblici
- **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti,
partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()`
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa()`, non righe
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
di evento, i giorni con più eventi si dividono lo spazio.
- **`/eventi`** — "Gestione eventi", riservata agli amministratori (`useIsAdmin()`): crea,
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database).
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
`linkPerEvento()` decide dove porta il click — `/partita/$id` per una partita (con
`/partita-csi/$id` come alternativa "solo CSI" quando non c'è un evento collegato, vedi
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, nessun link per eventi ed
eventi virtuali (compleanni).
## Lettura lato server
`src/lib/eventi.server.ts` (`leggiEventi()`) è la stessa conversione riga→modello di
`eventi.ts`, ma con `supabaseAdmin` per le route API che girano senza sessione utente (es.
`sollecita-presenze.ts`, `promemoria-palloni.ts` — vedi `presenze.md` e `palloni.md`) e per
`notifiche-smart.ts`, che decide i promemoria da mandare in base agli eventi del giorno.
---
## Limiti noti
1. **Cancellare un evento è distruttivo per tutto ciò che vi era agganciato.** Un trigger
(`m14_pulizia_dati_evento_cancellato`,
[DD-029](../DESIGN_DECISIONS.md#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati))
pulisce a cascata presenze, cacche, voti MVP/pagelle/badge social, turni palloni e scout
di quell'evento: non è recuperabile con un annulla, e prima di M14 quelle righe restavano
orfane nel database (bonificate una tantum da M15/M16, vedi `PROJECT_STATE.md`).
2. **Nessuna creazione automatica degli eventi partita dal calendario CSI.** Le gare
ufficiali arrivano già come dati (`getEventsByTeamId.php`, vedi `collegamento-csi.md`),
ma un amministratore deve comunque creare a mano l'evento corrispondente in `/eventi`
perché esistano convocazioni, presenze, MVP e pagelle per quella partita — altrimenti la
gara resta visibile solo nello storico CSI, con un dettaglio "solo CSI" più povero
(`/partita-csi/$id` invece di `/partita/$id`). In `docs/ROADMAP.md` sotto "Prossimo".
+273 -37
View File
@@ -1,7 +1,8 @@
# Modulo — Collegamento CSI
**Stato:** implementato (stagione 2025/26)
**Route interessata:** `/classifica`
**Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id`
(dettaglio di una gara: formazioni e scontri diretti)
---
@@ -21,26 +22,125 @@ Il portale **non espone un'API pubblica documentata**. Vengono usati gli stessi
che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza
API key, ma **non offrono alcuna garanzia di stabilità**.
| Endpoint | Formato | Uso |
| ------------------------------------------------ | ------- | ------------------------------------------------------------------------------ |
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi |
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra: data, ora, avversario, campo, risultato, parziali |
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
`project-last_results.php`, `team-roster.php`, `team-results.php`.
Le pagine "umane" (`league_details.php`, `team_details.php`) sono gusci lato server: non
contengono dati, li caricano dopo via JS dagli stessi endpoint `components/*.php`
verificato leggendo `assets/js/project.js` e `assets/js/team.js`, referenziati in fondo
alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare
un `project_id`), nessun codice le chiama direttamente.
### Identificativi (stagione 2025/26)
| Cosa | Valore |
| ------------------- | -------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` | `767` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
| `team_id` | `3359` |
| Girone | B |
| Cosa | Valore |
| ---------------------- | ---------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` (girone) | `767` |
| Coppa | PVM Coppa CSI Misto Silver |
| `project_id` (coppa) | `848` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
| `team_id` | `3359` |
| Girone | B |
Gli identificativi sono costanti in `src/lib/csi-core.ts`.
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
`src/lib/csi-core.ts`.
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
verificato chiamando l'endpoint direttamente, che oggi restituisce sia
`project_id=848` (Coppa) sia `project_id=767` (Campionato). Non serve aprire
`team_details.php` nel browser, questo componente basta.
### Endpoint usati dall'app
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
| -------------------------------------------------- | ------- | ------------------------------------ | ------------------------------------ |
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`,
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
Colonne per `<td>` (0-indicizzate): `0` Pos · `1` Squadra (nome + logo + link a
`team_details.php?team_id=…`) · `2` Punti · `3` Partite giocate · `4` Vinte · `5` Perse ·
`6`-`7` Tie-break vinti/persi (non lette) · `8` Set fatti · `9` Set subiti · poi punti
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per `project_id=767`
(campionato) e `project_id=848` (fase a gironi della Coppa): `parseClassifica()` è
condivisa, nessun parser dedicato per la Coppa.
**Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e**
Coppa insieme, vedi limite 4), con: `id`, `start` (`"2025-11-12T22:00:00"`, data+ora
locale), `team1`/`team2` (nomi squadre), `result` (`"3 - 1"`, stringa libera), `partials`
(`"25 - 23</br>23 - 25</br>..."`, HTML nei separatori), `field` (impianto), `project`
(nome campionato, es. `"PVM - Coppa CSI Misto Silver"` — usato per distinguere le
competizioni, vedi limite 4), `league`, `group` (es. `"Girone B"`), `match_number`. Letto
da `partiteDaEventi()` in `csi-core.ts`, che estrae punteggio/parziali con le regex
`punteggio()`/`parziali()` — vedi limite 5 sui rischi di questo parsing.
**Campi leggeri aggiuntivi letti dallo stesso JSON** (nessuna fetch in più, solo campi in
più letti dallo stesso `evento`): `team1_logo`/`team2_logo` (URL del logo, quello
dell'avversario finisce in `PartitaCsi.logoAvversario`), `group` (girone, es. `"Girone
B"`), `match_number` (n° gara, es. `"5/XEB"`), `referees` (arbitro, spesso vuoto), `link`
(URL del referto ufficiale, `match_details.php?id=…`).
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
`project-last_results.php`, `project-sheets-scorers.php`/`-results.php`/`-measures.php`
(sotto-tab di `project-sheets`: marcatori, risultati per giornata, provvedimenti
disciplinari), `team-roster.php` (rosa), `team-staff.php`, `team-results.php`,
`team-scorers.php`.
### Dettaglio di una singola gara (formazioni e scontri diretti)
Il referto di ogni gara sul portale (`match_details.php?id=<matchId>`, dove `matchId` è lo
stesso `id` restituito da `getEventsByTeamId.php`) carica a sua volta tre componenti via
`assets/js/match.js`:
| Endpoint | Formato | Uso |
| ----------------------------------------------- | ------- | --------------------------------------------------------- |
| `components/match-main.php?match_id=<id>` | HTML | Giornata e una nota libera sotto l'impianto |
| `components/match-players.php?match_id=<id>` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre |
| `components/match-stats.php?match_id=<id>` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI |
Altri due componenti della stessa pagina non sono usati: `match-live.php` (diretta testuale
punto-per-punto, utile solo a gara in corso) e la lista dettagliata dei precedenti dentro
`historyModal` in `match-stats.php` (un elenco partita-per-partita meno affidabile del
riepilogo aggregato — vedi sotto).
**`match-main.php`** — `parseInfoPartita()` (`csi-core.ts`) legge: la giornata (es. `"2ª
Giornata"`, assente per gare fuori dal girone come la finale di Coppa) e una nota libera
sotto l'impianto (`nota`). Quella nota **non ha un formato fisso**: a volte è `"Pubblico
non ammesso"`, a volte il nome della palestra, a volte altro — va mostrata così com'è, non
interpretata come un flag booleano.
**`match-players.php`** — `parseFormazioni()` legge due blocchi `<div class="col-12
col-md-6 mt-4">`, separati nel markup dal commento `<!-- SQUADRA OSPITE -->`, ciascuno con
una `<ul class="list-group">` di giocatori in ordine titolari → divisore "A DISPOSIZIONE"
→ panchina → divisore "STAFF" → staff (numero maglia, nome, ruolo; lo staff ha la stessa
struttura ma senza numero). Quale dei due blocchi sia "noi" si riconosce con
`isNostraSquadra()`, non assumendo un ordine fisso casa/ospite — verificato che l'ordine
nel markup è sempre "squadra casa" prima e "squadra ospite" dopo, ma il codice non si fida
di questo per evitare sorprese. `null` se nessuno dei due nomi è la nostra squadra
(referto non ancora compilato o formato cambiato).
**`match-stats.php`** — `parsePrecedenti()` legge il blocco di riepilogo in fondo alla
pagina (non la lista `historyModal` partita-per-partita, che in un caso osservato conteneva
gare di **altre squadre** senza relazione con la gara corrente — dato non affidabile da
interpretare): numero di precedenti, vittorie totali/in casa/fuori di entrambe, probabilità
di vittoria calcolata dal CSI. **Attenzione a un'insidia verificata sui dati reali**: le
due barre di probabilità sono colorate per chi è favorito (verde = più alta, rosso = più
bassa), **non** per casa/ospite — un parser ingenuo che associasse il verde alla squadra
casa sbaglierebbe metà delle volte. Il parser usa invece l'ordine di apparizione nel
markup (prima barra = squadra casa, seconda = ospite), coerente con l'ordine dei blocchi
"Squadra casa"/"Squadra ospite" più sopra nella stessa pagina. Con "0 precedenti" il CSI
omette del tutto le righe vittorie/in-casa/fuori (restano a `0`) ma la probabilità resta
comunque presente: `parsePrecedenti()` distingue quindi "0 precedenti" (oggetto valido con
`totale: 0`) da "formato non riconosciuto" (`null`, solo se nessuno dei due nomi squadra è
identificabile).
**Formato di `DettaglioPartitaCsi`** (il JSON che compongono insieme):
`{ giornata, nota, formazioni: { noi, avversario } | null, precedenti: PrecedentiCsi | null
}`, dove ogni `FormazioneSquadra` è `{ squadra, titolari: GiocatoreFormazione[], panchina,
staff: StaffFormazione[] }` e `GiocatoreFormazione` è `{ numero, nome, ruolo }`.
---
@@ -49,29 +149,83 @@ Gli identificativi sono costanti in `src/lib/csi-core.ts`.
```
CSI (portale)
↓ fetch server-side, cache 6 ore
/api/public/csi → src/routes/api/public/csi.ts
↓ JSON { classifica, partite, girone, aggiornato }
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
/api/public/csi → src/routes/api/public/csi.ts
↓ JSON { classifica, classificaCoppa, partite, girone, aggiornato }
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
/classifica → src/routes/classifica.tsx
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone
sotto; tab "Storico partite": ogni squadra col proprio logo,
chevron di dettaglio sulle gare cliccabili)
CSI (portale, 3 endpoint)
↓ fetch server-side on-demand, cache per-partita 6 ore
/api/public/csi-partita/$id → src/routes/api/public/csi-partita.$id.ts
↓ JSON DettaglioPartitaCsi { giornata, nota, formazioni, precedenti }
useCsiPartita() → src/lib/csi-partita.ts (React Query, staleTime 6h)
DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazioni + scontri diretti)
/partita/$id (evento CrAPP collegato) o /partita-csi/$id (nessun evento collegato)
```
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe),
`partiteDaEventi()` (JSON → partite), `isNostraSquadra()`, `partiteGiocate()`.
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI. Cache in memoria di
6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne esiste uno).
- **`src/lib/csi.ts`** — hook client, una lettura per sessione.
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe,
usata sia per `classifica` sia per `classificaCoppa`), `partiteDaEventi()` (JSON → partite),
`isNostraSquadra()`, `partiteGiocate()`, `matchDaPartitaCsi()` (porta i campi leggeri —
logo, girone, n° gara, arbitro, link — nella forma usata dalle liste), `parseInfoPartita()`,
`parseFormazioni()`, `parsePrecedenti()` (vedi sezione precedente).
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI per classifica e
partite: tre fetch in parallelo (classifica girone, classifica Coppa, partite). Cache in
memoria di 6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne
esiste uno). Se solo la Coppa fallisce (`scarica(...).catch(() => "")`) la risposta resta
comunque `200` con `classificaCoppa: []`: è un dato supplementare, non blocca la classifica
del girone.
- **`src/routes/api/public/csi-partita.$id.ts`** — route separata per il dettaglio di una
singola gara: tre fetch in parallelo (`match-main`/`match-players`/`match-stats.php`). Cache
in memoria **per `matchId`** (una `Map`, non un singolo valore come `csi.ts`), stessa
finestra di 6 ore. A differenza di `/api/public/csi`, qui un fallimento del fetch è fatale
(`503`, nessun fallback "meglio un dato vecchio"): non c'è ancora una cache da riusare la
prima volta che qualcuno apre una gara, e un errore upstream reale (verificato: CSI risponde
`500` su `match-stats.php` per un `match_id` inventato) va distinto da "gara senza
formazioni ancora pubblicate" (quell'endpoint risponde `200` con markup vuoto, gestito da
`parseFormazioni()`/`parsePrecedenti()` restituendo `null`, non da un errore HTTP).
- **`src/lib/csi.ts`** / **`src/lib/csi-partita.ts`** — hook client React Query, stessa
`staleTime` di 6h. `useCsiPartita(matchId)` è `enabled` solo quando `matchId` è definito:
va montato solo nel dettaglio di una gara, mai in una lista (altrimenti sarebbe una fetch
per riga, vedi "Regole rispettate" sotto).
- **`src/components/crapp/DettaglioCsi.tsx`** — UI condivisa tra `/partita/$id` e
`/partita-csi/$id`: `LogoSquadra` (logo con hotlink diretto al portale CSI, si nasconde da
sola se l'immagine non carica invece di mostrare un'icona rotta), `MetaPartitaCsi` (girone,
n° gara, arbitro, link al referto — campi leggeri, zero fetch aggiuntive), `DettaglioCsiEsteso`
(formazioni + scontri diretti, monta `useCsiPartita()`).
- **`src/routes/partita-csi.$id.tsx`** — dettaglio "solo CSI" per le gare **senza** un evento
CrAPP collegato (l'app non crea ancora eventi automaticamente dal calendario CSI, vedi
"Evoluzioni possibili"): nessuna convocazione/presenza/MVP/scout, solo risultato, parziali
e i dati CSI di questa sezione. `id` è l'`id` della gara sul portale CSI
(`PartitaCsi.id`), non un evento CrAPP.
- **`src/routes/partita.$id.tsx`** — per le gare **con** un evento CrAPP collegato, mostra le
stesse informazioni CSI (logo, metadati, formazioni, scontri diretti) in più rispetto a
prima, quando `csiMatch` esiste per quella data.
- **`test/unit/csi-core.test.ts`** — check del parsing: `bun test/unit/csi-core.test.ts`.
Con `CSI_LIVE=1` verifica anche gli endpoint reali.
Con `CSI_LIVE=1` verifica anche gli endpoint reali, incluse formazioni e precedenti di una
gara giocata.
### Regole rispettate
- **Nessuna chiamata dal browser**: il portale viene contattato solo lato server, al massimo
4 volte al giorno, indipendentemente da quanti giocatori aprono l'app (regola anti-consumo).
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della tabella.
- **Nessuna chiamata dal browser per classifica/partite**: il portale viene contattato solo
lato server, al massimo 4 volte al giorno per `/api/public/csi`, indipendentemente da
quanti giocatori aprono l'app (regola anti-consumo). **`/api/public/csi-partita/$id` è
diverso di proposito**: è on-demand, chiamato solo quando un giocatore apre il dettaglio
di una gara specifica (mai precaricato in una lista, vedi `useCsiPartita()` sopra) — non
rientra nel limite delle 4 chiamate/giorno perché non è un dato mostrato a tutti a ogni
apertura dell'app, ma cache comunque 6 ore per evitare rifetch ripetuti sulla stessa gara.
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della
tabella/lista, sia per classifica/partite sia per formazioni/precedenti.
- **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo
dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati
ricadono sulle partite dello Scout Live locale (`useScoutMatches()`).
`/api/public/csi-partita/$id` non ha questo fallback sulla prima chiamata per una gara mai
vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non
mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina.
- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo.
---
@@ -83,18 +237,100 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`.
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id, ricavabile da
`team_details.php?team_id=3359`, che elenca i campionati della squadra. Oggi va aggiornato
a mano in `csi-core.ts`.
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
istanze. Sufficiente per una squadra; se serve di più, spostare i dati in una tabella
Supabase riempita da un job cron (stesso pattern di `promemoria-palloni`).
4. **I risultati includono anche la Coppa**, non solo il girone di campionato.
istanze — vale sia per `/api/public/csi` sia per la `Map` per-partita di
`/api/public/csi-partita/$id`. Sufficiente per una squadra; se serve di più, spostare i
dati in una tabella Supabase riempita da un job cron (stesso pattern di
`promemoria-palloni`).
4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.**
`getEventsByTeamId.php?team_id=3359` è per squadra, non per competizione (vedi tabella
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa
struttura a tabella-per-girone di `project_id=767`, quindi `parseClassifica()` funziona
invariata — nessun parser dedicato. Resta un limite: quella pagina copre **solo la fase a
gironi**. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola
andata seguiti da una finale secca tra le due vincenti, disputata in un `project_id`
figlio separato generato a fine fase a gironi (verificato con `curl` diretto:
`project-main.php?project_id=848` descrive il regolamento — "due gironi sola andata, le
due vincenti in finale, gare 3 set su 5" — e `project-sheets.php?project_id=848` mostra
sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il
tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in
finale, `/classifica` continuerebbe a mostrare la classifica (ormai chiusa) del proprio
girone di Coppa, non l'esito della finale: non c'è codice che segua quel `project_id`
figlio, che oltretutto cambia a ogni edizione della Coppa e non è noto in anticipo.
5. **Le partite si leggono da JSON, con parsing fragile su campi testuali.** `result` e
`partials` in `getEventsByTeamId.php` sono stringhe libere tipo `"3-1"`, lette con
un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il portale CSI
cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la regex
non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata"
(`setNostri`/`setLoro` a `null`) — silenziosamente, senza errori. Se invece la risposta
cambiasse forma radicalmente (non più un array), `partiteDaEventi()` torna `[]`.
**Conseguenza sugli obiettivi di squadra**: le "vittorie in campionato" (`obiettivi.ts`,
obiettivi o3/o4/o5) dipendono da `partiteGiocate(csi.partite)` — se il parsing delle partite
si rompe così, questi tre obiettivi restano bloccati a 0% anche a fronte di vittorie reali.
**Il fallback della route non se ne accorgerebbe da solo**: `/api/public/csi` lancia un
errore solo se *sia* la classifica *sia* le partite sono vuote insieme
(`classifica.length === 0 && partite.length === 0`); se si rompe solo il parsing delle
partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200`
con `partite: []`. Per questo `leggiCsi()` confronta il JSON grezzo con il risultato di
`partiteDaEventi()` tramite `partiteFormatoSospetto()` (`csi-core.ts`): se ci sono eventi
grezzi ma nessuno è stato riconosciuto come nostra partita, logga un `console.error`
distingue così un vero "formato cambiato" da un legittimo "nessuna gara ancora in
programma" (dove gli eventi grezzi stessi sono vuoti). Il flag `formatoSospetto` viaggia
anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
(`src/routes/classifica.tsx`), dove mostra un badge discreto ("Il portale CSI potrebbe aver
cambiato formato: dati da verificare.") al posto della normale riga "Dati CSI aggiornati
alle...": un log server passa inosservato per settimane, un badge visibile a chi apre la
pagina campionato molto meno. Il fix, quando succede, è isolato a
`partiteDaEventi()`/`punteggio()`/`parziali()` in `csi-core.ts` (gli endpoint stessi
cambiano solo se cambia il dominio o serve autenticazione, nel qual caso va toccata anche
`src/routes/api/public/csi.ts`); va poi aggiornato anche `test/unit/csi-core.test.ts` con
fixture nel nuovo formato.
6. **Il portale può essere del tutto irraggiungibile, non solo cambiare formato.** Scenario
diverso dal punto 5 (lì il JSON è valido ma non riconosciuto, qui la risposta non è
nemmeno JSON): l'8 settembre 2026 `getEventsByTeamId.php` ha risposto con `200` ma un
errore SQL del loro backend in chiaro al posto del JSON
(`Query non valida (getProjectTeams): Table 'uqc2os2x_livescore.seasons' doesn't exist`,
verificato con `curl` diretto sul loro dominio). `leggiCsi()` (`src/routes/api/public/
csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde
`503 "CSI non raggiungibile"` (o serve la cache se ce n'è una) — nessun crash, ma nessun
dato nuovo finché il portale non torna. **Effetto sulla suite test**: i test di
`test/integration/api.test.ts` che leggono il CSI reale sondano `/api/public/csi` una
volta prima di partire; se risponde con errore li salta (`salta()`, non `prova()`) invece
di farli fallire, loggando il motivo — la suite resta verde durante un'indisponibilità
temporanea del portale, senza che quei 5 test vengano cancellati o disattivati in modo
permanente: tornano a girare da soli non appena il CSI risponde di nuovo con `200`.
7. **I loghi delle squadre sono "hotlinked" direttamente dal browser al portale CSI**
(`LogoSquadra` in `DettaglioCsi.tsx` punta a `logoAvversario`, un URL
`livescore.csibologna.it/images/...`). È un'eccezione consapevole alla regola "nessuna
chiamata dal browser al CSI": un'immagine, a differenza dei dati, non ha bisogno di
passare dalla cache server per restare aggiornata, e proxarla/cacherla lato server per
ogni squadra avversaria (potenzialmente decine a stagione) sarebbe uno sforzo sproporzionato
al beneficio. Se un logo non carica (URL cambiato, squadra senza foto),
`LogoSquadra` si nasconde da sola (`onError``null`) invece di mostrare un'icona rotta.
8. **L'ordine "titolari"/"A disposizione" in `parseFormazioni()` è quello del referto CSI,
non necessariamente il sestetto che è sceso davvero in campo al fischio d'inizio.** Il
CSI non separa esplicitamente "chi ha giocato titolare" da "chi era comunque convocato e
in lista gara": il divisore "A DISPOSIZIONE" nella pagina sembra riflettere l'ordine di
inserimento nel referto più che le sostituzioni reali. Va quindi presentato come "referto
del CSI", non come cronaca esatta di chi ha giocato quanto.
---
## Evoluzioni possibili
- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili).
- Creazione automatica degli eventi partita da calendario CSI.
- Creazione automatica degli eventi partita da calendario CSI — risolverebbe anche il
limite 4 di sopra: ogni gara avrebbe un evento CrAPP e andrebbe sempre su `/partita/$id`,
senza più bisogno di `/partita-csi/$id` per le gare "orfane".
- Confronto tra i parziali ufficiali e quelli dello Scout Live.
- Tabellone a eliminazione della fase finale di Coppa (limite 4): oggi non tracciato, il
`project_id` figlio (es. `905`) andrebbe scoperto a runtime leggendo il link dentro
`project-sheets.php?project_id=848` invece di essere una costante.
+15 -5
View File
@@ -1,6 +1,6 @@
# Modulo — Votazione MVP
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
---
@@ -37,6 +37,9 @@ restano nel database ma non vengono più letti da nessuna schermata).
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
incoronare nessuno, nemmeno senza concorrenza.
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
nel profilo e in home.
@@ -48,10 +51,17 @@ restano nel database ma non vengono più letti da nessuna schermata).
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
vincolo è che non sia il votante stesso (`mvp_no_autovoto`): che votante e votato fossero
presenti a quella partita, e che siano passate due ore dall'inizio, restano filtri solo
applicativi — chi scrive su PostgREST li aggira.
- In caso di parità, nessun MVP viene assegnato per quella partita.
vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`).
Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia
il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
= tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti**
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque.
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella
partita.
---
+8 -4
View File
@@ -81,10 +81,14 @@ route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi
è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna
variabile d'ambiente.
| Route | Controllo | Chi la chiama |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante riservato agli admin |
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
| Route | Controllo | Chi la chiama |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
---
+142 -29
View File
@@ -1,6 +1,7 @@
# Modulo — Obiettivi di squadra
**Stato:** implementato (v1.0), con costanti stagionali da aggiornare a mano
**Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano,
copertura test completa (unit + integration) su tutti e 10 gli obiettivi.
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
---
@@ -15,47 +16,159 @@ comportamenti di squadra oltre alla singola prestazione.
## Dati
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts` che legge dati
già aggregati altrove (`risposte_presenze`, `pagelle_voti`, i risultati ufficiali CSI, le
serie).
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts`
(`obiettiviSquadra()`) che legge dati già aggregati altrove (`risposte_presenze`,
`pagelle_voti`, i risultati ufficiali CSI, le serie di presenza). `obiettiviOrdinati()` li
ordina mettendo i completati in coda e gli altri per progresso decrescente.
Non c'è nessuno stato da tenere sincronizzato quando un evento viene cancellato: gli obiettivi
sono ricalcolati da zero a ogni render partendo dall'elenco eventi corrente, quindi un evento
sparito da `eventi_app` smette semplicemente di contare, senza bisogno di nessuna pulizia
esplicita. Il problema che *sembrava* riguardare gli obiettivi era in realtà nelle tabelle
collegate a un evento (presenze, pagelle, MVP, ecc.), che restavano orfane a database dopo la
cancellazione: risolto a livello database con un trigger (migration
`m14_pulizia_dati_evento_cancellato`, DD-029), non nel modulo Obiettivi.
`obiettiviSquadra(rosa, ctx, oggi)` accetta un terzo parametro opzionale `oggi: Date` (default
`new Date()`) per iniettare una data deterministica nei test — usato dai due obiettivi con mese
corrente dinamico (vedi sotto).
---
## Obiettivi definiti
| Obiettivo | Calcolo | Target | Fonte |
| ---------------------------------- | ----------------------------------------------- | ------ | ---------------------------------------------- |
| 90% presenze ad agosto | risposte presente/ritardo sugli eventi del mese | 90% | `risposte_presenze` |
| Tutti rispondono alle convocazioni | risposte totali / eventi possibili | 90% | `risposte_presenze` |
| 250 presenze complessive | somma presenze di tutta la rosa | 250 | aggregato da `useRosa()` |
| Media pagelle da 7.5 | media di squadra | 7.5 | `pagelle_voti` |
| 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
| Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 | `serieAllenamenti` |
| 1 / 5 / 10 vittorie in campionato | partite vinte da dati CSI ufficiali | 1/5/10 | modulo [Collegamento CSI](collegamento-csi.md) |
| 1 evento di squadra al mese | eventi di tipo "evento" nel mese | 1 | `eventi_app` |
| id | Obiettivo | Calcolo | Target | Fonte |
| ----- | ----------------------------------- | ----------------------------------------------------- | ----------------------- | ------------------------------------------ |
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
smart (`notifiche-smart.ts`).
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
---
## Limiti noti
## Obiettivi mensili — mese dinamico
- "Continuità di squadra" dipende da `serieAllenamenti` (vedi
[Serie di presenze](serie-presenze.md)), calcolato sui dati reali: un evento passato senza
risposta vale come assenza e azzera la serie, quindi l'obiettivo misura anche quanto la
squadra risponde alle convocazioni, non solo la presenza.
- **Il mese di riferimento è una costante fissa nel codice** (agosto 2026): gli obiettivi
legati al mese corrente vanno aggiornati manualmente a ogni cambio di mese o stagione, oggi
sono "congelati" su un mese già passato.
- Le vittorie di campionato dipendono dal parsing HTML del portale CSI: se quel parsing si
rompe, questi tre obiettivi restano a 0% anche a fronte di vittorie reali.
- I target (250 presenze, 200 pagelle, ecc.) sono costanti fisse, da rivedere manualmente a
ogni stagione.
`o1` ("90% presenze del mese") e `o6` ("1 evento di squadra al mese") si azzerano
automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente
(fuso Europe/Rome, `meseCorrente(oggi)`), non più una costante fissa. Per `o1`, titolo
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
seguono di conseguenza.
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
anch'essa l'ultimo giorno del mese corrente (`fineMese(oggi)`), non più una data fissa.
---
## Evoluzioni possibili
## Continuità di squadra — il target 12 è il minimo per un 6vs6
- Calcolare il mese di riferimento dinamicamente invece di una costante hardcoded.
Il target di 12 giocatori con almeno 3 allenamenti consecutivi (`o11`) **non è arbitrario**: è
il numero minimo di giocatori per schierare due sestetti (6 contro 6) in allenamento. A
differenza degli altri target fissi, non va scalato in proporzione alla rosa se questa cambia
dimensione — resta 12 finché l'obiettivo è "riuscire ad allenarsi in modo completo".
Dipende da `serieAllenamenti` (vedi [Serie di presenze](serie-presenze.md)), calcolato sui dati
reali: un evento passato senza risposta vale come assenza e azzera la serie, quindi l'obiettivo
misura anche quanto la squadra risponde alle convocazioni, non solo la presenza fisica.
---
## Vittorie in campionato (o3/o4/o5) — dipendenza dal portale CSI
Le vittorie (`ctx.vittorie`) arrivano dal **JSON** delle partite del portale CSI Bologna
(`getEventsByTeamId.php`, non la pagina HTML della classifica), tramite
`partiteGiocate(csi.partite).filter(p => p.setNostri > p.setLoro)` calcolato in
`src/lib/rosa.ts` (`useObiettivi()`). `o3`/`o4`/`o5` sono lo stesso numero di vittorie letto a
tre soglie diverse (1/5/10), ciascuna cappata con `Math.min` — nessuna delle tre supera mai il
proprio target, nemmeno con più vittorie di quante ne servano.
### Il limite: il parsing del JSON può rompersi in silenzio
`result` e `partials` nella risposta di `getEventsByTeamId.php` sono stringhe libere tipo
`"3-1"`, lette con un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il
portale CSI cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la
regex non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata" — **senza
errori**. Se la risposta cambiasse forma radicalmente (non più un array), `partiteDaEventi()`
torna `[]`. In entrambi i casi `o3`/`o4`/`o5` restano bloccati a 0% anche a fronte di vittorie
reali, e il fallback della route (`/api/public/csi`) non se ne accorgerebbe da solo: lancia un
errore solo se *sia* la classifica *sia* le partite sono vuote insieme, quindi se si rompe solo
il JSON delle partite mentre la classifica HTML continua a funzionare, la route risponde
comunque `200` con `partite: []`.
### Come è mitigato oggi
- **`partiteFormatoSospetto()`** (`csi-core.ts`) confronta gli eventi grezzi ricevuti con il
risultato di `partiteDaEventi()`: se ci sono eventi ma nessuno è stato riconosciuto come
nostra partita, il formato è quasi certamente cambiato (distingue così un vero "formato
rotto" da un legittimo "nessuna gara ancora in programma", dove gli eventi grezzi sono vuoti
anche loro).
- La route (`src/routes/api/public/csi.ts`) logga un `console.error` quando succede.
- Il flag viaggia anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
(`src/routes/classifica.tsx`), dove sostituisce la riga "Dati CSI aggiornati alle..." con un
badge discreto color warning ("Il portale CSI potrebbe aver cambiato formato: dati da
verificare.") — visibile a chi apre la pagina campionato, non solo nei log del server.
### Come fixarlo, se succede
1. **Vedere il nuovo formato**: guardare la risposta reale dell'endpoint, o lanciare
`CSI_LIVE=1 bun test/unit/csi-core.test.ts` (interroga il portale vero).
2. **Aggiornare il parsing** in `src/lib/csi-core.ts`: quasi sempre basta toccare
`punteggio()`/`parziali()` (le regex sul formato del punteggio) o i nomi dei campi letti in
`partiteDaEventi()`. Il resto dell'app consuma solo i tipi già puliti che questo file
produce (`DatiCsi`, `PartitaCsi[]`), quindi il fix resta isolato.
3. Serve toccare anche `src/routes/api/public/csi.ts` solo se cambiano gli **URL/endpoint**
stessi o serve autenticazione — non per un semplice cambio di formato dei dati.
4. **Aggiornare i test**: `test/unit/csi-core.test.ts` con fixture nel nuovo formato, altrimenti
restano verdi contro un formato che non esiste più.
Dettagli completi (endpoint, identificativi di stagione, altri limiti del collegamento CSI) in
[Collegamento CSI](collegamento-csi.md).
---
## Copertura test
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
da un backend reale, non solo funzione pura con contesto costruito a mano).
| Obiettivi | Unit test | Integration test |
| ------------ | -------------------------------- | ------------------------------------------------------------ |
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
(evento dentro/fuori mese), scadenza dinamica.
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
della tabella (`pagelle_no_autovoto`, `pagelle_voto_range`), li rilegge, verifica media
arrotondata e conteggio.
- **o3/o4/o5** (CSI reale, non Supabase — le vittorie non toccano il database): estende
`test/integration/api.test.ts`, che già chiama `/api/public/csi` dal vivo. Legge le vittorie
vere del giorno con la stessa logica di `useObiettivi()`, le passa a `obiettiviSquadra()` e
verifica cap e target su dati reali.
Per rilanciare tutto: `npm run test` (unit, nessuna rete) e `npm run test:integration`
(richiede `npx supabase start` per o1/o2/o6/o7/o11/o12/o13, e rete verso CSI Bologna per
o3/o4/o5 — quest'ultimo gira comunque anche senza stack Supabase locale).
+25 -12
View File
@@ -1,6 +1,6 @@
# Modulo — Pagelle
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/pagelle.ts`, `src/components/crapp/Pagelle.tsx`
---
@@ -29,9 +29,19 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
- `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
volte, l'ultimo voto sovrascrive il precedente.
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
tutti i voti della stagione; `pagellePartita()` la calcola per singola partita;
`mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in `squadra.tsx`.
- `useRosa()` inietta la media stagionale nel campo `mediaVoto` di ogni giocatore.
**tutti i voti mai ricevuti** — l'app non ha un concetto di stagione/reset, quindi non è
"la media di questa stagione" ma lo storico completo; `pagellePartita()` la calcola per
singola partita; `mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in
`squadra.tsx`.
- `useRosa()` inietta questa media storica nel campo `mediaVoto` di ogni giocatore, insieme al
numero di voti ricevuti (`votiPagella`) — usato dal badge Pagellone (vedi
[badge.md](badge.md)) per richiedere un minimo di voti prima che la media conti, e mostrato
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
- La StatTile **home** applica la stessa soglia del badge Pagellone (DD-028) tramite la
funzione pura `mediaVotoColpoDOcchio()`: sotto `VOTI_MINIMI_PAGELLA` voti ricevuti mostra
`—` invece della media, non solo quando i voti sono zero. È stata estratta come funzione
testabile (coerente con DD-020) invece di restare una condizione inline nella route. Le
StatTile di **profilo** e **squadra** non applicano questa soglia (vedi "Limiti noti").
---
@@ -39,25 +49,28 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
- Anti auto-voto imposto anche a livello database (constraint, non solo filtro UI).
- L'admin può marcare un evento come `pagelleChiuse` (`eventi.ts`), che nasconde i bottoni di
voto in UI.
voto in UI **e**, da M13, rifiuta anche a database un voto scritto dopo la chiusura (RLS
`evento_permette_voto()`, `pagelle_voti`).
- Da M13 anche il votante e il votato devono essere convocati all'evento: verificato a
database, non solo in UI (stessa RLS di sopra).
---
## Limiti noti
- **`pagelleChiuse` è solo un flag UI**: nessuna policy RLS lo controlla, quindi un voto
"fuori tempo" resta tecnicamente possibile bypassando l'interfaccia.
- **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id`
sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non
mostra mai il votante, ma il dato non è né aggregato né mascherato lato server.
- Nessun controllo a livello database che il votante sia realmente un convocato della
partita: solo filtro applicativo.
- La media non richiede un numero minimo di voti: con un solo voto ricevuto, la media
coincide con quel voto.
- La media mostrata nel **profilo** e in **squadra** non richiede un numero minimo di voti:
con un solo voto ricevuto, la media coincide con quel voto. Il badge Pagellone (`badge.md`)
e la StatTile **home** (DD-028) applicano invece la stessa soglia minima prima di
considerarla — profilo e squadra no.
- Le due regole di M13 (convocazione, `pagelle_chiuse`) valgono solo per la policy "Ognuno
gestisce i propri voti pagella": un amministratore può ancora correggere un voto fuori
convocazione o dopo la chiusura, di proposito (deve poter sistemare un errore).
---
## Evoluzioni possibili
- Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati.
- Far rispettare `pagelleChiuse` anche via RLS.
+17 -8
View File
@@ -1,6 +1,6 @@
# Modulo — Palloni
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/palloni.ts`, `src/lib/palloni-core.ts`,
`src/components/crapp/TurnoPalloni.tsx`, `src/components/crapp/PromemoriaPalloni.tsx`,
`src/routes/api/public/promemoria-palloni.ts`
@@ -33,11 +33,17 @@ compaiono.
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
upsert su `evento_id`.
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
ricalcolato a runtime da `conteggioTurni()` su turni salvati **più proposte non ancora
confermate** (partite/eventi) — non è uno storico in tabella dedicata. Conta solo gli
eventi già passati (`e.data < oggi`, stesso criterio delle presenze): un turno assegnato
in anticipo per un allenamento futuro non è ancora "portato", quindi non sale finché quel
giorno non arriva.
ricalcolato a runtime da `conteggioTurni()` sui **soli turni confermati** (`turniSalvati`
in `rosa.ts`) — non è uno storico in tabella dedicata, ma non include le proposte
automatiche di `completaTurni()` (quelle restano solo per la UI di rotazione,
`TurnoPalloni.tsx`/`PromemoriaPalloni.tsx`). Conta solo gli eventi già passati (`e.data <
oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un allenamento
futuro non è ancora "portato", quindi non sale finché quel giorno non arriva.
- `serieConsecutivaPalloni()` (`palloni-core.ts`) calcola le volte **consecutive** in cui il
giocatore ha portato i palloni (`Giocatore.seriePalloni` in `rosa.ts`), mostrate nel
sottotitolo della classifica interna di Squadra quando si ordina per Palloni. Stesso
criterio "solo eventi già passati" di `conteggioTurni()`; un evento passato senza turno
confermato non spezza la serie di nessuno (viene saltato, non conta come "non portati").
- `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx`
è il banner in Home per il giocatore di turno.
@@ -62,10 +68,13 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
- Il conteggio dei turni include anche le proposte non confermate: badge e statistiche
possono contare turni mai effettivamente convalidati da nessuno.
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
"assente" o "infortunato" per quell'evento.
- **`conteggioTurni()` non filtra per tipo evento** (a differenza di `eventiPalloni()`, che
scarta i compleanni): guarda solo `e.data < oggi`. Un turno registrato per errore su un
evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa combinazione — ma
il comportamento attuale è pinnato da un test dedicato in `palloni-core.test.ts`.
---
+9 -1
View File
@@ -1,6 +1,6 @@
# Modulo — Presenze
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/presenze.ts`, `src/lib/presenze-mese.ts`, `src/components/crapp/RosaPresenze.tsx`,
`src/components/crapp/EventoCard.tsx`, `src/routes/api/public/sollecita-presenze.ts`
@@ -52,6 +52,14 @@ contaPresenzeGiocatore() / totaliEventiGiocatore() → src/lib/presenze.ts
usePresenzeUltimoMese() → src/lib/presenze-mese.ts
(percentuale ultimi 30gg, da cache già in memoria)
contaPresenzeGiocatore() alimenta il campo `presenze` del `Giocatore` in `useRosa()`, mostrato
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
contaPartiteGiocate() è contaPresenzeGiocatore() ristretto alle sole partite (non
allenamenti): alimenta `Giocatore.partiteGiocate`, usato nel sottotitolo della classifica
interna di Squadra quando si ordina per MVP — un conteggio di eventi generico (allenamenti
compresi) sarebbe fuorviante lì, perché l'MVP si vota solo alle partite.
--- sollecito (solo admin) ---
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
+16 -2
View File
@@ -1,5 +1,11 @@
# Modulo — Profilo Giocatore
**Stato:** implementato
**File principali:** `src/lib/profili.ts`, `src/lib/profili-core.ts`, `src/routes/profilo.tsx`,
`src/routes/admin.tsx`
---
## Obiettivo
Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amministrative e documentali di ciascun membro della squadra.
@@ -159,9 +165,17 @@ Contiene.
## Dashboard amministratore
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
Profilo → Opzioni).
Profilo → Opzioni), organizzata in tab scorrevoli a pillole (`BarraSottosezioni`, stesso
componente di [Squadra](squadra.md) e Campionato): Squadra, Profili, Disattivati (solo se
c'è almeno un giocatore disattivato) e Notifiche.
Per ogni giocatore vengono mostrati.
La tab **Notifiche** mostra quanti giocatori attivi hanno almeno un dispositivo iscritto
alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (vedi
[Notifiche](notifiche.md)). È solo consultiva: l'attivazione resta un gesto che ogni
giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
di altri.
Per ogni giocatore, nella tab Profili, vengono mostrati.
- Stato del profilo
- Certificato medico
+1 -1
View File
@@ -1,6 +1,6 @@
# Modulo — Scout Live
**Stato:** implementato (v1.0, fix M7 per la persistenza condivisa)
**Stato:** implementato (fix M7 per la persistenza condivisa)
**File principali:** `src/lib/scout-live.ts`, `src/lib/scout-stato.ts`, `src/lib/scout-store.ts`,
`src/lib/scout-export.ts`, `src/lib/cacche.ts`, `src/components/crapp/ScoutEntry.tsx`,
`src/components/crapp/SondaggioCacche.tsx`, `src/routes/scout.tsx`, `src/routes/partita.$id.tsx`
+2 -1
View File
@@ -5,7 +5,8 @@
`src/components/crapp/SerieCard.tsx`
**Migration collegata:** `m9_risposte_presenze_risposto_il`
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`,
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`)
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`),
`test/integration/serie-allenamenti-badge.test.ts`, `test/integration/serie-conferme-badge.test.ts`
---
+89
View File
@@ -0,0 +1,89 @@
# Modulo — Squadra
**Stato:** implementato
**File principali:** `src/lib/giocatori-squadra.ts`, `src/lib/giocatori-squadra.server.ts`,
`src/lib/rosa.ts`, `src/routes/squadra.tsx`, `src/routes/admin.tsx` (sezione rosa)
**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/rosa.test.ts`
---
## Obiettivo
Tenere l'anagrafica della rosa (nome, numero di maglia, ruolo, chi è collegato a quale
account) in un unico posto — `giocatori_squadra` — e farla usare a tutte le schermate che
hanno bisogno di sapere "chi c'è in squadra", invece di ciascuna avere la propria copia.
Prima di [DD-015](../DESIGN_DECISIONS.md#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database)
la lista viveva hardcoded in `src/lib/crapp-data.ts`: aggiungere o disattivare un
giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
---
## Due letture diverse, per non pagare due volte lo stesso costo
- **`useAnagraficaRosa()`** (`rosa.ts`) — solo id, nome, ruolo, numero, data di nascita dei
giocatori `attivo`. Serve dove basta sapere chi c'è, es. i compleanni nel Calendario o le
liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni.
- **`useRosa()`** (`rosa.ts`) — la stessa anagrafica arricchita con tutte le statistiche
personali calcolate a runtime: presenze, partite giocate, serie (presenze, allenamenti,
partite, conferme, palloni), MVP vinti, media voto pagelle, palloni, cacche, infortuni,
ritardi. Non fa query aggiuntive: combina in un `useMemo` le cache già in memoria di
`mvp-voti.ts`, `pagelle.ts`, `cacche.ts`, `palloni.ts`, `infortuni.ts`, `presenze.ts`,
`eventi.ts` — la spec di ciascuna di queste statistiche sta nel modulo relativo
(`mvp.md`, `pagelle.md`, `palloni.md`, `infortuni.md`, `presenze.md`). `useRosa()` è anche
la base di `useIo()` (il giocatore sul dispositivo corrente) e `useObiettivi()`
(`obiettivi-squadra.md`).
Entrambe filtrano solo i giocatori `attivo`: chi ha lasciato la squadra resta nel database
(presenze, voti, pagelle e badge della stagione restano agganciati al suo id) ma sparisce
dagli elenchi correnti.
## Gestione dati squadra (solo amministratore)
Da `/admin` un amministratore può ([DD-017](../DESIGN_DECISIONS.md#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore)):
| Azione | Hook | Effetto |
| ---------------------- | ------------------------ | ------------------------------------------------------------- |
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database |
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
accesso con Google, per corrispondenza email
([DD-018](../DESIGN_DECISIONS.md#dd-018--collegamento-automatico-giocatoreaccount-per-email)).
`useCollegaGiocatore()` esiste per completare quel flusso, non per una scelta libera
dell'admin.
Le regole di validazione (`validaDatiSquadra()`, `numeroGiaUsato()`) rispecchiano i vincoli
della tabella (numero maglia univoco tra gli attivi, campi obbligatori): l'obiettivo è
mostrare un messaggio leggibile invece di far arrivare un errore Postgres grezzo
all'amministratore.
## Classifica interna di Squadra
La tab "Stats" di `/squadra` mostra una classifica interna ordinabile per 5 criteri
(`CriterioClassifica` in `rosa.ts`): presenze, media voto, MVP, palloni, cacche/partita.
`classificaRank()` calcola un "dense rank" (a parità di valore stessa posizione, il
successivo non salta — 1, 1, 2, non 1, 1, 3); `dettaglioClassifica()` sceglie quale
sottostatistica mostrare sotto il nome, coerente col criterio selezionato (es. "voti
pagella" per il criterio media voto, non sempre "presenze consecutive").
Le altre tab di `/squadra` (Rosa, Obiettivi, Badge) sono viste diverse sugli stessi dati di
`useRosa()`/`useObiettivi()`/`badges.ts`: non introducono altra logica di dominio, solo
presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squadra.md`.
---
## Limiti noti
1. **`giocatori_squadra` non ha ancora una colonna per la data di nascita.** Per i
giocatori storici (seed iniziale) la nascita viene letta da `crapp-data.ts`
(`nascitaPerId`, lookup per id); un giocatore aggiunto dopo la migrazione non ha nascita
nota finché la colonna non esiste (DD-015). Effetto visibile: niente compleanno nel
Calendario per quei giocatori.
2. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database
non risponde o non è ancora popolato, `rosaFallback()` genera una rosa di riserva dai
dati statici storici. Un ambiente nuovo senza dati in `giocatori_squadra` mostra quindi
comunque una squadra, non una schermata vuota — ma è la rosa 2025/26 hardcoded, non
quella reale.
+1 -1
View File
@@ -6,7 +6,7 @@ import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", ".output", ".vinxi"] },
{ ignores: ["dist", ".output", ".vinxi", "pocketbase/pb_data"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
+7 -4
View File
@@ -1,6 +1,6 @@
{
"name": "crapp",
"version": "0.9.0",
"version": "0.9.2",
"private": true,
"sideEffects": false,
"type": "module",
@@ -14,10 +14,12 @@
"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"
"test:all": "bun test/run.ts all",
"pocketbase:start": "docker compose -f docker-compose.pocketbase.yml up -d",
"pocketbase:stop": "docker compose -f docker-compose.pocketbase.yml down",
"pocketbase:reset": "bash scripts/pocketbase-reset.sh"
},
"dependencies": {
"@lovable.dev/cloud-auth-js": "^1.1.2",
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.101.1",
@@ -28,6 +30,7 @@
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"motion": "^13.2.0",
"pocketbase": "^0.28.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"sonner": "^2.0.7",
@@ -39,7 +42,7 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@lovable.dev/vite-tanstack-config": "2.8.5",
"@tanstack/devtools-vite": "^0.8.3",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
View File
@@ -0,0 +1,49 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Equivalente del trigger enforce_giocatori_squadra_update (M1/M5/M8, DD-016/DD-018):
// un non-admin può aggiornare uno slot di giocatori_squadra solo per reclamarlo (slot
// libero, email dell'account uguale a quella registrata sulla riga), senza cambiare nessun
// altro campo. La updateRule della collection apre la porta solo quando auth_user_id è
// vuoto o l'utente è admin; qui si valida il resto.
onRecordUpdateRequest((e) => {
// Superuser (pbAdmin lato server, dashboard, script di migrazione dati) e admin
// applicativi (role="admin" sulla collection users) bypassano il vincolo di claim.
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (isAdmin) {
e.next();
return;
}
const originale = e.app.findRecordById("giocatori_squadra", e.record.id);
const campiBloccati = [
"nome",
"cognome",
"numero",
"ruolo",
"attivo",
"email",
"numero_tessera",
"data_tessera",
];
for (const campo of campiBloccati) {
if (String(e.record.get(campo)) !== String(originale.get(campo))) {
throw new BadRequestError("Aggiornamento non autorizzato su giocatori_squadra");
}
}
const utenteId = e.auth ? e.auth.id : "";
const emailAccount = (e.auth ? e.auth.get("email") || "" : "").toLowerCase();
const emailSlot = (originale.get("email") || "").toLowerCase();
const slotLibero = !originale.get("auth_user_id");
const claimSuAccountCorrente = e.record.get("auth_user_id") === utenteId;
const emailCombacia = emailSlot !== "" && emailSlot === emailAccount;
if (!slotLibero || !claimSuAccountCorrente || !emailCombacia) {
throw new BadRequestError("Aggiornamento non autorizzato su giocatori_squadra");
}
e.next();
}, "giocatori_squadra");
+23
View File
@@ -0,0 +1,23 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Il Client ID/Secret di Google OAuth2 configurati da dashboard vivono in pb_data e
// andrebbero persi a ogni "npm run pocketbase:reset" (azzera pb_data). Questo hook li
// reimposta a ogni avvio da variabili d'ambiente (mai committate, solo in .env), così
// sopravvivono al reset senza reinserirli a mano ogni volta dalla dashboard.
onBootstrap((e) => {
e.next();
const clientId = $os.getenv("GOOGLE_OAUTH_CLIENT_ID");
const clientSecret = $os.getenv("GOOGLE_OAUTH_CLIENT_SECRET");
if (!clientId || !clientSecret) {
return;
}
const collection = e.app.findCollectionByNameOrId("users");
collection.oauth2.enabled = true;
collection.oauth2.providers = [
{ name: "google", clientId: clientId, clientSecret: clientSecret },
];
e.app.save(collection);
});
@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Equivalente del trigger risposte_presenze_risposto_il_immutabile (M9): risposto_il è
// l'istante della PRIMA risposta, scritto una sola volta e mai più modificabile — serve
// per la serie "Conferme 24h" (confronto con eventi_app.created).
onRecordCreateRequest((e) => {
e.record.set("risposto_il", new Date().toISOString());
e.next();
}, "risposte_presenze");
onRecordUpdateRequest((e) => {
const originale = e.app.findRecordById("risposte_presenze", e.record.id);
e.record.set("risposto_il", originale.get("risposto_il"));
e.next();
}, "risposte_presenze");
+137
View File
@@ -0,0 +1,137 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Equivalente di evento_permette_voto() + i CHECK "no autovoto" (M12/M13): applicato a
// mvp_voti, pagelle_voti, badge_social_voti su create/update. Gli admin non hanno questi
// vincoli (possono correggere un voto anche fuori convocazione/dopo la chiusura pagelle).
// convocati vuoto = "tutta la rosa" (stessa convenzione di convocatiEvento() in eventi.ts).
//
// La validazione è ripetuta in ciascun callback (non estratta in una funzione condivisa a
// livello di file): ogni hook di PocketBase viene eseguito nel proprio contesto JS isolato,
// che non vede le funzioni dichiarate fuori dal callback stesso (verificato empiricamente:
// una funzione condivisa causa "ReferenceError: ... is not defined" a runtime).
onRecordCreateRequest((e) => {
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (!isAdmin) {
const votanteId = e.record.get("votante");
const votatoId = e.record.get("votato");
if (votanteId === votatoId) {
throw new BadRequestError("Non puoi votare te stesso");
}
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
const convocati = evento.get("convocati") || [];
if (
convocati.length > 0 &&
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
) {
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
}
}
e.next();
}, "mvp_voti");
onRecordUpdateRequest((e) => {
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (!isAdmin) {
const votanteId = e.record.get("votante");
const votatoId = e.record.get("votato");
if (votanteId === votatoId) {
throw new BadRequestError("Non puoi votare te stesso");
}
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
const convocati = evento.get("convocati") || [];
if (
convocati.length > 0 &&
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
) {
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
}
}
e.next();
}, "mvp_voti");
onRecordCreateRequest((e) => {
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (!isAdmin) {
const votanteId = e.record.get("votante");
const votatoId = e.record.get("votato");
if (votanteId === votatoId) {
throw new BadRequestError("Non puoi votare te stesso");
}
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
const convocati = evento.get("convocati") || [];
if (
convocati.length > 0 &&
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
) {
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
}
if (evento.get("pagelle_chiuse")) {
throw new BadRequestError("Le pagelle per questo evento sono chiuse");
}
}
e.next();
}, "pagelle_voti");
onRecordUpdateRequest((e) => {
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (!isAdmin) {
const votanteId = e.record.get("votante");
const votatoId = e.record.get("votato");
if (votanteId === votatoId) {
throw new BadRequestError("Non puoi votare te stesso");
}
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
const convocati = evento.get("convocati") || [];
if (
convocati.length > 0 &&
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
) {
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
}
if (evento.get("pagelle_chiuse")) {
throw new BadRequestError("Le pagelle per questo evento sono chiuse");
}
}
e.next();
}, "pagelle_voti");
onRecordCreateRequest((e) => {
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (!isAdmin) {
const votanteId = e.record.get("votante");
const votatoId = e.record.get("votato");
if (votanteId === votatoId) {
throw new BadRequestError("Non puoi votare te stesso");
}
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
const convocati = evento.get("convocati") || [];
if (
convocati.length > 0 &&
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
) {
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
}
}
e.next();
}, "badge_social_voti");
onRecordUpdateRequest((e) => {
const isAdmin = e.hasSuperuserAuth() || (e.auth && e.auth.get("role") === "admin");
if (!isAdmin) {
const votanteId = e.record.get("votante");
const votatoId = e.record.get("votato");
if (votanteId === votatoId) {
throw new BadRequestError("Non puoi votare te stesso");
}
const evento = e.app.findRecordById("eventi_app", e.record.get("evento"));
const convocati = evento.get("convocati") || [];
if (
convocati.length > 0 &&
(convocati.indexOf(votanteId) === -1 || convocati.indexOf(votatoId) === -1)
) {
throw new BadRequestError("Votante e votato devono essere convocati all'evento");
}
}
e.next();
}, "badge_social_voti");
@@ -0,0 +1,665 @@
/// <reference path="../pb_data/types.d.ts" />
//
// Schema iniziale PocketBase per CrAPP — solo ambiente locale (Docker).
//
// Non è la riproduzione 1:1 delle 27 migration Supabase in supabase/migrations/: rappresenta
// lo STATO FINALE attuale dello schema (fonte: docs/DATABASE.md + lettura diretta delle
// migration Supabase più recenti), riscritto nel modello PocketBase (collection + campi +
// API rules). Le tabelle Supabase non più usate dal codice (`giocatori`, `eventi`/`presenze`
// "nuovo modello" mai adottato, `promemoria_push` deprecata) non vengono portate.
//
// `user_roles` non diventa una collection: il ruolo admin/user è un campo diretto sulla
// collection auth nativa `users` (decisione 3 del piano di migrazione).
//
// La logica procedurale che in Postgres viveva in trigger/funzioni (claim-slot su
// giocatori_squadra, blocco autovoto, convocati/pagelle_chiuse, cascata su cancellazione
// evento, risposto_il immutabile) NON sta qui: è in pb_hooks/*.pb.js, perché le API rules di
// PocketBase sono espressioni, non codice procedurale.
migrate(
(app) => {
// ---------------------------------------------------------------------------------------
// users (collection auth nativa): aggiunge il campo ruolo che sostituisce user_roles.
// ---------------------------------------------------------------------------------------
const users = app.findCollectionByNameOrId("users");
users.fields.add(
new Field({
name: "role",
type: "select",
required: true,
values: ["user", "admin"],
maxSelect: 1,
}),
);
// Ogni utente autenticato legge il proprio record; l'admin (verificato lato hook al primo
// login, poi qui) può leggere tutti. Il ruolo lo scrive solo chi è già admin.
users.listRule = "id = @request.auth.id || @request.auth.role = 'admin'";
users.viewRule = "id = @request.auth.id || @request.auth.role = 'admin'";
users.updateRule = "id = @request.auth.id";
app.save(users);
// ---------------------------------------------------------------------------------------
// giocatori_squadra — anagrafica operativa della squadra (DD-015, DD-016, DD-018)
// ---------------------------------------------------------------------------------------
const giocatori = new Collection({
name: "giocatori_squadra",
type: "base",
fields: [
// Id testuali corti (g1..gN, come in Supabase): il vincolo di default di PocketBase
// (minimo 15 caratteri) va rilassato esplicitamente.
{ name: "id", type: "text", min: 1, max: 40, pattern: "^g[0-9]+$" },
{ name: "nome", type: "text", required: true },
{ name: "cognome", type: "text", required: true },
{ name: "numero", type: "number", required: true, min: 1 },
{ name: "ruolo", type: "text", required: true },
{
name: "auth_user_id",
type: "relation",
required: false,
collectionId: users.id,
maxSelect: 1,
},
{ name: "email", type: "email", required: false },
{ name: "attivo", type: "bool", required: false },
{ name: "numero_tessera", type: "text", required: false },
{ name: "data_tessera", type: "date", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_giocatori_squadra_auth_user ON giocatori_squadra (auth_user_id) WHERE auth_user_id != ''",
"CREATE UNIQUE INDEX idx_giocatori_squadra_email ON giocatori_squadra (email) WHERE email != ''",
],
// Lettura: rosa attiva a tutti gli autenticati, tutta la rosa agli admin (DD-015/M1).
listRule: "@request.auth.id != '' && (attivo = true || @request.auth.role = 'admin')",
viewRule: "@request.auth.id != '' && (attivo = true || @request.auth.role = 'admin')",
// Creazione slot: solo admin.
createRule: "@request.auth.role = 'admin'",
// Aggiornamento: admin senza vincoli, oppure claim di uno slot libero — il resto
// (che il claim tocchi solo auth_user_id e rispetti l'email) lo valida l'hook
// giocatori_squadra_claim.pb.js, la rule qui apre solo la porta.
updateRule: "@request.auth.role = 'admin' || (@request.auth.id != '' && auth_user_id = '')",
deleteRule: "@request.auth.role = 'admin'",
});
app.save(giocatori);
// ---------------------------------------------------------------------------------------
// profili_giocatore — dati personali/documento/certificato, 1:1 con giocatori_squadra
// ---------------------------------------------------------------------------------------
const profili = new Collection({
name: "profili_giocatore",
type: "base",
fields: [
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "data_nascita", type: "date", required: false },
{ name: "luogo_nascita", type: "text", required: false },
{ name: "indirizzo", type: "text", required: false },
{ name: "telefono", type: "text", required: false },
{ name: "email", type: "email", required: false },
{
name: "documento_tipo",
type: "select",
required: false,
maxSelect: 1,
values: ["Carta d'identità", "Passaporto", "Patente"],
},
{ name: "documento_numero", type: "text", required: false },
{ name: "documento_rilasciato_da", type: "text", required: false },
{ name: "documento_emissione", type: "date", required: false },
{ name: "documento_scadenza", type: "date", required: false },
{
name: "documento_fronte",
type: "file",
required: false,
protected: true,
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
},
{
name: "documento_retro",
type: "file",
required: false,
protected: true,
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
},
{ name: "certificato_scadenza", type: "date", required: false },
{
name: "certificato",
type: "file",
required: false,
protected: true,
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
},
{
name: "foto",
type: "file",
required: false,
protected: true,
maxSelect: 1,
maxSize: 10485760,
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
},
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_profili_giocatore_giocatore ON profili_giocatore (giocatore)",
],
// Documenti sanitari/identità: mai pubblici (DD-016 regola 4). Solo il giocatore
// proprietario o un admin, sia per i campi sia per i file allegati (le API rules
// governano anche il download dei file in PocketBase, non serve un bucket a parte).
listRule:
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
viewRule:
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
createRule:
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
updateRule:
"@request.auth.id != '' && (giocatore.auth_user_id = @request.auth.id || @request.auth.role = 'admin')",
deleteRule: "@request.auth.role = 'admin'",
});
app.save(profili);
// ---------------------------------------------------------------------------------------
// avatar_giocatori — foto profilo pubbliche (M6 avatar-giocatori). Collection separata
// da giocatori_squadra: la policy è "chiunque autenticato carica/sostituisce/elimina
// qualsiasi avatar" (nessun controllo per-proprietario, DD in docs/DATABASE.md), molto
// più permissiva delle regole di giocatori_squadra — mescolarle avrebbe o indebolito
// quelle o impedito il caricamento a chi non è admin.
// ---------------------------------------------------------------------------------------
const avatar = new Collection({
name: "avatar_giocatori",
type: "base",
fields: [
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "foto",
type: "file",
required: false,
maxSelect: 1,
maxSize: 5242880,
mimeTypes: ["image/jpeg"],
},
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_avatar_giocatori_giocatore ON avatar_giocatori (giocatore)",
],
// Bucket pubblico in Supabase: leggibile da chiunque, anche senza login.
listRule: "",
viewRule: "",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(avatar);
// ---------------------------------------------------------------------------------------
// eventi_app — eventi gestionali (partite/allenamenti/eventi squadra)
// ---------------------------------------------------------------------------------------
const eventi = new Collection({
name: "eventi_app",
type: "base",
fields: [
// Id testuali corti (e1..eN, come in Supabase).
{ name: "id", type: "text", min: 1, max: 40, pattern: "^e[0-9]+$" },
{
name: "tipo",
type: "select",
required: true,
maxSelect: 1,
values: ["partita", "allenamento", "evento"],
},
{ name: "titolo", type: "text", required: true },
{ name: "luogo", type: "text", required: false },
{ name: "data", type: "date", required: true },
{ name: "ora", type: "text", required: false },
{ name: "note", type: "text", required: false },
{
name: "convocati",
type: "relation",
required: false,
collectionId: giocatori.id,
maxSelect: 999,
},
{ name: "campionato", type: "bool", required: false },
{ name: "casa", type: "bool", required: false },
{ name: "pagelle_chiuse", type: "bool", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
// Lettura aperta a tutti gli autenticati; scrittura solo admin (rotta /eventi già
// riservata — M11/DD-023).
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.role = 'admin'",
updateRule: "@request.auth.role = 'admin'",
deleteRule: "@request.auth.role = 'admin'",
});
app.save(eventi);
// ---------------------------------------------------------------------------------------
// risposte_presenze — risposte dei giocatori agli eventi
// ---------------------------------------------------------------------------------------
const presenze = new Collection({
name: "risposte_presenze",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "stato", type: "text", required: true },
// Istante della PRIMA risposta (M9): valorizzato e reso immutabile dall'hook
// risposte_presenze_immutabili.pb.js, non scrivibile direttamente dal client.
{ name: "risposto_il", type: "date", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_risposte_presenze_evento_giocatore ON risposte_presenze (evento, giocatore)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
});
app.save(presenze);
// ---------------------------------------------------------------------------------------
// cacche_partita — sondaggio pre-partita (statistiche/badge segreti)
// ---------------------------------------------------------------------------------------
const cacche = new Collection({
name: "cacche_partita",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "quantita", type: "number", required: true, min: 0, max: 10 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_cacche_partita_evento_giocatore ON cacche_partita (evento, giocatore)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && giocatore.auth_user_id = @request.auth.id)",
});
app.save(cacche);
// ---------------------------------------------------------------------------------------
// mvp_voti / pagelle_voti / badge_social_voti — votazioni tra compagni
//
// Ownership (votante = chi scrive) è espressa qui via API rule. Autovoto e vincoli
// "convocato all'evento" / "pagelle non chiuse" (M12/M13) NON sono esprimibili in modo
// sicuro come semplice espressione di rule su relazioni multiple: li applica l'hook
// pb_hooks/voti_convocati.pb.js su create/update, per tutte e tre le collection.
// ---------------------------------------------------------------------------------------
const mvpVoti = new Collection({
name: "mvp_voti",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "votante",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{
name: "votato",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "votato_nome", type: "text", required: true },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_mvp_voti_evento_votante ON mvp_voti (evento, votante)"],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
});
app.save(mvpVoti);
const pagelleVoti = new Collection({
name: "pagelle_voti",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "votante",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{
name: "votato",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "voto", type: "number", required: true, min: 1, max: 10 },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_pagelle_voti_evento_votante_votato ON pagelle_voti (evento, votante, votato)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
});
app.save(pagelleVoti);
const badgeSocialVoti = new Collection({
name: "badge_social_voti",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "categoria", type: "text", required: true },
{
name: "votante",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{
name: "votato",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "votato_nome", type: "text", required: true },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_badge_social_voti_evento_categoria_votante ON badge_social_voti (evento, categoria, votante)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
updateRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
deleteRule:
"@request.auth.role = 'admin' || (@request.auth.id != '' && votante.auth_user_id = @request.auth.id)",
});
app.save(badgeSocialVoti);
// ---------------------------------------------------------------------------------------
// turni_palloni / scout_sessioni / scout_live / scout_partite — nessun gate in UI,
// aperte a qualsiasi autenticato (invariato da Supabase, vedi docs/DATABASE.md).
// ---------------------------------------------------------------------------------------
const turniPalloni = new Collection({
name: "turni_palloni",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "aggiornato_da", type: "text", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_turni_palloni_evento ON turni_palloni (evento)"],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(turniPalloni);
const scoutSessioni = new Collection({
name: "scout_sessioni",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{
name: "giocatore",
type: "relation",
required: true,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "giocatore_nome", type: "text", required: true },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_scout_sessioni_evento ON scout_sessioni (evento)"],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(scoutSessioni);
const scoutLive = new Collection({
name: "scout_live",
type: "base",
fields: [
{
name: "evento",
type: "relation",
required: true,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "stato", type: "json", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: ["CREATE UNIQUE INDEX idx_scout_live_evento ON scout_live (evento)"],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(scoutLive);
const scoutPartite = new Collection({
name: "scout_partite",
type: "base",
fields: [
// Id libero (come in Supabase, "id text PRIMARY KEY", generato dal client).
{ name: "id", type: "text", min: 1, max: 60 },
{
name: "evento",
type: "relation",
required: false,
collectionId: eventi.id,
maxSelect: 1,
cascadeDelete: true,
},
{ name: "data", type: "date", required: true },
{ name: "avversario", type: "text", required: true },
{ name: "casa", type: "bool", required: false },
{ name: "set_nostri", type: "number", required: true, min: 0 },
{ name: "set_loro", type: "number", required: true, min: 0 },
{ name: "parziali", type: "json", required: false },
{ name: "azioni", type: "json", required: false },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(scoutPartite);
// ---------------------------------------------------------------------------------------
// push_subscriptions — dispositivi registrati per le notifiche push
// ---------------------------------------------------------------------------------------
const pushSubscriptions = new Collection({
name: "push_subscriptions",
type: "base",
fields: [
{
name: "giocatore",
type: "relation",
required: false,
collectionId: giocatori.id,
maxSelect: 1,
},
{ name: "endpoint", type: "text", required: true },
{ name: "p256dh", type: "text", required: true },
{ name: "auth", type: "text", required: true },
{ name: "created", type: "autodate", onCreate: true },
{ name: "updated", type: "autodate", onCreate: true, onUpdate: true },
],
indexes: [
"CREATE UNIQUE INDEX idx_push_subscriptions_endpoint ON push_subscriptions (endpoint)",
],
listRule: "@request.auth.id != ''",
viewRule: "@request.auth.id != ''",
createRule: "@request.auth.id != ''",
updateRule: "@request.auth.id != ''",
deleteRule: "@request.auth.id != ''",
});
app.save(pushSubscriptions);
},
(app) => {
const names = [
"push_subscriptions",
"scout_partite",
"scout_live",
"scout_sessioni",
"turni_palloni",
"badge_social_voti",
"pagelle_voti",
"mvp_voti",
"cacche_partita",
"risposte_presenze",
"eventi_app",
"avatar_giocatori",
"profili_giocatore",
"giocatori_squadra",
];
for (const name of names) {
const collection = app.findCollectionByNameOrId(name);
if (collection) app.delete(collection);
}
const users = app.findCollectionByNameOrId("users");
users.fields.removeByName("role");
app.save(users);
},
);
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Ricrea da zero il PocketBase locale (equivalente di "npx supabase db reset").
# Ferma il container, azzera pb_data (le migration in pocketbase/pb_migrations/ vengono
# riapplicate all'avvio) e riavvia. Il superuser (da POCKETBASE_SUPERUSER_EMAIL/PASSWORD
# in .env) e il provider Google OAuth2 (da GOOGLE_OAUTH_CLIENT_ID/SECRET) si ricreano da
# soli all'avvio, senza passaggi manuali.
set -euo pipefail
cd "$(dirname "$0")/.."
docker compose -f docker-compose.pocketbase.yml down
# Alcuni file (avatar, thumbnail) li scrive il container con un utente diverso da quello
# host: un semplice "rm -rf" da host può fallire per permessi, quindi si pulisce da dentro
# un container usa-e-getta.
docker run --rm -v "$(pwd)/pocketbase/pb_data:/pb_data" alpine sh -c 'rm -rf /pb_data/* /pb_data/.[!.]* 2>/dev/null || true'
mkdir -p pocketbase/pb_data
touch pocketbase/pb_data/.gitkeep
docker compose -f docker-compose.pocketbase.yml up -d
echo "PocketBase locale ricreato. Dashboard: http://127.0.0.1:8090/_/"
+7 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { urlAvatar } from "@/lib/avatar-store";
import { urlAvatarDaRiga, useAvatarMap } from "@/lib/avatar-store";
export function Avatar({
id,
@@ -19,8 +19,12 @@ export function Avatar({
const [errore, setErrore] = useState(false);
useEffect(() => setErrore(false), [id, bust]);
if (!errore) {
const src = bust ? `${urlAvatar(id)}?v=${bust}` : urlAvatar(id);
const { data: mappa } = useAvatarMap();
const riga = mappa?.[id];
if (riga && !errore) {
const base = urlAvatarDaRiga(riga);
const src = bust ? `${base}?v=${bust}` : base;
return (
<img
src={src}
+29 -13
View File
@@ -1,7 +1,8 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
import { proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
export type VoceSottosezione = {
id: string;
@@ -20,8 +21,9 @@ const transizioneTab = { type: "tween" as const, duration: 0.16, ease: [0.25, 0.
*
* `variante="sottolineatura"`: pillola piena sulla tab attiva (Squadra e Classifica CSI);
* su mobile la sola barra tab scorre in orizzontale senza allargare la pagina.
* Default `pillole`: tab a larghezza naturale (Profilo). Il titolo ripetuto sotto la
* barra non viene mai mostrato: letichetta è già nella tab.
* Default `pillole`: tab a larghezza uguale (basata sulla etichetta più lunga); se non
* entrano nella viewport la barra scorre in orizzontale (Profilo). Il titolo ripetuto sotto
* la barra non viene mai mostrato: letichetta è già nella tab.
*/
export function BarraSottosezioni({
voci,
@@ -32,13 +34,17 @@ export function BarraSottosezioni({
voci: VoceSottosezione[];
defaultId?: string;
variante?: "pillole" | "sottolineatura";
/** Tab a larghezza uguale che riempiono la barra (es. Campionato). */
/** Tab a larghezza uguale che riempiono la barra (es. Campionato, Squadra). */
riempiLarghezza?: boolean;
}) {
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
const direzione = useRef(0);
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const ridotto = useReducedMotion();
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: qui disattiva sia lo swipe orizzontale sia il
// blur della barra tab, i due costi maggiori all'ingresso in una pagina con
// sottosezioni (Squadra, Campionato, Profilo).
const ridotto = useMotoRidotto();
const indice = Math.max(
0,
voci.findIndex((v) => v.id === attiva),
@@ -69,7 +75,8 @@ export function BarraSottosezioni({
<div
className={cn(
"min-w-0 max-w-full border-b border-border bg-background",
!sottolineatura && "bg-background/80 pt-3 backdrop-blur-md",
!sottolineatura &&
(ridotto ? "bg-background pt-3" : "bg-background/80 pt-3 backdrop-blur-md"),
)}
>
{/* Solo la barra tab può scrollare in orizzontale: non allarga il layout pagina. */}
@@ -83,11 +90,13 @@ export function BarraSottosezioni({
role="tablist"
aria-label="Sottosezioni"
className={cn(
"flex flex-nowrap",
riempiLarghezza
? "w-full"
: "w-max min-w-full",
sottolineatura ? "gap-1 px-2 py-1.5" : "snap-x snap-mandatory gap-1.5 px-5",
? "flex w-full flex-nowrap"
: sottolineatura
? "flex w-max min-w-full flex-nowrap"
: // Pillole: colonne uguali alla voce più lunga; overflow → scroll sul wrapper.
"grid w-max min-w-full grid-flow-col auto-cols-[1fr]",
sottolineatura ? "gap-1 px-2 py-1.5" : "gap-1.5 px-5",
)}
>
{voci.map((v) => {
@@ -107,7 +116,11 @@ export function BarraSottosezioni({
}}
className={cn(
"min-h-11 touch-manipulation whitespace-nowrap text-sm font-bold uppercase tracking-wide transition-colors",
riempiLarghezza ? "min-w-0 flex-1" : "shrink-0",
riempiLarghezza
? "min-w-0 flex-1"
: sottolineatura
? "shrink-0"
: "w-full",
sottolineatura
? cn(
"rounded-xl px-1 py-2.5 text-center",
@@ -116,7 +129,8 @@ export function BarraSottosezioni({
: "text-muted-foreground",
)
: cn(
"snap-center rounded-full px-3.5 py-2",
"rounded-full py-2 text-center",
riempiLarghezza ? "px-2" : "px-3.5",
selezionata
? "bg-accent text-accent-foreground shadow-pop"
: "bg-secondary text-muted-foreground",
@@ -136,7 +150,9 @@ export function BarraSottosezioni({
key={voce.id}
role="tabpanel"
aria-label={voce.label}
drag="x"
// Sui device deboli lo swipe orizzontale (pointer listener + hit-testing
// ad ogni frame) resta disattivato: si cambia tab solo toccando la barra.
drag={ridotto ? false : "x"}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.12}
dragMomentum={false}
+17 -3
View File
@@ -1,7 +1,8 @@
import { Link } from "@tanstack/react-router";
import { CalendarDays, Home, Trophy, Users } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { motion } from "motion/react";
import { molla } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
// Quattro voci e non cinque: il profilo sta in alto a destra
// nell'intestazione di ogni pagina, dove lo cerca chi arriva da iOS.
@@ -13,7 +14,10 @@ const items = [
] as const;
export function BottomNav() {
const ridotto = useReducedMotion();
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: qui disattiva sia la molla della capsula sia il
// vetro sfocato, i due costi maggiori ad ogni cambio di rotta.
const ridotto = useMotoRidotto();
return (
<nav
@@ -62,11 +66,21 @@ export function BottomNav() {
*/}
{/* `vetro` porta con sé le proprie ombre, quindi niente `shadow-chrome`:
sarebbero due `box-shadow` sullo stesso elemento e una vincerebbe. */}
<div className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5">
<div
className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5"
// Su device deboli il backdrop-filter con feTurbulence/feDisplacementMap
// (solo Blink, cioè Android) e la molla della capsula sotto sono i due
// costi maggiori ad ogni cambio di rotta: `data-leggero` li disattiva.
{...(ridotto ? { "data-leggero": "" } : {})}
>
{items.map(({ to, label, icon: Icon }) => (
<Link
key={to}
to={to}
// Il chunk della pagina parte già al tocco (touchstart), non al tap completo:
// le 4 rotte sono piccole (8-12KB), nessun costo aggiuntivo di query (nessuna
// rotta usa `loader`, quindi non prefetcha dati, solo codice).
preload="intent"
activeOptions={{ exact: to === "/" }}
activeProps={{ "aria-current": "page" }}
// Lo stato attivo non è solo colore: è la capsula piena sotto la
+283
View File
@@ -0,0 +1,283 @@
import { useState } from "react";
import { ExternalLink, ShieldAlert, Users2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { useCsiPartita } from "@/lib/csi-partita";
import type { FormazioneSquadra, PrecedentiCsi } from "@/lib/csi-core";
/** Logo squadra dal portale CSI: nascosto invece che rotto se l'immagine non carica. */
export function LogoSquadra({
src,
alt,
className,
}: {
src: string;
alt: string;
className?: string;
}) {
const [errore, setErrore] = useState(false);
if (!src || errore) return null;
return (
<img
src={src}
alt={alt}
loading="lazy"
onError={() => setErrore(true)}
className={cn("shrink-0 rounded-lg object-contain", className)}
/>
);
}
/** Metadati leggeri sempre disponibili (nessuna fetch aggiuntiva): girone, n° gara, arbitro, referto. */
export function MetaPartitaCsi({
girone,
numeroGara,
arbitro,
link,
}: {
girone: string;
numeroGara: string;
arbitro: string;
link: string;
}) {
const voci = [
girone,
numeroGara ? `N° gara ${numeroGara}` : "",
arbitro ? `Arbitro: ${arbitro}` : "",
]
.filter(Boolean)
.join(" · ");
if (!voci && !link) return null;
return (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{voci ? <span>{voci}</span> : null}
{link ? (
<a
href={link}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 font-semibold text-accent"
>
Referto ufficiale CSI <ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
);
}
function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
return (
<div>
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
{squadra.squadra}
</p>
<div className="mt-2 space-y-1">
{squadra.titolari.map((g, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="w-6 shrink-0 text-center font-display text-xs text-accent">
{g.numero}
</span>
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
{g.ruolo ? <span className="text-xs text-muted-foreground">{g.ruolo}</span> : null}
</div>
))}
</div>
{squadra.panchina.length > 0 ? (
<>
<p className="mt-3 text-[11px] font-bold uppercase text-muted-foreground/70">
A disposizione
</p>
<div className="mt-1 space-y-1">
{squadra.panchina.map((g, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-6 shrink-0 text-center">{g.numero}</span>
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
</div>
))}
</div>
</>
) : null}
{squadra.staff.length > 0 ? (
<div className="mt-3 space-y-0.5 text-xs text-muted-foreground">
{squadra.staff.map((s, i) => (
<div key={i}>
{s.nome} · {s.ruolo}
</div>
))}
</div>
) : null}
</div>
);
}
function BarraPrecedenti({
label,
noi,
avversario,
}: {
label: string;
noi: number;
avversario: number;
}) {
const totale = noi + avversario || 1;
return (
<div className="text-xs">
<div className="flex items-center justify-between text-muted-foreground">
<span className="font-bold text-foreground">{noi}</span>
<span>{label}</span>
<span className="font-bold text-foreground">{avversario}</span>
</div>
<div className="mt-1 flex h-1.5 overflow-hidden rounded-full bg-secondary">
<div className="bg-accent" style={{ width: `${(noi / totale) * 100}%` }} />
</div>
</div>
);
}
const NOME_NOI = "CRAP Volley";
/** Intestazione con i nomi delle due squadre, per non dover ripeterli su ogni barra sotto. */
function TestataSquadre({ avversario }: { avversario: string }) {
return (
<div className="flex items-center justify-between text-xs font-bold">
<span className="truncate text-accent">{NOME_NOI}</span>
<span className="truncate text-right text-muted-foreground">{avversario}</span>
</div>
);
}
function BarraProbabilita({
avversario,
noi,
avversarioPct,
}: {
avversario: string;
noi: number;
avversarioPct: number;
}) {
const favoritaNoi = noi >= avversarioPct;
return (
<div>
<p className="mb-2 text-xs text-muted-foreground">
Probabilità di vittoria (calcolo CSI):{" "}
<span className="font-bold text-foreground">{favoritaNoi ? NOME_NOI : avversario}</span>{" "}
favorita al {Math.max(noi, avversarioPct).toFixed(0)}%.
</p>
<div className="mb-1 flex items-center justify-between text-[11px] font-bold">
<span className={favoritaNoi ? "text-success" : "text-muted-foreground"}>{NOME_NOI}</span>
<span className={!favoritaNoi ? "text-success" : "text-muted-foreground"}>
{avversario}
</span>
</div>
<div className="flex h-7 overflow-hidden rounded-full">
<div
className="flex items-center justify-center bg-success text-xs font-bold text-success-foreground"
style={{ width: `${noi}%` }}
>
{noi.toFixed(0)}%
</div>
<div
className="flex items-center justify-center bg-destructive text-xs font-bold text-destructive-foreground"
style={{ width: `${avversarioPct}%` }}
>
{avversarioPct.toFixed(0)}%
</div>
</div>
</div>
);
}
function BloccoPrecedenti({
precedenti,
avversario,
}: {
precedenti: PrecedentiCsi;
avversario: string;
}) {
return (
<div className="space-y-3">
<TestataSquadre avversario={avversario} />
<p className="text-xs text-muted-foreground">
{precedenti.totale > 0
? `${precedenti.totale} precedenti in archivio sul portale CSI.`
: "Nessun precedente in archivio sul portale CSI: primo confronto tra le due squadre."}
</p>
{precedenti.totale > 0 ? (
<>
<BarraPrecedenti
label="vittorie"
noi={precedenti.vinteNoi}
avversario={precedenti.vinteAvversario}
/>
<BarraPrecedenti
label="in casa"
noi={precedenti.casaNoi}
avversario={precedenti.casaAvversario}
/>
<BarraPrecedenti
label="fuori"
noi={precedenti.fuoriNoi}
avversario={precedenti.fuoriAvversario}
/>
</>
) : null}
{precedenti.probabilitaNoi !== null && precedenti.probabilitaAvversario !== null ? (
<BarraProbabilita
avversario={avversario}
noi={precedenti.probabilitaNoi}
avversarioPct={precedenti.probabilitaAvversario}
/>
) : null}
</div>
);
}
/**
* Formazioni e storico scontri diretti per una gara CSI: una fetch in più (cache 6h lato
* server), quindi va montato solo quando l'utente apre il dettaglio della gara, mai in una
* lista. `matchId` è l'id della gara sul portale CSI (`PartitaCsi.id`). `avversario` serve
* solo a etichettare le barre di "Scontri diretti" (nome squadra, non un dato dal CSI).
*/
export function DettaglioCsiEsteso({
matchId,
avversario,
}: {
matchId: string;
avversario: string;
}) {
const { data, isLoading } = useCsiPartita(matchId);
if (isLoading) {
return <p className="px-1 text-center text-xs text-muted-foreground">Carico i dati dal CSI</p>;
}
if (!data || (!data.formazioni && !data.precedenti)) return null;
return (
<div className="space-y-4">
{data.giornata || data.nota ? (
<p className="text-xs text-muted-foreground">
{[data.giornata, data.nota].filter(Boolean).join(" · ")}
</p>
) : null}
{data.formazioni ? (
<Card>
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
<Users2 className="h-4 w-4 text-accent" /> Formazioni
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ListaFormazione squadra={data.formazioni.noi} />
<ListaFormazione squadra={data.formazioni.avversario} />
</div>
</Card>
) : null}
{data.precedenti ? (
<Card>
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
</div>
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
</Card>
) : null}
</div>
);
}
+7 -8
View File
@@ -16,7 +16,7 @@ import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
import { dataOggi } from "@/lib/scout-live";
const tipoMeta = {
@@ -69,7 +69,10 @@ export function EventoCard({
}) {
const { risposte } = usePresenzeEvento(evento.id);
const salva = useSalvaPresenza();
const io = useGiocatoreCorrente();
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta): `useGiocatoreBase`
// legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`.
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
const io = useGiocatoreBase();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const stato = io ? risposte[io.id] : undefined;
@@ -85,8 +88,7 @@ export function EventoCard({
const passato = evento.data < dataOggi();
const cliccabile = Boolean(linkTo);
/** Solo gli eventi extra-campo non hanno scheda dedicata: le note restano sulla card. */
const noteCard =
evento.tipo === "evento" ? evento.note.trim() : "";
const noteCard = evento.tipo === "evento" ? evento.note.trim() : "";
const stati =
evento.tipo === "evento"
? // Se resta un vecchio "infortunato", mostra il bottone solo per poterlo togliere.
@@ -190,10 +192,7 @@ export function EventoCard({
{io ? (
<div
className={cn(
"pointer-events-auto flex w-full gap-1",
metaStato ? "mt-2.5" : "mt-3",
)}
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
role="group"
aria-label="La tua presenza"
>
+3 -2
View File
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar";
import type { Giocatore } from "@/lib/crapp-data";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
@@ -20,7 +20,8 @@ export function Pagelle({
convocati: Giocatore[];
chiuse?: boolean;
}) {
const io = useGiocatoreCorrente();
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const { voti: tutti, isPending } = usePagelle();
const vota = useVotaPagella();
const [apertoPer, setApertoPer] = useState<string | null>(null);
+14 -10
View File
@@ -34,15 +34,17 @@ function Intestazione({ titolo, completa }: { titolo: string; completa: boolean
function CampoFile({
label,
path,
recordId,
sezione,
giocatoreId,
onCaricato,
}: {
label: string;
path: string | null;
recordId: string | null;
sezione: SezioneFile;
giocatoreId: string;
onCaricato: (path: string) => Promise<void>;
onCaricato: (risultato: { id: string; filename: string }) => Promise<void>;
}) {
const input = useRef<HTMLInputElement>(null);
const [inCorso, setInCorso] = useState(false);
@@ -53,7 +55,7 @@ function CampoFile({
if (!file) return;
setInCorso(true);
try {
const nuovo = await caricaFile(giocatoreId, sezione, file, path);
const nuovo = await caricaFile(giocatoreId, sezione, file);
await onCaricato(nuovo);
toast.success(`${label} caricato`);
} catch (errore) {
@@ -77,10 +79,10 @@ function CampoFile({
<span className="truncate">{label}</span>
</span>
<span className="flex shrink-0 items-center gap-2">
{path ? (
{path && recordId ? (
<button
type="button"
onClick={() => void scaricaFile(path)}
onClick={() => void scaricaFile(recordId, path)}
className="premi rounded-xl bg-secondary p-2 text-muted-foreground"
aria-label={`Vedi ${label}`}
>
@@ -287,8 +289,10 @@ export function ProfiloAmministrativo({
}
// 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 });
const caricato =
(campo: "documentoFrontePath" | "documentoRetroPath" | "certificatoPath" | "fotoPath") =>
async (risultato: { id: string; filename: string }) =>
scrivi({ ...corrente, id: risultato.id, [campo]: risultato.filename });
const corpo = (
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
@@ -304,10 +308,6 @@ export function ProfiloAmministrativo({
</span>
</div>
<p className="text-xs text-muted-foreground">
Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro.
</p>
<CampiProfilo
corrente={corrente}
aggiorna={aggiorna}
@@ -317,6 +317,7 @@ export function ProfiloAmministrativo({
<CampoFile
label="Foto fronte"
path={corrente.documentoFrontePath}
recordId={corrente.id}
sezione="documento-fronte"
giocatoreId={giocatoreId}
onCaricato={caricato("documentoFrontePath")}
@@ -324,6 +325,7 @@ export function ProfiloAmministrativo({
<CampoFile
label="Foto retro"
path={corrente.documentoRetroPath}
recordId={corrente.id}
sezione="documento-retro"
giocatoreId={giocatoreId}
onCaricato={caricato("documentoRetroPath")}
@@ -334,6 +336,7 @@ export function ProfiloAmministrativo({
<CampoFile
label="Certificato medico"
path={corrente.certificatoPath}
recordId={corrente.id}
sezione="certificato"
giocatoreId={giocatoreId}
onCaricato={caricato("certificatoPath")}
@@ -345,6 +348,7 @@ export function ProfiloAmministrativo({
<CampoFile
label="Foto tessera"
path={corrente.fotoPath}
recordId={corrente.id}
sezione="foto"
giocatoreId={giocatoreId}
onCaricato={caricato("fotoPath")}
+3 -2
View File
@@ -3,11 +3,12 @@ import { formatData } from "@/lib/crapp-data";
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
import { useTurniPalloni } from "@/lib/palloni";
import { useEventi } from "@/lib/eventi";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
/** Avvisi per chi è di turno: prendere i palloni oggi, o riportarli oggi. */
export function PromemoriaPalloni() {
const io = useGiocatoreCorrente();
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. Montato in Home.
const io = useGiocatoreBase();
const { turni } = useTurniPalloni();
const { eventi } = useEventi();
if (!io) return null;
+7 -5
View File
@@ -7,8 +7,8 @@ import { Avatar } from "@/components/crapp/Avatar";
import { Barra } from "@/components/motion/Barra";
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useRosa } from "@/lib/rosa";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import { dataOggi } from "@/lib/scout-live";
@@ -18,9 +18,11 @@ const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente
export function RosaPresenze({ eventoId, data }: { eventoId: string; data: string }) {
const { risposte, isPending } = usePresenzeEvento(eventoId);
const salva = useSalvaPresenza();
const io = useGiocatoreCorrente();
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
// niente statistiche di squadra.
const io = useGiocatoreBase();
const admin = useIsAdmin();
const rosa = useRosa();
const rosa = useAnagraficaRosa();
const [sollecito, setSollecito] = useState(false);
const passato = data < dataOggi();
@@ -169,7 +171,7 @@ function Gruppo({
}: {
titolo: string;
n: number;
lista: Giocatore[];
lista: Array<Pick<Giocatore, "id" | "nome" | "ruolo" | "numero">>;
attenzione?: boolean;
}) {
return (
+3 -2
View File
@@ -2,7 +2,7 @@ import { Link } from "@tanstack/react-router";
import { ChevronRight, Lock, Radio } from "lucide-react";
import { cn } from "@/lib/utils";
import { sessioneScaduta, usePartitaDiOggi, useSessioneScout } from "@/lib/scout-live";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
/**
* Accesso allo scout live: attivo solo il giorno della partita e se nessun altro lo sta usando.
@@ -17,7 +17,8 @@ export function ScoutEntry({
}) {
const { pronto, partita: diOggi } = usePartitaDiOggi();
const partita = eventoId && diOggi?.id !== eventoId ? null : diOggi;
const io = useGiocatoreCorrente();
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const { data: sessione } = useSessioneScout(partita?.id ?? null);
const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null;
+3 -2
View File
@@ -4,7 +4,7 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import {
@@ -27,7 +27,8 @@ export function SondaggioCacche({
dataEvento: string;
oraEvento: string;
}) {
const io = useGiocatoreCorrente();
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const { righe } = useCacche();
const salva = useSalvaCacche();
const { righe: squadra } = useGiocatoriSquadra();
+3 -2
View File
@@ -7,14 +7,15 @@ import { intestazioniAutenticate } from "@/lib/auth";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
import { useIsAdmin } from "@/lib/ruoli";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
export function TurnoPalloni({ eventoId }: { eventoId: string }) {
const [aperto, setAperto] = useState(false);
const [avviso, setAvviso] = useState(false);
const { salvati, turni, isPending } = useTurniPalloni();
const assegna = useAssegnaTurno();
const io = useGiocatoreCorrente();
// Solo `.nome` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const admin = useIsAdmin();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
+3 -2
View File
@@ -3,7 +3,7 @@ import { Crown, Vote } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
import { usePresenzeEvento } from "@/lib/presenze";
import type { Evento } from "@/lib/eventi";
import {
@@ -25,7 +25,8 @@ import {
*/
export function VotazioneMvp({ evento }: { evento: Evento }) {
const matchId = evento.id;
const io = useGiocatoreCorrente();
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const voti = useVotiMvp();
const vota = useVotaMvp();
const { righe: squadra } = useGiocatoriSquadra();
+3 -2
View File
@@ -3,7 +3,7 @@ import { Check, Crown, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
import {
categorieSocial,
conteggioCategoria,
@@ -15,7 +15,8 @@ import {
/** Voto social post-partita: un compagno per categoria, veloce da mobile. */
export function VotoSocial({ matchId }: { matchId: string }) {
const io = useGiocatoreCorrente();
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const voti = useVotiSocial();
const vota = useVotaSocial();
const { righe: squadra } = useGiocatoriSquadra();
+12 -5
View File
@@ -2,8 +2,9 @@ import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from "
import { Link } from "@tanstack/react-router";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { statoMeta, type Stato } from "@/lib/crapp-data";
import { useIo } from "@/lib/rosa";
import { inizialiDa, statoMeta, type Stato } from "@/lib/crapp-data";
import { nomeCompleto } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { Avatar } from "@/components/crapp/Avatar";
import { Reveal } from "@/components/motion/Reveal";
import { Numero } from "@/components/motion/Numero";
@@ -25,6 +26,7 @@ export function TeamLogo({
alt="CRAP Volley"
width={192}
height={192}
decoding="async"
className={cn("shrink-0 rounded-2xl object-cover shadow-pop", className)}
/>
);
@@ -49,10 +51,15 @@ export function Card({
/**
* Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è
* l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a
* `PageHeader` per rimetterci il logo sarebbe un link a stessa.
* `PageHeader` con il logo che porta alla home.
*
* Usa `useGiocatoreBase` (sola anagrafica) e non `useIo`: qui serve solo id e
* iniziali, mentre `useIo` calcola l'intera rosa con statistiche (MVP, pagelle,
* cacche, palloni, infortuni). Essendo in un componente montato su quasi ogni
* pagina, quei moduli finirebbero nel bundle condiviso di tutte le rotte.
*/
export function LinkProfilo() {
const g = useIo();
const g = useGiocatoreBase();
if (!g) return <TeamLogo className="h-12 w-12" />;
return (
<Link
@@ -60,7 +67,7 @@ export function LinkProfilo() {
aria-label="Il tuo profilo"
className="premi shrink-0 rounded-2xl ring-2 ring-primary-foreground/30"
>
<Avatar id={g.id} fallback={g.iniziali} className="h-12 w-12 text-lg" />
<Avatar id={g.id} fallback={inizialiDa(nomeCompleto(g))} className="h-12 w-12 text-lg" />
</Link>
);
}
-41
View File
@@ -1,41 +0,0 @@
// This file is auto-generated by Lovable. Do not modify it.
import { createLovableAuth } from "@lovable.dev/cloud-auth-js";
import { supabase } from "../supabase/client";
const lovableAuth = createLovableAuth();
type SignInOptions = {
redirect_uri?: string;
extraParams?: Record<string, string>;
};
export const lovable = {
auth: {
signInWithOAuth: async (
provider: "google" | "apple" | "microsoft" | "lovable",
opts?: SignInOptions,
) => {
const result = await lovableAuth.signInWithOAuth(provider, {
redirect_uri: opts?.redirect_uri ?? window.location.origin,
extraParams: {
...opts?.extraParams,
},
});
if (result.redirected) {
return result;
}
if (result.error) {
return result;
}
try {
await supabase.auth.setSession(result.tokens);
} catch (e) {
return { error: e instanceof Error ? e : new Error(String(e)) };
}
return result;
},
},
};
@@ -0,0 +1,13 @@
import { createMiddleware } from "@tanstack/react-start";
import { pb } from "./client";
// Deve essere registrato come `functionMiddleware` globale in `src/start.ts`; altrimenti
// il browser non allega il bearer token alle RPC delle server function.
export const attachPocketBaseAuth = createMiddleware({ type: "function" }).client(
async ({ next }) => {
const token = pb.authStore.token;
return next({
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
},
);
@@ -0,0 +1,39 @@
// Client server-side con privilegi superuser - bypassa le API rules delle collection
// (equivalente del service role Supabase, con una differenza: PocketBase non ha una
// chiave statica, l'autenticazione è email+password contro la collection _superusers e
// va rinnovata quando il token scade).
//
// SECURITY: usare solo per operazioni server-side fidate, mai esporre al client.
// Carica dentro gli handler server: const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
// L'import a livello di modulo è sicuro solo in altri moduli *.server.ts - le route e le
// server function finiscono nel bundle client.
import PocketBase from "pocketbase";
let _pb: PocketBase | undefined;
export async function pbAdmin(): Promise<PocketBase> {
const POCKETBASE_URL = process.env["POCKETBASE_URL"];
const POCKETBASE_SUPERUSER_EMAIL = process.env["POCKETBASE_SUPERUSER_EMAIL"];
const POCKETBASE_SUPERUSER_PASSWORD = process.env["POCKETBASE_SUPERUSER_PASSWORD"];
if (!POCKETBASE_URL || !POCKETBASE_SUPERUSER_EMAIL || !POCKETBASE_SUPERUSER_PASSWORD) {
const missing = [
...(!POCKETBASE_URL ? ["POCKETBASE_URL"] : []),
...(!POCKETBASE_SUPERUSER_EMAIL ? ["POCKETBASE_SUPERUSER_EMAIL"] : []),
...(!POCKETBASE_SUPERUSER_PASSWORD ? ["POCKETBASE_SUPERUSER_PASSWORD"] : []),
];
const message = `Variabili d'ambiente PocketBase mancanti: ${missing.join(", ")}.`;
console.error(`[PocketBase] ${message}`);
throw new Error(message);
}
if (!_pb) _pb = new PocketBase(POCKETBASE_URL);
if (!_pb.authStore.isValid) {
await _pb
.collection("_superusers")
.authWithPassword(POCKETBASE_SUPERUSER_EMAIL, POCKETBASE_SUPERUSER_PASSWORD);
}
return _pb;
}
+28
View File
@@ -0,0 +1,28 @@
import PocketBase from "pocketbase";
function createPocketBaseClient(): PocketBase {
// Use import.meta.env for client-side (Vite build-time replacement)
// Fall back to process.env for SSR (server-side rendering)
const POCKETBASE_URL = import.meta.env["VITE_POCKETBASE_URL"] || process.env["POCKETBASE_URL"];
if (!POCKETBASE_URL) {
const message = "Manca la variabile d'ambiente POCKETBASE_URL (o VITE_POCKETBASE_URL).";
console.error(`[PocketBase] ${message}`);
throw new Error(message);
}
// PocketBase persiste la sessione in localStorage lato browser e in memoria lato SSR
// di default: nessuna configurazione aggiuntiva serve (a differenza del client Supabase).
return new PocketBase(POCKETBASE_URL);
}
let _pb: PocketBase | undefined;
// Import the PocketBase client like this:
// import { pb } from "@/integrations/pocketbase/client";
export const pb = new Proxy({} as PocketBase, {
get(_, prop, receiver) {
if (!_pb) _pb = createPocketBaseClient();
return Reflect.get(_pb, prop, receiver);
},
});
+8
View File
@@ -0,0 +1,8 @@
/**
* PocketBase restituisce i campi data/ora come "AAAA-MM-GG HH:MM:SS.sssZ" (spazio, non
* "T"): `Date.parse` su questo formato non è garantito coerente su tutti i motori JS.
* Normalizza a ISO 8601 stretto prima di qualsiasi confronto/calcolo di date.
*/
export function pbISO(v: string): string {
return v.includes("T") ? v : v.replace(" ", "T");
}
+52
View File
@@ -0,0 +1,52 @@
import PocketBase, { ClientResponseError, type RecordModel } from "pocketbase";
import { pb } from "./client";
/**
* PocketBase non ha un upsert nativo. Per le collection con id significativo (es. `e1`,
* `g1`, dove l'id stesso è la chiave di unicità che prima era gestita da
* `onConflict: "id"`): aggiorna se il record esiste, altrimenti lo crea con quell'id.
*
* `client` di default è il client browser (`pb`): passare esplicitamente `pbAdmin()` nei
* moduli server-side che oggi usano `supabaseAdmin`.
*/
export async function upsertById<T = RecordModel>(
collection: string,
id: string,
dati: Record<string, unknown>,
client: PocketBase = pb,
): Promise<T> {
try {
return await client.collection(collection).update<T>(id, dati);
} catch (errore) {
if (errore instanceof ClientResponseError && errore.status === 404) {
return await client.collection(collection).create<T>({ id, ...dati });
}
throw errore;
}
}
/**
* Per le collection dove l'unicità è su una combinazione di campi (es.
* `evento_id,giocatore_id`, prima gestita da un indice UNIQUE + `onConflict`) e l'id del
* record è generato da PocketBase, non ha significato applicativo: cerca il record che
* combacia col filtro e lo aggiorna, altrimenti ne crea uno nuovo.
*/
export async function upsertByFilter<T = RecordModel>(
collection: string,
filtro: string,
parametri: Record<string, unknown>,
dati: Record<string, unknown>,
client: PocketBase = pb,
): Promise<T> {
try {
const esistente = await client
.collection(collection)
.getFirstListItem<T>(client.filter(filtro, parametri));
return await client.collection(collection).update<T>((esistente as RecordModel).id, dati);
} catch (errore) {
if (errore instanceof ClientResponseError && errore.status === 404) {
return await client.collection(collection).create<T>(dati);
}
throw errore;
}
}
+27 -20
View File
@@ -2,18 +2,19 @@
* Controllo di accesso per le route in `src/routes/api/public/` che inviano notifiche
* a tutta la squadra (DD-024).
*
* Quelle route usano la service role e saltano la RLS: senza un controllo qui, chiunque
* conosca l'URL può far suonare i telefoni di tutti. L'id di un evento non è un segreto
* è un timestamp in base 36 e compare negli URL che la squadra si scambia quindi non
* può fare da credenziale.
* Quelle route usano il superuser PocketBase e saltano le API rules: senza un controllo
* qui, chiunque conosca l'URL può far suonare i telefoni di tutti. L'id di un evento non
* è un segreto è un timestamp in base 36 e compare negli URL che la squadra si scambia
* quindi non può fare da credenziale.
*
* Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli
* amministratori, quindi il controllo è uno solo: `richiediAdmin` verifica il token della
* sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
* proseguire, altrimenti la `Response` di rifiuto già pronta.
* sessione PocketBase e il campo `role` sul record utente. Torna `null` quando la
* richiesta può proseguire, altrimenti la `Response` di rifiuto già pronta.
*/
import PocketBase from "pocketbase";
/** Il token della sessione Supabase, se la richiesta ne porta uno ben formato. */
/** Il token della sessione PocketBase, se la richiesta ne porta uno ben formato. */
function tokenDaRichiesta(request: Request): string | null {
const intestazione = request.headers.get("authorization");
if (!intestazione?.startsWith("Bearer ")) return null;
@@ -31,19 +32,25 @@ export async function richiediAdmin(request: Request): Promise<Response | null>
const token = tokenDaRichiesta(request);
if (!token) return new Response("Autenticazione richiesta", { status: 401 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const POCKETBASE_URL = process.env["POCKETBASE_URL"];
if (!POCKETBASE_URL) {
console.error("[PocketBase] Manca la variabile d'ambiente POCKETBASE_URL.");
return new Response("Configurazione server non valida", { status: 500 });
}
const { data: utente, error } = await supabaseAdmin.auth.getUser(token);
if (error || !utente?.user) return new Response("Sessione non valida", { status: 401 });
// Client "vuoto" con il token dell'utente allegato: authRefresh() lo valida e restituisce
// il record aggiornato in un'unica chiamata, ruolo incluso (con Supabase servivano due
// round trip: getUser() più una query separata su user_roles).
const pb = new PocketBase(POCKETBASE_URL);
pb.authStore.save(token, null);
// Stessa fonte di `src/lib/ruoli.ts`: i permessi stanno solo in `user_roles` (DD-011).
const { data: ruolo } = await supabaseAdmin
.from("user_roles")
.select("role")
.eq("user_id", utente.user.id)
.eq("role", "admin")
.maybeSingle();
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 });
return null;
try {
const { record } = await pb.collection("users").authRefresh();
if (record["role"] !== "admin") {
return new Response("Riservato agli amministratori", { status: 403 });
}
return null;
} catch {
return new Response("Sessione non valida", { status: 401 });
}
}
+25 -36
View File
@@ -1,49 +1,39 @@
import { useEffect, useState } from "react";
import type { Session } from "@supabase/supabase-js";
import { supabase } from "@/integrations/supabase/client";
import type { AuthRecord } from "pocketbase";
import { pb } from "@/integrations/pocketbase/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`).
* dal campo `role` sul record utente (vedi `ruoli.ts`).
*/
export function useSessione() {
const [sessione, setSessione] = useState<Session | null>(null);
const [utente, setUtente] = useState<AuthRecord>(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.
// Il client PocketBase esplode alla costruzione se manca la variabile 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);
setUtente(pb.authStore.record);
setPronta(true);
return () => {
attivo = false;
};
// A differenza di Supabase, l'authStore di PocketBase è già popolato in modo
// sincrono all'avvio (legge localStorage nel costruttore): non serve una chiamata
// asincrona equivalente a getSession().
return pb.authStore.onChange((_token, record) => setUtente(record));
} catch (errore) {
console.error("[auth] PocketBase non disponibile", errore);
setPronta(true);
return undefined;
}
}, []);
return {
sessione,
sessione: utente,
pronta,
utenteId: sessione?.user.id ?? null,
emailUtente: sessione?.user.email ?? null,
utenteId: utente?.id ?? null,
emailUtente: (utente?.["email"] as string | undefined) ?? null,
};
}
@@ -53,20 +43,19 @@ export function useSessione() {
* un token già scaduto.
*/
export async function intestazioniAutenticate(): Promise<Record<string, string>> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
const token = pb.authStore.token;
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function accediConGoogle(): Promise<void> {
const { error } = await supabase.auth.signInWithOAuth({
// createData copre il primo accesso: PocketBase crea il record utente al volo se non
// esiste ancora per questa identità Google, e il campo `role` è obbligatorio.
await pb.collection("users").authWithOAuth2({
provider: "google",
options: { redirectTo: window.location.origin },
createData: { role: "user" },
});
if (error) throw error;
}
export async function esci(): Promise<void> {
const { error } = await supabase.auth.signOut();
if (error) throw error;
pb.authStore.clear();
}
+48 -31
View File
@@ -1,41 +1,54 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { pb } from "@/integrations/pocketbase/client";
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
const BUCKET = "avatar-giocatori";
const NOME_FILE = "avatar.jpg";
/**
* Foto profilo pubbliche (M6 avatar-giocatori): collection separata da giocatori_squadra,
* chiunque autenticato può caricare/sostituire/eliminare l'avatar di chiunque (nessun
* controllo per-proprietario, come il vecchio bucket pubblico Supabase).
*/
type RigaAvatar = { id: string; giocatore: string; foto: string };
function percorso(id: string) {
return `${id}/${NOME_FILE}`;
export const AVATAR_KEY = ["avatar-giocatori"] as const;
async function fetchAvatarMap(): Promise<Record<string, RigaAvatar>> {
const righe = await pb.collection("avatar_giocatori").getFullList<RigaAvatar>();
const mappa: Record<string, RigaAvatar> = {};
for (const r of righe) if (r.foto) mappa[r.giocatore] = r;
return mappa;
}
/** 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;
/** Una lettura per sessione, condivisa da tutte le istanze di <Avatar>: react-query la
* deduplica anche se il componente compare decine di volte nella stessa pagina (rosa). */
export function useAvatarMap() {
return useQuery({ queryKey: AVATAR_KEY, queryFn: fetchAvatarMap, staleTime: 30 * 60_000 });
}
const chiaveEsiste = (id: string) => ["avatar-esiste", id] as const;
/** URL pubblico e stabile: il campo non è protetto, nessuna richiesta di rete aggiuntiva. */
export function urlAvatarDaRiga(riga: RigaAvatar): string {
return pb.files.getURL({ id: riga.id, collectionName: "avatar_giocatori" }, riga.foto);
}
/** 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);
},
});
const query = useAvatarMap();
return { ...query, data: id ? !!query.data?.[id] : false };
}
/**
* Dopo un caricamento o una rimozione lo stato è noto: si scrive in cache invece di
* rileggere l'elenco del bucket (una richiesta in meno per ogni cambio foto).
* rileggere l'elenco (una richiesta in meno per ogni cambio foto).
*/
export function useImpostaAvatarEsiste() {
const qc = useQueryClient();
return (id: string, esiste: boolean) => qc.setQueryData(chiaveEsiste(id), esiste);
return (id: string, riga: RigaAvatar | null) => {
qc.setQueryData<Record<string, RigaAvatar>>(AVATAR_KEY, (prec) => {
const nuova = { ...(prec ?? {}) };
if (riga) nuova[id] = riga;
else delete nuova[id];
return nuova;
});
};
}
/** Ridimensiona e comprime l'immagine scelta in un quadrato JPEG. */
@@ -77,17 +90,21 @@ function fileToBlob(file: File, size = 256): Promise<Blob> {
}
/** Ridimensiona, comprime e carica la foto profilo: sovrascrive quella precedente. */
export async function caricaAvatar(id: string, file: File) {
export async function caricaAvatar(id: string, file: File): Promise<RigaAvatar> {
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;
const foto = new File([blob], "avatar.jpg", { type: "image/jpeg" });
return upsertByFilter<RigaAvatar>(
"avatar_giocatori",
"giocatore = {:giocatore}",
{ giocatore: id },
{ giocatore: id, foto },
);
}
export async function rimuoviAvatar(id: string) {
const { error } = await supabase.storage.from(BUCKET).remove([percorso(id)]);
if (error) throw error;
export async function rimuoviAvatar(id: string): Promise<void> {
const esistente = await pb
.collection("avatar_giocatori")
.getFirstListItem(pb.filter("giocatore = {:giocatore}", { giocatore: id }))
.catch(() => null);
if (esistente) await pb.collection("avatar_giocatori").delete(esistente.id);
}
+31 -10
View File
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { HandHeart, Handshake, Laugh, Scale, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { supabase } from "@/integrations/supabase/client";
import { pb } from "@/integrations/pocketbase/client";
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
export type CategoriaSocial = {
id: string;
@@ -58,6 +59,14 @@ export type VotoSocial = {
votato_nome: string;
};
type RigaSocialPocketBase = {
evento: string;
categoria: string;
votante: string;
votato: string;
votato_nome: string;
};
const CHIAVE = ["badge-social-voti"] as const;
/** Tutti i voti social (poche righe): nessun polling, cache lunga. */
@@ -66,11 +75,14 @@ export function useVotiSocial() {
queryKey: CHIAVE,
staleTime: 10 * 60_000,
queryFn: async (): Promise<VotoSocial[]> => {
const { data, error } = await supabase
.from("badge_social_voti")
.select("match_id, categoria, votante_id, votato_id, votato_nome");
if (error) throw error;
return (data ?? []) as VotoSocial[];
const righe = await pb.collection("badge_social_voti").getFullList<RigaSocialPocketBase>();
return righe.map((r) => ({
match_id: r.evento,
categoria: r.categoria,
votante_id: r.votante,
votato_id: r.votato,
votato_nome: r.votato_nome,
}));
},
});
}
@@ -79,10 +91,19 @@ export function useVotaSocial() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (voto: VotoSocial) => {
const { error } = await supabase
.from("badge_social_voti")
.upsert(voto, { onConflict: "match_id,categoria,votante_id" });
if (error) throw error;
// No autovoto e convocazione li valida l'hook voti_convocati.pb.js.
await upsertByFilter(
"badge_social_voti",
"evento = {:evento} && categoria = {:categoria} && votante = {:votante}",
{ evento: voto.match_id, categoria: voto.categoria, votante: voto.votante_id },
{
evento: voto.match_id,
categoria: voto.categoria,
votante: voto.votante_id,
votato: voto.votato_id,
votato_nome: voto.votato_nome,
},
);
return voto;
},
// Aggiornamento locale della cache: zero riletture.
+11 -3
View File
@@ -58,6 +58,12 @@ export type BadgeDef = {
notificaPush?: string;
};
/**
* Voti minimi perché la media pagelle conti per il badge Pagellone: sotto soglia una singola
* pagella potrebbe sbloccarlo (o farlo sparire) senza significatività statistica.
*/
export const VOTI_MINIMI_PAGELLA = 5;
export const badgeDefs: BadgeDef[] = [
{
id: "mvp",
@@ -77,7 +83,7 @@ export const badgeDefs: BadgeDef[] = [
unita: "di media voto",
icon: ClipboardCheck,
soglie: { bronzo: 6.5, argento: 7.5, oro: 8.5 },
valore: (g) => g.mediaVoto,
valore: (g) => (g.votiPagella >= VOTI_MINIMI_PAGELLA ? g.mediaVoto : 0),
},
{
id: "palloni",
@@ -130,7 +136,9 @@ export const badgeSegreti: BadgeDef[] = [
icon: Ghost,
segreto: true,
soglie: { bronzo: 1, argento: 1, oro: 1 },
valore: (g) => (g.mvp >= 2 && g.mediaVoto >= 8 ? 1 : 0),
// Stessa soglia minima di voti del badge Pagellone: sotto VOTI_MINIMI_PAGELLA la media
// non è statisticamente significativa, non deve poter sbloccare nemmeno questo segreto.
valore: (g) => (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8 ? 1 : 0),
celebrazione: "Nei momenti caldi ci sei sempre.",
},
{
@@ -178,7 +186,7 @@ export const badgeSegreti: BadgeDef[] = [
id: "s-cacche",
nome: "Trono di ferro",
descrizione:
"Almeno 3 partite di campionato affrontate con 3 o più cacche pre-gara. Il bagno del PalaCRAP porta il tuo nome 🚽😂",
"Almeno 3 partite affrontate con 3 o più cacche pre-gara (campionato o amichevole, qui non si fanno sconti). Il bagno del PalaCRAP porta il tuo nome 🚽😂",
unita: "partite da record",
icon: Toilet,
segreto: true,
+20 -10
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { pb } from "@/integrations/pocketbase/client";
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
import { oggiISO } from "./palloni-core";
/** Ora di apertura del sondaggio, il giorno stesso della partita. */
@@ -48,6 +49,12 @@ export type RigaCacche = {
quantita: number;
};
type RigaCacchePocketBase = {
evento: string;
giocatore: string;
quantita: number;
};
export const CACCHE_KEY = ["cacche"] as const;
export function useCacche() {
@@ -55,11 +62,12 @@ export function useCacche() {
queryKey: CACCHE_KEY,
staleTime: 10 * 60_000,
queryFn: async (): Promise<RigaCacche[]> => {
const { data, error } = await supabase
.from("cacche_partita")
.select("evento_id, giocatore_id, quantita");
if (error) throw error;
return (data ?? []) as RigaCacche[];
const righe = await pb.collection("cacche_partita").getFullList<RigaCacchePocketBase>();
return righe.map((r) => ({
evento_id: r.evento,
giocatore_id: r.giocatore,
quantita: r.quantita,
}));
},
});
return { ...query, righe: query.data ?? [] };
@@ -69,10 +77,12 @@ export function useSalvaCacche() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (riga: RigaCacche) => {
const { error } = await supabase
.from("cacche_partita")
.upsert(riga, { onConflict: "evento_id,giocatore_id" });
if (error) throw error;
await upsertByFilter(
"cacche_partita",
"evento = {:evento} && giocatore = {:giocatore}",
{ evento: riga.evento_id, giocatore: riga.giocatore_id },
{ evento: riga.evento_id, giocatore: riga.giocatore_id, quantita: riga.quantita },
);
return riga;
},
onSuccess: (riga) => {
+11 -1
View File
@@ -28,6 +28,8 @@ export type Giocatore = {
nascita: string;
presenze: number;
totaliEventi: number;
/** Solo partite (non allenamenti) a cui era presente o in ritardo. */
partiteGiocate: number;
streak: number;
/** Serie consecutive per tipo: si azzerano in modo indipendente. */
serieAllenamenti: number;
@@ -37,8 +39,12 @@ export type Giocatore = {
mvp: number;
/** Media delle pagelle ricevute dai compagni (1-10). */
mediaVoto: number;
/** Quante pagelle ha ricevuto: sotto la soglia minima il badge Pagellone resta bloccato. */
votiPagella: number;
/** Quante volte ha portato i palloni. */
palloni: number;
/** Volte consecutive (fino a oggi) in cui ha portato i palloni. */
seriePalloni: number;
/** Partite di campionato con almeno 3 cacche dichiarate. */
cacche: number;
/** Media di cacche dichiarate per partita. */
@@ -73,7 +79,8 @@ const rosaCSI: Rosa[] = [
{ nome: "Giada Valbonesi", nascita: "1994-05-20", ruolo: "Opposto", numero: 10 },
];
function inizialiDa(nome: string) {
/** Iniziali da un nome completo (max 2 lettere), es. per il fallback di `Avatar`. */
export function inizialiDa(nome: string) {
return nome
.split(" ")
.map((p) => p[0] ?? "")
@@ -98,15 +105,18 @@ export const giocatori: Giocatore[] = rosaCSI
iniziali: inizialiDa(r.nome),
presenze: 0,
totaliEventi: 0,
partiteGiocate: 0,
streak: 0,
serieAllenamenti: 0,
seriePartite: 0,
serieConferme: 0,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
infortuni: 0,
ritardi: 0,
palloni: 0,
seriePalloni: 0,
cacche: 0,
cacchePartita: 0,
}))
+225
View File
@@ -10,6 +10,12 @@ import type { RigaClassifica } from "./crapp-data";
export const CSI_BASE = "https://livescore.csibologna.it";
/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */
export const CSI_PROJECT_ID = 767;
/**
* Coppa CSI Misto Silver 2025/26. Stesso formato di tabella del campionato (girone
* all'italiana), solo con meno squadre: `parseClassifica()` funziona invariata. Cambia a
* ogni stagione come CSI_PROJECT_ID, vedi docs/modules/collegamento-csi.md.
*/
export const CSI_COPPA_PROJECT_ID = 848;
/** C.R.A.P. Volley sul portale CSI. */
export const CSI_TEAM_ID = 3359;
export const CSI_GIRONE = "Girone B";
@@ -19,12 +25,23 @@ export const urlClassifica = (projectId = CSI_PROJECT_ID) =>
`${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`;
export const urlPartite = (teamId = CSI_TEAM_ID) =>
`${CSI_BASE}/assets/json/getEventsByTeamId.php?team_id=${teamId}`;
/** Info generali della gara (giornata, pubblico ammesso). */
export const urlPartitaInfo = (matchId: string) =>
`${CSI_BASE}/components/match-main.php?match_id=${matchId}`;
/** Formazioni titolari/panchina/staff di entrambe le squadre. */
export const urlPartitaFormazioni = (matchId: string) =>
`${CSI_BASE}/components/match-players.php?match_id=${matchId}`;
/** Storico scontri diretti e probabilità di vittoria calcolata dal CSI. */
export const urlPartitaPrecedenti = (matchId: string) =>
`${CSI_BASE}/components/match-stats.php?match_id=${matchId}`;
export type PartitaCsi = {
id: string;
data: string;
ora: string;
avversario: string;
/** URL del logo avversario sul portale CSI, vuoto se non presente. */
logoAvversario: string;
casa: boolean;
/** null finché la gara non è stata giocata. */
setNostri: number | null;
@@ -32,13 +49,60 @@ export type PartitaCsi = {
parziali: Array<[number, number]>;
campo: string;
competizione: string;
/** Es. "Girone B". */
girone: string;
/** Es. "5/XEB". */
numeroGara: string;
arbitro: string;
/** Referto ufficiale su livescore.csibologna.it. */
link: string;
};
export type GiocatoreFormazione = { numero: string; nome: string; ruolo: string };
export type StaffFormazione = { nome: string; ruolo: string };
export type FormazioneSquadra = {
squadra: string;
titolari: GiocatoreFormazione[];
panchina: GiocatoreFormazione[];
staff: StaffFormazione[];
};
export type PrecedentiCsi = {
totale: number;
vinteNoi: number;
vinteAvversario: number;
casaNoi: number;
casaAvversario: number;
fuoriNoi: number;
fuoriAvversario: number;
/** Percentuale 0-100, null se il CSI non la pubblica (es. sport senza storico). */
probabilitaNoi: number | null;
probabilitaAvversario: number | null;
};
export type DettaglioPartitaCsi = {
/** Es. "2ª Giornata", vuoto se non trovata (fase a eliminazione, coppa...). */
giornata: string;
/**
* Testo libero del CSI sotto l'impianto: a volte "Pubblico non ammesso", a volte il nome
* della palestra o altro non ha un formato fisso, va mostrato così com'è. Vuoto se assente.
*/
nota: string;
/** null se il referto non ha ancora le formazioni (partita non ancora giocata/schierata). */
formazioni: { noi: FormazioneSquadra; avversario: FormazioneSquadra } | null;
/** null solo se il formato non è riconosciuto; con 0 precedenti i campi restano a 0. */
precedenti: PrecedentiCsi | null;
};
export type DatiCsi = {
classifica: RigaClassifica[];
/** Classifica del girone di Coppa (project_id 848). Vuota se il parsing fallisce. */
classificaCoppa: RigaClassifica[];
partite: PartitaCsi[];
girone: string;
aggiornato: string;
/** true se il formato delle partite sembra cambiato (vedi `partiteFormatoSospetto`). */
formatoSospetto: boolean;
};
/** "C.R.A.P. Volley" e "CRAP Volley" devono confrontarsi uguali. */
@@ -107,11 +171,17 @@ type EventoCsi = {
id?: number | string;
start?: string;
team1?: string;
team1_logo?: string;
team2?: string;
team2_logo?: string;
result?: string;
partials?: string;
field?: string;
project?: string;
group?: string;
match_number?: string;
referees?: string;
link?: string;
};
function punteggio(result: string | undefined): [number, number] | null {
@@ -146,12 +216,17 @@ export function partiteDaEventi(eventi: unknown): PartitaCsi[] {
data: dataIso,
ora: (oraIso ?? "").slice(0, 5),
avversario: casa ? team2 : team1,
logoAvversario: (casa ? evento.team2_logo : evento.team1_logo)?.trim() ?? "",
casa,
setNostri: set ? (casa ? set[0] : set[1]) : null,
setLoro: set ? (casa ? set[1] : set[0]) : null,
parziali: casa ? tutti : tutti.map(([a, b]) => [b, a] as [number, number]),
campo: testo(evento.field ?? ""),
competizione: testo(evento.project ?? ""),
girone: testo(evento.group ?? ""),
numeroGara: testo(evento.match_number ?? ""),
arbitro: testo(evento.referees ?? ""),
link: evento.link?.trim() ?? "",
});
}
return partite.sort((a, b) => b.data.localeCompare(a.data));
@@ -162,15 +237,165 @@ export function partiteGiocate(partite: PartitaCsi[]): PartitaCsi[] {
return partite.filter((p) => p.setNostri !== null && p.setLoro !== null);
}
/**
* True se il formato di `getEventsByTeamId.php` sembra cambiato: `partiteDaEventi()` fallisce
* in modo silenzioso (nessun array o campi non riconosciuti), quindi un array vuoto da solo non
* distingue "il portale CSI ha cambiato formato" da "la squadra non ha ancora gare in
* programma". Qui invece si confronta con la risposta grezza: se contiene eventi ma nessuno è
* stato riconosciuto come nostra partita, è quasi certamente un problema di parsing, non una
* stagione senza gare. Usata da `/api/public/csi` per loggare il caso invece di lasciarlo
* silenzioso vedi "Limiti noti" in docs/modules/collegamento-csi.md.
*/
export function partiteFormatoSospetto(eventiGrezzi: unknown, partite: PartitaCsi[]): boolean {
if (!Array.isArray(eventiGrezzi)) return true;
return eventiGrezzi.length > 0 && partite.length === 0;
}
/** Converte una gara CSI già giocata nella forma comune usata nelle liste risultati. */
export function matchDaPartitaCsi(p: PartitaCsi) {
return {
id: p.id,
data: p.data,
avversario: p.avversario,
logoAvversario: p.logoAvversario,
casa: p.casa,
setNostri: p.setNostri ?? 0,
setLoro: p.setLoro ?? 0,
parziali: p.parziali,
campo: p.campo,
girone: p.girone,
numeroGara: p.numeroGara,
arbitro: p.arbitro,
link: p.link,
};
}
/** Estrae giornata e nota libera (pubblico ammesso, dettagli impianto...) da `match-main.php`. */
export function parseInfoPartita(html: string): { giornata: string; nota: string } {
const giornataM = /(\d+)\s*<sup>a<\/sup>\s*Giornata/.exec(html);
const notaM = /<div class="text-start"><span>([^<]*)<\/span><\/div>/.exec(html);
return {
giornata: giornataM ? `${giornataM[1]}ª Giornata` : "",
nota: notaM ? testo(notaM[1] ?? "") : "",
};
}
/**
* Una squadra dentro `match-players.php`: una `<ul class="list-group">` di `<li>`, in
* ordine titolari divisore "A DISPOSIZIONE" panchina divisore "STAFF" staff. I
* membri dello staff hanno la stessa struttura dei giocatori ma senza numero di maglia.
*/
function parseBloccoSquadraFormazione(blocco: string): FormazioneSquadra | null {
const nomeM = /team_details\.php\?team_id=\d*"[^>]*>([^<]+)<\/a>/.exec(blocco);
const squadra = nomeM ? testo(nomeM[1] ?? "") : "";
if (!squadra) return null;
const titolari: GiocatoreFormazione[] = [];
const panchina: GiocatoreFormazione[] = [];
const staff: StaffFormazione[] = [];
let sezione: "titolari" | "panchina" | "staff" = "titolari";
for (const voce of blocco.match(/<li class="list-group-item[\s\S]*?<\/li>/gi) ?? []) {
if (/A DISPOSIZIONE/.test(voce)) {
sezione = "panchina";
continue;
}
if (/STAFF/.test(voce)) {
sezione = "staff";
continue;
}
const nomePersonaM = /person_details\.php\?id=\d+">([^<]+)<\/a>/.exec(voce);
if (!nomePersonaM) continue;
const nome = testo(nomePersonaM[1] ?? "");
const ruoloM = /<div class="small">([^<]*)<\/div>/.exec(voce);
const ruolo = ruoloM ? testo(ruoloM[1] ?? "") : "";
if (sezione === "staff") {
staff.push({ nome, ruolo });
continue;
}
const numeroM = /shrink-8"[^>]*>(\d+)</.exec(voce);
const giocatore = { numero: numeroM?.[1] ?? "", nome, ruolo };
(sezione === "titolari" ? titolari : panchina).push(giocatore);
}
return { squadra, titolari, panchina, staff };
}
/**
* Formazioni di entrambe le squadre da `match-players.php`. Il markup non distingue
* esplicitamente casa/ospite, ma le racchiude in due blocchi separati dal commento
* `<!-- SQUADRA OSPITE -->`; qui si riconosce la nostra tramite `isNostraSquadra()`
* invece di assumere un ordine fisso.
*/
export function parseFormazioni(
html: string,
): { noi: FormazioneSquadra; avversario: FormazioneSquadra } | null {
const idx = html.indexOf("SQUADRA OSPITE");
if (idx === -1) return null;
const primo = parseBloccoSquadraFormazione(html.slice(0, idx));
const secondo = parseBloccoSquadraFormazione(html.slice(idx));
if (!primo || !secondo) return null;
const noi = isNostraSquadra(primo.squadra)
? primo
: isNostraSquadra(secondo.squadra)
? secondo
: null;
if (!noi) return null;
return { noi, avversario: noi === primo ? secondo : primo };
}
/**
* Storico scontri diretti e probabilità di vittoria da `match-stats.php`. Il blocco di
* riepilogo (non la lista partita-per-partita nel modal, meno affidabile da interpretare)
* riporta i numeri nell'ordine squadra-casa/squadra-ospite di *questa* gara: qui si
* riconosce quale delle due siamo noi tramite `isNostraSquadra()`, come in
* `parseFormazioni()`. Con "0 precedenti" il CSI omette del tutto le righe
* vittorie/in-casa/fuori (restano a 0) ma la probabilità resta comunque presente.
*/
export function parsePrecedenti(html: string): PrecedentiCsi | null {
const idxCasa = html.indexOf("Squadra casa");
const idxOspite = html.indexOf("Squadra ospite");
if (idxCasa === -1 || idxOspite === -1) return null;
const nomeCasaM = /team_details\.php\?team_id=\d+"[^>]*>([^<]+)<\/a>/.exec(
html.slice(idxCasa, idxOspite),
);
const nomeOspiteM = /team_details\.php\?team_id=\d+"[^>]*>([^<]+)<\/a>/.exec(
html.slice(idxOspite),
);
const nomeCasa = nomeCasaM ? testo(nomeCasaM[1] ?? "") : "";
const nomeOspite = nomeOspiteM ? testo(nomeOspiteM[1] ?? "") : "";
const noiECasa = isNostraSquadra(nomeCasa);
if (!noiECasa && !isNostraSquadra(nomeOspite)) return null;
const totaleM = /Precedenti:<\/span>\s*<b><a[^>]*>(\d+)<\/a><\/b>/.exec(html);
const vittorieM =
/<div><b>(\d+)<\/b><\/div>\s*<div[^>]*>vittorie<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(html);
const inCasaM =
/<div><b>(\d+)<\/b><\/div>\s*<div>in casa<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(html);
const fuoriM = /<div><b>(\d+)<\/b><\/div>\s*<div>fuori<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(
html,
);
// Le due barre "progress-bar" appaiono nell'ordine casa/ospite: il colore (verde/rosso)
// segue chi è favorito, non la posizione, quindi qui si usa l'ordine nel markup e non la
// classe CSS per capire quale percentuale è "nostra".
const percentuali = [...html.matchAll(/progress-bar[\s\S]*?width:\s*([\d.]+)%/g)].map((m) =>
Number(m[1]),
);
const [probCasa, probOspite] = percentuali;
// I due gruppi di ogni regex sono nell'ordine squadra-casa/squadra-ospite di questa gara:
// `casaIndice`/`ospiteIndice` scelgono quale dei due è "noi" in base a `noiECasa`.
const casaIndice = noiECasa ? 1 : 2;
const ospiteIndice = noiECasa ? 2 : 1;
const numero = (m: RegExpExecArray | null, indice: 1 | 2) => (m ? Number(m[indice]) : 0);
return {
totale: totaleM ? Number(totaleM[1]) : 0,
vinteNoi: numero(vittorieM, casaIndice),
vinteAvversario: numero(vittorieM, ospiteIndice),
casaNoi: numero(inCasaM, casaIndice),
casaAvversario: numero(inCasaM, ospiteIndice),
fuoriNoi: numero(fuoriM, casaIndice),
fuoriAvversario: numero(fuoriM, ospiteIndice),
probabilitaNoi: (noiECasa ? probCasa : probOspite) ?? null,
probabilitaAvversario: (noiECasa ? probOspite : probCasa) ?? null,
};
}
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import type { DettaglioPartitaCsi } from "./csi-core";
/**
* Dettaglio di una singola gara CSI (formazioni, storico scontri diretti): una fetch in
* più per partita, cache server 6h come `useCsi()`. Va usato solo quando l'utente apre il
* dettaglio di una gara, mai in una lista: vedi docs/modules/collegamento-csi.md.
*/
export function useCsiPartita(matchId: string | undefined) {
return useQuery<DettaglioPartitaCsi>({
queryKey: ["csi-partita", matchId],
queryFn: async () => {
const res = await fetch(`/api/public/csi-partita/${matchId}`);
if (!res.ok) throw new Error("csi-partita non disponibile");
return res.json();
},
enabled: !!matchId,
staleTime: 6 * 60 * 60_000,
retry: 1,
});
}
+4 -6
View File
@@ -1,11 +1,9 @@
import { daRiga, type Evento, type RigaEvento } from "./eventi";
const COLONNE =
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
/** Lettura eventi lato server (route API): stessa conversione del client. */
export async function leggiEventi(): Promise<Evento[]> {
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data } = await supabaseAdmin.from("eventi_app").select(COLONNE).order("data");
return ((data ?? []) as RigaEvento[]).map(daRiga);
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
const admin = await pbAdmin();
const righe = await admin.collection("eventi_app").getFullList<RigaEvento>({ sort: "data" });
return righe.map(daRiga);
}
+47 -39
View File
@@ -1,5 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { pb } from "@/integrations/pocketbase/client";
import { pbISO } from "@/integrations/pocketbase/formato";
import { upsertById } from "@/integrations/pocketbase/upsert";
import type { Giocatore } from "./crapp-data";
export type EventoTipo = "partita" | "allenamento" | "evento" | "compleanno";
@@ -25,6 +27,7 @@ export type Evento = {
creatoIl?: string | undefined;
};
/** Riga così come la restituisce PocketBase. */
export type RigaEvento = {
id: string;
tipo: string;
@@ -32,35 +35,37 @@ export type RigaEvento = {
luogo: string;
data: string;
ora: string;
note: string | null;
convocati: string[] | null;
note: string;
convocati: string[];
campionato: boolean;
casa: boolean | null;
casa: boolean;
pagelle_chiuse: boolean;
creato_il?: string;
created?: string;
};
/** I campi "date" di PocketBase tornano come datetime completo: qui serve solo "YYYY-MM-DD". */
function soloData(v: string): string {
return v.slice(0, 10);
}
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
export function daRiga(r: RigaEvento): Evento {
return {
id: r.id,
tipo: (r.tipo as EventoTipo) ?? "evento",
tipo: (r.tipo as EventoTipo) || "evento",
titolo: r.titolo,
luogo: r.luogo ?? "",
data: r.data,
ora: r.ora ?? "",
note: r.note ?? "",
luogo: r.luogo || "",
data: soloData(r.data),
ora: r.ora || "",
note: r.note || "",
convocati: r.convocati ?? [],
campionato: !!r.campionato,
casa: r.casa ?? true,
casa: r.casa,
pagelleChiuse: !!r.pagelle_chiuse,
creatoIl: r.creato_il,
creatoIl: r.created ? pbISO(r.created) : undefined,
};
}
const COLONNE =
"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";
@@ -79,9 +84,8 @@ export function daCategoria(c: CategoriaEvento): Pick<Evento, "tipo" | "campiona
export const EVENTI_KEY = ["eventi"] as const;
async function fetchEventi(): Promise<Evento[]> {
const { data, error } = await supabase.from("eventi_app").select(COLONNE).order("data");
if (error) throw error;
return ((data ?? []) as RigaEvento[]).map(daRiga);
const righe = await pb.collection("eventi_app").getFullList<RigaEvento>({ sort: "data" });
return righe.map(daRiga);
}
/** Una lettura per sessione: il calendario cambia raramente. */
@@ -119,23 +123,18 @@ export function useSalvaEvento() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (evento: Evento) => {
const { error } = await supabase.from("eventi_app").upsert(
{
id: evento.id,
tipo: evento.tipo,
titolo: evento.titolo,
luogo: evento.luogo,
data: evento.data,
ora: evento.ora,
note: evento.note,
convocati: evento.convocati,
campionato: evento.campionato,
casa: evento.casa,
pagelle_chiuse: evento.pagelleChiuse,
},
{ onConflict: "id" },
);
if (error) throw error;
await upsertById("eventi_app", evento.id, {
tipo: evento.tipo,
titolo: evento.titolo,
luogo: evento.luogo,
data: evento.data,
ora: evento.ora,
note: evento.note,
convocati: evento.convocati,
campionato: evento.campionato,
casa: evento.casa,
pagelle_chiuse: evento.pagelleChiuse,
});
return evento;
},
// Aggiornamento locale della cache: nessuna rilettura dal database.
@@ -152,8 +151,7 @@ export function useEliminaEvento() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { error } = await supabase.from("eventi_app").delete().eq("id", id);
if (error) throw error;
await pb.collection("eventi_app").delete(id);
return id;
},
onSuccess: (id) => {
@@ -162,8 +160,18 @@ export function useEliminaEvento() {
});
}
/** Compleanni della rosa, come eventi di calendario dell'anno indicato. */
export function compleanniEventi(rosa: Giocatore[], anno = new Date().getFullYear()): Evento[] {
/**
* Compleanni della rosa, come eventi di calendario dell'anno indicato.
*
* Prende solo i tre campi che usa (non l'intero `Giocatore`): il Calendario li
* legge da un'anagrafica leggera per non tirarsi dietro tutte le statistiche
* (MVP, pagelle, cacche, palloni, infortuni) di `useRosa` solo per le date di
* nascita.
*/
export function compleanniEventi(
rosa: Array<Pick<Giocatore, "id" | "nome" | "nascita">>,
anno = new Date().getFullYear(),
): Evento[] {
return rosa
.filter((g) => g.nascita)
.map((g) => {
+8 -12
View File
@@ -1,20 +1,16 @@
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. */
/** Lettura squadra lato server (route API): usa il superuser per non dipendere dalla
* sessione del chiamante, 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);
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
const admin = await pbAdmin();
const righe = await admin.collection("giocatori_squadra").getFullList<RigaGiocatoreSquadra>({
sort: "cognome,nome",
});
return righe.map(daRigaSquadra);
}
+59 -65
View File
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
import { pb } from "@/integrations/pocketbase/client";
import { dividiNome, giocatori } from "./crapp-data";
/**
@@ -20,17 +20,18 @@ export type GiocatoreSquadra = {
dataTessera: string | null;
};
/** Riga così come la restituisce PocketBase: le relazioni/campi vuoti sono "", mai null. */
export type RigaGiocatoreSquadra = {
id: string;
nome: string;
cognome: string;
numero: number;
ruolo: string;
auth_user_id: string | null;
auth_user_id: string;
attivo: boolean;
email: string | null;
numero_tessera: string | null;
data_tessera: string | null;
email: string;
numero_tessera: string;
data_tessera: string;
};
/** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */
@@ -76,10 +77,18 @@ export function slotPerEmail(
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";
/** "" -> null: PocketBase non ha un concetto di colonna NULL, i campi vuoti sono stringa vuota. */
function vuotoANull(v: string): string | null {
return v ? v : null;
}
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
/** I campi "date" di PocketBase tornano come datetime completo ("2026-01-01 00:00:00.000Z"):
* qui serve solo "YYYY-MM-DD" (input HTML type="date", confronti testuali con altre date). */
function soloData(v: string): string | null {
return v ? v.slice(0, 10) : null;
}
/** Conversione riga PocketBase -> modello applicativo (riusabile anche lato server). */
export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
return {
id: r.id,
@@ -87,22 +96,18 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
cognome: r.cognome,
numero: r.numero,
ruolo: r.ruolo,
authUserId: r.auth_user_id,
authUserId: vuotoANull(r.auth_user_id),
attivo: r.attivo,
email: r.email,
numeroTessera: r.numero_tessera,
dataTessera: r.data_tessera,
email: vuotoANull(r.email),
numeroTessera: vuotoANull(r.numero_tessera),
dataTessera: soloData(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[];
const righe = await pb.collection("giocatori_squadra").getFullList<RigaGiocatoreSquadra>({
sort: "cognome,nome",
});
return righe.map(daRigaSquadra);
}
@@ -118,8 +123,8 @@ export function useGiocatoriSquadra() {
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.
* Controlli che rispecchiano i vincoli della collection (`numero > 0`, campi obbligatori):
* meglio dirlo qui che far tornare un errore di PocketBase all'utente.
* Restituisce il messaggio da mostrare, oppure `null` se va bene.
*/
export function validaDatiSquadra(dati: DatiSquadra): string | null {
@@ -132,7 +137,7 @@ export function validaDatiSquadra(dati: DatiSquadra): string | null {
return null;
}
/** Il prossimo id libero nel formato `g<N>` richiesto dal vincolo della tabella. */
/** Il prossimo id libero nel formato `g<N>` richiesto dal vincolo della collection. */
export function prossimoIdGiocatore(righe: GiocatoreSquadra[]): string {
const max = righe.reduce((acc, g) => {
const n = Number(g.id.slice(1));
@@ -150,7 +155,7 @@ export function numeroGiaUsato(
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
}
/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
/** Modifica dei dati squadra. Solo un admin passa le API rules di M1. */
export function useSalvaDatiSquadra() {
const queryClient = useQueryClient();
return useMutation({
@@ -160,14 +165,10 @@ export function useSalvaDatiSquadra() {
cognome: input.dati.cognome.trim(),
numero: input.dati.numero,
ruolo: input.dati.ruolo.trim(),
email: input.dati.email?.trim() || null,
email: input.dati.email?.trim() || "",
};
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update(dati)
.eq("id", input.giocatoreId);
if (error) throw error;
return { giocatoreId: input.giocatoreId, dati };
await pb.collection("giocatori_squadra").update(input.giocatoreId, dati);
return { giocatoreId: input.giocatoreId, dati: { ...dati, email: dati.email || null } };
},
onSuccess: (input) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
@@ -182,7 +183,7 @@ export type DatiTesseramento = Pick<GiocatoreSquadra, "numeroTessera" | "dataTes
/**
* 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
* l'hook di M8 lo rende scrivibile solo da un admin, il giocatore non può autodichiararsi
* tesserato.
*/
export function useSalvaTesseramento() {
@@ -190,17 +191,16 @@ export function useSalvaTesseramento() {
return useMutation({
mutationFn: async (input: { giocatoreId: string; dati: DatiTesseramento }) => {
const dati = {
numero_tessera: input.dati.numeroTessera?.trim() || null,
data_tessera: input.dati.dataTessera || null,
numero_tessera: input.dati.numeroTessera?.trim() || "",
data_tessera: input.dati.dataTessera || "",
};
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update(dati)
.eq("id", input.giocatoreId);
if (error) throw error;
await pb.collection("giocatori_squadra").update(input.giocatoreId, dati);
return {
giocatoreId: input.giocatoreId,
dati: { numeroTessera: dati.numero_tessera, dataTessera: dati.data_tessera },
dati: {
numeroTessera: vuotoANull(dati.numero_tessera),
dataTessera: vuotoANull(dati.data_tessera),
},
};
},
onSuccess: (input) => {
@@ -212,7 +212,7 @@ export function useSalvaTesseramento() {
}
/**
* Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le policy di M1.
* Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le API rules di M1.
* L'id (`g<N>`) non è generato dal database: va calcolato con `prossimoIdGiocatore`
* prima di chiamare questa mutazione.
*/
@@ -226,15 +226,20 @@ export function useAggiungiGiocatore() {
cognome: input.dati.cognome.trim(),
numero: input.dati.numero,
ruolo: input.dati.ruolo.trim(),
email: input.dati.email?.trim() || null,
email: input.dati.email?.trim() || "",
attivo: true,
};
await pb.collection("giocatori_squadra").create(riga);
return {
...riga,
email: riga.email || null,
authUserId: null,
numeroTessera: null,
dataTessera: null,
};
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga);
if (error) throw error;
// Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL.
return { ...riga, authUserId: null, attivo: true, numeroTessera: null, dataTessera: null };
},
// Aggiornamento locale della cache: nessuna rilettura, stesso ordine della query
// (`.order("cognome").order("nome")`).
// (sort "cognome,nome").
onSuccess: (nuovo) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
[...(prec ?? []), nuovo].sort(
@@ -253,11 +258,7 @@ 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;
await pb.collection("giocatori_squadra").update(input.giocatoreId, { attivo: input.attivo });
return input;
},
onSuccess: (input) => {
@@ -276,11 +277,7 @@ 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;
await pb.collection("giocatori_squadra").update(giocatoreId, { auth_user_id: "" });
return giocatoreId;
},
onSuccess: (giocatoreId) => {
@@ -292,20 +289,17 @@ export function useScollegaAccount() {
}
/**
* 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.
* Collega l'account al giocatore scelto. L'hook giocatori_squadra_claim.pb.js accetta
* l'operazione solo se lo slot è libero, l'email combacia e 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;
await pb
.collection("giocatori_squadra")
.update(input.giocatoreId, { auth_user_id: input.utenteId });
return input;
},
onSuccess: (input) => {
-58
View File
@@ -1,58 +0,0 @@
type LovableErrorOptions = {
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
handled?: boolean;
severity?: "error" | "warning" | "info";
};
type LovableEvents = {
captureException?: (
error: unknown,
context?: Record<string, unknown>,
options?: LovableErrorOptions,
) => void;
};
declare global {
interface Window {
__lovableEvents?: LovableEvents;
__lovableReportRuntimeError?: (payload: {
message: string;
stack?: string;
filename?: string;
}) => void;
}
}
export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
if (typeof window === "undefined") return;
window.__lovableEvents?.captureException?.(
error,
{
source: "react_error_boundary",
route: window.location.pathname,
...context,
},
{
mechanism: "react_error_boundary",
handled: false,
severity: "error",
},
);
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
// editor's telemetry never sees them. Forward to lovable.js's reporting hook,
// which is present only inside the editor preview.
// Loaders and server fns commonly throw a raw Response; String(it) is the
// opaque "[object Response]", so pull out the status and URL instead.
const message =
error instanceof Response
? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
: error instanceof Error
? error.message
: String(error);
const stack = error instanceof Error ? error.stack : undefined;
window.__lovableReportRuntimeError?.({
message,
...(stack !== undefined && { stack }),
filename: window.location.pathname,
});
}
+49 -14
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { pb } from "@/integrations/pocketbase/client";
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
export type VotoMvp = {
match_id: string;
@@ -8,6 +9,13 @@ export type VotoMvp = {
votato_nome: string;
};
type RigaMvpPocketBase = {
evento: string;
votante: string;
votato: string;
votato_nome: string;
};
const CHIAVE = ["mvp-voti"] as const;
/** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */
@@ -27,11 +35,13 @@ export function useVotiMvp() {
queryKey: CHIAVE,
staleTime: 10 * 60_000,
queryFn: async (): Promise<VotoMvp[]> => {
const { data, error } = await supabase
.from("mvp_voti")
.select("match_id, votante_id, votato_id, votato_nome");
if (error) throw error;
return (data ?? []) as VotoMvp[];
const righe = await pb.collection("mvp_voti").getFullList<RigaMvpPocketBase>();
return righe.map((r) => ({
match_id: r.evento,
votante_id: r.votante,
votato_id: r.votato,
votato_nome: r.votato_nome,
}));
},
});
}
@@ -40,10 +50,18 @@ export function useVotaMvp() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (voto: VotoMvp) => {
const { error } = await supabase
.from("mvp_voti")
.upsert(voto, { onConflict: "match_id,votante_id" });
if (error) throw error;
// No autovoto e convocazione li valida l'hook voti_convocati.pb.js.
await upsertByFilter(
"mvp_voti",
"evento = {:evento} && votante = {:votante}",
{ evento: voto.match_id, votante: voto.votante_id },
{
evento: voto.match_id,
votante: voto.votante_id,
votato: voto.votato_id,
votato_nome: voto.votato_nome,
},
);
return voto;
},
// Aggiorna la cache localmente: nessuna rilettura dal database.
@@ -60,6 +78,9 @@ export function useVotaMvp() {
export type ConteggioMvp = { id: string; nome: string; voti: number };
/** Voti minimi in una partita perché l'MVP possa essere assegnato (un solo voto non decide). */
export const VOTI_MINIMI_MVP = 2;
/** Conteggio voti di una partita, dal più votato. */
export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] {
const map = new Map<string, ConteggioMvp>();
@@ -83,8 +104,13 @@ export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> {
const out: Record<string, string> = {};
for (const [matchId] of perMatch) {
const top = conteggioPartita(voti, matchId);
// In caso di parità nessun MVP assegnato finché il voto non si sblocca.
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
// In caso di parità, o sotto il quorum minimo, nessun MVP assegnato.
if (
totaleVoti >= VOTI_MINIMI_MVP &&
top.length > 0 &&
(top.length === 1 || top[0]!.voti > top[1]!.voti)
) {
out[matchId] = top[0]!.nome;
}
}
@@ -95,13 +121,22 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) {
return voti.find((v) => v.match_id === matchId && v.votante_id === votanteId) ?? null;
}
/** MVP vinti per giocatore, contando una vittoria per partita votata. */
/**
* MVP vinti per giocatore, contando una vittoria per partita votata (una partita in pareggio
* al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti
* restituisce una mappa vuota.
*/
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> {
const out: Record<string, number> = {};
const matchIds = new Set(voti.map((v) => v.match_id));
for (const matchId of matchIds) {
const top = conteggioPartita(voti, matchId);
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
if (
totaleVoti >= VOTI_MINIMI_MVP &&
top.length > 0 &&
(top.length === 1 || top[0]!.voti > top[1]!.voti)
) {
const id = top[0]!.id;
out[id] = (out[id] ?? 0) + 1;
}
+22
View File
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { intestazioniAutenticate } from "./auth";
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
async function fetchNotificheAttive(): Promise<Set<string>> {
const res = await fetch("/api/public/notifiche-attive", {
headers: await intestazioniAutenticate(),
});
if (!res.ok) throw new Error("Impossibile leggere le notifiche attive");
const { giocatoreIds } = (await res.json()) as { giocatoreIds: string[] };
return new Set(giocatoreIds);
}
/** Insieme degli id giocatore con almeno un dispositivo iscritto alle notifiche push. */
export function useNotificheAttive() {
return useQuery({
queryKey: NOTIFICHE_ATTIVE_KEY,
queryFn: fetchNotificheAttive,
staleTime: 5 * 60_000,
});
}
+4
View File
@@ -0,0 +1,4 @@
/** Id giocatore distinti tra le righe di `push_subscriptions` (una riga per dispositivo). */
export function idsConNotificheAttive(righe: Array<{ giocatore_id: string }>): string[] {
return [...new Set(righe.map((r) => r.giocatore_id))];
}
+34 -11
View File
@@ -26,11 +26,32 @@ export type ContestoObiettivi = {
export const contestoVuoto: ContestoObiettivi = { eventi: [], presenze: {}, pagelle: [] };
const MESE = "2026-08";
/** Mese corrente in formato "YYYY-MM" (fuso Europe/Rome), per gli obiettivi che si azzerano ogni mese. */
function meseCorrente(oggi: Date): string {
return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(oggi).slice(0, 7);
}
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number) {
/** "a settembre" / "ad agosto": preposizione con elisione davanti a vocale. */
function aMese(oggi: Date): string {
const nome = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", month: "long" }).format(
oggi,
);
const preposizione = /^[aeiou]/i.test(nome) ? "ad" : "a";
return `${preposizione} ${nome}`;
}
/** Ultimo giorno del mese corrente, come "YYYY-MM-DD". */
function fineMese(oggi: Date): string {
const mese = meseCorrente(oggi);
const anno = Number(mese.slice(0, 4));
const numeroMese = Number(mese.slice(5, 7));
const ultimoGiorno = new Date(Date.UTC(anno, numeroMese, 0)).getUTCDate();
return `${mese}-${String(ultimoGiorno).padStart(2, "0")}`;
}
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese: string) {
const delMese = ctx.eventi.filter(
(e) => e.data.startsWith(MESE) && (e.tipo === "partita" || e.tipo === "allenamento"),
(e) => e.data.startsWith(mese) && (e.tipo === "partita" || e.tipo === "allenamento"),
);
if (delMese.length === 0 || rosaSize === 0) return 0;
const posti = delMese.length * rosaSize;
@@ -56,19 +77,21 @@ function percentualeRisposte(ctx: ContestoObiettivi, rosaSize: number) {
export function obiettiviSquadra(
rosa: Giocatore[] = giocatori,
ctx: ContestoObiettivi = contestoVuoto,
oggi: Date = new Date(),
): ObiettivoSquadra[] {
const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0);
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
const vittorie = ctx.vittorie ?? 0;
const mese = meseCorrente(oggi);
return [
{
id: "o1",
titolo: "90% di presenze ad agosto",
titolo: `90% di presenze ${aMese(oggi)}`,
descrizione: "Media presenze su partite e allenamenti del mese",
valore: percentualePresenzeMese(ctx, rosa.length),
valore: percentualePresenzeMese(ctx, rosa.length, mese),
target: 90,
unita: "%",
scadenza: "2026-08-31",
scadenza: fineMese(oggi),
emoji: "📣",
impatto: "Ogni sì in più alza la media di tutta la squadra.",
},
@@ -79,7 +102,7 @@ export function obiettiviSquadra(
valore: percentualeRisposte(ctx, rosa.length),
target: 90,
unita: "%",
scadenza: "2026-09-30",
scadenza: fineMese(oggi),
emoji: "⚡",
impatto: "Bastano pochi tap per far quadrare i conti a chi organizza.",
},
@@ -147,7 +170,7 @@ export function obiettiviSquadra(
id: "o5",
titolo: "10 vittorie in campionato",
descrizione: "Obiettivo stagionale per il podio",
valore: vittorie,
valore: Math.min(vittorie, 10),
target: 10,
unita: "vittorie",
emoji: "🏆",
@@ -157,7 +180,7 @@ export function obiettiviSquadra(
id: "o6",
titolo: "1 evento di squadra al mese",
descrizione: "Pizzate, cene e uscite fuori dal campo",
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(MESE)).length,
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(mese)).length,
target: 1,
unita: "eventi",
emoji: "🍕",
@@ -170,8 +193,8 @@ export function progressoObiettivo(o: ObiettivoSquadra) {
return Math.min(100, Math.round((o.valore / o.target) * 100));
}
export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi) {
return obiettiviSquadra(rosa, ctx).sort((a, b) => {
export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi, oggi?: Date) {
return obiettiviSquadra(rosa, ctx, oggi).sort((a, b) => {
const pa = progressoObiettivo(a);
const pb = progressoObiettivo(b);
const ca = pa >= 100 ? 1 : 0;
+43 -11
View File
@@ -1,5 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { pb } from "@/integrations/pocketbase/client";
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
import { VOTI_MINIMI_PAGELLA } from "./badges";
/** Voto anonimo da 1 a 10 dato a un compagno per una partita. */
export type VotoPagella = {
@@ -9,6 +11,13 @@ export type VotoPagella = {
voto: number;
};
type RigaPagellaPocketBase = {
evento: string;
votante: string;
votato: string;
voto: number;
};
export const PAGELLE_KEY = ["pagelle"] as const;
/** Poche righe per stagione: una lettura per sessione basta. */
@@ -17,11 +26,13 @@ export function usePagelle() {
queryKey: PAGELLE_KEY,
staleTime: 10 * 60_000,
queryFn: async (): Promise<VotoPagella[]> => {
const { data, error } = await supabase
.from("pagelle_voti")
.select("match_id, votante_id, votato_id, voto");
if (error) throw error;
return (data ?? []) as VotoPagella[];
const righe = await pb.collection("pagelle_voti").getFullList<RigaPagellaPocketBase>();
return righe.map((r) => ({
match_id: r.evento,
votante_id: r.votante,
votato_id: r.votato,
voto: r.voto,
}));
},
});
return { ...query, voti: query.data ?? [] };
@@ -31,10 +42,18 @@ export function useVotaPagella() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (voto: VotoPagella) => {
const { error } = await supabase
.from("pagelle_voti")
.upsert(voto, { onConflict: "match_id,votante_id,votato_id" });
if (error) throw error;
// Convocazione e "pagelle non chiuse" li valida l'hook voti_convocati.pb.js.
await upsertByFilter(
"pagelle_voti",
"evento = {:evento} && votante = {:votante} && votato = {:votato}",
{ evento: voto.match_id, votante: voto.votante_id, votato: voto.votato_id },
{
evento: voto.match_id,
votante: voto.votante_id,
votato: voto.votato_id,
voto: voto.voto,
},
);
return voto;
},
// Cache aggiornata localmente: nessuna rilettura.
@@ -60,7 +79,11 @@ function arrotonda(n: number) {
return Math.round(n * 10) / 10;
}
/** Media stagionale di ciascun giocatore: giocatoreId -> media e numero di voti. */
/**
* Media storica di ciascun giocatore su tutti i voti mai ricevuti (l'app non ha un concetto
* di stagione/reset): giocatoreId -> media e numero di voti. Chi non ha ancora ricevuto voti
* non compare nella mappa (nessuna divisione per zero): sta al chiamante gestire il fallback.
*/
export function mediePagelle(voti: VotoPagella[]): Record<string, MediaPagella> {
const somma: Record<string, { tot: number; n: number }> = {};
for (const v of voti) {
@@ -90,6 +113,15 @@ export function mieiVoti(voti: VotoPagella[], matchId: string, votanteId: string
return out;
}
/**
* Media voto da mostrare nella StatTile home (sezione «Colpo d'occhio», DD-028): sotto
* `VOTI_MINIMI_PAGELLA` voti ricevuti, `` invece della media grezza stessa soglia del
* badge Pagellone, non applicata invece dalle StatTile di profilo e squadra.
*/
export function mediaVotoColpoDOcchio(g: { mediaVoto: number; votiPagella: number }): number | "—" {
return g.votiPagella >= VOTI_MINIMI_PAGELLA ? g.mediaVoto : "—";
}
/** Media pagelle di tutta la squadra su tutte le partite. */
export function mediaSquadra(voti: VotoPagella[]) {
if (voti.length === 0) return 0;
+18
View File
@@ -1,6 +1,7 @@
import { formatData } from "./crapp-data";
import type { Evento } from "./eventi";
import { dataOggi } from "./scout-live";
import { aggiornaSerie } from "./serie";
export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null };
@@ -81,6 +82,23 @@ export function conteggioTurni(
return out;
}
/**
* Volte consecutive in cui il giocatore ha portato i palloni, contando solo eventi già
* passati (stesso criterio `e.data < oggi` di `conteggioTurni`): un evento passato senza
* turno confermato non spezza la serie di nessuno, perché non dice ancora chi ha portato
* i palloni quella volta.
*/
export function serieConsecutivaPalloni(
giocatoreId: string,
turni: Record<string, string>,
eventi: Evento[],
oggi: string = dataOggi(),
): number {
return eventiPalloni(eventi)
.filter((e) => e.data < oggi && turni[e.id])
.reduce((serie, e) => aggiornaSerie(serie, turni[e.id] === giocatoreId), 0);
}
export function eventiDelGiorno(eventi: Evento[], isoData: string): Evento[] {
return eventiPalloni(eventi).filter((e) => e.data === isoData);
}
+14 -16
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { pb } from "@/integrations/pocketbase/client";
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
import { completaTurni } from "./palloni-core";
import { useEventi } from "./eventi";
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
@@ -7,18 +8,15 @@ import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
export const TURNI_KEY = ["turni-palloni"] as const;
type RigaTurno = {
evento_id: string;
giocatore_id: string;
aggiornato_da: string | null;
evento: string;
giocatore: string;
aggiornato_da: string;
};
async function fetchTurni(): Promise<Record<string, string>> {
const { data, error } = await supabase
.from("turni_palloni")
.select("evento_id, giocatore_id, aggiornato_da");
if (error) throw error;
const righe = await pb.collection("turni_palloni").getFullList<RigaTurno>();
const mappa: Record<string, string> = {};
for (const riga of (data ?? []) as RigaTurno[]) mappa[riga.evento_id] = riga.giocatore_id;
for (const riga of righe) mappa[riga.evento] = riga.giocatore;
return mappa;
}
@@ -37,16 +35,16 @@ export function useAssegnaTurno() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { eventoId: string; giocatoreId: string; da: string | null }) => {
const { error } = await supabase.from("turni_palloni").upsert(
await upsertByFilter(
"turni_palloni",
"evento = {:evento}",
{ evento: input.eventoId },
{
evento_id: input.eventoId,
giocatore_id: input.giocatoreId,
aggiornato_da: input.da,
aggiornato_il: new Date().toISOString(),
evento: input.eventoId,
giocatore: input.giocatoreId,
aggiornato_da: input.da ?? "",
},
{ onConflict: "evento_id" },
);
if (error) throw error;
return input;
},
// Scrittura unica + aggiornamento cache locale, senza rilettura.
+60 -27
View File
@@ -1,5 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { pb } from "@/integrations/pocketbase/client";
import { pbISO } from "@/integrations/pocketbase/formato";
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
import type { Stato } from "./crapp-data";
import type { Evento } from "./eventi";
import { aggiornaSerie } from "./serie";
@@ -13,29 +15,62 @@ export type MappaPresenze = Record<string, Record<string, Stato>>;
/** eventoId -> giocatoreId -> istante della prima risposta (ISO). */
export type MappaTempiRisposta = Record<string, Record<string, string>>;
type RigaPresenza = {
id: string;
evento: string;
giocatore: string;
stato: string;
risposto_il: string;
};
/** Allenamenti e partite CrAPP già passati, che contano per le statistiche di presenza. */
function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string, oggi = dataOggi()) {
function eventiContanoPresenze(
eventi: Evento[],
giocatoreId?: string,
oggi = dataOggi(),
tipo?: "partita" | "allenamento",
) {
return eventi.filter(
(e) =>
(e.tipo === "partita" || e.tipo === "allenamento") &&
(tipo === undefined || e.tipo === tipo) &&
e.data < oggi &&
(giocatoreId === undefined || e.convocati.length === 0 || e.convocati.includes(giocatoreId)),
);
}
/** Presenze effettive (presente o in ritardo) su eventi CrAPP. */
/**
* Presenze effettive (presente o in ritardo) su eventi CrAPP. Senza eventi rilevanti
* restituisce 0. `tipo` filtra a un solo tipo di evento (es. solo partite); di default
* conta partite e allenamenti insieme, come il resto delle statistiche di presenza.
*/
export function contaPresenzeGiocatore(
giocatoreId: string,
eventi: Evento[],
presenze: MappaPresenze,
oggi: string = dataOggi(),
tipo?: "partita" | "allenamento",
): number {
return eventiContanoPresenze(eventi, giocatoreId, oggi).filter((e) => {
return eventiContanoPresenze(eventi, giocatoreId, oggi, tipo).filter((e) => {
const stato = presenze[e.id]?.[giocatoreId];
return stato === "presente" || stato === "ritardo";
}).length;
}
/**
* Partite (non allenamenti) a cui il giocatore era presente o in ritardo: il dato giusto
* per contesti legati alle prestazioni in campo (es. MVP), a differenza di
* `totaliEventiGiocatore()` che è il denominatore delle presenze e include gli allenamenti.
*/
export function contaPartiteGiocate(
giocatoreId: string,
eventi: Evento[],
presenze: MappaPresenze,
oggi: string = dataOggi(),
): number {
return contaPresenzeGiocatore(giocatoreId, eventi, presenze, oggi, "partita");
}
/** Eventi CrAPP rilevanti per il denominatore presenze di un giocatore. */
export function totaliEventiGiocatore(
giocatoreId: string,
@@ -133,15 +168,12 @@ function serieSu(
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, risposto_il");
if (error) throw error;
const righe = await pb.collection("risposte_presenze").getFullList<RigaPresenza>();
const presenze: MappaPresenze = {};
const tempi: MappaTempiRisposta = {};
for (const riga of data ?? []) {
(presenze[riga.evento_id] ??= {})[riga.giocatore_id] = riga.stato as Stato;
(tempi[riga.evento_id] ??= {})[riga.giocatore_id] = riga.risposto_il;
for (const riga of righe) {
(presenze[riga.evento] ??= {})[riga.giocatore] = riga.stato as Stato;
(tempi[riga.evento] ??= {})[riga.giocatore] = pbISO(riga.risposto_il);
}
return { presenze, tempi };
}
@@ -163,7 +195,7 @@ export function usePresenzeEvento(eventoId: string) {
* Due dettagli non sono cosmetici e non vanno persi (vedi `docs/modules/serie-presenze.md`):
*
* - l'istante si scrive **solo se manca** (`??=`), come fa il database, dove `risposto_il`
* non viene inviato sull'upsert e un trigger lo congela: è la prima risposta, non l'ultima,
* non viene inviato sulla scrittura e un hook lo congela: è la prima risposta, non l'ultima,
* e un ripensamento non deve far ripartire il cronometro della serie "Conferme 24h";
* - cancellare la risposta (`stato: null`) elimina **anche** l'istante, così se il giocatore
* risponde di nuovo il cronometro riparte davvero ha ritirato la risposta.
@@ -194,23 +226,24 @@ export function useSalvaPresenza() {
return useMutation({
mutationFn: async (input: { eventoId: string; giocatoreId: string; stato: Stato | null }) => {
if (input.stato === null) {
const { error } = await supabase
.from("risposte_presenze")
.delete()
.eq("evento_id", input.eventoId)
.eq("giocatore_id", input.giocatoreId);
if (error) throw error;
const esistente = await pb
.collection("risposte_presenze")
.getFirstListItem(
pb.filter("evento = {:evento} && giocatore = {:giocatore}", {
evento: input.eventoId,
giocatore: input.giocatoreId,
}),
)
.catch(() => null);
if (esistente) await pb.collection("risposte_presenze").delete(esistente.id);
} else {
const { error } = await supabase.from("risposte_presenze").upsert(
{
evento_id: input.eventoId,
giocatore_id: input.giocatoreId,
stato: input.stato,
aggiornato_il: new Date().toISOString(),
},
{ onConflict: "evento_id,giocatore_id" },
// risposto_il NON va inviato: lo valorizza/congela l'hook risposte_presenze_immutabili.pb.js.
await upsertByFilter(
"risposte_presenze",
"evento = {:evento} && giocatore = {:giocatore}",
{ evento: input.eventoId, giocatore: input.giocatoreId },
{ evento: input.eventoId, giocatore: input.giocatoreId, stato: input.stato },
);
if (error) throw error;
}
return input;
},
+7 -72
View File
@@ -2,10 +2,14 @@ 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.
* Profilo amministrativo di un giocatore (DD-016). I file veri sono campi file su
* PocketBase (`profili_giocatore`, protetti mai URL pubblici): qui viaggia il nome del
* file caricato (usato anche come semplice marcatore "presente/assente"). `id` è l'id del
* record PocketBase (non quello del giocatore): serve a costruire l'URL dei file, `null`
* finché il profilo non è mai stato salvato.
*/
export type Profilo = {
id: string | null;
giocatoreId: string;
dataNascita: string | null;
luogoNascita: string | null;
@@ -24,30 +28,9 @@ export type Profilo = {
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 {
id: null,
giocatoreId,
dataNascita: null,
luogoNascita: null,
@@ -67,54 +50,6 @@ export function profiloVuoto(giocatoreId: string): Profilo {
};
}
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;
+115 -58
View File
@@ -1,29 +1,71 @@
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";
import { pb } from "@/integrations/pocketbase/client";
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
import type { Profilo } from "./profili-core";
export const PROFILI_KEY = ["profili-giocatore"] as const;
export const BUCKET = "profili-giocatore";
type RigaProfiloPocketBase = {
id: string;
giocatore: string;
data_nascita: string;
luogo_nascita: string;
indirizzo: string;
telefono: string;
email: string;
documento_tipo: string;
documento_numero: string;
documento_rilasciato_da: string;
documento_emissione: string;
documento_scadenza: string;
documento_fronte: string;
documento_retro: string;
certificato_scadenza: string;
certificato: string;
foto: string;
};
/** "" -> null: PocketBase non ha un concetto di colonna NULL, i campi vuoti sono stringa vuota. */
function vuotoANull(v: string): string | null {
return v ? v : null;
}
/** I campi "date" di PocketBase tornano come datetime completo: qui serve solo "YYYY-MM-DD". */
function soloData(v: string): string | null {
return v ? v.slice(0, 10) : null;
}
function daRiga(r: RigaProfiloPocketBase): Profilo {
return {
id: r.id,
giocatoreId: r.giocatore,
dataNascita: soloData(r.data_nascita),
luogoNascita: vuotoANull(r.luogo_nascita),
indirizzo: vuotoANull(r.indirizzo),
telefono: vuotoANull(r.telefono),
email: vuotoANull(r.email),
documentoTipo: vuotoANull(r.documento_tipo),
documentoNumero: vuotoANull(r.documento_numero),
documentoRilasciatoDa: vuotoANull(r.documento_rilasciato_da),
documentoEmissione: soloData(r.documento_emissione),
documentoScadenza: soloData(r.documento_scadenza),
documentoFrontePath: vuotoANull(r.documento_fronte),
documentoRetroPath: vuotoANull(r.documento_retro),
certificatoScadenza: soloData(r.certificato_scadenza),
certificatoPath: vuotoANull(r.certificato),
fotoPath: vuotoANull(r.foto),
};
}
async function fetchProfili(): Promise<Record<string, Profilo>> {
const { data, error } = await supabaseNuoveTabelle
.from("profili_giocatore")
.select(COLONNE_PROFILO);
if (error) throw error;
const righe = await pb.collection("profili_giocatore").getFullList<RigaProfiloPocketBase>();
const mappa: Record<string, Profilo> = {};
for (const r of (data ?? []) as RigaProfilo[]) mappa[r.giocatore_id] = daRigaProfilo(r);
for (const r of righe) mappa[r.giocatore] = daRiga(r);
return mappa;
}
/**
* Profili visibili all'utente corrente: le policy RLS decidono quanti sono il proprio
* Profili visibili all'utente corrente: le API rule decidono quanti sono il proprio
* per un giocatore, tutti per un admin. Una lettura per sessione.
*/
export function useProfili() {
@@ -32,33 +74,55 @@ export function useProfili() {
}
/**
* 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.
* I documenti sono un campo file protetto e non hanno URL permanenti (DD-016 regola 4):
* ogni download passa da un token che scade a breve, l'equivalente PocketBase della
* signed URL Supabase.
*/
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 urlFirmato(recordId: string, filename: string): Promise<string> {
const token = await pb.files.getToken();
return pb.files.getURL({ id: recordId, collectionName: "profili_giocatore" }, filename, {
token,
});
}
export async function scaricaFile(path: string): Promise<void> {
const url = await urlFirmato(path);
export async function scaricaFile(recordId: string, filename: string): Promise<void> {
const url = await urlFirmato(recordId, filename);
window.open(url, "_blank", "noopener,noreferrer");
}
/**
* Salva il profilo del giocatore. Le policy RLS lasciano scrivere solo la propria riga:
* Salva i campi testuali del profilo. Le API rule lasciano scrivere solo la propria riga:
* il vincolo vive nel database, qui non serve ricontrollarlo.
*
* I campi file NON passano da qui: PocketBase si aspetta un file in quei campi, non una
* stringa, quindi mandarli in questo upsert testuale fallirebbe la validazione. Li scrive
* `caricaFile` con una richiesta multipart dedicata ometterli qui li lascia semplicemente
* invariati, esattamente il comportamento voluto.
*/
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;
const riga = await upsertByFilter<RigaProfiloPocketBase>(
"profili_giocatore",
"giocatore = {:giocatore}",
{ giocatore: profilo.giocatoreId },
{
giocatore: profilo.giocatoreId,
data_nascita: profilo.dataNascita?.trim() || "",
luogo_nascita: profilo.luogoNascita?.trim() || "",
indirizzo: profilo.indirizzo?.trim() || "",
telefono: profilo.telefono?.trim() || "",
email: profilo.email?.trim() || "",
documento_tipo: profilo.documentoTipo?.trim() || "",
documento_numero: profilo.documentoNumero?.trim() || "",
documento_rilasciato_da: profilo.documentoRilasciatoDa?.trim() || "",
documento_emissione: profilo.documentoEmissione || "",
documento_scadenza: profilo.documentoScadenza || "",
certificato_scadenza: profilo.certificatoScadenza || "",
},
);
return daRiga(riga);
},
// Scrittura unica e cache aggiornata a mano, senza rilettura.
onSuccess: (profilo) => {
@@ -72,46 +136,39 @@ export function useSalvaProfilo() {
export type SezioneFile = "documento-fronte" | "documento-retro" | "certificato" | "foto";
const CAMPO_SEZIONE: Record<SezioneFile, keyof RigaProfiloPocketBase> = {
"documento-fronte": "documento_fronte",
"documento-retro": "documento_retro",
certificato: "certificato",
foto: "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.
* Carica un file nel campo giusto del profilo del giocatore (crea il profilo se non
* esiste ancora) e restituisce id del record e nome del file, da salvare nella bozza
* locale. Il controllo su tipo e dimensione sta qui perché è il confine con un file scelto
* dall'utente; le API rule della collection impediscono comunque di scrivere sul profilo
* di qualcun altro.
*/
export async function caricaFile(
giocatoreId: string,
sezione: SezioneFile,
file: File,
pathPrecedente?: string | null,
): Promise<string> {
): Promise<{ id: string; filename: 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;
const campo = CAMPO_SEZIONE[sezione];
const riga = await upsertByFilter<RigaProfiloPocketBase>(
"profili_giocatore",
"giocatore = {:giocatore}",
{ giocatore: giocatoreId },
{ giocatore: giocatoreId, [campo]: file },
);
return { id: riga.id, filename: riga[campo] };
}
+96 -4
View File
@@ -4,12 +4,13 @@ import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
import { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti";
import { mediePagelle, usePagelle } from "./pagelle";
import { statisticheCacche, useCacche } from "./cacche";
import { conteggioTurni } from "./palloni-core";
import { conteggioTurni, serieConsecutivaPalloni } from "./palloni-core";
import { useTurniPalloni } from "./palloni";
import { useInfortuniERitardi } from "./infortuni";
import { useGiocatoreId } from "./user-store";
import { useEventi } from "./eventi";
import {
contaPartiteGiocate,
contaPresenzeGiocatore,
serieConferme,
serieConsecutiva,
@@ -24,6 +25,31 @@ function iniziali(nome: string, cognome: string): string {
return `${nome[0] ?? ""}${cognome[0] ?? ""}`.toUpperCase();
}
/**
* Solo anagrafica (id, nome, ruolo, numero, data di nascita) dei giocatori attivi es.
* per i compleanni nel Calendario o le liste presenze. A differenza di `useRosa` non
* legge MVP, pagelle, cacche, palloni infortuni: evita di montare quei cinque hook e
* il relativo `useMemo` solo per l'anagrafica.
*/
export function useAnagraficaRosa(): Array<
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita">
> {
const { righe: squadra } = useGiocatoriSquadra();
return useMemo(
() =>
squadra
.filter((g) => g.attivo)
.map((g) => ({
id: g.id,
nome: nomeCompleto(g),
ruolo: g.ruolo,
numero: g.numero,
nascita: nascitaPerId[g.id] ?? "",
})),
[squadra],
);
}
/**
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
* palloni, infortuni, ritardi, cacche). Legge l'anagrafica da `giocatori_squadra`
@@ -36,7 +62,7 @@ export function useRosa(): Giocatore[] {
const voti = useVotiMvp();
const { voti: pagelle } = usePagelle();
const { righe: cacche } = useCacche();
const { turni } = useTurniPalloni();
const { salvati: turniSalvati } = useTurniPalloni();
const { infortuni, ritardi } = useInfortuniERitardi();
const { eventi } = useEventi();
const { presenze: mappaPresenze, tempi } = useRispostePresenze();
@@ -46,7 +72,10 @@ export function useRosa(): Giocatore[] {
return useMemo(() => {
const medie = mediePagelle(pagelle);
const statCacche = statisticheCacche(cacche);
const palloni = conteggioTurni(turni, eventi);
// Solo i turni confermati, non le proposte automatiche di completaTurni(): il badge deve
// premiare chi ha davvero portato i palloni, non chi l'algoritmo di rotazione ha
// scelto per un evento passato senza che nessuno confermasse nulla.
const palloni = conteggioTurni(turniSalvati, eventi);
const mvpVinti = mvpVintiPerGiocatore(votiMvp);
return squadra
@@ -60,19 +89,82 @@ export function useRosa(): Giocatore[] {
iniziali: iniziali(g.nome, g.cognome),
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
totaliEventi: totaliEventiGiocatore(g.id, eventi),
partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze),
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
serieConferme: serieConferme(g.id, eventi, tempi),
mvp: mvpVinti[g.id] ?? 0,
mediaVoto: medie[g.id]?.media ?? 0,
votiPagella: medie[g.id]?.voti ?? 0,
palloni: palloni[g.id] ?? 0,
seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi),
cacche: statCacche[g.id]?.giornateTop ?? 0,
cacchePartita: statCacche[g.id]?.media ?? 0,
infortuni: infortuni[g.id] ?? 0,
ritardi: ritardi[g.id] ?? 0,
}));
}, [squadra, votiMvp, pagelle, cacche, turni, infortuni, ritardi, eventi, mappaPresenze, tempi]);
}, [
squadra,
votiMvp,
pagelle,
cacche,
turniSalvati,
infortuni,
ritardi,
eventi,
mappaPresenze,
tempi,
]);
}
/** Criteri di ordinamento della classifica interna di Squadra. */
export type CriterioClassifica = "presenze" | "mediaVoto" | "mvp" | "palloni" | "cacchePartita";
/**
* Dettaglio mostrato sotto il nome nella classifica interna, coerente col criterio
* selezionato: mostrare sempre le "presenze consecutive" aveva senso solo per Presenze,
* per gli altri criteri era un dato fuorviante perché scollegato dal valore in classifica.
*/
export function dettaglioClassifica(
g: {
streak: number;
votiPagella: number;
partiteGiocate: number;
cacche: number;
seriePalloni: number;
},
criterio: CriterioClassifica,
): string {
switch (criterio) {
case "mediaVoto":
return `${g.votiPagella} voti pagella`;
case "mvp":
return `${g.partiteGiocate} partite giocate`;
case "cacchePartita":
return `${g.cacche} giornate top`;
case "palloni":
return `${g.seriePalloni} volte consecutive`;
default:
return `${g.streak} presenze consecutive`;
}
}
/**
* Posizione in classifica ("dense rank"): a parità di valore i giocatori condividono la
* stessa posizione e il numero successivo non salta (1, 1, 2 non 1, 1, 3). `valori` deve
* essere già ordinato in modo decrescente, coerente con l'ordine visualizzato.
*/
export function classificaRank(valori: number[]): number[] {
const rank: number[] = [];
for (let i = 0; i < valori.length; i++) {
if (i > 0 && valori[i] === valori[i - 1]) {
rank.push(rank[i - 1] ?? 1);
} else {
rank.push((rank[i - 1] ?? 0) + 1);
}
}
return rank;
}
/** Il giocatore selezionato sul dispositivo, con le statistiche complete. */
+9 -28
View File
@@ -1,34 +1,15 @@
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.
* Permessi di amministrazione: unica fonte è il campo `role` sul record utente
* PocketBase (DD-011). Nessuna lista di nomi, altrimenti basterebbe scegliere il nome
* giusto per amministrare.
*
* A differenza di Supabase (dove il ruolo stava nella tabella separata `user_roles` e
* richiedeva una query dedicata), qui il ruolo arriva già con la sessione: nessuna
* richiesta di rete o cache aggiuntiva serve.
*/
/** `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;
const { sessione } = useSessione();
return sessione?.["role"] === "admin";
}
+63 -29
View File
@@ -1,6 +1,9 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { ClientResponseError } from "pocketbase";
import { pb } from "@/integrations/pocketbase/client";
import { pbISO } from "@/integrations/pocketbase/formato";
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
import { useEventi, type Evento } from "./eventi";
/** Minuti dopo i quali una sessione scout inattiva viene considerata libera. */
@@ -29,6 +32,23 @@ export type SessioneScout = {
aggiornato_il: string;
};
type RigaSessionePocketBase = {
id: string;
evento: string;
giocatore: string;
giocatore_nome: string;
updated: string;
};
function daRigaSessione(r: RigaSessionePocketBase): SessioneScout {
return {
evento_id: r.evento,
giocatore_id: r.giocatore,
giocatore_nome: r.giocatore_nome,
aggiornato_il: pbISO(r.updated),
};
}
export function sessioneScaduta(s: SessioneScout | null): boolean {
if (!s) return true;
const aggiornato = new Date(s.aggiornato_il).getTime();
@@ -41,13 +61,17 @@ export const SESSIONE_KEY = (eventoId: string) => ["scout-sessione", eventoId] a
/** 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;
try {
const riga = await pb
.collection("scout_sessioni")
.getFirstListItem<RigaSessionePocketBase>(
pb.filter("evento = {:evento}", { evento: eventoId }),
);
return daRigaSessione(riga);
} catch (errore) {
if (errore instanceof ClientResponseError && errore.status === 404) return null;
throw errore;
}
}
export function useSessioneScout(eventoId: string | null) {
@@ -70,16 +94,12 @@ export function useApriSessioneScout() {
if (attuale && !sessioneScaduta(attuale) && attuale.giocatore_id !== input.giocatoreId) {
return false;
}
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" },
await upsertByFilter(
"scout_sessioni",
"evento = {:evento}",
{ evento: input.eventoId },
{ evento: input.eventoId, giocatore: input.giocatoreId, giocatore_nome: input.nome },
);
if (error) throw error;
return true;
},
onSuccess: (_ok, input) => {
@@ -92,13 +112,17 @@ export function useChiudiSessioneScout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { eventoId: string; giocatoreId: string }) => {
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;
const attuale = await pb
.collection("scout_sessioni")
.getFirstListItem<RigaSessionePocketBase>(
pb.filter("evento = {:evento}", { evento: input.eventoId }),
)
.catch((errore) => {
if (errore instanceof ClientResponseError && errore.status === 404) return null;
throw errore;
});
if (attuale && attuale.giocatore === input.giocatoreId) {
await pb.collection("scout_sessioni").delete(attuale.id);
}
},
onSuccess: (_d, input) => {
@@ -116,11 +140,21 @@ export function useHeartbeatScout(
useEffect(() => {
if (!attivo || !eventoId || !giocatoreId) return;
const id = window.setInterval(() => {
void supabase
.from("scout_sessioni")
.update({ aggiornato_il: new Date().toISOString() })
.eq("evento_id", eventoId)
.eq("giocatore_id", giocatoreId);
// Solo aggiornamento, mai creazione: se la sessione è stata chiusa o presa da un
// altro giocatore nel frattempo, il filtro non trova nulla e il battito è un no-op
// (stesso comportamento dell'update Supabase originale). Riscrivere `giocatore` con
// lo stesso valore basta a PocketBase per aggiornare `updated`, che qui fa le veci
// di `aggiornato_il`.
void pb
.collection("scout_sessioni")
.getFirstListItem<RigaSessionePocketBase>(
pb.filter("evento = {:evento} && giocatore = {:giocatore}", {
evento: eventoId,
giocatore: giocatoreId,
}),
)
.then((riga) => pb.collection("scout_sessioni").update(riga.id, { giocatore: giocatoreId }))
.catch(() => {});
}, 60_000);
return () => window.clearInterval(id);
}, [attivo, eventoId, giocatoreId]);
+28 -18
View File
@@ -1,5 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { ClientResponseError } from "pocketbase";
import { pb } from "@/integrations/pocketbase/client";
import { upsertByFilter } from "@/integrations/pocketbase/upsert";
import type { Azione } from "./scout-store";
/** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */
@@ -19,6 +21,8 @@ export const statoIniziale = (avversario: string, casa: boolean): StatoScout =>
export const SCOUT_STATO_KEY = (eventoId: string) => ["scout-stato", eventoId] as const;
type RigaScoutLive = { evento: string; stato: unknown };
export function useStatoScout(eventoId: string | null) {
return useQuery({
queryKey: SCOUT_STATO_KEY(eventoId ?? "-"),
@@ -26,13 +30,14 @@ export function useStatoScout(eventoId: string | null) {
staleTime: Infinity,
queryFn: async (): Promise<StatoScout | null> => {
if (!eventoId) return null;
const { data, error } = await supabase
.from("scout_live")
.select("stato")
.eq("evento_id", eventoId)
.maybeSingle();
if (error) throw error;
const stato = data?.stato as StatoScout | undefined;
const riga = await pb
.collection("scout_live")
.getFirstListItem<RigaScoutLive>(pb.filter("evento = {:evento}", { evento: eventoId }))
.catch((errore) => {
if (errore instanceof ClientResponseError && errore.status === 404) return null;
throw errore;
});
const stato = riga?.stato as StatoScout | undefined;
if (!stato || !Array.isArray(stato.azioni)) return null;
return stato;
},
@@ -43,15 +48,12 @@ export function useSalvaStatoScout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { eventoId: string; stato: StatoScout }) => {
const { error } = await supabase.from("scout_live").upsert(
{
evento_id: input.eventoId,
stato: JSON.parse(JSON.stringify(input.stato)),
aggiornato_il: new Date().toISOString(),
},
{ onConflict: "evento_id" },
await upsertByFilter(
"scout_live",
"evento = {:evento}",
{ evento: input.eventoId },
{ evento: input.eventoId, stato: JSON.parse(JSON.stringify(input.stato)) },
);
if (error) throw error;
return input;
},
onSuccess: (input) => {
@@ -64,8 +66,16 @@ export function useCancellaStatoScout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (eventoId: string) => {
const { error } = await supabase.from("scout_live").delete().eq("evento_id", eventoId);
if (error) throw error;
const riga = await pb
.collection("scout_live")
.getFirstListItem<RigaScoutLive & { id: string }>(
pb.filter("evento = {:evento}", { evento: eventoId }),
)
.catch((errore) => {
if (errore instanceof ClientResponseError && errore.status === 404) return null;
throw errore;
});
if (riga) await pb.collection("scout_live").delete(riga.id);
return eventoId;
},
onSuccess: (eventoId) => {
+10 -14
View File
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { pb } from "@/integrations/pocketbase/client";
import { giocatori, type Giocatore } from "./crapp-data";
export type AzioneTipo = "attacco" | "ace" | "muro" | "errore" | "punto_avv" | "errore_avv";
@@ -85,7 +85,7 @@ type RigaScoutPartita = {
function daRiga(r: RigaScoutPartita): ScoutMatch {
return {
id: r.id,
data: r.data,
data: r.data.slice(0, 10),
avversario: r.avversario,
casa: r.casa,
setNostri: r.set_nostri,
@@ -98,12 +98,10 @@ function daRiga(r: RigaScoutPartita): ScoutMatch {
export const SCOUT_MATCHES_KEY = ["scout-partite"] as const;
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);
const righe = await pb.collection("scout_partite").getFullList<RigaScoutPartita>({
sort: "-created",
});
return righe.map(daRiga);
}
/** Partite scoutate condivise con tutta la squadra: chi scoutizza le vede da qualsiasi
@@ -121,9 +119,9 @@ export function useSalvaScoutMatch() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { eventoId: string | null; match: ScoutMatch }) => {
const { error } = await supabase.from("scout_partite").insert({
await pb.collection("scout_partite").create({
id: input.match.id,
evento_id: input.eventoId,
evento: input.eventoId ?? "",
data: input.match.data,
avversario: input.match.avversario,
casa: input.match.casa,
@@ -132,10 +130,9 @@ export function useSalvaScoutMatch() {
parziali: JSON.parse(JSON.stringify(input.match.parziali)),
azioni: JSON.parse(JSON.stringify(input.match.azioni)),
});
if (error) throw error;
return input.match;
},
// La query ordina per `creato_il` decrescente: la partita appena salvata è la più recente.
// La query ordina per creazione decrescente: la partita appena salvata è la più recente.
onSuccess: (match) =>
queryClient.setQueryData<ScoutMatch[]>(SCOUT_MATCHES_KEY, (prec) => [
match,
@@ -148,8 +145,7 @@ 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;
await pb.collection("scout_partite").delete(id);
return id;
},
onSuccess: (id) =>
+64
View File
@@ -19,13 +19,16 @@ import { Route as ProfiloRouteImport } from './routes/profilo'
import { Route as ScoutRouteImport } from './routes/scout'
import { Route as SquadraRouteImport } from './routes/squadra'
import { Route as AllenamentoIdRouteImport } from './routes/allenamento.$id'
import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
import { Route as ApiPublicSollecitaPresenzeRouteImport } from './routes/api/public/sollecita-presenze'
import { Route as ApiPublicCsiPartitaIdRouteImport } from './routes/api/public/csi-partita.$id'
const IndexRoute = IndexRouteImport.update({
id: '/',
@@ -77,6 +80,11 @@ const AllenamentoIdRoute = AllenamentoIdRouteImport.update({
path: '/allenamento/$id',
getParentRoute: () => rootRouteImport,
} as any)
const PartitaCsiIdRoute = PartitaCsiIdRouteImport.update({
id: '/partita-csi/$id',
path: '/partita-csi/$id',
getParentRoute: () => rootRouteImport,
} as any)
const PartitaIdRoute = PartitaIdRouteImport.update({
id: '/partita/$id',
path: '/partita/$id',
@@ -92,6 +100,12 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
path: '/api/public/csi',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicNotificheAttiveRoute =
ApiPublicNotificheAttiveRouteImport.update({
id: '/api/public/notifiche-attive',
path: '/api/public/notifiche-attive',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicPromemoriaPalloniRoute =
ApiPublicPromemoriaPalloniRouteImport.update({
id: '/api/public/promemoria-palloni',
@@ -114,6 +128,11 @@ const ApiPublicSollecitaPresenzeRoute =
path: '/api/public/sollecita-presenze',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicCsiPartitaIdRoute = ApiPublicCsiPartitaIdRouteImport.update({
id: '/api/public/csi-partita/$id',
path: '/api/public/csi-partita/$id',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -126,13 +145,16 @@ export interface FileRoutesByFullPath {
'/scout': typeof ScoutRoute
'/squadra': typeof SquadraRoute
'/allenamento/$id': typeof AllenamentoIdRoute
'/partita-csi/$id': typeof PartitaCsiIdRoute
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
'/api/public/csi-partita/$id': typeof ApiPublicCsiPartitaIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -145,13 +167,16 @@ export interface FileRoutesByTo {
'/scout': typeof ScoutRoute
'/squadra': typeof SquadraRoute
'/allenamento/$id': typeof AllenamentoIdRoute
'/partita-csi/$id': typeof PartitaCsiIdRoute
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
'/api/public/csi-partita/$id': typeof ApiPublicCsiPartitaIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -165,13 +190,16 @@ export interface FileRoutesById {
'/scout': typeof ScoutRoute
'/squadra': typeof SquadraRoute
'/allenamento/$id': typeof AllenamentoIdRoute
'/partita-csi/$id': typeof PartitaCsiIdRoute
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
'/api/public/csi-partita/$id': typeof ApiPublicCsiPartitaIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -186,13 +214,16 @@ export interface FileRouteTypes {
| '/scout'
| '/squadra'
| '/allenamento/$id'
| '/partita-csi/$id'
| '/partita/$id'
| '/api/public/apri-sondaggio'
| '/api/public/csi'
| '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
| '/api/public/push-subscribe'
| '/api/public/sollecita-presenze'
| '/api/public/csi-partita/$id'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -205,13 +236,16 @@ export interface FileRouteTypes {
| '/scout'
| '/squadra'
| '/allenamento/$id'
| '/partita-csi/$id'
| '/partita/$id'
| '/api/public/apri-sondaggio'
| '/api/public/csi'
| '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
| '/api/public/push-subscribe'
| '/api/public/sollecita-presenze'
| '/api/public/csi-partita/$id'
id:
| '__root__'
| '/'
@@ -224,13 +258,16 @@ export interface FileRouteTypes {
| '/scout'
| '/squadra'
| '/allenamento/$id'
| '/partita-csi/$id'
| '/partita/$id'
| '/api/public/apri-sondaggio'
| '/api/public/csi'
| '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
| '/api/public/push-subscribe'
| '/api/public/sollecita-presenze'
| '/api/public/csi-partita/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -244,13 +281,16 @@ export interface RootRouteChildren {
ScoutRoute: typeof ScoutRoute
SquadraRoute: typeof SquadraRoute
AllenamentoIdRoute: typeof AllenamentoIdRoute
PartitaCsiIdRoute: typeof PartitaCsiIdRoute
PartitaIdRoute: typeof PartitaIdRoute
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
ApiPublicSollecitaPresenzeRoute: typeof ApiPublicSollecitaPresenzeRoute
ApiPublicCsiPartitaIdRoute: typeof ApiPublicCsiPartitaIdRoute
}
declare module '@tanstack/react-router' {
@@ -325,6 +365,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AllenamentoIdRouteImport
parentRoute: typeof rootRouteImport
}
'/partita-csi/$id': {
id: '/partita-csi/$id'
path: '/partita-csi/$id'
fullPath: '/partita-csi/$id'
preLoaderRoute: typeof PartitaCsiIdRouteImport
parentRoute: typeof rootRouteImport
}
'/partita/$id': {
id: '/partita/$id'
path: '/partita/$id'
@@ -346,6 +393,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiPublicCsiRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/notifiche-attive': {
id: '/api/public/notifiche-attive'
path: '/api/public/notifiche-attive'
fullPath: '/api/public/notifiche-attive'
preLoaderRoute: typeof ApiPublicNotificheAttiveRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/promemoria-palloni': {
id: '/api/public/promemoria-palloni'
path: '/api/public/promemoria-palloni'
@@ -374,6 +428,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiPublicSollecitaPresenzeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/csi-partita/$id': {
id: '/api/public/csi-partita/$id'
path: '/api/public/csi-partita/$id'
fullPath: '/api/public/csi-partita/$id'
preLoaderRoute: typeof ApiPublicCsiPartitaIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
@@ -388,13 +449,16 @@ const rootRouteChildren: RootRouteChildren = {
ScoutRoute: ScoutRoute,
SquadraRoute: SquadraRoute,
AllenamentoIdRoute: AllenamentoIdRoute,
PartitaCsiIdRoute: PartitaCsiIdRoute,
PartitaIdRoute: PartitaIdRoute,
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
ApiPublicCsiRoute: ApiPublicCsiRoute,
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
ApiPublicSollecitaPresenzeRoute: ApiPublicSollecitaPresenzeRoute,
ApiPublicCsiPartitaIdRoute: ApiPublicCsiPartitaIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
-4
View File
@@ -12,7 +12,6 @@ import {
import { useEffect, useState, type ReactNode } from "react";
import appCss from "../styles.css?url";
import { reportLovableError } from "../lib/lovable-error-reporting";
import { BottomNav } from "../components/crapp/BottomNav";
import { CelebrazioneBadge } from "../components/crapp/CelebrazioneBadge";
import { Toaster } from "../components/ui/sonner";
@@ -47,9 +46,6 @@ function NotFoundComponent() {
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
console.error(error);
const router = useRouter();
useEffect(() => {
reportLovableError(error, { boundary: "tanstack_root_error_component" });
}, [error]);
return (
<div className="flex min-h-dvh items-center justify-center bg-background px-4">
+109 -65
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import {
BadgeCheck,
Bell,
ChevronDown,
Download,
FileText,
@@ -16,14 +17,8 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
Campo,
classiInput,
PageHeader,
Section,
Select,
StatTile,
} from "@/components/crapp/ui-bits";
import { Campo, classiInput, PageHeader, Select, StatTile } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
import {
nomeCompleto,
@@ -54,6 +49,7 @@ import {
import { oggiISO } from "@/lib/palloni-core";
import { scaricaCsv } from "@/lib/scout-export";
import { useIsAdmin } from "@/lib/ruoli";
import { useNotificheAttive } from "@/lib/notifiche-admin";
import { Reveal } from "@/components/motion/Reveal";
export const Route = createFileRoute("/admin")({
@@ -232,7 +228,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
</Select>
</Campo>
</div>
<Campo label="Email (collegamento automatico al login, DD-018)">
<Campo label="Email">
<input
type="email"
value={squadraCorrente.email ?? ""}
@@ -342,19 +338,21 @@ function Documento({
label,
stato,
path,
recordId,
}: {
icona: React.ReactNode;
label: string;
stato: StatoScadenza | "presente" | "assente";
path: string | null;
recordId: string | null;
}) {
const [inCorso, setInCorso] = useState(false);
async function scarica() {
if (!path || inCorso) return;
if (!path || !recordId || inCorso) return;
setInCorso(true);
try {
await scaricaFile(path);
await scaricaFile(recordId, path);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Download non riuscito");
} finally {
@@ -366,7 +364,7 @@ function Documento({
<button
type="button"
onClick={scarica}
disabled={!path || inCorso}
disabled={!path || !recordId || inCorso}
className={cn(
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase disabled:opacity-60",
statoClasse[stato],
@@ -384,13 +382,16 @@ function SchedaGiocatore({
profilo,
oggi,
indice,
aperta,
onToggle,
}: {
g: GiocatoreSquadra;
profilo: Profilo | undefined;
oggi: string;
indice: number;
aperta: boolean;
onToggle: () => void;
}) {
const [aperta, setAperta] = useState(false);
const nome = nomeCompleto(g);
const { ruolo, numero } = g;
const perc = completamento(profilo);
@@ -401,11 +402,7 @@ function SchedaGiocatore({
return (
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
<button
type="button"
onClick={() => setAperta((v) => !v)}
className="flex w-full items-center gap-3 text-left"
>
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 text-left">
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm tabular-nums">
{numero}
</div>
@@ -436,24 +433,28 @@ function SchedaGiocatore({
label="Doc fronte"
stato={fronte}
path={profilo?.documentoFrontePath ?? null}
recordId={profilo?.id ?? null}
/>
<Documento
icona={<IdCard className="h-3.5 w-3.5" />}
label="Doc retro"
stato={retro}
path={profilo?.documentoRetroPath ?? null}
recordId={profilo?.id ?? null}
/>
<Documento
icona={<FileText className="h-3.5 w-3.5" />}
label="Certificato"
stato={certificato}
path={profilo?.certificatoPath ?? null}
recordId={profilo?.id ?? null}
/>
<Documento
icona={<Image className="h-3.5 w-3.5" />}
label="Foto"
stato={sezioni.foto ? "presente" : "assente"}
path={profilo?.fotoPath ?? null}
recordId={profilo?.id ?? null}
/>
<span
className={cn(
@@ -552,7 +553,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
</Select>
</Campo>
</div>
<Campo label="Email (collegamento automatico al login, opzionale)">
<Campo label="Email">
<input
type="email"
value={dati.email ?? ""}
@@ -623,6 +624,8 @@ function Dashboard() {
const admin = useIsAdmin();
const { righe: squadra } = useGiocatoriSquadra();
const { profili, isPending } = useProfili();
const { data: notificheAttive } = useNotificheAttive();
const [schedaAperta, setSchedaAperta] = useState<string | null>(null);
const oggi = oggiISO();
if (!admin) {
@@ -649,56 +652,97 @@ function Dashboard() {
).length;
const tesserati = attivi.filter((g) => g.numeroTessera).length;
const contenutoSquadra = (
<>
<div className="grid grid-cols-2 gap-2">
<StatTile valore={attivi.length} label="Giocatori" />
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
<StatTile
valore={`${certificatiOk}/${attivi.length}`}
label="Certificati validi"
hint="non scaduti"
/>
<StatTile valore={`${tesserati}/${attivi.length}`} label="Tesserati" hint="CSI" />
</div>
<button
type="button"
onClick={() => scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<Download className="h-4 w-4" /> Esporta CSV tesseramento
</button>
<AggiungiGiocatore righe={squadra} />
</>
);
const contenutoProfili = isPending ? (
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
Caricamento
</p>
) : (
<div className="space-y-3">
{attivi.map((g, i) => (
<SchedaGiocatore
key={g.id}
g={g}
profilo={profili[g.id]}
oggi={oggi}
indice={i}
aperta={schedaAperta === g.id}
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))}
/>
))}
</div>
);
const contenutoDisattivati = (
<div className="space-y-2">
{disattivi.map((g) => (
<GiocatoreDisattivato key={g.id} g={g} />
))}
</div>
);
const contenutoNotifiche = notificheAttive ? (
<>
<StatTile
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
label="Notifiche attive"
/>
<div className="mt-3 space-y-2">
{attivi
.filter((g) => notificheAttive.has(g.id))
.map((g) => (
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card">
<Bell className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<p className="truncate text-sm font-semibold leading-tight">{nomeCompleto(g)}</p>
</div>
))}
</div>
</>
) : (
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
Caricamento
</p>
);
return (
<>
<PageHeader titolo="Dashboard" sottotitolo="Profili e tesseramento" />
<Section titolo="Squadra">
<div className="grid grid-cols-2 gap-2">
<StatTile valore={attivi.length} label="Giocatori" />
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
<StatTile
valore={`${certificatiOk}/${attivi.length}`}
label="Certificati validi"
hint="non scaduti"
/>
<StatTile valore={`${tesserati}/${attivi.length}`} label="Tesserati" hint="CSI" />
</div>
<button
type="button"
onClick={() =>
scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))
}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<Download className="h-4 w-4" /> Esporta CSV tesseramento
</button>
<AggiungiGiocatore righe={squadra} />
</Section>
<Section titolo="Profili" indice={1}>
{isPending ? (
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
Caricamento
</p>
) : (
<div className="space-y-3">
{attivi.map((g, i) => (
<SchedaGiocatore key={g.id} g={g} profilo={profili[g.id]} oggi={oggi} indice={i} />
))}
</div>
)}
</Section>
{disattivi.length > 0 ? (
<Section titolo="Giocatori disattivati" indice={2}>
<div className="space-y-2">
{disattivi.map((g) => (
<GiocatoreDisattivato key={g.id} g={g} />
))}
</div>
</Section>
) : null}
<BarraSottosezioni
defaultId="squadra"
variante="sottolineatura"
riempiLarghezza
voci={[
{ id: "squadra", label: "Squadra", contenuto: contenutoSquadra },
{ id: "profili", label: "Profili", contenuto: contenutoProfili },
...(disattivi.length > 0
? [{ id: "disattivati", label: "Disattivati", contenuto: contenutoDisattivati }]
: []),
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
]}
/>
</>
);
}
+10 -10
View File
@@ -6,6 +6,8 @@ import { inviaPush } from "@/lib/webpush.server";
const schema = z.object({ eventoId: z.string().min(1).max(50) });
type RigaIscrizione = { id: string; endpoint: string; p256dh: string; auth: string };
/** Avviso "sondaggio pre-partita aperto": lo fa partire un admin dalla pagina partita. */
export const Route = createFileRoute("/api/public/apri-sondaggio")({
server: {
@@ -21,23 +23,21 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
const partita = eventi.find((e) => e.id === parsed.data.eventoId);
if (!partita) return new Response("Evento non trovato", { status: 404 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: iscrizioni } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, p256dh, auth");
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
const admin = await pbAdmin();
const iscrizioni = await admin
.collection("push_subscriptions")
.getFullList<RigaIscrizione>();
const titolo = "💩 Sondaggio pre-partita aperto";
const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`;
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
for (const iscrizione of iscrizioni) {
try {
const { stato } = await inviaPush(iscrizione, titolo, testo);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
.delete()
.eq("endpoint", iscrizione.endpoint);
await admin.collection("push_subscriptions").delete(iscrizione.id);
} else if (stato >= 200 && stato < 300) {
inviate += 1;
}
@@ -46,7 +46,7 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
}
}
return Response.json({ inviate, destinatari: (iscrizioni ?? []).length });
return Response.json({ inviate, destinatari: iscrizioni.length });
},
},
},
+63
View File
@@ -0,0 +1,63 @@
import { createFileRoute } from "@tanstack/react-router";
import {
parseFormazioni,
parseInfoPartita,
parsePrecedenti,
urlPartitaFormazioni,
urlPartitaInfo,
urlPartitaPrecedenti,
type DettaglioPartitaCsi,
} from "@/lib/csi-core";
const SCADENZA_MS = 6 * 60 * 60 * 1000;
// Una gara giocata non cambia più: la cache per-partita non ha bisogno di scadere mai
// per i dati storici, ma teniamo la stessa finestra di /api/public/csi per semplicità e
// per non tenere in memoria per sempre partite che nessuno riguarda più. Stessa cache in
// memoria del processo, stesso limite (si perde ai cold start): vedi limite 3 in
// docs/modules/collegamento-csi.md.
const cache = new Map<string, { dati: DettaglioPartitaCsi; scadenza: number }>();
async function scarica(url: string): Promise<string> {
const res = await fetch(url, {
headers: { "User-Agent": "CrAPP/1.0 (+https://crapvolley.it)" },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`CSI ${res.status} su ${url}`);
return res.text();
}
async function leggiDettaglioPartita(matchId: string): Promise<DettaglioPartitaCsi> {
const [main, players, stats] = await Promise.all([
scarica(urlPartitaInfo(matchId)),
scarica(urlPartitaFormazioni(matchId)),
scarica(urlPartitaPrecedenti(matchId)),
]);
return {
...parseInfoPartita(main),
formazioni: parseFormazioni(players),
precedenti: parsePrecedenti(stats),
};
}
export const Route = createFileRoute("/api/public/csi-partita/$id")({
server: {
handlers: {
GET: async ({ params }) => {
const matchId = params.id;
const voce = cache.get(matchId);
if (voce && Date.now() < voce.scadenza) return Response.json(voce.dati);
try {
const dati = await leggiDettaglioPartita(matchId);
cache.set(matchId, { dati, scadenza: Date.now() + SCADENZA_MS });
return Response.json(dati);
} catch (error) {
console.error("csi-partita", matchId, error);
// Meglio un dato vecchio che nessun dato, come /api/public/csi.
if (voce) return Response.json(voce.dati);
return new Response("CSI non raggiungibile", { status: 503 });
}
},
},
},
});
+31 -3
View File
@@ -1,8 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import {
CSI_COPPA_PROJECT_ID,
CSI_GIRONE,
parseClassifica,
partiteDaEventi,
partiteFormatoSospetto,
urlClassifica,
urlPartite,
type DatiCsi,
@@ -27,13 +29,39 @@ async function scarica(url: string): Promise<string> {
}
async function leggiCsi(): Promise<DatiCsi> {
const [html, json] = await Promise.all([scarica(urlClassifica()), scarica(urlPartite())]);
const [html, htmlCoppa, json] = await Promise.all([
scarica(urlClassifica()),
// Non blocca la risposta se fallisce: la Coppa è un dato supplementare, non critico
// come classifica/partite del girone (vedi il controllo sotto).
scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""),
scarica(urlPartite()),
]);
const classifica = parseClassifica(html);
const partite = partiteDaEventi(JSON.parse(json));
const classificaCoppa = parseClassifica(htmlCoppa);
const eventiGrezzi = JSON.parse(json);
const partite = partiteDaEventi(eventiGrezzi);
if (classifica.length === 0 && partite.length === 0) {
throw new Error("CSI: risposta senza classifica né partite");
}
return { classifica, partite, girone: CSI_GIRONE, aggiornato: new Date().toISOString() };
// La classifica basta a evitare l'errore sopra, ma se solo le partite si rompono (formato di
// getEventsByTeamId.php cambiato) la route tornerebbe comunque 200 senza che nessuno se ne
// accorga: le vittorie degli obiettivi di squadra resterebbero ferme a 0% in silenzio. Oltre
// al log server, il flag arriva fino a `/classifica` (badge discreto) perché qualcuno se ne
// accorga anche senza guardare i log.
const formatoSospetto = partiteFormatoSospetto(eventiGrezzi, partite);
if (formatoSospetto) {
console.error(
"csi: il formato di getEventsByTeamId.php sembra cambiato, nessuna partita riconosciuta",
);
}
return {
classifica,
classificaCoppa,
partite,
girone: CSI_GIRONE,
aggiornato: new Date().toISOString(),
formatoSospetto,
};
}
export const Route = createFileRoute("/api/public/csi")({
+29
View File
@@ -0,0 +1,29 @@
import { createFileRoute } from "@tanstack/react-router";
import { richiediAdmin } from "@/lib/auth-route.server";
import { idsConNotificheAttive } from "@/lib/notifiche-attive.server";
export const Route = createFileRoute("/api/public/notifiche-attive")({
server: {
handlers: {
GET: async ({ request }) => {
const negato = await richiediAdmin(request);
if (negato) return negato;
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
const admin = await pbAdmin();
try {
const righe = await admin
.collection("push_subscriptions")
.getFullList<{ giocatore: string }>();
return Response.json({
giocatoreIds: idsConNotificheAttive(righe.map((r) => ({ giocatore_id: r.giocatore }))),
});
} catch (error) {
console.error("notifiche-attive", error);
return new Response("Errore lettura", { status: 500 });
}
},
},
},
});
+23 -19
View File
@@ -9,6 +9,15 @@ import { leggiEventi } from "@/lib/eventi.server";
const schema = z.object({ eventoId: z.string().min(1).max(50) });
type RigaTurno = { evento: string; giocatore: string };
type RigaIscrizione = {
id: string;
endpoint: string;
giocatore: string;
p256dh: string;
auth: string;
};
/**
* Promemoria del turno palloni per un evento: lo fa partire un admin dalla pagina
* dell'evento (DD-025). Il testo viaggia cifrato dentro la push.
@@ -27,13 +36,12 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
const evento = eventi.find((e) => e.id === parsed.data.eventoId);
if (!evento) return new Response("Evento non trovato", { status: 404 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
const admin = await pbAdmin();
const { data: righe } = await supabaseAdmin
.from("turni_palloni")
.select("evento_id, giocatore_id");
const righeTurni = await admin.collection("turni_palloni").getFullList<RigaTurno>();
const salvati: Record<string, string> = {};
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
for (const riga of righeTurni) salvati[riga.evento] = riga.giocatore;
const squadra = await leggiGiocatoriSquadra();
const rosa = squadra
@@ -44,25 +52,21 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
const avvisi = avvisiPalloniEvento(turni, eventi, evento.id);
if (avvisi.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
const { data: iscrizioni } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, giocatore_id, p256dh, auth")
.in(
"giocatore_id",
avvisi.map((a) => a.giocatoreId),
);
const idsFiltro = avvisi.map((a) => a.giocatoreId);
const filtro = idsFiltro.map((_, i) => `giocatore = {:g${i}}`).join(" || ");
const parametri = Object.fromEntries(idsFiltro.map((id, i) => [`g${i}`, id]));
const iscrizioni = await admin
.collection("push_subscriptions")
.getFullList<RigaIscrizione>({ filter: admin.filter(filtro, parametri) });
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
const avviso = avvisi.find((a) => a.giocatoreId === iscrizione.giocatore_id);
for (const iscrizione of iscrizioni) {
const avviso = avvisi.find((a) => a.giocatoreId === iscrizione.giocatore);
if (!avviso) continue;
try {
const { stato } = await inviaPush(iscrizione, avviso.titolo, avviso.testo);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
.delete()
.eq("endpoint", iscrizione.endpoint);
await admin.collection("push_subscriptions").delete(iscrizione.id);
} else if (stato >= 200 && stato < 300) {
inviate += 1;
}
@@ -71,7 +75,7 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
}
}
return Response.json({ inviate, destinatari: (iscrizioni ?? []).length });
return Response.json({ inviate, destinatari: iscrizioni.length });
},
},
},
+27 -16
View File
@@ -17,17 +17,24 @@ export const Route = createFileRoute("/api/public/push-subscribe")({
const parsed = schemaIscrizione.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { error } = await supabaseAdmin.from("push_subscriptions").upsert(
{
endpoint: parsed.data.endpoint,
giocatore_id: parsed.data.giocatoreId,
p256dh: parsed.data.p256dh,
auth: parsed.data.auth,
},
{ onConflict: "endpoint" },
);
if (error) {
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
const { upsertByFilter } = await import("@/integrations/pocketbase/upsert");
const admin = await pbAdmin();
try {
await upsertByFilter(
"push_subscriptions",
"endpoint = {:endpoint}",
{ endpoint: parsed.data.endpoint },
{
endpoint: parsed.data.endpoint,
giocatore: parsed.data.giocatoreId,
p256dh: parsed.data.p256dh,
auth: parsed.data.auth,
},
admin,
);
} catch (error) {
console.error("push-subscribe", error);
return new Response("Errore salvataggio", { status: 500 });
}
@@ -37,11 +44,15 @@ export const Route = createFileRoute("/api/public/push-subscribe")({
const parsed = schemaCancellazione.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
await supabaseAdmin
.from("push_subscriptions")
.delete()
.eq("endpoint", parsed.data.endpoint);
const { pbAdmin } = await import("@/integrations/pocketbase/client.server");
const admin = await pbAdmin();
const esistente = await admin
.collection("push_subscriptions")
.getFirstListItem(
admin.filter("endpoint = {:endpoint}", { endpoint: parsed.data.endpoint }),
)
.catch(() => null);
if (esistente) await admin.collection("push_subscriptions").delete(esistente.id);
return Response.json({ ok: true });
},
},

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