Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1744d4087 | ||
|
|
d125bdfb6a | ||
|
|
689468141d | ||
|
|
0f5a27ed5f | ||
|
|
15885f32c7 | ||
|
|
fcff950006 | ||
|
|
99ca3b6495 | ||
|
|
457a0fe23d | ||
|
|
d8fc6ef04f | ||
|
|
e99d853987 | ||
|
|
efca4243b6 | ||
|
|
abd6fafc0c | ||
|
|
d52ceeb0f9 | ||
|
|
a9f146d77e | ||
|
|
04e80a3531 | ||
|
|
5569d08b59 | ||
|
|
c628997934 | ||
|
|
8353761b6e | ||
|
|
15f058f760 | ||
|
|
8dfd81ebe4 | ||
|
|
bef5542ce3 | ||
|
|
57463f993b | ||
|
|
c7cd527045 | ||
|
|
00235d1594 | ||
|
|
6d177d5d8a | ||
|
|
89b08349c4 | ||
|
|
f1d61ebd6e | ||
|
|
dbf60d3a7e | ||
|
|
b0c1e2f826 | ||
|
|
0eb2d60569 | ||
|
|
3b305b0f8e | ||
|
|
a451d4187f | ||
|
|
8a692bfe2f | ||
|
|
cde01ff02b | ||
|
|
d250f41749 | ||
|
|
4fb05748c3 | ||
|
|
10d1455eff | ||
|
|
e6a222f196 | ||
|
|
1fdc6720a0 | ||
|
|
080379cc64 | ||
|
|
8c9cf370e8 | ||
|
|
c7588c5aa2 | ||
|
|
9b81fcf2fc | ||
|
|
242941740b | ||
|
|
7e51fe6345 | ||
|
|
8c6fceb682 | ||
|
|
0dadabfea6 | ||
|
|
261578357a | ||
|
|
447016f03b | ||
|
|
e13722eae9 | ||
|
|
2b30dd4708 | ||
|
|
440bf0cd56 | ||
|
|
8add483a5b | ||
|
|
3258a24f20 | ||
|
|
82b6c6b333 | ||
|
|
08b00627e5 |
+5
-1
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"template": "tanstack_start_ts_current",
|
||||
"revision": "tanstack_start_ts_current-9e5645c506e5"
|
||||
}
|
||||
+28
-6
@@ -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`)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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 casa–ospite (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
@@ -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
@@ -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 l’avvio 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).
|
||||
- L’export CSV deve essere affidabile e completo: è il deliverable chiave della v1.1.
|
||||
- L’export 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 l’automazione.
|
||||
|
||||
---
|
||||
|
||||
### 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 l’app identifica l’utente 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)
|
||||
L’app 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 l’anagrafica 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 l’autenticazione 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 l’anagrafica 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 l’autenticazione 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. L’associazione **`auth_user_id` ↔ giocatore** è un’operazione **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 d’identità.
|
||||
- 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 dell’anagrafica: `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 M1–M2 (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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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".
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
↓
|
||||
|
||||
@@ -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,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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
Executable
+19
@@ -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/_/"
|
||||
@@ -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}
|
||||
|
||||
@@ -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: l’etichetta è 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: l’etichetta è 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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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,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();
|
||||
|
||||
@@ -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 sé 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 né 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
@@ -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
@@ -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
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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")({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user