Compare commits
71
Commits
67dbf25044
...
822180bffc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
822180bffc | ||
|
|
7237e8ff39 | ||
|
|
952f7b43d3 | ||
|
|
438076c572 | ||
|
|
fe1ac5434c | ||
|
|
9aef361bf0 | ||
|
|
f70430e1bf | ||
|
|
72a9864e94 | ||
|
|
2db2086c10 | ||
|
|
ac4a4a96e8 | ||
|
|
6286c18a72 | ||
|
|
70b4123452 | ||
|
|
0c2a6c5330 | ||
|
|
5c9ffb84e8 | ||
|
|
988eb6d575 | ||
|
|
0710d143a9 | ||
|
|
9c173df753 | ||
|
|
a07c8104d5 | ||
|
|
224bb93beb | ||
|
|
923d1fe762 | ||
|
|
4c0ea66126 | ||
|
|
b6c0f40f66 | ||
|
|
7e93229eee | ||
|
|
4470139504 | ||
|
|
cce6525f09 | ||
|
|
c7445cfab1 | ||
|
|
ff45b2379c | ||
|
|
d3b117e470 | ||
|
|
64771fffc6 | ||
|
|
395cee3c9c | ||
|
|
5e4ad307c9 | ||
|
|
c52e4c4e27 | ||
|
|
d63beae04c | ||
|
|
c314a08f6d | ||
|
|
d2d62b6799 | ||
|
|
f6036f21ec | ||
|
|
1a93c7657a | ||
|
|
85223baa9d | ||
|
|
0a04025fe9 | ||
|
|
d813dee282 | ||
|
|
13e5c3bd23 | ||
|
|
215af4bbc4 | ||
|
|
127e7c76e9 | ||
|
|
aa1c49542b | ||
|
|
1036eb860d | ||
|
|
f325c0485c | ||
|
|
5a6aaad885 | ||
|
|
e4f963d170 | ||
|
|
f424b3a9f7 | ||
|
|
990c2bbd4c | ||
|
|
764f75085e | ||
|
|
9e17c2fadd | ||
|
|
53b2252997 | ||
|
|
718ef09dfa | ||
|
|
3f52e9cc54 | ||
|
|
255afde48e | ||
|
|
da51517ffc | ||
|
|
ea29f053e3 | ||
|
|
e1e8dd5415 | ||
|
|
c06b33e83b | ||
|
|
9ca124a868 | ||
|
|
af563603f9 | ||
|
|
fee3d0b55a | ||
|
|
baaffc66db | ||
|
|
7d16bdba75 | ||
|
|
7b4bfe8b27 | ||
|
|
32782227f4 | ||
|
|
57b36b5a09 | ||
|
|
19e1bb0790 | ||
|
|
7a2b26ec89 | ||
|
|
6dc63d9250 |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"vercel@claude-plugins-official": true,
|
||||
"supabase@claude-plugins-official": true,
|
||||
"claude-md-management@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
description: Regole di progetto CrAPP
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
Prima di qualsiasi modifica leggi @AGENTS.md e seguine le regole: sono vincolanti e valgono
|
||||
per intero.
|
||||
|
||||
- Non implementare funzionalità non documentate in `docs/`.
|
||||
- Lavora su `develop`, mai direttamente su `main`.
|
||||
- Codice, commenti e documentazione in italiano.
|
||||
|
||||
Non aggiungere regole in questo file: una regola nuova va in `AGENTS.md`, che leggono anche
|
||||
Claude Code e Codex. Vale per qualsiasi aggiunta o modifica — regola, funzionalità, decisione,
|
||||
schema database: prima di considerare finito il lavoro esegui la checklist «Fine lavoro» di
|
||||
`AGENTS.md`.
|
||||
@@ -0,0 +1,70 @@
|
||||
name: 🐛 Segnala un bug
|
||||
description: Segnala un comportamento non corretto di CrAPP
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: input
|
||||
id: titolo-bug
|
||||
attributes:
|
||||
label: Titolo del bug
|
||||
description: Riassumi il problema in una frase
|
||||
placeholder: "Es. Il numero di maglia duplicato non blocca il salvataggio"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: comportamento-attuale
|
||||
attributes:
|
||||
label: Cosa succede
|
||||
description: Descrivi il comportamento che hai osservato
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: comportamento-atteso
|
||||
attributes:
|
||||
label: Cosa ti aspettavi succedesse
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: passaggi
|
||||
attributes:
|
||||
label: Passaggi per riprodurre
|
||||
placeholder: |
|
||||
1. Vai su ...
|
||||
2. Clicca su ...
|
||||
3. Compare l'errore ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: ambiente
|
||||
attributes:
|
||||
label: Dispositivo/browser
|
||||
description: Da dove hai riscontrato il problema
|
||||
options:
|
||||
- Smartphone (Chrome/Safari)
|
||||
- Computer (Chrome)
|
||||
- Computer (Safari)
|
||||
- Computer (Firefox)
|
||||
- Altro
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: Screenshot
|
||||
description: Trascina qui una o più immagini che mostrano il problema (opzionale)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: duplicati
|
||||
attributes:
|
||||
label: Verifica
|
||||
options:
|
||||
- label: Ho controllato che non esista già una issue aperta su questo stesso problema
|
||||
required: true
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,55 @@
|
||||
name: ✨ Richiesta di funzionalità
|
||||
description: Suggerisci una nuova funzionalità per CrAPP
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: input
|
||||
id: titolo-feature
|
||||
attributes:
|
||||
label: Titolo della funzionalità
|
||||
description: Riassumi la proposta in una frase
|
||||
placeholder: "Es. Notifica push quando viene aggiunto un nuovo evento"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problema
|
||||
attributes:
|
||||
label: Problema che risolve
|
||||
description: Cosa non riesci a fare oggi, o cosa ti costa troppa fatica
|
||||
placeholder: "Es. Non mi accorgo quando viene aggiunta una partita finché non apro l'app"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: soluzione
|
||||
attributes:
|
||||
label: Soluzione proposta
|
||||
description: Come immagini che dovrebbe funzionare
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternative
|
||||
attributes:
|
||||
label: Alternative considerate
|
||||
description: Altri modi in cui potresti risolvere lo stesso problema (opzionale)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: screenshot
|
||||
attributes:
|
||||
label: Screenshot o mockup
|
||||
description: Trascina qui immagini di riferimento (opzionale)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: duplicati
|
||||
attributes:
|
||||
label: Verifica
|
||||
options:
|
||||
- label: Ho controllato che non esista già una richiesta simile
|
||||
required: true
|
||||
@@ -39,3 +39,5 @@ dist-ssr
|
||||
|
||||
# Optional
|
||||
.vercel
|
||||
# Supabase CLI local state
|
||||
supabase/.temp/
|
||||
+21
-14
@@ -7,25 +7,32 @@ Al momento c'è un solo "obiettivo di squadra" ed è hardcoded nella home (`src/
|
||||
## Cosa faccio
|
||||
|
||||
1. **Modello dati locale** in `src/lib/crapp-data.ts`
|
||||
- Nuovo tipo `ObiettivoSquadra`: id, titolo, descrizione, target, valore attuale, unità, scadenza (opzionale), icona.
|
||||
- Array `obiettiviSquadra` con gli obiettivi demo della stagione.
|
||||
|
||||
- Nuovo tipo `ObiettivoSquadra`: id, titolo, descrizione, target, valore attuale, unità, scadenza (opzionale), icona.
|
||||
- Array `obiettiviSquadra` con gli obiettivi demo della stagione.
|
||||
|
||||
2. **Sezione "Obiettivi di squadra" dentro la scheda Squadra** (`src/routes/squadra.tsx`)
|
||||
- Nuova sezione con la lista completa degli obiettivi: barra di progresso, percentuale, stato (in corso / completato).
|
||||
- Ordinati con gli obiettivi in corso in cima e i completati in fondo.
|
||||
- Nessuna nuova rotta e nessuna modifica al bottom nav.
|
||||
|
||||
- Nuova sezione con la lista completa degli obiettivi: barra di progresso, percentuale, stato (in corso / completato).
|
||||
- Ordinati con gli obiettivi in corso in cima e i completati in fondo.
|
||||
- Nessuna nuova rotta e nessuna modifica al bottom nav.
|
||||
|
||||
3. **Widget home dinamico** (`src/routes/index.tsx`)
|
||||
- Sostituisce l'obiettivo hardcoded: mostra sempre il primo obiettivo in corso preso dalla lista.
|
||||
- Progresso calcolato dai dati reali dove possibile (es. presenze derivate dagli eventi).
|
||||
|
||||
- Sostituisce l'obiettivo hardcoded: mostra sempre il primo obiettivo in corso preso dalla lista.
|
||||
- Progresso calcolato dai dati reali dove possibile (es. presenze derivate dagli eventi).
|
||||
|
||||
4. **Obiettivi demo iniziali**
|
||||
- 90% di presenze ad agosto (collegato agli eventi di agosto).
|
||||
- 70% di risposte entro 24h nel prossimo mese.
|
||||
- Prima vittoria del campionato (collegato allo storico match).
|
||||
- 5 vittorie in campionato (collegato allo storico match).
|
||||
- 10 vittorie in campionato (collegato allo storico match).
|
||||
- 1 evento di squadra al mese (pizzata, ecc.).
|
||||
|
||||
- 90% di presenze ad agosto (collegato agli eventi di agosto).
|
||||
- 70% di risposte entro 24h nel prossimo mese.
|
||||
- Prima vittoria del campionato (collegato allo storico match).
|
||||
- 5 vittorie in campionato (collegato allo storico match).
|
||||
- 10 vittorie in campionato (collegato allo storico match).
|
||||
- 1 evento di squadra al mese (pizzata, ecc.).
|
||||
|
||||
## Cosa non cambia
|
||||
|
||||
- Resta un prototipo offline: i dati restano in `src/lib/crapp-data.ts`.
|
||||
- I badge individuali restano come sono in `src/lib/badges.ts` e nella rosa di `src/routes/squadra.tsx`.
|
||||
- Bottom nav e rotte invariate.
|
||||
- Bottom nav e rotte invariate.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Ridimensionamento badge nella lista squadra
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Rendere i badge accanto al nome del giocatore nella lista squadra più compatti e meno invasivi, mantenendo lo stile stilizzato (icone Lucide colorate per grado) e lasciando la scheda espansa con una dimensione leggibile.
|
||||
|
||||
## Modifiche previste
|
||||
@@ -19,5 +20,6 @@ Rendere i badge accanto al nome del giocatore nella lista squadra più compatti
|
||||
- Eseguire build per assicurarsi che non ci siano errori di tipo o stile.
|
||||
|
||||
## Cosa non cambia
|
||||
|
||||
- Colori dei gradi, soglie badge, logica di sblocco e votazione MVP.
|
||||
- Layout generale della pagina e bottom navigation.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# Rimuovere placeholder "Livello 7" dal Profilo
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Eliminare il testo statico "Livello 7" dalla scheda profilo, dato che non è collegato a nessun calcolo reale e l'utente preferisce toglierlo per ora.
|
||||
|
||||
## Modifica
|
||||
|
||||
- `src/routes/profilo.tsx`: rimuovere il paragrafo `<p className="font-display text-2xl leading-none">Livello 7</p>` (riga 116) e, se necessario, riallineare il layout circostante per evitare spazi vuoti strani.
|
||||
|
||||
## Verifica
|
||||
|
||||
- Build senza errori.
|
||||
- Preview della pagina Profilo: nessun riferimento a "Livello" visibile.
|
||||
|
||||
@@ -16,6 +16,7 @@ Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra t
|
||||
## Impostazione tecnica
|
||||
|
||||
**Backend (Lovable Cloud)**
|
||||
|
||||
- Attivazione di Lovable Cloud.
|
||||
- Tabella `eventi` (spostando i dati demo attuali su database) o, in alternativa minima, tabella `turni_palloni` con `evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`. Scelgo la seconda per limitare il refactor: gli eventi restano in `crapp-data.ts` finché non si passa a calendario dinamico.
|
||||
- Tabella `push_subscriptions` (giocatore_id, endpoint, chiavi) per le notifiche.
|
||||
@@ -23,11 +24,13 @@ Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra t
|
||||
- Server functions in `src/lib/palloni.functions.ts`: `getTurni`, `setTurno`, `suggerisciTurno`.
|
||||
|
||||
**Frontend**
|
||||
|
||||
- Nuovo componente `TurnoPalloni` usato in `EventoCard` e in Home.
|
||||
- Lettura via TanStack Query (`ensureQueryData` nel loader, `useSuspenseQuery` nel componente), invalidazione dopo la modifica.
|
||||
- Banner promemoria in Home basato su data odierna + evento successivo, mostrato solo al giocatore selezionato in `user-store`.
|
||||
|
||||
**Notifiche push**
|
||||
|
||||
- Service worker dedicato al messaging (separato dalla PWA esistente), chiavi VAPID salvate come secret.
|
||||
- Schermata in Profilo: "Attiva notifiche palloni" con richiesta di permesso.
|
||||
- Invio schedulato tramite un endpoint `src/routes/api/public/promemoria-palloni.ts` protetto da secret, richiamato una volta al giorno da un job pianificato (pg_cron).
|
||||
|
||||
@@ -1,10 +1,142 @@
|
||||
<!-- LOVABLE:BEGIN -->
|
||||
> [!IMPORTANT]
|
||||
> This project is connected to [Lovable](https://lovable.dev). Avoid rewriting
|
||||
> published git history — force pushing, or rebasing/amending/squashing commits
|
||||
> that are already pushed — as it rewrites history on Lovable's side and the
|
||||
> user will likely lose their project history.
|
||||
>
|
||||
> Commits you push to the connected branch sync back to Lovable and show up in
|
||||
> the editor, so keep the branch in a working state.
|
||||
<!-- LOVABLE:END -->
|
||||
# CrAPP — regole per gli assistenti AI
|
||||
|
||||
Regole vincolanti per qualsiasi assistente AI (Claude Code, Codex, Cursor, ChatGPT) che lavora
|
||||
su questo repository. Valgono integralmente; `CLAUDE.md` le richiama e non le ripete.
|
||||
|
||||
CrAPP è una PWA per la gestione di una squadra di pallavolo. Deve ridurre il lavoro degli
|
||||
amministratori, aumentare il coinvolgimento dei giocatori, centralizzare le informazioni della
|
||||
squadra e usare l'AI solo quando porta un beneficio reale. Il perché sta in
|
||||
[docs/VISION.md](docs/VISION.md).
|
||||
|
||||
## Prima di modificare il codice
|
||||
|
||||
1. Leggi l'indice [docs/README.md](docs/README.md) e segui l'ordine di lettura che indica; poi
|
||||
il documento del modulo interessato in [docs/modules/](docs/modules/).
|
||||
2. Verifica lo stato attuale del repository: commit recenti, modifiche non committate, lavoro
|
||||
introdotto da altri collaboratori o da altri assistenti.
|
||||
3. Non presumere che il progetto sia come l'hai lasciato nell'ultima sessione: la fonte di
|
||||
verità è il repository, non la cronologia della conversazione.
|
||||
|
||||
Non implementare funzionalità non documentate: prima si documenta
|
||||
([DD-002](docs/DESIGN_DECISIONS.md#dd-002--sviluppo-document-first)), poi si scrive il codice.
|
||||
|
||||
## Comandi
|
||||
|
||||
Le dipendenze si installano con **bun** (`bun.lock`). `bunfig.toml` impone
|
||||
`minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a
|
||||
`minimumReleaseAgeExcludes` richiede conferma esplicita dell'utente.
|
||||
|
||||
```bash
|
||||
npm run dev # vite dev su http://localhost:8080
|
||||
npm run build # build di produzione (nitro)
|
||||
npm run lint # eslint (include prettier come regola)
|
||||
npm run format # prettier --write .
|
||||
npm run test # suite di test (test/); npm run test:all per quella completa
|
||||
|
||||
npx supabase start # database locale in Docker (migration applicate + seed)
|
||||
npx supabase db reset # ricrea il database locale da zero
|
||||
npx supabase db push # applica le migration al progetto cloud
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
**Chi aggiunge o modifica una funzione scrive anche il test.** Non è opzionale e non si
|
||||
rimanda: una funzione nuova senza test non è finita, una funzione modificata il cui test non
|
||||
copre più il comportamento nuovo va aggiornata nello stesso lavoro.
|
||||
|
||||
- I test devono **risultare verdi**: non si consegna con test rossi, non si commenta un test
|
||||
che fallisce e non si indebolisce un'asserzione per farla passare. Se un test rosso segnala
|
||||
un comportamento voluto che è cambiato, si aggiorna il test spiegando perché.
|
||||
- La logica di dominio pura sta in `src/lib/` ed è quella da coprire in `test/unit/`: se una
|
||||
funzione è difficile da testare perché mischia calcolo e hook, separala (`*-core.ts`) come
|
||||
già fatto per palloni e pagelle.
|
||||
- Convenzioni, struttura delle cartelle e comandi in [test/README.md](test/README.md).
|
||||
- Se il comportamento cambia, cambia anche la documentazione: modulo in
|
||||
[docs/modules/](docs/modules/), più i file elencati in Tracciabilità.
|
||||
|
||||
## Fine lavoro
|
||||
|
||||
Prima di dire che hai finito:
|
||||
|
||||
1. i test delle funzioni aggiunte o modificate esistono e sono verdi;
|
||||
2. `npm run lint` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e);
|
||||
3. la documentazione toccata dalla modifica è aggiornata (vedi Test e Tracciabilità);
|
||||
4. hai detto all'utente cosa hai cambiato, cosa hai lasciato fuori e quali rischi vedi.
|
||||
|
||||
## Git
|
||||
|
||||
`main` è la versione in produzione: qualsiasi commit deve lasciare l'app funzionante.
|
||||
`develop` pubblica una preview Vercel, ma oggi è fermo indietro rispetto a `main` e non
|
||||
rappresenta lo stato attuale (DD-019). I branch `feature/…`, `fix/…`, `refactor/…` servono per
|
||||
lavori paralleli o rischiosi.
|
||||
|
||||
**È l'utente a decidere su quale branch va un commit.** L'assistente può consigliare un branch
|
||||
dedicato quando la modifica è rischiosa o parallela ad altro lavoro, ma non cambia branch né
|
||||
apre PR di propria iniziativa. In assenza di indicazioni si lavora dove si trova il repository.
|
||||
|
||||
Non committare, non fare push e non aprire PR senza che l'utente lo abbia chiesto.
|
||||
|
||||
## Tracciabilità
|
||||
|
||||
Ogni modifica significativa deve lasciare una traccia leggibile senza la cronologia delle
|
||||
conversazioni: commit con messaggio descrittivo, più il documento giusto tra
|
||||
[docs/CHANGELOG.md](docs/CHANGELOG.md) (cosa è stato rilasciato e quando),
|
||||
[PROJECT_STATE.md](PROJECT_STATE.md) (stato generale del progetto),
|
||||
[docs/DESIGN_DECISIONS.md](docs/DESIGN_DECISIONS.md) (decisioni architetturali, voci `DD-XXX`),
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), [docs/TODO.md](docs/TODO.md),
|
||||
[docs/DATABASE.md](docs/DATABASE.md) (se cambia lo schema).
|
||||
|
||||
Quali contenuti vanno in quale file, e le convenzioni di scrittura, stanno nelle regole di
|
||||
manutenzione di [docs/README.md](docs/README.md): ogni informazione ha una sola casa, non
|
||||
duplicarla altrove.
|
||||
|
||||
## Database
|
||||
|
||||
Il database è Supabase; lo schema documentato sta in [docs/DATABASE.md](docs/DATABASE.md),
|
||||
allineato alle migration in `supabase/migrations/`.
|
||||
|
||||
- Ogni modifica allo schema è una **nuova** migration: le migration già applicate sono storia
|
||||
e non si riscrivono.
|
||||
- Non eliminare tabelle esistenti, non modificare lo schema senza motivazione.
|
||||
- Ordine: progetta → documenta → crea la migration → testala in locale (`npx supabase db reset`)
|
||||
→ verifica l'assenza di regressioni → solo dopo applicala in produzione.
|
||||
- Preferisci strutture scalabili, evita duplicazione dei dati.
|
||||
|
||||
## Codice e interfaccia
|
||||
|
||||
L'architettura tecnica (stack, struttura delle cartelle, punti fermi da non rompere) sta in
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): leggila prima di toccare routing, `vite.config.ts`,
|
||||
client Supabase o autenticazione.
|
||||
|
||||
Componenti piccoli, riutilizzabili, a responsabilità singola. Prima di crearne uno nuovo,
|
||||
verifica se esiste già in `src/components/`. L'interfaccia resta semplice, moderna, veloce,
|
||||
ottimizzata per smartphone: poche schermate, pochi click, stile coerente con l'esistente.
|
||||
|
||||
## Regola anti-regressione
|
||||
|
||||
Le nuove versioni aggiungono funzionalità. Non riscrivere moduli già funzionanti senza una
|
||||
motivazione esplicita, e non fare refactoring trasversali mentre sviluppi altro. Prima di
|
||||
modificare un modulo esistente verifica quali altre parti dell'app lo usano.
|
||||
|
||||
## L'AI non deve
|
||||
|
||||
- introdurre librerie senza necessità, né aggirare `minimumReleaseAge`;
|
||||
- modificare il database o il comportamento dell'app senza richiesta esplicita;
|
||||
- eliminare funzionalità esistenti;
|
||||
- sovrascrivere modifiche di altri collaboratori senza averne compreso lo scopo;
|
||||
- riscrivere migration già applicate;
|
||||
- committare, pushare o cambiare branch di propria iniziativa.
|
||||
|
||||
## L'AI deve
|
||||
|
||||
- spiegare le modifiche importanti e segnalare rischi, conflitti e possibili regressioni
|
||||
**prima** di toccare parti sensibili;
|
||||
- mantenere la compatibilità con il codice esistente e riutilizzare i componenti;
|
||||
- privilegiare la semplicità;
|
||||
- tenere aggiornata la documentazione quando serve.
|
||||
|
||||
## Filosofia
|
||||
|
||||
Prima di scrivere codice: questa modifica rende CrAPP più semplice? Riduce il lavoro degli
|
||||
amministratori? Migliora l'esperienza dei giocatori? È coerente con la documentazione? Riduce
|
||||
o aumenta la complessità futura? Se almeno una risposta è negativa, rivaluta la soluzione.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Le regole di progetto stanno in @AGENTS.md: valgono integralmente e non sono ripetute qui —
|
||||
compresi i comandi (`npm run dev/lint/test`, supabase) e la checklist «Fine lavoro» da eseguire
|
||||
prima di dire che hai finito. La documentazione tecnica è indicizzata in
|
||||
[docs/README.md](docs/README.md); l'architettura in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
**Non aggiungere regole in questo file.** Una regola nuova va in `AGENTS.md`, che leggono
|
||||
anche Codex e Cursor; scritta qui la vedrebbe solo Claude Code.
|
||||
@@ -0,0 +1,14 @@
|
||||
Copyright (c) 2026 Ivan Cacciari e Davide Grilli. Tutti i diritti riservati.
|
||||
|
||||
Il presente software e la relativa documentazione (il "Software") sono di
|
||||
proprietà esclusiva di Ivan Cacciari e Davide Grilli.
|
||||
|
||||
Non è concessa alcuna licenza d'uso, salvo autorizzazione scritta esplicita
|
||||
dei titolari. È vietato copiare, modificare, distribuire, sublicenziare,
|
||||
vendere, pubblicare o comunque sfruttare in tutto o in parte il Software,
|
||||
in qualsiasi forma o con qualsiasi mezzo, senza il preventivo consenso
|
||||
scritto dei titolari.
|
||||
|
||||
IL SOFTWARE È FORNITO "COSÌ COM'È", SENZA GARANZIE DI ALCUN TIPO, ESPLICITE
|
||||
O IMPLICITE. I TITOLARI NON SONO RESPONSABILI PER QUALSIASI DANNO DERIVANTE
|
||||
DALL'USO O DALL'IMPOSSIBILITÀ DI USO DEL SOFTWARE.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Project State
|
||||
|
||||
Ultimo aggiornamento: 04/09/2026
|
||||
|
||||
## Stato generale
|
||||
|
||||
Fase corrente:
|
||||
|
||||
Backend migrato al nuovo Supabase proprietario. Autenticazione Google, dashboard
|
||||
amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzione su `main`.
|
||||
Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora
|
||||
sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati
|
||||
reali (M9).
|
||||
|
||||
---
|
||||
|
||||
## Infrastruttura
|
||||
|
||||
- Si lavora direttamente su `main` (DD-019): `develop` esiste ma è fermo indietro, quindi la
|
||||
sua preview Vercel non rappresenta lo stato attuale
|
||||
- Cursor e Claude Code come ambienti di sviluppo
|
||||
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
|
||||
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
|
||||
- 20 migration in `supabase/migrations/`, fino a `m9_risposte_presenze_risposto_il`
|
||||
- Sviluppo locale verificato con il nuovo Supabase
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
- Backend operativo: Supabase proprietario (`kfkcldwncxqaixetsjes`)
|
||||
- Lovable Cloud: non più backend operativo di CrAPP
|
||||
- Vecchio Project Ref `hetycilxgkdmccelwerq`: deprecato, non utilizzare
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
- Schema v1.0 e migration da M1 a M9 applicate al nuovo Supabase
|
||||
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`)
|
||||
più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori
|
||||
attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
|
||||
bisogno di una migration
|
||||
- `public.giocatori_squadra` è ora la source of truth della rosa letta dall'app (DD-015,
|
||||
03/09/2026): «Aggiungi giocatore» e «Disattiva giocatore» della dashboard admin si
|
||||
riflettono su Squadra, Presenze, Pagelle, Badge e Scout. `src/lib/crapp-data.ts` resta
|
||||
solo come seed storico, fallback offline e sorgente della data di nascita (colonna non
|
||||
ancora presente su `giocatori_squadra`)
|
||||
- Migration `m6_avatar_giocatori`: bucket pubblico `avatar-giocatori` per le foto profilo,
|
||||
al posto di `localStorage` (una per giocatore, letto da `src/lib/avatar-store.ts`)
|
||||
- Migration `m7_scout_partite`: nuova tabella `scout_partite` per l'archivio delle partite
|
||||
scoutate concluse, e collegamento della tabella `scout_sessioni` (già presente nello
|
||||
schema ma mai usata) al blocco condiviso dello Scout Live — prima entrambi vivevano solo
|
||||
in `localStorage`, quindi visibili a un solo dispositivo
|
||||
|
||||
---
|
||||
|
||||
## Moduli completati
|
||||
|
||||
- Squadra
|
||||
- Presenze
|
||||
- Badge
|
||||
- Scout Live (blocco e archivio partite sincronizzati tra dispositivi, migration `m7_scout_partite`)
|
||||
- Pagelle
|
||||
- MVP
|
||||
- Notifiche
|
||||
- Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`)
|
||||
- Serie di presenze (specifica in `docs/modules/serie-presenze.md`)
|
||||
|
||||
---
|
||||
|
||||
## Autenticazione e dashboard amministratore
|
||||
|
||||
In produzione su `main`. **Il login è l'unica via d'accesso** (31/08/2026): la selezione
|
||||
libera del giocatore non esiste più, senza sessione Google si resta su `/benvenuto`, e i
|
||||
permessi di amministrazione arrivano solo da `user_roles`.
|
||||
|
||||
**Attenzione all'ordine:** finché il provider Google è spento in Supabase, «Accedi con
|
||||
Google» risponde
|
||||
|
||||
```
|
||||
{"code":400,"error_code":"validation_failed","msg":"Unsupported provider: provider is not enabled"}
|
||||
```
|
||||
|
||||
e **nessuno entra nell'app**. Vale ancora per chi allestisce un ambiente nuovo (per esempio
|
||||
lo stack Supabase locale): il passo 1 qui sotto va fatto per primo.
|
||||
|
||||
Passaggi in ordine, nessuno dei quali è reversibile a metà. **Stato al 04/09/2026: fatti i
|
||||
passaggi 1, 2, 3 e 5 (M4 applicata); il passaggio 4 è un processo continuo (7 dei 16 giocatori
|
||||
attivi hanno già fatto il primo accesso).**
|
||||
|
||||
1. **Provider Google in Supabase** — Google Cloud Console: consent screen _External_ (scope
|
||||
`email` e `profile`, non sensibili: nessuna verifica richiesta, e la modalità _Testing_
|
||||
regge fino a 100 utenti, più che sufficiente per la squadra), credenziale
|
||||
_Web application_ con redirect URI
|
||||
`https://kfkcldwncxqaixetsjes.supabase.co/auth/v1/callback`. Client ID e
|
||||
secret in _Authentication → Providers → Google_. In _URL Configuration_: Site URL di
|
||||
produzione, più `localhost:8080` e il wildcard delle preview Vercel tra i Redirect URLs.
|
||||
Per provare sullo stack locale invece che sul cloud servono anche `enabled = true` in
|
||||
`[auth.external.google]` di `supabase/config.toml`, le due variabili
|
||||
`SUPABASE_AUTH_GOOGLE_*` in `.env` e una credenziale con redirect URI
|
||||
`http://127.0.0.1:54321/auth/v1/callback`.
|
||||
2. **Migration M2** (`supabase db push`). È un `CREATE` puro: si può applicare in
|
||||
produzione senza toccare il comportamento attuale.
|
||||
3. **Primo admin**, dopo il primo login (l'ID esiste solo da quel momento):
|
||||
`INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '<mail>';`
|
||||
4. **Collegamento degli account**: ciascuno accede con Google e viene collegato in
|
||||
automatico al proprio giocatore per email (DD-018) — nessuna scelta manuale. Finché
|
||||
l'email di un giocatore non è impostata, il suo accesso mostra un errore; da `/admin` si
|
||||
imposta l'email di un giocatore (nuovo o esistente) senza bisogno di una migration. Da
|
||||
settembre 2026 tutti i giocatori attivi hanno l'email registrata, ma il collegamento vero
|
||||
e proprio (`auth_user_id`) avviene solo al primo login di ciascuno, quindi resta un
|
||||
processo continuo che si ripete a ogni nuovo giocatore aggiunto a stagione in corso. Uno
|
||||
slot già collegato può essere liberato solo da un admin.
|
||||
5. **Solo a squadra collegata**: migration `m4_solo_autenticati`, che toglie al ruolo `anon`
|
||||
l'accesso alle tabelle v1.0. Da lì in poi i dati sono raggiungibili solo con una sessione;
|
||||
le route in `src/routes/api/public/` usano la service role e continuano a funzionare.
|
||||
**Applicata in produzione il 03/09/2026** — non è più necessario aspettare che l'intera
|
||||
rosa abbia già fatto login: il login era già l'unica via d'accesso lato app, quindi i
|
||||
giocatori non ancora collegati non erano comunque impattati; M4 chiudeva solo un residuo
|
||||
di accesso diretto al database bypassando l'app.
|
||||
|
||||
Attenzione: dev e produzione condividono lo stesso progetto Supabase. Un account di prova che
|
||||
collega uno slot lo occupa anche in produzione, e va liberato da un admin.
|
||||
|
||||
## Prossimo sviluppo
|
||||
|
||||
Niente di assegnato: la v1.1 è completa, tesseramento CSI incluso (numero e data di tessera
|
||||
registrabili da `/admin`, migration `m8_tesseramento_csi`). Le voci ancora aperte stanno in
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md).
|
||||
|
||||
---
|
||||
|
||||
## Note
|
||||
|
||||
Il progetto segue una metodologia document-first.
|
||||
|
||||
Ogni nuova funzionalità viene progettata nella cartella `docs/modules/` prima di essere implementata.
|
||||
@@ -1,118 +1,61 @@
|
||||
# CRAP Volley Hub
|
||||
# CrAPP 🏐
|
||||
|
||||
CrAPP – App per CRAP Volley
|
||||
CrAPP è una Progressive Web App sviluppata per digitalizzare completamente la gestione di una squadra di pallavolo.
|
||||
|
||||
Vorrei sviluppare un’app mobile per la squadra di pallavolo CRAP Volley, con nome CrAPP, disponibile per Android e iOS. L’obiettivo è creare un’app semplice da usare, moderna, bella da vedere e più coinvolgente rispetto a SportEasy, includendo anche funzionalità normalmente a pagamento in altre app.
|
||||
## Funzionalità principali
|
||||
|
||||
Funzionalità principali
|
||||
- Gestione squadra
|
||||
- Gestione presenze
|
||||
- Calendario allenamenti e partite
|
||||
- Scout Live
|
||||
- Badge e gamification
|
||||
- Statistiche
|
||||
- Notifiche intelligenti
|
||||
- Gestione amministrativa
|
||||
- AI per la pianificazione degli allenamenti (in sviluppo)
|
||||
|
||||
Gestione presenze/assenze
|
||||
## Stack tecnologico
|
||||
|
||||
Partite
|
||||
React 19, TypeScript, TanStack Start (SSR), Vite 8, Tailwind CSS 4, Radix UI / shadcn,
|
||||
Supabase (PostgreSQL, Auth, Storage), Vercel, GitHub. Dettagli in
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
Allenamenti
|
||||
## Avvio locale
|
||||
|
||||
Eventi extra
|
||||
Le dipendenze si installano con **bun** (`bun.lock`):
|
||||
|
||||
Stati rapidi: presente, assente, forse, in ritardo, indisponibile, infortunato
|
||||
|
||||
Statistiche giocatori
|
||||
|
||||
Presenze totali
|
||||
|
||||
Presenze consecutive
|
||||
|
||||
Gol/punti o altre statistiche specifiche della pallavolo
|
||||
|
||||
MVP, migliori performance, medie stagione
|
||||
|
||||
Statistiche partite
|
||||
|
||||
Risultati
|
||||
|
||||
Formazioni
|
||||
|
||||
Andamento set
|
||||
|
||||
Storico match
|
||||
|
||||
Campionato in tempo reale
|
||||
|
||||
Visualizzazione classifica e risultati
|
||||
|
||||
Dati presi direttamente dal sito del CSI
|
||||
|
||||
Aggiornamento automatico o importazione periodica
|
||||
|
||||
Calendario squadra
|
||||
|
||||
Allenamenti
|
||||
|
||||
Partite
|
||||
|
||||
Promemoria
|
||||
|
||||
Vista mensile e lista eventi
|
||||
|
||||
Profilo giocatore
|
||||
|
||||
Foto
|
||||
|
||||
Ruolo
|
||||
|
||||
Statistiche personali
|
||||
|
||||
Badge e obiettivi
|
||||
|
||||
Idea di stile
|
||||
|
||||
Interfaccia sportiva, pulita e moderna
|
||||
|
||||
Molto mobile-first
|
||||
|
||||
Design divertente, energico e più “premium”
|
||||
|
||||
Inserire in seguito il logo della squadra
|
||||
|
||||
Possibile uso di badge, livelli, premi e mini-gamification per rendere l’app più piacevole da usare
|
||||
|
||||
Extra che sarebbe bello aggiungere
|
||||
|
||||
Notifiche push per convocazioni e cambi orario
|
||||
|
||||
Chat o bacheca squadra
|
||||
|
||||
Report automatici dopo le partite
|
||||
|
||||
Sondaggi rapidi per disponibilità
|
||||
|
||||
Sezione “Best of the match”
|
||||
|
||||
Obiettivi di gruppo per presenza e continuità
|
||||
|
||||
Obiettivo finale
|
||||
|
||||
Realizzare una app che non sia solo utile per la gestione della squadra, ma anche piacevole, coinvolgente e bella da usare ogni giorno.
|
||||
|
||||
This project was built with [Lovable](https://lovable.dev).
|
||||
|
||||
**Live app**: https://volley-cronos-app.lovable.app
|
||||
|
||||
## Build with Lovable
|
||||
|
||||
Continue developing this project in the [Lovable editor](https://lovable.dev/projects/8d07b0e4-6bd2-4a17-9dd2-bb2cf13f9f7c).
|
||||
|
||||
- **Ship faster**: describe what you want to build and Lovable handles the code.
|
||||
- **Stay in sync**: every change made in Lovable is committed straight to this repository.
|
||||
- **Full ownership**: this code is yours. Push to `main` on GitHub and your changes sync back into Lovable, ready for your next prompt.
|
||||
|
||||
## Development
|
||||
|
||||
Prefer working locally? You need Node.js and npm — [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating).
|
||||
|
||||
```sh
|
||||
git clone <this-repository-url>
|
||||
cd <repository-name>
|
||||
npm i
|
||||
npm run dev
|
||||
```bash
|
||||
bun install
|
||||
npm run dev # http://localhost:8080
|
||||
```
|
||||
|
||||
## Comandi
|
||||
|
||||
```bash
|
||||
npm run build # build di produzione
|
||||
npm run lint # eslint (include prettier)
|
||||
npm run test # test unit; npm run test:all per la suite completa
|
||||
```
|
||||
|
||||
Chi aggiunge o modifica una funzione scrive anche il test e lo lascia verde
|
||||
([test/README.md](test/README.md)).
|
||||
|
||||
## Deploy
|
||||
|
||||
Deploy automatico su Vercel a ogni push su `main`, che è anche il branch di lavoro corrente.
|
||||
`develop` pubblica un Preview Deployment, ma oggi è indietro rispetto a `main`. Su quale branch
|
||||
committare lo decide chi sviluppa (DD-019).
|
||||
|
||||
## Variabili d'ambiente
|
||||
|
||||
Il progetto richiede le seguenti variabili:
|
||||
|
||||
- `SUPABASE_URL`
|
||||
- `SUPABASE_PUBLISHABLE_KEY`
|
||||
- `VITE_SUPABASE_URL`
|
||||
- `VITE_SUPABASE_PUBLISHABLE_KEY`
|
||||
|
||||
## Documentazione
|
||||
|
||||
Indice in [docs/README.md](docs/README.md). Le regole per gli assistenti AI stanno in
|
||||
[AGENTS.md](AGENTS.md), lo stato corrente del lavoro in [PROJECT_STATE.md](PROJECT_STATE.md).
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Architettura del progetto
|
||||
|
||||
Come è fatta CrAPP: stack, organizzazione del codice, flusso di sviluppo. È il documento di
|
||||
riferimento tecnico — `CLAUDE.md` non ripete questi contenuti, li richiama.
|
||||
|
||||
## Stack
|
||||
|
||||
| Livello | Tecnologie |
|
||||
| ------------- | ------------------------------------------------------------------------------------- |
|
||||
| Frontend | React 19, TypeScript, TanStack Start (SSR), Vite 8, Tailwind CSS 4, Radix UI / shadcn |
|
||||
| Backend | Supabase (PostgreSQL, Auth, Storage) |
|
||||
| Hosting | Vercel |
|
||||
| Versionamento | Git, GitHub |
|
||||
|
||||
Le dipendenze sono installate con **bun** (`bun.lock`, `bunfig.toml`). `bunfig.toml` impone
|
||||
`minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a
|
||||
`minimumReleaseAgeExcludes` richiede conferma esplicita.
|
||||
|
||||
## Struttura del progetto
|
||||
|
||||
```
|
||||
src/
|
||||
components/ componenti condivisi (crapp/, ui/, motion/)
|
||||
routes/ routing file-based
|
||||
lib/ logica di dominio, un file per modulo
|
||||
integrations/ client Supabase e integrazioni esterne
|
||||
hooks/
|
||||
assets/
|
||||
supabase/ migration SQL
|
||||
test/ suite di test (unit, integration, end-to-end)
|
||||
docs/ documentazione ufficiale
|
||||
```
|
||||
|
||||
## Punti fermi
|
||||
|
||||
- **Routing**: file-based in `src/routes/`. `src/routeTree.gen.ts` è **generato**, non si
|
||||
modifica a mano.
|
||||
- **Configurazione Vite**: `vite.config.ts` usa `@lovable.dev/vite-tanstack-config`, che
|
||||
include già devtools, tanstackStart, viteReact, tailwind, tsconfig-paths, nitro e l'alias
|
||||
`@` → `src/`. **Non ri-aggiungere questi plugin**: l'app si rompe.
|
||||
- **Entry point server**: `src/server.ts` avvolge l'entry di TanStack Start per intercettare
|
||||
gli errori SSR che h3 trasformerebbe in un 500 JSON silenzioso, e renderizza
|
||||
`renderErrorPage()`. `src/start.ts` registra i middleware globali (error handler, CSRF sui
|
||||
server functions, `attachSupabaseAuth`).
|
||||
- **Supabase**: `src/integrations/supabase/client.ts` (browser/SSR, chiave publishable — file
|
||||
generato) e `client.server.ts` (`supabaseAdmin`, solo server). `types.ts` è generato dallo
|
||||
schema; finché non viene rigenerato, le tabelle introdotte da M1/M2 si usano tramite
|
||||
`client-nuove-tabelle.ts`, con i tipi di riga dichiarati nei moduli di `src/lib/`.
|
||||
- **Autenticazione**: login Google via Supabase Auth (`src/lib/auth.ts`, DD-011). È l'unica
|
||||
strada di accesso: `__root.tsx` rimanda a `/benvenuto` chi non ha sessione, e l'identità
|
||||
del giocatore è lo slot di `giocatori_squadra` collegato all'account. I permessi di
|
||||
amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`).
|
||||
|
||||
## Livello dati
|
||||
|
||||
Tutta la logica di dominio sta in `src/lib/`, un file per modulo (`presenze`, `eventi`,
|
||||
`pagelle`, `mvp-voti`, `palloni`, `cacche`, `badges`, `scout-*`, `infortuni`, …). Il pattern
|
||||
ricorrente:
|
||||
|
||||
- ogni modulo esporta hook TanStack Query (`useX`); i default globali stanno in
|
||||
`src/router.tsx` (`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect`
|
||||
disattivati, `retry: 1`);
|
||||
- dopo una mutazione la cache si aggiorna con `setQueryData`, **non** con
|
||||
`invalidateQueries`: invalidare provoca una rilettura e costa una query in più (unica
|
||||
eccezione oggi: `scout-live.ts`);
|
||||
- le funzioni pure di calcolo sono separate dagli hook (es. `palloni-core.ts` vs
|
||||
`palloni.ts`, `mediePagelle()` vs `usePagelle()`);
|
||||
- `src/lib/rosa.ts` è l'aggregatore: compone tutti gli hook e restituisce la rosa completa
|
||||
con le statistiche derivate, **senza query aggiuntive** rispetto a quelle già in cache. Le
|
||||
route consumano `useRosa()`, non i singoli moduli.
|
||||
|
||||
Nessun accesso al database dai componenti: solo attraverso i moduli in `src/lib/`, così il
|
||||
backend resta sostituibile in un solo punto (DD-013, [PORTABILITA.md](PORTABILITA.md)).
|
||||
|
||||
Vincoli di efficienza cloud — niente polling, cache lunga, `setQueryData` invece di
|
||||
`invalidateQueries` — in [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md).
|
||||
|
||||
Badge e statistiche sono calcolati a runtime dai dati, non persistiti (DD-007). La
|
||||
gamification deve restare equa tra ruoli (DD-008).
|
||||
|
||||
La rosa vive nella tabella `giocatori_squadra`, letta tramite `useRosa()`/`useGiocatoriSquadra()`
|
||||
(DD-015, DD-016). `src/lib/crapp-data.ts` (`rosaCSI`) resta solo come seed storico e fallback
|
||||
quando il database non risponde.
|
||||
|
||||
## UI
|
||||
|
||||
Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `PageHeader`, `Section`,
|
||||
`StatTile`), primitive shadcn in `src/components/ui/`, animazioni in
|
||||
`src/components/motion/`. Mobile-first (DD-005): poche schermate, pochi click.
|
||||
|
||||
## Comandi
|
||||
|
||||
```bash
|
||||
npm run dev # vite dev su http://localhost:8080
|
||||
npm run build # build di produzione (nitro)
|
||||
npm run lint # eslint (include prettier come regola)
|
||||
npm run format # prettier --write .
|
||||
npm run test # test unit (veloci, senza rete né database)
|
||||
npm run test:integration # route server vere
|
||||
npm run test:e2e # percorsi sull'app servita
|
||||
npm run test:all # tutto
|
||||
```
|
||||
|
||||
Database di sviluppo in locale (Docker), alternativo al progetto Supabase cloud:
|
||||
|
||||
```bash
|
||||
npx supabase start # avvia lo stack locale e applica tutte le migration
|
||||
npx supabase stop # spegne i container
|
||||
npx supabase db reset # ricrea il database da zero: migration + supabase/seed.sql
|
||||
npx supabase db push # applica le migration al progetto cloud
|
||||
```
|
||||
|
||||
`supabase/seed.sql` popola qualche profilo di prova e gira **solo in locale**. Serve perché
|
||||
il progetto cloud è uno solo, condiviso tra sviluppo e produzione: lo stack locale è il posto
|
||||
dove provare le migration distruttive senza toccare i dati veri.
|
||||
|
||||
## Branch e flusso di sviluppo
|
||||
|
||||
- `main` → produzione, deploy automatico su Vercel. È anche il branch di lavoro corrente.
|
||||
- `develop` → preview Vercel; oggi indietro rispetto a `main`, non rappresenta lo stato attuale.
|
||||
- `feature/…`, `fix/…`, `refactor/…` → lavori rischiosi o paralleli.
|
||||
|
||||
Su quale branch va un commit lo decide l'utente (DD-019): un assistente AI può consigliare un
|
||||
branch dedicato, non sceglierlo. Poiché si lavora su `main`, la rete di sicurezza sono i test,
|
||||
che vanno scritti insieme al codice e devono essere verdi (DD-020, [test/README.md](../test/README.md)).
|
||||
@@ -0,0 +1,108 @@
|
||||
# Changelog
|
||||
|
||||
Tutte le modifiche significative del progetto vengono registrate in questo documento, in
|
||||
ordine dalla più recente. L'elenco delle funzionalità disponibili e previste non si ripete
|
||||
qui: sta in [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
## Versione attuale — agosto 2026
|
||||
|
||||
### Serie di presenze calcolate sui dati reali
|
||||
|
||||
- `serieConsecutiva()` (`src/lib/presenze.ts`) deriva le serie da eventi passati e
|
||||
`risposte_presenze`: prima erano `0` fisso in `useRosa()` e la sezione «Serie di presenze»
|
||||
del profilo era di fatto inerte, insieme ai badge e all'obiettivo «Continuità di squadra»
|
||||
che ne dipendono.
|
||||
- Migration `m9_risposte_presenze_risposto_il`: nuova colonna `risposto_il` con l'istante
|
||||
della **prima** risposta, resa immutabile da un trigger (`aggiornato_il` registrava solo
|
||||
l'ultima modifica, quindi chi rispondeva subito e cambiava idea dopo risultava lento).
|
||||
Confrontata con `eventi_app.creato_il` sblocca finalmente la serie "Conferme 24h" e i badge
|
||||
"Risposta lampo" e "Mai un forfait". Il dato non è ricostruibile all'indietro: vale da qui
|
||||
in avanti (vedi [modules/serie-presenze.md](modules/serie-presenze.md)).
|
||||
- La barra di progresso di una serie ora misura l'avanzamento fra il traguardo raggiunto e il
|
||||
successivo: prima usava `valore/prossimo` e tornava indietro a ogni traguardo (2/3 = 67%,
|
||||
poi 3/6 = 50%).
|
||||
|
||||
### Segnalazioni dal profilo
|
||||
|
||||
- «Segnala un bug» e «Suggerisci una nuova funzionalità» in `/profilo` → Impostazioni: due
|
||||
link che aprono una issue GitHub sul template giusto
|
||||
(`.github/ISSUE_TEMPLATE/bug_report.yml`, `feature_request.yml`). Nessuna tabella e nessuna
|
||||
schermata di gestione: la segnalazione vive su GitHub
|
||||
(vedi [modules/profilo-giocatore.md](modules/profilo-giocatore.md)).
|
||||
|
||||
### Autenticazione e dashboard amministratore (in produzione)
|
||||
|
||||
- Login con Google tramite Supabase Auth (DD-011). Al primo accesso l'account si collega a
|
||||
un giocatore di `giocatori_squadra`, e il collegamento non è più modificabile dal
|
||||
giocatore stesso (DD-016 regola 2).
|
||||
- La selezione libera del giocatore è stata rimossa: `/benvenuto` offre solo l'accesso con
|
||||
Google e senza sessione non si entra in nessuna schermata. Sparita anche la variabile
|
||||
`VITE_AUTH_OBBLIGATORIA` (non serve più) e il pulsante «Cambia giocatore» in `/profilo`.
|
||||
- I permessi di amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`): la lista
|
||||
di nomi in `crapp-data.ts` è stata eliminata, altrimenti bastava scegliere il nome giusto
|
||||
per amministrare.
|
||||
- Migration `m4_solo_autenticati`: toglie al ruolo `anon` l'accesso alle tabelle v1.0.
|
||||
Applicata in produzione il 03/09/2026, dopo aver impostato l'email di tutta la rosa
|
||||
attiva — il login era già l'unica via d'accesso lato app, quindi il collegamento dei
|
||||
singoli account (che resta un processo continuo a ogni login) non era comunque
|
||||
condizionato da questa migration.
|
||||
- Collegamento automatico al proprio giocatore per email (DD-018, migration
|
||||
`m5_email_giocatori_squadra`): niente più scelta manuale da un elenco, `/benvenuto`
|
||||
confronta l'email dell'account Google con `giocatori_squadra.email` e collega da solo.
|
||||
Senza corrispondenza compare solo un messaggio d'errore, con un pulsante per uscire e
|
||||
riprovare con un altro account.
|
||||
- Dashboard admin: nuove azioni "Aggiungi giocatore" (con email opzionale per il
|
||||
collegamento automatico) e "Disattiva/Riattiva giocatore" per chi lascia la squadra — la
|
||||
riga non viene mai eliminata, così presenze, voti, pagelle e badge della stagione restano
|
||||
agganciati al suo id. L'email è anche modificabile dal pannello "Dati squadra" di ogni
|
||||
giocatore già in rosa.
|
||||
- Tracciamento tesseramento CSI (migration `m8_tesseramento_csi`): numero e data di tessera
|
||||
in `giocatori_squadra`, come gli altri campi che gestisce solo l'admin (DD-016/DD-018). La
|
||||
dashboard mostra chi è già tesserato (badge sulla scheda, contatore in "Squadra") e un
|
||||
pannello per registrare numero e data una volta arrivata la tessera dal CSI.
|
||||
- Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica
|
||||
documento, certificato medico e foto tessera con le relative scadenze
|
||||
([modules/profilo-giocatore.md](modules/profilo-giocatore.md)).
|
||||
- Nuova schermata `/admin`: stato dei profili della squadra, download di documento,
|
||||
certificato e foto tessera, export CSV per il tesseramento CSI.
|
||||
- Migration `m2_profili_giocatore` (tabella dei profili e bucket privato), additiva.
|
||||
- Dalla dashboard l'amministratore modifica i dati squadra (nome, cognome, numero, ruolo),
|
||||
compila i dati personali al posto di un giocatore e scollega un account da un profilo
|
||||
(DD-017). I file restano esclusi: li carica solo il giocatore. Nessuna migration: le
|
||||
policy di M1 e M2 lo consentivano già.
|
||||
- Foto profilo sincronizzate tra dispositivi: da `/profilo` la foto caricata finisce nel
|
||||
bucket pubblico `avatar-giocatori` (migration `m6_avatar_giocatori`) invece che in
|
||||
`localStorage`, così compare per tutta la squadra e non solo su chi l'ha caricata.
|
||||
L'Avatar mostra numero/iniziali finché la foto non è presente.
|
||||
- Scout Live sincronizzato tra dispositivi: il blocco "chi sta scoutando" ora passa dalla
|
||||
tabella `scout_sessioni` invece che da `localStorage`, quindi due telefoni non possono più
|
||||
prendere il controllo insieme sovrascrivendosi a vicenda. Le partite scoutate concluse
|
||||
vengono archiviate nella nuova tabella `scout_partite` (migration `m7_scout_partite`):
|
||||
prima restavano visibili solo sul telefono di chi aveva chiuso la partita.
|
||||
|
||||
### Test
|
||||
|
||||
- Suite di test in `test/` (unit, integration, end-to-end) eseguita con bun,
|
||||
senza nuove dipendenze: `npm run test` e `npm run test:all`.
|
||||
- Corretto un difetto emerso dai test: una sessione Scout Live con timestamp
|
||||
illeggibile restava bloccata per sempre invece di scadere.
|
||||
|
||||
### Collegamento CSI
|
||||
|
||||
- Classifica e risultati ufficiali letti dal portale Livescore CSI Bologna
|
||||
(stagione 2025/26, Campionato Open Misto Eccellenza, Girone B).
|
||||
- La pagina Campionato non usa più dati dimostrativi.
|
||||
- Dettagli e limiti in [modules/collegamento-csi.md](modules/collegamento-csi.md).
|
||||
|
||||
### Infrastruttura
|
||||
|
||||
- Migrazione completa da Lovable a sviluppo locale.
|
||||
- Configurazione Git.
|
||||
- Repository GitHub indipendente.
|
||||
- Deploy automatico tramite Vercel.
|
||||
- Branch main e develop.
|
||||
|
||||
## Versione 1.0 — luglio 2026
|
||||
|
||||
Prima versione usata dalla squadra. Funzionalità incluse: vedi
|
||||
[ROADMAP.md § Versione 1.0](ROADMAP.md#versione-10--rilasciata).
|
||||
@@ -0,0 +1,68 @@
|
||||
# Database CrAPP
|
||||
|
||||
Struttura del database Supabase (PostgreSQL) e ruolo di ogni tabella. Lo schema autoritativo
|
||||
sono le migration in `supabase/migrations/`: **una tabella nuova va documentata qui nella
|
||||
stessa modifica che la crea**. Le funzionalità future stanno in [ROADMAP.md](ROADMAP.md),
|
||||
non in questo file.
|
||||
|
||||
## Anagrafica e utenti
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde, e come sorgente della data di nascita (non ancora una colonna di questa tabella). Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. |
|
||||
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
|
||||
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
|
||||
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
|
||||
|
||||
`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
|
||||
|
||||
## Storage
|
||||
|
||||
| Bucket | Scopo | Note |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `profili-giocatore` | Documento d'identità, certificato medico e foto tessera, in cartelle per giocatore (`<giocatore_id>/<sezione>.<est>`). | **Privato** e destinato a restare tale: contiene documenti e dati sanitari, che non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella, l'admin può scaricare tutto tramite signed URL a scadenza breve. Creato dalla migration `m2_profili_giocatore`. |
|
||||
| `avatar-giocatori` | Foto profilo mostrate nel cerchio avatar (Squadra, Profilo), un file per giocatore (`<giocatore_id>/avatar.jpg`). | **Pubblico**: foto informali, non documenti sensibili. Qualsiasi autenticato può caricare/sostituire/eliminare un file (nessun controllo per-proprietario, la maggior parte dei giocatori non ha ancora `auth_user_id` collegato, DD-018). Letto da `src/lib/avatar-store.ts`. Creato dalla migration `m6_avatar_giocatori`. |
|
||||
|
||||
## Eventi e presenze
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. |
|
||||
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
|
||||
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
|
||||
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||
|
||||
## Scout
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ---------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. Prima viveva solo in `localStorage`: "Scout occupato da X" non funzionava mai tra dispositivi diversi (fix M7). |
|
||||
| `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione di Scout Live. | Serve esclusivamente per statistiche di squadra, mai per classifiche individuali (DD-008). Letta/scritta da `src/lib/scout-stato.ts`. |
|
||||
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. Prima il risultato finale finiva solo in `localStorage`: invisibile a chiunque non fosse il dispositivo di chi aveva chiuso la partita (fix M7). |
|
||||
|
||||
## Votazioni
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ------------------------------------ | ------------------------ |
|
||||
| `mvp_voti` | Voti MVP assegnati a fine partita. | |
|
||||
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. |
|
||||
| `badge_social_voti` | Voti social per i badge. | |
|
||||
|
||||
## Turni e notifiche
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| -------------------- | --------------------------------------------- | ---- |
|
||||
| `turni_palloni` | Gestione dei turni palloni. | |
|
||||
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
|
||||
| `promemoria_push` | Storico dei promemoria inviati. | |
|
||||
|
||||
## Funzioni speciali
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ---------------- | --------------------- | -------------------------------------- |
|
||||
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
|
||||
|
||||
## Badge
|
||||
|
||||
Non esiste una tabella dedicata: i badge vengono **calcolati a runtime** dall'applicazione a
|
||||
partire dai dati esistenti (DD-007).
|
||||
@@ -0,0 +1,670 @@
|
||||
# Registro delle decisioni di progetto
|
||||
|
||||
Questo documento raccoglie le **decisioni importanti** prese nel corso della vita di CrAPP: scelte che hanno influito sulla direzione del prodotto, sull’organizzazione del lavoro o su come l’app si evolve nel tempo.
|
||||
|
||||
Non descrive _come_ è fatto il codice. Per quello esistono `ARCHITECTURE.md` e `DATABASE.md`.
|
||||
|
||||
Serve a rispondere a domande del tipo:
|
||||
|
||||
- _Perché abbiamo scelto così?_
|
||||
- _Cosa avevamo escluso e perché?_
|
||||
- _Quando conviene riaprire una decisione?_
|
||||
|
||||
---
|
||||
|
||||
## Indice
|
||||
|
||||
**Accettate**
|
||||
|
||||
| ID | Titolo |
|
||||
| --------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| [DD-001](#dd-001--crapp-deve-restare-indipendente-da-lovable) | Indipendenza da Lovable |
|
||||
| [DD-002](#dd-002--sviluppo-document-first) | Sviluppo document-first |
|
||||
| [DD-004](#dd-004--ogni-versione-aggiunge-non-riscrive) | Ogni versione aggiunge, non riscrive |
|
||||
| [DD-005](#dd-005--mobile-first-pochi-click-pochi-schermi) | Mobile-first |
|
||||
| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile |
|
||||
| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB |
|
||||
| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli |
|
||||
| [DD-009](#dd-009--tesseramento-csi-manuale-in-v11-integrazione-api-in-v20) | CSI manuale v1.1, API v2.0 |
|
||||
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati-in-v1) | Niente storico certificati v1 |
|
||||
| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo |
|
||||
| [DD-012](#dd-012--non-migrare-gli-id-giocatore-in-v11) | Non migrare ID in v1.1 |
|
||||
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
|
||||
| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB |
|
||||
| [DD-016](#dd-016--schema-dati-profilo-giocatore-v11-f0) | Schema dati Profilo Giocatore v1.1 |
|
||||
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
|
||||
| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email |
|
||||
| [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) | Il branch lo decide l'utente |
|
||||
| [DD-020](#dd-020--una-funzione-modificata-senza-test-non-è-finita) | Test obbligatori e verdi |
|
||||
|
||||
**In valutazione**
|
||||
|
||||
| ID | Titolo |
|
||||
| ----------------------------------------------------------------- | ---------------------- |
|
||||
| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB |
|
||||
|
||||
**Sostituite**
|
||||
|
||||
| ID | Titolo |
|
||||
| ---------------------------------------------------------------- | --------------------- |
|
||||
| [DD-003](#dd-003--due-branch-main-stabile-develop-per-il-lavoro) | Branch main / develop |
|
||||
|
||||
---
|
||||
|
||||
## Come usare questo registro
|
||||
|
||||
Ogni decisione segue lo stesso schema:
|
||||
|
||||
| Campo | Significato |
|
||||
| ------------------------ | ------------------------------------------------------ |
|
||||
| **Data** | Quando la decisione è stata presa o confermata |
|
||||
| **Stato** | Accettata · In valutazione · Sostituita · Obsoleta |
|
||||
| **Contesto** | Quale problema o opportunità avevamo di fronte |
|
||||
| **Decisione** | Cosa abbiamo scelto di fare |
|
||||
| **Alternative scartate** | Cosa non abbiamo fatto e perché |
|
||||
| **Conseguenze** | Cosa comporta nel quotidiano (utenti, admin, sviluppo) |
|
||||
| **Riesame** | Quando ha senso riconsiderarla |
|
||||
|
||||
**Quando aggiungere una voce**
|
||||
|
||||
- una scelta influisce su più moduli o su più release;
|
||||
- escludiamo un’alternativa non ovvia;
|
||||
- accettiamo un compromesso consapevole (debito, limitazione, ritardo);
|
||||
- cambiamo una decisione precedente.
|
||||
|
||||
**Quando non serve**
|
||||
|
||||
- dettagli implementativi locali;
|
||||
- scelte estetiche minori;
|
||||
- bugfix o correzioni puntuali.
|
||||
|
||||
**Come registrare una nuova decisione**
|
||||
|
||||
Copiare [`_template-dd.md`](_template-dd.md) in fondo al documento, assegnare il primo ID
|
||||
libero e aggiungerlo all'indice.
|
||||
|
||||
---
|
||||
|
||||
## Decisioni accettate
|
||||
|
||||
---
|
||||
|
||||
### DD-001 — CrAPP deve restare indipendente da Lovable
|
||||
|
||||
**Data:** luglio 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il progetto nasce come prototipo su Lovable Cloud. Per crescere serve controllo su codice, deploy, database e costi.
|
||||
|
||||
**Decisione**
|
||||
Spostare lo sviluppo su repository GitHub indipendente, con deploy su Vercel e database Supabase gestito dal team.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Restare su Lovable come unica piattaforma → troppa dipendenza da un servizio esterno.
|
||||
- Riscrivere tutto da zero → costo e rischio inutili; il prototipo funzionava già.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Maggiore libertà e responsabilità per il team.
|
||||
- Restano tracce del passaggio (dipendenze, meta tag): vanno eliminate gradualmente, non in blocco.
|
||||
- L’app deve poter girare anche fuori dall’ecosistema Lovable (vedi `PORTABILITA.md`).
|
||||
|
||||
**Riesame**
|
||||
Quando il progetto non userà più alcun componente Lovable.
|
||||
|
||||
---
|
||||
|
||||
### DD-002 — Sviluppo document-first
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Con più persone (e assistenti AI) che lavorano sul codice, serviva un modo per evitare funzionalità “inventate” al volo e incoerenze tra moduli.
|
||||
|
||||
**Decisione**
|
||||
Ogni nuova funzionalità significativa viene prima **progettata e documentata** in `docs/modules/`, poi implementata. Il flusso ufficiale è: idea → progettazione → documentazione → database → codice → test → release.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Documentare solo a posteriori → troppo spesso incompleto o assente.
|
||||
- Affidarsi solo al codice come documentazione → illeggibile per chi non programma.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Rallenta leggermente l’avvio di nuove feature, ma riduce rework e discussioni infinite.
|
||||
- I moduli v1.0 vanno retro-documentati quando possibile.
|
||||
- Nessuna feature non documentata entra in produzione.
|
||||
|
||||
**Riesame**
|
||||
Se il team diventa molto piccolo e la documentazione smette di essere consultata.
|
||||
|
||||
---
|
||||
|
||||
### DD-003 — Due branch: main stabile, develop per il lavoro
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Sostituita da [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) (settembre 2026)
|
||||
|
||||
**Contesto**
|
||||
Serve separare ciò che i giocatori usano ogni giorno da ciò che è ancora in prova.
|
||||
|
||||
**Decisione**
|
||||
|
||||
- `main` → produzione, sempre funzionante, deploy automatico.
|
||||
- `develop` → sviluppo e preview, merge su `main` solo dopo test.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Sviluppare direttamente su `main` → rischio di rotture in produzione.
|
||||
- Branch per ogni feature → eccessivo per la dimensione attuale del team.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Gli utenti in produzione non vedono lavori incompleti.
|
||||
- Ogni release su `main` deve includere verifica delle funzionalità esistenti.
|
||||
|
||||
**Riesame**
|
||||
Sostituita: nella pratica il lavoro è finito direttamente su `main` e `develop` è rimasto
|
||||
indietro. Vedi DD-019.
|
||||
|
||||
---
|
||||
|
||||
### DD-004 — Ogni versione aggiunge, non riscrive
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
CrAPP v1.0 è già usata dalla squadra per presenze, calendario, scout, badge e notifiche. Rischiare regressioni su moduli funzionanti vanifica la fiducia degli utenti.
|
||||
|
||||
**Decisione**
|
||||
Le nuove versioni **introducono** funzionalità. Non si riscrive un modulo già operativo salvo richiesta esplicita e pianificata.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Refactoring ampio “per pulire” insieme a ogni release → alto rischio, poco valore immediato per gli utenti.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Coesistono temporaneamente soluzioni vecchie e nuove (es. dati hardcoded accanto a tabelle database).
|
||||
- Il debito tecnico va gestito con migration dedicate, non di nascosto.
|
||||
|
||||
**Riesame**
|
||||
Quando un modulo diventa ingestibile o blocca una release importante.
|
||||
|
||||
---
|
||||
|
||||
### DD-005 — Mobile-first, pochi click, pochi schermi
|
||||
|
||||
**Data:** origine progetto
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
I giocatori usano l’app soprattutto da smartphone, spesso in spogliatoio o in palestra, con poco tempo e poca pazienza.
|
||||
|
||||
**Decisione**
|
||||
Interfaccia semplice, veloce, ottimizzata per telefono. Navigazione ridotta (barra inferiore). Ogni schermata deve avere uno scopo chiaro.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Layout da desktop con menu complessi → scomodo in mobilità.
|
||||
- App nativa iOS/Android → costi e tempi di pubblicazione non giustificati per una squadra amatoriale.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Funzionalità amministrative complesse vanno semplificate o suddivise con cura.
|
||||
- La PWA è la forma giusta per questo pubblico.
|
||||
|
||||
**Riesame**
|
||||
Se emergono esigenze desktop forti (es. gestione documenti massiva solo da PC).
|
||||
|
||||
---
|
||||
|
||||
### DD-006 — Intelligenza artificiale solo se porta beneficio reale
|
||||
|
||||
**Data:** origine progetto
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
L’AI è attraente ma può complicare l’app, aumentare i costi e creare aspettative irrealistiche.
|
||||
|
||||
**Decisione**
|
||||
Usare l’AI solo quando riduce lavoro agli admin o migliora concretamente l’esperienza dei giocatori. Non introdurla “perché si può”.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- AI ovunque (chatbot, suggerimenti automatici, analisi predittive) → fuori focus per una squadra amatoriale.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- “AI Allenamenti” è in roadmap v1.2, non v1.1.
|
||||
- Ogni proposta AI va valutata con la domanda: _chi risparmia tempo e quanto?_
|
||||
|
||||
**Riesame**
|
||||
Quando l’AI diventa economica e affidabile per casi d’uso chiari (es. generazione allenamenti).
|
||||
|
||||
---
|
||||
|
||||
### DD-007 — Badge calcolati dall’app, non salvati nel database
|
||||
|
||||
**Data:** origine progetto
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
I badge dipendono da statistiche già disponibili (presenze, MVP, cacche, ecc.). Salvare ogni badge sbloccato nel database aggiungerebbe complessità senza beneficio immediato.
|
||||
|
||||
**Decisione**
|
||||
I badge vengono **calcolati al volo** dall’applicazione in base ai dati esistenti. Non esiste una tabella badge dedicata.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Tabella `badge_sbloccati` con storico → utile in futuro per notifiche retroattive o audit, ma non necessaria ora.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Meno migration e meno sincronizzazione.
|
||||
- Lo “sblocco” celebrativo usa cache locale per non ripetere animazioni.
|
||||
- Un eventuale storico badge richiederà una nuova decisione.
|
||||
|
||||
**Riesame**
|
||||
Se servono badge manuali assegnati dagli admin o storico immutabile.
|
||||
|
||||
---
|
||||
|
||||
### DD-008 — Gamification equa tra ruoli
|
||||
|
||||
**Data:** origine progetto
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
In pallavolo i ruoli hanno statistiche diverse (un libero non segna punti d’attacco). Confrontare tutti sugli stessi numeri sarebbe ingiusto e scoraggiante.
|
||||
|
||||
**Decisione**
|
||||
Le statistiche **personali** in profilo e squadra devono essere **eque per tutti i ruoli**. Dati tecnici di reparto (punti, ace, muri) restano nello Scout Live come informazione di squadra, non come leva competitiva individuale.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Classifiche individuali basate su punti → penalizza libero, palleggiatore, centrale.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Badge e obiettivi usano presenze, MVP, pagelle, serie, cacche — metriche accessibili a tutti.
|
||||
- Lo scout resta strumento tecnico, non gioco.
|
||||
|
||||
**Riesame**
|
||||
Se la squadra chiede esplicitamente classifiche tecniche per ruolo.
|
||||
|
||||
---
|
||||
|
||||
### DD-009 — Tesseramento CSI manuale in v1.1, integrazione API in v2.0
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
La v1.1 deve aiutare gli admin a raccogliere documenti e dati per il tesseramento CSI. Un collegamento automatico al sistema CSI è complesso e non urgente.
|
||||
|
||||
**Decisione**
|
||||
|
||||
- **v1.1:** profilo completo, dashboard admin, download documenti, export CSV con i campi richiesti dal CSI.
|
||||
- **v2.0:** eventuale collegamento automatico a CSI (calendario, risultati, classifica ufficiale).
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Integrazione CSI già in v1.1 → scope troppo ampio, dipendenza da API esterne non controllate.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Gli admin guadagnano subito tempo (niente più Excel e chat per i documenti).
|
||||
- L’export CSV deve essere affidabile e completo: è il deliverable chiave della v1.1.
|
||||
|
||||
**Riesame**
|
||||
Quando il CSI mette a disposizione API stabili o quando il volume di tesseramenti giustifica l’automazione.
|
||||
|
||||
---
|
||||
|
||||
### DD-010 — Profilo giocatore: niente storico certificati in v1
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il certificato medico va aggiornato ogni stagione. Tenere lo storico di tutte le versioni complica upload, storage e privacy.
|
||||
|
||||
**Decisione**
|
||||
In v1 il giocatore può **sovrascrivere** certificato e data di scadenza. Lo storico delle versioni precedenti non viene conservato.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Archivio certificati → utile per audit, rinviato a versioni future.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Implementazione più semplice e veloce.
|
||||
- Gli admin vedono solo il certificato attuale.
|
||||
- Va comunicato chiaramente ai giocatori che sostituire il file elimina quello precedente.
|
||||
|
||||
**Riesame**
|
||||
Se il CSI o il regolamento interno richiedono conservazione storica.
|
||||
|
||||
---
|
||||
|
||||
### DD-011 — Autenticazione reale prima del profilo amministrativo completo
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Oggi 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.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Continuare solo con selezione da lista → inaccettabile per dati sensibili.
|
||||
- Lovable Auth → crea dipendenza da piattaforma che stiamo abbandonando.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Tutti dovranno fare login almeno una volta.
|
||||
- Gli admin useranno ruoli veri (`user_roles`), non una lista di nomi hardcoded.
|
||||
- È prerequisito per dashboard admin e export CSI.
|
||||
|
||||
**Riesame**
|
||||
Dopo il rollout auth, se emergono problemi di adozione (giocatori poco digitali).
|
||||
|
||||
---
|
||||
|
||||
### DD-012 — Non migrare gli ID giocatore in v1.1
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
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.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Migrare tutto a UUID in v1.1 → rischio alto di rompere presenze, voti, scout e notifiche.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Coesistono due modelli anagrafici fino a migration dedicata.
|
||||
- `DATABASE.md` va tenuto aggiornato su cosa è “attivo” e cosa è “futuro”.
|
||||
|
||||
**Riesame**
|
||||
Quando la v1.1 è stabile e c’è tempo per una migration con checklist regressioni completa.
|
||||
|
||||
---
|
||||
|
||||
### DD-013 — Portabilità: l’app non deve dipendere da servizi esclusivi
|
||||
|
||||
**Data:** luglio 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
La squadra potrebbe voler cambiare hosting, database o fornitore auth in futuro.
|
||||
|
||||
**Decisione**
|
||||
CrAPP deve poter girare su **Node.js + PostgreSQL standard**. Niente funzionalità bloccate su servizi proprietari. I dati si accedono solo tramite moduli in `src/lib/`, non direttamente dai componenti.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Accettare lock-in per velocità → contrario alla lunga vita del progetto.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Supabase va bene perché è PostgreSQL e self-hostable.
|
||||
- Le API push e i job restano endpoint HTTP richiamabili da qualsiasi scheduler.
|
||||
|
||||
**Riesame**
|
||||
Se si adotta un servizio che viola questa regola.
|
||||
|
||||
---
|
||||
|
||||
### DD-016 — Schema dati Profilo Giocatore v1.1 (F0)
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
La progettazione F0 del modulo Profilo Giocatore ha definito come persistere dati personali, documenti e certificati, in coesistenza con 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.
|
||||
|
||||
**Decisione**
|
||||
Per la v1.1 si introducono **due nuove tabelle additive**:
|
||||
|
||||
- **`giocatori_squadra`** — anagrafica squadra con ID testuali (`g1`…`g17`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento account (`auth_user_id`).
|
||||
- **`profili_giocatore`** — dati personali, metadati documento identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`.
|
||||
|
||||
Regole vincolanti:
|
||||
|
||||
1. **`giocatori_squadra` diventa progressivamente la source of truth** per l’anagrafica squadra. Durante la transizione, `crapp-data.ts` resta come **fallback** se il database non è disponibile o i dati non sono ancora migrati.
|
||||
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.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Estendere la tabella `giocatori` UUID → conflitto con ID operativi del codice e rischio di regressioni.
|
||||
- Salvare file come base64 nel database → ingestibile, difficile da gestire e da scaricare.
|
||||
- Bucket pubblico con URL permanenti → inaccettabile per dati sanitari e documenti 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.
|
||||
|
||||
**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).
|
||||
- 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).
|
||||
- Se il CSI o il regolamento richiedono conservazione storica documenti o consensi privacy dedicati.
|
||||
|
||||
---
|
||||
|
||||
### DD-017 — L'amministratore può compilare i dati al posto del giocatore
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il modulo Profilo Giocatore era costruito su un confine netto: ognuno scrive solo la propria riga, l'amministratore legge e scarica. Nella pratica quel confine blocca il lavoro che il modulo doveva togliere: se metà squadra non compila i propri dati, l'export per il tesseramento CSI resta incompleto e l'admin torna a chiedere le informazioni in chat — esattamente ciò che CrAPP deve eliminare. Inoltre le docs assegnavano già agli admin la gestione dei dati squadra (nome, cognome, numero, ruolo) e il reset del collegamento all'account (DD-016 regola 2), senza che esistesse una schermata per farlo.
|
||||
|
||||
**Decisione**
|
||||
Dalla dashboard amministratore, un admin può:
|
||||
|
||||
1. modificare i **dati squadra** di qualsiasi giocatore (nome, cognome, numero, ruolo);
|
||||
2. compilare e correggere i **dati personali e del documento** di qualsiasi giocatore;
|
||||
3. **scollegare** un account da un profilo, liberando lo slot.
|
||||
|
||||
Restano fuori, e non cambiano:
|
||||
|
||||
- i **file** (documento, certificato, foto): l'admin li scarica ma non li carica né li sostituisce. Un documento d'identità lo produce il suo titolare, e la catena di responsabilità deve restare leggibile;
|
||||
- il **giocatore**, che continua a non poter toccare i propri dati squadra.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Lasciare tutto al giocatore → l'export CSI resta incompleto e il lavoro amministrativo torna in chat, contro la missione del progetto.
|
||||
- Dare all'admin anche l'upload dei file → confonde chi ha fornito un documento, su dati sanitari e d'identità dove serve il contrario.
|
||||
- Un ruolo intermedio (segreteria) per i soli dati personali → un ruolo in più per una squadra sola, con gli stessi tre amministratori di adesso.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Il modello dei permessi non è più "ognuno i suoi": è "ognuno i suoi, più l'admin su tutti, tranne i file". Le policy RLS di M1 e M2 lo consentivano già, quindi non servono migration.
|
||||
- Un admin può correggere un errore di battitura in un numero di documento senza inseguire il giocatore.
|
||||
- Un admin vede e scrive dati personali altrui: è un potere reale, dato a tre persone su diciassette. Va assegnato con la stessa cura di prima (una riga in `user_roles`, nessuna auto-promozione).
|
||||
- Il completamento del profilo smette di essere un indicatore di _chi ha risposto_ e diventa un indicatore di _quali dati mancano_, chiunque li abbia inseriti.
|
||||
|
||||
**Riesame**
|
||||
|
||||
- Se la squadra cresce al punto da rendere sensato un ruolo di sola segreteria.
|
||||
- Se serve tracciare _chi_ ha modificato un dato: oggi non c'è audit, e con la scrittura condivisa la domanda prima o poi arriva.
|
||||
|
||||
---
|
||||
|
||||
### DD-018 — Collegamento automatico giocatore↔account per email
|
||||
|
||||
**Data:** settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
DD-016 regola 2 prevedeva che, al primo accesso, il giocatore scegliesse manualmente il proprio slot libero da un elenco (`/benvenuto`). In pratica ogni giocatore ha un'email nota (o presto nota), quindi far scegliere un nome da una lista è un passaggio superfluo e un rischio: un giocatore può selezionare per errore lo slot di un compagno, e nulla nel flusso lo impedisce a livello di prodotto.
|
||||
|
||||
**Decisione**
|
||||
Al primo accesso, `giocatori_squadra` viene interrogata per email (case-insensitive, tramite la nuova colonna `email`) invece di mostrare un elenco di slot liberi. Se l'email dell'account Google corrisponde a una riga libera, il collegamento avviene automaticamente. Se non corrisponde a nessuna riga (email non ancora nota, o nessun profilo per quella persona), l'utente vede solo un messaggio d'errore che invita a contattare un amministratore, con un pulsante per uscire e riprovare con un altro account — nessuna selezione manuale di ripiego. Le email sono popolate via migration (`m5_email_giocatori_squadra`) per la rosa iniziale; un'interfaccia in `/admin` per impostarle su nuovi giocatori è arrivata poco dopo (vedi "Alternative scartate"). Il trigger `enforce_giocatori_squadra_update` (DD-016) viene esteso per richiedere anche la corrispondenza email, non solo lo slot libero: il vincolo resta nel database, non solo nella UI.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Mantenere la selezione manuale come ripiego quando l'email non trova corrispondenza → scartata: vanificherebbe la garanzia "ognuno collega solo il proprio profilo" e reintrodurrebbe il rischio di scelta errata che questa decisione vuole eliminare.
|
||||
- Un'interfaccia admin per scrivere l'email dei giocatori → rimandata al momento della decisione, poi implementata nel form "Aggiungi giocatore" di `/admin` (`src/routes/admin.tsx`): serviva per collegare i giocatori aggiunti a metà stagione senza passare da una nuova migration ogni volta.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Le righe senza email nota restano bloccate — nessuno può collegarle, nemmeno per errore — finché un admin non la imposta da `/admin` (o, per la rosa iniziale, una migration). Da settembre 2026 tutta la rosa attiva ha l'email registrata.
|
||||
- `slotLiberi` (funzione ed elenco "slot liberi" in `/benvenuto`) è stato rimosso: non aveva più chiamanti in produzione dopo il cambio.
|
||||
- Un utente che accede con l'account Google sbagliato resta bloccato su `/benvenuto` finché non esce e riprova con l'account giusto.
|
||||
|
||||
**Riesame**
|
||||
|
||||
- Se in futuro serve un'assistenza admin diretta dal flusso di login invece che da `/admin`.
|
||||
|
||||
---
|
||||
|
||||
## Decisioni in valutazione
|
||||
|
||||
---
|
||||
|
||||
### DD-014 — Convergenza schema database (eventi e presenze)
|
||||
|
||||
**Data:** —
|
||||
**Stato:** In valutazione
|
||||
|
||||
**Contesto**
|
||||
Esistono due modelli paralleli: tabelle “legacy” usate dall’app (`eventi_app`, `risposte_presenze`) e tabelle “nuove” con autenticazione e vincoli (`eventi`, `presenze`, `giocatori` UUID).
|
||||
|
||||
**Decisione proposta**
|
||||
Unificare gradualmente sul modello autenticato, dopo auth e profilo stabili.
|
||||
|
||||
**Perché non ora**
|
||||
Rischio regressioni su calendario e presenze, moduli più usati della squadra.
|
||||
|
||||
**Riesame previsto**
|
||||
Post v1.1, con migration e test dedicati.
|
||||
|
||||
---
|
||||
|
||||
### DD-015 — Rosa anagrafica: da codice hardcoded a database
|
||||
|
||||
**Data:** 3 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
La lista giocatori viveva nel codice sorgente (`src/lib/crapp-data.ts`). Il database aveva già
|
||||
`giocatori_squadra` (migration M1) popolata ma non letta da nessuna schermata tranne
|
||||
`/benvenuto` e `/admin`: «Aggiungi giocatore» e «Disattiva giocatore» della dashboard non
|
||||
avevano effetto su Squadra, Presenze, Pagelle, Badge e Scout, mantenendo gli stessi ID finché
|
||||
non si farà DD-012.
|
||||
|
||||
**Decisione**
|
||||
`useRosa()` (e con lei `useIo`, `useObiettivi`) legge ora `giocatori_squadra` tramite
|
||||
`useGiocatoriSquadra()`, filtrando solo i giocatori `attivo`. Tutti i punti che prima
|
||||
importavano la lista statica (`convocatiEvento`, `compleanniEventi`, `completaTurni`,
|
||||
`csvScoutMatch`, i widget di voto/scout/palloni, le due route API che mandano push) sono stati
|
||||
agganciati allo stesso hook o, lato server, a `leggiGiocatoriSquadra()`
|
||||
(`src/lib/giocatori-squadra.server.ts`, stesso pattern di `eventi.server.ts`).
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Un giocatore aggiunto o disattivato dalla dashboard admin ora si riflette ovunque, non solo
|
||||
in `/benvenuto` e `/admin`.
|
||||
- `giocatori_squadra` non ha ancora una colonna per la data di nascita: per i 17 giocatori
|
||||
storici resta quella di `crapp-data.ts` (`nascitaPerId`, lookup per id); un giocatore
|
||||
aggiunto dopo la migrazione non ha nascita nota finché la colonna non esiste. Follow-up da
|
||||
aprire quando serve davvero.
|
||||
- `src/lib/crapp-data.ts` resta come seed storico e fallback (`rosaFallback()`), non più come
|
||||
fonte viva.
|
||||
|
||||
---
|
||||
|
||||
### DD-019 — Il branch dei commit lo decide l'utente
|
||||
|
||||
**Data:** 4 settembre 2026
|
||||
**Stato:** Accettata — sostituisce [DD-003](#dd-003--due-branch-main-stabile-develop-per-il-lavoro)
|
||||
|
||||
**Contesto**
|
||||
DD-003 prevedeva di lavorare su `develop` e portare su `main` solo dopo i test. Nella pratica
|
||||
è successo il contrario: `main` è arrivato a 43 commit di vantaggio su `develop`, che è rimasto
|
||||
fermo. Una regola che nessuno segue è peggio di nessuna regola, perché rende inaffidabile tutto
|
||||
il resto del documento — e con più assistenti AI in gioco il rischio vero non era il branch
|
||||
sbagliato, ma un agente che committa o pusha per conto suo.
|
||||
|
||||
**Decisione**
|
||||
È l'utente a dire su quale branch va un commit. L'assistente può **consigliare** un branch
|
||||
dedicato quando la modifica è rischiosa o parallela ad altro lavoro, ma non cambia branch, non
|
||||
committa, non fa push e non apre PR di propria iniziativa. In assenza di indicazioni si lavora
|
||||
dove si trova il repository, di fatto `main`.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Tenere DD-003 e riallineare `develop` → si sarebbe rotta di nuovo alla prima fretta.
|
||||
- Dismettere `develop` → si perderebbero le preview Vercel, utili quando servono davvero.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- `main` è insieme produzione e branch di lavoro: ogni commit deve lasciare l'app funzionante,
|
||||
quindi la rete di sicurezza sono i test (vedi DD-020), non il branch.
|
||||
- `develop` esiste ancora ma è indietro: la sua preview Vercel non rappresenta lo stato attuale
|
||||
finché non viene riallineata.
|
||||
|
||||
**Riesame**
|
||||
Se il team cresce oltre una persona che scrive codice, o se un lavoro lungo ha bisogno di stare
|
||||
fuori produzione per più di qualche giorno.
|
||||
|
||||
---
|
||||
|
||||
### DD-020 — Una funzione modificata senza test non è finita
|
||||
|
||||
**Data:** 4 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Con `main` come branch di lavoro (DD-019) non c'è più un ambiente di prova tra il codice e i
|
||||
giocatori. La suite in `test/` esisteva già ma scriverla era di fatto facoltativo, e i difetti
|
||||
trovati dai test sono arrivati a posteriori (la sessione Scout Live che non scadeva mai, le
|
||||
serie di presenze ferme a zero per settimane).
|
||||
|
||||
**Decisione**
|
||||
Chi aggiunge o modifica una funzione scrive o aggiorna il test nello stesso lavoro, e i test
|
||||
devono essere verdi prima di consegnare. Non si commenta un test che fallisce né si indebolisce
|
||||
un'asserzione per farla passare: se il comportamento voluto è cambiato, si aggiorna il test
|
||||
dicendo perché.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Test solo sui moduli critici → il confine «critico» si sposta a ogni fretta.
|
||||
- Introdurre un framework di test → la suite bun con `node:assert` funziona e non aggiunge
|
||||
dipendenze (vedi [test/README.md](../test/README.md)).
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- La logica di dominio va tenuta separabile dagli hook (`*-core.ts`), altrimenti non è
|
||||
testabile in `test/unit/` senza rete.
|
||||
- Le modifiche costano un po' di più; le regressioni in produzione costano di più.
|
||||
|
||||
**Riesame**
|
||||
Se comparisse un ambiente di staging stabile che rende superflua parte della copertura.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Efficienza cloud
|
||||
|
||||
Regola di progetto: CrAPP gira su un piano cloud minimo (Supabase + Vercel) per ~17 utenti.
|
||||
Query, traffico e invocazioni vanno tenuti al minimo **per costruzione**, non ottimizzati dopo.
|
||||
|
||||
## Regole da rispettare
|
||||
|
||||
1. **Niente polling**: mai `refetchInterval` verso il database. Per sincronizzare più schede
|
||||
aperte si usano `BroadcastChannel` o gli eventi di `storage`.
|
||||
2. **Cache lunga e passiva**: i default del `QueryClient` stanno in `src/router.tsx`
|
||||
(`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` disattivati,
|
||||
`retry: 1`). Non alzare la frequenza di refetch modulo per modulo.
|
||||
3. **Dopo una mutazione si aggiorna la cache con `setQueryData`**, non con
|
||||
`invalidateQueries`: invalidare costa una rilettura. Unica eccezione oggi:
|
||||
`src/lib/scout-live.ts`.
|
||||
4. **Scout Live**: scrive solo chi sta segnando; gli altri leggono dati già salvati.
|
||||
5. **Write once, read many**: statistiche, badge e classifiche si calcolano una volta e non
|
||||
si ricalcolano a ogni apertura di pagina. I badge restano calcolati a runtime dai dati già
|
||||
in cache, senza query aggiuntive (DD-007): `src/lib/rosa.ts` aggrega ciò che è già stato
|
||||
letto.
|
||||
6. **Push solo per eventi importanti**: convocazioni, promemoria allenamento/partita, turno
|
||||
palloni, esito finale.
|
||||
7. **Niente funzionalità pesanti**: foto, video, chat.
|
||||
8. **Indici** sui campi usati per filtri e relazioni in ogni nuova migration.
|
||||
|
||||
## Obiettivi non ancora attuati
|
||||
|
||||
Questi punti sono stati definiti come direzione, ma **non sono implementati**: non descrivono
|
||||
il comportamento attuale.
|
||||
|
||||
- **Dati CSI**: sincronizzazione periodica server-side salvata su una tabella locale, con
|
||||
l'app che legge solo dal database interno. Oggi la lettura è live dal portale a ogni
|
||||
richiesta, tramite `/api/public/csi` (vedi [modules/collegamento-csi.md](modules/collegamento-csi.md)).
|
||||
- **Aggregati persistiti**: uno schema con `statistiche_aggregate` e `classifica_csi` è stato
|
||||
ipotizzato ma non esiste; nessuna di quelle tabelle è in `supabase/migrations/`. Va valutato
|
||||
con una decisione dedicata, perché tocca DD-007 (badge e statistiche calcolati a runtime).
|
||||
+10
-10
@@ -5,16 +5,16 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
|
||||
|
||||
## Stato attuale
|
||||
|
||||
| Componente | Portabile? | Note |
|
||||
|---|---|---|
|
||||
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
|
||||
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
|
||||
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
|
||||
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
|
||||
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
|
||||
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
|
||||
| `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. |
|
||||
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
|
||||
| Componente | Portabile? | Note |
|
||||
| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
|
||||
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
|
||||
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
|
||||
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
|
||||
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
|
||||
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
|
||||
| `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. |
|
||||
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
|
||||
|
||||
## Regole da rispettare nelle prossime modifiche
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Documentazione CrAPP
|
||||
|
||||
Indice della documentazione ufficiale del progetto. Ogni file risponde a una domanda
|
||||
precisa: se l'informazione che cerchi non è nel file indicato, probabilmente non esiste
|
||||
ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--sviluppo-document-first)).
|
||||
|
||||
## Dove sta cosa
|
||||
|
||||
| Documento | Risponde a |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| [VISION.md](VISION.md) | Perché esiste CrAPP, quali principi deve rispettare una funzionalità |
|
||||
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto e cosa è previsto, versione per versione |
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
|
||||
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
|
||||
| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
|
||||
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio |
|
||||
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
|
||||
| [TODO.md](TODO.md) | A cosa si sta lavorando adesso |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
|
||||
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
|
||||
|
||||
Le regole vincolanti per gli assistenti AI stanno in [AGENTS.md](../AGENTS.md);
|
||||
lo stato corrente del lavoro in [PROJECT_STATE.md](../PROJECT_STATE.md).
|
||||
|
||||
## Ordine di lettura
|
||||
|
||||
Prima di modificare il codice, nell'ordine: questo indice → `VISION.md` → `ROADMAP.md` →
|
||||
`ARCHITECTURE.md` → `DATABASE.md` → `DESIGN_DECISIONS.md` → `TODO.md` → il documento del
|
||||
modulo interessato in `modules/`.
|
||||
|
||||
## Regole di manutenzione
|
||||
|
||||
Ogni informazione ha **una sola casa**, per evitare che le copie divergano:
|
||||
|
||||
- l'elenco delle funzionalità (fatte e previste) sta solo in `ROADMAP.md`;
|
||||
- `CHANGELOG.md` registra _quando_ qualcosa è stato rilasciato, non ripete l'elenco;
|
||||
- `TODO.md` contiene solo il lavoro in corso o imminente, e rimanda alla roadmap;
|
||||
- lo schema del database sta solo in `DATABASE.md`, allineato alle migration in
|
||||
`supabase/migrations/`: una tabella nuova si documenta nella stessa modifica che la crea;
|
||||
- le motivazioni stanno solo in `DESIGN_DECISIONS.md`, in voci `DD-XXX`; per aggiungerne
|
||||
una si copia [\_template-dd.md](_template-dd.md).
|
||||
|
||||
Convenzioni di scrittura: un solo titolo `#` per file (le sezioni interne partono da `##`),
|
||||
niente `---` come riempitivo tra i paragrafi, tabelle al posto degli elenchi ripetitivi.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Roadmap
|
||||
|
||||
Elenco unico delle funzionalità di CrAPP, fatte e previste. È la fonte di riferimento per
|
||||
il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata, `TODO.md` cosa si
|
||||
sta facendo adesso.
|
||||
|
||||
## Versione 1.0 — rilasciata
|
||||
|
||||
- [x] Gestione squadra
|
||||
- [x] Calendario
|
||||
- [x] Presenze
|
||||
- [x] Serie di presenze
|
||||
- [x] Scout Live
|
||||
- [x] Badge
|
||||
- [x] Badge social
|
||||
- [x] Pagelle
|
||||
- [x] Obiettivi di squadra
|
||||
- [x] Notifiche Push (promemoria intelligenti)
|
||||
|
||||
## Versione 1.1
|
||||
|
||||
Le voci spuntate sono in produzione su `main`. I passaggi di attivazione ancora aperti (per
|
||||
esempio il collegamento dei singoli account) stanno in [PROJECT_STATE.md](../PROJECT_STATE.md).
|
||||
|
||||
- [x] Certificati medici — caricamento, scadenza, stato e download; lo storico dei
|
||||
certificati resta un'estensione futura
|
||||
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
|
||||
tesserato (numero e data di tessera)
|
||||
- [x] Dashboard amministratore
|
||||
- [x] Download CSV dati
|
||||
|
||||
## Versione 1.2
|
||||
|
||||
- [ ] Database esercizi
|
||||
- [ ] AI Allenamenti
|
||||
- [ ] Archivio allenamenti
|
||||
|
||||
## Versione 2.0
|
||||
|
||||
- [x] Collegamento CSI (stagione 2025/26)
|
||||
- [x] Classifica automatica
|
||||
- [x] Risultati campionato
|
||||
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
|
||||
Campionato usa solo quelle giocate
|
||||
|
||||
## Idee future
|
||||
|
||||
- [ ] Gestione quote
|
||||
- [ ] Calendario Google
|
||||
- [ ] Backup automatici
|
||||
- [ ] Analisi statistiche avanzate
|
||||
- [ ] Widget meteo
|
||||
- [ ] Analisi Scout con AI
|
||||
@@ -0,0 +1,37 @@
|
||||
# TODO
|
||||
|
||||
Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previste sta in
|
||||
[ROADMAP.md](ROADMAP.md); le idee non ancora valutate pure.
|
||||
|
||||
## In corso
|
||||
|
||||
- Documentazione tecnica del progetto.
|
||||
- Autenticazione Google e dashboard amministratore: il codice è in produzione su `main` e il
|
||||
login è l'unica via d'accesso. La migration M4, che chiude gli accessi `anon` alle
|
||||
tabelle v1.0, è stata applicata in produzione (03/09/2026). Resta il collegamento dei
|
||||
singoli account: ogni giocatore si aggancia al proprio profilo al primo login (DD-018), un
|
||||
processo continuo — vale anche per chi viene aggiunto a stagione in corso da `/admin`.
|
||||
Stato di dettaglio in [PROJECT_STATE.md](../PROJECT_STATE.md).
|
||||
|
||||
## Prossimo
|
||||
|
||||
- Niente di assegnato. Le voci ancora aperte in [ROADMAP.md](ROADMAP.md) sono «Calendario
|
||||
ufficiale» (v2.0, i dati delle gare future arrivano già dal feed CSI) e la v1.2.
|
||||
|
||||
La gestione tesseramenti CSI della v1.1 è completa: raccolta dati, export CSV e tracciamento
|
||||
di chi è già tesserato (numero e data di tessera, migration `m8_tesseramento_csi`, registrabili
|
||||
da `/admin`).
|
||||
|
||||
Il profilo giocatore lato giocatore e i certificati medici sono fatti: `ProfiloAmministrativo`
|
||||
in `src/routes/profilo.tsx` carica documento, certificato e foto con le date di scadenza, e
|
||||
la dashboard amministratore legge quei dati.
|
||||
|
||||
## Manutenzione ricorrente
|
||||
|
||||
- Collegamento CSI: aggiornare `project_id` e `team_id` a inizio stagione 2026/27
|
||||
(vedi [modules/collegamento-csi.md](modules/collegamento-csi.md)).
|
||||
|
||||
## Backlog
|
||||
|
||||
- AI Allenamenti (roadmap v1.2).
|
||||
- Backup automatici (roadmap, idee future).
|
||||
@@ -0,0 +1,32 @@
|
||||
# Visione del progetto
|
||||
|
||||
## Missione
|
||||
|
||||
CrAPP nasce con un obiettivo semplice:
|
||||
|
||||
Digitalizzare completamente la gestione di una squadra di pallavolo, eliminando il maggior numero possibile di attività manuali e aumentando il coinvolgimento dei giocatori attraverso strumenti moderni e intuitivi.
|
||||
|
||||
## Principi del progetto
|
||||
|
||||
Ogni funzionalità sviluppata deve rispettare almeno uno di questi principi:
|
||||
|
||||
- Ridurre il lavoro amministrativo.
|
||||
- Migliorare il coinvolgimento della squadra.
|
||||
- Centralizzare tutte le informazioni in un'unica piattaforma.
|
||||
- Automatizzare le attività ripetitive.
|
||||
- Sfruttare l'intelligenza artificiale solo quando porta un reale beneficio.
|
||||
|
||||
## Filosofia
|
||||
|
||||
CrAPP deve essere:
|
||||
|
||||
- Semplice
|
||||
- Veloce
|
||||
- Divertente
|
||||
- Intuitiva
|
||||
- Accessibile da smartphone
|
||||
- Utilizzabile anche da persone poco esperte
|
||||
|
||||
## Obiettivo finale
|
||||
|
||||
Diventare il punto di riferimento per la gestione quotidiana della squadra, sostituendo chat, fogli Excel e strumenti separati con un'unica applicazione.
|
||||
@@ -0,0 +1,21 @@
|
||||
### DD-XXX — [Titolo breve della decisione]
|
||||
|
||||
**Data:**
|
||||
**Stato:** Accettata · In valutazione · Sostituita · Obsoleta
|
||||
|
||||
**Contesto**
|
||||
[Quale problema stavamo risolvendo?]
|
||||
|
||||
**Decisione**
|
||||
[Cosa abbiamo scelto?]
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- [Alternativa 1] → [perché no]
|
||||
- [Alternativa 2] → [perché no]
|
||||
|
||||
**Conseguenze**
|
||||
[Cosa cambia per utenti, admin e team di sviluppo]
|
||||
|
||||
**Riesame**
|
||||
[Quando o in quali condizioni rivedere la decisione]
|
||||
@@ -0,0 +1,89 @@
|
||||
-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016)
|
||||
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle v1.0 esistenti.
|
||||
-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato
|
||||
-- `profili-giocatore` creato dalla migration M3.
|
||||
|
||||
CREATE TABLE public.profili_giocatore (
|
||||
giocatore_id text PRIMARY KEY REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
|
||||
|
||||
-- Dati personali richiesti dal tesseramento CSI
|
||||
data_nascita date,
|
||||
luogo_nascita text,
|
||||
indirizzo text,
|
||||
telefono text,
|
||||
email text,
|
||||
|
||||
-- Documento di identità
|
||||
documento_tipo text,
|
||||
documento_numero text,
|
||||
documento_rilasciato_da text,
|
||||
documento_emissione date,
|
||||
documento_scadenza date,
|
||||
-- Il documento si carica fronte e retro: il CSI li vuole entrambi.
|
||||
documento_fronte_path text,
|
||||
documento_retro_path text,
|
||||
|
||||
-- Certificato medico (storico non conservato in v1: DD-010)
|
||||
certificato_scadenza date,
|
||||
certificato_path text,
|
||||
|
||||
-- Foto tessera
|
||||
foto_path text,
|
||||
|
||||
creato_il timestamptz NOT NULL DEFAULT now(),
|
||||
aggiornato_il timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.profili_giocatore IS
|
||||
'Dati personali, documento e certificato di ciascun giocatore, 1:1 con giocatori_squadra. Vedi DD-016.';
|
||||
|
||||
CREATE TRIGGER update_profili_giocatore_aggiornato_il
|
||||
BEFORE UPDATE ON public.profili_giocatore
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.update_aggiornato_il();
|
||||
|
||||
ALTER TABLE public.profili_giocatore ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Il giocatore vede e modifica solo il proprio profilo: il collegamento passa
|
||||
-- da giocatori_squadra.auth_user_id, che solo un admin può riassegnare (M1).
|
||||
CREATE POLICY "Il giocatore legge il proprio profilo" ON public.profili_giocatore
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Il giocatore crea il proprio profilo" ON public.profili_giocatore
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Il giocatore aggiorna il proprio profilo" ON public.profili_giocatore
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Gli admin leggono tutti i profili ed esportano i dati per il tesseramento.
|
||||
CREATE POLICY "Gli admin gestiscono tutti i profili" ON public.profili_giocatore
|
||||
FOR ALL TO authenticated
|
||||
USING (public.has_role(auth.uid(), 'admin'::public.app_role))
|
||||
WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON public.profili_giocatore TO authenticated;
|
||||
GRANT ALL ON public.profili_giocatore TO service_role;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- M3 — Bucket privato per documenti, certificati e foto tessera (DD-016 regole 3 e 4)
|
||||
-- Migration additiva. Il bucket nasce privato e resta privato: documenti d'identità e
|
||||
-- dati sanitari non devono mai essere raggiungibili da un URL pubblico. L'accesso avviene
|
||||
-- solo con client autenticato o con signed URL a scadenza breve generata per gli admin.
|
||||
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('profili-giocatore', 'profili-giocatore', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Convenzione dei path: `<giocatore_id>/<sezione>.<estensione>` (es. `g4/certificato.pdf`).
|
||||
-- La prima cartella è l'ID del giocatore: è così che si riconosce il proprietario del file.
|
||||
CREATE POLICY "Il giocatore gestisce i propri file" ON storage.objects
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
bucket_id = 'profili-giocatore'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
bucket_id = 'profili-giocatore'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Gli admin scaricano i file di tutti, ma non li modificano: i documenti restano
|
||||
-- in mano al giocatore che li ha caricati.
|
||||
CREATE POLICY "Gli admin scaricano tutti i file dei profili" ON storage.objects
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
bucket_id = 'profili-giocatore'
|
||||
AND public.has_role(auth.uid(), 'admin'::public.app_role)
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
# Modulo — Badge
|
||||
|
||||
**Stato:** implementato (v1.0), coerente con DD-007 e DD-008
|
||||
**File principali:** `src/lib/badges.ts`, `src/lib/badge-social.ts`,
|
||||
`src/components/crapp/CollezioneBadge.tsx`, `src/components/crapp/BadgeDrawer.tsx`,
|
||||
`src/components/crapp/CelebrazioneBadge.tsx`, `src/components/crapp/VotoSocial.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Gamification: sbloccare badge (gradi bronzo/argento/oro, più badge "segreti") in base a
|
||||
statistiche personali reali del giocatore, per motivare la partecipazione senza penalizzare i
|
||||
ruoli con meno statistiche "spettacolari" (DD-008).
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Nessuna tabella dedicata ai badge sbloccati: **calcolati interamente a runtime**
|
||||
dall'oggetto `Giocatore` (DD-007). L'unica tabella coinvolta è `badge_social_voti`, per i
|
||||
badge assegnati per voto dai compagni.
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
- `badgeDefs`/`badgeSegreti` (`badges.ts`) definiscono ogni badge con una funzione
|
||||
`valore(g)` e soglie bronzo/argento/oro. Le fonti dato sono solo statistiche indipendenti
|
||||
dal ruolo in campo: MVP, media pagelle, turni palloni, presenze, serie, infortuni,
|
||||
ritardi, cacche — **mai** punti/ace/muri dello Scout Live, coerentemente con DD-008.
|
||||
- I badge segreti restano nascosti (icona lucchetto) finché non sbloccati.
|
||||
- **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per
|
||||
partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della
|
||||
partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita
|
||||
(modificabile), con **auto-voto escluso sia in UI sia in logica** (`VotoSocial.tsx`). A
|
||||
differenza delle [Pagelle](pagelle.md), qui non c'è alcun tentativo di anonimato:
|
||||
`votante_id`/`votato_id` sono entrambi visibili.
|
||||
- `CollezioneBadge.tsx` mostra sbloccati, in progresso, badge social vinti e un contatore di
|
||||
badge segreti ancora da scoprire; `BadgeDrawer.tsx` il dettaglio di un singolo badge;
|
||||
`CelebrazioneBadge.tsx` l'overlay celebrativo alla prima visualizzazione di un badge nuovo.
|
||||
- Il rilevamento "nuovo" (`notifiche-smart.ts`) confronta id deterministici con quelli già
|
||||
visti, salvati in `localStorage` — quindi **locale al dispositivo**, non sincronizzato tra
|
||||
dispositivi dello stesso giocatore.
|
||||
|
||||
---
|
||||
|
||||
## Regole rispettate
|
||||
|
||||
- **DD-007**: nessuna tabella `badge_sbloccati`, tutto calcolato a runtime dai dati
|
||||
esistenti.
|
||||
- **DD-008**: nessun `BadgeDef` usa dati di reparto (punti/ace/muri); solo statistiche
|
||||
raggiungibili da qualunque ruolo.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
|
||||
"Risposta lampo" e il segreto "Mai un forfait" si muovono solo se cambiano le serie. Le
|
||||
serie sono calcolate sui dati reali dalla migration `m9` in avanti, ma "Risposta lampo" e
|
||||
"Mai un forfait" dipendono da `serieConferme`, e `risposto_il` non è ricostruibile per le
|
||||
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
|
||||
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
|
||||
"ottenuto" può sparire o apparire retroattivamente.
|
||||
- RLS permissiva su `badge_social_voti` (stesso schema di `mvp_voti`): nessun controllo
|
||||
server-side che `votante_id` coincida con l'utente autenticato.
|
||||
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
|
||||
browser o dispositivo.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
|
||||
- Verificare sui dati di stagione che i tre badge legati alle serie si sblocchino davvero,
|
||||
ora che le serie sono calcolate.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Modulo — Collegamento CSI
|
||||
|
||||
**Stato:** implementato (stagione 2025/26)
|
||||
**Route interessata:** `/classifica`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Mostrare nell'app la classifica e i risultati **ufficiali** del campionato CSI, al posto
|
||||
dei dati dimostrativi hardcoded in `crapp-data.ts`. Nessun inserimento manuale da parte
|
||||
degli amministratori: è esattamente il tipo di lavoro amministrativo che CrAPP deve togliere.
|
||||
|
||||
---
|
||||
|
||||
## Sorgente dati
|
||||
|
||||
Portale **Livescore CSI Bologna** (`https://livescore.csibologna.it`).
|
||||
|
||||
Il portale **non espone un'API pubblica documentata**. Vengono usati gli stessi endpoint
|
||||
che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza
|
||||
API key, ma **non offrono alcuna garanzia di stabilità**.
|
||||
|
||||
| Endpoint | Formato | Uso |
|
||||
| ------------------------------------------------ | ------- | ------------------------------------------------------------------------------ |
|
||||
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi |
|
||||
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra: data, ora, avversario, campo, risultato, parziali |
|
||||
|
||||
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
|
||||
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
|
||||
`project-last_results.php`, `team-roster.php`, `team-results.php`.
|
||||
|
||||
### Identificativi (stagione 2025/26)
|
||||
|
||||
| Cosa | Valore |
|
||||
| ------------------- | -------------------------------------- |
|
||||
| Campionato | PVM - Campionato Open Misto Eccellenza |
|
||||
| `project_id` | `767` |
|
||||
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
|
||||
| `team_id` | `3359` |
|
||||
| Girone | B |
|
||||
|
||||
Gli identificativi sono costanti in `src/lib/csi-core.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
```
|
||||
CSI (portale)
|
||||
↓ fetch server-side, cache 6 ore
|
||||
/api/public/csi → src/routes/api/public/csi.ts
|
||||
↓ JSON { classifica, partite, girone, aggiornato }
|
||||
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
|
||||
↓
|
||||
/classifica → src/routes/classifica.tsx
|
||||
```
|
||||
|
||||
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe),
|
||||
`partiteDaEventi()` (JSON → partite), `isNostraSquadra()`, `partiteGiocate()`.
|
||||
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI. Cache in memoria di
|
||||
6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne esiste uno).
|
||||
- **`src/lib/csi.ts`** — hook client, una lettura per sessione.
|
||||
- **`test/unit/csi-core.test.ts`** — check del parsing: `bun test/unit/csi-core.test.ts`.
|
||||
Con `CSI_LIVE=1` verifica anche gli endpoint reali.
|
||||
|
||||
### Regole rispettate
|
||||
|
||||
- **Nessuna chiamata dal browser**: il portale viene contattato solo lato server, al massimo
|
||||
4 volte al giorno, indipendentemente da quanti giocatori aprono l'app (regola anti-consumo).
|
||||
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della tabella.
|
||||
- **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo
|
||||
dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati
|
||||
ricadono sulle partite dello Scout Live locale (`useScoutMatches()`).
|
||||
- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
1. **La classifica si legge da HTML.** Se il portale cambia la struttura della tabella il
|
||||
parsing restituisce un array vuoto: `/classifica` non si rompe, ma mostra "Classifica non
|
||||
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
|
||||
sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`.
|
||||
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
|
||||
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id, ricavabile da
|
||||
`team_details.php?team_id=3359`, che elenca i campionati della squadra. Oggi va aggiornato
|
||||
a mano in `csi-core.ts`.
|
||||
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
|
||||
istanze. Sufficiente per una squadra; se serve di più, spostare i dati in una tabella
|
||||
Supabase riempita da un job cron (stesso pattern di `promemoria-palloni`).
|
||||
4. **I risultati includono anche la Coppa**, non solo il girone di campionato.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili).
|
||||
- Creazione automatica degli eventi partita da calendario CSI.
|
||||
- Confronto tra i parziali ufficiali e quelli dello Scout Live.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Modulo — Infortuni
|
||||
|
||||
**Stato:** implementato in forma minima (solo conteggio)
|
||||
**File principali:** `src/lib/infortuni.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Tracciare quanti eventi (allenamenti o partite) un giocatore ha saltato per infortunio,
|
||||
riusando lo stato di presenza `infortunato` già registrato per le convocazioni — nessun
|
||||
modulo di gestione infortuni a sé stante.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Nessuna tabella dedicata: il dato vive interamente dentro `risposte_presenze`, come uno dei
|
||||
valori possibili dell'enum `Stato` (`presente`, `assente`, `forse`, `ritardo`, `infortunato`).
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
`contaStato()`/`contaInfortuni()` (`infortuni.ts`) contano, per ciascun giocatore, quante
|
||||
volte compare lo stato `infortunato` nella mappa presenze già in cache (nessuna query
|
||||
aggiuntiva). Lo stesso meccanismo, con `contaRitardi()`, conta i ritardi. Il risultato
|
||||
alimenta il campo `infortuni` del `Giocatore` in `useRosa()`.
|
||||
|
||||
Visibile in UI solo indirettamente, tramite il [badge](badge.md) segreto "Cliente VIP
|
||||
dell'Infermeria" (sbloccato con almeno 3 infortuni): non esiste uno StatTile dedicato nel
|
||||
profilo che mostri il numero di infortuni come statistica di superficie.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Nessuna durata o periodo tracciato: è solo un conteggio di eventi con quello stato, non un
|
||||
inizio/fine infortunio.
|
||||
- Il conteggio dipende dal fatto che qualcuno imposti correttamente lo stato "infortunato"
|
||||
invece di "assente": nessuna validazione o promemoria lo garantisce.
|
||||
- Poco visibile per valori bassi (1-2), perché emerge solo tramite un badge a soglia 3.
|
||||
- `conInfortuni()`, una funzione di merge alternativa nello stesso file, non risulta usata da
|
||||
nessuna parte del codice attuale — probabile residuo non collegato.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Uno StatTile dedicato nel profilo, oltre al badge segreto.
|
||||
- Se servisse un vero tracciamento (durata, tipo di infortunio), servirebbe una tabella
|
||||
dedicata: oggi il modulo copre solo il conteggio.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Modulo — Votazione MVP
|
||||
|
||||
**Stato:** implementato (v1.0)
|
||||
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Eleggere il MVP di una partita tramite voto tra compagni, un voto a testa, con vincitore
|
||||
calcolato a runtime.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
|
||||
partita, sovrascrivibile.
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
- Il pannello compare in `partita.$id.tsx` solo se esiste un risultato (Scout Live salvato)
|
||||
per la partita, altrimenti mostra "la partita non è ancora stata disputata".
|
||||
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza
|
||||
limiti, senza storico.
|
||||
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
|
||||
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
|
||||
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
|
||||
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
|
||||
nel profilo e in home.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **Nessun controllo che impedisca di votare se stessi** — a differenza dei
|
||||
[Badge social](badge.md), che escludono esplicitamente l'auto-voto. È una lacuna, non un
|
||||
limite di design dichiarato altrove.
|
||||
- Nessuna scadenza o chiusura della votazione: resta aperta indefinitamente.
|
||||
- RLS permissiva: `votante_id`/`votato_id` sono testo libero inviato dal client (l'id
|
||||
giocatore proviene da `localStorage`, non da un claim di sessione verificato server-side);
|
||||
nessun trigger lega il voto all'utente autenticato.
|
||||
- In caso di parità, nessun MVP viene assegnato per quella partita.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Impedire l'auto-voto come già avviene nei Badge social.
|
||||
- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita).
|
||||
@@ -0,0 +1,93 @@
|
||||
# Modulo — Notifiche
|
||||
|
||||
**Stato:** implementato parzialmente — solo il canale "turno palloni" è realmente collegato
|
||||
(vedi Limiti noti)
|
||||
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/push-client.ts`,
|
||||
`src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
|
||||
`src/routes/api/public/push-messaggio.ts`, `src/routes/api/public/push-subscribe.ts`,
|
||||
`public/push-sw.js`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
|
||||
indipendenti:
|
||||
|
||||
- **Push VAPID** — arrivano anche ad app chiusa (turno palloni, sollecito presenze).
|
||||
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
||||
serie e obiettivi appena raggiunti; non è un canale push separato.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
`push_subscriptions` (un dispositivo per riga, chiave `endpoint`), `promemoria_push` (coda
|
||||
"consuma e cancella" del testo da mostrare — nonostante il nome, **non** è uno storico
|
||||
persistente: la riga viene eliminata non appena letta dal service worker).
|
||||
|
||||
---
|
||||
|
||||
## Iscrizione alle notifiche push
|
||||
|
||||
1. Il giocatore attiva "Notifiche turno palloni" in `/profilo` → richiesta permesso browser.
|
||||
2. `GET /api/public/push-config` restituisce solo la chiave pubblica VAPID.
|
||||
3. Registrazione del service worker `public/push-sw.js` e `pushManager.subscribe()`.
|
||||
4. `POST /api/public/push-subscribe` registra endpoint e chiavi in `push_subscriptions`
|
||||
(upsert).
|
||||
|
||||
---
|
||||
|
||||
## Ruolo delle tre route pubbliche
|
||||
|
||||
- **`push-config`** — espone la sola chiave pubblica VAPID.
|
||||
- **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo.
|
||||
- **`push-messaggio`** — non invia nulla: il service worker la interroga **al momento della
|
||||
ricezione** di una push (che arriva sempre "vuota", senza testo, per compatibilità) per
|
||||
sapere quale messaggio mostrare. Priorità: un messaggio in coda su `promemoria_push`
|
||||
(scritto da `sollecita-presenze`, vedi [Presenze](presenze.md)), altrimenti il messaggio
|
||||
calcolato al volo sul turno palloni (vedi [Palloni](palloni.md)).
|
||||
|
||||
L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un JWT VAPID
|
||||
(ECDSA P-256) e fa una POST senza corpo all'endpoint push del browser; è riusato identico da
|
||||
`sollecita-presenze.ts` e `promemoria-palloni.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Notifiche smart
|
||||
|
||||
`calcolaNotifiche()` (`notifiche-smart.ts`) genera un evento solo quando "c'è qualcosa di
|
||||
reale": badge appena sbloccato, "sei a un passo" da un traguardo, serie che raggiunge un
|
||||
traguardo esatto, obiettivo di squadra tra il 90 e il 100%, badge social vinto. Ogni notifica
|
||||
ha un id deterministico; quelli già mostrati sono salvati in `localStorage` per non
|
||||
ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **Le 4 voci "Notifiche convocazioni", "Promemoria allenamenti", "Cambi orario", "Bacheca
|
||||
squadra" in `/profilo` sono placeholder statici**: checkbox non controllati
|
||||
(`defaultChecked`, nessun `onChange`), non collegati a nessuno stato, nessuna colonna DB
|
||||
per queste preferenze. L'unica preferenza realmente funzionante è "Notifiche turno
|
||||
palloni".
|
||||
- `promemoria_push` è descritta altrove come "storico" ma nel codice è una coda che si
|
||||
autocancella alla lettura: non conserva nulla.
|
||||
- Nessuna verifica di autenticazione su `push-messaggio` (chiunque conosca un endpoint push
|
||||
valido può leggerne il messaggio) né su `promemoria-palloni`.
|
||||
- Compatibilità iOS/Safari non gestita esplicitamente nel codice (nessun branch dedicato):
|
||||
serve l'installazione da schermata Home per funzionare, ma l'app non lo segnala
|
||||
esplicitamente.
|
||||
- Le notifiche smart dipendono da un service worker già registrato: se il giocatore non ha
|
||||
mai attivato le push, `notificaSistema()` non ha un `reg` a cui appoggiarsi e la notifica
|
||||
locale non viene mai mostrata, anche con permesso concesso.
|
||||
- Payload push sempre vuoto: ogni notifica richiede una fetch aggiuntiva (`push-messaggio`)
|
||||
per ottenere il testo, quindi serve rete disponibile anche solo per mostrare il messaggio.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Collegare (o rimuovere) le 4 preferenze placeholder in `/profilo`.
|
||||
- Aggiungere autenticazione alle route pubbliche coinvolte.
|
||||
- Gestire esplicitamente il caso iOS (messaggio se l'app non è installata da Home).
|
||||
@@ -0,0 +1,61 @@
|
||||
# Modulo — Obiettivi di squadra
|
||||
|
||||
**Stato:** implementato (v1.0), con costanti stagionali da aggiornare a mano
|
||||
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Mostrare traguardi collettivi (non individuali) che avanzano con il contributo di tutta la
|
||||
rosa — presenze, risposte alle convocazioni, pagelle, risultati di campionato — per motivare
|
||||
comportamenti di squadra oltre alla singola prestazione.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts` che legge dati
|
||||
già aggregati altrove (`risposte_presenze`, `pagelle_voti`, i risultati ufficiali CSI, le
|
||||
serie).
|
||||
|
||||
---
|
||||
|
||||
## Obiettivi definiti
|
||||
|
||||
| Obiettivo | Calcolo | Target | Fonte |
|
||||
| ----------------------------------- | ------------------------------------------------- | ------ | -------------------------------------------- |
|
||||
| 90% presenze ad agosto | risposte presente/ritardo sugli eventi del mese | 90% | `risposte_presenze` |
|
||||
| Tutti rispondono alle convocazioni | risposte totali / eventi possibili | 90% | `risposte_presenze` |
|
||||
| 250 presenze complessive | somma presenze di tutta la rosa | 250 | aggregato da `useRosa()` |
|
||||
| Media pagelle da 7.5 | media di squadra | 7.5 | `pagelle_voti` |
|
||||
| 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||
| Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 | `serieAllenamenti` |
|
||||
| 1 / 5 / 10 vittorie in campionato | partite vinte da dati CSI ufficiali | 1/5/10 | modulo [Collegamento CSI](collegamento-csi.md) |
|
||||
| 1 evento di squadra al mese | eventi di tipo "evento" nel mese | 1 | `eventi_app` |
|
||||
|
||||
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
||||
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
|
||||
smart (`notifiche-smart.ts`).
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- "Continuità di squadra" dipende da `serieAllenamenti` (vedi
|
||||
[Serie di presenze](serie-presenze.md)), calcolato sui dati reali: un evento passato senza
|
||||
risposta vale come assenza e azzera la serie, quindi l'obiettivo misura anche quanto la
|
||||
squadra risponde alle convocazioni, non solo la presenza.
|
||||
- **Il mese di riferimento è una costante fissa nel codice** (agosto 2026): gli obiettivi
|
||||
legati al mese corrente vanno aggiornati manualmente a ogni cambio di mese o stagione, oggi
|
||||
sono "congelati" su un mese già passato.
|
||||
- Le vittorie di campionato dipendono dal parsing HTML del portale CSI: se quel parsing si
|
||||
rompe, questi tre obiettivi restano a 0% anche a fronte di vittorie reali.
|
||||
- I target (250 presenze, 200 pagelle, ecc.) sono costanti fisse, da rivedere manualmente a
|
||||
ogni stagione.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Calcolare il mese di riferimento dinamicamente invece di una costante hardcoded.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Modulo — Pagelle
|
||||
|
||||
**Stato:** implementato (v1.0)
|
||||
**File principali:** `src/lib/pagelle.ts`, `src/components/crapp/Pagelle.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Voto tra compagni (1-10) a fine partita per ciascun convocato, usato per calcolare una media
|
||||
personale mostrata nel profilo e una media di squadra.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Tabella `pagelle_voti`, con vincoli imposti a livello database: `CHECK voto BETWEEN 1 AND 10`,
|
||||
`CHECK votante_id <> votato_id` (anti auto-voto imposto anche dal database, non solo dalla
|
||||
UI), `UNIQUE (match_id, votante_id, votato_id)`.
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
- Il pannello `Pagelle` compare in `partita.$id.tsx` solo se esiste un risultato per la
|
||||
partita (scout salvato o dato CSI).
|
||||
- Ogni convocato può votare tutti gli altri convocati, mai se stesso — escluso sia in UI sia
|
||||
dal vincolo DB.
|
||||
- `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
|
||||
volte, l'ultimo voto sovrascrive il precedente.
|
||||
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
|
||||
tutti i voti della stagione; `pagellePartita()` la calcola per singola partita;
|
||||
`mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in `squadra.tsx`.
|
||||
- `useRosa()` inietta la media stagionale nel campo `mediaVoto` di ogni giocatore.
|
||||
|
||||
---
|
||||
|
||||
## Regole rispettate
|
||||
|
||||
- Anti auto-voto imposto anche a livello database (constraint, non solo filtro UI).
|
||||
- L'admin può marcare un evento come `pagelleChiuse` (`eventi.ts`), che nasconde i bottoni di
|
||||
voto in UI.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **`pagelleChiuse` è solo un flag UI**: nessuna policy RLS lo controlla, quindi un voto
|
||||
"fuori tempo" resta tecnicamente possibile bypassando l'interfaccia.
|
||||
- **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id`
|
||||
sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non
|
||||
mostra mai il votante, ma il dato non è né aggregato né mascherato lato server.
|
||||
- Nessun controllo a livello database che il votante sia realmente un convocato della
|
||||
partita: solo filtro applicativo.
|
||||
- La media non richiede un numero minimo di voti: con un solo voto ricevuto, la media
|
||||
coincide con quel voto.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati.
|
||||
- Far rispettare `pagelleChiuse` anche via RLS.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Modulo — Palloni
|
||||
|
||||
**Stato:** implementato (v1.0)
|
||||
**File principali:** `src/lib/palloni.ts`, `src/lib/palloni-core.ts`,
|
||||
`src/components/crapp/TurnoPalloni.tsx`, `src/components/crapp/PromemoriaPalloni.tsx`,
|
||||
`src/routes/api/public/promemoria-palloni.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Gestire un turno a rotazione condiviso per chi porta e riporta i palloni ad allenamenti e
|
||||
partite, con proposta automatica, possibilità di modifica manuale e promemoria push il
|
||||
giorno stesso.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Tabella `turni_palloni` (`evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`) —
|
||||
contiene solo i turni **confermati manualmente**; le proposte automatiche non salvate non vi
|
||||
compaiono.
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
- `completaTurni()` (`palloni-core.ts`) propone, per ogni evento senza turno già salvato, il
|
||||
candidato con meno turni fatti, poi quello che non lo fa da più tempo, poi per ordine
|
||||
alfabetico — un algoritmo greedy, non un ordine fisso né solo per data.
|
||||
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
|
||||
upsert su `evento_id`.
|
||||
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
|
||||
ricalcolato a runtime da `conteggioTurni()` su turni salvati **più proposte non ancora
|
||||
confermate** — non è uno storico in tabella dedicata.
|
||||
- `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx`
|
||||
è il banner in Home per il giocatore di turno.
|
||||
|
||||
---
|
||||
|
||||
## Route API pubblica `/api/public/promemoria-palloni`
|
||||
|
||||
Pensata per essere chiamata quotidianamente da uno scheduler esterno (pg_cron o simile,
|
||||
secondo `docs/PORTABILITA.md`), non da nessun componente client. Calcola i destinatari del
|
||||
giorno — chi deve **prendere** i palloni oggi e chi deve **riportarli** (l'incaricato
|
||||
dell'evento precedente) — e invia loro una push "vuota" (`src/lib/webpush.server.ts`); il
|
||||
testo effettivo viene calcolato al volo dal service worker interrogando
|
||||
`/api/public/push-messaggio` (vedi [Notifiche](notifiche.md)).
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **Nessuna verifica di autenticazione/secret** sulla route `promemoria-palloni`: chiunque
|
||||
può invocarla via POST diretto, nonostante il piano originale prevedesse una protezione
|
||||
con secret.
|
||||
- **Nessun cron nel repository**: lo scheduling effettivo (se esiste) è configurato fuori dal
|
||||
codice versionato — da verificare lato Supabase/hosting.
|
||||
- Il conteggio dei turni include anche le proposte non confermate: badge e statistiche
|
||||
possono contare turni mai effettivamente convalidati da nessuno.
|
||||
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
|
||||
"assente" o "infortunato" per quell'evento.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Aggiungere un secret/header di autorizzazione alla route pubblica.
|
||||
- Versionare il cron (es. una migration con `cron.schedule`) invece di configurarlo solo
|
||||
lato dashboard.
|
||||
- Escludere dalla rotazione chi ha già dichiarato assenza per l'evento.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Modulo — Presenze
|
||||
|
||||
**Stato:** implementato (v1.0)
|
||||
**File principali:** `src/lib/presenze.ts`, `src/lib/presenze-mese.ts`, `src/components/crapp/RosaPresenze.tsx`,
|
||||
`src/routes/api/public/sollecita-presenze.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Permettere a ogni giocatore di confermare o rifiutare la propria partecipazione a un evento
|
||||
(allenamento o partita) e mostrare a tutta la squadra chi ha risposto e come, sostituendo i
|
||||
solleciti a voce o su chat esterne.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Tabella `risposte_presenze` (PK composita `evento_id, giocatore_id`), letta e scritta da
|
||||
`src/lib/presenze.ts`. È il modello "in uso" citato in `docs/DATABASE.md`; le tabelle
|
||||
`eventi`/`presenze` previste da DD-014 non sono referenziate da nessun punto del codice
|
||||
attuale.
|
||||
|
||||
Stati possibili (`Stato` in `src/lib/crapp-data.ts`): `presente`, `assente`, `forse`,
|
||||
`ritardo`, `infortunato`. Solo `presente` e `ritardo` contano come presenza effettiva nelle
|
||||
statistiche. L'assenza di una riga per `(evento, giocatore)` equivale a "non ha ancora
|
||||
risposto".
|
||||
|
||||
---
|
||||
|
||||
## Implementazione
|
||||
|
||||
```
|
||||
Giocatore tocca uno stato in RosaPresenze
|
||||
↓
|
||||
useSalvaPresenza() → src/lib/presenze.ts (upsert o delete su risposte_presenze,
|
||||
↓ onConflict evento_id+giocatore_id)
|
||||
risposte_presenze (Supabase)
|
||||
↓ letta da
|
||||
useRispostePresenze() → src/lib/presenze.ts (1 query per sessione, staleTime 5 min,
|
||||
↓ legge tutta la tabella)
|
||||
RosaPresenze → src/components/crapp/RosaPresenze.tsx
|
||||
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato)
|
||||
allenamento.$id.tsx / partita.$id.tsx
|
||||
|
||||
--- statistiche ---
|
||||
contaPresenzeGiocatore() / totaliEventiGiocatore() → src/lib/presenze.ts
|
||||
usePresenzeUltimoMese() → src/lib/presenze-mese.ts
|
||||
(percentuale ultimi 30gg, da cache già in memoria)
|
||||
|
||||
--- sollecito (solo admin) ---
|
||||
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
|
||||
↓
|
||||
src/routes/api/public/sollecita-presenze.ts
|
||||
├─ legge l'evento (eventi_app) e le risposte già date
|
||||
├─ calcola i destinatari: giocatori attivi senza risposta o con "forse"
|
||||
├─ per ciascuno registra il messaggio in promemoria_push e invia una push
|
||||
│ (src/lib/webpush.server.ts)
|
||||
└─ elimina le iscrizioni push scadute (404/410)
|
||||
```
|
||||
|
||||
Un evento conta ai fini delle statistiche di presenza solo se è di tipo `partita` o
|
||||
`allenamento` e il giocatore è tra i convocati (o non ci sono convocati specificati, cioè
|
||||
vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Regole rispettate
|
||||
|
||||
- Aggiornamento ottimistico della cache locale dopo ogni salvataggio: nessuna rilettura dal
|
||||
server, la UI risponde subito.
|
||||
- Il sollecito è **manuale**: nessun cron nel repository lo richiama automaticamente, parte
|
||||
solo dal bottone admin.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Nessuna finestra temporale per rispondere: si può cambiare risposta anche a evento passato.
|
||||
- Il controllo "solo il giocatore risponde per sé" è solo lato UI: le policy RLS di
|
||||
`risposte_presenze` permettono a qualunque utente autenticato di scrivere qualunque riga
|
||||
(`USING(true) WITH CHECK(true)`).
|
||||
- `useRispostePresenze()` legge sempre l'intera tabella, non filtrata per evento: adeguato per
|
||||
una singola squadra, da rivedere se il volume cresce molto.
|
||||
- La route `/api/public/sollecita-presenze` non verifica lato server che il chiamante sia
|
||||
admin: la protezione è solo nell'interfaccia (bottone visibile solo se `useIsAdmin()`).
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Restringere anche lato RLS/route chi può scrivere una risposta o chiamare il sollecito.
|
||||
- Filtrare la lettura delle presenze per evento invece di caricare tutta la tabella.
|
||||
@@ -0,0 +1,245 @@
|
||||
# Modulo — Profilo Giocatore
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amministrative e documentali di ciascun membro della squadra.
|
||||
|
||||
L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al giocatore sia agli amministratori, eliminando la gestione tramite chat, documenti cartacei e fogli Excel.
|
||||
|
||||
## Utenti
|
||||
|
||||
### Giocatore
|
||||
|
||||
Può:
|
||||
|
||||
- visualizzare il proprio profilo
|
||||
- modificare i propri dati personali
|
||||
- aggiornare il certificato medico
|
||||
- aggiornare i documenti
|
||||
- caricare le immagini richieste
|
||||
|
||||
### Amministratore
|
||||
|
||||
Può:
|
||||
|
||||
- visualizzare il profilo di tutti i giocatori
|
||||
- scaricare documenti e certificati
|
||||
- esportare i dati necessari al tesseramento CSI
|
||||
- verificare lo stato di completamento dei profili
|
||||
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo, email)
|
||||
- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017)
|
||||
- scollegare un account da un profilo, liberando lo slot
|
||||
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale)
|
||||
- disattivare un giocatore che ha lasciato la squadra, e riattivarlo in caso di errore: la
|
||||
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
|
||||
agganciati al suo id
|
||||
|
||||
Non può caricare o sostituire i file altrui: documento, certificato e foto restano
|
||||
responsabilità del giocatore che li fornisce.
|
||||
|
||||
## Flusso utente
|
||||
|
||||
### Primo accesso
|
||||
|
||||
1. Login tramite Google oppure Email. _Implementato con il solo Google: la squadra ha tutti
|
||||
un account Google, e un secondo metodo è additivo (un bottone in più sulla stessa
|
||||
schermata) il giorno che serve._
|
||||
2. Collegamento automatico al proprio giocatore, confrontando l'email dell'account Google
|
||||
con l'email registrata in `giocatori_squadra` (DD-018). Nessuna scelta manuale: se
|
||||
l'email non corrisponde a nessun profilo, l'accesso si ferma con un messaggio che invita
|
||||
a contattare un amministratore.
|
||||
3. Accesso alla Home.
|
||||
|
||||
Se il profilo non è completo compare automaticamente un widget di completamento.
|
||||
|
||||
## Home
|
||||
|
||||
Il giocatore visualizza un widget dedicato.
|
||||
|
||||
### Completa il tuo profilo
|
||||
|
||||
Viene mostrata una barra di avanzamento (esempio: _Profilo completato — 85%_), composta dalle seguenti sezioni.
|
||||
|
||||
- Dati personali
|
||||
- Documento di identità
|
||||
- Certificato medico
|
||||
- Foto tessera
|
||||
|
||||
Quando tutte le sezioni sono complete il widget scompare automaticamente.
|
||||
|
||||
## Profilo
|
||||
|
||||
Il profilo viene suddiviso in sette aree.
|
||||
|
||||
### Dati Giocatore
|
||||
|
||||
**Dati squadra** — solo lettura, gestiti esclusivamente dagli amministratori.
|
||||
|
||||
- Nome
|
||||
- Cognome
|
||||
- Numero di maglia
|
||||
- Ruolo
|
||||
|
||||
**Dati personali** — modificabili dal giocatore.
|
||||
|
||||
- Data di nascita
|
||||
- Luogo di nascita
|
||||
- Indirizzo di residenza
|
||||
- Telefono
|
||||
- Email
|
||||
|
||||
### Documento di identità
|
||||
|
||||
Campi.
|
||||
|
||||
- Tipo documento
|
||||
- Numero documento
|
||||
- Rilasciato da
|
||||
- Data emissione
|
||||
- Data scadenza
|
||||
|
||||
Upload.
|
||||
|
||||
- Foto fronte
|
||||
- Foto retro
|
||||
|
||||
### Certificato medico
|
||||
|
||||
Campi.
|
||||
|
||||
- Data di scadenza
|
||||
|
||||
Upload.
|
||||
|
||||
- Certificato medico
|
||||
|
||||
Il giocatore può aggiornare liberamente sia la data sia il file.
|
||||
|
||||
Lo storico non viene mantenuto nella prima versione.
|
||||
|
||||
### Foto tessera
|
||||
|
||||
Upload di una fotografia formato tessera.
|
||||
|
||||
Utilizzata dagli amministratori per il tesseramento CSI.
|
||||
|
||||
### Statistiche
|
||||
|
||||
Sezione già presente. Contiene.
|
||||
|
||||
- Presenze
|
||||
- Voto medio
|
||||
- MVP
|
||||
- Serie
|
||||
- Altre statistiche disponibili
|
||||
|
||||
### Badge
|
||||
|
||||
Sezione già presente.
|
||||
|
||||
Contiene tutti i badge ottenuti e quelli ancora da sbloccare.
|
||||
|
||||
### Impostazioni
|
||||
|
||||
Contiene.
|
||||
|
||||
- Logout
|
||||
- Preferenze notifiche
|
||||
- Impostazioni applicazione
|
||||
- Segnala un bug e Suggerisci una nuova funzionalità: due link che aprono una issue GitHub
|
||||
già impostata sul template giusto (`.github/ISSUE_TEMPLATE/bug_report.yml` e
|
||||
`feature_request.yml`). Nessun dato passa dall'app — la segnalazione vive interamente su
|
||||
GitHub, così non servono né una tabella né una schermata di gestione.
|
||||
|
||||
## Dashboard amministratore
|
||||
|
||||
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
|
||||
Profilo → Impostazioni).
|
||||
|
||||
Per ogni giocatore vengono mostrati.
|
||||
|
||||
- Stato del profilo
|
||||
- Certificato medico
|
||||
- Documento di identità
|
||||
- Foto tessera
|
||||
- Stato tesseramento CSI (tesserato / da tesserare)
|
||||
|
||||
Azioni disponibili.
|
||||
|
||||
- Visualizza profilo (la scheda si apre in linea nell'elenco: nessuna schermata separata)
|
||||
- Scarica certificato
|
||||
- Scarica documento
|
||||
- Scarica foto tessera
|
||||
- Modifica dati squadra e dati personali del giocatore (DD-017)
|
||||
- Registra numero e data della tessera CSI, una volta arrivata dal comitato
|
||||
- Scollega account, per liberare uno slot assegnato per errore
|
||||
- Aggiungi giocatore, per inserire un nuovo membro della squadra
|
||||
- Disattiva/Riattiva giocatore, per chi lascia la squadra (o rientra)
|
||||
|
||||
## Esportazione CSI
|
||||
|
||||
Gli amministratori possono esportare un file CSV contenente esclusivamente i dati richiesti per il tesseramento.
|
||||
|
||||
Campi esportati.
|
||||
|
||||
- Nome
|
||||
- Cognome
|
||||
- Data di nascita
|
||||
- Luogo di nascita
|
||||
- Indirizzo
|
||||
- Telefono
|
||||
- Email
|
||||
- Tipo documento
|
||||
- Numero documento
|
||||
- Rilasciato da
|
||||
- Data emissione
|
||||
- Data scadenza
|
||||
|
||||
## Tracciamento tesseramento
|
||||
|
||||
Numero e data della tessera CSI non sono dati che il giocatore conosce in anticipo: arrivano
|
||||
dal comitato dopo l'iscrizione effettiva. Per questo, a differenza dei dati personali del
|
||||
profilo, li scrive solo un amministratore — come nome, cognome, numero di maglia e ruolo
|
||||
(DD-017), il trigger sulla tabella li rende non modificabili dal giocatore stesso. La
|
||||
dashboard mostra un badge "Tesserato"/"Da tesserare" su ogni scheda e il conteggio
|
||||
complessivo della squadra.
|
||||
|
||||
## Completamento profilo
|
||||
|
||||
Ogni sezione contribuisce alla percentuale di completamento.
|
||||
|
||||
| Sezione | Peso |
|
||||
| --------------------- | ---- |
|
||||
| Dati personali | 30% |
|
||||
| Documento di identità | 30% |
|
||||
| Certificato medico | 30% |
|
||||
| Foto tessera | 10% |
|
||||
|
||||
Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
|
||||
|
||||
## Permessi
|
||||
|
||||
**Giocatore** — può modificare esclusivamente il proprio profilo.
|
||||
|
||||
**Amministratore** — può visualizzare tutti i profili, scaricare tutti i documenti, esportare i
|
||||
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
|
||||
Non carica file al posto di altri.
|
||||
|
||||
## Versione 1
|
||||
|
||||
- Profilo giocatore
|
||||
- Completamento profilo
|
||||
- Gestione dati personali
|
||||
- Documento di identità
|
||||
- Certificato medico
|
||||
- Foto tessera
|
||||
- Dashboard amministratore
|
||||
- Esportazione CSV CSI
|
||||
|
||||
## Versioni future
|
||||
|
||||
- Storico certificati medici
|
||||
- Gestione documenti aggiuntivi
|
||||
- Consensi privacy
|
||||
- Firma digitale
|
||||
- Verifica automatica documenti
|
||||
@@ -0,0 +1,114 @@
|
||||
# Modulo — Scout Live
|
||||
|
||||
**Stato:** implementato (v1.0, fix M7 per la persistenza condivisa)
|
||||
**File principali:** `src/lib/scout-live.ts`, `src/lib/scout-stato.ts`, `src/lib/scout-store.ts`,
|
||||
`src/lib/scout-export.ts`, `src/lib/cacche.ts`, `src/components/crapp/ScoutEntry.tsx`,
|
||||
`src/components/crapp/SondaggioCacche.tsx`, `src/routes/scout.tsx`, `src/routes/partita.$id.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Permettere a un solo referente per volta di registrare in tempo reale, durante la partita,
|
||||
punti, ace, muri ed errori di ciascun giocatore in campo, con salvataggio condiviso su
|
||||
Supabase (non più solo `localStorage`, fix M7) così che tutta la squadra veda lo stato
|
||||
aggiornato da qualunque dispositivo.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
- `scout_sessioni` — chi ha il controllo dello Scout Live per una partita (una riga per
|
||||
`evento_id`, quindi un solo detentore).
|
||||
- `scout_live` — stato in corso (azioni non ancora concluse) di una sessione.
|
||||
- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni),
|
||||
mai più modificato una volta salvato (solo eliminabile per intero).
|
||||
- `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento
|
||||
(`UNIQUE evento_id, giocatore_id`).
|
||||
|
||||
---
|
||||
|
||||
## Chi può usarlo
|
||||
|
||||
Solo gli admin lato UI: `ScoutEntry.tsx` e `scout.tsx` bloccano i non-admin con il messaggio
|
||||
"Scout riservato". **Il controllo non è imposto a livello database**: le policy RLS di
|
||||
`scout_sessioni`/`scout_live`/`scout_partite` sono aperte a qualunque utente autenticato, non
|
||||
solo agli admin — la migration M4 toglie l'accesso solo al ruolo `anon`.
|
||||
|
||||
---
|
||||
|
||||
## Meccanismo di lock condiviso
|
||||
|
||||
- `useApriSessioneScout()` (`scout-live.ts`) prende il controllo con un upsert su
|
||||
`scout_sessioni` (chiave `evento_id`), rifiutando se un altro giocatore ha già una sessione
|
||||
non scaduta.
|
||||
- Una sessione scade dopo 5 minuti di inattività; `useHeartbeatScout()` la rinnova ogni 60
|
||||
secondi finché lo scout resta aperto.
|
||||
- Il rilascio (`useChiudiSessioneScout()`) avviene al bottone "Rilascia", a fine partita, e
|
||||
sull'evento `pagehide` della finestra (per liberare il lock se il browser viene chiuso senza
|
||||
uscire esplicitamente).
|
||||
- Nessun realtime: la sessione si rilegge solo all'apertura/focus pagina o al bottone
|
||||
"Aggiorna" (`staleTime` 30s).
|
||||
|
||||
---
|
||||
|
||||
## Cosa registra
|
||||
|
||||
Tipi di azione (`AzioneTipo`, `scout-store.ts`): `attacco`, `ace`, `muro`, `errore`,
|
||||
`punto_avv`, `errore_avv` — attacco/ace/muro ed errore avversario valgono come punto nostro,
|
||||
errore nostro e punto avversario come punto avversario. Le azioni con giocatore
|
||||
(attacco/ace/muro/errore) richiedono di selezionarlo prima dalla griglia dei convocati
|
||||
(filtrati sulle risposte "presente"/"ritardo", con fallback a tutta la rosa se nessuno ha
|
||||
risposto). Salvataggio automatico su `scout_live` con debounce di 800ms a ogni cambiamento.
|
||||
|
||||
---
|
||||
|
||||
## Fine partita
|
||||
|
||||
`finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in
|
||||
`scout_partite` (INSERT, non upsert), poi cancella la riga da `scout_live` (stato consumato)
|
||||
e rilascia la sessione.
|
||||
|
||||
---
|
||||
|
||||
## Export CSV
|
||||
|
||||
`scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per
|
||||
giocatore e log cronologico delle azioni. Scaricabile dagli admin dalla pagina partita,
|
||||
sezione "Report tecnico".
|
||||
|
||||
---
|
||||
|
||||
## Sondaggio cacche
|
||||
|
||||
`SondaggioCacche.tsx` chiede "quante cacche hai fatto prima di questa partita" (0-5+), sempre
|
||||
modificabile, senza gating temporale reale (visibile sia prima sia dopo la partita nonostante
|
||||
il nome). `statisticheCacche()` (`cacche.ts`) calcola media, record e `giornateTop`
|
||||
(giornate con ≥3), soglia usata per un [badge](badge.md) segreto — coerente con DD-007 (badge
|
||||
calcolati a runtime).
|
||||
|
||||
---
|
||||
|
||||
## Regole rispettate
|
||||
|
||||
- **DD-008 (gamification equa)**: i dati tecnici (punti/ace/muri) restano confinati allo Scout
|
||||
Live come statistica di squadra e non entrano nel tipo `Giocatore` usato per badge o
|
||||
classifiche individuali.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Controllo "solo admin" non imposto dal database (vedi sopra).
|
||||
- Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock:
|
||||
lettura e upsert non sono atomici.
|
||||
- `scout_partite` si inserisce ma non si corregge dall'interfaccia: solo eliminazione totale.
|
||||
- Abbinamento partita↔scout fatto anche per uguaglianza di data come fallback: ambiguo se due
|
||||
partite cadono lo stesso giorno.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale.
|
||||
- Restringere le policy RLS al solo ruolo admin.
|
||||
@@ -0,0 +1,312 @@
|
||||
# Modulo — Serie di presenze
|
||||
|
||||
**Stato:** implementato — tutte e tre le serie calcolate sui dati reali
|
||||
**File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`,
|
||||
`src/components/crapp/SerieCard.tsx`
|
||||
**Migration collegata:** `m9_risposte_presenze_risposto_il`
|
||||
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Motivare la costanza dei giocatori mostrando "serie" (streak) di comportamenti positivi
|
||||
consecutivi — presenza agli allenamenti, presenza alle partite, risposta entro 24 ore alla
|
||||
convocazione — con traguardi progressivi, sullo stile delle app fitness. È anche uno dei
|
||||
requisiti di sblocco di alcuni [badge](badge.md) e di un [obiettivo di squadra](obiettivi-squadra.md).
|
||||
|
||||
---
|
||||
|
||||
## Le tre serie in sintesi
|
||||
|
||||
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
|
||||
| ------------- | ------------------ | ------------------------------------------------------------ | ------------ |
|
||||
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
|
||||
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
|
||||
| `conferme` | `serieConferme` | Eventi consecutivi con risposta entro 24h dalla convocazione | 3, 8, 15, 20 |
|
||||
|
||||
Esiste un quarto contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle
|
||||
presenze ma **su partite e allenamenti insieme**. Non ha card né traguardi, compare come
|
||||
"presenze consecutive" in `src/routes/index.tsx`, `src/routes/squadra.tsx` e
|
||||
`src/routes/profilo.tsx`.
|
||||
|
||||
Le serie sono **indipendenti**: un buco agli allenamenti non tocca partite e conferme. È la
|
||||
regola scritta in `aggiornaSerie()` e va mantenuta se si aggiungono altre serie.
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
Non esiste una tabella delle serie e non c'è nessun contatore salvato: **le serie sono
|
||||
ricalcolate da zero a ogni render**, partendo dagli eventi e dalle risposte già in cache
|
||||
React Query. Nessuna query aggiuntiva, nessuna migration da rifare quando si cambia una
|
||||
regola, nessun rischio di contatori disallineati dalla realtà.
|
||||
|
||||
Conseguenza pratica: se domani si inseriscono le presenze di eventi passati (import,
|
||||
backfill, correzione a mano), le serie si aggiornano da sole al caricamento successivo.
|
||||
|
||||
### Tabelle lette
|
||||
|
||||
| Tabella | Colonne usate | A cosa servono |
|
||||
| ------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `eventi_app` | `id`, `tipo`, `data`, `convocati`, `creato_il` | Quali impegni contano, in che ordine, e quando è partita la convocazione |
|
||||
| `risposte_presenze` | `evento_id`, `giocatore_id`, `stato`, `risposto_il` | Se l'impegno è stato onorato e quanto in fretta è arrivata la risposta |
|
||||
|
||||
`risposto_il` (migration `m9`) è l'istante della **prima** risposta del giocatore per quell'
|
||||
evento. Un trigger (`risposte_presenze_risposto_il_immutabile`) lo blocca su qualsiasi
|
||||
UPDATE: senza, un giocatore che risponde subito e cambia idea una settimana dopo risulterebbe
|
||||
lento. `aggiornato_il` continua a registrare l'ultima modifica ed è un'altra cosa: non usarlo
|
||||
per le conferme.
|
||||
|
||||
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
|
||||
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta.
|
||||
|
||||
---
|
||||
|
||||
## Flusso completo
|
||||
|
||||
```
|
||||
eventi_app ─┐
|
||||
├─► useEventi() ─┐
|
||||
risposte_ │ (src/lib/eventi.ts) │
|
||||
presenze ─┘ ├─► useRosa() ─► Giocatore.serie* ─┐
|
||||
useRispostePresenze() ─┘ (rosa.ts) │
|
||||
(presenze.ts) │
|
||||
▼
|
||||
serieGiocatore() / serieMigliore()
|
||||
(serie.ts, applica serieDefs)
|
||||
│
|
||||
┌────────────────────────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
SerieGriglia SerieHome badges.ts
|
||||
(profilo) (home) obiettivi.ts
|
||||
```
|
||||
|
||||
Chi calcola cosa:
|
||||
|
||||
- **`src/lib/presenze.ts`** — i tre numeri, dai dati grezzi.
|
||||
- **`src/lib/rosa.ts`** — li attacca a ogni `Giocatore` dentro l'unica `useMemo` di `useRosa()`.
|
||||
- **`src/lib/serie.ts`** — definizioni, traguardi, progresso e microcopy: da un numero a uno stato mostrabile.
|
||||
- **`src/components/crapp/SerieCard.tsx`** — la resa a schermo.
|
||||
|
||||
---
|
||||
|
||||
## Il calcolo (`src/lib/presenze.ts`)
|
||||
|
||||
Tutte le serie passano dalla stessa funzione privata `serieSu()`, che fa quattro cose in
|
||||
ordine:
|
||||
|
||||
1. **Filtra gli eventi rilevanti** con `eventiContanoPresenze()` — la stessa funzione che
|
||||
alimenta il conteggio presenze, così le due statistiche non possono divergere:
|
||||
- solo `tipo` `partita` o `allenamento` (mai `evento` o `compleanno`);
|
||||
- solo eventi a cui il giocatore era convocato. **`convocati` vuoto significa "tutta la
|
||||
rosa"**, non "nessuno": chi non è nell'elenco di una convocazione ristretta non vede
|
||||
quell'evento e la sua serie non si spezza.
|
||||
2. **Scarta il futuro** (`e.data <= oggi`). Gli eventi di oggi contano già: se serve un
|
||||
confronto diverso, il parametro `oggi` è iniettabile (i test lo fissano a una data).
|
||||
3. **Ordina per data crescente** (`localeCompare` su `YYYY-MM-DD`).
|
||||
4. **Riduce** applicando `aggiornaSerie(serie, onorato(e))` a ogni evento: `+1` se onorato,
|
||||
`0` altrimenti. La serie finale è quella che risulta **dopo l'ultimo evento passato**.
|
||||
|
||||
Quel che cambia fra le serie è solo il predicato `onorato`.
|
||||
|
||||
### `serieConsecutiva()` — allenamenti, partite, `streak`
|
||||
|
||||
```ts
|
||||
serieConsecutiva(giocatoreId, eventi, presenze, tipo?, oggi?)
|
||||
```
|
||||
|
||||
Onorato = lo stato salvato è `presente` **o** `ritardo`. Gli stati possibili sono
|
||||
`presente | assente | forse | ritardo | infortunato` (`src/lib/crapp-data.ts`).
|
||||
|
||||
Conseguenze da conoscere prima di cambiare qualcosa:
|
||||
|
||||
- **`infortunato` azzera la serie**, esattamente come `assente`. Coerente con il conteggio
|
||||
presenze, ma è una scelta da rivedere se si vuole "congelare" la serie di chi è fermo per
|
||||
infortunio.
|
||||
- **Nessuna risposta azzera la serie.** Un evento passato per cui il giocatore non ha mai
|
||||
toccato l'app equivale a un'assenza. È voluto (la serie premia anche il rispondere), ma
|
||||
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti.
|
||||
- Senza `tipo` conta partite e allenamenti insieme: è così che si ottiene `streak`.
|
||||
|
||||
### `serieConferme()` — conferme entro 24 ore
|
||||
|
||||
```ts
|
||||
serieConferme(giocatoreId, eventi, tempi, oggi?)
|
||||
```
|
||||
|
||||
Onorato = esiste una risposta **e** `risposto_il − creato_il ≤ 24h` (confronto inclusivo,
|
||||
costante `ORE_24`, entrambi gli istanti passati da `Date.parse`).
|
||||
|
||||
- Conta **partite e allenamenti insieme**, non c'è una versione per tipo.
|
||||
- **Lo stato non conta**: anche un "assente" dato in fretta tiene viva la serie. È una serie
|
||||
sulla reattività, non sulla presenza.
|
||||
- **Gli eventi senza `creatoIl` vengono saltati e non spezzano la serie.** Sono gli eventi
|
||||
costruiti dal client e mai salvati a database — i compleanni di `compleanniEventi()` e la
|
||||
bozza di `eventoVuoto()`. Senza istante di convocazione la domanda "ha risposto in fretta?"
|
||||
non ha risposta, e trattarli come un buco punirebbe il giocatore per un dettaglio tecnico.
|
||||
- **Le 24 ore partono dalla creazione dell'evento**, non da un invio di notifica: oggi un
|
||||
momento di "convocazione mandata" distinto non esiste. Se un domani ci sarà, è quello
|
||||
l'istante giusto da confrontare.
|
||||
|
||||
### Lettura e cache
|
||||
|
||||
`fetchPresenze()` fa **una sola query** e costruisce due mappe:
|
||||
|
||||
```ts
|
||||
presenze: { [eventoId]: { [giocatoreId]: Stato } }
|
||||
tempi: { [eventoId]: { [giocatoreId]: string /* ISO */ } }
|
||||
```
|
||||
|
||||
Entrambe vivono nella stessa entry di React Query (`PRESENZE_KEY`, `staleTime` 5 minuti) e
|
||||
`useRispostePresenze()` le espone come `presenze` e `tempi`.
|
||||
|
||||
`useSalvaPresenza()` non rilegge dopo la scrittura: aggiorna la cache a mano e deve tenere
|
||||
allineate **entrambe** le mappe. Sull'`upsert` la colonna `risposto_il` non viene inviata —
|
||||
è quello che la lascia intatta lato database sugli aggiornamenti — e la cache locale imita
|
||||
la stessa regola con `istanti[giocatoreId] ??= new Date().toISOString()`: si valorizza solo
|
||||
se manca. Chi tocca quella mutation deve preservare questi due dettagli, altrimenti ogni
|
||||
ripensamento farebbe ripartire il cronometro delle conferme.
|
||||
|
||||
---
|
||||
|
||||
## Da numero a card (`src/lib/serie.ts`)
|
||||
|
||||
`serieDefs` è l'unica fonte di verità della UI: label, descrizione, icona, traguardi e la
|
||||
funzione `valore(g)` che pesca il campo giusto dal `Giocatore`.
|
||||
|
||||
`statoSerie(def, g)` produce quello che serve a disegnare una card:
|
||||
|
||||
| Campo | Come si ricava |
|
||||
| ----------- | --------------------------------------------------------------------------- |
|
||||
| `valore` | `def.valore(g)` |
|
||||
| `prossimo` | primo traguardo **strettamente maggiore** del valore; `null` oltre l'ultimo |
|
||||
| `manca` | `prossimo - valore` (`0` se fuori scala) |
|
||||
| `progresso` | percentuale **dentro il livello corrente**, vedi sotto |
|
||||
| `messaggio` | microcopy, vedi sotto |
|
||||
|
||||
### Progresso
|
||||
|
||||
```
|
||||
progresso = round((valore - traguardoPrecedente) / (prossimo - traguardoPrecedente) * 100)
|
||||
```
|
||||
|
||||
La base è il traguardo già raggiunto, non zero. Con la vecchia formula (`valore / prossimo`)
|
||||
la barra **tornava indietro** ogni volta che se ne raggiungeva uno: a 2 allenamenti segnava
|
||||
67%, al terzo scendeva al 50%. Ora ogni traguardo apre un livello nuovo che riparte da 0% e
|
||||
sale fino a 100%, che si tocca solo restando fuori scala (`prossimo === null`).
|
||||
|
||||
Esempio con i traguardi degli allenamenti (3, 6, 10, 15):
|
||||
|
||||
| Valore | Prossimo | Base | Progresso |
|
||||
| ------ | -------- | ---- | --------- |
|
||||
| 0 | 3 | 0 | 0% |
|
||||
| 2 | 3 | 0 | 67% |
|
||||
| 3 | 6 | 3 | 0% |
|
||||
| 5 | 6 | 3 | 67% |
|
||||
| 15+ | — | — | 100% |
|
||||
|
||||
### Messaggi
|
||||
|
||||
`messaggioSerie()` valuta in quest'ordine, prima corrispondenza vince:
|
||||
|
||||
1. `valore === 0` → «Serie … azzerata: riparti dal prossimo.»
|
||||
2. `prossimo === null` → «Serie leggendaria: sei fuori scala!»
|
||||
3. `manca === 1` → «Manca solo una volta al prossimo traguardo!»
|
||||
4. `valore >= 5` → «Che continuità: ancora N e sali di livello.»
|
||||
5. altrimenti → «Bella partenza: N al prossimo traguardo.»
|
||||
|
||||
Nota: il caso 1 scatta anche per chi non ha **mai** iniziato, e dice "azzerata". Se dà
|
||||
fastidio, va distinto lì — il calcolo non sa differenziare "mai partito" da "appena rotto".
|
||||
|
||||
### Aggregatori
|
||||
|
||||
- `serieGiocatore(g)` — tutte le serie nell'ordine di `serieDefs`.
|
||||
- `serieMigliore(g)` — quella col valore più alto. `Array.sort` è stabile, quindi **a parità
|
||||
vince la prima definita in `serieDefs`**: con tutto a zero esce sempre "Allenamenti".
|
||||
|
||||
---
|
||||
|
||||
## Interfaccia (`src/components/crapp/SerieCard.tsx`)
|
||||
|
||||
- **`SerieGriglia`** — montata in `src/routes/profilo.tsx`, sezione "Serie di presenze". Una
|
||||
card per serie: icona (sfondo gradiente se `valore > 0`, grigio se a zero), label,
|
||||
descrizione, fiamma col numero, barra `Barra` e riga di testo `"valore/prossimo · messaggio"`
|
||||
(il prefisso `valore/prossimo` sparisce fuori scala).
|
||||
- **`SerieHome`** — riepilogo compatto: la serie migliore in evidenza più i tre numeri in
|
||||
griglia. Attualmente **non è montata in nessuna route**: è pronta ma non usata.
|
||||
|
||||
---
|
||||
|
||||
## Chi dipende dalle serie
|
||||
|
||||
Toccare la regola di calcolo muove anche questi, che non hanno logica propria:
|
||||
|
||||
| Dove | Cosa | Soglie |
|
||||
| ------------------------------- | -------------------------------------------------------------- | --------------------------- |
|
||||
| `badges.ts` `serie-allenamenti` | "Sempre in palestra", su `serieAllenamenti` | bronzo 3, argento 6, oro 10 |
|
||||
| `badges.ts` `serie-conferme` | "Risposta lampo", su `serieConferme` | bronzo 3, argento 8, oro 15 |
|
||||
| `badges.ts` `s-mai-forfait` | Badge segreto: `serieConferme >= 10` **e** `presenze >= 15` | — |
|
||||
| `obiettivi.ts` `o11` | "Continuità di squadra": giocatori con `serieAllenamenti >= 3` | target 12 |
|
||||
|
||||
---
|
||||
|
||||
## Costo
|
||||
|
||||
`useRosa()` ricalcola quattro serie per ogni giocatore attivo a ogni invalidazione della
|
||||
memo, e ogni serie scorre tutti gli eventi: **O(rosa × eventi)** per render memoizzato. Con
|
||||
una rosa e un calendario di squadra sono numeri irrisori. Le dipendenze della memo includono
|
||||
`eventi`, `mappaPresenze` e `tempi`: se in futuro qualcuna cambiasse identità a ogni render,
|
||||
il costo diventerebbe per-render e andrebbe stabilizzata a monte.
|
||||
|
||||
---
|
||||
|
||||
## Come modificare
|
||||
|
||||
- **Cambiare i traguardi di una serie** → l'array `traguardi` in `serieDefs`. Devono restare
|
||||
crescenti (un test lo verifica) e non serve altro: progresso e messaggi si adeguano.
|
||||
- **Cambiare la regola di presenza** (per esempio non azzerare su `infortunato`) → il
|
||||
predicato dentro `serieConsecutiva()`. Valutare se allineare anche
|
||||
`contaPresenzeGiocatore()`, che oggi usa lo stesso criterio.
|
||||
- **Non azzerare quando manca la risposta** → sempre in quel predicato: distinguere
|
||||
`stato === undefined` e restituire la serie invariata invece di `false`. Richiede di
|
||||
cambiare `serieSu()`, che oggi conosce solo "onorato sì/no".
|
||||
- **Cambiare la finestra delle conferme** → la costante `ORE_24`.
|
||||
- **Contare anche gli eventi extra-campo** (pizzate, `tipo: "evento"`) → il filtro in
|
||||
`eventiContanoPresenze()`, che però è condiviso col conteggio presenze: meglio un filtro
|
||||
dedicato passato a `serieSu()` che modificarlo lì.
|
||||
- **Aggiungere una quarta serie** → una voce in `serieDefs` (label, descrizione, icona,
|
||||
traguardi, `valore`), un campo nel tipo `Giocatore` (`crapp-data.ts`, più lo zero nel seed),
|
||||
il calcolo in `presenze.ts` e il collegamento in `useRosa()`. La UI non va toccata: griglia
|
||||
e home iterano su `serieDefs`.
|
||||
- **Mostrare il riepilogo in home** → `SerieHome` esiste già, basta montarla.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
**Le conferme rapide valgono solo da `m9` in avanti.** `risposto_il` non è ricostruibile a
|
||||
posteriori: le righe già esistenti al momento della migration hanno ereditato `aggiornato_il`,
|
||||
che è l'ultima modifica e non la prima risposta. Sui dati precedenti la serie è quindi
|
||||
un'approssimazione ottimistica.
|
||||
|
||||
**Un evento passato senza risposta azzera la serie**, come un'assenza dichiarata: chi non ha
|
||||
mai risposto ha serie a 0.
|
||||
|
||||
**L'ordinamento usa solo `data`, non `ora`.** Due eventi lo stesso giorno vengono processati
|
||||
nell'ordine in cui arrivano dalla query (`.order("data")`), quindi non deterministico fra
|
||||
loro. Irrilevante finché un buco e una presenza nello stesso giorno danno lo stesso
|
||||
risultato finale, ma va sistemato se un giorno serve l'ordine esatto.
|
||||
|
||||
**Il fuso è quello del client.** `oggi` nasce da `new Date().toISOString()`, cioè UTC: nelle
|
||||
prime ore della giornata italiana un evento di oggi può risultare "non ancora passato".
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Istante di convocazione esplicito (invio notifica) da usare al posto di `creato_il` per le
|
||||
conferme.
|
||||
- Distinguere "serie mai iniziata" da "serie interrotta" nel microcopy.
|
||||
- Verificare che i badge e l'obiettivo "Continuità di squadra" si sblocchino davvero sui dati
|
||||
di stagione.
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: Efficienza Cloud
|
||||
description: Regole per minimizzare query, traffico e invocazioni Cloud (piano 20 crediti/mese, 17 utenti)
|
||||
type: feature
|
||||
---
|
||||
- Nessun polling (`refetchInterval`) verso il database; sincronizzazione locale via BroadcastChannel/storage dove possibile.
|
||||
- QueryClient globale: staleTime 5 min, gcTime 30 min, refetchOnWindowFocus/Mount/Reconnect disattivati, retry 1.
|
||||
- Dopo una mutazione aggiornare la cache con `setQueryData`, non `invalidateQueries` (evita riletture).
|
||||
- Scout live: scrive solo l'utente che segna; gli altri leggono dati già salvati.
|
||||
- Statistiche, badge e classifiche: "write once, read many" — calcolate e salvate una volta a fine partita, mai ricalcolate a ogni apertura pagina.
|
||||
- Dati CSI: sincronizzazione periodica server-side salvata su tabella locale; l'app legge solo dal database interno.
|
||||
- Push solo per eventi importanti: convocazioni, promemoria allenamento/partita, turno palloni, esito finale.
|
||||
- Niente foto/video/chat o funzionalità pesanti.
|
||||
- Schema target: team_id, eventi, presenze, azioni_scout, statistiche_aggregate, classifica_csi, notifiche, con indici sui campi di filtro/relazione.
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
name: Portabilità su Node.js + PostgreSQL
|
||||
description: Vincolo di architettura — l'app deve girare su un normale server Node.js con PostgreSQL, senza servizi esclusivi Lovable Cloud
|
||||
type: constraint
|
||||
---
|
||||
L'app deve restare completamente portabile: ogni funzionalità deve poter girare su un normale server Node.js con PostgreSQL.
|
||||
|
||||
Regole:
|
||||
- Accesso ai dati solo tramite i moduli in `src/lib/*.ts`; i componenti non parlano mai direttamente col database.
|
||||
- Vietato usare funzionalità proprietarie Lovable/Supabase non self-hostable (edge functions proprietarie, auth Lovable come unico login, storage proprietario). `src/integrations/lovable/*` resta opzionale e non importato.
|
||||
- SQL standard PostgreSQL nelle migrazioni; niente estensioni esclusive del provider.
|
||||
- Configurazione solo via variabili d'ambiente standard; niente valori hardcoded.
|
||||
- Job pianificati sempre richiamabili con un semplice HTTP POST, così funzionano con qualsiasi scheduler.
|
||||
- Web push implementato con Web Crypto (compatibile Node 18+), non con SDK proprietari.
|
||||
|
||||
Dettaglio e guida di migrazione: `docs/PORTABILITA.md`.
|
||||
+4
-2
@@ -1,2 +1,4 @@
|
||||
- [Efficienza Cloud](mem://features/cloud-efficienza) — Regole anti-consumo: niente polling, cache lunga, aggregati precalcolati, sync CSI server-side
|
||||
- [Portabilità](mem://features/portabilita) — L'app deve girare su Node.js + PostgreSQL standard, nessun servizio esclusivo Lovable Cloud
|
||||
Le regole di progetto non vivono più qui: sono in `docs/`.
|
||||
|
||||
- Efficienza cloud → `docs/EFFICIENZA_CLOUD.md`
|
||||
- Portabilità → `docs/PORTABILITA.md`
|
||||
|
||||
+5
-1
@@ -9,7 +9,11 @@
|
||||
"build:dev": "vite build --mode development",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"test": "bun test/run.ts",
|
||||
"test:integration": "bun test/run.ts integration",
|
||||
"test:e2e": "bun test/run.ts e2e",
|
||||
"test:all": "bun test/run.ts all"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
|
||||
+1
-1
@@ -43,4 +43,4 @@ self.addEventListener("notificationclick", (event) => {
|
||||
return self.clients.openWindow("/");
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAvatar } from "@/lib/avatar-store";
|
||||
import { urlAvatar } from "@/lib/avatar-store";
|
||||
|
||||
export function Avatar({
|
||||
id,
|
||||
fallback,
|
||||
className,
|
||||
alt,
|
||||
bust,
|
||||
}: {
|
||||
id: string;
|
||||
fallback: string;
|
||||
className?: string;
|
||||
alt?: string;
|
||||
/** Forza il ricaricamento ignorando la cache: usato dopo aver cambiato la propria foto. */
|
||||
bust?: number;
|
||||
}) {
|
||||
const src = useAvatar(id);
|
||||
if (src) {
|
||||
const [errore, setErrore] = useState(false);
|
||||
useEffect(() => setErrore(false), [id, bust]);
|
||||
|
||||
if (!errore) {
|
||||
const src = bust ? `${urlAvatar(id)}?v=${bust}` : urlAvatar(id);
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? `Foto di ${fallback}`}
|
||||
onError={() => setErrore(true)}
|
||||
className={cn("shrink-0 rounded-2xl object-cover", className)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -14,13 +14,7 @@ import {
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
function DettaglioBadge({
|
||||
def,
|
||||
stato,
|
||||
}: {
|
||||
def: BadgeDef;
|
||||
stato?: BadgeStato;
|
||||
}) {
|
||||
function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
||||
const Icon = def.icon;
|
||||
const grado = stato?.grado ?? null;
|
||||
const meta = grado ? gradoMeta[grado] : null;
|
||||
@@ -94,10 +88,20 @@ function DettaglioBadge({
|
||||
raggiunto ? `${gm.bg} ${gm.ring}` : "bg-secondary ring-border",
|
||||
)}
|
||||
>
|
||||
<p className={cn("text-[10px] font-bold uppercase", raggiunto ? gm.text : "text-muted-foreground")}>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[10px] font-bold uppercase",
|
||||
raggiunto ? gm.text : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{gm.label}
|
||||
</p>
|
||||
<p className={cn("font-display text-xl leading-none", raggiunto ? "text-foreground" : "text-muted-foreground")}>
|
||||
<p
|
||||
className={cn(
|
||||
"font-display text-xl leading-none",
|
||||
raggiunto ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{def.soglie[g]}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">{def.unita}</p>
|
||||
@@ -130,7 +134,7 @@ export function BadgeDrawer({
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>{def.nome}</DrawerTitle>
|
||||
<DrawerDescription>{def.descrizione}</DrawerDescription>
|
||||
|
||||
@@ -27,4 +27,4 @@ export function BottomNav() {
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,4 +76,4 @@ export function CelebrazioneBadge() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
prossimoTraguardo,
|
||||
type BadgeStato,
|
||||
} from "@/lib/badges";
|
||||
import { badgeSocialVinti, categorieSocial, type VotoSocial, type CategoriaSocial } from "@/lib/badge-social";
|
||||
import {
|
||||
badgeSocialVinti,
|
||||
categorieSocial,
|
||||
type VotoSocial,
|
||||
type CategoriaSocial,
|
||||
} from "@/lib/badge-social";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { Numero } from "@/components/motion/Numero";
|
||||
@@ -91,7 +96,9 @@ function SocialDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">Vinto</p>
|
||||
<p className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Vinto
|
||||
</p>
|
||||
<p className="mt-1 font-display text-3xl leading-none">
|
||||
{conteggio}{" "}
|
||||
<span className="text-lg text-muted-foreground">
|
||||
@@ -246,4 +253,4 @@ export function CollezioneBadge({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { MapPin, Clock, Users, Cake, ArrowRight } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatData, giocatori, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
|
||||
@@ -36,16 +37,18 @@ export function EventoCard({
|
||||
const { risposte } = usePresenzeEvento(evento.id);
|
||||
const salva = useSalvaPresenza();
|
||||
const io = useGiocatoreCorrente();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const stato = io ? risposte[io.id] : undefined;
|
||||
const presentiVeri = giocatori.filter(
|
||||
const presentiVeri = rosa.filter(
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
).length;
|
||||
const tipo =
|
||||
evento.tipo === "partita" && !evento.campionato
|
||||
? { label: "Amichevole", className: "bg-accent/70 text-accent-foreground" }
|
||||
: tipoMeta[evento.tipo];
|
||||
const totale = giocatori.length;
|
||||
const perc = Math.round((presentiVeri / totale) * 100);
|
||||
const totale = rosa.length;
|
||||
const perc = totale ? Math.round((presentiVeri / totale) * 100) : 0;
|
||||
const isCompleanno = evento.tipo === "compleanno";
|
||||
|
||||
if (isCompleanno) {
|
||||
@@ -118,33 +121,35 @@ export function EventoCard({
|
||||
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-3" />
|
||||
|
||||
{io ? (
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{stati.map((s) => {
|
||||
const meta = statoMeta[s];
|
||||
const attivo = stato === s;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={salva.isPending}
|
||||
onClick={() =>
|
||||
salva.mutate({
|
||||
eventoId: evento.id,
|
||||
giocatoreId: io.id,
|
||||
stato: attivo ? null : s,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full border border-border px-2.5 py-1.5 text-[11px] font-semibold transition-all active:scale-95",
|
||||
attivo ? cn(meta.className, "border-transparent shadow-card") : "bg-background text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{stati.map((s) => {
|
||||
const meta = statoMeta[s];
|
||||
const attivo = stato === s;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={salva.isPending}
|
||||
onClick={() =>
|
||||
salva.mutate({
|
||||
eventoId: evento.id,
|
||||
giocatoreId: io.id,
|
||||
stato: attivo ? null : s,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full border border-border px-2.5 py-1.5 text-[11px] font-semibold transition-all active:scale-95",
|
||||
attivo
|
||||
? cn(meta.className, "border-transparent shadow-card")
|
||||
: "bg-background text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ClipboardCheck, Lock } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import type { Giocatore } from "@/lib/crapp-data";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
|
||||
|
||||
@@ -12,11 +12,11 @@ const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
/** Pagelle di fine partita: voto anonimo 1-10 a ciascun compagno. */
|
||||
export function Pagelle({
|
||||
matchId,
|
||||
convocati = giocatori,
|
||||
convocati,
|
||||
chiuse = false,
|
||||
}: {
|
||||
matchId: string;
|
||||
convocati?: typeof giocatori;
|
||||
convocati: Giocatore[];
|
||||
chiuse?: boolean;
|
||||
}) {
|
||||
const io = useGiocatoreCorrente();
|
||||
@@ -104,7 +104,9 @@ export function Pagelle({
|
||||
onClick={() => invia(g.id, v)}
|
||||
className={cn(
|
||||
"rounded-lg py-1.5 text-[11px] font-bold tabular-nums transition-transform active:scale-90",
|
||||
mio === v ? "bg-accent text-accent-foreground" : "bg-card text-foreground",
|
||||
mio === v
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-card text-foreground",
|
||||
)}
|
||||
>
|
||||
{v}
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Check, Eye, Loader2, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SezioneTendina } from "@/components/crapp/ui-bits";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import {
|
||||
caricaFile,
|
||||
scaricaFile,
|
||||
useProfili,
|
||||
useSalvaProfilo,
|
||||
type SezioneFile,
|
||||
} from "@/lib/profili";
|
||||
import {
|
||||
completamento,
|
||||
profiloVuoto,
|
||||
sezioniComplete,
|
||||
type Profilo,
|
||||
type Sezione,
|
||||
} from "@/lib/profili-core";
|
||||
|
||||
const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"];
|
||||
|
||||
const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm";
|
||||
|
||||
function Campo({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-1 block">{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">{titolo}</h3>
|
||||
{completa ? <Check className="h-4 w-4 text-success" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CampoFile({
|
||||
label,
|
||||
path,
|
||||
sezione,
|
||||
giocatoreId,
|
||||
onCaricato,
|
||||
}: {
|
||||
label: string;
|
||||
path: string | null;
|
||||
sezione: SezioneFile;
|
||||
giocatoreId: string;
|
||||
onCaricato: (path: string) => Promise<void>;
|
||||
}) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [inCorso, setInCorso] = useState(false);
|
||||
|
||||
async function scegli(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setInCorso(true);
|
||||
try {
|
||||
const nuovo = await caricaFile(giocatoreId, sezione, file, path);
|
||||
await onCaricato(nuovo);
|
||||
toast.success(`${label} caricato`);
|
||||
} catch (errore) {
|
||||
toast.error(errore instanceof Error ? errore.message : "Caricamento non riuscito");
|
||||
} finally {
|
||||
setInCorso(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<span className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-6 w-6 shrink-0 place-items-center rounded-lg",
|
||||
path ? "bg-success text-success-foreground" : "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{path ? <Check className="h-3.5 w-3.5" /> : <Upload className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
{path ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void scaricaFile(path)}
|
||||
className="premi rounded-xl bg-secondary p-2 text-muted-foreground"
|
||||
aria-label={`Vedi ${label}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => input.current?.click()}
|
||||
disabled={inCorso}
|
||||
className="premi rounded-xl bg-primary px-3 py-2 text-xs font-bold text-primary-foreground disabled:opacity-60"
|
||||
>
|
||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : path ? "Sostituisci" : "Carica"}
|
||||
</button>
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf"
|
||||
onChange={scegli}
|
||||
className="hidden"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* I campi del profilo, condivisi tra il giocatore e la dashboard amministratore (DD-017).
|
||||
* Gli upload arrivano come slot: l'admin non carica file al posto di altri, quindi da lì
|
||||
* quelle righe semplicemente non compaiono.
|
||||
*/
|
||||
export function CampiProfilo({
|
||||
corrente,
|
||||
aggiorna,
|
||||
sezioni,
|
||||
fileDocumento,
|
||||
fileCertificato,
|
||||
fileFoto,
|
||||
}: {
|
||||
corrente: Profilo;
|
||||
aggiorna: (patch: Partial<Profilo>) => void;
|
||||
sezioni: Record<Sezione, boolean>;
|
||||
fileDocumento?: React.ReactNode;
|
||||
fileCertificato?: React.ReactNode;
|
||||
fileFoto?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Intestazione titolo="Dati personali" completa={sezioni.dati} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Data di nascita">
|
||||
<input
|
||||
type="date"
|
||||
value={corrente.dataNascita ?? ""}
|
||||
onChange={(e) => aggiorna({ dataNascita: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Luogo di nascita">
|
||||
<input
|
||||
value={corrente.luogoNascita ?? ""}
|
||||
maxLength={80}
|
||||
onChange={(e) => aggiorna({ luogoNascita: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<Campo label="Indirizzo di residenza">
|
||||
<input
|
||||
value={corrente.indirizzo ?? ""}
|
||||
maxLength={120}
|
||||
onChange={(e) => aggiorna({ indirizzo: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Telefono">
|
||||
<input
|
||||
type="tel"
|
||||
value={corrente.telefono ?? ""}
|
||||
maxLength={20}
|
||||
onChange={(e) => aggiorna({ telefono: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Email">
|
||||
<input
|
||||
type="email"
|
||||
value={corrente.email ?? ""}
|
||||
maxLength={120}
|
||||
onChange={(e) => aggiorna({ email: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
|
||||
<Intestazione titolo="Documento di identità" completa={sezioni.documento} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Tipo">
|
||||
<select
|
||||
value={corrente.documentoTipo ?? ""}
|
||||
onChange={(e) => aggiorna({ documentoTipo: e.target.value })}
|
||||
className={classiInput}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{TIPI_DOCUMENTO.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Campo>
|
||||
<Campo label="Numero">
|
||||
<input
|
||||
value={corrente.documentoNumero ?? ""}
|
||||
maxLength={40}
|
||||
onChange={(e) => aggiorna({ documentoNumero: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<Campo label="Rilasciato da">
|
||||
<input
|
||||
value={corrente.documentoRilasciatoDa ?? ""}
|
||||
maxLength={80}
|
||||
onChange={(e) => aggiorna({ documentoRilasciatoDa: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Data emissione">
|
||||
<input
|
||||
type="date"
|
||||
value={corrente.documentoEmissione ?? ""}
|
||||
onChange={(e) => aggiorna({ documentoEmissione: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Data scadenza">
|
||||
<input
|
||||
type="date"
|
||||
value={corrente.documentoScadenza ?? ""}
|
||||
onChange={(e) => aggiorna({ documentoScadenza: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
{fileDocumento}
|
||||
|
||||
<Intestazione titolo="Certificato medico" completa={sezioni.certificato} />
|
||||
<Campo label="Data di scadenza">
|
||||
<input
|
||||
type="date"
|
||||
value={corrente.certificatoScadenza ?? ""}
|
||||
onChange={(e) => aggiorna({ certificatoScadenza: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
{fileCertificato}
|
||||
{fileFoto}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dati amministrativi del giocatore: quello che la dashboard amministratore poi legge.
|
||||
* Ogni giocatore scrive solo la propria riga — è la RLS a garantirlo, non questo componente.
|
||||
*/
|
||||
export function ProfiloAmministrativo({
|
||||
giocatoreId,
|
||||
indice = 0,
|
||||
}: {
|
||||
giocatoreId: string;
|
||||
indice?: number;
|
||||
}) {
|
||||
const { profili } = useProfili();
|
||||
const salva = useSalvaProfilo();
|
||||
const [bozza, setBozza] = useState<Profilo | null>(null);
|
||||
|
||||
const salvato = profili[giocatoreId];
|
||||
const corrente = bozza ?? salvato ?? profiloVuoto(giocatoreId);
|
||||
const sporco = bozza !== null;
|
||||
const perc = completamento(corrente);
|
||||
const sezioni = sezioniComplete(corrente);
|
||||
|
||||
function aggiorna(patch: Partial<Profilo>) {
|
||||
setBozza({ ...corrente, ...patch });
|
||||
}
|
||||
|
||||
async function scrivi(profilo: Profilo) {
|
||||
await salva.mutateAsync(profilo);
|
||||
setBozza(null);
|
||||
}
|
||||
|
||||
async function salvaBozza() {
|
||||
try {
|
||||
await scrivi(corrente);
|
||||
toast.success("Profilo aggiornato");
|
||||
} catch (errore) {
|
||||
toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito");
|
||||
}
|
||||
}
|
||||
|
||||
// Un file caricato va persistito subito, insieme a quello che si stava scrivendo.
|
||||
const caricato = (campo: keyof Profilo) => async (path: string) =>
|
||||
scrivi({ ...corrente, [campo]: path });
|
||||
|
||||
return (
|
||||
<SezioneTendina
|
||||
titolo="Dati per il tesseramento"
|
||||
indice={indice}
|
||||
azione={<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>}
|
||||
>
|
||||
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent-grad transition-all"
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro.
|
||||
</p>
|
||||
|
||||
<CampiProfilo
|
||||
corrente={corrente}
|
||||
aggiorna={aggiorna}
|
||||
sezioni={sezioni}
|
||||
fileDocumento={
|
||||
<div className="divide-y divide-border">
|
||||
<CampoFile
|
||||
label="Foto fronte"
|
||||
path={corrente.documentoFrontePath}
|
||||
sezione="documento-fronte"
|
||||
giocatoreId={giocatoreId}
|
||||
onCaricato={caricato("documentoFrontePath")}
|
||||
/>
|
||||
<CampoFile
|
||||
label="Foto retro"
|
||||
path={corrente.documentoRetroPath}
|
||||
sezione="documento-retro"
|
||||
giocatoreId={giocatoreId}
|
||||
onCaricato={caricato("documentoRetroPath")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
fileCertificato={
|
||||
<CampoFile
|
||||
label="Certificato medico"
|
||||
path={corrente.certificatoPath}
|
||||
sezione="certificato"
|
||||
giocatoreId={giocatoreId}
|
||||
onCaricato={caricato("certificatoPath")}
|
||||
/>
|
||||
}
|
||||
fileFoto={
|
||||
<>
|
||||
<Intestazione titolo="Foto tessera" completa={sezioni.foto} />
|
||||
<CampoFile
|
||||
label="Foto tessera"
|
||||
path={corrente.fotoPath}
|
||||
sezione="foto"
|
||||
giocatoreId={giocatoreId}
|
||||
onCaricato={caricato("fotoPath")}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={salvaBozza}
|
||||
disabled={!sporco || salva.isPending}
|
||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{sporco ? "Salva" : "Salvato"}
|
||||
</button>
|
||||
</div>
|
||||
</SezioneTendina>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget di Home: sparisce da solo quando il profilo è completo
|
||||
* (docs/modules/profilo-giocatore.md § Home).
|
||||
*/
|
||||
export function CompletaProfilo({
|
||||
giocatoreId,
|
||||
indice = 0,
|
||||
}: {
|
||||
giocatoreId: string;
|
||||
indice?: number;
|
||||
}) {
|
||||
const { profili, isPending } = useProfili();
|
||||
const perc = completamento(profili[giocatoreId]);
|
||||
if (isPending || perc === 100) return null;
|
||||
|
||||
return (
|
||||
<Reveal indice={indice} className="px-5 pt-4">
|
||||
<Link to="/profilo" className="premi block rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-display text-sm uppercase tracking-wide">
|
||||
Completa il tuo profilo
|
||||
</span>
|
||||
<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent-grad transition-all"
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Documento, certificato medico e foto tessera servono per il tesseramento CSI.
|
||||
</p>
|
||||
</Link>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import {
|
||||
eventiPalloni,
|
||||
eventoPrecedente,
|
||||
eventoSuccessivo,
|
||||
oggiISO,
|
||||
} from "@/lib/palloni-core";
|
||||
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
|
||||
import { useTurniPalloni } from "@/lib/palloni";
|
||||
import { useEventi } from "@/lib/eventi";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
@@ -59,4 +54,4 @@ export function PromemoriaPalloni() {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { giocatori, isAdmin, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useRosa } from "@/lib/rosa";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
|
||||
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
|
||||
|
||||
@@ -14,12 +16,14 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
|
||||
const { risposte, isPending } = usePresenzeEvento(eventoId);
|
||||
const salva = useSalvaPresenza();
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useIsAdmin();
|
||||
const rosa = useRosa();
|
||||
const [sollecito, setSollecito] = useState(false);
|
||||
|
||||
const mancanti = giocatori.filter((g) => !risposte[g.id]);
|
||||
const risposteN = giocatori.length - mancanti.length;
|
||||
const perc = Math.round((risposteN / giocatori.length) * 100);
|
||||
const daSollecitare = mancanti.length + giocatori.filter((g) => risposte[g.id] === "forse").length;
|
||||
const mancanti = rosa.filter((g) => !risposte[g.id]);
|
||||
const risposteN = rosa.length - mancanti.length;
|
||||
const perc = rosa.length ? Math.round((risposteN / rosa.length) * 100) : 0;
|
||||
const daSollecitare = mancanti.length + rosa.filter((g) => risposte[g.id] === "forse").length;
|
||||
|
||||
async function sollecita() {
|
||||
setSollecito(true);
|
||||
@@ -48,7 +52,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
|
||||
<div className="rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-sm font-bold">
|
||||
Hanno risposto {risposteN}/{giocatori.length}
|
||||
Hanno risposto {risposteN}/{rosa.length}
|
||||
</p>
|
||||
<span className="font-display text-xl leading-none">{perc}%</span>
|
||||
</div>
|
||||
@@ -56,7 +60,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{ordine.map((s) => {
|
||||
const n = giocatori.filter((g) => risposte[g.id] === s).length;
|
||||
const n = rosa.filter((g) => risposte[g.id] === s).length;
|
||||
return (
|
||||
<span
|
||||
key={s}
|
||||
@@ -105,7 +109,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{io && isAdmin(io.id) ? (
|
||||
{admin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sollecita}
|
||||
@@ -127,7 +131,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
|
||||
) : null}
|
||||
|
||||
{ordine.map((s) => {
|
||||
const lista = giocatori.filter((g) => risposte[g.id] === s);
|
||||
const lista = rosa.filter((g) => risposte[g.id] === s);
|
||||
if (lista.length === 0) return null;
|
||||
return (
|
||||
<Gruppo
|
||||
@@ -159,7 +163,7 @@ function Gruppo({
|
||||
}: {
|
||||
titolo: string;
|
||||
n: number;
|
||||
lista: typeof giocatori;
|
||||
lista: Giocatore[];
|
||||
attenzione?: boolean;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -3,27 +3,36 @@ import { ChevronRight, Lock, Radio } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sessioneScaduta, usePartitaDiOggi, useSessioneScout } from "@/lib/scout-live";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { isAdmin } from "@/lib/crapp-data";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
|
||||
/** Accesso allo scout live: attivo solo il giorno della partita e se nessun altro lo sta usando. */
|
||||
export function ScoutEntry({ variante = "grande" }: { variante?: "grande" | "compatto" }) {
|
||||
const { pronto, partita } = usePartitaDiOggi();
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useIsAdmin();
|
||||
const { data: sessione } = useSessioneScout(partita?.id ?? null);
|
||||
|
||||
// Strumento tecnico: solo i referenti/allenatori scoutizzano la partita.
|
||||
const abilitato = !!io && isAdmin(io.id);
|
||||
const abilitato = admin;
|
||||
|
||||
const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null;
|
||||
const occupato = !!attiva && attiva.giocatore_id !== io?.id;
|
||||
const disponibile = abilitato && pronto && !!partita && !occupato;
|
||||
|
||||
const titolo = !abilitato ? "Scout live" : !partita ? "Scout live non attivo" : occupato ? "Scout occupato" : "Scout live";
|
||||
const sottotitolo = !abilitato ? "Riservato ad allenatori e referenti" : !partita
|
||||
? "Si attiva il giorno della partita"
|
||||
: occupato
|
||||
? `In uso da ${attiva!.giocatore_nome}`
|
||||
: "Segna punti, ace e muri in tempo reale";
|
||||
const titolo = !abilitato
|
||||
? "Scout live"
|
||||
: !partita
|
||||
? "Scout live non attivo"
|
||||
: occupato
|
||||
? "Scout occupato"
|
||||
: "Scout live";
|
||||
const sottotitolo = !abilitato
|
||||
? "Riservato ad allenatori e referenti"
|
||||
: !partita
|
||||
? "Si attiva il giorno della partita"
|
||||
: occupato
|
||||
? `In uso da ${attiva!.giocatore_nome}`
|
||||
: "Segna punti, ace e muri in tempo reale";
|
||||
|
||||
const contenuto = (
|
||||
<>
|
||||
|
||||
@@ -19,7 +19,9 @@ export function SerieGriglia({ g }: { g: Giocatore }) {
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-10 w-10 shrink-0 place-items-center rounded-2xl",
|
||||
attiva ? "bg-accent-grad text-accent-foreground" : "bg-secondary text-muted-foreground",
|
||||
attiva
|
||||
? "bg-accent-grad text-accent-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
@@ -29,7 +31,9 @@ export function SerieGriglia({ g }: { g: Giocatore }) {
|
||||
<p className="text-[11px] text-muted-foreground">{s.def.descrizione}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 font-display text-2xl leading-none">
|
||||
<Flame className={cn("h-4 w-4", attiva ? "text-accent" : "text-muted-foreground/40")} />
|
||||
<Flame
|
||||
className={cn("h-4 w-4", attiva ? "text-accent" : "text-muted-foreground/40")}
|
||||
/>
|
||||
{s.valore}
|
||||
</span>
|
||||
</div>
|
||||
@@ -74,4 +78,4 @@ export function SerieHome({ g }: { g: Giocatore }) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { mediaPartita, useCacche, useSalvaCacche } from "@/lib/cacche";
|
||||
|
||||
@@ -11,6 +11,7 @@ export function SondaggioCacche({ eventoId }: { eventoId: string }) {
|
||||
const io = useGiocatoreCorrente();
|
||||
const { righe } = useCacche();
|
||||
const salva = useSalvaCacche();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
|
||||
const dellaPartita = righe.filter((r) => r.evento_id === eventoId);
|
||||
const mia = dellaPartita.find((r) => r.giocatore_id === io?.id);
|
||||
@@ -18,7 +19,10 @@ export function SondaggioCacche({ eventoId }: { eventoId: string }) {
|
||||
const classifica = [...dellaPartita]
|
||||
.sort((a, b) => b.quantita - a.quantita)
|
||||
.slice(0, 3)
|
||||
.map((r) => ({ ...r, nome: giocatori.find((g) => g.id === r.giocatore_id)?.nome ?? "—" }));
|
||||
.map((r) => {
|
||||
const g = squadra.find((g) => g.id === r.giocatore_id);
|
||||
return { ...r, nome: g ? nomeCompleto(g) : "—" };
|
||||
});
|
||||
|
||||
async function rispondi(quantita: number) {
|
||||
if (!io) return;
|
||||
@@ -68,7 +72,10 @@ export function SondaggioCacche({ eventoId }: { eventoId: string }) {
|
||||
Media squadra {media}
|
||||
</span>
|
||||
{classifica.map((r, i) => (
|
||||
<span key={r.giocatore_id} className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
|
||||
<span
|
||||
key={r.giocatore_id}
|
||||
className="rounded-full bg-secondary px-2.5 py-1 font-semibold"
|
||||
>
|
||||
{i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉"} {r.nome} · {r.quantita}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Check, CircleDot, Loader2, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
|
||||
@@ -12,10 +12,12 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
const { salvati, turni, isPending } = useTurniPalloni();
|
||||
const assegna = useAssegnaTurno();
|
||||
const io = useGiocatoreCorrente();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
|
||||
const id = turni[eventoId];
|
||||
const proposto = !salvati[eventoId];
|
||||
const giocatore = giocatori.find((g) => g.id === id);
|
||||
const giocatore = rosa.find((g) => g.id === id);
|
||||
|
||||
function scegli(giocatoreId: string) {
|
||||
setAperto(false);
|
||||
@@ -46,7 +48,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
{isPending || assegna.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{giocatore ? giocatore.nome : "Da assegnare"}</span>
|
||||
<span className="truncate">{giocatore ? nomeCompleto(giocatore) : "Da assegnare"}</span>
|
||||
{proposto && giocatore ? (
|
||||
<span className="shrink-0 rounded-full bg-card px-1.5 py-0.5 text-[9px] font-bold uppercase text-muted-foreground">
|
||||
proposto
|
||||
@@ -59,7 +61,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
|
||||
{aperto ? (
|
||||
<div className="mt-2.5 max-h-56 space-y-1 overflow-y-auto rounded-xl bg-card p-1.5">
|
||||
{giocatori.map((g) => (
|
||||
{rosa.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
@@ -70,7 +72,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
)}
|
||||
>
|
||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-7 w-7 text-xs" />
|
||||
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{nomeCompleto(g)}</span>
|
||||
{g.id === id ? <Check className="h-4 w-4 shrink-0 text-accent" /> : null}
|
||||
</button>
|
||||
))}
|
||||
@@ -78,4 +80,4 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { Crown, Vote } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { conteggioPartita, mioVoto, useVotaMvp, useVotiMvp, type VotoMvp } from "@/lib/mvp-voti";
|
||||
|
||||
@@ -11,6 +11,8 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
|
||||
const io = useGiocatoreCorrente();
|
||||
const voti = useVotiMvp();
|
||||
const vota = useVotaMvp();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const [aperto, setAperto] = useState(false);
|
||||
|
||||
const tutti: VotoMvp[] = voti.data ?? [];
|
||||
@@ -69,12 +71,12 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
|
||||
|
||||
{aperto ? (
|
||||
<div className="mt-3 grid max-h-60 grid-cols-2 gap-1.5 overflow-y-auto">
|
||||
{giocatori.map((g) => (
|
||||
{rosa.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
disabled={vota.isPending}
|
||||
onClick={() => invia(g.id, g.nome)}
|
||||
onClick={() => invia(g.id, nomeCompleto(g))}
|
||||
className={cn(
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
||||
mio?.votato_id === g.id
|
||||
@@ -82,7 +84,7 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
|
||||
: "bg-card text-foreground",
|
||||
)}
|
||||
>
|
||||
{g.nome}
|
||||
{nomeCompleto(g)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -97,4 +99,4 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { Check, Crown, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import {
|
||||
categorieSocial,
|
||||
@@ -18,6 +18,8 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
const io = useGiocatoreCorrente();
|
||||
const voti = useVotiSocial();
|
||||
const vota = useVotaSocial();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const [aperta, setAperta] = useState<string | null>(null);
|
||||
|
||||
const tutti = voti.data ?? [];
|
||||
@@ -93,14 +95,14 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
|
||||
{isOpen ? (
|
||||
<div className="grid max-h-56 grid-cols-2 gap-1.5 overflow-y-auto border-t border-border p-3">
|
||||
{giocatori
|
||||
{rosa
|
||||
.filter((g) => g.id !== io?.id)
|
||||
.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
disabled={vota.isPending}
|
||||
onClick={() => invia(cat.id, g.id, g.nome)}
|
||||
onClick={() => invia(cat.id, g.id, nomeCompleto(g))}
|
||||
className={cn(
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
||||
mio?.votato_id === g.id
|
||||
@@ -108,7 +110,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
: "bg-secondary text-foreground",
|
||||
)}
|
||||
>
|
||||
{g.nome}
|
||||
{nomeCompleto(g)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -122,8 +124,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold">
|
||||
<Crown className="h-3.5 w-3.5 text-oro" />
|
||||
<Icon className="h-3.5 w-3.5 text-accent" />
|
||||
{vincitore.nome} · {vincitore.voti}{" "}
|
||||
{vincitore.voti === 1 ? "voto" : "voti"}
|
||||
{vincitore.nome} · {vincitore.voti} {vincitore.voti === 1 ? "voto" : "voti"}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
@@ -137,4 +138,4 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
@@ -52,6 +53,48 @@ export function Section({
|
||||
);
|
||||
}
|
||||
|
||||
/** Sezione con titolo cliccabile: il contenuto si apre e chiude. `anteprima` resta sempre visibile. */
|
||||
export function SezioneTendina({
|
||||
titolo,
|
||||
children,
|
||||
anteprima,
|
||||
azione,
|
||||
defaultAperta = false,
|
||||
indice = 0,
|
||||
}: {
|
||||
titolo: string;
|
||||
children: ReactNode;
|
||||
anteprima?: ReactNode;
|
||||
azione?: ReactNode;
|
||||
defaultAperta?: boolean;
|
||||
indice?: number;
|
||||
}) {
|
||||
const [aperta, setAperta] = useState(defaultAperta);
|
||||
return (
|
||||
<Reveal as="section" indice={indice} className="px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperta((v) => !v)}
|
||||
className="mb-3 flex w-full items-center justify-between gap-3 text-left active:scale-[0.99]"
|
||||
aria-expanded={aperta}
|
||||
>
|
||||
<h2 className="font-display text-lg uppercase tracking-wide">{titolo}</h2>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
{azione}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
aperta && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{anteprima}
|
||||
{aperta ? children : null}
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatoBadge({ stato, className }: { stato: Stato; className?: string }) {
|
||||
const meta = statoMeta[stato];
|
||||
return (
|
||||
@@ -62,7 +105,7 @@ export function StatoBadge({ stato, className }: { stato: Stato; className?: str
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
{meta.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -87,4 +130,4 @@ export function StatTile({
|
||||
{hint ? <p className="mt-0.5 text-[11px] text-accent">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,4 @@ export function Barra({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@ export function Numero({
|
||||
{suffisso}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,4 +26,4 @@ export function Reveal({
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ type SignInOptions = {
|
||||
|
||||
export const lovable = {
|
||||
auth: {
|
||||
signInWithOAuth: async (provider: "google" | "apple" | "microsoft" | "lovable", opts?: SignInOptions) => {
|
||||
signInWithOAuth: async (
|
||||
provider: "google" | "apple" | "microsoft" | "lovable",
|
||||
opts?: SignInOptions,
|
||||
) => {
|
||||
const result = await lovableAuth.signInWithOAuth(provider, {
|
||||
redirect_uri: opts?.redirect_uri ?? window.location.origin,
|
||||
extraParams: {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// This file is automatically generated. Do not edit it directly.
|
||||
import { createMiddleware } from '@tanstack/react-start'
|
||||
import { supabase } from './client'
|
||||
import { createMiddleware } from "@tanstack/react-start";
|
||||
import { supabase } from "./client";
|
||||
|
||||
// Must be registered as a global `functionMiddleware` in `src/start.ts`; otherwise
|
||||
// the browser never attaches the bearer token to serverFn RPCs.
|
||||
export const attachSupabaseAuth = createMiddleware({ type: 'function' }).client(
|
||||
export const attachSupabaseAuth = createMiddleware({ type: "function" }).client(
|
||||
async ({ next }) => {
|
||||
const { data } = await supabase.auth.getSession()
|
||||
const token = data.session?.access_token
|
||||
const { data } = await supabase.auth.getSession();
|
||||
const token = data.session?.access_token;
|
||||
return next({
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
});
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
// This file is automatically generated. Do not edit it directly.
|
||||
import { createMiddleware } from '@tanstack/react-start'
|
||||
import { getRequest } from '@tanstack/react-start/server'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import type { Database } from './types'
|
||||
|
||||
|
||||
import { createMiddleware } from "@tanstack/react-start";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "./types";
|
||||
|
||||
function isNewSupabaseApiKey(value: string): boolean {
|
||||
return value.startsWith('sb_publishable_') || value.startsWith('sb_secret_');
|
||||
return value.startsWith("sb_publishable_") || value.startsWith("sb_secret_");
|
||||
}
|
||||
|
||||
function createSupabaseFetch(supabaseKey: string): typeof fetch {
|
||||
return (input, init) => {
|
||||
const headers = new Headers(
|
||||
typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined,
|
||||
typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined,
|
||||
);
|
||||
|
||||
if (init?.headers) {
|
||||
@@ -21,81 +19,79 @@ function createSupabaseFetch(supabaseKey: string): typeof fetch {
|
||||
}
|
||||
|
||||
// New Supabase API keys are opaque strings, not bearer JWTs.
|
||||
if (isNewSupabaseApiKey(supabaseKey) && headers.get('Authorization') === `Bearer ${supabaseKey}`) {
|
||||
headers.delete('Authorization');
|
||||
if (
|
||||
isNewSupabaseApiKey(supabaseKey) &&
|
||||
headers.get("Authorization") === `Bearer ${supabaseKey}`
|
||||
) {
|
||||
headers.delete("Authorization");
|
||||
}
|
||||
|
||||
headers.set('apikey', supabaseKey);
|
||||
headers.set("apikey", supabaseKey);
|
||||
return fetch(input, { ...init, headers });
|
||||
};
|
||||
}
|
||||
|
||||
export const requireSupabaseAuth = createMiddleware({ type: 'function' }).server(
|
||||
export const requireSupabaseAuth = createMiddleware({ type: "function" }).server(
|
||||
async ({ next }) => {
|
||||
|
||||
const SUPABASE_URL = process.env['SUPABASE_URL'];
|
||||
const SUPABASE_PUBLISHABLE_KEY = process.env['SUPABASE_PUBLISHABLE_KEY'];
|
||||
const SUPABASE_URL = process.env["SUPABASE_URL"];
|
||||
const SUPABASE_PUBLISHABLE_KEY = process.env["SUPABASE_PUBLISHABLE_KEY"];
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
|
||||
const missing = [
|
||||
...(!SUPABASE_URL ? ['SUPABASE_URL'] : []),
|
||||
...(!SUPABASE_PUBLISHABLE_KEY ? ['SUPABASE_PUBLISHABLE_KEY'] : []),
|
||||
...(!SUPABASE_URL ? ["SUPABASE_URL"] : []),
|
||||
...(!SUPABASE_PUBLISHABLE_KEY ? ["SUPABASE_PUBLISHABLE_KEY"] : []),
|
||||
];
|
||||
const message = `Missing Supabase environment variable(s): ${missing.join(', ')}. Connect Supabase in Lovable Cloud.`;
|
||||
const message = `Missing Supabase environment variable(s): ${missing.join(", ")}. Connect Supabase in Lovable Cloud.`;
|
||||
console.error(`[Supabase] ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
|
||||
const request = getRequest();
|
||||
|
||||
if (!request?.headers) {
|
||||
throw new Error('Unauthorized: No request headers available');
|
||||
throw new Error("Unauthorized: No request headers available");
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const authHeader = request.headers.get("authorization");
|
||||
|
||||
if (!authHeader) {
|
||||
throw new Error('Unauthorized: No authorization header provided');
|
||||
throw new Error("Unauthorized: No authorization header provided");
|
||||
}
|
||||
|
||||
if (!authHeader.startsWith('Bearer ')) {
|
||||
throw new Error('Unauthorized: Only Bearer tokens are supported');
|
||||
if (!authHeader.startsWith("Bearer ")) {
|
||||
throw new Error("Unauthorized: Only Bearer tokens are supported");
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
if (!token) {
|
||||
throw new Error('Unauthorized: No token provided');
|
||||
throw new Error("Unauthorized: No token provided");
|
||||
}
|
||||
|
||||
if (token.split('.').length !== 3) {
|
||||
throw new Error('Unauthorized: Invalid token');
|
||||
if (token.split(".").length !== 3) {
|
||||
throw new Error("Unauthorized: Invalid token");
|
||||
}
|
||||
|
||||
const supabase = createClient<Database>(
|
||||
SUPABASE_URL!,
|
||||
SUPABASE_PUBLISHABLE_KEY!,
|
||||
{
|
||||
global: {
|
||||
fetch: createSupabaseFetch(SUPABASE_PUBLISHABLE_KEY!),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
const supabase = createClient<Database>(SUPABASE_URL!, SUPABASE_PUBLISHABLE_KEY!, {
|
||||
global: {
|
||||
fetch: createSupabaseFetch(SUPABASE_PUBLISHABLE_KEY!),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
auth: {
|
||||
storage: undefined,
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
auth: {
|
||||
storage: undefined,
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { data, error } = await supabase.auth.getClaims(token);
|
||||
if (error || !data?.claims) {
|
||||
throw new Error('Unauthorized: Invalid token');
|
||||
throw new Error("Unauthorized: Invalid token");
|
||||
}
|
||||
|
||||
if (!data.claims.sub) {
|
||||
throw new Error('Unauthorized: No user ID found in token');
|
||||
throw new Error("Unauthorized: No user ID found in token");
|
||||
}
|
||||
|
||||
return next({
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { supabase } from "./client";
|
||||
|
||||
/**
|
||||
* `types.ts` è generato dallo schema e non include ancora le tabelle introdotte dalle
|
||||
* migration M1 (`giocatori_squadra`) e M2 (`profili_giocatore`). Finché non viene
|
||||
* rigenerato si passa da qui: i tipi delle righe sono dichiarati nei moduli di `src/lib/`,
|
||||
* che restano l'unico punto di accesso al database (DD-013).
|
||||
*
|
||||
* Da eliminare quando `types.ts` sarà rigenerato: i moduli torneranno a usare `supabase`.
|
||||
*/
|
||||
export const supabaseNuoveTabelle = supabase as unknown as SupabaseClient;
|
||||
@@ -2,17 +2,17 @@
|
||||
// Server-side Supabase client with service role key - bypasses RLS.
|
||||
// Use this for admin operations in server functions and server routes only.
|
||||
// For user-authenticated queries (with RLS), use the auth middleware instead.
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './types';
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "./types";
|
||||
|
||||
function isNewSupabaseApiKey(value: string): boolean {
|
||||
return value.startsWith('sb_publishable_') || value.startsWith('sb_secret_');
|
||||
return value.startsWith("sb_publishable_") || value.startsWith("sb_secret_");
|
||||
}
|
||||
|
||||
function createSupabaseFetch(supabaseKey: string): typeof fetch {
|
||||
return (input, init) => {
|
||||
const headers = new Headers(
|
||||
typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined,
|
||||
typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined,
|
||||
);
|
||||
|
||||
if (init?.headers) {
|
||||
@@ -20,25 +20,28 @@ function createSupabaseFetch(supabaseKey: string): typeof fetch {
|
||||
}
|
||||
|
||||
// New Supabase API keys are opaque strings, not bearer JWTs.
|
||||
if (isNewSupabaseApiKey(supabaseKey) && headers.get('Authorization') === `Bearer ${supabaseKey}`) {
|
||||
headers.delete('Authorization');
|
||||
if (
|
||||
isNewSupabaseApiKey(supabaseKey) &&
|
||||
headers.get("Authorization") === `Bearer ${supabaseKey}`
|
||||
) {
|
||||
headers.delete("Authorization");
|
||||
}
|
||||
|
||||
headers.set('apikey', supabaseKey);
|
||||
headers.set("apikey", supabaseKey);
|
||||
return fetch(input, { ...init, headers });
|
||||
};
|
||||
}
|
||||
|
||||
function createSupabaseAdminClient() {
|
||||
const SUPABASE_URL = process.env['SUPABASE_URL'];
|
||||
const SUPABASE_SERVICE_ROLE_KEY = process.env['SUPABASE_SERVICE_ROLE_KEY'];
|
||||
const SUPABASE_URL = process.env["SUPABASE_URL"];
|
||||
const SUPABASE_SERVICE_ROLE_KEY = process.env["SUPABASE_SERVICE_ROLE_KEY"];
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
|
||||
const missing = [
|
||||
...(!SUPABASE_URL ? ['SUPABASE_URL'] : []),
|
||||
...(!SUPABASE_SERVICE_ROLE_KEY ? ['SUPABASE_SERVICE_ROLE_KEY'] : []),
|
||||
...(!SUPABASE_URL ? ["SUPABASE_URL"] : []),
|
||||
...(!SUPABASE_SERVICE_ROLE_KEY ? ["SUPABASE_SERVICE_ROLE_KEY"] : []),
|
||||
];
|
||||
const message = `Missing Supabase environment variable(s): ${missing.join(', ')}. Connect Supabase in Lovable Cloud.`;
|
||||
const message = `Missing Supabase environment variable(s): ${missing.join(", ")}. Connect Supabase in Lovable Cloud.`;
|
||||
console.error(`[Supabase] ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
@@ -51,7 +54,7 @@ function createSupabaseAdminClient() {
|
||||
storage: undefined,
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// This file is automatically generated. Do not edit it directly.
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './types';
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "./types";
|
||||
|
||||
function isNewSupabaseApiKey(value: string): boolean {
|
||||
return value.startsWith('sb_publishable_') || value.startsWith('sb_secret_');
|
||||
return value.startsWith("sb_publishable_") || value.startsWith("sb_secret_");
|
||||
}
|
||||
|
||||
function createSupabaseFetch(supabaseKey: string): typeof fetch {
|
||||
return (input, init) => {
|
||||
const headers = new Headers(
|
||||
typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined,
|
||||
typeof Request !== "undefined" && input instanceof Request ? input.headers : undefined,
|
||||
);
|
||||
|
||||
if (init?.headers) {
|
||||
@@ -17,28 +17,31 @@ function createSupabaseFetch(supabaseKey: string): typeof fetch {
|
||||
}
|
||||
|
||||
// New Supabase API keys are opaque strings, not bearer JWTs.
|
||||
if (isNewSupabaseApiKey(supabaseKey) && headers.get('Authorization') === `Bearer ${supabaseKey}`) {
|
||||
headers.delete('Authorization');
|
||||
if (
|
||||
isNewSupabaseApiKey(supabaseKey) &&
|
||||
headers.get("Authorization") === `Bearer ${supabaseKey}`
|
||||
) {
|
||||
headers.delete("Authorization");
|
||||
}
|
||||
|
||||
headers.set('apikey', supabaseKey);
|
||||
headers.set("apikey", supabaseKey);
|
||||
return fetch(input, { ...init, headers });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function createSupabaseClient() {
|
||||
// Use import.meta.env for client-side (Vite build-time replacement)
|
||||
// Fall back to process.env for SSR (server-side rendering)
|
||||
const SUPABASE_URL = import.meta.env['VITE_SUPABASE_URL'] || process.env['SUPABASE_URL'];
|
||||
const SUPABASE_PUBLISHABLE_KEY = import.meta.env['VITE_SUPABASE_PUBLISHABLE_KEY'] || process.env['SUPABASE_PUBLISHABLE_KEY'];
|
||||
const SUPABASE_URL = import.meta.env["VITE_SUPABASE_URL"] || process.env["SUPABASE_URL"];
|
||||
const SUPABASE_PUBLISHABLE_KEY =
|
||||
import.meta.env["VITE_SUPABASE_PUBLISHABLE_KEY"] || process.env["SUPABASE_PUBLISHABLE_KEY"];
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
|
||||
const missing = [
|
||||
...(!SUPABASE_URL ? ['SUPABASE_URL'] : []),
|
||||
...(!SUPABASE_PUBLISHABLE_KEY ? ['SUPABASE_PUBLISHABLE_KEY'] : []),
|
||||
...(!SUPABASE_URL ? ["SUPABASE_URL"] : []),
|
||||
...(!SUPABASE_PUBLISHABLE_KEY ? ["SUPABASE_PUBLISHABLE_KEY"] : []),
|
||||
];
|
||||
const message = `Missing Supabase environment variable(s): ${missing.join(', ')}. Connect Supabase in Lovable Cloud.`;
|
||||
const message = `Missing Supabase environment variable(s): ${missing.join(", ")}. Connect Supabase in Lovable Cloud.`;
|
||||
console.error(`[Supabase] ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
@@ -48,10 +51,10 @@ function createSupabaseClient() {
|
||||
fetch: createSupabaseFetch(SUPABASE_PUBLISHABLE_KEY),
|
||||
},
|
||||
auth: {
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
storage: typeof window !== "undefined" ? localStorage : undefined,
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,4 +68,3 @@ export const supabase = new Proxy({} as ReturnType<typeof createSupabaseClient>,
|
||||
return Reflect.get(_supabase, prop, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+611
-443
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Session } from "@supabase/supabase-js";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
|
||||
/**
|
||||
* Autenticazione reale con Google (DD-011). Il login ha sostituito la selezione del
|
||||
* giocatore: senza sessione non si entra, e i permessi di amministrazione arrivano solo
|
||||
* da `user_roles` (vedi `ruoli.ts`).
|
||||
*/
|
||||
export function useSessione() {
|
||||
const [sessione, setSessione] = useState<Session | null>(null);
|
||||
const [pronta, setPronta] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let attivo = true;
|
||||
// Il client Supabase esplode alla costruzione se mancano le variabili d'ambiente:
|
||||
// qui va assorbito, altrimenti la schermata di accesso non si disegna proprio e
|
||||
// resta irraggiungibile anche la selezione del giocatore.
|
||||
try {
|
||||
supabase.auth
|
||||
.getSession()
|
||||
.then(({ data }) => {
|
||||
if (!attivo) return;
|
||||
setSessione(data.session);
|
||||
setPronta(true);
|
||||
})
|
||||
.catch(() => attivo && setPronta(true));
|
||||
const { data } = supabase.auth.onAuthStateChange((_evento, nuova) => setSessione(nuova));
|
||||
return () => {
|
||||
attivo = false;
|
||||
data.subscription.unsubscribe();
|
||||
};
|
||||
} catch (errore) {
|
||||
console.error("[auth] Supabase non disponibile", errore);
|
||||
setPronta(true);
|
||||
return () => {
|
||||
attivo = false;
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sessione,
|
||||
pronta,
|
||||
utenteId: sessione?.user.id ?? null,
|
||||
emailUtente: sessione?.user.email ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function accediConGoogle(): Promise<void> {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: "google",
|
||||
options: { redirectTo: window.location.origin },
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function esci(): Promise<void> {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) throw error;
|
||||
}
|
||||
+47
-46
@@ -1,60 +1,41 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
|
||||
const KEY = "crapp-avatars-v1";
|
||||
const listeners = new Set<() => void>();
|
||||
let cache: Record<string, string> | undefined;
|
||||
const BUCKET = "avatar-giocatori";
|
||||
const NOME_FILE = "avatar.jpg";
|
||||
|
||||
function read(): Record<string, string> {
|
||||
if (cache) return cache;
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
cache = JSON.parse(window.localStorage.getItem(KEY) ?? "{}") as Record<string, string>;
|
||||
} catch {
|
||||
cache = {};
|
||||
}
|
||||
return cache;
|
||||
function percorso(id: string) {
|
||||
return `${id}/${NOME_FILE}`;
|
||||
}
|
||||
|
||||
function write(next: Record<string, string>) {
|
||||
cache = next;
|
||||
try {
|
||||
window.localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* quota o storage non disponibile */
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
/** URL pubblico e stabile: il bucket è pubblico, nessuna richiesta di rete. */
|
||||
export function urlAvatar(id: string): string {
|
||||
return supabase.storage.from(BUCKET).getPublicUrl(percorso(id)).data.publicUrl;
|
||||
}
|
||||
|
||||
const EMPTY: Record<string, string> = {};
|
||||
const chiaveEsiste = (id: string) => ["avatar-esiste", id] as const;
|
||||
|
||||
export function useAvatars(): Record<string, string> {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
/** Solo per il proprio profilo: sapere se mostrare "rimuovi immagine". */
|
||||
export function useAvatarEsiste(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: chiaveEsiste(id ?? ""),
|
||||
enabled: !!id,
|
||||
staleTime: 60_000,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.storage.from(BUCKET).list(id!, { search: NOME_FILE });
|
||||
if (error) throw error;
|
||||
return (data ?? []).some((f) => f.name === NOME_FILE);
|
||||
},
|
||||
() => read(),
|
||||
() => EMPTY,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function useAvatar(id: string | undefined): string | null {
|
||||
const all = useAvatars();
|
||||
return id ? (all[id] ?? null) : null;
|
||||
export function useInvalidaAvatarEsiste() {
|
||||
const qc = useQueryClient();
|
||||
return (id: string) => qc.invalidateQueries({ queryKey: chiaveEsiste(id) });
|
||||
}
|
||||
|
||||
export function rimuoviAvatar(id: string) {
|
||||
const next = { ...read() };
|
||||
delete next[id];
|
||||
write(next);
|
||||
}
|
||||
|
||||
export function salvaAvatar(id: string, dataUrl: string) {
|
||||
write({ ...read(), [id]: dataUrl });
|
||||
}
|
||||
|
||||
/** Ridimensiona e comprime l'immagine scelta per stare in localStorage. */
|
||||
export function fileToAvatar(file: File, size = 256): Promise<string> {
|
||||
/** Ridimensiona e comprime l'immagine scelta in un quadrato JPEG. */
|
||||
function fileToBlob(file: File, size = 256): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error("Lettura file fallita"));
|
||||
@@ -79,10 +60,30 @@ export function fileToAvatar(file: File, size = 256): Promise<string> {
|
||||
size,
|
||||
size,
|
||||
);
|
||||
resolve(canvas.toDataURL("image/jpeg", 0.82));
|
||||
canvas.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error("Conversione fallita"))),
|
||||
"image/jpeg",
|
||||
0.82,
|
||||
);
|
||||
};
|
||||
img.src = reader.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** Ridimensiona, comprime e carica la foto profilo: sovrascrive quella precedente. */
|
||||
export async function caricaAvatar(id: string, file: File) {
|
||||
const blob = await fileToBlob(file);
|
||||
const { error } = await supabase.storage.from(BUCKET).upload(percorso(id), blob, {
|
||||
contentType: "image/jpeg",
|
||||
upsert: true,
|
||||
cacheControl: "0",
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function rimuoviAvatar(id: string) {
|
||||
const { error } = await supabase.storage.from(BUCKET).remove([percorso(id)]);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
@@ -139,8 +139,7 @@ export function mioVotoSocial(
|
||||
) {
|
||||
return (
|
||||
voti.find(
|
||||
(v) =>
|
||||
v.match_id === matchId && v.categoria === categoria && v.votante_id === votanteId,
|
||||
(v) => v.match_id === matchId && v.categoria === categoria && v.votante_id === votanteId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
@@ -156,4 +155,4 @@ export function badgeSocialVinti(voti: VotoSocial[], giocatoreId: string) {
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
+14
-11
@@ -19,10 +19,7 @@ export type Grado = "bronzo" | "argento" | "oro";
|
||||
|
||||
export const gradiOrdine: Grado[] = ["bronzo", "argento", "oro"];
|
||||
|
||||
export const gradoMeta: Record<
|
||||
Grado,
|
||||
{ label: string; text: string; bg: string; ring: string }
|
||||
> = {
|
||||
export const gradoMeta: Record<Grado, { label: string; text: string; bg: string; ring: string }> = {
|
||||
bronzo: { label: "Bronzo", text: "text-bronzo", bg: "bg-bronzo/15", ring: "ring-bronzo/40" },
|
||||
argento: { label: "Argento", text: "text-argento", bg: "bg-argento/20", ring: "ring-argento/50" },
|
||||
oro: { label: "Oro", text: "text-oro", bg: "bg-oro/20", ring: "ring-oro/50" },
|
||||
@@ -50,7 +47,8 @@ export const badgeDefs: BadgeDef[] = [
|
||||
{
|
||||
id: "mvp",
|
||||
nome: "MVP",
|
||||
descrizione: "Riconoscimento per il miglior giocatore della partita, scelto dai compagni a fine match.",
|
||||
descrizione:
|
||||
"Riconoscimento per il miglior giocatore della partita, scelto dai compagni a fine match.",
|
||||
unita: "MVP",
|
||||
icon: Trophy,
|
||||
soglie: { bronzo: 1, argento: 3, oro: 5 },
|
||||
@@ -59,7 +57,8 @@ export const badgeDefs: BadgeDef[] = [
|
||||
{
|
||||
id: "pagella",
|
||||
nome: "Pagellone",
|
||||
descrizione: "Media dei voti che i compagni ti danno a fine partita: conta come giochi, non quanti punti fai.",
|
||||
descrizione:
|
||||
"Media dei voti che i compagni ti danno a fine partita: conta come giochi, non quanti punti fai.",
|
||||
unita: "di media voto",
|
||||
icon: ClipboardCheck,
|
||||
soglie: { bronzo: 6.5, argento: 7.5, oro: 8.5 },
|
||||
@@ -68,7 +67,8 @@ export const badgeDefs: BadgeDef[] = [
|
||||
{
|
||||
id: "palloni",
|
||||
nome: "Sherpa dei palloni",
|
||||
descrizione: "Quante volte ti sei caricato la sacca dei palloni: lavoro oscuro, badge luminoso.",
|
||||
descrizione:
|
||||
"Quante volte ti sei caricato la sacca dei palloni: lavoro oscuro, badge luminoso.",
|
||||
unita: "turni palloni",
|
||||
icon: CircleDot,
|
||||
soglie: { bronzo: 3, argento: 6, oro: 10 },
|
||||
@@ -86,7 +86,8 @@ export const badgeDefs: BadgeDef[] = [
|
||||
{
|
||||
id: "serie-allenamenti",
|
||||
nome: "Sempre in palestra",
|
||||
descrizione: "Allenamenti consecutivi a cui sei stato presente: la costanza paga più del talento.",
|
||||
descrizione:
|
||||
"Allenamenti consecutivi a cui sei stato presente: la costanza paga più del talento.",
|
||||
unita: "allenamenti di fila",
|
||||
icon: Rocket,
|
||||
soglie: { bronzo: 3, argento: 6, oro: 10 },
|
||||
@@ -108,7 +109,8 @@ export const badgeSegreti: BadgeDef[] = [
|
||||
{
|
||||
id: "s-tiebreak",
|
||||
nome: "Uomo tie-break",
|
||||
descrizione: "Sbloccato da chi ha almeno 2 MVP e una media voto alta: nei momenti caldi ci sei sempre.",
|
||||
descrizione:
|
||||
"Sbloccato da chi ha almeno 2 MVP e una media voto alta: nei momenti caldi ci sei sempre.",
|
||||
unita: "MVP con media alta",
|
||||
icon: Ghost,
|
||||
segreto: true,
|
||||
@@ -119,7 +121,8 @@ export const badgeSegreti: BadgeDef[] = [
|
||||
{
|
||||
id: "s-mai-forfait",
|
||||
nome: "Mai un forfait",
|
||||
descrizione: "Sbloccato con 10 conferme rapide consecutive e 15 presenze: su di te la squadra può contare a occhi chiusi.",
|
||||
descrizione:
|
||||
"Sbloccato con 10 conferme rapide consecutive e 15 presenze: su di te la squadra può contare a occhi chiusi.",
|
||||
unita: "requisito nascosto",
|
||||
icon: Anchor,
|
||||
segreto: true,
|
||||
@@ -268,4 +271,4 @@ export function badgeSbloccati(g: Giocatore): BadgeStato[] {
|
||||
|
||||
export function descrizioneSoglie(def: BadgeDef) {
|
||||
return `${def.soglie.bronzo}/${def.soglie.argento}/${def.soglie.oro} ${def.unita}`;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -63,7 +63,13 @@ function arrotonda(n: number) {
|
||||
export function statisticheCacche(righe: RigaCacche[]): Record<string, StatCacche> {
|
||||
const out: Record<string, StatCacche> = {};
|
||||
for (const r of righe) {
|
||||
const cur = out[r.giocatore_id] ?? { totale: 0, giornate: 0, media: 0, record: 0, giornateTop: 0 };
|
||||
const cur = out[r.giocatore_id] ?? {
|
||||
totale: 0,
|
||||
giornate: 0,
|
||||
media: 0,
|
||||
record: 0,
|
||||
giornateTop: 0,
|
||||
};
|
||||
cur.totale += r.quantita;
|
||||
cur.giornate += 1;
|
||||
cur.record = Math.max(cur.record, r.quantita);
|
||||
|
||||
+46
-84
@@ -2,10 +2,18 @@ export type Stato = "presente" | "assente" | "forse" | "ritardo" | "infortunato"
|
||||
|
||||
export const statoMeta: Record<Stato, { label: string; emoji: string; className: string }> = {
|
||||
presente: { label: "Presente", emoji: "✅", className: "bg-success text-success-foreground" },
|
||||
assente: { label: "Assente", emoji: "❌", className: "bg-destructive text-destructive-foreground" },
|
||||
assente: {
|
||||
label: "Assente",
|
||||
emoji: "❌",
|
||||
className: "bg-destructive text-destructive-foreground",
|
||||
},
|
||||
forse: { label: "Forse", emoji: "🤔", className: "bg-warning text-warning-foreground" },
|
||||
ritardo: { label: "In ritardo", emoji: "⏱️", className: "bg-info text-info-foreground" },
|
||||
infortunato: { label: "Infortunato", emoji: "🩹", className: "bg-primary text-primary-foreground" },
|
||||
infortunato: {
|
||||
label: "Infortunato",
|
||||
emoji: "🩹",
|
||||
className: "bg-primary text-primary-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -74,74 +82,43 @@ function inizialiDa(nome: string) {
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
type StatsDemo = Pick<Giocatore, "presenze" | "streak" | "mvp" | "mediaVoto">;
|
||||
|
||||
/** Statistiche demo: mix di badge bronzo / argento / oro già sbloccati. */
|
||||
const statsDemo: Record<string, StatsDemo> = {
|
||||
"Ivan Cacciari": { presenze: 31, streak: 8, mvp: 5, mediaVoto: 8.7 },
|
||||
"Davide Grilli": { presenze: 27, streak: 5, mvp: 3, mediaVoto: 8.4 },
|
||||
"Nicola Pezzoli": { presenze: 24, streak: 4, mvp: 2, mediaVoto: 8.2 },
|
||||
"Laura Passabì": { presenze: 22, streak: 6, mvp: 3, mediaVoto: 8.1 },
|
||||
"Francesca Tucci": { presenze: 19, streak: 3, mvp: 1, mediaVoto: 7.9 },
|
||||
"Iacopo Ricci": { presenze: 21, streak: 2, mvp: 3, mediaVoto: 7.8 },
|
||||
"Alessandra Brunacci": { presenze: 14, streak: 3, mvp: 1, mediaVoto: 7.5 },
|
||||
"Mattias Bologna": { presenze: 12, streak: 2, mvp: 1, mediaVoto: 7.4 },
|
||||
"Giada Valbonesi": { presenze: 13, streak: 4, mvp: 1, mediaVoto: 7.6 },
|
||||
"Alessio Cocco": { presenze: 11, streak: 1, mvp: 0, mediaVoto: 7.2 },
|
||||
"Mattia Catalano": { presenze: 9, streak: 2, mvp: 0, mediaVoto: 7.0 },
|
||||
"Antonella Loverre": { presenze: 8, streak: 1, mvp: 0, mediaVoto: 6.9 },
|
||||
"Carlo Di Castelnuovo": { presenze: 7, streak: 1, mvp: 0, mediaVoto: 6.8 },
|
||||
"Camilla Esposito": { presenze: 6, streak: 2, mvp: 0, mediaVoto: 6.7 },
|
||||
"Salvador Battistella": { presenze: 20, streak: 5, mvp: 1, mediaVoto: 7.7 },
|
||||
"Silvia Chilese": { presenze: 16, streak: 3, mvp: 0, mediaVoto: 7.3 },
|
||||
"Cristina Titone": { presenze: 15, streak: 2, mvp: 0, mediaVoto: 7.1 },
|
||||
};
|
||||
|
||||
/** Serie demo derivate dalle statistiche: ogni tipo ha il suo contatore. */
|
||||
function serieDa(s?: StatsDemo) {
|
||||
const streak = s?.streak ?? 0;
|
||||
const presenze = s?.presenze ?? 0;
|
||||
return {
|
||||
serieAllenamenti: streak,
|
||||
seriePartite: Math.ceil(streak / 2),
|
||||
serieConferme: presenze >= 20 ? 12 : presenze >= 14 ? 8 : presenze >= 8 ? 4 : 1,
|
||||
};
|
||||
export function dividiNome(completo: string): { nome: string; cognome: string } {
|
||||
const spazio = completo.indexOf(" ");
|
||||
if (spazio < 0) return { nome: completo, cognome: "" };
|
||||
return { nome: completo.slice(0, spazio), cognome: completo.slice(spazio + 1) };
|
||||
}
|
||||
|
||||
export const giocatori: Giocatore[] = rosaCSI.map((r, i) => ({
|
||||
id: `g${i + 1}`,
|
||||
nome: r.nome,
|
||||
numero: r.numero ?? 0,
|
||||
ruolo: r.ruolo,
|
||||
nascita: r.nascita,
|
||||
iniziali: inizialiDa(r.nome),
|
||||
totaliEventi: 32,
|
||||
infortuni: 0,
|
||||
ritardi: 0,
|
||||
palloni: 0,
|
||||
cacche: 0,
|
||||
cacchePartita: 0,
|
||||
...(statsDemo[r.nome] ?? { presenze: 0, streak: 0, mvp: 0, mediaVoto: 0 }),
|
||||
...serieDa(statsDemo[r.nome]),
|
||||
}));
|
||||
export const giocatori: Giocatore[] = rosaCSI
|
||||
.map((r, i) => ({
|
||||
id: `g${i + 1}`,
|
||||
nome: r.nome,
|
||||
numero: r.numero ?? 0,
|
||||
ruolo: r.ruolo,
|
||||
nascita: r.nascita,
|
||||
iniziali: inizialiDa(r.nome),
|
||||
presenze: 0,
|
||||
totaliEventi: 0,
|
||||
streak: 0,
|
||||
serieAllenamenti: 0,
|
||||
seriePartite: 0,
|
||||
serieConferme: 0,
|
||||
mvp: 0,
|
||||
mediaVoto: 0,
|
||||
infortuni: 0,
|
||||
ritardi: 0,
|
||||
palloni: 0,
|
||||
cacche: 0,
|
||||
cacchePartita: 0,
|
||||
}))
|
||||
.sort((a, b) => dividiNome(a.nome).cognome.localeCompare(dividiNome(b.nome).cognome, "it"));
|
||||
|
||||
export type Match = {
|
||||
id: string;
|
||||
data: string;
|
||||
avversario: string;
|
||||
casa: boolean;
|
||||
setNostri: number;
|
||||
setLoro: number;
|
||||
parziali: Array<[number, number]>;
|
||||
mvp: string;
|
||||
};
|
||||
|
||||
export const storicoMatch: Match[] = [
|
||||
{ id: "m1", data: "2026-07-25", avversario: "Pallavolo Sesto", casa: true, setNostri: 3, setLoro: 1, parziali: [[25, 19], [23, 25], [25, 21], [25, 18]], mvp: "Davide Grilli" },
|
||||
{ id: "m2", data: "2026-07-18", avversario: "ASD Rondinella", casa: false, setNostri: 2, setLoro: 3, parziali: [[25, 22], [19, 25], [25, 23], [20, 25], [12, 15]], mvp: "Ivan Cacciari" },
|
||||
{ id: "m3", data: "2026-07-11", avversario: "Virtus Cinisello", casa: true, setNostri: 3, setLoro: 0, parziali: [[25, 15], [25, 20], [25, 17]], mvp: "Laura Passabì" },
|
||||
{ id: "m4", data: "2026-07-04", avversario: "Nuova Bovisa", casa: false, setNostri: 3, setLoro: 2, parziali: [[21, 25], [25, 23], [18, 25], [25, 20], [15, 11]], mvp: "Nicola Pezzoli" },
|
||||
];
|
||||
/**
|
||||
* Fallback per la data di nascita: `giocatori_squadra` non ha ancora questa colonna
|
||||
* (DD-015 follow-up). Un giocatore aggiunto dopo la migrazione non ha nascita nota.
|
||||
*/
|
||||
export const nascitaPerId: Record<string, string> = Object.fromEntries(
|
||||
giocatori.map((g) => [g.id, g.nascita]),
|
||||
);
|
||||
|
||||
export type RigaClassifica = {
|
||||
pos: number;
|
||||
@@ -154,25 +131,10 @@ export type RigaClassifica = {
|
||||
punti: number;
|
||||
};
|
||||
|
||||
export const classifica: RigaClassifica[] = [
|
||||
{ pos: 1, squadra: "ASD Rondinella", giocate: 12, vinte: 10, perse: 2, setFatti: 33, setSubiti: 12, punti: 29 },
|
||||
{ pos: 2, squadra: "CRAP Volley", giocate: 12, vinte: 9, perse: 3, setFatti: 31, setSubiti: 16, punti: 26 },
|
||||
{ pos: 3, squadra: "Pallavolo Sesto", giocate: 12, vinte: 8, perse: 4, setFatti: 29, setSubiti: 19, punti: 24 },
|
||||
{ pos: 4, squadra: "Volley Bruzzano", giocate: 12, vinte: 7, perse: 5, setFatti: 26, setSubiti: 21, punti: 21 },
|
||||
{ pos: 5, squadra: "Aurora Nera", giocate: 12, vinte: 5, perse: 7, setFatti: 22, setSubiti: 25, punti: 16 },
|
||||
{ pos: 6, squadra: "Virtus Cinisello", giocate: 12, vinte: 3, perse: 9, setFatti: 15, setSubiti: 30, punti: 10 },
|
||||
{ pos: 7, squadra: "Nuova Bovisa", giocate: 12, vinte: 2, perse: 10, setFatti: 13, setSubiti: 32, punti: 7 },
|
||||
];
|
||||
|
||||
export function formatData(iso: string) {
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
return d.toLocaleDateString("it-IT", { weekday: "short", day: "2-digit", month: "long" });
|
||||
}
|
||||
|
||||
/** Referenti che possono gestire eventi e sollecitare le risposte. */
|
||||
export const adminNomi = ["Ivan Cacciari", "Iacopo Ricci", "Cristina Titone"];
|
||||
|
||||
export function isAdmin(giocatoreId: string) {
|
||||
const g = giocatori.find((x) => x.id === giocatoreId);
|
||||
return Boolean(g && adminNomi.includes(g.nome));
|
||||
}
|
||||
/* I permessi di amministrazione stanno in `user_roles` (DD-011), non in una lista di nomi:
|
||||
vedi `src/lib/ruoli.ts`. */
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { RigaClassifica } from "./crapp-data";
|
||||
|
||||
/**
|
||||
* Lettura dei dati ufficiali dal portale Livescore CSI Bologna.
|
||||
* Non esiste un'API documentata: usiamo gli stessi endpoint che il sito chiama
|
||||
* via ajax. Nessuna autenticazione, ma nessuna garanzia di stabilità: ogni
|
||||
* funzione qui deve fallire in modo pulito (array vuoto), mai lanciare.
|
||||
*/
|
||||
|
||||
export const CSI_BASE = "https://livescore.csibologna.it";
|
||||
/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */
|
||||
export const CSI_PROJECT_ID = 767;
|
||||
/** C.R.A.P. Volley sul portale CSI. */
|
||||
export const CSI_TEAM_ID = 3359;
|
||||
export const CSI_GIRONE = "Girone B";
|
||||
export const CSI_NOME_SQUADRA = "C.R.A.P. Volley";
|
||||
|
||||
export const urlClassifica = (projectId = CSI_PROJECT_ID) =>
|
||||
`${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`;
|
||||
export const urlPartite = (teamId = CSI_TEAM_ID) =>
|
||||
`${CSI_BASE}/assets/json/getEventsByTeamId.php?team_id=${teamId}`;
|
||||
|
||||
export type PartitaCsi = {
|
||||
id: string;
|
||||
data: string;
|
||||
ora: string;
|
||||
avversario: string;
|
||||
casa: boolean;
|
||||
/** null finché la gara non è stata giocata. */
|
||||
setNostri: number | null;
|
||||
setLoro: number | null;
|
||||
parziali: Array<[number, number]>;
|
||||
campo: string;
|
||||
competizione: string;
|
||||
};
|
||||
|
||||
export type DatiCsi = {
|
||||
classifica: RigaClassifica[];
|
||||
partite: PartitaCsi[];
|
||||
girone: string;
|
||||
aggiornato: string;
|
||||
};
|
||||
|
||||
/** "C.R.A.P. Volley" e "CRAP Volley" devono confrontarsi uguali. */
|
||||
function normalizza(nome: string): string {
|
||||
return nome.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
export function isNostraSquadra(nome: string): boolean {
|
||||
return normalizza(nome) === normalizza(CSI_NOME_SQUADRA);
|
||||
}
|
||||
|
||||
const entita: Record<string, string> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
nbsp: " ",
|
||||
deg: "°",
|
||||
apos: "'",
|
||||
};
|
||||
|
||||
function testo(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/&(#\d+|[a-z]+);/gi, (intero, codice: string) =>
|
||||
codice.startsWith("#")
|
||||
? String.fromCharCode(Number(codice.slice(1)))
|
||||
: (entita[codice.toLowerCase()] ?? intero),
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Estrae la classifica dal frammento HTML di `project-sheets.php`.
|
||||
* Il campionato ha due gironi: prendiamo la tabella che contiene la nostra
|
||||
* squadra. Colonne (indice del `<td>`): 0 Pos · 1 Squadra · 2 Punti ·
|
||||
* 3 Giocate · 4 Vinte · 5 Perse · 8 Set fatti · 9 Set subiti.
|
||||
*/
|
||||
export function parseClassifica(html: string): RigaClassifica[] {
|
||||
const tabelle = html.match(/<table[\s\S]*?<\/table>/gi) ?? [];
|
||||
const nostra = tabelle.find((t) => normalizza(testo(t)).includes(normalizza(CSI_NOME_SQUADRA)));
|
||||
if (!nostra) return [];
|
||||
|
||||
const righe: RigaClassifica[] = [];
|
||||
for (const riga of nostra.match(/<tr[\s\S]*?<\/tr>/gi) ?? []) {
|
||||
const celle = (riga.match(/<td[\s\S]*?<\/td>/gi) ?? []).map(testo);
|
||||
if (celle.length < 10) continue;
|
||||
const pos = Number(celle[0]);
|
||||
if (!Number.isFinite(pos) || pos === 0 || !celle[1]) continue;
|
||||
righe.push({
|
||||
pos,
|
||||
squadra: celle[1],
|
||||
punti: Number(celle[2]) || 0,
|
||||
giocate: Number(celle[3]) || 0,
|
||||
vinte: Number(celle[4]) || 0,
|
||||
perse: Number(celle[5]) || 0,
|
||||
setFatti: Number(celle[8]) || 0,
|
||||
setSubiti: Number(celle[9]) || 0,
|
||||
});
|
||||
}
|
||||
return righe;
|
||||
}
|
||||
|
||||
type EventoCsi = {
|
||||
id?: number | string;
|
||||
start?: string;
|
||||
team1?: string;
|
||||
team2?: string;
|
||||
result?: string;
|
||||
partials?: string;
|
||||
field?: string;
|
||||
project?: string;
|
||||
};
|
||||
|
||||
function punteggio(result: string | undefined): [number, number] | null {
|
||||
const m = /(\d+)\s*-\s*(\d+)/.exec(result ?? "");
|
||||
return m ? [Number(m[1]), Number(m[2])] : null;
|
||||
}
|
||||
|
||||
function parziali(partials: string | undefined): Array<[number, number]> {
|
||||
return [...(partials ?? "").matchAll(/(\d+)\s*-\s*(\d+)/g)].map((m) => [
|
||||
Number(m[1]),
|
||||
Number(m[2]),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Converte gli eventi di `getEventsByTeamId.php` nel formato usato dall'app. */
|
||||
export function partiteDaEventi(eventi: unknown): PartitaCsi[] {
|
||||
if (!Array.isArray(eventi)) return [];
|
||||
const partite: PartitaCsi[] = [];
|
||||
for (const evento of eventi as EventoCsi[]) {
|
||||
const team1 = testo(evento.team1 ?? "");
|
||||
const team2 = testo(evento.team2 ?? "");
|
||||
const casa = isNostraSquadra(team1);
|
||||
if (!casa && !isNostraSquadra(team2)) continue;
|
||||
|
||||
const [dataIso, oraIso] = (evento.start ?? "").split("T");
|
||||
if (!dataIso) continue;
|
||||
|
||||
const set = punteggio(evento.result);
|
||||
const tutti = parziali(evento.partials);
|
||||
partite.push({
|
||||
id: String(evento.id ?? `${dataIso}-${team1}-${team2}`),
|
||||
data: dataIso,
|
||||
ora: (oraIso ?? "").slice(0, 5),
|
||||
avversario: casa ? team2 : team1,
|
||||
casa,
|
||||
setNostri: set ? (casa ? set[0] : set[1]) : null,
|
||||
setLoro: set ? (casa ? set[1] : set[0]) : null,
|
||||
parziali: casa ? tutti : tutti.map(([a, b]) => [b, a] as [number, number]),
|
||||
campo: testo(evento.field ?? ""),
|
||||
competizione: testo(evento.project ?? ""),
|
||||
});
|
||||
}
|
||||
return partite.sort((a, b) => b.data.localeCompare(a.data));
|
||||
}
|
||||
|
||||
/** Solo le gare già giocate, dalla più recente. */
|
||||
export function partiteGiocate(partite: PartitaCsi[]): PartitaCsi[] {
|
||||
return partite.filter((p) => p.setNostri !== null && p.setLoro !== null);
|
||||
}
|
||||
|
||||
/** Converte una gara CSI già giocata nella forma comune usata nelle liste risultati. */
|
||||
export function matchDaPartitaCsi(p: PartitaCsi) {
|
||||
return {
|
||||
id: p.id,
|
||||
data: p.data,
|
||||
avversario: p.avversario,
|
||||
casa: p.casa,
|
||||
setNostri: p.setNostri ?? 0,
|
||||
setLoro: p.setLoro ?? 0,
|
||||
parziali: p.parziali,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { DatiCsi } from "./csi-core";
|
||||
|
||||
export const CSI_KEY = ["csi"] as const;
|
||||
|
||||
/**
|
||||
* Classifica e risultati ufficiali CSI. Il server tiene una cache di 6 ore:
|
||||
* qui basta una lettura per sessione.
|
||||
*/
|
||||
export function useCsi() {
|
||||
return useQuery<DatiCsi>({
|
||||
queryKey: CSI_KEY,
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/public/csi");
|
||||
if (!res.ok) throw new Error("CSI non disponibile");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 6 * 60 * 60_000,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
+13
-7
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { giocatori } from "./crapp-data";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
|
||||
export type EventoTipo = "partita" | "allenamento" | "evento" | "compleanno";
|
||||
|
||||
@@ -20,6 +20,9 @@ export type Evento = {
|
||||
casa: boolean;
|
||||
/** Le pagelle di questa partita non accettano più voti. */
|
||||
pagelleChiuse: boolean;
|
||||
/** Quando l'evento è stato creato: è l'istante della convocazione.
|
||||
* Assente sugli eventi generati dal client (compleanni, bozze non salvate). */
|
||||
creatoIl?: string | undefined;
|
||||
};
|
||||
|
||||
export type RigaEvento = {
|
||||
@@ -34,6 +37,7 @@ export type RigaEvento = {
|
||||
campionato: boolean;
|
||||
casa: boolean | null;
|
||||
pagelle_chiuse: boolean;
|
||||
creato_il?: string;
|
||||
};
|
||||
|
||||
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
|
||||
@@ -50,11 +54,12 @@ export function daRiga(r: RigaEvento): Evento {
|
||||
campionato: !!r.campionato,
|
||||
casa: r.casa ?? true,
|
||||
pagelleChiuse: !!r.pagelle_chiuse,
|
||||
creatoIl: r.creato_il,
|
||||
};
|
||||
}
|
||||
|
||||
const COLONNE =
|
||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
|
||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
|
||||
|
||||
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
|
||||
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
|
||||
@@ -158,8 +163,9 @@ export function useEliminaEvento() {
|
||||
}
|
||||
|
||||
/** Compleanni della rosa, come eventi di calendario dell'anno indicato. */
|
||||
export function compleanniEventi(anno = new Date().getFullYear()): Evento[] {
|
||||
return giocatori
|
||||
export function compleanniEventi(rosa: Giocatore[], anno = new Date().getFullYear()): Evento[] {
|
||||
return rosa
|
||||
.filter((g) => g.nascita)
|
||||
.map((g) => {
|
||||
const md = g.nascita.slice(5);
|
||||
const eta = anno - Number(g.nascita.slice(0, 4));
|
||||
@@ -181,7 +187,7 @@ export function compleanniEventi(anno = new Date().getFullYear()): Evento[] {
|
||||
}
|
||||
|
||||
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
|
||||
export function convocatiEvento(evento: Evento | null) {
|
||||
if (!evento || evento.convocati.length === 0) return giocatori;
|
||||
return giocatori.filter((g) => evento.convocati.includes(g.id));
|
||||
export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) {
|
||||
if (!evento || evento.convocati.length === 0) return rosa;
|
||||
return rosa.filter((g) => evento.convocati.includes(g.id));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import {
|
||||
COLONNE_SQUADRA,
|
||||
daRigaSquadra,
|
||||
type GiocatoreSquadra,
|
||||
type RigaGiocatoreSquadra,
|
||||
} from "./giocatori-squadra";
|
||||
|
||||
/** Lettura squadra lato server (route API): stessa conversione del client. */
|
||||
export async function leggiGiocatoriSquadra(): Promise<GiocatoreSquadra[]> {
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
// `types.ts` non include ancora `giocatori_squadra` con le colonne di M8 (vedi client-nuove-tabelle.ts).
|
||||
const client = supabaseAdmin as unknown as SupabaseClient;
|
||||
const { data } = await client
|
||||
.from("giocatori_squadra")
|
||||
.select(COLONNE_SQUADRA)
|
||||
.order("cognome")
|
||||
.order("nome");
|
||||
return ((data ?? []) as RigaGiocatoreSquadra[]).map(daRigaSquadra);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
|
||||
import { dividiNome, giocatori } from "./crapp-data";
|
||||
|
||||
/**
|
||||
* Anagrafica operativa della squadra (`giocatori_squadra`, migration M1). È la source of
|
||||
* truth per il collegamento account ↔ giocatore; `crapp-data.ts` resta il fallback finché
|
||||
* la migrazione non è completa (DD-016 regola 1).
|
||||
*/
|
||||
export type GiocatoreSquadra = {
|
||||
id: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
numero: number;
|
||||
ruolo: string;
|
||||
authUserId: string | null;
|
||||
attivo: boolean;
|
||||
email: string | null;
|
||||
numeroTessera: string | null;
|
||||
dataTessera: string | null;
|
||||
};
|
||||
|
||||
export type RigaGiocatoreSquadra = {
|
||||
id: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
numero: number;
|
||||
ruolo: string;
|
||||
auth_user_id: string | null;
|
||||
attivo: boolean;
|
||||
email: string | null;
|
||||
numero_tessera: string | null;
|
||||
data_tessera: string | null;
|
||||
};
|
||||
|
||||
/** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */
|
||||
export const RUOLI = ["Palleggiatore", "Banda", "Opposto", "Centrale", "Libero", "Jolly"] as const;
|
||||
|
||||
export const SQUADRA_KEY = ["giocatori-squadra"] as const;
|
||||
|
||||
/** Rosa di riserva quando il database non risponde o non è ancora popolato. */
|
||||
export function rosaFallback(): GiocatoreSquadra[] {
|
||||
return giocatori.map((g) => ({
|
||||
...dividiNome(g.nome),
|
||||
id: g.id,
|
||||
numero: g.numero,
|
||||
ruolo: g.ruolo,
|
||||
authUserId: null,
|
||||
attivo: true,
|
||||
email: null,
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function nomeCompleto(g: GiocatoreSquadra): string {
|
||||
return `${g.nome} ${g.cognome}`.trim();
|
||||
}
|
||||
|
||||
/** Lo slot già collegato a questo account, se esiste. */
|
||||
export function slotDi(
|
||||
righe: GiocatoreSquadra[],
|
||||
utenteId: string | null,
|
||||
): GiocatoreSquadra | null {
|
||||
if (!utenteId) return null;
|
||||
return righe.find((g) => g.authUserId === utenteId) ?? null;
|
||||
}
|
||||
|
||||
/** Lo slot libero la cui email coincide con quella dell'account Google (case-insensitive). */
|
||||
export function slotPerEmail(
|
||||
righe: GiocatoreSquadra[],
|
||||
email: string | null,
|
||||
): GiocatoreSquadra | null {
|
||||
if (!email) return null;
|
||||
const cercata = email.trim().toLowerCase();
|
||||
return righe.find((g) => !g.authUserId && g.email?.trim().toLowerCase() === cercata) ?? null;
|
||||
}
|
||||
|
||||
export const COLONNE_SQUADRA =
|
||||
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera";
|
||||
|
||||
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
|
||||
export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
|
||||
return {
|
||||
id: r.id,
|
||||
nome: r.nome,
|
||||
cognome: r.cognome,
|
||||
numero: r.numero,
|
||||
ruolo: r.ruolo,
|
||||
authUserId: r.auth_user_id,
|
||||
attivo: r.attivo,
|
||||
email: r.email,
|
||||
numeroTessera: r.numero_tessera,
|
||||
dataTessera: r.data_tessera,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchSquadra(): Promise<GiocatoreSquadra[]> {
|
||||
const { data, error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.select(COLONNE_SQUADRA)
|
||||
.order("cognome")
|
||||
.order("nome");
|
||||
if (error) throw error;
|
||||
const righe = (data ?? []) as RigaGiocatoreSquadra[];
|
||||
return righe.map(daRigaSquadra);
|
||||
}
|
||||
|
||||
/** Anagrafica squadra: una lettura per sessione, cambia raramente. */
|
||||
export function useGiocatoriSquadra() {
|
||||
const query = useQuery({ queryKey: SQUADRA_KEY, queryFn: fetchSquadra, staleTime: 30 * 60_000 });
|
||||
const righe = query.data?.length ? query.data : rosaFallback();
|
||||
return { ...query, righe, daDatabase: !!query.data?.length };
|
||||
}
|
||||
|
||||
/** Dati squadra: li gestisce solo un amministratore (DD-017). L'email è quella usata per
|
||||
* il collegamento automatico al primo accesso (DD-018), non il dato personale del profilo. */
|
||||
export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo" | "email">;
|
||||
|
||||
/**
|
||||
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
|
||||
* meglio dirlo qui che far tornare un errore Postgres all'utente.
|
||||
* Restituisce il messaggio da mostrare, oppure `null` se va bene.
|
||||
*/
|
||||
export function validaDatiSquadra(dati: DatiSquadra): string | null {
|
||||
if (!dati.nome.trim()) return "Il nome non può essere vuoto.";
|
||||
if (!dati.cognome.trim()) return "Il cognome non può essere vuoto.";
|
||||
if (!Number.isInteger(dati.numero) || dati.numero <= 0)
|
||||
return "Il numero di maglia deve essere maggiore di zero.";
|
||||
if (!dati.ruolo.trim()) return "Il ruolo non può essere vuoto.";
|
||||
if (dati.email?.trim() && !dati.email.includes("@")) return "L'email non è valida.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Il prossimo id libero nel formato `g<N>` richiesto dal vincolo della tabella. */
|
||||
export function prossimoIdGiocatore(righe: GiocatoreSquadra[]): string {
|
||||
const max = righe.reduce((acc, g) => {
|
||||
const n = Number(g.id.slice(1));
|
||||
return Number.isFinite(n) && n > acc ? n : acc;
|
||||
}, 0);
|
||||
return `g${max + 1}`;
|
||||
}
|
||||
|
||||
/** Numeri di maglia doppi: il database li accetta, la squadra no. */
|
||||
export function numeroGiaUsato(
|
||||
righe: GiocatoreSquadra[],
|
||||
giocatoreId: string,
|
||||
numero: number,
|
||||
): boolean {
|
||||
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
|
||||
}
|
||||
|
||||
/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
|
||||
export function useSalvaDatiSquadra() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => {
|
||||
const dati = {
|
||||
nome: input.dati.nome.trim(),
|
||||
cognome: input.dati.cognome.trim(),
|
||||
numero: input.dati.numero,
|
||||
ruolo: input.dati.ruolo.trim(),
|
||||
email: input.dati.email?.trim() || null,
|
||||
};
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update(dati)
|
||||
.eq("id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
return { giocatoreId: input.giocatoreId, dati };
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Numero e data della tessera CSI, note solo dopo il tesseramento effettivo. */
|
||||
export type DatiTesseramento = Pick<GiocatoreSquadra, "numeroTessera" | "dataTessera">;
|
||||
|
||||
/**
|
||||
* Registra numero e data della tessera CSI (roadmap v1.1). Campo puramente amministrativo:
|
||||
* il trigger di M8 lo rende scrivibile solo da un admin, il giocatore non può autodichiararsi
|
||||
* tesserato.
|
||||
*/
|
||||
export function useSalvaTesseramento() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; dati: DatiTesseramento }) => {
|
||||
const dati = {
|
||||
numero_tessera: input.dati.numeroTessera?.trim() || null,
|
||||
data_tessera: input.dati.dataTessera || null,
|
||||
};
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update(dati)
|
||||
.eq("id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
return {
|
||||
giocatoreId: input.giocatoreId,
|
||||
dati: { numeroTessera: dati.numero_tessera, dataTessera: dati.data_tessera },
|
||||
};
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le policy di M1.
|
||||
* L'id (`g<N>`) non è generato dal database: va calcolato con `prossimoIdGiocatore`
|
||||
* prima di chiamare questa mutazione.
|
||||
*/
|
||||
export function useAggiungiGiocatore() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { id: string; dati: DatiSquadra }) => {
|
||||
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert({
|
||||
id: input.id,
|
||||
nome: input.dati.nome.trim(),
|
||||
cognome: input.dati.cognome.trim(),
|
||||
numero: input.dati.numero,
|
||||
ruolo: input.dati.ruolo.trim(),
|
||||
email: input.dati.email?.trim() || null,
|
||||
});
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: SQUADRA_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attiva o disattiva un giocatore (es. ha lasciato la squadra): non elimina la riga, così
|
||||
* presenze, voti, pagelle e badge della stagione restano agganciati al suo id.
|
||||
*/
|
||||
export function useImpostaAttivo() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; attivo: boolean }) => {
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update({ attivo: input.attivo })
|
||||
.eq("id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
return input;
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, attivo: input.attivo } : g)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Libera uno slot occupato per errore (DD-016 regola 2, DD-017). Il giocatore
|
||||
* potrà ricollegarsi al primo accesso; i dati del profilo restano dove sono.
|
||||
*/
|
||||
export function useScollegaAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (giocatoreId: string) => {
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update({ auth_user_id: null })
|
||||
.eq("id", giocatoreId);
|
||||
if (error) throw error;
|
||||
return giocatoreId;
|
||||
},
|
||||
onSuccess: (giocatoreId) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
(prec ?? []).map((g) => (g.id === giocatoreId ? { ...g, authUserId: null } : g)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Collega l'account al giocatore scelto. Il trigger di M1 accetta l'operazione solo se
|
||||
* lo slot è libero e se nessun altro campo cambia (DD-016 regola 2): il vincolo vive nel
|
||||
* database, non qui.
|
||||
*/
|
||||
export function useCollegaGiocatore() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; utenteId: string }) => {
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update({ auth_user_id: input.utenteId })
|
||||
.eq("id", input.giocatoreId)
|
||||
.is("auth_user_id", null);
|
||||
if (error) throw error;
|
||||
return input;
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
(prec ?? []).map((g) =>
|
||||
g.id === input.giocatoreId ? { ...g, authUserId: input.utenteId } : g,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -57,4 +57,4 @@ export function useInfortuniERitardi(): { infortuni: ContoInfortuni; ritardi: Co
|
||||
() => ({ infortuni: contaInfortuni(presenze), ritardi: contaRitardi(presenze) }),
|
||||
[presenze],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -90,4 +90,4 @@ export async function coriandoli(ridotto = false) {
|
||||
origin: { y: 0.7 },
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -83,4 +83,18 @@ export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> {
|
||||
|
||||
export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) {
|
||||
return voti.find((v) => v.match_id === matchId && v.votante_id === votanteId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/** MVP vinti per giocatore, contando una vittoria per partita votata. */
|
||||
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
const matchIds = new Set(voti.map((v) => v.match_id));
|
||||
for (const matchId of matchIds) {
|
||||
const top = conteggioPartita(voti, matchId);
|
||||
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
|
||||
const id = top[0]!.id;
|
||||
out[id] = (out[id] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import {
|
||||
microcopyObiettivo,
|
||||
progressoObiettivo,
|
||||
type ObiettivoSquadra,
|
||||
} from "./obiettivi";
|
||||
import {
|
||||
badgeGiocatore,
|
||||
badgeSegretiSbloccati,
|
||||
gradoMeta,
|
||||
prossimoTraguardo,
|
||||
} from "./badges";
|
||||
import { microcopyObiettivo, progressoObiettivo, type ObiettivoSquadra } from "./obiettivi";
|
||||
import { badgeGiocatore, badgeSegretiSbloccati, gradoMeta, prossimoTraguardo } from "./badges";
|
||||
import { serieGiocatore } from "./serie";
|
||||
import { badgeSocialVinti, categorieSocial, type VotoSocial } from "./badge-social";
|
||||
|
||||
@@ -166,4 +157,4 @@ export function useNotificheSmart(g: Giocatore | null, votiSocial: VotoSocial[]
|
||||
const chiudi = useCallback(() => setCoda((c) => c.slice(1)), []);
|
||||
|
||||
return { notifica: coda[0] ?? null, restanti: Math.max(0, coda.length - 1), chiudi };
|
||||
}
|
||||
}
|
||||
|
||||
+12
-11
@@ -1,4 +1,4 @@
|
||||
import { giocatori, storicoMatch, type Giocatore } from "./crapp-data";
|
||||
import { giocatori, type Giocatore } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
import type { MappaPresenze } from "./presenze";
|
||||
import { mediaSquadra, type VotoPagella } from "./pagelle";
|
||||
@@ -20,18 +20,20 @@ export type ContestoObiettivi = {
|
||||
eventi: Evento[];
|
||||
presenze: MappaPresenze;
|
||||
pagelle: VotoPagella[];
|
||||
/** Vittorie ufficiali in campionato (dato CSI). */
|
||||
vittorie?: number;
|
||||
};
|
||||
|
||||
export const contestoVuoto: ContestoObiettivi = { eventi: [], presenze: {}, pagelle: [] };
|
||||
|
||||
const MESE = "2026-08";
|
||||
|
||||
function percentualePresenzeMese(ctx: ContestoObiettivi) {
|
||||
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number) {
|
||||
const delMese = ctx.eventi.filter(
|
||||
(e) => e.data.startsWith(MESE) && (e.tipo === "partita" || e.tipo === "allenamento"),
|
||||
);
|
||||
if (delMese.length === 0) return 0;
|
||||
const posti = delMese.length * giocatori.length;
|
||||
if (delMese.length === 0 || rosaSize === 0) return 0;
|
||||
const posti = delMese.length * rosaSize;
|
||||
const presenti = delMese.reduce((s, e) => {
|
||||
const risposte = ctx.presenze[e.id] ?? {};
|
||||
return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length;
|
||||
@@ -39,10 +41,10 @@ function percentualePresenzeMese(ctx: ContestoObiettivi) {
|
||||
return Math.round((presenti / posti) * 100);
|
||||
}
|
||||
|
||||
function percentualeRisposte(ctx: ContestoObiettivi) {
|
||||
function percentualeRisposte(ctx: ContestoObiettivi, rosaSize: number) {
|
||||
const daRispondere = ctx.eventi.filter((e) => e.tipo !== "compleanno");
|
||||
if (daRispondere.length === 0) return 0;
|
||||
const posti = daRispondere.length * giocatori.length;
|
||||
if (daRispondere.length === 0 || rosaSize === 0) return 0;
|
||||
const posti = daRispondere.length * rosaSize;
|
||||
const risposte = daRispondere.reduce(
|
||||
(s, e) => s + Object.keys(ctx.presenze[e.id] ?? {}).length,
|
||||
0,
|
||||
@@ -50,8 +52,6 @@ function percentualeRisposte(ctx: ContestoObiettivi) {
|
||||
return Math.round((risposte / posti) * 100);
|
||||
}
|
||||
|
||||
const vittorie = storicoMatch.filter((m) => m.setNostri > m.setLoro).length;
|
||||
|
||||
/** Obiettivi collaborativi: si muovono con il contributo di tutta la rosa. */
|
||||
export function obiettiviSquadra(
|
||||
rosa: Giocatore[] = giocatori,
|
||||
@@ -59,12 +59,13 @@ export function obiettiviSquadra(
|
||||
): ObiettivoSquadra[] {
|
||||
const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0);
|
||||
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
|
||||
const vittorie = ctx.vittorie ?? 0;
|
||||
return [
|
||||
{
|
||||
id: "o1",
|
||||
titolo: "90% di presenze ad agosto",
|
||||
descrizione: "Media presenze su partite e allenamenti del mese",
|
||||
valore: percentualePresenzeMese(ctx),
|
||||
valore: percentualePresenzeMese(ctx, rosa.length),
|
||||
target: 90,
|
||||
unita: "%",
|
||||
scadenza: "2026-08-31",
|
||||
@@ -75,7 +76,7 @@ export function obiettiviSquadra(
|
||||
id: "o2",
|
||||
titolo: "Tutti rispondono alle convocazioni",
|
||||
descrizione: "Percentuale di risposte date sugli eventi in programma",
|
||||
valore: percentualeRisposte(ctx),
|
||||
valore: percentualeRisposte(ctx, rosa.length),
|
||||
target: 90,
|
||||
unita: "%",
|
||||
scadenza: "2026-09-30",
|
||||
|
||||
+66
-14
@@ -1,8 +1,11 @@
|
||||
import { giocatori } from "./crapp-data";
|
||||
import { formatData } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
|
||||
export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null };
|
||||
|
||||
/** Candidato al turno palloni: solo id e nome bastano per assegnare e ordinare. */
|
||||
export type CandidatoTurno = { id: string; nome: string };
|
||||
|
||||
/** Eventi che richiedono i palloni (allenamenti, partite, extra), in ordine di data. */
|
||||
export function eventiPalloni(eventi: Evento[]): Evento[] {
|
||||
return eventi
|
||||
@@ -18,10 +21,11 @@ export function eventiPalloni(eventi: Evento[]): Evento[] {
|
||||
export function completaTurni(
|
||||
turni: Record<string, string>,
|
||||
eventi: Evento[],
|
||||
rosa: CandidatoTurno[],
|
||||
): Record<string, string> {
|
||||
const risultato: Record<string, string> = { ...turni };
|
||||
const conteggio = new Map<string, number>(giocatori.map((g) => [g.id, 0]));
|
||||
const ultimo = new Map<string, number>(giocatori.map((g) => [g.id, -1]));
|
||||
const conteggio = new Map<string, number>(rosa.map((g) => [g.id, 0]));
|
||||
const ultimo = new Map<string, number>(rosa.map((g) => [g.id, -1]));
|
||||
|
||||
eventiPalloni(eventi).forEach((evento, indice) => {
|
||||
const assegnato = risultato[evento.id];
|
||||
@@ -32,17 +36,15 @@ export function completaTurni(
|
||||
}
|
||||
if (assegnato) return;
|
||||
|
||||
const scelto = giocatori
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const ca = conteggio.get(a.id) ?? 0;
|
||||
const cb = conteggio.get(b.id) ?? 0;
|
||||
if (ca !== cb) return ca - cb;
|
||||
const ua = ultimo.get(a.id) ?? -1;
|
||||
const ub = ultimo.get(b.id) ?? -1;
|
||||
if (ua !== ub) return ua - ub;
|
||||
return a.nome.localeCompare(b.nome);
|
||||
})[0];
|
||||
const scelto = rosa.slice().sort((a, b) => {
|
||||
const ca = conteggio.get(a.id) ?? 0;
|
||||
const cb = conteggio.get(b.id) ?? 0;
|
||||
if (ca !== cb) return ca - cb;
|
||||
const ua = ultimo.get(a.id) ?? -1;
|
||||
const ub = ultimo.get(b.id) ?? -1;
|
||||
if (ua !== ub) return ua - ub;
|
||||
return a.nome.localeCompare(b.nome);
|
||||
})[0];
|
||||
|
||||
if (!scelto) return;
|
||||
risultato[evento.id] = scelto.id;
|
||||
@@ -83,3 +85,53 @@ export function oggiISO(): string {
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/** Chi deve ricevere l'avviso push, oggi: chi porta i palloni e chi li riprende. */
|
||||
export function destinatariPromemoriaPalloni(
|
||||
turni: Record<string, string>,
|
||||
eventi: Evento[],
|
||||
oggi: string,
|
||||
): string[] {
|
||||
const destinatari = new Set<string>();
|
||||
for (const evento of eventiDelGiorno(eventi, oggi)) {
|
||||
const incaricato = turni[evento.id];
|
||||
if (incaricato) destinatari.add(incaricato);
|
||||
const prima = eventoPrecedente(eventi, evento.id);
|
||||
const precedente = prima ? turni[prima.id] : undefined;
|
||||
if (precedente) destinatari.add(precedente);
|
||||
}
|
||||
return [...destinatari];
|
||||
}
|
||||
|
||||
/** Testo del push per un giocatore: priorità a "riporta oggi", poi "tocca a te", poi generico. */
|
||||
export function messaggioPalloniOggi(
|
||||
turni: Record<string, string>,
|
||||
eventi: Evento[],
|
||||
oggi: string,
|
||||
mioId: string,
|
||||
nome: string,
|
||||
): { title: string; body: string } {
|
||||
for (const evento of eventiDelGiorno(eventi, oggi)) {
|
||||
const prima = eventoPrecedente(eventi, evento.id);
|
||||
if (prima && turni[prima.id] === mioId) {
|
||||
return {
|
||||
title: "Porta i palloni oggi",
|
||||
body: `${evento.titolo} · ${evento.ora}. I palloni li hai tu dalla volta scorsa.`,
|
||||
};
|
||||
}
|
||||
if (turni[evento.id] === mioId) {
|
||||
const dopo = eventoSuccessivo(eventi, evento.id);
|
||||
return {
|
||||
title: "Tocca a te prendere i palloni",
|
||||
body: dopo
|
||||
? `A fine ${evento.titolo} porta a casa i palloni e riportali il ${formatData(dopo.data)}.`
|
||||
: `A fine ${evento.titolo} porta a casa i palloni.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: "CrAPP · Turno palloni",
|
||||
body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.",
|
||||
};
|
||||
}
|
||||
|
||||
+4
-1
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { completaTurni } from "./palloni-core";
|
||||
import { useEventi } from "./eventi";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
|
||||
export const TURNI_KEY = ["turni-palloni"] as const;
|
||||
|
||||
@@ -26,8 +27,10 @@ export function useTurniPalloni() {
|
||||
// Cambia raramente: una lettura per sessione è sufficiente.
|
||||
const query = useQuery({ queryKey: TURNI_KEY, queryFn: fetchTurni, staleTime: 30 * 60_000 });
|
||||
const { eventi } = useEventi();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const salvati = query.data ?? {};
|
||||
return { ...query, salvati, turni: completaTurni(salvati, eventi) };
|
||||
const rosa = squadra.filter((g) => g.attivo).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
|
||||
return { ...query, salvati, turni: completaTurni(salvati, eventi, rosa) };
|
||||
}
|
||||
|
||||
export function useAssegnaTurno() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEventi } from "./eventi";
|
||||
import { useRispostePresenze } from "./presenze";
|
||||
import { giocatori } from "./crapp-data";
|
||||
import { useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
|
||||
/**
|
||||
* Percentuale di presenze dell'ultimo mese (30 giorni), utile per le convocazioni.
|
||||
@@ -46,6 +46,7 @@ export function usePresenzeUltimoMeseTutti(): Record<
|
||||
> {
|
||||
const { eventi } = useEventi();
|
||||
const { presenze } = useRispostePresenze();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
|
||||
return useMemo(() => {
|
||||
const oggi = new Date();
|
||||
@@ -53,6 +54,7 @@ export function usePresenzeUltimoMeseTutti(): Record<
|
||||
inizio.setDate(inizio.getDate() - 30);
|
||||
const da = inizio.toISOString().slice(0, 10);
|
||||
const a = oggi.toISOString().slice(0, 10);
|
||||
const idRosa = squadra.filter((g) => g.attivo).map((g) => g.id);
|
||||
|
||||
const rilevanti = eventi.filter(
|
||||
(e) => (e.tipo === "partita" || e.tipo === "allenamento") && e.data >= da && e.data <= a,
|
||||
@@ -60,8 +62,7 @@ export function usePresenzeUltimoMeseTutti(): Record<
|
||||
|
||||
const out: Record<string, { presenti: number; totali: number; percentuale: number }> = {};
|
||||
for (const e of rilevanti) {
|
||||
const ids =
|
||||
e.convocati.length > 0 ? e.convocati : giocatori.map((g) => g.id);
|
||||
const ids = e.convocati.length > 0 ? e.convocati : idRosa;
|
||||
for (const id of ids) {
|
||||
const rec = (out[id] ??= { presenti: 0, totali: 0, percentuale: 0 });
|
||||
rec.totali += 1;
|
||||
@@ -73,5 +74,5 @@ export function usePresenzeUltimoMeseTutti(): Record<
|
||||
rec.percentuale = rec.totali ? Math.round((rec.presenti / rec.totali) * 100) : 0;
|
||||
}
|
||||
return out;
|
||||
}, [eventi, presenze]);
|
||||
}
|
||||
}, [eventi, presenze, squadra]);
|
||||
}
|
||||
|
||||
+120
-13
@@ -1,28 +1,126 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import type { Stato } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
import { aggiornaSerie } from "./serie";
|
||||
|
||||
export const PRESENZE_KEY = ["risposte-presenze"] as const;
|
||||
|
||||
/** eventoId -> giocatoreId -> stato */
|
||||
export type MappaPresenze = Record<string, Record<string, Stato>>;
|
||||
|
||||
async function fetchPresenze(): Promise<MappaPresenze> {
|
||||
/** eventoId -> giocatoreId -> istante della prima risposta (ISO). */
|
||||
export type MappaTempiRisposta = Record<string, Record<string, string>>;
|
||||
|
||||
/** Allenamenti e partite CrAPP che contano per le statistiche di presenza. */
|
||||
function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string) {
|
||||
return eventi.filter(
|
||||
(e) =>
|
||||
(e.tipo === "partita" || e.tipo === "allenamento") &&
|
||||
(giocatoreId === undefined ||
|
||||
e.convocati.length === 0 ||
|
||||
e.convocati.includes(giocatoreId)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Presenze effettive (presente o in ritardo) su eventi CrAPP. */
|
||||
export function contaPresenzeGiocatore(
|
||||
giocatoreId: string,
|
||||
eventi: Evento[],
|
||||
presenze: MappaPresenze,
|
||||
): number {
|
||||
return eventiContanoPresenze(eventi, giocatoreId).filter((e) => {
|
||||
const stato = presenze[e.id]?.[giocatoreId];
|
||||
return stato === "presente" || stato === "ritardo";
|
||||
}).length;
|
||||
}
|
||||
|
||||
/** Eventi CrAPP rilevanti per il denominatore presenze di un giocatore. */
|
||||
export function totaliEventiGiocatore(giocatoreId: string, eventi: Evento[]): number {
|
||||
return eventiContanoPresenze(eventi, giocatoreId).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serie di presenze consecutive su eventi già passati, in ordine di data:
|
||||
* ogni presenza (o ritardo) vale +1, qualsiasi altra risposta — o nessuna
|
||||
* risposta — azzera la serie. Senza `tipo` conta partite e allenamenti insieme.
|
||||
*/
|
||||
export function serieConsecutiva(
|
||||
giocatoreId: string,
|
||||
eventi: Evento[],
|
||||
presenze: MappaPresenze,
|
||||
tipo?: "partita" | "allenamento",
|
||||
oggi: string = oggiIso(),
|
||||
): number {
|
||||
return serieSu(giocatoreId, eventi, oggi, tipo, (e) => {
|
||||
const stato = presenze[e.id]?.[giocatoreId];
|
||||
return stato === "presente" || stato === "ritardo";
|
||||
});
|
||||
}
|
||||
|
||||
const ORE_24 = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Serie di conferme rapide: risposte arrivate entro 24 ore dalla convocazione
|
||||
* (`creatoIl` dell'evento). Gli eventi senza istante di creazione — quelli generati
|
||||
* dal client, non salvati a database — non spezzano la serie: vengono saltati.
|
||||
*/
|
||||
export function serieConferme(
|
||||
giocatoreId: string,
|
||||
eventi: Evento[],
|
||||
tempi: MappaTempiRisposta,
|
||||
oggi: string = oggiIso(),
|
||||
): number {
|
||||
return serieSu(
|
||||
giocatoreId,
|
||||
eventi.filter((e) => e.creatoIl),
|
||||
oggi,
|
||||
undefined,
|
||||
(e) => {
|
||||
const risposto = tempi[e.id]?.[giocatoreId];
|
||||
return risposto !== undefined && Date.parse(risposto) - Date.parse(e.creatoIl!) <= ORE_24;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function oggiIso() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Scorre gli eventi già passati in ordine di data applicando la regola delle serie. */
|
||||
function serieSu(
|
||||
giocatoreId: string,
|
||||
eventi: Evento[],
|
||||
oggi: string,
|
||||
tipo: "partita" | "allenamento" | undefined,
|
||||
onorato: (e: Evento) => boolean,
|
||||
): number {
|
||||
return eventiContanoPresenze(eventi, giocatoreId)
|
||||
.filter((e) => (tipo === undefined || e.tipo === tipo) && e.data <= oggi)
|
||||
.sort((a, b) => a.data.localeCompare(b.data))
|
||||
.reduce((serie, e) => aggiornaSerie(serie, onorato(e)), 0);
|
||||
}
|
||||
|
||||
type LetturaPresenze = { presenze: MappaPresenze; tempi: MappaTempiRisposta };
|
||||
|
||||
async function fetchPresenze(): Promise<LetturaPresenze> {
|
||||
const { data, error } = await supabase
|
||||
.from("risposte_presenze")
|
||||
.select("evento_id, giocatore_id, stato");
|
||||
.select("evento_id, giocatore_id, stato, risposto_il");
|
||||
if (error) throw error;
|
||||
const mappa: MappaPresenze = {};
|
||||
const presenze: MappaPresenze = {};
|
||||
const tempi: MappaTempiRisposta = {};
|
||||
for (const riga of data ?? []) {
|
||||
(mappa[riga.evento_id] ??= {})[riga.giocatore_id] = riga.stato as Stato;
|
||||
(presenze[riga.evento_id] ??= {})[riga.giocatore_id] = riga.stato as Stato;
|
||||
(tempi[riga.evento_id] ??= {})[riga.giocatore_id] = riga.risposto_il;
|
||||
}
|
||||
return mappa;
|
||||
return { presenze, tempi };
|
||||
}
|
||||
|
||||
/** Una lettura per sessione: le risposte cambiano poco durante la navigazione. */
|
||||
export function useRispostePresenze() {
|
||||
const query = useQuery({ queryKey: PRESENZE_KEY, queryFn: fetchPresenze, staleTime: 5 * 60_000 });
|
||||
return { ...query, presenze: query.data ?? {} };
|
||||
return { ...query, presenze: query.data?.presenze ?? {}, tempi: query.data?.tempi ?? {} };
|
||||
}
|
||||
|
||||
export function usePresenzeEvento(eventoId: string) {
|
||||
@@ -57,13 +155,22 @@ export function useSalvaPresenza() {
|
||||
},
|
||||
// Scrittura unica + aggiornamento cache locale, nessuna rilettura.
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<MappaPresenze>(PRESENZE_KEY, (prec) => {
|
||||
const mappa: MappaPresenze = { ...(prec ?? {}) };
|
||||
const evento = { ...(mappa[input.eventoId] ?? {}) };
|
||||
if (input.stato === null) delete evento[input.giocatoreId];
|
||||
else evento[input.giocatoreId] = input.stato;
|
||||
mappa[input.eventoId] = evento;
|
||||
return mappa;
|
||||
queryClient.setQueryData<LetturaPresenze>(PRESENZE_KEY, (prec) => {
|
||||
const presenze: MappaPresenze = { ...(prec?.presenze ?? {}) };
|
||||
const tempi: MappaTempiRisposta = { ...(prec?.tempi ?? {}) };
|
||||
const stati = { ...(presenze[input.eventoId] ?? {}) };
|
||||
const istanti = { ...(tempi[input.eventoId] ?? {}) };
|
||||
if (input.stato === null) {
|
||||
delete stati[input.giocatoreId];
|
||||
delete istanti[input.giocatoreId];
|
||||
} else {
|
||||
stati[input.giocatoreId] = input.stato;
|
||||
// Come a database: l'istante è quello della prima risposta, non dei ripensamenti.
|
||||
istanti[input.giocatoreId] ??= new Date().toISOString();
|
||||
}
|
||||
presenze[input.eventoId] = stati;
|
||||
tempi[input.eventoId] = istanti;
|
||||
return { presenze, tempi };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { rigaCsv } from "./scout-export";
|
||||
import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
|
||||
/**
|
||||
* Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato
|
||||
* `profili-giocatore`: qui viaggiano solo i path.
|
||||
*/
|
||||
export type Profilo = {
|
||||
giocatoreId: string;
|
||||
dataNascita: string | null;
|
||||
luogoNascita: string | null;
|
||||
indirizzo: string | null;
|
||||
telefono: string | null;
|
||||
email: string | null;
|
||||
documentoTipo: string | null;
|
||||
documentoNumero: string | null;
|
||||
documentoRilasciatoDa: string | null;
|
||||
documentoEmissione: string | null;
|
||||
documentoScadenza: string | null;
|
||||
documentoFrontePath: string | null;
|
||||
documentoRetroPath: string | null;
|
||||
certificatoScadenza: string | null;
|
||||
certificatoPath: string | null;
|
||||
fotoPath: string | null;
|
||||
};
|
||||
|
||||
export type RigaProfilo = {
|
||||
giocatore_id: string;
|
||||
data_nascita: string | null;
|
||||
luogo_nascita: string | null;
|
||||
indirizzo: string | null;
|
||||
telefono: string | null;
|
||||
email: string | null;
|
||||
documento_tipo: string | null;
|
||||
documento_numero: string | null;
|
||||
documento_rilasciato_da: string | null;
|
||||
documento_emissione: string | null;
|
||||
documento_scadenza: string | null;
|
||||
documento_fronte_path: string | null;
|
||||
documento_retro_path: string | null;
|
||||
certificato_scadenza: string | null;
|
||||
certificato_path: string | null;
|
||||
foto_path: string | null;
|
||||
};
|
||||
|
||||
export const COLONNE_PROFILO =
|
||||
"giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email, documento_tipo, documento_numero, documento_rilasciato_da, documento_emissione, documento_scadenza, documento_fronte_path, documento_retro_path, certificato_scadenza, certificato_path, foto_path";
|
||||
|
||||
export function profiloVuoto(giocatoreId: string): Profilo {
|
||||
return {
|
||||
giocatoreId,
|
||||
dataNascita: null,
|
||||
luogoNascita: null,
|
||||
indirizzo: null,
|
||||
telefono: null,
|
||||
email: null,
|
||||
documentoTipo: null,
|
||||
documentoNumero: null,
|
||||
documentoRilasciatoDa: null,
|
||||
documentoEmissione: null,
|
||||
documentoScadenza: null,
|
||||
documentoFrontePath: null,
|
||||
documentoRetroPath: null,
|
||||
certificatoScadenza: null,
|
||||
certificatoPath: null,
|
||||
fotoPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function daRigaProfilo(r: RigaProfilo): Profilo {
|
||||
return {
|
||||
giocatoreId: r.giocatore_id,
|
||||
dataNascita: r.data_nascita,
|
||||
luogoNascita: r.luogo_nascita,
|
||||
indirizzo: r.indirizzo,
|
||||
telefono: r.telefono,
|
||||
email: r.email,
|
||||
documentoTipo: r.documento_tipo,
|
||||
documentoNumero: r.documento_numero,
|
||||
documentoRilasciatoDa: r.documento_rilasciato_da,
|
||||
documentoEmissione: r.documento_emissione,
|
||||
documentoScadenza: r.documento_scadenza,
|
||||
documentoFrontePath: r.documento_fronte_path,
|
||||
documentoRetroPath: r.documento_retro_path,
|
||||
certificatoScadenza: r.certificato_scadenza,
|
||||
certificatoPath: r.certificato_path,
|
||||
fotoPath: r.foto_path,
|
||||
};
|
||||
}
|
||||
|
||||
/** I campi vuoti tornano al database come NULL, non come stringa vuota. */
|
||||
function oNull(valore: string | null): string | null {
|
||||
const pulito = valore?.trim();
|
||||
return pulito ? pulito : null;
|
||||
}
|
||||
|
||||
export function aRigaProfilo(p: Profilo): RigaProfilo {
|
||||
return {
|
||||
giocatore_id: p.giocatoreId,
|
||||
data_nascita: oNull(p.dataNascita),
|
||||
luogo_nascita: oNull(p.luogoNascita),
|
||||
indirizzo: oNull(p.indirizzo),
|
||||
telefono: oNull(p.telefono),
|
||||
email: oNull(p.email),
|
||||
documento_tipo: oNull(p.documentoTipo),
|
||||
documento_numero: oNull(p.documentoNumero),
|
||||
documento_rilasciato_da: oNull(p.documentoRilasciatoDa),
|
||||
documento_emissione: oNull(p.documentoEmissione),
|
||||
documento_scadenza: oNull(p.documentoScadenza),
|
||||
documento_fronte_path: oNull(p.documentoFrontePath),
|
||||
documento_retro_path: oNull(p.documentoRetroPath),
|
||||
certificato_scadenza: oNull(p.certificatoScadenza),
|
||||
certificato_path: oNull(p.certificatoPath),
|
||||
foto_path: oNull(p.fotoPath),
|
||||
};
|
||||
}
|
||||
|
||||
/** Pesi delle sezioni del profilo (docs/modules/profilo-giocatore.md). */
|
||||
export const PESI = { dati: 30, documento: 30, certificato: 30, foto: 10 } as const;
|
||||
|
||||
export type Sezione = keyof typeof PESI;
|
||||
|
||||
export function sezioniComplete(p: Profilo | null | undefined): Record<Sezione, boolean> {
|
||||
return {
|
||||
dati: !!(p?.dataNascita && p.luogoNascita && p.indirizzo && p.telefono && p.email),
|
||||
documento: !!(
|
||||
p?.documentoTipo &&
|
||||
p.documentoNumero &&
|
||||
p.documentoScadenza &&
|
||||
p.documentoFrontePath &&
|
||||
p.documentoRetroPath
|
||||
),
|
||||
certificato: !!(p?.certificatoScadenza && p.certificatoPath),
|
||||
foto: !!p?.fotoPath,
|
||||
};
|
||||
}
|
||||
|
||||
/** Percentuale di completamento: calcolata a runtime, mai persistita (DD-007, DD-016). */
|
||||
export function completamento(p: Profilo | null | undefined): number {
|
||||
const complete = sezioniComplete(p);
|
||||
return (Object.keys(PESI) as Sezione[]).reduce(
|
||||
(somma, s) => somma + (complete[s] ? PESI[s] : 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export type StatoScadenza = "mancante" | "scaduto" | "valido";
|
||||
|
||||
/** Un certificato scaduto blocca il tesseramento: per l'admin non vale come presente. */
|
||||
export function statoScadenza(
|
||||
scadenza: string | null | undefined,
|
||||
path: string | null | undefined,
|
||||
oggi: string,
|
||||
): StatoScadenza {
|
||||
if (!path || !scadenza) return "mancante";
|
||||
return scadenza < oggi ? "scaduto" : "valido";
|
||||
}
|
||||
|
||||
/** Colonne richieste dal tesseramento CSI, nell'ordine del documento di modulo. */
|
||||
const INTESTAZIONI = [
|
||||
"Nome",
|
||||
"Cognome",
|
||||
"Data di nascita",
|
||||
"Luogo di nascita",
|
||||
"Indirizzo",
|
||||
"Telefono",
|
||||
"Email",
|
||||
"Tipo documento",
|
||||
"Numero documento",
|
||||
"Rilasciato da",
|
||||
"Data emissione",
|
||||
"Data scadenza",
|
||||
];
|
||||
|
||||
export function csvTesseramento(
|
||||
squadra: GiocatoreSquadra[],
|
||||
profili: Record<string, Profilo>,
|
||||
): string {
|
||||
const righe = [rigaCsv(INTESTAZIONI)];
|
||||
for (const g of squadra) {
|
||||
const p = profili[g.id];
|
||||
righe.push(
|
||||
rigaCsv([
|
||||
g.nome,
|
||||
g.cognome,
|
||||
p?.dataNascita ?? "",
|
||||
p?.luogoNascita ?? "",
|
||||
p?.indirizzo ?? "",
|
||||
p?.telefono ?? "",
|
||||
p?.email ?? "",
|
||||
p?.documentoTipo ?? "",
|
||||
p?.documentoNumero ?? "",
|
||||
p?.documentoRilasciatoDa ?? "",
|
||||
p?.documentoEmissione ?? "",
|
||||
p?.documentoScadenza ?? "",
|
||||
]),
|
||||
);
|
||||
}
|
||||
return righe.join("\n");
|
||||
}
|
||||
|
||||
/** Etichetta per l'elenco della dashboard. */
|
||||
export function etichettaGiocatore(g: GiocatoreSquadra): string {
|
||||
return `#${g.numero} ${nomeCompleto(g)}`;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
|
||||
import {
|
||||
aRigaProfilo,
|
||||
COLONNE_PROFILO,
|
||||
daRigaProfilo,
|
||||
type Profilo,
|
||||
type RigaProfilo,
|
||||
} from "./profili-core";
|
||||
|
||||
export const PROFILI_KEY = ["profili-giocatore"] as const;
|
||||
export const BUCKET = "profili-giocatore";
|
||||
|
||||
async function fetchProfili(): Promise<Record<string, Profilo>> {
|
||||
const { data, error } = await supabaseNuoveTabelle
|
||||
.from("profili_giocatore")
|
||||
.select(COLONNE_PROFILO);
|
||||
if (error) throw error;
|
||||
const mappa: Record<string, Profilo> = {};
|
||||
for (const r of (data ?? []) as RigaProfilo[]) mappa[r.giocatore_id] = daRigaProfilo(r);
|
||||
return mappa;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profili visibili all'utente corrente: le policy RLS decidono quanti sono — il proprio
|
||||
* per un giocatore, tutti per un admin. Una lettura per sessione.
|
||||
*/
|
||||
export function useProfili() {
|
||||
const query = useQuery({ queryKey: PROFILI_KEY, queryFn: fetchProfili, staleTime: 30 * 60_000 });
|
||||
return { ...query, profili: query.data ?? {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* I documenti stanno in un bucket privato e non hanno URL permanenti (DD-016 regola 4):
|
||||
* ogni download passa da una signed URL che scade in un minuto.
|
||||
*/
|
||||
export async function urlFirmato(path: string): Promise<string> {
|
||||
const { data, error } = await supabase.storage.from(BUCKET).createSignedUrl(path, 60);
|
||||
if (error) throw error;
|
||||
return data.signedUrl;
|
||||
}
|
||||
|
||||
export async function scaricaFile(path: string): Promise<void> {
|
||||
const url = await urlFirmato(path);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Salva il profilo del giocatore. Le policy RLS lasciano scrivere solo la propria riga:
|
||||
* il vincolo vive nel database, qui non serve ricontrollarlo.
|
||||
*/
|
||||
export function useSalvaProfilo() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (profilo: Profilo) => {
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("profili_giocatore")
|
||||
.upsert(aRigaProfilo(profilo), { onConflict: "giocatore_id" });
|
||||
if (error) throw error;
|
||||
return profilo;
|
||||
},
|
||||
// Scrittura unica e cache aggiornata a mano, senza rilettura.
|
||||
onSuccess: (profilo) => {
|
||||
queryClient.setQueryData<Record<string, Profilo>>(PROFILI_KEY, (prec) => ({
|
||||
...(prec ?? {}),
|
||||
[profilo.giocatoreId]: profilo,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type SezioneFile = "documento-fronte" | "documento-retro" | "certificato" | "foto";
|
||||
|
||||
const MAX_BYTE = 8 * 1024 * 1024;
|
||||
const TIPI_AMMESSI = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
||||
|
||||
function estensione(nome: string): string {
|
||||
const punto = nome.lastIndexOf(".");
|
||||
const est = punto > 0 ? nome.slice(punto + 1).toLowerCase() : "";
|
||||
return /^[a-z0-9]{1,5}$/.test(est) ? est : "bin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Carica un file nella cartella del giocatore e restituisce il path da salvare sul profilo.
|
||||
* Il controllo su tipo e dimensione sta qui perché è il confine con un file scelto
|
||||
* dall'utente; le policy dello Storage impediscono comunque di scrivere fuori dalla
|
||||
* propria cartella.
|
||||
*/
|
||||
export async function caricaFile(
|
||||
giocatoreId: string,
|
||||
sezione: SezioneFile,
|
||||
file: File,
|
||||
pathPrecedente?: string | null,
|
||||
): Promise<string> {
|
||||
if (!TIPI_AMMESSI.includes(file.type)) {
|
||||
throw new Error("Formato non ammesso: usa JPG, PNG, WEBP o PDF.");
|
||||
}
|
||||
if (file.size > MAX_BYTE) throw new Error("File troppo grande: massimo 8 MB.");
|
||||
|
||||
const path = `${giocatoreId}/${sezione}.${estensione(file.name)}`;
|
||||
const { error } = await supabase.storage
|
||||
.from(BUCKET)
|
||||
.upload(path, file, { upsert: true, contentType: file.type });
|
||||
if (error) throw error;
|
||||
|
||||
// Cambiando estensione il vecchio file resterebbe orfano nel bucket.
|
||||
if (pathPrecedente && pathPrecedente !== path) {
|
||||
await supabase.storage.from(BUCKET).remove([pathPrecedente]);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function rimuoviFile(path: string): Promise<void> {
|
||||
const { error } = await supabase.storage.from(BUCKET).remove([path]);
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -69,4 +69,4 @@ export async function disattivaNotifiche(): Promise<void> {
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
await reg?.unregister();
|
||||
}
|
||||
}
|
||||
|
||||
+58
-24
@@ -1,29 +1,45 @@
|
||||
import { useMemo } from "react";
|
||||
import { giocatori, type Giocatore } from "./crapp-data";
|
||||
import { giocatoriConScout, useScoutMatches } from "./scout-store";
|
||||
import { useVotiMvp, vincitoriMvp } from "./mvp-voti";
|
||||
import { nascitaPerId, type Giocatore } from "./crapp-data";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti";
|
||||
import { mediePagelle, usePagelle } from "./pagelle";
|
||||
import { statisticheCacche, useCacche } from "./cacche";
|
||||
import { conteggioTurni } from "./palloni-core";
|
||||
import { useTurniPalloni } from "./palloni";
|
||||
import { useInfortuniERitardi } from "./infortuni";
|
||||
import { useGiocatoreBase } from "./user-store";
|
||||
import { useGiocatoreId } from "./user-store";
|
||||
import { useEventi } from "./eventi";
|
||||
import { useRispostePresenze } from "./presenze";
|
||||
import {
|
||||
contaPresenzeGiocatore,
|
||||
serieConferme,
|
||||
serieConsecutiva,
|
||||
totaliEventiGiocatore,
|
||||
useRispostePresenze,
|
||||
} from "./presenze";
|
||||
import { obiettiviOrdinati } from "./obiettivi";
|
||||
import { useCsi } from "./csi";
|
||||
import { partiteGiocate } from "./csi-core";
|
||||
|
||||
function iniziali(nome: string, cognome: string): string {
|
||||
return `${nome[0] ?? ""}${cognome[0] ?? ""}`.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
|
||||
* palloni, infortuni, ritardi, cacche). Usa solo cache già in memoria:
|
||||
* nessuna query aggiuntiva rispetto a quelle che l'app fa comunque.
|
||||
* palloni, infortuni, ritardi, cacche). Legge l'anagrafica da `giocatori_squadra`
|
||||
* (DD-015): solo i giocatori attivi, gli altri restano nel database ma spariscono
|
||||
* dagli elenchi correnti. Usa solo cache già in memoria: nessuna query aggiuntiva
|
||||
* rispetto a quelle che l'app fa comunque.
|
||||
*/
|
||||
export function useRosa(): Giocatore[] {
|
||||
const scoutMatches = useScoutMatches();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const voti = useVotiMvp();
|
||||
const { voti: pagelle } = usePagelle();
|
||||
const { righe: cacche } = useCacche();
|
||||
const { turni } = useTurniPalloni();
|
||||
const { infortuni, ritardi } = useInfortuniERitardi();
|
||||
const { eventi } = useEventi();
|
||||
const { presenze: mappaPresenze, tempi } = useRispostePresenze();
|
||||
|
||||
const votiMvp = voti.data ?? [];
|
||||
|
||||
@@ -31,24 +47,40 @@ export function useRosa(): Giocatore[] {
|
||||
const medie = mediePagelle(pagelle);
|
||||
const statCacche = statisticheCacche(cacche);
|
||||
const palloni = conteggioTurni(turni);
|
||||
return giocatoriConScout(scoutMatches, vincitoriMvp(votiMvp)).map((g) => ({
|
||||
...g,
|
||||
mediaVoto: medie[g.id]?.media ?? g.mediaVoto,
|
||||
palloni: palloni[g.id] ?? 0,
|
||||
cacche: statCacche[g.id]?.giornateTop ?? 0,
|
||||
cacchePartita: statCacche[g.id]?.media ?? 0,
|
||||
infortuni: infortuni[g.id] ?? 0,
|
||||
ritardi: ritardi[g.id] ?? 0,
|
||||
}));
|
||||
}, [scoutMatches, votiMvp, pagelle, cacche, turni, infortuni, ritardi]);
|
||||
const mvpVinti = mvpVintiPerGiocatore(votiMvp);
|
||||
|
||||
return squadra
|
||||
.filter((g) => g.attivo)
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
nome: nomeCompleto(g),
|
||||
numero: g.numero,
|
||||
ruolo: g.ruolo,
|
||||
nascita: nascitaPerId[g.id] ?? "",
|
||||
iniziali: iniziali(g.nome, g.cognome),
|
||||
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
|
||||
totaliEventi: totaliEventiGiocatore(g.id, eventi),
|
||||
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
|
||||
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
|
||||
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
|
||||
serieConferme: serieConferme(g.id, eventi, tempi),
|
||||
mvp: mvpVinti[g.id] ?? 0,
|
||||
mediaVoto: medie[g.id]?.media ?? 0,
|
||||
palloni: palloni[g.id] ?? 0,
|
||||
cacche: statCacche[g.id]?.giornateTop ?? 0,
|
||||
cacchePartita: statCacche[g.id]?.media ?? 0,
|
||||
infortuni: infortuni[g.id] ?? 0,
|
||||
ritardi: ritardi[g.id] ?? 0,
|
||||
}));
|
||||
}, [squadra, votiMvp, pagelle, cacche, turni, infortuni, ritardi, eventi, mappaPresenze, tempi]);
|
||||
}
|
||||
|
||||
/** Il giocatore selezionato sul dispositivo, con le statistiche complete. */
|
||||
export function useIo(): Giocatore | null {
|
||||
const base = useGiocatoreBase();
|
||||
const id = useGiocatoreId();
|
||||
const rosa = useRosa();
|
||||
if (!base) return null;
|
||||
return rosa.find((g) => g.id === base.id) ?? base;
|
||||
if (!id) return null;
|
||||
return rosa.find((g) => g.id === id) ?? null;
|
||||
}
|
||||
|
||||
/** Obiettivi collaborativi calcolati sui dati reali già in cache. */
|
||||
@@ -57,7 +89,9 @@ export function useObiettivi() {
|
||||
const { eventi } = useEventi();
|
||||
const { presenze } = useRispostePresenze();
|
||||
const { voti: pagelle } = usePagelle();
|
||||
return obiettiviOrdinati(rosa, { eventi, presenze, pagelle });
|
||||
const { data: csi } = useCsi();
|
||||
const vittorie = csi
|
||||
? partiteGiocate(csi.partite).filter((p) => (p.setNostri ?? 0) > (p.setLoro ?? 0)).length
|
||||
: 0;
|
||||
return obiettiviOrdinati(rosa, { eventi, presenze, pagelle, vittorie });
|
||||
}
|
||||
|
||||
export { giocatori };
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useSessione } from "./auth";
|
||||
|
||||
export const RUOLI_KEY = ["ruolo-admin"] as const;
|
||||
|
||||
/**
|
||||
* Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011).
|
||||
* Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare.
|
||||
*/
|
||||
|
||||
/** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */
|
||||
async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null> {
|
||||
if (!utenteId) return null;
|
||||
const { data, error } = await supabase
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", utenteId)
|
||||
.eq("role", "admin")
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return !!data;
|
||||
}
|
||||
|
||||
export function useIsAdmin(): boolean {
|
||||
const { utenteId } = useSessione();
|
||||
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
|
||||
const query = useQuery({
|
||||
queryKey: [...RUOLI_KEY, utenteId],
|
||||
queryFn: () => fetchRuoloAdmin(utenteId),
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
return query.data === true;
|
||||
}
|
||||
+21
-13
@@ -1,7 +1,8 @@
|
||||
import { giocatori } from "./crapp-data";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store";
|
||||
|
||||
function riga(campi: Array<string | number>) {
|
||||
/** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */
|
||||
export function rigaCsv(campi: Array<string | number>) {
|
||||
return campi
|
||||
.map((c) => {
|
||||
const testo = String(c);
|
||||
@@ -11,26 +12,33 @@ function riga(campi: Array<string | number>) {
|
||||
}
|
||||
|
||||
/** Esporta la scoutizzazione di una partita in CSV (separatore ";" per Excel IT). */
|
||||
export function csvScoutMatch(match: ScoutMatch): string {
|
||||
export function csvScoutMatch(match: ScoutMatch, rosa: Giocatore[]): string {
|
||||
const righe: string[] = [];
|
||||
righe.push(riga(["Partita", match.casa ? "CRAP Volley" : match.avversario, "vs", match.casa ? match.avversario : "CRAP Volley"]));
|
||||
righe.push(riga(["Data", match.data, "Set", `${match.setNostri}-${match.setLoro}`]));
|
||||
righe.push(
|
||||
rigaCsv([
|
||||
"Partita",
|
||||
match.casa ? "CRAP Volley" : match.avversario,
|
||||
"vs",
|
||||
match.casa ? match.avversario : "CRAP Volley",
|
||||
]),
|
||||
);
|
||||
righe.push(rigaCsv(["Data", match.data, "Set", `${match.setNostri}-${match.setLoro}`]));
|
||||
righe.push("");
|
||||
righe.push(riga(["Set", "Parziale nostro", "Parziale loro"]));
|
||||
match.parziali.forEach((p, i) => righe.push(riga([i + 1, p[0], p[1]])));
|
||||
righe.push(rigaCsv(["Set", "Parziale nostro", "Parziale loro"]));
|
||||
match.parziali.forEach((p, i) => righe.push(rigaCsv([i + 1, p[0], p[1]])));
|
||||
righe.push("");
|
||||
righe.push(riga(["Numero", "Giocatore", "Ruolo", "Punti", "Ace", "Muri", "Errori"]));
|
||||
righe.push(rigaCsv(["Numero", "Giocatore", "Ruolo", "Punti", "Ace", "Muri", "Errori"]));
|
||||
const totali = totaliPerGiocatore(match.azioni);
|
||||
for (const g of giocatori) {
|
||||
for (const g of rosa) {
|
||||
const t = totali.get(g.id);
|
||||
if (!t) continue;
|
||||
righe.push(riga([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori]));
|
||||
righe.push(rigaCsv([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori]));
|
||||
}
|
||||
righe.push("");
|
||||
righe.push(riga(["Set", "Giocatore", "Azione"]));
|
||||
righe.push(rigaCsv(["Set", "Giocatore", "Azione"]));
|
||||
for (const a of match.azioni) {
|
||||
const g = giocatori.find((x) => x.id === a.giocatoreId);
|
||||
righe.push(riga([a.set, g?.nome ?? "—", azioniMeta[a.tipo].label]));
|
||||
const g = rosa.find((x) => x.id === a.giocatoreId);
|
||||
righe.push(rigaCsv([a.set, g?.nome ?? "—", azioniMeta[a.tipo].label]));
|
||||
}
|
||||
return righe.join("\n");
|
||||
}
|
||||
|
||||
+51
-85
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useEventi, type Evento } from "./eventi";
|
||||
|
||||
/** Minuti dopo i quali una sessione scout inattiva viene considerata libera. */
|
||||
@@ -24,79 +25,34 @@ export type SessioneScout = {
|
||||
|
||||
export function sessioneScaduta(s: SessioneScout | null): boolean {
|
||||
if (!s) return true;
|
||||
return Date.now() - new Date(s.aggiornato_il).getTime() > SCADENZA_MINUTI * 60_000;
|
||||
}
|
||||
|
||||
const storageKey = (eventoId: string) => `crap-scout-session-${eventoId}`;
|
||||
|
||||
function readSession(eventoId: string): SessioneScout | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(eventoId));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as SessioneScout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSession(eventoId: string, sessione: SessioneScout | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (sessione) {
|
||||
window.localStorage.setItem(storageKey(eventoId), JSON.stringify(sessione));
|
||||
} else {
|
||||
window.localStorage.removeItem(storageKey(eventoId));
|
||||
}
|
||||
try {
|
||||
const bc = new BroadcastChannel(`crap-scout-${eventoId}`);
|
||||
bc.postMessage(sessione);
|
||||
bc.close();
|
||||
} catch {
|
||||
// fallback: storage event is already fired by localStorage
|
||||
}
|
||||
const aggiornato = new Date(s.aggiornato_il).getTime();
|
||||
// Timestamp illeggibile: meglio liberare la sessione che lasciarla bloccata per sempre.
|
||||
if (Number.isNaN(aggiornato)) return true;
|
||||
return Date.now() - aggiornato > SCADENZA_MINUTI * 60_000;
|
||||
}
|
||||
|
||||
export const SESSIONE_KEY = (eventoId: string) => ["scout-sessione", eventoId] as const;
|
||||
|
||||
/** Sessione condivisa: chi la tiene aperta lo vede chiunque, su qualsiasi dispositivo. */
|
||||
async function leggiSessione(eventoId: string): Promise<SessioneScout | null> {
|
||||
const { data, error } = await supabase
|
||||
.from("scout_sessioni")
|
||||
.select("evento_id, giocatore_id, giocatore_nome, aggiornato_il")
|
||||
.eq("evento_id", eventoId)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
export function useSessioneScout(eventoId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const query = useQuery({
|
||||
return useQuery({
|
||||
queryKey: SESSIONE_KEY(eventoId ?? "-"),
|
||||
enabled: !!eventoId,
|
||||
// Sincronizzazione via BroadcastChannel/storage: nessun polling.
|
||||
staleTime: Infinity,
|
||||
queryFn: async (): Promise<SessioneScout | null> => {
|
||||
if (!eventoId || typeof window === "undefined") return null;
|
||||
return readSession(eventoId);
|
||||
},
|
||||
// Nessun push in tempo reale: si ricontrolla all'apertura/focus della pagina
|
||||
// o con il pulsante "Aggiorna" quando risulta occupato.
|
||||
staleTime: 30_000,
|
||||
queryFn: () => leggiSessione(eventoId!),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventoId) return;
|
||||
let bc: BroadcastChannel | null = null;
|
||||
try {
|
||||
bc = new BroadcastChannel(`crap-scout-${eventoId}`);
|
||||
bc.onmessage = (e) => {
|
||||
queryClient.setQueryData(SESSIONE_KEY(eventoId), e.data as SessioneScout | null);
|
||||
};
|
||||
} catch {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === storageKey(eventoId)) {
|
||||
queryClient.setQueryData(SESSIONE_KEY(eventoId), e.newValue ? (JSON.parse(e.newValue) as SessioneScout) : null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}
|
||||
return () => {
|
||||
if (bc) {
|
||||
bc.onmessage = null;
|
||||
bc.close();
|
||||
}
|
||||
};
|
||||
}, [eventoId, queryClient]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
/** Prende il controllo dello scout se libero o scaduto. Ritorna true se ottenuto. */
|
||||
@@ -104,17 +60,20 @@ export function useApriSessioneScout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { eventoId: string; giocatoreId: string; nome: string }) => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const current = readSession(input.eventoId);
|
||||
if (current && !sessioneScaduta(current) && current.giocatore_id !== input.giocatoreId) {
|
||||
const attuale = await leggiSessione(input.eventoId);
|
||||
if (attuale && !sessioneScaduta(attuale) && attuale.giocatore_id !== input.giocatoreId) {
|
||||
return false;
|
||||
}
|
||||
writeSession(input.eventoId, {
|
||||
evento_id: input.eventoId,
|
||||
giocatore_id: input.giocatoreId,
|
||||
giocatore_nome: input.nome,
|
||||
aggiornato_il: new Date().toISOString(),
|
||||
});
|
||||
const { error } = await supabase.from("scout_sessioni").upsert(
|
||||
{
|
||||
evento_id: input.eventoId,
|
||||
giocatore_id: input.giocatoreId,
|
||||
giocatore_nome: input.nome,
|
||||
aggiornato_il: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "evento_id" },
|
||||
);
|
||||
if (error) throw error;
|
||||
return true;
|
||||
},
|
||||
onSuccess: (_ok, input) => {
|
||||
@@ -127,10 +86,13 @@ export function useChiudiSessioneScout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { eventoId: string; giocatoreId: string }) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const current = readSession(input.eventoId);
|
||||
if (current && current.giocatore_id === input.giocatoreId) {
|
||||
writeSession(input.eventoId, null);
|
||||
const attuale = await leggiSessione(input.eventoId);
|
||||
if (attuale && attuale.giocatore_id === input.giocatoreId) {
|
||||
const { error } = await supabase
|
||||
.from("scout_sessioni")
|
||||
.delete()
|
||||
.eq("evento_id", input.eventoId);
|
||||
if (error) throw error;
|
||||
}
|
||||
},
|
||||
onSuccess: (_d, input) => {
|
||||
@@ -140,15 +102,19 @@ export function useChiudiSessioneScout() {
|
||||
}
|
||||
|
||||
/** Mantiene viva la sessione mentre lo scout è aperto. */
|
||||
export function useHeartbeatScout(eventoId: string | null, giocatoreId: string | null, attivo: boolean) {
|
||||
export function useHeartbeatScout(
|
||||
eventoId: string | null,
|
||||
giocatoreId: string | null,
|
||||
attivo: boolean,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!attivo || !eventoId || !giocatoreId || typeof window === "undefined") return;
|
||||
if (!attivo || !eventoId || !giocatoreId) return;
|
||||
const id = window.setInterval(() => {
|
||||
const current = readSession(eventoId);
|
||||
if (current && current.giocatore_id === giocatoreId) {
|
||||
current.aggiornato_il = new Date().toISOString();
|
||||
writeSession(eventoId, current);
|
||||
}
|
||||
void supabase
|
||||
.from("scout_sessioni")
|
||||
.update({ aggiornato_il: new Date().toISOString() })
|
||||
.eq("evento_id", eventoId)
|
||||
.eq("giocatore_id", giocatoreId);
|
||||
}, 60_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [attivo, eventoId, giocatoreId]);
|
||||
|
||||
+121
-93
@@ -1,24 +1,55 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { giocatori, classifica, type Giocatore, type RigaClassifica } from "./crapp-data";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { giocatori, type Giocatore } from "./crapp-data";
|
||||
|
||||
export type AzioneTipo =
|
||||
| "attacco"
|
||||
| "ace"
|
||||
| "muro"
|
||||
| "errore"
|
||||
| "punto_avv"
|
||||
| "errore_avv";
|
||||
export type AzioneTipo = "attacco" | "ace" | "muro" | "errore" | "punto_avv" | "errore_avv";
|
||||
|
||||
export const azioniMeta: Record<
|
||||
AzioneTipo,
|
||||
{ label: string; short: string; nostro: boolean; richiedeGiocatore: boolean; className: string }
|
||||
> = {
|
||||
attacco: { label: "Punto attacco", short: "Punto", nostro: true, richiedeGiocatore: true, className: "bg-accent text-accent-foreground" },
|
||||
ace: { label: "Ace", short: "Ace", nostro: true, richiedeGiocatore: true, className: "bg-success text-success-foreground" },
|
||||
muro: { label: "Muro", short: "Muro", nostro: true, richiedeGiocatore: true, className: "bg-info text-info-foreground" },
|
||||
errore: { label: "Errore nostro", short: "Errore", nostro: false, richiedeGiocatore: true, className: "bg-destructive text-destructive-foreground" },
|
||||
punto_avv: { label: "Punto avversario", short: "Punto avv.", nostro: false, richiedeGiocatore: false, className: "bg-muted text-muted-foreground" },
|
||||
errore_avv: { label: "Errore avversario", short: "Err. avv.", nostro: true, richiedeGiocatore: false, className: "bg-secondary text-foreground" },
|
||||
attacco: {
|
||||
label: "Punto attacco",
|
||||
short: "Punto",
|
||||
nostro: true,
|
||||
richiedeGiocatore: true,
|
||||
className: "bg-accent text-accent-foreground",
|
||||
},
|
||||
ace: {
|
||||
label: "Ace",
|
||||
short: "Ace",
|
||||
nostro: true,
|
||||
richiedeGiocatore: true,
|
||||
className: "bg-success text-success-foreground",
|
||||
},
|
||||
muro: {
|
||||
label: "Muro",
|
||||
short: "Muro",
|
||||
nostro: true,
|
||||
richiedeGiocatore: true,
|
||||
className: "bg-info text-info-foreground",
|
||||
},
|
||||
errore: {
|
||||
label: "Errore nostro",
|
||||
short: "Errore",
|
||||
nostro: false,
|
||||
richiedeGiocatore: true,
|
||||
className: "bg-destructive text-destructive-foreground",
|
||||
},
|
||||
punto_avv: {
|
||||
label: "Punto avversario",
|
||||
short: "Punto avv.",
|
||||
nostro: false,
|
||||
richiedeGiocatore: false,
|
||||
className: "bg-muted text-muted-foreground",
|
||||
},
|
||||
errore_avv: {
|
||||
label: "Errore avversario",
|
||||
short: "Err. avv.",
|
||||
nostro: true,
|
||||
richiedeGiocatore: false,
|
||||
className: "bg-secondary text-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
export type Azione = {
|
||||
@@ -37,54 +68,87 @@ export type ScoutMatch = {
|
||||
setNostri: number;
|
||||
setLoro: number;
|
||||
parziali: Array<[number, number]>;
|
||||
mvp: string;
|
||||
azioni: Azione[];
|
||||
};
|
||||
|
||||
const KEY = "crapp-scout-v1";
|
||||
type RigaScoutPartita = {
|
||||
id: string;
|
||||
data: string;
|
||||
avversario: string;
|
||||
casa: boolean;
|
||||
set_nostri: number;
|
||||
set_loro: number;
|
||||
parziali: unknown;
|
||||
azioni: unknown;
|
||||
};
|
||||
|
||||
let cache: ScoutMatch[] | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function read(): ScoutMatch[] {
|
||||
if (cache) return cache;
|
||||
if (typeof window === "undefined") return (cache = []);
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
cache = raw ? (JSON.parse(raw) as ScoutMatch[]) : [];
|
||||
} catch {
|
||||
cache = [];
|
||||
}
|
||||
return cache;
|
||||
function daRiga(r: RigaScoutPartita): ScoutMatch {
|
||||
return {
|
||||
id: r.id,
|
||||
data: r.data,
|
||||
avversario: r.avversario,
|
||||
casa: r.casa,
|
||||
setNostri: r.set_nostri,
|
||||
setLoro: r.set_loro,
|
||||
parziali: (r.parziali as Array<[number, number]> | null) ?? [],
|
||||
azioni: (r.azioni as Azione[] | null) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function write(next: ScoutMatch[]) {
|
||||
cache = next;
|
||||
try {
|
||||
window.localStorage.setItem(KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* storage non disponibile */
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
export const SCOUT_MATCHES_KEY = ["scout-partite"] as const;
|
||||
|
||||
export function salvaScoutMatch(m: ScoutMatch) {
|
||||
write([m, ...read()]);
|
||||
}
|
||||
|
||||
export function eliminaScoutMatch(id: string) {
|
||||
write(read().filter((m) => m.id !== id));
|
||||
async function fetchScoutMatches(): Promise<ScoutMatch[]> {
|
||||
const { data, error } = await supabase
|
||||
.from("scout_partite")
|
||||
.select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni")
|
||||
.order("creato_il", { ascending: false });
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(daRiga);
|
||||
}
|
||||
|
||||
/** Partite scoutate condivise con tutta la squadra: chi scoutizza le vede da qualsiasi
|
||||
* dispositivo, non solo da quello di chi ha chiuso la partita. */
|
||||
export function useScoutMatches(): ScoutMatch[] {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
const { data } = useQuery({
|
||||
queryKey: SCOUT_MATCHES_KEY,
|
||||
staleTime: 60_000,
|
||||
queryFn: fetchScoutMatches,
|
||||
});
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
export function useSalvaScoutMatch() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { eventoId: string | null; match: ScoutMatch }) => {
|
||||
const { error } = await supabase.from("scout_partite").insert({
|
||||
id: input.match.id,
|
||||
evento_id: input.eventoId,
|
||||
data: input.match.data,
|
||||
avversario: input.match.avversario,
|
||||
casa: input.match.casa,
|
||||
set_nostri: input.match.setNostri,
|
||||
set_loro: input.match.setLoro,
|
||||
parziali: JSON.parse(JSON.stringify(input.match.parziali)),
|
||||
azioni: JSON.parse(JSON.stringify(input.match.azioni)),
|
||||
});
|
||||
if (error) throw error;
|
||||
return input.match;
|
||||
},
|
||||
() => read(),
|
||||
() => [],
|
||||
);
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: SCOUT_MATCHES_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEliminaScoutMatch() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { error } = await supabase.from("scout_partite").delete().eq("id", id);
|
||||
if (error) throw error;
|
||||
return id;
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: SCOUT_MATCHES_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Somma delle azioni di un match per giocatore. */
|
||||
@@ -122,58 +186,22 @@ export function totaliSquadra(matches: ScoutMatch[]) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Presenze e MVP ricavati dalle partite scoutate: nessuna statistica individuale offensiva.
|
||||
* `mvpPerMatch` mappa idPartita -> nome dell'MVP eletto dalla squadra. */
|
||||
/**
|
||||
* MVP ricavati dalle partite scoutate (mappa matchId → nome vincitore).
|
||||
* Le presenze personali restano su `risposte_presenze`, non sullo scout.
|
||||
*/
|
||||
export function giocatoriConScout(
|
||||
matches: ScoutMatch[],
|
||||
mvpPerMatch: Record<string, string> = {},
|
||||
): Giocatore[] {
|
||||
return giocatori.map((g) => {
|
||||
let presenze = 0;
|
||||
let mvp = 0;
|
||||
for (const m of matches) {
|
||||
if (totaliPerGiocatore(m.azioni).has(g.id)) presenze += 1;
|
||||
if (mvpPerMatch[m.id] === g.nome) mvp += 1;
|
||||
}
|
||||
return {
|
||||
...g,
|
||||
mvp: g.mvp + mvp,
|
||||
presenze: g.presenze + presenze,
|
||||
totaliEventi: g.totaliEventi + matches.length,
|
||||
mvp,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Classifica demo aggiornata con i match scoutati (solo la nostra riga). */
|
||||
export function classificaConScout(matches: ScoutMatch[]): RigaClassifica[] {
|
||||
if (matches.length === 0) return classifica;
|
||||
const agg = matches.reduce(
|
||||
(s, m) => {
|
||||
const vinta = m.setNostri > m.setLoro;
|
||||
s.giocate += 1;
|
||||
s.vinte += vinta ? 1 : 0;
|
||||
s.perse += vinta ? 0 : 1;
|
||||
s.setFatti += m.setNostri;
|
||||
s.setSubiti += m.setLoro;
|
||||
s.punti += vinta ? (m.setLoro <= 1 ? 3 : 2) : m.setLoro === 3 && m.setNostri === 2 ? 1 : 0;
|
||||
return s;
|
||||
},
|
||||
{ giocate: 0, vinte: 0, perse: 0, setFatti: 0, setSubiti: 0, punti: 0 },
|
||||
);
|
||||
return classifica
|
||||
.map((r) =>
|
||||
r.squadra === "CRAP Volley"
|
||||
? {
|
||||
...r,
|
||||
giocate: r.giocate + agg.giocate,
|
||||
vinte: r.vinte + agg.vinte,
|
||||
perse: r.perse + agg.perse,
|
||||
setFatti: r.setFatti + agg.setFatti,
|
||||
setSubiti: r.setSubiti + agg.setSubiti,
|
||||
punti: r.punti + agg.punti,
|
||||
}
|
||||
: r,
|
||||
)
|
||||
.sort((a, b) => b.punti - a.punti || b.setFatti - b.setSubiti - (a.setFatti - a.setSubiti))
|
||||
.map((r, i) => ({ ...r, pos: i + 1 }));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user